From 71d070ca6ac9a66eea3316003d3c79b5df6b8a45 Mon Sep 17 00:00:00 2001 From: dbera Date: Fri, 7 Aug 2026 12:11:33 +0200 Subject: [PATCH 01/23] Add data-aware transition conditions --- .../src/graph/transitionConditions.test.ts | 287 ++++++++++++++++ frontend/src/graph/transitionConditions.ts | 322 ++++++++++++++++++ 2 files changed, 609 insertions(+) create mode 100644 frontend/src/graph/transitionConditions.test.ts create mode 100644 frontend/src/graph/transitionConditions.ts diff --git a/frontend/src/graph/transitionConditions.test.ts b/frontend/src/graph/transitionConditions.test.ts new file mode 100644 index 0000000..27747a9 --- /dev/null +++ b/frontend/src/graph/transitionConditions.test.ts @@ -0,0 +1,287 @@ +import { describe, expect, it } from "vitest"; + +import { + describeDataPath, + evaluateTransitionCondition, + matchesPartialObject, + resolveDataPath, + validateTransitionCondition, + type TransitionCondition, +} from "./transitionConditions"; + +const transition = { + inputs: { + request: { + id: 42, + priority: 7, + urgent: false, + optional: null, + type: "imaging", + }, + }, + outputs: { + completed: [ + { id: 12, status: "queued" }, + { id: 42, status: "ok", details: { duration: 15 } }, + ], + }, +}; + +function sourceCondition( + source: "inputs" | "outputs", + condition: Extract["condition"], +): TransitionCondition { + return { type: "source", source, condition }; +} + +describe("resolveDataPath", () => { + it("resolves nested object properties and array indexes", () => { + expect(resolveDataPath(transition, ["outputs", "completed", 1, "id"])).toEqual({ + found: true, + value: 42, + }); + }); + + it("distinguishes a missing field from an existing null value", () => { + expect(resolveDataPath(transition.inputs, ["request", "optional"])).toEqual({ + found: true, + value: null, + }); + expect(resolveDataPath(transition.inputs, ["request", "missing"])).toEqual({ + found: false, + value: undefined, + }); + }); + + it("formats paths for explanations", () => { + expect(describeDataPath("outputs", ["completed", 1, "id"])).toBe( + "outputs.completed[1].id", + ); + }); +}); + +describe("matchesPartialObject", () => { + it("matches a recursive object subset while allowing additional fields", () => { + expect( + matchesPartialObject(transition.inputs.request, { + id: 42, + }), + ).toBe(true); + + expect( + matchesPartialObject(transition.outputs.completed[1], { + details: { duration: 15 }, + }), + ).toBe(true); + }); + + it("uses exact semantics for arrays inside partial objects", () => { + expect( + matchesPartialObject( + { values: [1, 2], extra: true }, + { values: [1, 2] }, + ), + ).toBe(true); + expect( + matchesPartialObject( + { values: [1, 2, 3] }, + { values: [1, 2] }, + ), + ).toBe(false); + }); +}); + +describe("evaluateTransitionCondition", () => { + it("supports strict equality and inequality", () => { + expect( + evaluateTransitionCondition( + sourceCondition("inputs", { + type: "comparison", + path: ["request", "id"], + operator: "=", + value: 42, + }), + transition, + ).matches, + ).toBe(true); + + expect( + evaluateTransitionCondition( + sourceCondition("inputs", { + type: "comparison", + path: ["request", "id"], + operator: "=", + value: "42", + }), + transition, + ).matches, + ).toBe(false); + + expect( + evaluateTransitionCondition( + sourceCondition("inputs", { + type: "comparison", + path: ["request", "id"], + operator: "!=", + value: 57, + }), + transition, + ).matches, + ).toBe(true); + }); + + it("supports numeric ordering without coercion", () => { + for (const [operator, value, expected] of [ + ["<", 8, true], + ["<=", 7, true], + [">", 6, true], + [">=", 7, true], + [">", "6", false], + ] as const) { + expect( + evaluateTransitionCondition( + sourceCondition("inputs", { + type: "comparison", + path: ["request", "priority"], + operator, + value, + }), + transition, + ).matches, + ).toBe(expected); + } + }); + + it("supports exists and does-not-exist without confusing null and missing", () => { + expect( + evaluateTransitionCondition( + sourceCondition("inputs", { + type: "comparison", + path: ["request", "optional"], + operator: "exists", + }), + transition, + ).matches, + ).toBe(true); + + expect( + evaluateTransitionCondition( + sourceCondition("inputs", { + type: "comparison", + path: ["request", "missing"], + operator: "does-not-exist", + }), + transition, + ).matches, + ).toBe(true); + }); + + it("supports partial object matching", () => { + expect( + evaluateTransitionCondition( + sourceCondition("inputs", { + type: "partial-object", + path: ["request"], + value: { priority: 7, type: "imaging" }, + }), + transition, + ).matches, + ).toBe(true); + }); + + it("supports contains-item with nested conditions relative to each item", () => { + const condition = sourceCondition("outputs", { + type: "contains-item", + path: ["completed"], + condition: { + type: "group", + operator: "and", + conditions: [ + { + type: "comparison", + path: ["id"], + operator: "=", + value: 42, + }, + { + type: "comparison", + path: ["status"], + operator: "=", + value: "ok", + }, + { + type: "comparison", + path: ["details", "duration"], + operator: "<=", + value: 20, + }, + ], + }, + }); + + expect(evaluateTransitionCondition(condition, transition)).toEqual({ + matches: true, + errors: [], + }); + }); + + it("supports nested AND and OR across inputs and outputs", () => { + const condition: TransitionCondition = { + type: "group", + operator: "and", + conditions: [ + sourceCondition("inputs", { + type: "group", + operator: "or", + conditions: [ + { + type: "comparison", + path: ["request", "priority"], + operator: ">=", + value: 10, + }, + { + type: "comparison", + path: ["request", "type"], + operator: "=", + value: "imaging", + }, + ], + }), + sourceCondition("outputs", { + type: "contains-item", + path: ["completed"], + condition: { + type: "comparison", + path: ["status"], + operator: "=", + value: "ok", + }, + }), + ], + }; + + expect(evaluateTransitionCondition(condition, transition).matches).toBe(true); + }); + + it("returns validation errors instead of evaluating malformed conditions", () => { + const missingValue = sourceCondition("inputs", { + type: "comparison", + path: ["request", "id"], + operator: "=", + }); + const emptyGroup: TransitionCondition = { + type: "group", + operator: "and", + conditions: [], + }; + + expect(validateTransitionCondition(missingValue)).toEqual([ + "inputs requires a comparison value.", + ]); + expect(evaluateTransitionCondition(missingValue, transition).matches).toBe(false); + expect(validateTransitionCondition(emptyGroup)).toEqual([ + "The transition condition group must contain at least one condition.", + ]); + }); +}); diff --git a/frontend/src/graph/transitionConditions.ts b/frontend/src/graph/transitionConditions.ts new file mode 100644 index 0000000..82c1dac --- /dev/null +++ b/frontend/src/graph/transitionConditions.ts @@ -0,0 +1,322 @@ +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = + | JsonPrimitive + | JsonValue[] + | { [key: string]: JsonValue }; + +export type DataPathSegment = string | number; +export type DataSource = "inputs" | "outputs"; +export type ComparisonOperator = + | "=" + | "!=" + | "<" + | "<=" + | ">" + | ">=" + | "exists" + | "does-not-exist"; + +export type ValueCondition = + | { + type: "comparison"; + path: DataPathSegment[]; + operator: ComparisonOperator; + value?: JsonValue; + } + | { + type: "partial-object"; + path: DataPathSegment[]; + value: { [key: string]: JsonValue }; + } + | { + type: "contains-item"; + path: DataPathSegment[]; + condition: ValueCondition; + } + | { + type: "group"; + operator: "and" | "or"; + conditions: ValueCondition[]; + }; + +export type TransitionCondition = + | { + type: "source"; + source: DataSource; + condition: ValueCondition; + } + | { + type: "group"; + operator: "and" | "or"; + conditions: TransitionCondition[]; + }; + +export type TransitionData = { + inputs?: unknown; + outputs?: unknown; +}; + +export type ConditionEvaluation = { + matches: boolean; + errors: string[]; +}; + +type ResolvedValue = { + found: boolean; + value: unknown; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function resolveDataPath( + root: unknown, + path: readonly DataPathSegment[], +): ResolvedValue { + let current = root; + + for (const segment of path) { + if (typeof segment === "number") { + if (!Array.isArray(current) || segment < 0 || segment >= current.length) { + return { found: false, value: undefined }; + } + current = current[segment]; + continue; + } + + if (!isRecord(current) || !Object.prototype.hasOwnProperty.call(current, segment)) { + return { found: false, value: undefined }; + } + current = current[segment]; + } + + return { found: true, value: current }; +} + +function deepEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) { + return true; + } + + if (Array.isArray(left) && Array.isArray(right)) { + return ( + left.length === right.length && + left.every((value, index) => deepEqual(value, right[index])) + ); + } + + if (isRecord(left) && isRecord(right)) { + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + return ( + leftKeys.length === rightKeys.length && + leftKeys.every( + (key) => + Object.prototype.hasOwnProperty.call(right, key) && + deepEqual(left[key], right[key]), + ) + ); + } + + return false; +} + +export function matchesPartialObject( + actual: unknown, + expected: Record, +): boolean { + if (!isRecord(actual)) { + return false; + } + + return Object.entries(expected).every(([key, expectedValue]) => { + if (!Object.prototype.hasOwnProperty.call(actual, key)) { + return false; + } + + const actualValue = actual[key]; + if (isRecord(expectedValue)) { + return matchesPartialObject(actualValue, expectedValue as Record); + } + + return deepEqual(actualValue, expectedValue); + }); +} + +function formatPath(path: readonly DataPathSegment[]): string { + if (path.length === 0) { + return ""; + } + + return path + .map((segment) => + typeof segment === "number" ? `[${segment}]` : segment, + ) + .join(".") + .replace(/\.\[/g, "["); +} + +function validateValueCondition(condition: ValueCondition, location: string): string[] { + if (condition.type === "group") { + if (condition.conditions.length === 0) { + return [`${location} must contain at least one condition.`]; + } + return condition.conditions.flatMap((child, index) => + validateValueCondition(child, `${location}.conditions[${index}]`), + ); + } + + if (condition.type === "contains-item") { + return validateValueCondition(condition.condition, `${location}.condition`); + } + + if (condition.type === "comparison") { + const requiresValue = !["exists", "does-not-exist"].includes( + condition.operator, + ); + if (requiresValue && condition.value === undefined) { + return [`${location} requires a comparison value.`]; + } + if (!requiresValue && condition.value !== undefined) { + return [`${location} must not define a value for ${condition.operator}.`]; + } + } + + return []; +} + +export function validateTransitionCondition( + condition: TransitionCondition, +): string[] { + if (condition.type === "group") { + if (condition.conditions.length === 0) { + return ["The transition condition group must contain at least one condition."]; + } + return condition.conditions.flatMap((child, index) => + validateTransitionConditionAt(child, `conditions[${index}]`), + ); + } + + return validateValueCondition(condition.condition, condition.source); +} + +function validateTransitionConditionAt( + condition: TransitionCondition, + location: string, +): string[] { + if (condition.type === "group") { + if (condition.conditions.length === 0) { + return [`${location} must contain at least one condition.`]; + } + return condition.conditions.flatMap((child, index) => + validateTransitionConditionAt(child, `${location}.conditions[${index}]`), + ); + } + + return validateValueCondition( + condition.condition, + `${location}.${condition.source}`, + ); +} + +function evaluateComparison( + condition: Extract, + root: unknown, +): boolean { + const resolved = resolveDataPath(root, condition.path); + + if (condition.operator === "exists") { + return resolved.found; + } + if (condition.operator === "does-not-exist") { + return !resolved.found; + } + if (!resolved.found) { + return false; + } + + if (condition.operator === "=") { + return deepEqual(resolved.value, condition.value); + } + if (condition.operator === "!=") { + return !deepEqual(resolved.value, condition.value); + } + + if (typeof resolved.value !== "number" || typeof condition.value !== "number") { + return false; + } + + switch (condition.operator) { + case "<": + return resolved.value < condition.value; + case "<=": + return resolved.value <= condition.value; + case ">": + return resolved.value > condition.value; + case ">=": + return resolved.value >= condition.value; + } +} + +function evaluateValueCondition(condition: ValueCondition, root: unknown): boolean { + switch (condition.type) { + case "comparison": + return evaluateComparison(condition, root); + case "partial-object": { + const resolved = resolveDataPath(root, condition.path); + return resolved.found && matchesPartialObject(resolved.value, condition.value); + } + case "contains-item": { + const resolved = resolveDataPath(root, condition.path); + return ( + resolved.found && + Array.isArray(resolved.value) && + resolved.value.some((item) => evaluateValueCondition(condition.condition, item)) + ); + } + case "group": + return condition.operator === "and" + ? condition.conditions.every((child) => evaluateValueCondition(child, root)) + : condition.conditions.some((child) => evaluateValueCondition(child, root)); + } +} + +function evaluateValidatedTransitionCondition( + condition: TransitionCondition, + transition: TransitionData, +): boolean { + if (condition.type === "source") { + return evaluateValueCondition(condition.condition, transition[condition.source]); + } + + return condition.operator === "and" + ? condition.conditions.every((child) => + evaluateValidatedTransitionCondition(child, transition), + ) + : condition.conditions.some((child) => + evaluateValidatedTransitionCondition(child, transition), + ); +} + +export function evaluateTransitionCondition( + condition: TransitionCondition, + transition: TransitionData, +): ConditionEvaluation { + const errors = validateTransitionCondition(condition); + if (errors.length > 0) { + return { matches: false, errors }; + } + + return { + matches: evaluateValidatedTransitionCondition(condition, transition), + errors: [], + }; +} + +export function describeDataPath( + source: DataSource, + path: readonly DataPathSegment[], +): string { + return `${source}.${formatPath(path)}`; +} From 9512767036911d7e3c11132a90c83a3f1d7ce864 Mon Sep 17 00:00:00 2001 From: dbera Date: Fri, 7 Aug 2026 12:18:54 +0200 Subject: [PATCH 02/23] Add transition data correlation --- .../src/graph/transitionCorrelation.test.ts | 258 ++++++++++++ frontend/src/graph/transitionCorrelation.ts | 383 ++++++++++++++++++ 2 files changed, 641 insertions(+) create mode 100644 frontend/src/graph/transitionCorrelation.test.ts create mode 100644 frontend/src/graph/transitionCorrelation.ts diff --git a/frontend/src/graph/transitionCorrelation.test.ts b/frontend/src/graph/transitionCorrelation.test.ts new file mode 100644 index 0000000..ca7b51a --- /dev/null +++ b/frontend/src/graph/transitionCorrelation.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, it } from "vitest"; + +import { + captureActivationValues, + evaluateCorrelationCondition, + validateCaptureDefinitions, + validateCorrelationCondition, + type CorrelationCondition, +} from "./transitionCorrelation"; + +const activation = { + inputs: { + request: { + id: 42, + priority: 7, + amount: 100, + }, + }, +}; + +const target = { + outputs: { + completed: [ + { id: 12, status: "queued", amount: 125 }, + { id: 42, status: "ok", amount: 80 }, + ], + }, +}; + +describe("captureActivationValues", () => { + it("captures referenced activation values under readable aliases", () => { + expect( + captureActivationValues( + [ + { + alias: "request_id", + source: "inputs", + path: ["request", "id"], + }, + { + alias: "request_amount", + source: "inputs", + path: ["request", "amount"], + }, + ], + activation, + ), + ).toEqual({ + bindings: { + request_id: 42, + request_amount: 100, + }, + errors: [], + }); + }); + + it("rejects invalid and duplicate aliases", () => { + expect( + validateCaptureDefinitions([ + { alias: "1bad", source: "inputs", path: [] }, + { alias: "valid", source: "inputs", path: [] }, + { alias: "valid", source: "outputs", path: [] }, + ]), + ).toEqual([ + "captures[0].alias must start with a letter or underscore and contain only letters, numbers, and underscores.", + "Duplicate capture alias: $valid.", + ]); + }); + + it("fails clearly when a capture path is missing", () => { + expect( + captureActivationValues( + [ + { + alias: "missing_id", + source: "inputs", + path: ["request", "missing"], + }, + ], + activation, + ), + ).toEqual({ + bindings: {}, + errors: ["Capture $missing_id could not resolve its data path."], + }); + }); +}); + +describe("evaluateCorrelationCondition", () => { + const bindings = { + request_id: 42, + request_amount: 100, + }; + + it("compares a target field with a captured activation value", () => { + const condition: CorrelationCondition = { + type: "comparison", + left: { + kind: "target", + source: "outputs", + path: ["completed", 1, "id"], + }, + operator: "=", + right: { kind: "activation", alias: "request_id" }, + }; + + expect(evaluateCorrelationCondition(condition, target, bindings)).toEqual({ + matches: true, + errors: [], + }); + }); + + it("supports correlated array item matching", () => { + const condition: CorrelationCondition = { + type: "contains-item", + source: "outputs", + path: ["completed"], + condition: { + type: "group", + operator: "and", + conditions: [ + { + type: "comparison", + left: { kind: "item", path: ["id"] }, + operator: "=", + right: { kind: "activation", alias: "request_id" }, + }, + { + type: "comparison", + left: { kind: "item", path: ["status"] }, + operator: "=", + right: { kind: "literal", value: "ok" }, + }, + { + type: "comparison", + left: { kind: "item", path: ["amount"] }, + operator: "<", + right: { + kind: "activation", + alias: "request_amount", + }, + }, + ], + }, + }; + + expect(evaluateCorrelationCondition(condition, target, bindings).matches).toBe( + true, + ); + }); + + it("evaluates each pending activation independently", () => { + const condition: CorrelationCondition = { + type: "contains-item", + source: "outputs", + path: ["completed"], + condition: { + type: "comparison", + left: { kind: "item", path: ["id"] }, + operator: "=", + right: { kind: "activation", alias: "request_id" }, + }, + }; + + expect( + evaluateCorrelationCondition(condition, target, { request_id: 42 }).matches, + ).toBe(true); + expect( + evaluateCorrelationCondition(condition, target, { request_id: 57 }).matches, + ).toBe(false); + }); + + it("supports nested AND and OR correlation expressions", () => { + const condition: CorrelationCondition = { + type: "group", + operator: "or", + conditions: [ + { + type: "comparison", + left: { kind: "target", source: "outputs", path: ["missing"] }, + operator: "=", + right: { kind: "literal", value: true }, + }, + { + type: "contains-item", + source: "outputs", + path: ["completed"], + condition: { + type: "comparison", + left: { kind: "item", path: ["id"] }, + operator: "=", + right: { kind: "activation", alias: "request_id" }, + }, + }, + ], + }; + + expect(evaluateCorrelationCondition(condition, target, bindings).matches).toBe( + true, + ); + }); + + it("distinguishes missing target values from null values", () => { + const targetWithNull = { outputs: { result: null } }; + const exists: CorrelationCondition = { + type: "reference-exists", + reference: { kind: "target", source: "outputs", path: ["result"] }, + exists: true, + }; + const missing: CorrelationCondition = { + type: "reference-exists", + reference: { kind: "target", source: "outputs", path: ["missing"] }, + exists: false, + }; + + expect( + evaluateCorrelationCondition(exists, targetWithNull, bindings).matches, + ).toBe(true); + expect( + evaluateCorrelationCondition(missing, targetWithNull, bindings).matches, + ).toBe(true); + }); + + it("rejects unknown aliases and item references outside arrays", () => { + const unknownAlias: CorrelationCondition = { + type: "comparison", + left: { kind: "target", source: "outputs", path: [] }, + operator: "=", + right: { kind: "activation", alias: "unknown" }, + }; + const misplacedItem: CorrelationCondition = { + type: "comparison", + left: { kind: "item", path: ["id"] }, + operator: "=", + right: { kind: "literal", value: 42 }, + }; + + expect(validateCorrelationCondition(unknownAlias, ["request_id"])).toEqual([ + "correlation.right references unknown activation variable $unknown.", + ]); + expect(validateCorrelationCondition(misplacedItem, [])).toEqual([ + "correlation.left uses an item reference outside contains-item.", + ]); + }); + + it("uses strict numeric comparisons without coercion", () => { + const condition: CorrelationCondition = { + type: "comparison", + left: { kind: "activation", alias: "request_amount" }, + operator: ">", + right: { kind: "literal", value: "80" }, + }; + + expect(evaluateCorrelationCondition(condition, target, bindings).matches).toBe( + false, + ); + }); +}); diff --git a/frontend/src/graph/transitionCorrelation.ts b/frontend/src/graph/transitionCorrelation.ts new file mode 100644 index 0000000..a69f6b6 --- /dev/null +++ b/frontend/src/graph/transitionCorrelation.ts @@ -0,0 +1,383 @@ +import { + resolveDataPath, + type ComparisonOperator, + type DataPathSegment, + type DataSource, + type JsonValue, + type TransitionData, +} from "./transitionConditions"; + +export type CaptureDefinition = { + alias: string; + source: DataSource; + path: DataPathSegment[]; +}; + +export type ActivationBindings = Record; + +export type CaptureResult = { + bindings: ActivationBindings; + errors: string[]; +}; + +export type CorrelationValueReference = + | { + kind: "literal"; + value: JsonValue; + } + | { + kind: "activation"; + alias: string; + } + | { + kind: "target"; + source: DataSource; + path: DataPathSegment[]; + } + | { + kind: "item"; + path: DataPathSegment[]; + }; + +export type CorrelationCondition = + | { + type: "comparison"; + left: CorrelationValueReference; + operator: Exclude; + right: CorrelationValueReference; + } + | { + type: "reference-exists"; + reference: CorrelationValueReference; + exists: boolean; + } + | { + type: "contains-item"; + source: DataSource; + path: DataPathSegment[]; + condition: CorrelationCondition; + } + | { + type: "group"; + operator: "and" | "or"; + conditions: CorrelationCondition[]; + }; + +export type CorrelationEvaluation = { + matches: boolean; + errors: string[]; +}; + +type ResolvedReference = { + found: boolean; + value: unknown; +}; + +const ALIAS_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; + +function isJsonValue(value: unknown): value is JsonValue { + if ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return true; + } + + if (Array.isArray(value)) { + return value.every(isJsonValue); + } + + if (typeof value === "object") { + return Object.values(value as Record).every(isJsonValue); + } + + return false; +} + +function deepEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) { + return true; + } + + if (Array.isArray(left) && Array.isArray(right)) { + return ( + left.length === right.length && + left.every((value, index) => deepEqual(value, right[index])) + ); + } + + if ( + typeof left === "object" && + left !== null && + !Array.isArray(left) && + typeof right === "object" && + right !== null && + !Array.isArray(right) + ) { + const leftRecord = left as Record; + const rightRecord = right as Record; + const leftKeys = Object.keys(leftRecord); + const rightKeys = Object.keys(rightRecord); + return ( + leftKeys.length === rightKeys.length && + leftKeys.every( + (key) => + Object.prototype.hasOwnProperty.call(rightRecord, key) && + deepEqual(leftRecord[key], rightRecord[key]), + ) + ); + } + + return false; +} + +export function validateCaptureDefinitions( + definitions: readonly CaptureDefinition[], +): string[] { + const errors: string[] = []; + const aliases = new Set(); + + definitions.forEach((definition, index) => { + const location = `captures[${index}]`; + if (!ALIAS_PATTERN.test(definition.alias)) { + errors.push( + `${location}.alias must start with a letter or underscore and contain only letters, numbers, and underscores.`, + ); + } + if (aliases.has(definition.alias)) { + errors.push(`Duplicate capture alias: $${definition.alias}.`); + } + aliases.add(definition.alias); + }); + + return errors; +} + +export function captureActivationValues( + definitions: readonly CaptureDefinition[], + activation: TransitionData, +): CaptureResult { + const errors = validateCaptureDefinitions(definitions); + if (errors.length > 0) { + return { bindings: {}, errors }; + } + + const bindings: ActivationBindings = {}; + definitions.forEach((definition) => { + const resolved = resolveDataPath( + activation[definition.source], + definition.path, + ); + if (!resolved.found) { + errors.push(`Capture $${definition.alias} could not resolve its data path.`); + return; + } + if (!isJsonValue(resolved.value)) { + errors.push(`Capture $${definition.alias} did not resolve to JSON data.`); + return; + } + bindings[definition.alias] = structuredClone(resolved.value); + }); + + return { bindings: errors.length === 0 ? bindings : {}, errors }; +} + +function validateReference( + reference: CorrelationValueReference, + availableAliases: ReadonlySet, + insideItem: boolean, + location: string, +): string[] { + if (reference.kind === "activation" && !availableAliases.has(reference.alias)) { + return [`${location} references unknown activation variable $${reference.alias}.`]; + } + if (reference.kind === "item" && !insideItem) { + return [`${location} uses an item reference outside contains-item.`]; + } + return []; +} + +function validateCorrelationConditionAt( + condition: CorrelationCondition, + availableAliases: ReadonlySet, + insideItem: boolean, + location: string, +): string[] { + switch (condition.type) { + case "comparison": + return [ + ...validateReference( + condition.left, + availableAliases, + insideItem, + `${location}.left`, + ), + ...validateReference( + condition.right, + availableAliases, + insideItem, + `${location}.right`, + ), + ]; + case "reference-exists": + return validateReference( + condition.reference, + availableAliases, + insideItem, + `${location}.reference`, + ); + case "contains-item": + return validateCorrelationConditionAt( + condition.condition, + availableAliases, + true, + `${location}.condition`, + ); + case "group": + if (condition.conditions.length === 0) { + return [`${location} must contain at least one condition.`]; + } + return condition.conditions.flatMap((child, index) => + validateCorrelationConditionAt( + child, + availableAliases, + insideItem, + `${location}.conditions[${index}]`, + ), + ); + } +} + +export function validateCorrelationCondition( + condition: CorrelationCondition, + availableAliases: Iterable, +): string[] { + return validateCorrelationConditionAt( + condition, + new Set(availableAliases), + false, + "correlation", + ); +} + +function resolveReference( + reference: CorrelationValueReference, + target: TransitionData, + bindings: ActivationBindings, + item: unknown, +): ResolvedReference { + switch (reference.kind) { + case "literal": + return { found: true, value: reference.value }; + case "activation": + return Object.prototype.hasOwnProperty.call(bindings, reference.alias) + ? { found: true, value: bindings[reference.alias] } + : { found: false, value: undefined }; + case "target": + return resolveDataPath(target[reference.source], reference.path); + case "item": + return resolveDataPath(item, reference.path); + } +} + +function evaluateComparison( + operator: Extract["operator"], + left: ResolvedReference, + right: ResolvedReference, +): boolean { + if (!left.found || !right.found) { + return false; + } + + if (operator === "=") { + return deepEqual(left.value, right.value); + } + if (operator === "!=") { + return !deepEqual(left.value, right.value); + } + if (typeof left.value !== "number" || typeof right.value !== "number") { + return false; + } + + switch (operator) { + case "<": + return left.value < right.value; + case "<=": + return left.value <= right.value; + case ">": + return left.value > right.value; + case ">=": + return left.value >= right.value; + } +} + +function evaluateCorrelationConditionAt( + condition: CorrelationCondition, + target: TransitionData, + bindings: ActivationBindings, + item: unknown, +): boolean { + switch (condition.type) { + case "comparison": + return evaluateComparison( + condition.operator, + resolveReference(condition.left, target, bindings, item), + resolveReference(condition.right, target, bindings, item), + ); + case "reference-exists": { + const resolved = resolveReference( + condition.reference, + target, + bindings, + item, + ); + return condition.exists ? resolved.found : !resolved.found; + } + case "contains-item": { + const resolved = resolveDataPath(target[condition.source], condition.path); + return ( + resolved.found && + Array.isArray(resolved.value) && + resolved.value.some((candidate) => + evaluateCorrelationConditionAt( + condition.condition, + target, + bindings, + candidate, + ), + ) + ); + } + case "group": + return condition.operator === "and" + ? condition.conditions.every((child) => + evaluateCorrelationConditionAt(child, target, bindings, item), + ) + : condition.conditions.some((child) => + evaluateCorrelationConditionAt(child, target, bindings, item), + ); + } +} + +export function evaluateCorrelationCondition( + condition: CorrelationCondition, + target: TransitionData, + bindings: ActivationBindings, +): CorrelationEvaluation { + const errors = validateCorrelationCondition(condition, Object.keys(bindings)); + if (errors.length > 0) { + return { matches: false, errors }; + } + + return { + matches: evaluateCorrelationConditionAt( + condition, + target, + bindings, + undefined, + ), + errors: [], + }; +} From fc14e1ce15dc75ae41d7820bfa9a68c4cfecce36 Mon Sep 17 00:00:00 2001 From: dbera Date: Fri, 7 Aug 2026 12:25:01 +0200 Subject: [PATCH 03/23] Add Declare constraint model --- frontend/src/graph/declareConstraints.test.ts | 171 +++++++++++++++ frontend/src/graph/declareConstraints.ts | 197 ++++++++++++++++++ 2 files changed, 368 insertions(+) create mode 100644 frontend/src/graph/declareConstraints.test.ts create mode 100644 frontend/src/graph/declareConstraints.ts diff --git a/frontend/src/graph/declareConstraints.test.ts b/frontend/src/graph/declareConstraints.test.ts new file mode 100644 index 0000000..e914107 --- /dev/null +++ b/frontend/src/graph/declareConstraints.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from "vitest"; + +import { + DECLARE_TEMPLATE_DEFINITIONS, + getDeclareTemplateDefinition, + validateDeclareConstraint, + type DeclareConstraint, + type DeclarePredicateGroup, +} from "./declareConstraints"; + +const predicate = (name: string): DeclarePredicateGroup => ({ + relation: "or", + predicates: [ + { + transition: { operator: "equals", value: name }, + }, + ], +}); + +describe("Declare template registry", () => { + it("contains the complete planned template catalog with unique IDs", () => { + expect(DECLARE_TEMPLATE_DEFINITIONS).toHaveLength(30); + const ids = DECLARE_TEMPLATE_DEFINITIONS.map((definition) => definition.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("records roles and capabilities for representative templates", () => { + expect(getDeclareTemplateDefinition("response")).toMatchObject({ + category: "future", + requiredRoles: ["activation", "target"], + supportsCount: false, + supportsCorrelation: true, + }); + expect(getDeclareTemplateDefinition("at-least")).toMatchObject({ + category: "cardinality", + requiredRoles: ["activation"], + supportsCount: true, + supportsCorrelation: false, + }); + expect(getDeclareTemplateDefinition("alternate-succession")).toMatchObject({ + category: "bidirectional", + requiredRoles: ["activation", "target", "between"], + }); + }); +}); + +describe("validateDeclareConstraint", () => { + it("accepts a data-aware response constraint", () => { + const constraint: DeclareConstraint = { + id: "same-request-completes", + template: "response", + enabled: true, + activation: { + relation: "or", + predicates: [ + { + transition: { operator: "equals", value: "SubmitRequest" }, + captures: [ + { + alias: "request_id", + source: "inputs", + path: ["request", "id"], + }, + ], + }, + ], + }, + target: predicate("CompleteRequest"), + correlation: { + type: "comparison", + left: { + kind: "target", + source: "outputs", + path: ["request", "id"], + }, + operator: "=", + right: { kind: "activation", alias: "request_id" }, + }, + }; + + expect(validateDeclareConstraint(constraint)).toEqual([]); + }); + + it("requires the roles defined by the template", () => { + const constraint: DeclareConstraint = { + id: "incomplete-response", + template: "response", + enabled: true, + activation: predicate("A"), + }; + + expect(validateDeclareConstraint(constraint)).toContain("target is required."); + }); + + it("requires and validates counts for cardinality templates", () => { + const missingCount: DeclareConstraint = { + id: "at-least-a", + template: "at-least", + enabled: true, + activation: predicate("A"), + }; + const invalidCount: DeclareConstraint = { + ...missingCount, + count: -1, + }; + const validCount: DeclareConstraint = { + ...missingCount, + count: 2, + }; + + expect(validateDeclareConstraint(missingCount)).toContain( + "Count must be a non-negative integer.", + ); + expect(validateDeclareConstraint(invalidCount)).toContain( + "Count must be a non-negative integer.", + ); + expect(validateDeclareConstraint(validCount)).toEqual([]); + }); + + it("rejects roles and options not used by a template", () => { + const constraint: DeclareConstraint = { + id: "bad-init", + template: "init", + enabled: true, + activation: predicate("A"), + target: predicate("B"), + count: 1, + correlation: { + type: "comparison", + left: { kind: "literal", value: 1 }, + operator: "=", + right: { kind: "literal", value: 1 }, + }, + }; + + expect(validateDeclareConstraint(constraint)).toEqual([ + "Init does not support a count.", + "Init does not support correlation conditions.", + "Init does not use target.", + ]); + }); + + it("rejects empty predicates and captures outside activation", () => { + const constraint: DeclareConstraint = { + id: "bad-response", + template: "response", + enabled: true, + activation: { + relation: "and", + predicates: [{}], + }, + target: { + relation: "or", + predicates: [ + { + transition: { operator: "equals", value: "" }, + captures: [ + { alias: "bad", source: "outputs", path: ["id"] }, + ], + }, + ], + }, + }; + + expect(validateDeclareConstraint(constraint)).toEqual([ + "activation.predicates[0] must define a transition name or data condition.", + "target.predicates[0].transition must not be empty.", + "target.predicates[0] must not define activation captures.", + ]); + }); +}); diff --git a/frontend/src/graph/declareConstraints.ts b/frontend/src/graph/declareConstraints.ts new file mode 100644 index 0000000..cf4ca8e --- /dev/null +++ b/frontend/src/graph/declareConstraints.ts @@ -0,0 +1,197 @@ +import type { TransitionCondition } from "./transitionConditions"; +import type { + CaptureDefinition, + CorrelationCondition, +} from "./transitionCorrelation"; + +export type DeclareTemplateId = + | "at-least" + | "at-most" + | "exactly" + | "exactly-consecutive" + | "init" + | "end" + | "choice" + | "exclusive-choice" + | "responded-existence" + | "not-responded-existence" + | "coexistence" + | "not-coexistence" + | "response" + | "not-response" + | "chain-response" + | "not-chain-response" + | "alternate-response" + | "not-alternate-response" + | "precedence" + | "not-precedence" + | "chain-precedence" + | "not-chain-precedence" + | "alternate-precedence" + | "not-alternate-precedence" + | "succession" + | "not-succession" + | "chain-succession" + | "not-chain-succession" + | "alternate-succession" + | "not-alternate-succession"; + +export type DeclareTemplateCategory = + | "cardinality" + | "position" + | "choice" + | "existence" + | "future" + | "past" + | "bidirectional"; + +export type DeclarePredicateRole = "activation" | "target" | "between"; +export type ActivityRelation = "and" | "or"; + +export type TransitionNameMatcher = { + operator: "equals"; + value: string; +}; + +export type DeclarePredicate = { + transition?: TransitionNameMatcher; + condition?: TransitionCondition; + captures?: CaptureDefinition[]; +}; + +export type DeclarePredicateGroup = { + relation: ActivityRelation; + predicates: DeclarePredicate[]; +}; + +export type DeclareConstraint = { + id: string; + template: DeclareTemplateId; + enabled: boolean; + activation?: DeclarePredicateGroup; + target?: DeclarePredicateGroup; + between?: DeclarePredicateGroup; + correlation?: CorrelationCondition; + count?: number; +}; + +export type DeclareTemplateDefinition = { + id: DeclareTemplateId; + displayName: string; + category: DeclareTemplateCategory; + requiredRoles: DeclarePredicateRole[]; + supportsCount: boolean; + supportsCorrelation: boolean; + description: string; +}; + +const DEFINITIONS: readonly DeclareTemplateDefinition[] = [ + { id: "at-least", displayName: "At least N", category: "cardinality", requiredRoles: ["activation"], supportsCount: true, supportsCorrelation: false, description: "The activation occurs at least N times." }, + { id: "at-most", displayName: "At most N", category: "cardinality", requiredRoles: ["activation"], supportsCount: true, supportsCorrelation: false, description: "The activation occurs at most N times." }, + { id: "exactly", displayName: "Exactly N", category: "cardinality", requiredRoles: ["activation"], supportsCount: true, supportsCorrelation: false, description: "The activation occurs exactly N times." }, + { id: "exactly-consecutive", displayName: "Exactly N consecutively", category: "cardinality", requiredRoles: ["activation"], supportsCount: true, supportsCorrelation: false, description: "The activation occurs exactly N times consecutively." }, + { id: "init", displayName: "Init", category: "position", requiredRoles: ["activation"], supportsCount: false, supportsCorrelation: false, description: "The first transition matches the activation." }, + { id: "end", displayName: "End", category: "position", requiredRoles: ["activation"], supportsCount: false, supportsCorrelation: false, description: "The last transition matches the activation." }, + { id: "choice", displayName: "Choice", category: "choice", requiredRoles: ["activation", "target"], supportsCount: false, supportsCorrelation: false, description: "The activation or target occurs." }, + { id: "exclusive-choice", displayName: "Exclusive choice", category: "choice", requiredRoles: ["activation", "target"], supportsCount: false, supportsCorrelation: false, description: "Exactly one of activation and target occurs." }, + { id: "responded-existence", displayName: "Responded existence", category: "existence", requiredRoles: ["activation", "target"], supportsCount: false, supportsCorrelation: true, description: "If the activation occurs, a correlated target occurs before or after it." }, + { id: "not-responded-existence", displayName: "Not responded existence", category: "existence", requiredRoles: ["activation", "target"], supportsCount: false, supportsCorrelation: true, description: "If the activation occurs, no correlated target occurs in the path." }, + { id: "coexistence", displayName: "Coexistence", category: "existence", requiredRoles: ["activation", "target"], supportsCount: false, supportsCorrelation: true, description: "Activation and target either both occur or both do not occur." }, + { id: "not-coexistence", displayName: "Not coexistence", category: "existence", requiredRoles: ["activation", "target"], supportsCount: false, supportsCorrelation: true, description: "Activation and target do not both occur." }, + { id: "response", displayName: "Response", category: "future", requiredRoles: ["activation", "target"], supportsCount: false, supportsCorrelation: true, description: "Every activation is eventually followed by a correlated target." }, + { id: "not-response", displayName: "Not response", category: "future", requiredRoles: ["activation", "target"], supportsCount: false, supportsCorrelation: true, description: "No activation is followed later by a correlated target." }, + { id: "chain-response", displayName: "Chain response", category: "future", requiredRoles: ["activation", "target"], supportsCount: false, supportsCorrelation: true, description: "Every activation is immediately followed by a correlated target." }, + { id: "not-chain-response", displayName: "Not chain response", category: "future", requiredRoles: ["activation", "target"], supportsCount: false, supportsCorrelation: true, description: "No activation is immediately followed by a correlated target." }, + { id: "alternate-response", displayName: "Alternate response", category: "future", requiredRoles: ["activation", "target", "between"], supportsCount: false, supportsCorrelation: true, description: "Every activation is followed by a correlated target without another qualifying activation or forbidden between event." }, + { id: "not-alternate-response", displayName: "Not alternate response", category: "future", requiredRoles: ["activation", "target", "between"], supportsCount: false, supportsCorrelation: true, description: "Specialized negative alternate-response semantics." }, + { id: "precedence", displayName: "Precedence", category: "past", requiredRoles: ["activation", "target"], supportsCount: false, supportsCorrelation: true, description: "Every target has a correlated activation before it." }, + { id: "not-precedence", displayName: "Not precedence", category: "past", requiredRoles: ["activation", "target"], supportsCount: false, supportsCorrelation: true, description: "No target has a correlated activation before it." }, + { id: "chain-precedence", displayName: "Chain precedence", category: "past", requiredRoles: ["activation", "target"], supportsCount: false, supportsCorrelation: true, description: "Every target is immediately preceded by a correlated activation." }, + { id: "not-chain-precedence", displayName: "Not chain precedence", category: "past", requiredRoles: ["activation", "target"], supportsCount: false, supportsCorrelation: true, description: "No target is immediately preceded by a correlated activation." }, + { id: "alternate-precedence", displayName: "Alternate precedence", category: "past", requiredRoles: ["activation", "target", "between"], supportsCount: false, supportsCorrelation: true, description: "Every target has a correlated activation before it without another qualifying target or forbidden between event." }, + { id: "not-alternate-precedence", displayName: "Not alternate precedence", category: "past", requiredRoles: ["activation", "target", "between"], supportsCount: false, supportsCorrelation: true, description: "Specialized negative alternate-precedence semantics." }, + { id: "succession", displayName: "Succession", category: "bidirectional", requiredRoles: ["activation", "target"], supportsCount: false, supportsCorrelation: true, description: "Response and precedence both hold." }, + { id: "not-succession", displayName: "Not succession", category: "bidirectional", requiredRoles: ["activation", "target"], supportsCount: false, supportsCorrelation: true, description: "Negative succession semantics." }, + { id: "chain-succession", displayName: "Chain succession", category: "bidirectional", requiredRoles: ["activation", "target"], supportsCount: false, supportsCorrelation: true, description: "Chain response and chain precedence both hold." }, + { id: "not-chain-succession", displayName: "Not chain succession", category: "bidirectional", requiredRoles: ["activation", "target"], supportsCount: false, supportsCorrelation: true, description: "Negative chain-succession semantics." }, + { id: "alternate-succession", displayName: "Alternate succession", category: "bidirectional", requiredRoles: ["activation", "target", "between"], supportsCount: false, supportsCorrelation: true, description: "Alternate response and alternate precedence both hold." }, + { id: "not-alternate-succession", displayName: "Not alternate succession", category: "bidirectional", requiredRoles: ["activation", "target", "between"], supportsCount: false, supportsCorrelation: true, description: "Specialized negative alternate-succession semantics." }, +] as const; + +export const DECLARE_TEMPLATE_DEFINITIONS: readonly DeclareTemplateDefinition[] = + DEFINITIONS; + +const DEFINITION_BY_ID = new Map( + DEFINITIONS.map((definition) => [definition.id, definition]), +); + +export function getDeclareTemplateDefinition( + id: DeclareTemplateId, +): DeclareTemplateDefinition { + const definition = DEFINITION_BY_ID.get(id); + if (!definition) { + throw new Error(`Unknown Declare template: ${id}.`); + } + return definition; +} + +function validatePredicateGroup( + group: DeclarePredicateGroup | undefined, + role: DeclarePredicateRole, +): string[] { + if (!group) { + return [`${role} is required.`]; + } + if (group.predicates.length === 0) { + return [`${role} must contain at least one predicate.`]; + } + const errors: string[] = []; + group.predicates.forEach((predicate, index) => { + if (!predicate.transition && !predicate.condition) { + errors.push( + `${role}.predicates[${index}] must define a transition name or data condition.`, + ); + } + if (predicate.transition?.value.trim() === "") { + errors.push(`${role}.predicates[${index}].transition must not be empty.`); + } + if ((predicate.captures?.length ?? 0) > 0 && role !== "activation") { + errors.push(`${role}.predicates[${index}] must not define activation captures.`); + } + }); + return errors; +} + +export function validateDeclareConstraint( + constraint: DeclareConstraint, +): string[] { + const errors: string[] = []; + if (constraint.id.trim() === "") { + errors.push("Constraint ID must not be empty."); + } + + const definition = getDeclareTemplateDefinition(constraint.template); + for (const role of definition.requiredRoles) { + errors.push(...validatePredicateGroup(constraint[role], role)); + } + + if (definition.supportsCount) { + if (!Number.isInteger(constraint.count) || (constraint.count ?? 0) < 0) { + errors.push("Count must be a non-negative integer."); + } + } else if (constraint.count !== undefined) { + errors.push(`${definition.displayName} does not support a count.`); + } + + if (constraint.correlation && !definition.supportsCorrelation) { + errors.push(`${definition.displayName} does not support correlation conditions.`); + } + + for (const role of ["activation", "target", "between"] as const) { + if (!definition.requiredRoles.includes(role) && constraint[role]) { + errors.push(`${definition.displayName} does not use ${role}.`); + } + } + + return errors; +} From 0725946fa10c71c6f237d2cd7a91591185aaf4c5 Mon Sep 17 00:00:00 2001 From: dbera Date: Fri, 7 Aug 2026 12:29:56 +0200 Subject: [PATCH 04/23] Add Declare predicate and monitor framework --- frontend/src/graph/declareMonitor.test.ts | 106 +++++++++++++ frontend/src/graph/declareMonitor.ts | 96 ++++++++++++ frontend/src/graph/declarePredicates.test.ts | 150 +++++++++++++++++++ frontend/src/graph/declarePredicates.ts | 98 ++++++++++++ 4 files changed, 450 insertions(+) create mode 100644 frontend/src/graph/declareMonitor.test.ts create mode 100644 frontend/src/graph/declareMonitor.ts create mode 100644 frontend/src/graph/declarePredicates.test.ts create mode 100644 frontend/src/graph/declarePredicates.ts diff --git a/frontend/src/graph/declareMonitor.test.ts b/frontend/src/graph/declareMonitor.test.ts new file mode 100644 index 0000000..5dbecd6 --- /dev/null +++ b/frontend/src/graph/declareMonitor.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; + +import { + advanceMonitorSet, + canonicalMonitorStateKey, + createMonitorSet, + getMonitorSetStatus, + monitorSetStateKey, + type DeclareMonitor, +} from "./declareMonitor"; + +type TestState = { + count: number; + violated: boolean; +}; + +function atLeastMonitor(minimum: number): DeclareMonitor { + return { + initialState: () => ({ count: 0, violated: false }), + advance: (state, edge) => ({ + ...state, + count: state.count + (edge.transition === "A" ? 1 : 0), + }), + status: (state) => ({ + viable: !state.violated, + accepting: !state.violated && state.count >= minimum, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +function forbidMonitor(name: string): DeclareMonitor { + return { + initialState: () => ({ count: 0, violated: false }), + advance: (state, edge) => ({ + count: state.count, + violated: state.violated || edge.transition === name, + }), + status: (state) => ({ + viable: !state.violated, + accepting: !state.violated, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +describe("canonicalMonitorStateKey", () => { + it("is stable across object property insertion order", () => { + expect(canonicalMonitorStateKey({ b: 2, a: { d: 4, c: 3 } })).toBe( + canonicalMonitorStateKey({ a: { c: 3, d: 4 }, b: 2 }), + ); + }); +}); + +describe("monitor sets", () => { + it("distinguishes viable pending states from accepting states", () => { + const initial = createMonitorSet([ + { id: "at-least-two-a", monitor: atLeastMonitor(2) }, + ]); + + expect(getMonitorSetStatus(initial)).toEqual({ + viable: true, + accepting: false, + rejectedConstraintIds: [], + pendingConstraintIds: ["at-least-two-a"], + }); + + const once = advanceMonitorSet(initial, { transition: "A" }); + const twice = advanceMonitorSet(once, { transition: "A" }); + expect(getMonitorSetStatus(twice).accepting).toBe(true); + }); + + it("reports irreversible violations separately", () => { + const initial = createMonitorSet([ + { id: "needs-a", monitor: atLeastMonitor(1) }, + { id: "forbid-x", monitor: forbidMonitor("X") }, + ]); + const next = advanceMonitorSet(initial, { transition: "X" }); + + expect(getMonitorSetStatus(next)).toEqual({ + viable: false, + accepting: false, + rejectedConstraintIds: ["forbid-x"], + pendingConstraintIds: ["needs-a"], + }); + }); + + it("does not mutate earlier monitor states", () => { + const initial = createMonitorSet([ + { id: "needs-a", monitor: atLeastMonitor(1) }, + ]); + const next = advanceMonitorSet(initial, { transition: "A" }); + + expect(getMonitorSetStatus(initial).accepting).toBe(false); + expect(getMonitorSetStatus(next).accepting).toBe(true); + }); + + it("creates a deterministic combined state key", () => { + const entries = createMonitorSet([ + { id: "first", monitor: atLeastMonitor(1) }, + { id: "second", monitor: forbidMonitor("X") }, + ]); + + expect(monitorSetStateKey(entries)).toBe(monitorSetStateKey(entries)); + }); +}); diff --git a/frontend/src/graph/declareMonitor.ts b/frontend/src/graph/declareMonitor.ts new file mode 100644 index 0000000..6f5dea1 --- /dev/null +++ b/frontend/src/graph/declareMonitor.ts @@ -0,0 +1,96 @@ +import type { DeclareTransition } from "./declarePredicates"; + +export type MonitorStatus = { + viable: boolean; + accepting: boolean; +}; + +export interface DeclareMonitor { + initialState(): State; + advance(state: State, edge: DeclareTransition): State; + status(state: State): MonitorStatus; + stateKey(state: State): string; +} + +export type MonitorSetEntry = { + id: string; + monitor: DeclareMonitor; + state: State; +}; + +export type MonitorSetStatus = MonitorStatus & { + rejectedConstraintIds: string[]; + pendingConstraintIds: string[]; +}; + +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(canonicalize); + } + + if (typeof value === "object" && value !== null) { + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => [key, canonicalize(child)]), + ); + } + + return value; +} + +export function canonicalMonitorStateKey(state: unknown): string { + return JSON.stringify(canonicalize(state)); +} + +export function createMonitorSet( + monitors: readonly { id: string; monitor: DeclareMonitor }[], +): MonitorSetEntry[] { + return monitors.map(({ id, monitor }) => ({ + id, + monitor, + state: monitor.initialState(), + })); +} + +export function advanceMonitorSet( + entries: readonly MonitorSetEntry[], + edge: DeclareTransition, +): MonitorSetEntry[] { + return entries.map((entry) => ({ + ...entry, + state: entry.monitor.advance(entry.state, edge), + })); +} + +export function getMonitorSetStatus( + entries: readonly MonitorSetEntry[], +): MonitorSetStatus { + const rejectedConstraintIds: string[] = []; + const pendingConstraintIds: string[] = []; + + entries.forEach((entry) => { + const status = entry.monitor.status(entry.state); + if (!status.viable) { + rejectedConstraintIds.push(entry.id); + } else if (!status.accepting) { + pendingConstraintIds.push(entry.id); + } + }); + + return { + viable: rejectedConstraintIds.length === 0, + accepting: + rejectedConstraintIds.length === 0 && pendingConstraintIds.length === 0, + rejectedConstraintIds, + pendingConstraintIds, + }; +} + +export function monitorSetStateKey( + entries: readonly MonitorSetEntry[], +): string { + return JSON.stringify( + entries.map((entry) => [entry.id, entry.monitor.stateKey(entry.state)]), + ); +} diff --git a/frontend/src/graph/declarePredicates.test.ts b/frontend/src/graph/declarePredicates.test.ts new file mode 100644 index 0000000..ee8197e --- /dev/null +++ b/frontend/src/graph/declarePredicates.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; + +import { + evaluateDeclarePredicate, + evaluateDeclarePredicateGroup, + expandPredicateGroup, +} from "./declarePredicates"; + +const edge = { + transition: "SubmitRequest", + inputs: { + request: { id: 42, priority: 7 }, + }, + outputs: {}, +}; + +describe("evaluateDeclarePredicate", () => { + it("matches transition identity and data together", () => { + const result = evaluateDeclarePredicate( + { + transition: { operator: "equals", value: "SubmitRequest" }, + condition: { + type: "source", + source: "inputs", + condition: { + type: "comparison", + path: ["request", "priority"], + operator: ">=", + value: 5, + }, + }, + }, + edge, + ); + + expect(result).toEqual({ matches: true, bindings: {}, errors: [] }); + }); + + it("does not match when either identity or data fails", () => { + expect( + evaluateDeclarePredicate( + { transition: { operator: "equals", value: "Other" } }, + edge, + ).matches, + ).toBe(false); + + expect( + evaluateDeclarePredicate( + { + transition: { operator: "equals", value: "SubmitRequest" }, + condition: { + type: "source", + source: "inputs", + condition: { + type: "comparison", + path: ["request", "priority"], + operator: ">", + value: 10, + }, + }, + }, + edge, + ).matches, + ).toBe(false); + }); + + it("captures activation bindings only after a successful match", () => { + expect( + evaluateDeclarePredicate( + { + transition: { operator: "equals", value: "SubmitRequest" }, + captures: [ + { + alias: "request_id", + source: "inputs", + path: ["request", "id"], + }, + ], + }, + edge, + ), + ).toEqual({ + matches: true, + bindings: { request_id: 42 }, + errors: [], + }); + }); +}); + +describe("evaluateDeclarePredicateGroup", () => { + it("matches an OR group when any predicate matches", () => { + const result = evaluateDeclarePredicateGroup( + { + relation: "or", + predicates: [ + { transition: { operator: "equals", value: "Other" } }, + { transition: { operator: "equals", value: "SubmitRequest" } }, + ], + }, + edge, + ); + + expect(result.matches).toBe(true); + expect(result.predicateMatches.map((match) => match.predicateIndex)).toEqual([ + 1, + ]); + }); + + it("matches an AND group only when every predicate matches", () => { + const result = evaluateDeclarePredicateGroup( + { + relation: "and", + predicates: [ + { transition: { operator: "equals", value: "SubmitRequest" } }, + { + condition: { + type: "source", + source: "inputs", + condition: { + type: "comparison", + path: ["request", "id"], + operator: "=", + value: 42, + }, + }, + }, + ], + }, + edge, + ); + + expect(result.matches).toBe(true); + expect(result.predicateMatches).toHaveLength(2); + }); + + it("expands AND groups into independent primitive obligations", () => { + const group = { + relation: "and" as const, + predicates: [ + { transition: { operator: "equals" as const, value: "A" } }, + { transition: { operator: "equals" as const, value: "B" } }, + ], + }; + + expect(expandPredicateGroup(group)).toEqual([ + { relation: "or", predicates: [group.predicates[0]] }, + { relation: "or", predicates: [group.predicates[1]] }, + ]); + }); +}); diff --git a/frontend/src/graph/declarePredicates.ts b/frontend/src/graph/declarePredicates.ts new file mode 100644 index 0000000..93fb52f --- /dev/null +++ b/frontend/src/graph/declarePredicates.ts @@ -0,0 +1,98 @@ +import { + evaluateTransitionCondition, + type TransitionData, +} from "./transitionConditions"; +import { + captureActivationValues, + type ActivationBindings, +} from "./transitionCorrelation"; +import type { + DeclarePredicate, + DeclarePredicateGroup, +} from "./declareConstraints"; + +export type DeclareTransition = TransitionData & { + transition?: string; +}; + +export type PredicateMatch = { + predicateIndex: number; + bindings: ActivationBindings; +}; + +export type PredicateGroupEvaluation = { + matches: boolean; + predicateMatches: PredicateMatch[]; + errors: string[]; +}; + +export function evaluateDeclarePredicate( + predicate: DeclarePredicate, + edge: DeclareTransition, +): { matches: boolean; bindings: ActivationBindings; errors: string[] } { + if ( + predicate.transition && + edge.transition !== predicate.transition.value + ) { + return { matches: false, bindings: {}, errors: [] }; + } + + if (predicate.condition) { + const evaluation = evaluateTransitionCondition(predicate.condition, edge); + if (evaluation.errors.length > 0 || !evaluation.matches) { + return { + matches: false, + bindings: {}, + errors: evaluation.errors, + }; + } + } + + const capture = captureActivationValues(predicate.captures ?? [], edge); + if (capture.errors.length > 0) { + return { matches: false, bindings: {}, errors: capture.errors }; + } + + return { matches: true, bindings: capture.bindings, errors: [] }; +} + +export function evaluateDeclarePredicateGroup( + group: DeclarePredicateGroup, + edge: DeclareTransition, +): PredicateGroupEvaluation { + const predicateMatches: PredicateMatch[] = []; + const errors: string[] = []; + + group.predicates.forEach((predicate, predicateIndex) => { + const evaluation = evaluateDeclarePredicate(predicate, edge); + errors.push(...evaluation.errors); + if (evaluation.matches) { + predicateMatches.push({ + predicateIndex, + bindings: evaluation.bindings, + }); + } + }); + + return { + matches: + group.relation === "or" + ? predicateMatches.length > 0 + : predicateMatches.length === group.predicates.length, + predicateMatches, + errors, + }; +} + +export function expandPredicateGroup( + group: DeclarePredicateGroup, +): DeclarePredicateGroup[] { + if (group.relation === "or") { + return [group]; + } + + return group.predicates.map((predicate) => ({ + relation: "or", + predicates: [predicate], + })); +} From 40d81faacc16b4555ec612c09d212fd829dea86d Mon Sep 17 00:00:00 2001 From: dbera Date: Fri, 7 Aug 2026 12:33:48 +0200 Subject: [PATCH 05/23] Add basic Declare monitors --- .../src/graph/declareBasicMonitors.test.ts | 190 +++++++++++++ frontend/src/graph/declareBasicMonitors.ts | 250 ++++++++++++++++++ 2 files changed, 440 insertions(+) create mode 100644 frontend/src/graph/declareBasicMonitors.test.ts create mode 100644 frontend/src/graph/declareBasicMonitors.ts diff --git a/frontend/src/graph/declareBasicMonitors.test.ts b/frontend/src/graph/declareBasicMonitors.test.ts new file mode 100644 index 0000000..aad835a --- /dev/null +++ b/frontend/src/graph/declareBasicMonitors.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from "vitest"; + +import { + createAtLeastMonitor, + createAtMostMonitor, + createChoiceMonitor, + createEndMonitor, + createExactlyConsecutiveMonitor, + createExactlyMonitor, + createExclusiveChoiceMonitor, + createInitMonitor, +} from "./declareBasicMonitors"; +import type { DeclareMonitor } from "./declareMonitor"; +import type { DeclareTransition } from "./declarePredicates"; + +const group = (name: string) => ({ + relation: "or" as const, + predicates: [ + { transition: { operator: "equals" as const, value: name } }, + ], +}); + +function run( + monitor: DeclareMonitor, + transitions: string[], +): State { + return transitions.reduce( + (state, transition) => monitor.advance(state, { transition }), + monitor.initialState(), + ); +} + +function status( + monitor: DeclareMonitor, + transitions: string[], +) { + return monitor.status(run(monitor, transitions)); +} + +describe("cardinality monitors", () => { + it("implements at least N with an accepting saturated count", () => { + const monitor = createAtLeastMonitor(group("A"), 2); + expect(status(monitor, [])).toEqual({ viable: true, accepting: false }); + expect(status(monitor, ["A"])).toEqual({ viable: true, accepting: false }); + expect(status(monitor, ["A", "X", "A"])).toEqual({ + viable: true, + accepting: true, + }); + expect(run(monitor, ["A", "A", "A"]).count).toBe(2); + }); + + it("implements at most N and prunes after the limit", () => { + const monitor = createAtMostMonitor(group("A"), 1); + expect(status(monitor, [])).toEqual({ viable: true, accepting: true }); + expect(status(monitor, ["A"])).toEqual({ viable: true, accepting: true }); + expect(status(monitor, ["A", "A"])).toEqual({ + viable: false, + accepting: false, + }); + }); + + it("implements exactly N with pending and violated states", () => { + const monitor = createExactlyMonitor(group("A"), 2); + expect(status(monitor, ["A"])).toEqual({ viable: true, accepting: false }); + expect(status(monitor, ["A", "X", "A"])).toEqual({ + viable: true, + accepting: true, + }); + expect(status(monitor, ["A", "A", "A"])).toEqual({ + viable: false, + accepting: false, + }); + }); + + it("requires exactly N occurrences to form one consecutive run", () => { + const monitor = createExactlyConsecutiveMonitor(group("A"), 2); + expect(status(monitor, ["X", "A", "A", "X"])).toEqual({ + viable: true, + accepting: true, + }); + expect(status(monitor, ["A", "X", "A"])).toEqual({ + viable: false, + accepting: false, + }); + expect(status(monitor, ["A", "A", "A"])).toEqual({ + viable: false, + accepting: false, + }); + }); + + it("supports a zero cardinality", () => { + expect(status(createExactlyMonitor(group("A"), 0), [])).toEqual({ + viable: true, + accepting: true, + }); + expect(status(createAtMostMonitor(group("A"), 0), ["A"])).toEqual({ + viable: false, + accepting: false, + }); + expect(status(createExactlyConsecutiveMonitor(group("A"), 0), [])).toEqual({ + viable: true, + accepting: true, + }); + }); + + it("rejects invalid counts", () => { + expect(() => createAtLeastMonitor(group("A"), -1)).toThrow( + "Count must be a non-negative integer.", + ); + expect(() => createExactlyMonitor(group("A"), 1.5)).toThrow( + "Count must be a non-negative integer.", + ); + }); +}); + +describe("position monitors", () => { + it("requires Init to match the first transition", () => { + const monitor = createInitMonitor(group("A")); + expect(status(monitor, [])).toEqual({ viable: true, accepting: false }); + expect(status(monitor, ["A", "X"])).toEqual({ + viable: true, + accepting: true, + }); + expect(status(monitor, ["X", "A"])).toEqual({ + viable: false, + accepting: false, + }); + }); + + it("requires End to match the final transition", () => { + const monitor = createEndMonitor(group("A")); + expect(status(monitor, [])).toEqual({ viable: true, accepting: false }); + expect(status(monitor, ["A", "X"])).toEqual({ + viable: true, + accepting: false, + }); + expect(status(monitor, ["X", "A"])).toEqual({ + viable: true, + accepting: true, + }); + }); +}); + +describe("choice monitors", () => { + it("accepts Choice when either side occurs", () => { + const monitor = createChoiceMonitor(group("A"), group("B")); + expect(status(monitor, [])).toEqual({ viable: true, accepting: false }); + expect(status(monitor, ["A"])).toEqual({ viable: true, accepting: true }); + expect(status(monitor, ["B"])).toEqual({ viable: true, accepting: true }); + expect(status(monitor, ["A", "B"])).toEqual({ + viable: true, + accepting: true, + }); + }); + + it("accepts Exclusive Choice only when exactly one side occurs", () => { + const monitor = createExclusiveChoiceMonitor(group("A"), group("B")); + expect(status(monitor, [])).toEqual({ viable: true, accepting: false }); + expect(status(monitor, ["A"])).toEqual({ viable: true, accepting: true }); + expect(status(monitor, ["B"])).toEqual({ viable: true, accepting: true }); + expect(status(monitor, ["A", "B"])).toEqual({ + viable: false, + accepting: false, + }); + }); + + it("treats one edge matching both exclusive-choice sides as a violation", () => { + const dataGroup = { + relation: "or" as const, + predicates: [ + { + condition: { + type: "source" as const, + source: "inputs" as const, + condition: { + type: "comparison" as const, + path: ["value"], + operator: "=" as const, + value: 1, + }, + }, + }, + ], + }; + const monitor = createExclusiveChoiceMonitor(group("A"), dataGroup); + const edge: DeclareTransition = { transition: "A", inputs: { value: 1 } }; + const state = monitor.advance(monitor.initialState(), edge); + expect(monitor.status(state)).toEqual({ viable: false, accepting: false }); + }); +}); diff --git a/frontend/src/graph/declareBasicMonitors.ts b/frontend/src/graph/declareBasicMonitors.ts new file mode 100644 index 0000000..8fd0b04 --- /dev/null +++ b/frontend/src/graph/declareBasicMonitors.ts @@ -0,0 +1,250 @@ +import type { DeclarePredicateGroup } from "./declareConstraints"; +import { + canonicalMonitorStateKey, + type DeclareMonitor, +} from "./declareMonitor"; +import { + evaluateDeclarePredicateGroup, + type DeclareTransition, +} from "./declarePredicates"; + +type CountState = { + count: number; + violated: boolean; +}; + +type ConsecutiveCountState = CountState & { + runStarted: boolean; + runEnded: boolean; +}; + +type PositionState = { + seenTransition: boolean; + matches: boolean; + violated: boolean; +}; + +type ChoiceState = { + seenActivation: boolean; + seenTarget: boolean; + violated: boolean; +}; + +function matches(group: DeclarePredicateGroup, edge: DeclareTransition): boolean { + return evaluateDeclarePredicateGroup(group, edge).matches; +} + +function validateCount(count: number): void { + if (!Number.isInteger(count) || count < 0) { + throw new Error("Count must be a non-negative integer."); + } +} + +export function createAtLeastMonitor( + activation: DeclarePredicateGroup, + minimum: number, +): DeclareMonitor { + validateCount(minimum); + return { + initialState: () => ({ count: 0, violated: false }), + advance: (state, edge) => ({ + count: Math.min(minimum, state.count + (matches(activation, edge) ? 1 : 0)), + violated: state.violated, + }), + status: (state) => ({ + viable: !state.violated, + accepting: !state.violated && state.count >= minimum, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +export function createAtMostMonitor( + activation: DeclarePredicateGroup, + maximum: number, +): DeclareMonitor { + validateCount(maximum); + return { + initialState: () => ({ count: 0, violated: false }), + advance: (state, edge) => { + if (state.violated || !matches(activation, edge)) { + return state; + } + const count = state.count + 1; + return { count, violated: count > maximum }; + }, + status: (state) => ({ + viable: !state.violated, + accepting: !state.violated, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +export function createExactlyMonitor( + activation: DeclarePredicateGroup, + expected: number, +): DeclareMonitor { + validateCount(expected); + return { + initialState: () => ({ count: 0, violated: false }), + advance: (state, edge) => { + if (state.violated || !matches(activation, edge)) { + return state; + } + const count = state.count + 1; + return { count, violated: count > expected }; + }, + status: (state) => ({ + viable: !state.violated, + accepting: !state.violated && state.count === expected, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +export function createExactlyConsecutiveMonitor( + activation: DeclarePredicateGroup, + expected: number, +): DeclareMonitor { + validateCount(expected); + return { + initialState: () => ({ + count: 0, + runStarted: false, + runEnded: false, + violated: false, + }), + advance: (state, edge) => { + if (state.violated) { + return state; + } + const isActivation = matches(activation, edge); + if (isActivation) { + if (state.runEnded || expected === 0) { + return { ...state, violated: true }; + } + const count = state.count + 1; + return { + count, + runStarted: true, + runEnded: false, + violated: count > expected, + }; + } + return state.runStarted + ? { ...state, runEnded: true } + : state; + }, + status: (state) => ({ + viable: !state.violated, + accepting: !state.violated && state.count === expected, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +export function createInitMonitor( + activation: DeclarePredicateGroup, +): DeclareMonitor { + return { + initialState: () => ({ + seenTransition: false, + matches: false, + violated: false, + }), + advance: (state, edge) => { + if (state.seenTransition) { + return state; + } + const firstMatches = matches(activation, edge); + return { + seenTransition: true, + matches: firstMatches, + violated: !firstMatches, + }; + }, + status: (state) => ({ + viable: !state.violated, + accepting: state.seenTransition && state.matches && !state.violated, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +export function createEndMonitor( + activation: DeclarePredicateGroup, +): DeclareMonitor { + return { + initialState: () => ({ + seenTransition: false, + matches: false, + violated: false, + }), + advance: (_state, edge) => ({ + seenTransition: true, + matches: matches(activation, edge), + violated: false, + }), + status: (state) => ({ + viable: true, + accepting: state.seenTransition && state.matches, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +export function createChoiceMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, +): DeclareMonitor { + return { + initialState: () => ({ + seenActivation: false, + seenTarget: false, + violated: false, + }), + advance: (state, edge) => ({ + seenActivation: state.seenActivation || matches(activation, edge), + seenTarget: state.seenTarget || matches(target, edge), + violated: state.violated, + }), + status: (state) => ({ + viable: !state.violated, + accepting: + !state.violated && (state.seenActivation || state.seenTarget), + }), + stateKey: canonicalMonitorStateKey, + }; +} + +export function createExclusiveChoiceMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, +): DeclareMonitor { + return { + initialState: () => ({ + seenActivation: false, + seenTarget: false, + violated: false, + }), + advance: (state, edge) => { + if (state.violated) { + return state; + } + const seenActivation = state.seenActivation || matches(activation, edge); + const seenTarget = state.seenTarget || matches(target, edge); + return { + seenActivation, + seenTarget, + violated: seenActivation && seenTarget, + }; + }, + status: (state) => ({ + viable: !state.violated, + accepting: + !state.violated && state.seenActivation !== state.seenTarget, + }), + stateKey: canonicalMonitorStateKey, + }; +} From fd723d6e56c1393f045bb423e37eb12ab896a0c5 Mon Sep 17 00:00:00 2001 From: dbera Date: Fri, 7 Aug 2026 12:37:37 +0200 Subject: [PATCH 06/23] Add Declare response monitors --- .../src/graph/declareResponseMonitors.test.ts | 208 ++++++++++++ frontend/src/graph/declareResponseMonitors.ts | 318 ++++++++++++++++++ 2 files changed, 526 insertions(+) create mode 100644 frontend/src/graph/declareResponseMonitors.test.ts create mode 100644 frontend/src/graph/declareResponseMonitors.ts diff --git a/frontend/src/graph/declareResponseMonitors.test.ts b/frontend/src/graph/declareResponseMonitors.test.ts new file mode 100644 index 0000000..fbd8e08 --- /dev/null +++ b/frontend/src/graph/declareResponseMonitors.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from "vitest"; + +import type { DeclarePredicateGroup } from "./declareConstraints"; +import type { DeclareMonitor } from "./declareMonitor"; +import type { DeclareTransition } from "./declarePredicates"; +import { + createAlternateResponseMonitor, + createChainResponseMonitor, + createNotAlternateResponseMonitor, + createNotChainResponseMonitor, + createNotResponseMonitor, + createResponseMonitor, +} from "./declareResponseMonitors"; + +const group = (name: string): DeclarePredicateGroup => ({ + relation: "or", + predicates: [ + { transition: { operator: "equals", value: name } }, + ], +}); + +function run( + monitor: DeclareMonitor, + edges: DeclareTransition[], +): State { + return edges.reduce( + (state, edge) => monitor.advance(state, edge), + monitor.initialState(), + ); +} + +function status( + monitor: DeclareMonitor, + edges: DeclareTransition[], +) { + return monitor.status(run(monitor, edges)); +} + +const correlatedActivation: DeclarePredicateGroup = { + relation: "or", + predicates: [ + { + transition: { operator: "equals", value: "A" }, + captures: [ + { alias: "request_id", source: "inputs", path: ["id"] }, + ], + }, + ], +}; + +const correlation = { + type: "comparison" as const, + left: { kind: "target" as const, source: "outputs" as const, path: ["id"] }, + operator: "=" as const, + right: { kind: "activation" as const, alias: "request_id" }, +}; + +const A = (id: number): DeclareTransition => ({ + transition: "A", + inputs: { id }, +}); +const B = (id: number): DeclareTransition => ({ + transition: "B", + outputs: { id }, +}); +const X: DeclareTransition = { transition: "X" }; +const C: DeclareTransition = { transition: "C" }; + +describe("Response", () => { + it("is vacuously satisfied without activations", () => { + expect(status(createResponseMonitor(group("A"), group("B")), [X])).toEqual({ + viable: true, + accepting: true, + }); + }); + + it("keeps unmatched activations pending until a target occurs", () => { + const monitor = createResponseMonitor(group("A"), group("B")); + expect(status(monitor, [{ transition: "A" }, X])).toEqual({ + viable: true, + accepting: false, + }); + expect(status(monitor, [{ transition: "A" }, X, { transition: "B" }])).toEqual({ + viable: true, + accepting: true, + }); + }); + + it("correlates one target with each pending activation independently", () => { + const monitor = createResponseMonitor( + correlatedActivation, + group("B"), + correlation, + ); + const afterOneTarget = run(monitor, [A(10), A(20), B(10)]); + expect(afterOneTarget.pending).toEqual([{ bindings: { request_id: 20 } }]); + expect(monitor.status(afterOneTarget).accepting).toBe(false); + expect(status(monitor, [A(10), A(20), B(10), B(20)]).accepting).toBe(true); + }); + + it("processes a target before adding a same-edge activation", () => { + const both = group("A"); + const monitor = createResponseMonitor(both, both); + expect(status(monitor, [{ transition: "A" }]).accepting).toBe(false); + expect(status(monitor, [{ transition: "A" }, { transition: "A" }]).accepting).toBe(false); + }); +}); + +describe("Not response", () => { + it("rejects a later correlated target but permits earlier targets", () => { + const monitor = createNotResponseMonitor( + correlatedActivation, + group("B"), + correlation, + ); + expect(status(monitor, [B(10), A(10)]).accepting).toBe(true); + expect(status(monitor, [A(10), B(20)]).accepting).toBe(true); + expect(status(monitor, [A(10), B(10)])).toEqual({ + viable: false, + accepting: false, + }); + }); +}); + +describe("Chain response", () => { + it("requires the next edge to be a correlated target", () => { + const monitor = createChainResponseMonitor( + correlatedActivation, + group("B"), + correlation, + ); + expect(status(monitor, [A(10)])).toEqual({ viable: true, accepting: false }); + expect(status(monitor, [A(10), B(10)])).toEqual({ viable: true, accepting: true }); + expect(status(monitor, [A(10), X])).toEqual({ viable: false, accepting: false }); + expect(status(monitor, [A(10), B(20)])).toEqual({ viable: false, accepting: false }); + }); + + it("implements not chain response", () => { + const monitor = createNotChainResponseMonitor(group("A"), group("B")); + expect(status(monitor, [{ transition: "A" }]).accepting).toBe(true); + expect(status(monitor, [{ transition: "A" }, X]).accepting).toBe(true); + expect(status(monitor, [{ transition: "A" }, { transition: "B" }])).toEqual({ + viable: false, + accepting: false, + }); + }); +}); + +describe("Alternate response", () => { + it("requires a target before another activation", () => { + const monitor = createAlternateResponseMonitor( + group("A"), + group("B"), + group("C"), + ); + expect(status(monitor, [{ transition: "A" }, X, { transition: "B" }]).accepting).toBe(true); + expect(status(monitor, [{ transition: "A" }, { transition: "A" }])).toEqual({ + viable: false, + accepting: false, + }); + }); + + it("rejects the configured between predicate while waiting", () => { + const monitor = createAlternateResponseMonitor( + group("A"), + group("B"), + group("C"), + ); + expect(status(monitor, [{ transition: "A" }, C])).toEqual({ + viable: false, + accepting: false, + }); + }); + + it("uses target correlation before fulfilling the activation", () => { + const monitor = createAlternateResponseMonitor( + correlatedActivation, + group("B"), + group("C"), + correlation, + ); + expect(status(monitor, [A(10), B(20)]).accepting).toBe(false); + expect(status(monitor, [A(10), B(10)]).accepting).toBe(true); + }); +}); + +describe("Specialized negative alternate response", () => { + it("rejects A followed by B with only the allowed C predicate between", () => { + const monitor = createNotAlternateResponseMonitor( + group("A"), + group("B"), + group("C"), + ); + expect(status(monitor, [{ transition: "A" }, C, { transition: "B" }]).accepting).toBe(false); + expect(status(monitor, [{ transition: "A" }, X, { transition: "B" }]).accepting).toBe(true); + }); + + it("applies correlation to the forbidden target", () => { + const monitor = createNotAlternateResponseMonitor( + correlatedActivation, + group("B"), + group("C"), + correlation, + ); + expect(status(monitor, [A(10), C, B(20)]).accepting).toBe(true); + expect(status(monitor, [A(10), C, B(10)]).accepting).toBe(false); + }); +}); diff --git a/frontend/src/graph/declareResponseMonitors.ts b/frontend/src/graph/declareResponseMonitors.ts new file mode 100644 index 0000000..8d194bd --- /dev/null +++ b/frontend/src/graph/declareResponseMonitors.ts @@ -0,0 +1,318 @@ +import type { DeclarePredicateGroup } from "./declareConstraints"; +import { + canonicalMonitorStateKey, + type DeclareMonitor, +} from "./declareMonitor"; +import { + evaluateDeclarePredicateGroup, + type DeclareTransition, +} from "./declarePredicates"; +import { + evaluateCorrelationCondition, + type ActivationBindings, + type CorrelationCondition, +} from "./transitionCorrelation"; + +export type PendingActivation = { + bindings: ActivationBindings; +}; + +export type ResponseMonitorState = { + pending: PendingActivation[]; + violated: boolean; +}; + +export type ChainResponseMonitorState = { + pending: PendingActivation | null; + violated: boolean; +}; + +export type AlternateResponseMonitorState = { + pending: PendingActivation | null; + possibleTargets: DeclareTransition[]; + violated: boolean; +}; + +function groupMatches( + group: DeclarePredicateGroup, + edge: DeclareTransition, +): boolean { + return evaluateDeclarePredicateGroup(group, edge).matches; +} + +function activationMatches( + activation: DeclarePredicateGroup, + edge: DeclareTransition, +): PendingActivation[] { + const evaluation = evaluateDeclarePredicateGroup(activation, edge); + if (!evaluation.matches) { + return []; + } + + return evaluation.predicateMatches.map((match) => ({ + bindings: match.bindings, + })); +} + +function targetCorrelates( + target: DeclarePredicateGroup, + correlation: CorrelationCondition | undefined, + activation: PendingActivation, + edge: DeclareTransition, +): boolean { + if (!groupMatches(target, edge)) { + return false; + } + + return correlation + ? evaluateCorrelationCondition(correlation, edge, activation.bindings).matches + : true; +} + +function fulfillPending( + pending: readonly PendingActivation[], + target: DeclarePredicateGroup, + correlation: CorrelationCondition | undefined, + edge: DeclareTransition, +): PendingActivation[] { + return pending.filter( + (activation) => !targetCorrelates(target, correlation, activation, edge), + ); +} + +export function createResponseMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return { + initialState: () => ({ pending: [], violated: false }), + advance: (state, edge) => { + const stillPending = fulfillPending( + state.pending, + target, + correlation, + edge, + ); + return { + pending: [...stillPending, ...activationMatches(activation, edge)], + violated: state.violated, + }; + }, + status: (state) => ({ + viable: !state.violated, + accepting: !state.violated && state.pending.length === 0, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +export function createNotResponseMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return { + initialState: () => ({ pending: [], violated: false }), + advance: (state, edge) => { + if (state.violated) { + return state; + } + + const forbiddenTarget = state.pending.some((pending) => + targetCorrelates(target, correlation, pending, edge), + ); + return { + pending: [...state.pending, ...activationMatches(activation, edge)], + violated: forbiddenTarget, + }; + }, + status: (state) => ({ + viable: !state.violated, + accepting: !state.violated, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +export function createChainResponseMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return { + initialState: () => ({ pending: null, violated: false }), + advance: (state, edge) => { + if (state.violated) { + return state; + } + + const violated = + state.pending !== null && + !targetCorrelates(target, correlation, state.pending, edge); + const activations = activationMatches(activation, edge); + return { + pending: activations[0] ?? null, + violated, + }; + }, + status: (state) => ({ + viable: !state.violated, + accepting: !state.violated && state.pending === null, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +export function createNotChainResponseMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return { + initialState: () => ({ pending: null, violated: false }), + advance: (state, edge) => { + if (state.violated) { + return state; + } + + const violated = + state.pending !== null && + targetCorrelates(target, correlation, state.pending, edge); + const activations = activationMatches(activation, edge); + return { + pending: activations[0] ?? null, + violated, + }; + }, + status: (state) => ({ + viable: !state.violated, + accepting: !state.violated, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +export function createAlternateResponseMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + between: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return { + initialState: () => ({ + pending: null, + possibleTargets: [], + violated: false, + }), + advance: (state, edge) => { + if (state.violated) { + return state; + } + + const newActivations = activationMatches(activation, edge); + if (newActivations.length > 0) { + const fulfilled = + state.pending === null || + state.possibleTargets.some((candidate) => + targetCorrelates(target, correlation, state.pending!, candidate), + ); + return { + pending: newActivations[0], + possibleTargets: [], + violated: !fulfilled, + }; + } + + if (state.pending !== null && groupMatches(between, edge)) { + return { ...state, violated: true }; + } + + return state.pending !== null && groupMatches(target, edge) + ? { + ...state, + possibleTargets: [...state.possibleTargets, edge], + } + : state; + }, + status: (state) => { + const pendingFulfilled = + state.pending === null || + state.possibleTargets.some((candidate) => + targetCorrelates(target, correlation, state.pending!, candidate), + ); + return { + viable: !state.violated, + accepting: !state.violated && pendingFulfilled, + }; + }, + stateKey: canonicalMonitorStateKey, + }; +} + +export function createNotAlternateResponseMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + allowedBetween: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return { + initialState: () => ({ + pending: null, + possibleTargets: [], + violated: false, + }), + advance: (state, edge) => { + if (state.violated) { + return state; + } + + const newActivations = activationMatches(activation, edge); + if (newActivations.length > 0) { + const forbiddenSequenceCompleted = + state.pending !== null && + state.possibleTargets.some((candidate) => + targetCorrelates(target, correlation, state.pending!, candidate), + ); + return { + pending: newActivations[0], + possibleTargets: [], + violated: forbiddenSequenceCompleted, + }; + } + + if (state.pending === null) { + return state; + } + + if (groupMatches(target, edge)) { + return { + ...state, + possibleTargets: [...state.possibleTargets, edge], + }; + } + + if (!groupMatches(allowedBetween, edge)) { + return { + pending: null, + possibleTargets: [], + violated: false, + }; + } + + return state; + }, + status: (state) => { + const forbiddenSequenceCompleted = + state.pending !== null && + state.possibleTargets.some((candidate) => + targetCorrelates(target, correlation, state.pending!, candidate), + ); + return { + viable: !state.violated, + accepting: !state.violated && !forbiddenSequenceCompleted, + }; + }, + stateKey: canonicalMonitorStateKey, + }; +} From d4be421d5eeea4df4902e7bde2b2163c10585a2f Mon Sep 17 00:00:00 2001 From: dbera Date: Fri, 7 Aug 2026 12:41:14 +0200 Subject: [PATCH 07/23] Add Declare precedence monitors --- .../graph/declarePrecedenceMonitors.test.ts | 230 +++++++++++++ .../src/graph/declarePrecedenceMonitors.ts | 323 ++++++++++++++++++ 2 files changed, 553 insertions(+) create mode 100644 frontend/src/graph/declarePrecedenceMonitors.test.ts create mode 100644 frontend/src/graph/declarePrecedenceMonitors.ts diff --git a/frontend/src/graph/declarePrecedenceMonitors.test.ts b/frontend/src/graph/declarePrecedenceMonitors.test.ts new file mode 100644 index 0000000..a061547 --- /dev/null +++ b/frontend/src/graph/declarePrecedenceMonitors.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from "vitest"; + +import type { DeclarePredicateGroup } from "./declareConstraints"; +import type { DeclareMonitor } from "./declareMonitor"; +import type { DeclareTransition } from "./declarePredicates"; +import { + createAlternatePrecedenceMonitor, + createChainPrecedenceMonitor, + createNotAlternatePrecedenceMonitor, + createNotChainPrecedenceMonitor, + createNotPrecedenceMonitor, + createPrecedenceMonitor, +} from "./declarePrecedenceMonitors"; + +const group = (name: string): DeclarePredicateGroup => ({ + relation: "or", + predicates: [ + { transition: { operator: "equals", value: name } }, + ], +}); + +function run( + monitor: DeclareMonitor, + edges: DeclareTransition[], +): State { + return edges.reduce( + (state, edge) => monitor.advance(state, edge), + monitor.initialState(), + ); +} + +function status( + monitor: DeclareMonitor, + edges: DeclareTransition[], +) { + return monitor.status(run(monitor, edges)); +} + +const correlatedActivation: DeclarePredicateGroup = { + relation: "or", + predicates: [ + { + transition: { operator: "equals", value: "A" }, + captures: [ + { alias: "request_id", source: "inputs", path: ["id"] }, + ], + }, + ], +}; + +const correlation = { + type: "comparison" as const, + left: { + kind: "target" as const, + source: "outputs" as const, + path: ["id"], + }, + operator: "=" as const, + right: { kind: "activation" as const, alias: "request_id" }, +}; + +const A = (id: number): DeclareTransition => ({ + transition: "A", + inputs: { id }, +}); +const B = (id: number): DeclareTransition => ({ + transition: "B", + outputs: { id }, +}); +const X: DeclareTransition = { transition: "X" }; +const C: DeclareTransition = { transition: "C" }; + +describe("Precedence", () => { + it("is vacuously satisfied when no target occurs", () => { + expect( + status(createPrecedenceMonitor(group("A"), group("B")), [X]), + ).toEqual({ viable: true, accepting: true }); + }); + + it("requires an activation strictly before every target", () => { + const monitor = createPrecedenceMonitor(group("A"), group("B")); + expect(status(monitor, [{ transition: "A" }, { transition: "B" }])).toEqual({ + viable: true, + accepting: true, + }); + expect(status(monitor, [{ transition: "B" }])).toEqual({ + viable: false, + accepting: false, + }); + }); + + it("does not allow one edge to precede itself", () => { + const monitor = createPrecedenceMonitor(group("A"), group("A")); + expect(status(monitor, [{ transition: "A" }])).toEqual({ + viable: false, + accepting: false, + }); + }); + + it("uses a correlated earlier activation", () => { + const monitor = createPrecedenceMonitor( + correlatedActivation, + group("B"), + correlation, + ); + expect(status(monitor, [A(10), B(10)]).accepting).toBe(true); + expect(status(monitor, [A(10), B(20)])).toEqual({ + viable: false, + accepting: false, + }); + expect(status(monitor, [A(10), A(20), B(20)]).accepting).toBe(true); + }); +}); + +describe("Not precedence", () => { + it("forbids a correlated activation before a target", () => { + const monitor = createNotPrecedenceMonitor( + correlatedActivation, + group("B"), + correlation, + ); + expect(status(monitor, [B(10), A(10)]).accepting).toBe(true); + expect(status(monitor, [A(10), B(20)]).accepting).toBe(true); + expect(status(monitor, [A(10), B(10)])).toEqual({ + viable: false, + accepting: false, + }); + }); +}); + +describe("Chain precedence", () => { + it("requires the immediately previous edge to be a correlated activation", () => { + const monitor = createChainPrecedenceMonitor( + correlatedActivation, + group("B"), + correlation, + ); + expect(status(monitor, [A(10), B(10)])).toEqual({ + viable: true, + accepting: true, + }); + expect(status(monitor, [A(10), X, B(10)])).toEqual({ + viable: false, + accepting: false, + }); + expect(status(monitor, [A(10), B(20)])).toEqual({ + viable: false, + accepting: false, + }); + }); + + it("implements not chain precedence", () => { + const monitor = createNotChainPrecedenceMonitor(group("A"), group("B")); + expect(status(monitor, [{ transition: "A" }, X, { transition: "B" }]).accepting).toBe(true); + expect(status(monitor, [{ transition: "A" }, { transition: "B" }])).toEqual({ + viable: false, + accepting: false, + }); + }); +}); + +describe("Alternate precedence", () => { + it("requires an activation since the previous target", () => { + const monitor = createAlternatePrecedenceMonitor( + group("A"), + group("B"), + group("C"), + ); + expect(status(monitor, [{ transition: "A" }, X, { transition: "B" }]).accepting).toBe(true); + expect(status(monitor, [{ transition: "A" }, { transition: "B" }, { transition: "B" }])).toEqual({ + viable: false, + accepting: false, + }); + }); + + it("rejects the configured between predicate after the activation", () => { + const monitor = createAlternatePrecedenceMonitor( + group("A"), + group("B"), + group("C"), + ); + expect(status(monitor, [{ transition: "A" }, C, { transition: "B" }])).toEqual({ + viable: false, + accepting: false, + }); + }); + + it("uses correlation for the preceding activation", () => { + const monitor = createAlternatePrecedenceMonitor( + correlatedActivation, + group("B"), + group("C"), + correlation, + ); + expect(status(monitor, [A(10), B(10)]).accepting).toBe(true); + expect(status(monitor, [A(10), B(20)])).toEqual({ + viable: false, + accepting: false, + }); + }); +}); + +describe("Specialized negative alternate precedence", () => { + it("forbids A then B when only the allowed C predicate occurs between", () => { + const monitor = createNotAlternatePrecedenceMonitor( + group("A"), + group("B"), + group("C"), + ); + expect(status(monitor, [{ transition: "A" }, C, { transition: "B" }])).toEqual({ + viable: false, + accepting: false, + }); + expect(status(monitor, [{ transition: "A" }, X, { transition: "B" }]).accepting).toBe(true); + }); + + it("applies correlation to the forbidden preceding activation", () => { + const monitor = createNotAlternatePrecedenceMonitor( + correlatedActivation, + group("B"), + group("C"), + correlation, + ); + expect(status(monitor, [A(10), C, B(20)]).accepting).toBe(true); + expect(status(monitor, [A(10), C, B(10)])).toEqual({ + viable: false, + accepting: false, + }); + }); +}); diff --git a/frontend/src/graph/declarePrecedenceMonitors.ts b/frontend/src/graph/declarePrecedenceMonitors.ts new file mode 100644 index 0000000..cabc833 --- /dev/null +++ b/frontend/src/graph/declarePrecedenceMonitors.ts @@ -0,0 +1,323 @@ +import type { DeclarePredicateGroup } from "./declareConstraints"; +import { + canonicalMonitorStateKey, + type DeclareMonitor, +} from "./declareMonitor"; +import { + evaluateDeclarePredicateGroup, + type DeclareTransition, +} from "./declarePredicates"; +import { + evaluateCorrelationCondition, + type ActivationBindings, + type CorrelationCondition, +} from "./transitionCorrelation"; + +export type SeenActivation = { + bindings: ActivationBindings; +}; + +export type PrecedenceMonitorState = { + seenActivations: SeenActivation[]; + violated: boolean; +}; + +export type ChainPrecedenceMonitorState = { + previousActivations: SeenActivation[]; + violated: boolean; +}; + +export type AlternatePrecedenceMonitorState = { + candidateActivations: SeenActivation[]; + blocked: boolean; + violated: boolean; +}; + +function groupMatches( + group: DeclarePredicateGroup, + edge: DeclareTransition, +): boolean { + return evaluateDeclarePredicateGroup(group, edge).matches; +} + +function activationMatches( + activation: DeclarePredicateGroup, + edge: DeclareTransition, +): SeenActivation[] { + const evaluation = evaluateDeclarePredicateGroup(activation, edge); + if (!evaluation.matches) { + return []; + } + + return evaluation.predicateMatches.map((match) => ({ + bindings: match.bindings, + })); +} + +function correlates( + correlation: CorrelationCondition | undefined, + activation: SeenActivation, + targetEdge: DeclareTransition, +): boolean { + return correlation + ? evaluateCorrelationCondition( + correlation, + targetEdge, + activation.bindings, + ).matches + : true; +} + +function hasCorrelatedActivation( + activations: readonly SeenActivation[], + correlation: CorrelationCondition | undefined, + targetEdge: DeclareTransition, +): boolean { + return activations.some((activation) => + correlates(correlation, activation, targetEdge), + ); +} + +export function createPrecedenceMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return { + initialState: () => ({ seenActivations: [], violated: false }), + advance: (state, edge) => { + if (state.violated) { + return state; + } + + const targetOccurs = groupMatches(target, edge); + const violated = + targetOccurs && + !hasCorrelatedActivation(state.seenActivations, correlation, edge); + + return { + seenActivations: [ + ...state.seenActivations, + ...activationMatches(activation, edge), + ], + violated, + }; + }, + status: (state) => ({ + viable: !state.violated, + accepting: !state.violated, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +export function createNotPrecedenceMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return { + initialState: () => ({ seenActivations: [], violated: false }), + advance: (state, edge) => { + if (state.violated) { + return state; + } + + const targetOccurs = groupMatches(target, edge); + const violated = + targetOccurs && + hasCorrelatedActivation(state.seenActivations, correlation, edge); + + return { + seenActivations: [ + ...state.seenActivations, + ...activationMatches(activation, edge), + ], + violated, + }; + }, + status: (state) => ({ + viable: !state.violated, + accepting: !state.violated, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +export function createChainPrecedenceMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return { + initialState: () => ({ previousActivations: [], violated: false }), + advance: (state, edge) => { + if (state.violated) { + return state; + } + + const targetOccurs = groupMatches(target, edge); + const violated = + targetOccurs && + !hasCorrelatedActivation(state.previousActivations, correlation, edge); + + return { + previousActivations: activationMatches(activation, edge), + violated, + }; + }, + status: (state) => ({ + viable: !state.violated, + accepting: !state.violated, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +export function createNotChainPrecedenceMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return { + initialState: () => ({ previousActivations: [], violated: false }), + advance: (state, edge) => { + if (state.violated) { + return state; + } + + const targetOccurs = groupMatches(target, edge); + const violated = + targetOccurs && + hasCorrelatedActivation(state.previousActivations, correlation, edge); + + return { + previousActivations: activationMatches(activation, edge), + violated, + }; + }, + status: (state) => ({ + viable: !state.violated, + accepting: !state.violated, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +export function createAlternatePrecedenceMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + between: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return { + initialState: () => ({ + candidateActivations: [], + blocked: false, + violated: false, + }), + advance: (state, edge) => { + if (state.violated) { + return state; + } + + const targetOccurs = groupMatches(target, edge); + if (targetOccurs) { + const fulfilled = + !state.blocked && + hasCorrelatedActivation( + state.candidateActivations, + correlation, + edge, + ); + return { + candidateActivations: activationMatches(activation, edge), + blocked: false, + violated: !fulfilled, + }; + } + + const newActivations = activationMatches(activation, edge); + if (newActivations.length > 0) { + return { + candidateActivations: newActivations, + blocked: false, + violated: false, + }; + } + + if ( + state.candidateActivations.length > 0 && + groupMatches(between, edge) + ) { + return { ...state, blocked: true }; + } + + return state; + }, + status: (state) => ({ + viable: !state.violated, + accepting: !state.violated, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +export function createNotAlternatePrecedenceMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + allowedBetween: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return { + initialState: () => ({ + candidateActivations: [], + blocked: false, + violated: false, + }), + advance: (state, edge) => { + if (state.violated) { + return state; + } + + const targetOccurs = groupMatches(target, edge); + if (targetOccurs) { + const forbiddenSequence = + !state.blocked && + hasCorrelatedActivation( + state.candidateActivations, + correlation, + edge, + ); + return { + candidateActivations: activationMatches(activation, edge), + blocked: false, + violated: forbiddenSequence, + }; + } + + const newActivations = activationMatches(activation, edge); + if (newActivations.length > 0) { + return { + candidateActivations: newActivations, + blocked: false, + violated: false, + }; + } + + if ( + state.candidateActivations.length > 0 && + !groupMatches(allowedBetween, edge) + ) { + return { ...state, blocked: true }; + } + + return state; + }, + status: (state) => ({ + viable: !state.violated, + accepting: !state.violated, + }), + stateKey: canonicalMonitorStateKey, + }; +} From c5cfd6fe8c6034ce8d1815643f08bb58096e2eae Mon Sep 17 00:00:00 2001 From: dbera Date: Fri, 7 Aug 2026 12:46:50 +0200 Subject: [PATCH 08/23] Add Declare existence monitors --- .../graph/declareExistenceMonitors.test.ts | 154 +++++++++++ .../src/graph/declareExistenceMonitors.ts | 253 ++++++++++++++++++ 2 files changed, 407 insertions(+) create mode 100644 frontend/src/graph/declareExistenceMonitors.test.ts create mode 100644 frontend/src/graph/declareExistenceMonitors.ts diff --git a/frontend/src/graph/declareExistenceMonitors.test.ts b/frontend/src/graph/declareExistenceMonitors.test.ts new file mode 100644 index 0000000..1f5e1b0 --- /dev/null +++ b/frontend/src/graph/declareExistenceMonitors.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; + +import type { DeclarePredicateGroup } from "./declareConstraints"; +import type { DeclareMonitor } from "./declareMonitor"; +import type { DeclareTransition } from "./declarePredicates"; +import { + createCoexistenceMonitor, + createNotCoexistenceMonitor, + createNotRespondedExistenceMonitor, + createRespondedExistenceMonitor, +} from "./declareExistenceMonitors"; + +const group = (name: string): DeclarePredicateGroup => ({ + relation: "or", + predicates: [ + { transition: { operator: "equals", value: name } }, + ], +}); + +function run( + monitor: DeclareMonitor, + edges: DeclareTransition[], +): State { + return edges.reduce( + (state, edge) => monitor.advance(state, edge), + monitor.initialState(), + ); +} + +function status( + monitor: DeclareMonitor, + edges: DeclareTransition[], +) { + return monitor.status(run(monitor, edges)); +} + +const correlatedActivation: DeclarePredicateGroup = { + relation: "or", + predicates: [ + { + transition: { operator: "equals", value: "A" }, + captures: [ + { alias: "request_id", source: "inputs", path: ["id"] }, + ], + }, + ], +}; + +const correlation = { + type: "comparison" as const, + left: { + kind: "target" as const, + source: "outputs" as const, + path: ["id"], + }, + operator: "=" as const, + right: { kind: "activation" as const, alias: "request_id" }, +}; + +const A = (id: number): DeclareTransition => ({ + transition: "A", + inputs: { id }, +}); +const B = (id: number): DeclareTransition => ({ + transition: "B", + outputs: { id }, +}); +const X: DeclareTransition = { transition: "X" }; + +describe("Responded existence", () => { + it("is vacuously satisfied without an activation", () => { + const monitor = createRespondedExistenceMonitor(group("A"), group("B")); + expect(status(monitor, [X])).toEqual({ viable: true, accepting: true }); + }); + + it("allows the target before or after the activation", () => { + const monitor = createRespondedExistenceMonitor(group("A"), group("B")); + expect(status(monitor, [{ transition: "A" }, { transition: "B" }]).accepting).toBe(true); + expect(status(monitor, [{ transition: "B" }, { transition: "A" }]).accepting).toBe(true); + expect(status(monitor, [{ transition: "A" }])).toEqual({ + viable: true, + accepting: false, + }); + }); + + it("correlates targets before and after each activation", () => { + const monitor = createRespondedExistenceMonitor( + correlatedActivation, + group("B"), + correlation, + ); + expect(status(monitor, [B(10), A(10)]).accepting).toBe(true); + expect(status(monitor, [B(20), A(10)])).toEqual({ + viable: true, + accepting: false, + }); + expect(status(monitor, [A(10), A(20), B(10), B(20)]).accepting).toBe(true); + }); +}); + +describe("Not responded existence", () => { + it("forbids a matching pair in either order", () => { + const monitor = createNotRespondedExistenceMonitor( + correlatedActivation, + group("B"), + correlation, + ); + expect(status(monitor, [A(10)])).toEqual({ viable: true, accepting: true }); + expect(status(monitor, [B(10)])).toEqual({ viable: true, accepting: true }); + expect(status(monitor, [A(10), B(10)])).toEqual({ viable: false, accepting: false }); + expect(status(monitor, [B(10), A(10)])).toEqual({ viable: false, accepting: false }); + expect(status(monitor, [A(10), B(20)])).toEqual({ viable: true, accepting: true }); + }); +}); + +describe("Coexistence", () => { + it("requires both sides or neither side", () => { + const monitor = createCoexistenceMonitor(group("A"), group("B")); + expect(status(monitor, [])).toEqual({ viable: true, accepting: true }); + expect(status(monitor, [X])).toEqual({ viable: true, accepting: true }); + expect(status(monitor, [{ transition: "A" }])).toEqual({ viable: true, accepting: false }); + expect(status(monitor, [{ transition: "B" }])).toEqual({ viable: true, accepting: false }); + expect(status(monitor, [{ transition: "A" }, { transition: "B" }]).accepting).toBe(true); + expect(status(monitor, [{ transition: "B" }, { transition: "A" }]).accepting).toBe(true); + }); + + it("requires correlated counterparts for every activation and target", () => { + const monitor = createCoexistenceMonitor( + correlatedActivation, + group("B"), + correlation, + ); + expect(status(monitor, [A(10), B(10)]).accepting).toBe(true); + expect(status(monitor, [B(10), A(10)]).accepting).toBe(true); + expect(status(monitor, [A(10), B(20)])).toEqual({ viable: true, accepting: false }); + expect(status(monitor, [A(10), A(20), B(10)])).toEqual({ viable: true, accepting: false }); + expect(status(monitor, [A(10), B(10), B(20)])).toEqual({ viable: true, accepting: false }); + expect(status(monitor, [A(10), A(20), B(10), B(20)]).accepting).toBe(true); + }); +}); + +describe("Not coexistence", () => { + it("accepts either side alone and forbids a correlated pair", () => { + const monitor = createNotCoexistenceMonitor( + correlatedActivation, + group("B"), + correlation, + ); + expect(status(monitor, [A(10)])).toEqual({ viable: true, accepting: true }); + expect(status(monitor, [B(10)])).toEqual({ viable: true, accepting: true }); + expect(status(monitor, [A(10), B(20)])).toEqual({ viable: true, accepting: true }); + expect(status(monitor, [A(10), B(10)])).toEqual({ viable: false, accepting: false }); + }); +}); diff --git a/frontend/src/graph/declareExistenceMonitors.ts b/frontend/src/graph/declareExistenceMonitors.ts new file mode 100644 index 0000000..a05f6b6 --- /dev/null +++ b/frontend/src/graph/declareExistenceMonitors.ts @@ -0,0 +1,253 @@ +import type { DeclarePredicateGroup } from "./declareConstraints"; +import { + canonicalMonitorStateKey, + type DeclareMonitor, +} from "./declareMonitor"; +import { + evaluateDeclarePredicateGroup, + type DeclareTransition, +} from "./declarePredicates"; +import { + evaluateCorrelationCondition, + type ActivationBindings, + type CorrelationCondition, +} from "./transitionCorrelation"; + +type SeenActivation = { + bindings: ActivationBindings; +}; + +export type RespondedExistenceState = { + activations: SeenActivation[]; + pendingActivations: SeenActivation[]; + seenTargets: DeclareTransition[]; + violated: boolean; +}; + +export type CoexistenceState = RespondedExistenceState & { + unmatchedTargets: DeclareTransition[]; +}; + +function groupMatches( + group: DeclarePredicateGroup, + edge: DeclareTransition, +): boolean { + return evaluateDeclarePredicateGroup(group, edge).matches; +} + +function activationMatches( + activation: DeclarePredicateGroup, + edge: DeclareTransition, +): SeenActivation[] { + const evaluation = evaluateDeclarePredicateGroup(activation, edge); + if (!evaluation.matches) { + return []; + } + + return evaluation.predicateMatches.map((match) => ({ + bindings: match.bindings, + })); +} + +function correlates( + correlation: CorrelationCondition | undefined, + activation: SeenActivation, + targetEdge: DeclareTransition, +): boolean { + return correlation + ? evaluateCorrelationCondition( + correlation, + targetEdge, + activation.bindings, + ).matches + : true; +} + +function anyCorrelatedTarget( + targets: readonly DeclareTransition[], + activation: SeenActivation, + correlation: CorrelationCondition | undefined, +): boolean { + return targets.some((target) => correlates(correlation, activation, target)); +} + +function anyCorrelatedActivation( + activations: readonly SeenActivation[], + target: DeclareTransition, + correlation: CorrelationCondition | undefined, +): boolean { + return activations.some((activation) => + correlates(correlation, activation, target), + ); +} + +export function createRespondedExistenceMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return { + initialState: () => ({ + activations: [], + pendingActivations: [], + seenTargets: [], + violated: false, + }), + advance: (state, edge) => { + if (state.violated) { + return state; + } + + const targetOccurs = groupMatches(target, edge); + const seenTargets = targetOccurs + ? [...state.seenTargets, edge] + : state.seenTargets; + const existingPending = targetOccurs + ? state.pendingActivations.filter( + (candidate) => !correlates(correlation, candidate, edge), + ) + : state.pendingActivations; + const newActivations = activationMatches(activation, edge); + const newPending = newActivations.filter( + (candidate) => + !anyCorrelatedTarget(seenTargets, candidate, correlation), + ); + + return { + activations: [...state.activations, ...newActivations], + pendingActivations: [...existingPending, ...newPending], + seenTargets, + violated: false, + }; + }, + status: (state) => ({ + viable: !state.violated, + accepting: + !state.violated && state.pendingActivations.length === 0, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +export function createNotRespondedExistenceMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return { + initialState: () => ({ + activations: [], + pendingActivations: [], + seenTargets: [], + violated: false, + }), + advance: (state, edge) => { + if (state.violated) { + return state; + } + + const targetOccurs = groupMatches(target, edge); + const newActivations = activationMatches(activation, edge); + const targetViolates = + targetOccurs && + anyCorrelatedActivation(state.activations, edge, correlation); + const activationViolates = newActivations.some((candidate) => + anyCorrelatedTarget(state.seenTargets, candidate, correlation), + ); + + return { + activations: [...state.activations, ...newActivations], + pendingActivations: [], + seenTargets: targetOccurs + ? [...state.seenTargets, edge] + : state.seenTargets, + violated: targetViolates || activationViolates, + }; + }, + status: (state) => ({ + viable: !state.violated, + accepting: !state.violated, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +export function createCoexistenceMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return { + initialState: () => ({ + activations: [], + pendingActivations: [], + seenTargets: [], + unmatchedTargets: [], + violated: false, + }), + advance: (state, edge) => { + if (state.violated) { + return state; + } + + const targetOccurs = groupMatches(target, edge); + const newActivations = activationMatches(activation, edge); + const allActivations = [...state.activations, ...newActivations]; + const allTargets = targetOccurs + ? [...state.seenTargets, edge] + : state.seenTargets; + + const pendingActivations = [ + ...state.pendingActivations.filter( + (candidate) => + !targetOccurs || !correlates(correlation, candidate, edge), + ), + ...newActivations.filter( + (candidate) => + !anyCorrelatedTarget(allTargets, candidate, correlation), + ), + ]; + + const unmatchedTargets = [ + ...state.unmatchedTargets.filter( + (candidate) => + !newActivations.some((newActivation) => + correlates(correlation, newActivation, candidate), + ), + ), + ...(targetOccurs && + !anyCorrelatedActivation(allActivations, edge, correlation) + ? [edge] + : []), + ]; + + return { + activations: allActivations, + pendingActivations, + seenTargets: allTargets, + unmatchedTargets, + violated: false, + }; + }, + status: (state) => ({ + viable: !state.violated, + accepting: + !state.violated && + state.pendingActivations.length === 0 && + state.unmatchedTargets.length === 0, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +export function createNotCoexistenceMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return createNotRespondedExistenceMonitor( + activation, + target, + correlation, + ); +} From 5c41ed0bf7e32e15b40780bdd9a51c666aa63dd5 Mon Sep 17 00:00:00 2001 From: dbera Date: Fri, 7 Aug 2026 12:49:38 +0200 Subject: [PATCH 09/23] Add Declare succession monitors --- .../graph/declareSuccessionMonitors.test.ts | 196 ++++++++++++++++++ .../src/graph/declareSuccessionMonitors.ts | 141 +++++++++++++ 2 files changed, 337 insertions(+) create mode 100644 frontend/src/graph/declareSuccessionMonitors.test.ts create mode 100644 frontend/src/graph/declareSuccessionMonitors.ts diff --git a/frontend/src/graph/declareSuccessionMonitors.test.ts b/frontend/src/graph/declareSuccessionMonitors.test.ts new file mode 100644 index 0000000..2289e5b --- /dev/null +++ b/frontend/src/graph/declareSuccessionMonitors.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from "vitest"; + +import type { DeclarePredicateGroup } from "./declareConstraints"; +import type { DeclareMonitor } from "./declareMonitor"; +import type { DeclareTransition } from "./declarePredicates"; +import { + createAlternateSuccessionMonitor, + createChainSuccessionMonitor, + createNotAlternateSuccessionMonitor, + createNotChainSuccessionMonitor, + createNotSuccessionMonitor, + createSuccessionMonitor, +} from "./declareSuccessionMonitors"; + +const group = (name: string): DeclarePredicateGroup => ({ + relation: "or", + predicates: [ + { transition: { operator: "equals", value: name } }, + ], +}); + +function run( + monitor: DeclareMonitor, + edges: DeclareTransition[], +): State { + return edges.reduce( + (state, edge) => monitor.advance(state, edge), + monitor.initialState(), + ); +} + +function status( + monitor: DeclareMonitor, + edges: DeclareTransition[], +) { + return monitor.status(run(monitor, edges)); +} + +const correlatedActivation: DeclarePredicateGroup = { + relation: "or", + predicates: [ + { + transition: { operator: "equals", value: "A" }, + captures: [ + { alias: "request_id", source: "inputs", path: ["id"] }, + ], + }, + ], +}; + +const correlation = { + type: "comparison" as const, + left: { + kind: "target" as const, + source: "outputs" as const, + path: ["id"], + }, + operator: "=" as const, + right: { kind: "activation" as const, alias: "request_id" }, +}; + +const A = (id: number): DeclareTransition => ({ + transition: "A", + inputs: { id }, +}); +const B = (id: number): DeclareTransition => ({ + transition: "B", + outputs: { id }, +}); +const X: DeclareTransition = { transition: "X" }; +const C: DeclareTransition = { transition: "C" }; + +describe("Succession", () => { + it("combines response and precedence semantics", () => { + const monitor = createSuccessionMonitor(group("A"), group("B")); + expect(status(monitor, [])).toEqual({ viable: true, accepting: true }); + expect(status(monitor, [{ transition: "A" }, { transition: "B" }])).toEqual({ + viable: true, + accepting: true, + }); + expect(status(monitor, [{ transition: "A" }])).toEqual({ + viable: true, + accepting: false, + }); + expect(status(monitor, [{ transition: "B" }])).toEqual({ + viable: false, + accepting: false, + }); + }); + + it("requires correlation in both directions", () => { + const monitor = createSuccessionMonitor( + correlatedActivation, + group("B"), + correlation, + ); + expect(status(monitor, [A(10), B(10)]).accepting).toBe(true); + expect(status(monitor, [A(10), B(20)])).toEqual({ + viable: false, + accepting: false, + }); + }); +}); + +describe("Not succession", () => { + it("forbids a target after an activation", () => { + const monitor = createNotSuccessionMonitor(group("A"), group("B")); + expect(status(monitor, [{ transition: "A" }]).accepting).toBe(true); + expect(status(monitor, [{ transition: "B" }]).accepting).toBe(true); + expect(status(monitor, [{ transition: "B" }, { transition: "A" }]).accepting).toBe(true); + expect(status(monitor, [{ transition: "A" }, { transition: "B" }])).toEqual({ + viable: false, + accepting: false, + }); + }); +}); + +describe("Chain succession", () => { + it("requires every A-B relationship to be immediate in both directions", () => { + const monitor = createChainSuccessionMonitor(group("A"), group("B")); + expect(status(monitor, [{ transition: "A" }, { transition: "B" }])).toEqual({ + viable: true, + accepting: true, + }); + expect(status(monitor, [{ transition: "A" }, X, { transition: "B" }])).toEqual({ + viable: false, + accepting: false, + }); + expect(status(monitor, [{ transition: "B" }])).toEqual({ + viable: false, + accepting: false, + }); + }); + + it("implements negative chain succession", () => { + const monitor = createNotChainSuccessionMonitor(group("A"), group("B")); + expect(status(monitor, [{ transition: "A" }, X, { transition: "B" }]).accepting).toBe(true); + expect(status(monitor, [{ transition: "A" }, { transition: "B" }])).toEqual({ + viable: false, + accepting: false, + }); + }); +}); + +describe("Alternate succession", () => { + it("combines alternate response and alternate precedence", () => { + const monitor = createAlternateSuccessionMonitor( + group("A"), + group("B"), + group("C"), + ); + expect(status(monitor, [{ transition: "A" }, X, { transition: "B" }])).toEqual({ + viable: true, + accepting: true, + }); + expect(status(monitor, [{ transition: "A" }, { transition: "A" }])).toEqual({ + viable: false, + accepting: false, + }); + expect(status(monitor, [{ transition: "A" }, { transition: "B" }, { transition: "B" }])).toEqual({ + viable: false, + accepting: false, + }); + expect(status(monitor, [{ transition: "A" }, C, { transition: "B" }])).toEqual({ + viable: false, + accepting: false, + }); + }); + + it("implements specialized negative alternate succession", () => { + const monitor = createNotAlternateSuccessionMonitor( + group("A"), + group("B"), + group("C"), + ); + expect(status(monitor, [{ transition: "A" }, C, { transition: "B" }])).toEqual({ + viable: false, + accepting: false, + }); + expect(status(monitor, [{ transition: "A" }, X, { transition: "B" }]).accepting).toBe(true); + }); + + it("preserves correlation in the composed alternate monitors", () => { + const monitor = createAlternateSuccessionMonitor( + correlatedActivation, + group("B"), + group("C"), + correlation, + ); + expect(status(monitor, [A(10), B(10)]).accepting).toBe(true); + expect(status(monitor, [A(10), B(20)])).toEqual({ + viable: false, + accepting: false, + }); + }); +}); diff --git a/frontend/src/graph/declareSuccessionMonitors.ts b/frontend/src/graph/declareSuccessionMonitors.ts new file mode 100644 index 0000000..6fe55b7 --- /dev/null +++ b/frontend/src/graph/declareSuccessionMonitors.ts @@ -0,0 +1,141 @@ +import type { DeclarePredicateGroup } from "./declareConstraints"; +import { + canonicalMonitorStateKey, + type DeclareMonitor, +} from "./declareMonitor"; +import type { DeclareTransition } from "./declarePredicates"; +import { + createAlternatePrecedenceMonitor, + createChainPrecedenceMonitor, + createNotAlternatePrecedenceMonitor, + createNotChainPrecedenceMonitor, + createNotPrecedenceMonitor, + createPrecedenceMonitor, +} from "./declarePrecedenceMonitors"; +import { + createAlternateResponseMonitor, + createChainResponseMonitor, + createNotAlternateResponseMonitor, + createNotChainResponseMonitor, + createNotResponseMonitor, + createResponseMonitor, +} from "./declareResponseMonitors"; +import type { CorrelationCondition } from "./transitionCorrelation"; + +export type CompositeMonitorState = { + left: unknown; + right: unknown; +}; + +function composeMonitors( + left: DeclareMonitor, + right: DeclareMonitor, +): DeclareMonitor { + return { + initialState: () => ({ + left: left.initialState(), + right: right.initialState(), + }), + advance: (state, edge: DeclareTransition) => ({ + left: left.advance(state.left, edge), + right: right.advance(state.right, edge), + }), + status: (state) => { + const leftStatus = left.status(state.left); + const rightStatus = right.status(state.right); + return { + viable: leftStatus.viable && rightStatus.viable, + accepting: leftStatus.accepting && rightStatus.accepting, + }; + }, + stateKey: canonicalMonitorStateKey, + }; +} + +export function createSuccessionMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return composeMonitors( + createResponseMonitor(activation, target, correlation), + createPrecedenceMonitor(activation, target, correlation), + ); +} + +export function createNotSuccessionMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return composeMonitors( + createNotResponseMonitor(activation, target, correlation), + createNotPrecedenceMonitor(activation, target, correlation), + ); +} + +export function createChainSuccessionMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return composeMonitors( + createChainResponseMonitor(activation, target, correlation), + createChainPrecedenceMonitor(activation, target, correlation), + ); +} + +export function createNotChainSuccessionMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return composeMonitors( + createNotChainResponseMonitor(activation, target, correlation), + createNotChainPrecedenceMonitor(activation, target, correlation), + ); +} + +export function createAlternateSuccessionMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + between: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return composeMonitors( + createAlternateResponseMonitor( + activation, + target, + between, + correlation, + ), + createAlternatePrecedenceMonitor( + activation, + target, + between, + correlation, + ), + ); +} + +export function createNotAlternateSuccessionMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + allowedBetween: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return composeMonitors( + createNotAlternateResponseMonitor( + activation, + target, + allowedBetween, + correlation, + ), + createNotAlternatePrecedenceMonitor( + activation, + target, + allowedBetween, + correlation, + ), + ); +} From 1c56ffc5cbd9e56b3abb2a7d296fd038ea7464f5 Mon Sep 17 00:00:00 2001 From: dbera Date: Fri, 7 Aug 2026 13:03:49 +0200 Subject: [PATCH 10/23] Add Declare monitor factory --- .../src/graph/declareMonitorFactory.test.ts | 233 +++++++++++++++++ frontend/src/graph/declareMonitorFactory.ts | 247 ++++++++++++++++++ 2 files changed, 480 insertions(+) create mode 100644 frontend/src/graph/declareMonitorFactory.test.ts create mode 100644 frontend/src/graph/declareMonitorFactory.ts diff --git a/frontend/src/graph/declareMonitorFactory.test.ts b/frontend/src/graph/declareMonitorFactory.test.ts new file mode 100644 index 0000000..6008a94 --- /dev/null +++ b/frontend/src/graph/declareMonitorFactory.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it } from "vitest"; + +import { + DECLARE_TEMPLATE_DEFINITIONS, + type DeclareConstraint, + type DeclarePredicateGroup, + type DeclareTemplateId, +} from "./declareConstraints"; +import { + compileDeclareConstraints, + createDeclareMonitor, + validateExecutableDeclareConstraint, +} from "./declareMonitorFactory"; + +const group = (name: string): DeclarePredicateGroup => ({ + relation: "or", + predicates: [ + { transition: { operator: "equals", value: name } }, + ], +}); + +function constraintFor(template: DeclareTemplateId): DeclareConstraint { + const definition = DECLARE_TEMPLATE_DEFINITIONS.find( + (candidate) => candidate.id === template, + ); + if (!definition) { + throw new Error(`Missing definition for ${template}.`); + } + + return { + id: `constraint-${template}`, + template, + enabled: true, + activation: group("A"), + target: definition.requiredRoles.includes("target") + ? group("B") + : undefined, + between: definition.requiredRoles.includes("between") + ? group("C") + : undefined, + count: definition.supportsCount ? 1 : undefined, + }; +} + +describe("createDeclareMonitor", () => { + it("creates an executable monitor for every registered template", () => { + for (const definition of DECLARE_TEMPLATE_DEFINITIONS) { + const monitor = createDeclareMonitor(constraintFor(definition.id)); + expect(monitor.initialState()).toBeDefined(); + expect(typeof monitor.advance).toBe("function"); + expect(typeof monitor.status).toBe("function"); + expect(typeof monitor.stateKey).toBe("function"); + } + }); + + it("creates a working cardinality monitor", () => { + const monitor = createDeclareMonitor({ + id: "at-least-one-a", + template: "at-least", + enabled: true, + activation: group("A"), + count: 1, + }); + const initial = monitor.initialState(); + const next = monitor.advance(initial, { transition: "A" }); + + expect(monitor.status(initial).accepting).toBe(false); + expect(monitor.status(next).accepting).toBe(true); + }); + + it("creates a working correlated response monitor", () => { + const monitor = createDeclareMonitor({ + id: "same-request-response", + template: "response", + enabled: true, + activation: { + relation: "or", + predicates: [ + { + transition: { operator: "equals", value: "A" }, + captures: [ + { alias: "request_id", source: "inputs", path: ["id"] }, + ], + }, + ], + }, + target: group("B"), + correlation: { + type: "comparison", + left: { + kind: "target", + source: "outputs", + path: ["id"], + }, + operator: "=", + right: { kind: "activation", alias: "request_id" }, + }, + }); + + const activated = monitor.advance(monitor.initialState(), { + transition: "A", + inputs: { id: 42 }, + }); + const wrongTarget = monitor.advance(activated, { + transition: "B", + outputs: { id: 57 }, + }); + const rightTarget = monitor.advance(wrongTarget, { + transition: "B", + outputs: { id: 42 }, + }); + + expect(monitor.status(activated).accepting).toBe(false); + expect(monitor.status(wrongTarget).accepting).toBe(false); + expect(monitor.status(rightTarget).accepting).toBe(true); + }); + + it("rejects unknown correlation aliases before execution", () => { + const constraint: DeclareConstraint = { + id: "bad-correlation", + template: "response", + enabled: true, + activation: group("A"), + target: group("B"), + correlation: { + type: "comparison", + left: { kind: "target", source: "outputs", path: ["id"] }, + operator: "=", + right: { kind: "activation", alias: "missing" }, + }, + }; + + expect(validateExecutableDeclareConstraint(constraint)).toContain( + "correlation.right references unknown activation variable $missing.", + ); + expect(() => createDeclareMonitor(constraint)).toThrow( + "Declare constraint bad-correlation is invalid", + ); + }); + + it("validates duplicate aliases across activation predicates", () => { + const constraint: DeclareConstraint = { + id: "duplicate-alias", + template: "response", + enabled: true, + activation: { + relation: "or", + predicates: [ + { + transition: { operator: "equals", value: "A1" }, + captures: [{ alias: "id", source: "inputs", path: ["id"] }], + }, + { + transition: { operator: "equals", value: "A2" }, + captures: [{ alias: "id", source: "inputs", path: ["id"] }], + }, + ], + }, + target: group("B"), + }; + + expect(validateExecutableDeclareConstraint(constraint)).toContain( + "Duplicate capture alias: $id.", + ); + }); +}); + +describe("compileDeclareConstraints", () => { + it("compiles only enabled constraints and preserves order", () => { + const result = compileDeclareConstraints([ + { + id: "first", + template: "at-least", + enabled: true, + activation: group("A"), + count: 1, + }, + { + id: "disabled", + template: "at-most", + enabled: false, + activation: group("B"), + count: 1, + }, + { + id: "second", + template: "init", + enabled: true, + activation: group("A"), + }, + ]); + + expect(result.map((entry) => entry.id)).toEqual(["first", "second"]); + }); + + it("rejects duplicate enabled IDs", () => { + expect(() => + compileDeclareConstraints([ + { + id: "duplicate", + template: "init", + enabled: true, + activation: group("A"), + }, + { + id: "duplicate", + template: "end", + enabled: true, + activation: group("B"), + }, + ]), + ).toThrow("Duplicate enabled Declare constraint ID: duplicate."); + }); + + it("allows a disabled constraint to share an enabled ID", () => { + expect( + compileDeclareConstraints([ + { + id: "same", + template: "init", + enabled: true, + activation: group("A"), + }, + { + id: "same", + template: "end", + enabled: false, + activation: group("B"), + }, + ]), + ).toHaveLength(1); + }); +}); diff --git a/frontend/src/graph/declareMonitorFactory.ts b/frontend/src/graph/declareMonitorFactory.ts new file mode 100644 index 0000000..0cb56c6 --- /dev/null +++ b/frontend/src/graph/declareMonitorFactory.ts @@ -0,0 +1,247 @@ +import { + createAtLeastMonitor, + createAtMostMonitor, + createChoiceMonitor, + createEndMonitor, + createExactlyConsecutiveMonitor, + createExactlyMonitor, + createExclusiveChoiceMonitor, + createInitMonitor, +} from "./declareBasicMonitors"; +import { + validateDeclareConstraint, + type DeclareConstraint, + type DeclarePredicateGroup, +} from "./declareConstraints"; +import { + createCoexistenceMonitor, + createNotCoexistenceMonitor, + createNotRespondedExistenceMonitor, + createRespondedExistenceMonitor, +} from "./declareExistenceMonitors"; +import type { DeclareMonitor } from "./declareMonitor"; +import { + createAlternatePrecedenceMonitor, + createChainPrecedenceMonitor, + createNotAlternatePrecedenceMonitor, + createNotChainPrecedenceMonitor, + createNotPrecedenceMonitor, + createPrecedenceMonitor, +} from "./declarePrecedenceMonitors"; +import { + createAlternateResponseMonitor, + createChainResponseMonitor, + createNotAlternateResponseMonitor, + createNotChainResponseMonitor, + createNotResponseMonitor, + createResponseMonitor, +} from "./declareResponseMonitors"; +import { + createAlternateSuccessionMonitor, + createChainSuccessionMonitor, + createNotAlternateSuccessionMonitor, + createNotChainSuccessionMonitor, + createNotSuccessionMonitor, + createSuccessionMonitor, +} from "./declareSuccessionMonitors"; +import { + validateCaptureDefinitions, + validateCorrelationCondition, +} from "./transitionCorrelation"; + +export type CompiledDeclareConstraint = { + id: string; + monitor: DeclareMonitor; +}; + +function requireGroup( + constraint: DeclareConstraint, + role: "activation" | "target" | "between", +): DeclarePredicateGroup { + const group = constraint[role]; + if (!group) { + throw new Error(`${role} is required.`); + } + return group; +} + +function collectActivationAliases(constraint: DeclareConstraint): string[] { + return ( + constraint.activation?.predicates.flatMap((predicate) => + (predicate.captures ?? []).map((capture) => capture.alias), + ) ?? [] + ); +} + +export function validateExecutableDeclareConstraint( + constraint: DeclareConstraint, +): string[] { + const errors = [...validateDeclareConstraint(constraint)]; + const captures = + constraint.activation?.predicates.flatMap( + (predicate) => predicate.captures ?? [], + ) ?? []; + errors.push(...validateCaptureDefinitions(captures)); + + if (constraint.correlation) { + errors.push( + ...validateCorrelationCondition( + constraint.correlation, + collectActivationAliases(constraint), + ), + ); + } + + return [...new Set(errors)]; +} + +export function createDeclareMonitor( + constraint: DeclareConstraint, +): DeclareMonitor { + const errors = validateExecutableDeclareConstraint(constraint); + if (errors.length > 0) { + throw new Error( + `Declare constraint ${constraint.id || ""} is invalid:\n${errors + .map((error) => `- ${error}`) + .join("\n")}`, + ); + } + + const activation = requireGroup(constraint, "activation"); + const target = () => requireGroup(constraint, "target"); + const between = () => requireGroup(constraint, "between"); + const count = () => constraint.count ?? 0; + const correlation = constraint.correlation; + + switch (constraint.template) { + case "at-least": + return createAtLeastMonitor(activation, count()); + case "at-most": + return createAtMostMonitor(activation, count()); + case "exactly": + return createExactlyMonitor(activation, count()); + case "exactly-consecutive": + return createExactlyConsecutiveMonitor(activation, count()); + case "init": + return createInitMonitor(activation); + case "end": + return createEndMonitor(activation); + case "choice": + return createChoiceMonitor(activation, target()); + case "exclusive-choice": + return createExclusiveChoiceMonitor(activation, target()); + case "responded-existence": + return createRespondedExistenceMonitor( + activation, + target(), + correlation, + ); + case "not-responded-existence": + return createNotRespondedExistenceMonitor( + activation, + target(), + correlation, + ); + case "coexistence": + return createCoexistenceMonitor(activation, target(), correlation); + case "not-coexistence": + return createNotCoexistenceMonitor(activation, target(), correlation); + case "response": + return createResponseMonitor(activation, target(), correlation); + case "not-response": + return createNotResponseMonitor(activation, target(), correlation); + case "chain-response": + return createChainResponseMonitor(activation, target(), correlation); + case "not-chain-response": + return createNotChainResponseMonitor( + activation, + target(), + correlation, + ); + case "alternate-response": + return createAlternateResponseMonitor( + activation, + target(), + between(), + correlation, + ); + case "not-alternate-response": + return createNotAlternateResponseMonitor( + activation, + target(), + between(), + correlation, + ); + case "precedence": + return createPrecedenceMonitor(activation, target(), correlation); + case "not-precedence": + return createNotPrecedenceMonitor(activation, target(), correlation); + case "chain-precedence": + return createChainPrecedenceMonitor(activation, target(), correlation); + case "not-chain-precedence": + return createNotChainPrecedenceMonitor( + activation, + target(), + correlation, + ); + case "alternate-precedence": + return createAlternatePrecedenceMonitor( + activation, + target(), + between(), + correlation, + ); + case "not-alternate-precedence": + return createNotAlternatePrecedenceMonitor( + activation, + target(), + between(), + correlation, + ); + case "succession": + return createSuccessionMonitor(activation, target(), correlation); + case "not-succession": + return createNotSuccessionMonitor(activation, target(), correlation); + case "chain-succession": + return createChainSuccessionMonitor(activation, target(), correlation); + case "not-chain-succession": + return createNotChainSuccessionMonitor( + activation, + target(), + correlation, + ); + case "alternate-succession": + return createAlternateSuccessionMonitor( + activation, + target(), + between(), + correlation, + ); + case "not-alternate-succession": + return createNotAlternateSuccessionMonitor( + activation, + target(), + between(), + correlation, + ); + } +} + +export function compileDeclareConstraints( + constraints: readonly DeclareConstraint[], +): CompiledDeclareConstraint[] { + const enabled = constraints.filter((constraint) => constraint.enabled); + const seenIds = new Set(); + + enabled.forEach((constraint) => { + if (seenIds.has(constraint.id)) { + throw new Error(`Duplicate enabled Declare constraint ID: ${constraint.id}.`); + } + seenIds.add(constraint.id); + }); + + return enabled.map((constraint) => ({ + id: constraint.id, + monitor: createDeclareMonitor(constraint), + })); +} From 06422d46b1930e04b6a0ec9ffbc520989b57ced4 Mon Sep 17 00:00:00 2001 From: dbera Date: Fri, 7 Aug 2026 13:31:00 +0200 Subject: [PATCH 11/23] Integrate Declare constraints with path search --- frontend/src/graph/pathSearch.test.ts | 114 ++++++++++++++++++++++---- frontend/src/graph/pathSearch.ts | 100 ++++++++++------------ 2 files changed, 141 insertions(+), 73 deletions(-) diff --git a/frontend/src/graph/pathSearch.test.ts b/frontend/src/graph/pathSearch.test.ts index 9f437db..eeef5ec 100644 --- a/frontend/src/graph/pathSearch.test.ts +++ b/frontend/src/graph/pathSearch.test.ts @@ -329,32 +329,116 @@ describe("findKShortestBoundedPaths", () => { expect(result.stopReason).toBe("cancelled"); }); - it("accepts an empty future constraint model", () => { + it("accepts an empty Declare constraint list", () => { const result = findKShortestBoundedPaths( searchInput( ["A", "B"], [{ id: "ab", source: "A", target: "B" }], - { constraints: { requiredTransitions: [] } }, + { constraints: { declare: [] } }, ), ); - expect(result.paths.map((path) => path.edgeIds)).toEqual([["ab"]]); }); - it("rejects non-empty transition constraints until matching is implemented", () => { - expect(() => - findKShortestBoundedPaths( - searchInput( - ["A", "B"], - [{ id: "ab", source: "A", target: "B" }], - { - constraints: { - requiredTransitions: [{ transition: "Approve" }], - }, + it("filters paths using a Declare Init constraint", () => { + const result = findKShortestBoundedPaths( + searchInput( + ["source", "left", "right", "target"], + [ + { id: "bad-start", source: "source", target: "left", transition: "X" }, + { id: "bad-end", source: "left", target: "target", transition: "B" }, + { id: "good-start", source: "source", target: "right", transition: "A" }, + { id: "good-end", source: "right", target: "target", transition: "B" }, + ], + { + requestedPathCount: 2, + constraints: { + declare: [{ + id: "starts-with-a", + template: "init", + enabled: true, + activation: { + relation: "or", + predicates: [{ transition: { operator: "equals", value: "A" } }], + }, + }], }, - ), + }, ), - ).toThrow("Transition constraints are not implemented yet."); + ); + expect(result.paths.map((path) => path.edgeIds)).toEqual([ + ["good-start", "good-end"], + ]); + }); + + it("continues beyond an early target while Response is pending", () => { + const result = findKShortestBoundedPaths( + searchInput( + ["source", "target", "middle"], + [ + { id: "activate", source: "source", target: "target", transition: "A" }, + { id: "fulfil", source: "target", target: "middle", transition: "B" }, + { id: "return", source: "middle", target: "target", transition: "X" }, + ], + { + targetNodeId: "target", + requestedPathCount: 1, + maximumVisitsPerState: 2, + constraints: { + declare: [{ + id: "a-responded-by-b", + template: "response", + enabled: true, + activation: { relation: "or", predicates: [{ transition: { operator: "equals", value: "A" } }] }, + target: { relation: "or", predicates: [{ transition: { operator: "equals", value: "B" } }] }, + }], + }, + }, + ), + ); + expect(result.paths.map((path) => path.edgeIds)).toEqual([ + ["activate", "fulfil", "return"], + ]); + }); + + it("uses transition data for correlated Response search", () => { + const result = findKShortestBoundedPaths( + searchInput( + ["source", "a", "wrong", "target"], + [ + { id: "submit", source: "source", target: "a", transition: "Submit", inputs: { id: 42 } }, + { id: "wrong", source: "a", target: "wrong", transition: "Complete", outputs: { id: 57 } }, + { id: "right", source: "wrong", target: "target", transition: "Complete", outputs: { id: 42 } }, + ], + { + requestedPathCount: 1, + constraints: { + declare: [{ + id: "same-request", + template: "response", + enabled: true, + activation: { + relation: "or", + predicates: [{ + transition: { operator: "equals", value: "Submit" }, + captures: [{ alias: "request_id", source: "inputs", path: ["id"] }], + }], + }, + target: { relation: "or", predicates: [{ transition: { operator: "equals", value: "Complete" } }] }, + correlation: { + type: "comparison", + left: { kind: "target", source: "outputs", path: ["id"] }, + operator: "=", + right: { kind: "activation", alias: "request_id" }, + }, + }], + }, + }, + ), + ); + expect(result.paths.map((path) => path.edgeIds)).toEqual([ + ["submit", "wrong", "right"], + ]); }); it("rejects invalid search parameters", () => { diff --git a/frontend/src/graph/pathSearch.ts b/frontend/src/graph/pathSearch.ts index 3ae9f73..7c5f43b 100644 --- a/frontend/src/graph/pathSearch.ts +++ b/frontend/src/graph/pathSearch.ts @@ -1,3 +1,12 @@ +import type { DeclareConstraint } from "./declareConstraints"; +import { + advanceMonitorSet, + createMonitorSet, + getMonitorSetStatus, + type MonitorSetEntry, +} from "./declareMonitor"; +import { compileDeclareConstraints } from "./declareMonitorFactory"; + export type PathSearchEdge = { id: string; source: string; @@ -7,21 +16,8 @@ export type PathSearchEdge = { outputs?: unknown; }; -/** - * Reserved for ordered transition constraints in a future release. - * - * The current search accepts only an empty requiredTransitions array. Keeping - * the constraint model in the public API now avoids changing the search input - * and worker protocol when transition-name and partial-data matching are added. - */ -export type TransitionPattern = { - transition?: string; - inputs?: unknown; - outputs?: unknown; -}; - export type PathConstraints = { - requiredTransitions?: TransitionPattern[]; + declare?: DeclareConstraint[]; }; export type PathSearchInput = { @@ -67,17 +63,13 @@ type NormalizedTopology = { incomingNodeIdsByNodeId: Map; }; -type ConstraintProgress = { - nextRequiredTransitionIndex: number; -}; - type SearchCandidate = { currentNodeId: string; parent: SearchCandidate | null; incomingEdgeId: string | null; depth: number; estimatedTotalCost: number; - constraintProgress: ConstraintProgress; + monitorEntries: MonitorSetEntry[]; insertionSequence: number; }; @@ -206,12 +198,6 @@ function buildTopology(input: PathSearchInput): NormalizedTopology { throw new Error("Maximum visits per state must be a positive integer."); } - const requiredTransitions = input.constraints?.requiredTransitions ?? []; - - if (requiredTransitions.length > 0) { - throw new Error("Transition constraints are not implemented yet."); - } - const outgoingEdgesByNodeId = new Map(); const incomingNodeIdsByNodeId = new Map(); @@ -296,16 +282,6 @@ function validateResourceLimit( return value; } -function advanceConstraintProgress( - progress: ConstraintProgress, - _edge: PathSearchEdge, - _constraints: PathConstraints | undefined, -): ConstraintProgress | null { - // Future implementation: match the next required transition pattern against - // name, inputs, outputs, and other transition data using partial matching. - return progress; -} - function countNodeVisits( candidate: SearchCandidate, nodeId: string, @@ -369,6 +345,11 @@ export function findKShortestBoundedPaths( "Maximum queued candidates", ); + const compiledConstraints = compileDeclareConstraints( + input.constraints?.declare ?? [], + ); + const initialMonitorEntries = createMonitorSet(compiledConstraints); + const queue = new CandidateMinHeap(); let nextInsertionSequence = 1; let expandedCandidateCount = 0; @@ -383,9 +364,7 @@ export function findKShortestBoundedPaths( depth: 0, estimatedTotalCost: distancesToTarget.get(input.sourceNodeId) ?? Number.POSITIVE_INFINITY, - constraintProgress: { - nextRequiredTransitionIndex: 0, - }, + monitorEntries: initialMonitorEntries, insertionSequence: 0, }); @@ -415,22 +394,28 @@ export function findKShortestBoundedPaths( input.sourceNodeId === input.targetNodeId; if (candidate.currentNodeId === input.targetNodeId) { - const edgeIds = reconstructEdgeIds(candidate); - const pathKey = createPathKey(edgeIds); - - if (!emittedPathKeys.has(pathKey)) { - emittedPathKeys.add(pathKey); - paths.push({ - startNodeId: input.sourceNodeId, - edgeIds, - }); - } - - // The initial source-equals-target candidate must still be expanded so - // non-empty returning paths can be found. All other target arrivals end. - if (!isZeroTransitionSourceTargetPath) { - continue; + const monitorStatus = getMonitorSetStatus(candidate.monitorEntries); + + if (monitorStatus.accepting) { + const edgeIds = reconstructEdgeIds(candidate); + const pathKey = createPathKey(edgeIds); + + if (!emittedPathKeys.has(pathKey)) { + emittedPathKeys.add(pathKey); + paths.push({ + startNodeId: input.sourceNodeId, + edgeIds, + }); + } + + // The initial source-equals-target candidate must still be expanded so + // non-empty returning paths can be found. Other accepted arrivals end. + if (!isZeroTransitionSourceTargetPath) { + continue; + } } + // A target arrival with pending obligations remains expandable because a + // later transition may satisfy the constraints before returning here. } const outgoingEdges = @@ -454,13 +439,12 @@ export function findKShortestBoundedPaths( continue; } - const nextConstraintProgress = advanceConstraintProgress( - candidate.constraintProgress, + const nextMonitorEntries = advanceMonitorSet( + candidate.monitorEntries, edge, - input.constraints, ); - if (nextConstraintProgress === null) { + if (!getMonitorSetStatus(nextMonitorEntries).viable) { continue; } @@ -470,7 +454,7 @@ export function findKShortestBoundedPaths( incomingEdgeId: edge.id, depth: candidate.depth + 1, estimatedTotalCost: candidate.depth + 1 + remainingDistance, - constraintProgress: nextConstraintProgress, + monitorEntries: nextMonitorEntries, insertionSequence: nextInsertionSequence, }); nextInsertionSequence += 1; From def7384efe4df969055ee805fe4e0a9615d34f8f Mon Sep 17 00:00:00 2001 From: dbera Date: Fri, 7 Aug 2026 13:41:20 +0200 Subject: [PATCH 12/23] Pass transition data to path search --- frontend/src/App.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5f9423f..476e302 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -780,6 +780,9 @@ function App() { id: edge.id, source: edge.source, target: edge.target, + transition: edge.transition, + inputs: edge.inputs, + outputs: edge.outputs, })), sourceNodeId, targetNodeId, From c864a10b6876fa632972635ce5640dec731e898a Mon Sep 17 00:00:00 2001 From: dbera Date: Fri, 7 Aug 2026 15:35:12 +0200 Subject: [PATCH 13/23] Add Declare constraint builder --- frontend/src/App.css | 18 ++ frontend/src/App.tsx | 23 +- .../src/graph/DeclareConstraintBuilder.tsx | 253 ++++++++++++++++++ frontend/src/graph/pathSearch.test.ts | 64 +++++ frontend/src/graph/pathSearch.ts | 4 +- 5 files changed, 360 insertions(+), 2 deletions(-) create mode 100644 frontend/src/graph/DeclareConstraintBuilder.tsx diff --git a/frontend/src/App.css b/frontend/src/App.css index 1257f6d..9a76918 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -712,3 +712,21 @@ button.active { text-decoration: underline; } + + +.declare-builder { display: grid; gap: 10px; margin-top: 8px; padding-top: 12px; border-top: 1px solid #e2e8f0; } +.declare-builder-heading, .declare-constraint-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 10px; } +.declare-builder-heading > div { display: grid; gap: 2px; } +.declare-builder-heading strong { font-size: 13px; } +.declare-builder-heading span, .declare-empty, .declare-description { color: #64748b; font-size: 11px; } +.declare-builder-heading button, .declare-constraint-toolbar button { padding: 6px 8px; font-size: 11px; } +.declare-empty { margin: 0; padding: 9px; border: 1px dashed #cbd5e1; border-radius: 7px; } +.declare-constraint-list { display: grid; gap: 9px; } +.declare-constraint-card { display: grid; gap: 8px; padding: 10px; border: 1px solid #cbd5e1; border-radius: 8px; background: #f8fafc; } +.declare-constraint-card.invalid { border-color: #fca5a5; background: #fff7f7; } +.declare-constraint-card > label { display: grid; gap: 5px; color: #475569; font-size: 11px; font-weight: 700; } +.declare-constraint-card select, .declare-constraint-card input:not([type="checkbox"]) { width: 100%; min-width: 0; padding: 7px 8px; border: 1px solid #cbd5e1; border-radius: 6px; background: #fff; } +.declare-constraint-toolbar label { display: inline-flex; align-items: center; gap: 6px; color: #334155; font-size: 11px; font-weight: 700; } +.declare-constraint-toolbar input { width: auto; } +.declare-description { margin: 0; line-height: 1.4; } +.declare-errors { margin: 0; padding-left: 18px; color: #b91c1c; font-size: 10px; line-height: 1.4; } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 476e302..e094e69 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,9 @@ import { import cytoscape from "cytoscape"; import "./App.css"; import JsonViewer, { type JsonValue } from "./components/JsonViewer"; +import DeclareConstraintBuilder from "./graph/DeclareConstraintBuilder"; +import type { DeclareConstraint } from "./graph/declareConstraints"; +import { validateExecutableDeclareConstraint } from "./graph/declareMonitorFactory"; import PathSelectionControls, { type PathSelectionMode, } from "./components/PathSelectionControls"; @@ -116,6 +119,7 @@ function App() { const [pathSearchTarget, setPathSearchTarget] = useState(""); const [requestedPathCount, setRequestedPathCount] = useState(5); const [maximumVisitsPerState, setMaximumVisitsPerState] = useState(1); + const [declareConstraints, setDeclareConstraints] = useState([]); const [shownSearchPathIndex, setShownSearchPathIndex] = useState(null); const [computedPathViewActive, setComputedPathViewActive] = useState(false); const [focusedComputedStepKey, setFocusedComputedStepKey] = useState(null); @@ -773,6 +777,17 @@ function App() { setStatus(`Target state ${targetNodeId || "(empty)"} was not found`); return; } + const constraintErrors = declareConstraints + .filter((constraint) => constraint.enabled) + .flatMap((constraint) => + validateExecutableDeclareConstraint(constraint).map( + (error) => `${constraint.id}: ${error}`, + ), + ); + if (constraintErrors.length > 0) { + setStatus(`Fix the Declare constraints: ${constraintErrors.join(" ")}`); + return; + } setShownSearchPathIndex(null); pathSearch.run({ nodeIds: graph.nodes.map((node) => node.id), @@ -788,7 +803,7 @@ function App() { targetNodeId, requestedPathCount, maximumVisitsPerState, - constraints: {}, + constraints: { declare: declareConstraints }, }); setStatus(`Searching for up to ${requestedPathCount} paths from ${sourceNodeId} to ${targetNodeId}`); } @@ -898,6 +913,7 @@ function App() { graphAnalysis.reset(); pathSearch.reset(); + setDeclareConstraints([]); setShownSearchPathIndex(null); setFocusedComputedStepKey(null); setComputedPathViewActive(false); @@ -2031,6 +2047,11 @@ function App() { setMaximumVisitsPerState(Math.max(1, Number.parseInt(event.target.value, 10) || 1))} disabled={pathSearch.status === "running"} /> +

A visit limit of 1 produces loopless paths. Higher values allow bounded revisits. Paths are unique by ordered edge IDs.

{pathSearch.status === "running" ? ( diff --git a/frontend/src/graph/DeclareConstraintBuilder.tsx b/frontend/src/graph/DeclareConstraintBuilder.tsx new file mode 100644 index 0000000..1921e6b --- /dev/null +++ b/frontend/src/graph/DeclareConstraintBuilder.tsx @@ -0,0 +1,253 @@ +import { + DECLARE_TEMPLATE_DEFINITIONS, + getDeclareTemplateDefinition, + type DeclareConstraint, + type DeclarePredicateGroup, + type DeclarePredicateRole, + type DeclareTemplateId, +} from "./declareConstraints"; +import { validateExecutableDeclareConstraint } from "./declareMonitorFactory"; + +type Props = { + constraints: DeclareConstraint[]; + disabled: boolean; + onChange: (constraints: DeclareConstraint[]) => void; +}; + +function group(value = ""): DeclarePredicateGroup { + return { + relation: "or", + predicates: [{ transition: { operator: "equals", value } }], + }; +} + +function transitionValue( + constraint: DeclareConstraint, + role: DeclarePredicateRole, +): string { + return constraint[role]?.predicates[0]?.transition?.value ?? ""; +} + +export default function DeclareConstraintBuilder({ + constraints, + disabled, + onChange, +}: Props) { + function update( + id: string, + change: (constraint: DeclareConstraint) => DeclareConstraint, + ) { + onChange( + constraints.map((constraint) => + constraint.id === id ? change(constraint) : constraint, + ), + ); + } + + function add() { + let sequence = constraints.length + 1; + while (constraints.some((constraint) => constraint.id === `constraint-${sequence}`)) { + sequence += 1; + } + onChange([ + ...constraints, + { + id: `constraint-${sequence}`, + template: "response", + enabled: true, + activation: group(), + target: group(), + }, + ]); + } + + function changeTemplate(id: string, template: DeclareTemplateId) { + update(id, (constraint) => { + const definition = getDeclareTemplateDefinition(template); + return { + id: constraint.id, + template, + enabled: constraint.enabled, + activation: constraint.activation ?? group(), + target: definition.requiredRoles.includes("target") + ? constraint.target ?? group() + : undefined, + between: definition.requiredRoles.includes("between") + ? constraint.between ?? group() + : undefined, + count: definition.supportsCount ? constraint.count ?? 1 : undefined, + }; + }); + } + + function changeTransition( + id: string, + role: DeclarePredicateRole, + value: string, + ) { + update(id, (constraint) => ({ ...constraint, [role]: group(value) })); + } + + return ( +
+
+
+ Declare constraints + {constraints.length} configured +
+ +
+ {constraints.length === 0 ? ( +

+ No constraints. All bounded paths are eligible. +

+ ) : ( +
+ {constraints.map((constraint) => { + const definition = getDeclareTemplateDefinition(constraint.template); + const errors = validateExecutableDeclareConstraint(constraint); + return ( +
0 + ? "declare-constraint-card invalid" + : "declare-constraint-card" + } + > +
+ + +
+ + + {definition.requiredRoles.includes("target") && ( + + )} + {definition.requiredRoles.includes("between") && ( + + )} + {definition.supportsCount && ( + + )} +

{definition.description}

+ {constraint.enabled && errors.length > 0 && ( +
    + {errors.map((error) => ( +
  • {error}
  • + ))} +
+ )} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/frontend/src/graph/pathSearch.test.ts b/frontend/src/graph/pathSearch.test.ts index eeef5ec..b7348e2 100644 --- a/frontend/src/graph/pathSearch.test.ts +++ b/frontend/src/graph/pathSearch.test.ts @@ -38,6 +38,70 @@ describe("findKShortestBoundedPaths", () => { expect(result.exhausted).toBe(true); }); + it("rejects a real Thales-style path that violates Chain Response", () => { + const result = findKShortestBoundedPaths({ + nodeIds: ["0", "1", "2", "10"], + edges: [ + { + id: "edge-0", + source: "0", + target: "1", + transition: "RootSystemUser_SubmitLoginAttempt", + }, + { + id: "edge-1", + source: "1", + target: "2", + transition: "RootProtectedApplication_InvalidLogin", + }, + { + id: "edge-2", + source: "2", + target: "10", + transition: "RootSystemUser_ReceiveAuthenticalResult", + }, + ], + sourceNodeId: "0", + targetNodeId: "10", + requestedPathCount: 5, + maximumVisitsPerState: 1, + constraints: { + declare: [ + { + id: "login-chain-response", + template: "chain-response", + enabled: true, + activation: { + relation: "or", + predicates: [ + { + transition: { + operator: "equals", + value: "RootSystemUser_SubmitLoginAttempt", + }, + }, + ], + }, + target: { + relation: "or", + predicates: [ + { + transition: { + operator: "equals", + value: "RootSystemUser_ReceiveAuthenticalResult", + }, + }, + ], + }, + }, + ], + }, + }); + + expect(result.paths).toEqual([]); + expect(result.stopReason).toBe("exhausted"); + }); + it("orders paths by increasing transition count", () => { const result = findKShortestBoundedPaths( searchInput( diff --git a/frontend/src/graph/pathSearch.ts b/frontend/src/graph/pathSearch.ts index 7c5f43b..d1d3c3e 100644 --- a/frontend/src/graph/pathSearch.ts +++ b/frontend/src/graph/pathSearch.ts @@ -444,7 +444,9 @@ export function findKShortestBoundedPaths( edge, ); - if (!getMonitorSetStatus(nextMonitorEntries).viable) { + const nextMonitorStatus = getMonitorSetStatus(nextMonitorEntries); + + if (!nextMonitorStatus.viable) { continue; } From 6df53adf1df760c36312addde7678eb914cc3c83 Mon Sep 17 00:00:00 2001 From: dbera Date: Fri, 7 Aug 2026 16:00:02 +0200 Subject: [PATCH 14/23] Add graph-aware transition picker --- frontend/src/App.css | 61 ++++++++ frontend/src/App.tsx | 29 +++- .../src/graph/DeclareConstraintBuilder.tsx | 74 ++++------ frontend/src/graph/TransitionPicker.tsx | 130 ++++++++++++++++++ frontend/src/graph/transitionCatalog.test.ts | 32 +++++ frontend/src/graph/transitionCatalog.ts | 49 +++++++ 6 files changed, 326 insertions(+), 49 deletions(-) create mode 100644 frontend/src/graph/TransitionPicker.tsx create mode 100644 frontend/src/graph/transitionCatalog.test.ts create mode 100644 frontend/src/graph/transitionCatalog.ts diff --git a/frontend/src/App.css b/frontend/src/App.css index 9a76918..6850282 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -730,3 +730,64 @@ button.active { .declare-constraint-toolbar input { width: auto; } .declare-description { margin: 0; line-height: 1.4; } .declare-errors { margin: 0; padding-left: 18px; color: #b91c1c; font-size: 10px; line-height: 1.4; } + +.transition-picker-label { + position: relative; + z-index: 1; +} +.transition-picker-label:focus-within { + z-index: 5; +} +.transition-picker { + position: relative; + display: block; +} +.transition-picker-list { + position: absolute; + z-index: 20; + top: calc(100% + 4px); + right: 0; + left: 0; + display: grid; + max-height: 260px; + overflow-y: auto; + border: 1px solid #cbd5e1; + border-radius: 7px; + background: #ffffff; + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.16); +} +.transition-picker-list button { + display: grid; + gap: 2px; + width: 100%; + padding: 8px 9px; + border: 0; + border-bottom: 1px solid #e2e8f0; + border-radius: 0; + text-align: left; +} +.transition-picker-list button:last-child { + border-bottom: 0; +} +.transition-picker-list button:hover, +.transition-picker-list button.active { + background: #eff6ff; + color: #1d4ed8; +} +.transition-picker-list button span { + overflow-wrap: anywhere; + font-size: 11px; + font-weight: 700; +} +.transition-picker-list button small { + color: #64748b; + font-size: 9px; + font-weight: 600; +} +.transition-picker-empty { + padding: 10px; + color: #64748b; + font-size: 10px; + font-weight: 500; + line-height: 1.4; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e094e69..26a66f6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -36,6 +36,7 @@ import { useGraphAnalysis } from "./graph/useGraphAnalysis"; import { usePathSearch } from "./graph/usePathSearch"; import type { BoundedPath } from "./graph/pathSearch"; import type { StronglyConnectedComponent } from "./graph/graphAnalysis"; +import { buildTransitionCatalogue } from "./graph/transitionCatalog"; interface GraphNode { id: string; @@ -145,6 +146,20 @@ function App() { setSelectedPath(path); } + function invalidatePathSearchResults() { + if (pathSearch.status !== "not-run") { + pathSearch.reset(); + setShownSearchPathIndex(null); + setFocusedComputedStepKey(null); + setComputedPathViewActive(false); + } + } + + function changeDeclareConstraints(constraints: DeclareConstraint[]) { + invalidatePathSearchResults(); + setDeclareConstraints(constraints); + } + function makeNodeInspector(node: cytoscape.NodeSingular): InspectorInfo { const marking = node.data("marking") as JsonValue | null | undefined; @@ -1270,6 +1285,9 @@ function App() { return () => observer.disconnect(); }, []); + const transitionOptions = buildTransitionCatalogue( + graphRef.current?.edges.map((edge) => edge.transition) ?? [], + ); const visibleInspector = pinnedInspector ?? inspectorInfo; const selectedPathEdges = selectedPath?.edgeIds ?? []; const selectedPathEndNodeId = (() => { @@ -2034,23 +2052,24 @@ function App() { <>
- setPathSearchSource(event.target.value)} disabled={pathSearch.status === "running"} /> + { invalidatePathSearchResults(); setPathSearchSource(event.target.value); }} disabled={pathSearch.status === "running"} /> - setPathSearchTarget(event.target.value)} disabled={pathSearch.status === "running"} /> + { invalidatePathSearchResults(); setPathSearchTarget(event.target.value); }} disabled={pathSearch.status === "running"} />
- setRequestedPathCount(Math.max(1, Number.parseInt(event.target.value, 10) || 1))} disabled={pathSearch.status === "running"} /> + { invalidatePathSearchResults(); setRequestedPathCount(Math.max(1, Number.parseInt(event.target.value, 10) || 1)); }} disabled={pathSearch.status === "running"} />
- setMaximumVisitsPerState(Math.max(1, Number.parseInt(event.target.value, 10) || 1))} disabled={pathSearch.status === "running"} /> + { invalidatePathSearchResults(); setMaximumVisitsPerState(Math.max(1, Number.parseInt(event.target.value, 10) || 1)); }} disabled={pathSearch.status === "running"} />

A visit limit of 1 produces loopless paths. Higher values allow bounded revisits. Paths are unique by ordered edge IDs.

{pathSearch.status === "running" ? ( diff --git a/frontend/src/graph/DeclareConstraintBuilder.tsx b/frontend/src/graph/DeclareConstraintBuilder.tsx index 1921e6b..61128fe 100644 --- a/frontend/src/graph/DeclareConstraintBuilder.tsx +++ b/frontend/src/graph/DeclareConstraintBuilder.tsx @@ -7,10 +7,13 @@ import { type DeclareTemplateId, } from "./declareConstraints"; import { validateExecutableDeclareConstraint } from "./declareMonitorFactory"; +import TransitionPicker from "./TransitionPicker"; +import type { TransitionOption } from "./transitionCatalog"; type Props = { constraints: DeclareConstraint[]; disabled: boolean; + transitionOptions: readonly TransitionOption[]; onChange: (constraints: DeclareConstraint[]) => void; }; @@ -31,6 +34,7 @@ function transitionValue( export default function DeclareConstraintBuilder({ constraints, disabled, + transitionOptions, onChange, }: Props) { function update( @@ -165,54 +169,36 @@ export default function DeclareConstraintBuilder({ ))} - - {definition.requiredRoles.includes("target") && ( - )} {definition.requiredRoles.includes("between") && ( - + + changeTransition(constraint.id, "between", value) + } + /> )} {definition.supportsCount && (