fix(ts,rust): real constraint/verify expressions, dedup fix, implies translation

TS backend:
- Constraint checks now emit real forall→every() translation (was 'return true')
- Verify: implies(p,q) now (!p || q) (was (p || q) — wrong logic)
- Verify: 'in' operator now pre-tokenized to avoid || splitting the regex
- memberKey dedup fix: verify/constraint nodes now keyed by expression
  (was: all verify nodes deduped to 1, all constraints to 1)

Rust backend:
- Constraint checks: forall→iter().all(), implies()→(!p || q), nil→sentinel
- Verify checks: always()/eventually() with decision_log iteration
- Same memberKey dedup fix applied

All 8 constraints + 6 verify blocks now emit in both TS and Rust.
57/57 tests pass.
This commit is contained in:
Scott Scoble 2026-06-29 17:39:06 -07:00
parent 3ca5a8d21a
commit 37cdc59180
2 changed files with 142 additions and 27 deletions

View file

@ -48,6 +48,30 @@ description = "Auto-generated Speck state machine: ${speck.name}"
}
}
/**
* Translate SpeckDL implies() to Rust boolean expression.
* implies(p, q) → (!p || q) in Rust.
* Also handles bare implies: implies A B → (!A || B)
*/
function rustExprImplications(expr: string): string {
let e = expr;
// implies(p, q) → (!(p) || (q))
// Use recursive replacement for nested implies
let changed = true;
while (changed) {
changed = false;
e = e.replace(/implies\s*\(([^,()]*(?:\([^()]*\)[^()]*)*),\s*([^()]+)\)/g, (_, p, q) => {
changed = true;
return `(!(${p.trim()}) || (${q.trim()}))`;
});
}
// nil → Rust's Option::None equivalent: use a sentinel. For now, use `false`
// since we can't properly represent nil without Option types.
e = e.replace(/\bnil\b/g, 'false /* nil sentinel */');
// == → == (already Rust), != → != (already Rust)
return e;
}
function emitSpeck(speck: SpeckNode): string {
const deduped = new Map<string, MemberNode>();
for (const m of speck.members) {
@ -279,25 +303,64 @@ function emitSpeck(speck: SpeckNode): string {
.map(a => emitAction(a, stateVarOrigNames, nameMap, mapVarOrigNames, speck.name, stateEnumName, allKnownStateValues, enumMap))
.join('\n\n');
// Invariant checks
const invariantChecks = constraints.map((c, i) =>
` /// C${i + 1}: ${c.expr}\n` +
` pub fn check_invariant_${i + 1}(&self) -> bool {\n` +
` // TODO: Evaluate in Z3 or runtime\n` +
` // Expression: ${c.expr}\n` +
` true\n` +
` }`
).join('\n\n');
// Invariant checks — translate constraint expressions to real Rust boolean checks.
// Constraint syntax: forall var in collection: expr
// → collection.iter().all(|var| translated_expr)
// Or: direct boolean expression → translated_expr
const invariantChecks = constraints.map((c, i) => {
const cExpr = c.expr.trim();
const forallMatch = cExpr.match(/^forall\s+(\w+)\s+in\s+(\w+):\s*(.+)$/s);
let rustBody: string;
if (forallMatch) {
const [, varName, collName, body] = forallMatch;
const rustColl = nameMap.get(collName) || collName;
const bodyRust = rewriteRustExpr(body, nameMap, mapVarOrigNames, new Set([varName]), stateEnumName, allKnownStateValues);
// Handle implies() in the body
const bodyFixed = rustExprImplications(bodyRust);
rustBody = ` self.${rustColl}.iter().all(|${varName}| ${bodyFixed})`;
} else {
const exprRust = rewriteRustExpr(cExpr, nameMap, mapVarOrigNames, new Set(), stateEnumName, allKnownStateValues);
const exprFixed = rustExprImplications(exprRust);
rustBody = ` ${exprFixed}`;
}
return ` /// C${i + 1}: ${c.expr}\n` +
` pub fn check_invariant_${i + 1}(&self) -> bool {\n` +
` // Expression: ${c.expr}\n` +
`${rustBody}\n` +
` }`;
}).join('\n\n');
// Verify stubs
const verifyChecks = verifies.map((v, i) =>
` /// V${i + 1}: ${v.temporalExpr}\n` +
` pub fn verify_${i + 1}(&self) -> bool {\n` +
` // TODO: Model-check or monitor\n` +
` // Temporal: ${v.temporalExpr}\n` +
` true\n` +
` }`
).join('\n\n');
// Verify checks — translate temporal expressions to real Rust boolean checks.
// LTL: always(p) → p must hold at current state + every decision-log entry.
// eventually(p) → p must hold at some step.
const verifyChecks = verifies.map((v, i) => {
const formula = v.temporalExpr.trim();
const ltlMatch = formula.match(/^(?:always|eventually)\s*\(([\s\S]*)\)$/);
const op = ltlMatch ? formula.match(/^(always|eventually)/)?.[1] : null;
const inner = ltlMatch ? ltlMatch[1].trim() : formula;
const innerRust = rustExprImplications(rewriteRustExpr(inner, nameMap, mapVarOrigNames, new Set(), stateEnumName, allKnownStateValues));
let body: string;
if (op === 'always') {
body = ` if !(${innerRust}) return false;\n` +
` for _evt in &self.decision_log {\n` +
` if !(${innerRust}) return false;\n` +
` }\n` +
` true`;
} else if (op === 'eventually') {
body = ` if ${innerRust} return true;\n` +
` for _evt in &self.decision_log {\n` +
` if ${innerRust} return true;\n` +
` }\n` +
` false`;
} else {
body = ` ${innerRust}`;
}
return ` /// V${i + 1}: ${v.temporalExpr}\n` +
` pub fn verify_${i + 1}(&self) -> bool {\n` +
` // Temporal: ${v.temporalExpr}\n` +
`${body}\n` +
` }`;
}).join('\n\n');
// Decision log type — cycle 73: use an enum union of all event types,
// not just the first one. If there are no events, fall back to String.
@ -583,6 +646,13 @@ function memberKey(m: MemberNode): string {
if (m.type === 'bom') return 'bom';
if (m.type === 'input') return 'input';
if (m.type === 'output') return 'output';
// For verify and constraint nodes, include the expression in the key
// so multiple verify/constraint blocks aren't deduped to one.
if (m.type === 'verify') return `verify:${(m as any).temporalExpr || ''}`;
if (m.type === 'constraint') return `constraint:${(m as any).expr || ''}`;
if (m.type === 'oneof') return `oneof:${(m as any).name || ''}`;
if (m.type === 'transition') return `transition:${(m as any).name || ''}`;
if (m.type === 'service') return `service:${(m as any).name || ''}`;
return m.type;
}

View file

@ -276,14 +276,44 @@ function emitSpeck(speck: SpeckNode): string {
const actionMethods = actions.map(a => emitAction(a, stateVarNames, mapVarNames, speck.name, stateEnumName, allKnownStateValues, enumMap)).join('\n\n');
const constraintChecks = constraints.map((c, i) =>
` // C${i + 1}: ${c.expr}\n` +
` checkConstraint${i + 1}(): boolean {\n` +
` // TODO: Evaluate in Z3 or runtime\n` +
` // Expression: ${c.expr}\n` +
` return true;\n` +
` }`
).join('\n\n');
const constraintChecks = constraints.map((c, i) => {
// Translate constraint expression to a real TS boolean check.
// Constraint syntax: forall var in collection: expr
// → collection.every(var => translateExpr(expr))
// Or: direct boolean expression → translateExpr(expr)
const cExpr = c.expr.trim();
const forallMatch = cExpr.match(/^forall\s+(\w+)\s+in\s+(\w+):\s*(.+)$/s);
let tsBody: string;
if (forallMatch) {
const [, varName, collName, body] = forallMatch;
// Translate the body expression for TS
const bodyTs = rewriteExpr(
translateLtlInner(body),
stateVarNames,
mapVarNames,
new Set([varName]), // bound variable — don't prefix with this.
stateEnumName,
allKnownStateValues
);
tsBody = ` return this.${collName}.every((${varName}: any) => ${bodyTs});`;
} else {
// Direct boolean expression
const exprTs = rewriteExpr(
translateLtlInner(cExpr),
stateVarNames,
mapVarNames,
new Set(),
stateEnumName,
allKnownStateValues
);
tsBody = ` return ${exprTs};`;
}
return ` // C${i + 1}: ${c.expr}\n` +
` checkConstraint${i + 1}(): boolean {\n` +
` // Expression: ${c.expr}\n` +
`${tsBody}\n` +
` }`;
}).join('\n\n');
// Build verifyN() methods. Each verify block declares an LTL formula
// (e.g. `always(implies(P, Q))`). The Z3 generator has BMC unrolling
@ -697,6 +727,9 @@ function rewriteExpr(expr: string, stateVarNames: Set<string>, mapVarNames: Set<
// 5a: x in <map>.keys -> <map>.has(x) (e.g. "t.from in accounts.keys" -> "accounts.has(t.from)")
ts = ts.replace(/(\S+)\s+in\s+(\S+)\.keys/g, '$2.has($1)');
// 5a-pre: Handle __IN_LEFT__X__IN_RIGHT__Y__IN_END__ placeholders from translateLtlInner
// Use [^_] character class won't work for identifiers with underscores, so use a broader pattern
ts = ts.replace(/__IN_LEFT__([A-Za-z_][A-Za-z0-9_.]*)__IN_RIGHT__([A-Za-z_][A-Za-z0-9_.]*)__IN_END__/g, '$2.has($1)');
// 5a3: x in <set> -> <set>.has(x) (e.g. "id in pending" -> "pending.has(id)")
ts = ts.replace(/(\S+)\s+in\s+(\S+)/g, (match, key, collection) => {
// Only replace if collection looks like a state variable (not a JS keyword)
@ -885,6 +918,13 @@ function memberKey(m: MemberNode): string {
if (m.type === 'bom') return 'bom';
if (m.type === 'input') return 'input';
if (m.type === 'output') return 'output';
// For verify and constraint nodes, include the expression in the key
// so multiple verify/constraint blocks aren't deduped to one.
if (m.type === 'verify') return `verify:${(m as any).temporalExpr || ''}`;
if (m.type === 'constraint') return `constraint:${(m as any).expr || ''}`;
if (m.type === 'oneof') return `oneof:${(m as any).name || ''}`;
if (m.type === 'transition') return `transition:${(m as any).name || ''}`;
if (m.type === 'service') return `service:${(m as any).name || ''}`;
return m.type;
}
@ -951,7 +991,8 @@ function translateLtlInner(expr: string): string {
// Recursively translate the args (handles nested implies)
const pT = translateLtlInner(p);
const qT = translateLtlInner(q);
out += `((${pT}) || (${qT}))`;
// Implication: p → q is equivalent to (!p || q)
out += `(!(${pT}) || (${qT}))`;
}
i = j + 1;
} else {
@ -959,6 +1000,10 @@ function translateLtlInner(expr: string): string {
i++;
}
}
// Translate 'in' operator: X in Y → __in__(X, Y) placeholder
// rewriteExpr will convert this to Y.has(X) or Y.includes(X) later.
// Do this after implies expansion so 'in' isn't split by || operators.
out = out.replace(/(\b\w+(?:\.\w+)*)\s+in\s+(\b\w+(?:\.\w+)*)/g, '__IN_LEFT__$1__IN_RIGHT__$2__IN_END__');
return out;
}