simple-graph-query 2.6.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,71 @@
1
+ import { AbstractParseTreeVisitor } from "antlr4ts/tree/AbstractParseTreeVisitor";
2
+ import { ParseTree } from "antlr4ts/tree/ParseTree";
3
+ import { ForgeVisitor } from "./forge-antlr/ForgeVisitor";
4
+ import { Expr1Context, Expr1_5Context, Expr2Context, Expr3Context, Expr4Context, Expr5Context, Expr6Context, Expr7Context, Expr8Context, Expr9Context, Expr11Context, Expr12Context, Expr14Context, Expr15Context, Expr17Context, Expr18Context, ExprContext, NameContext, BlockContext } from "./forge-antlr/ForgeParser";
5
+ import { IForgeSchema } from "./types";
6
+ type Abstract = {
7
+ kind: "bool";
8
+ value: boolean;
9
+ } | {
10
+ kind: "num";
11
+ value: number;
12
+ } | {
13
+ kind: "empty";
14
+ } | {
15
+ kind: "typed";
16
+ arity: number;
17
+ columnTypes?: readonly string[];
18
+ } | {
19
+ kind: "ill-typed";
20
+ reason: string;
21
+ } | {
22
+ kind: "unknown";
23
+ };
24
+ export type StaticAnalysis = {
25
+ status: "unsat";
26
+ reason: string;
27
+ } | {
28
+ status: "tautology";
29
+ reason: string;
30
+ } | {
31
+ status: "empty";
32
+ reason: string;
33
+ } | {
34
+ status: "ill-typed";
35
+ reason: string;
36
+ } | {
37
+ status: "unknown";
38
+ };
39
+ export declare class ForgeExprStaticAnalyzer extends AbstractParseTreeVisitor<Abstract> implements ForgeVisitor<Abstract> {
40
+ private readonly schema?;
41
+ constructor(schema?: IForgeSchema);
42
+ private collectNamesFromList;
43
+ private collectBoundNames;
44
+ private illTypedIfBindsReservedName;
45
+ analyze(ctx: ParseTree): StaticAnalysis;
46
+ private static arityOf;
47
+ protected defaultResult(): Abstract;
48
+ private static bailIfIllTyped;
49
+ protected aggregateResult(aggregate: Abstract, nextResult: Abstract): Abstract;
50
+ visitExpr(ctx: ExprContext): Abstract;
51
+ private foldQuantifier;
52
+ visitBlock(ctx: BlockContext): Abstract;
53
+ visitExpr1(ctx: Expr1Context): Abstract;
54
+ visitExpr1_5(ctx: Expr1_5Context): Abstract;
55
+ visitExpr2(ctx: Expr2Context): Abstract;
56
+ visitExpr3(ctx: Expr3Context): Abstract;
57
+ visitExpr4(ctx: Expr4Context): Abstract;
58
+ visitExpr5(ctx: Expr5Context): Abstract;
59
+ visitExpr6(ctx: Expr6Context): Abstract;
60
+ visitExpr7(ctx: Expr7Context): Abstract;
61
+ visitExpr8(ctx: Expr8Context): Abstract;
62
+ visitExpr9(ctx: Expr9Context): Abstract;
63
+ visitExpr12(ctx: Expr12Context): Abstract;
64
+ visitExpr11(ctx: Expr11Context): Abstract;
65
+ visitExpr14(ctx: Expr14Context): Abstract;
66
+ visitExpr15(ctx: Expr15Context): Abstract;
67
+ visitExpr17(ctx: Expr17Context): Abstract;
68
+ visitExpr18(ctx: Expr18Context): Abstract;
69
+ visitName(ctx: NameContext): Abstract;
70
+ }
71
+ export {};
package/dist/index.d.ts CHANGED
@@ -1,20 +1,79 @@
1
1
  import { ForgeListenerImpl } from './forge-antlr/ForgeListenerImpl';
2
2
  import { ParseTreeWalker } from 'antlr4ts/tree/ParseTreeWalker';
3
3
  import { EvalResult } from './ForgeExprEvaluator';
4
- import { IDataInstance } from './types';
4
+ import { ForgeExprStaticAnalyzer, StaticAnalysis } from './ForgeExprStaticAnalyzer';
5
+ import { IDataInstance, IForgeSchema } from './types';
5
6
  export type ErrorResult = {
6
7
  error: Error;
7
8
  stackTrace?: string;
8
9
  };
9
10
  export type EvaluationResult = EvalResult | ErrorResult;
11
+ /**
12
+ * Evaluates Forge expressions against an `IDataInstance`.
13
+ *
14
+ * ## Caching contract (important for correctness)
15
+ *
16
+ * For performance, this class caches an inner evaluator (with its relation
17
+ * index and subexpression cache) across `evaluateExpression` calls. The
18
+ * cache is invalidated when the `datum` field is reassigned to a different
19
+ * reference, but **not** when the underlying `IDataInstance` is mutated in
20
+ * place (e.g. adding/removing atoms or tuples on the same object).
21
+ *
22
+ * If you mutate the underlying data in place, you **must** call
23
+ * {@link invalidate} (or reassign `datum` to a new object) before the next
24
+ * `evaluateExpression` call. Otherwise queries may return stale results.
25
+ *
26
+ * Recommended patterns:
27
+ * - Treat `IDataInstance` as immutable; create a new instance on data
28
+ * changes and construct a new `SimpleGraphQueryEvaluator` (or assign to
29
+ * `.datum`).
30
+ * - Or, if you mutate in place, call `invalidate()` after each mutation
31
+ * batch.
32
+ */
10
33
  export declare class SimpleGraphQueryEvaluator {
34
+ /**
35
+ * The data instance evaluated against. Reassigning this field is a
36
+ * supported invalidation signal — the inner evaluator cache is rebuilt
37
+ * on the next `evaluateExpression` call. **In-place mutation of the
38
+ * existing object is NOT detected**; call {@link invalidate} in that
39
+ * case.
40
+ */
11
41
  datum: IDataInstance;
12
42
  forgeListener: ForgeListenerImpl;
13
43
  walker: ParseTreeWalker;
14
44
  private parseTreeCache;
45
+ private cachedEvaluator;
46
+ private cachedEvaluatorDatum;
15
47
  constructor(datum: IDataInstance);
48
+ /**
49
+ * Discard the cached inner evaluator (and its relation index /
50
+ * subexpression cache). Call this after mutating the underlying
51
+ * `IDataInstance` in place, so the next `evaluateExpression` sees the
52
+ * updated data.
53
+ *
54
+ * Cheap: just nulls a couple of references. The caches rebuild lazily
55
+ * on the next query.
56
+ */
57
+ invalidate(): void;
16
58
  getExpressionParseTree(forgeExpr: string): import("./forge-antlr/ForgeParser").ParseExprContext;
17
59
  evaluateExpression(forgeExpr: string): EvaluationResult;
18
60
  }
61
+ /**
62
+ * Run a static analysis on a Forge expression.
63
+ *
64
+ * Returns `unsat` when the expression provably reduces to `false`, `empty`
65
+ * when it provably reduces to the empty set, `tautology` when it provably
66
+ * reduces to `true`, `ill-typed` for static type errors (e.g. arity
67
+ * mismatch), and `unknown` otherwise (including parse errors).
68
+ *
69
+ * When `schema` is provided, the analyzer also uses the type lattice and
70
+ * relation declarations to detect type-disjoint intersections, subtype
71
+ * tautologies in `in`, join column-type mismatches, and arity errors.
72
+ * Disjointness uses a closed-world rule (A ∩ B = ∅ iff no type in the
73
+ * lattice has both A and B in its lineage).
74
+ */
75
+ export declare function analyzeForgeExpression(forgeExpr: string, schema?: IForgeSchema): StaticAnalysis;
76
+ export { ForgeExprStaticAnalyzer, StaticAnalysis };
77
+ export type { IForgeSchema };
19
78
  export { synthesizeSelector, synthesizeBinaryRelation, synthesizeBinaryRelationWithWhy, synthesizeSelectorWithWhy, AtomSelectionExample, BinaryRelationExample, SelectorSynthesisError, SynthesisWhy, SynthesisWhyExample, WhyNode, } from './SelectorSynthesizer';
20
79
  export { getIdentifierName, quoteIfReserved, FORGE_RESERVED_KEYWORDS, } from './forge-antlr/utils';
@@ -50801,6 +50801,963 @@ class ForgeExprFreeVariableFinder extends AbstractParseTreeVisitor_1.AbstractPar
50801
50801
  exports.ForgeExprFreeVariableFinder = ForgeExprFreeVariableFinder;
50802
50802
 
50803
50803
 
50804
+ /***/ }),
50805
+
50806
+ /***/ "./src/ForgeExprStaticAnalyzer.ts":
50807
+ /*!****************************************!*\
50808
+ !*** ./src/ForgeExprStaticAnalyzer.ts ***!
50809
+ \****************************************/
50810
+ /***/ ((__unused_webpack_module, exports, __webpack_require__) => {
50811
+
50812
+ "use strict";
50813
+
50814
+ Object.defineProperty(exports, "__esModule", ({ value: true }));
50815
+ exports.ForgeExprStaticAnalyzer = void 0;
50816
+ const AbstractParseTreeVisitor_1 = __webpack_require__(/*! antlr4ts/tree/AbstractParseTreeVisitor */ "./node_modules/antlr4ts/tree/AbstractParseTreeVisitor.js");
50817
+ const ForgeParser_1 = __webpack_require__(/*! ./forge-antlr/ForgeParser */ "./src/forge-antlr/ForgeParser.ts");
50818
+ const utils_1 = __webpack_require__(/*! ./forge-antlr/utils */ "./src/forge-antlr/utils.ts");
50819
+ const UNKNOWN = { kind: "unknown" };
50820
+ // Helper that wraps the schema with O(1) lookups and lattice queries.
50821
+ class SchemaInfo {
50822
+ constructor(schema) {
50823
+ this.typesById = new Map(schema.getTypes().map((t) => [t.id, t]));
50824
+ this.relationsByName = new Map(schema.getRelations().map((r) => [r.name, r]));
50825
+ }
50826
+ getType(id) { return this.typesById.get(id); }
50827
+ getRelation(name) { return this.relationsByName.get(name); }
50828
+ // A ⊆ B when B is in A's lineage. `IType.types` is the lineage in ascending
50829
+ // order, conventionally starting with the type itself.
50830
+ isSubtypeOf(a, b) {
50831
+ if (a === b)
50832
+ return true;
50833
+ const t = this.typesById.get(a);
50834
+ if (!t)
50835
+ return false;
50836
+ return t.types.includes(b);
50837
+ }
50838
+ // Closed-world disjointness: A and B are disjoint iff no type in the
50839
+ // schema has both A and B in its lineage (i.e., no common subtype exists).
50840
+ // Sound only when the caller treats the supplied schema as complete.
50841
+ areDisjoint(a, b) {
50842
+ if (a === b)
50843
+ return false;
50844
+ for (const t of this.typesById.values()) {
50845
+ if (t.types.includes(a) && t.types.includes(b))
50846
+ return false;
50847
+ }
50848
+ return true;
50849
+ }
50850
+ }
50851
+ // ANTLR's .text concatenates child tokens without whitespace, which gives us
50852
+ // a usable canonical form for "syntactically the same subexpression".
50853
+ function sameSubtree(a, b) {
50854
+ if (!a || !b)
50855
+ return false;
50856
+ return a.text === b.text;
50857
+ }
50858
+ // True iff `ctx` is structurally a difference whose subtrahend (anywhere in a
50859
+ // left-associated minus chain) matches `targetText`. Strips parens and
50860
+ // pass-through wrappers. Examples that match for target="X":
50861
+ // X - X (Y - X) (Y - Z - X) ((Y - X) - Z)
50862
+ function subtractsTarget(ctx, targetText) {
50863
+ if (!ctx)
50864
+ return false;
50865
+ let cur = ctx;
50866
+ while (cur.childCount === 1)
50867
+ cur = cur.getChild(0);
50868
+ if (cur instanceof ForgeParser_1.Expr18Context && cur.LEFT_PAREN_TOK()) {
50869
+ return subtractsTarget(cur.expr(), targetText);
50870
+ }
50871
+ if (cur instanceof ForgeParser_1.Expr8Context && cur.MINUS_TOK()) {
50872
+ if (cur.expr10().text === targetText)
50873
+ return true;
50874
+ // Chain: `A - B - target` parses left-assoc as `((A - B) - target)`;
50875
+ // recurse on the left to catch deeper occurrences.
50876
+ return subtractsTarget(cur.expr8(), targetText);
50877
+ }
50878
+ return false;
50879
+ }
50880
+ // True iff `ctx` is structurally an intersection involving `targetText`
50881
+ // (so `ctx ⊆ target`). Walks through parens, pass-throughs, and chains of `&`.
50882
+ function intersectionInvolves(ctx, targetText) {
50883
+ if (!ctx)
50884
+ return false;
50885
+ let cur = ctx;
50886
+ while (cur.childCount === 1)
50887
+ cur = cur.getChild(0);
50888
+ if (cur instanceof ForgeParser_1.Expr18Context && cur.LEFT_PAREN_TOK()) {
50889
+ return intersectionInvolves(cur.expr(), targetText);
50890
+ }
50891
+ if (cur instanceof ForgeParser_1.Expr11Context && cur.AMP_TOK()) {
50892
+ if (cur.expr11().text === targetText)
50893
+ return true;
50894
+ if (cur.expr12().text === targetText)
50895
+ return true;
50896
+ return (intersectionInvolves(cur.expr11(), targetText) ||
50897
+ intersectionInvolves(cur.expr12(), targetText));
50898
+ }
50899
+ return false;
50900
+ }
50901
+ // True iff `ctx` is structurally a union containing `targetText`
50902
+ // (so `target ⊆ ctx`). Walks through parens, pass-throughs, and chains of `+`.
50903
+ function unionContains(ctx, targetText) {
50904
+ if (!ctx)
50905
+ return false;
50906
+ let cur = ctx;
50907
+ while (cur.childCount === 1)
50908
+ cur = cur.getChild(0);
50909
+ if (cur instanceof ForgeParser_1.Expr18Context && cur.LEFT_PAREN_TOK()) {
50910
+ return unionContains(cur.expr(), targetText);
50911
+ }
50912
+ if (cur instanceof ForgeParser_1.Expr8Context && cur.PLUS_TOK()) {
50913
+ if (cur.expr8().text === targetText)
50914
+ return true;
50915
+ if (cur.expr10().text === targetText)
50916
+ return true;
50917
+ return (unionContains(cur.expr8(), targetText) ||
50918
+ unionContains(cur.expr10(), targetText));
50919
+ }
50920
+ return false;
50921
+ }
50922
+ // Strip wrapping parens / pass-through expr layers to reveal an inner NEG_TOK
50923
+ // at the expr5 level. Returns the operand-text if `ctx` is structurally `not E`,
50924
+ // else undefined.
50925
+ function negationOperandText(ctx) {
50926
+ if (!ctx)
50927
+ return undefined;
50928
+ let cur = ctx;
50929
+ while (cur.childCount === 1) {
50930
+ cur = cur.getChild(0);
50931
+ }
50932
+ if (cur instanceof ForgeParser_1.Expr5Context && cur.NEG_TOK()) {
50933
+ const inner = cur.expr5();
50934
+ return inner ? inner.text : undefined;
50935
+ }
50936
+ if (cur instanceof ForgeParser_1.Expr18Context && cur.LEFT_PAREN_TOK()) {
50937
+ return negationOperandText(cur.expr());
50938
+ }
50939
+ return undefined;
50940
+ }
50941
+ class ForgeExprStaticAnalyzer extends AbstractParseTreeVisitor_1.AbstractParseTreeVisitor {
50942
+ constructor(schema) {
50943
+ super();
50944
+ if (schema)
50945
+ this.schema = new SchemaInfo(schema);
50946
+ }
50947
+ // Walk a nameList (`a, b, c`) and accumulate identifiers into `out`.
50948
+ collectNamesFromList(ctx, out) {
50949
+ out.add((0, utils_1.getIdentifierName)(ctx.name()));
50950
+ const tail = ctx.nameList();
50951
+ if (tail)
50952
+ this.collectNamesFromList(tail, out);
50953
+ }
50954
+ // Walk a quantDeclList (`a : S, b : T`) and accumulate all bound identifiers.
50955
+ collectBoundNames(ctx, out) {
50956
+ this.collectNamesFromList(ctx.quantDecl().nameList(), out);
50957
+ const tail = ctx.quantDeclList();
50958
+ if (tail)
50959
+ this.collectBoundNames(tail, out);
50960
+ }
50961
+ // Policy: when a schema is provided, type and relation names are reserved —
50962
+ // they may only refer to the schema entity. Returns an ill-typed Abstract if
50963
+ // any candidate name collides; undefined otherwise.
50964
+ illTypedIfBindsReservedName(names) {
50965
+ if (!this.schema)
50966
+ return undefined;
50967
+ for (const name of names) {
50968
+ if (this.schema.getType(name) || this.schema.getRelation(name)) {
50969
+ return {
50970
+ kind: "ill-typed",
50971
+ reason: `cannot bind variable named '${name}': it is a reserved schema ` +
50972
+ `${this.schema.getType(name) ? "type" : "relation"} name`,
50973
+ };
50974
+ }
50975
+ }
50976
+ return undefined;
50977
+ }
50978
+ // Public entry: convert internal lattice value to a verdict + reason.
50979
+ analyze(ctx) {
50980
+ const v = this.visit(ctx);
50981
+ if (v.kind === "bool") {
50982
+ return v.value
50983
+ ? { status: "tautology", reason: "expression folds to literal true" }
50984
+ : { status: "unsat", reason: "expression folds to literal false" };
50985
+ }
50986
+ if (v.kind === "empty") {
50987
+ return { status: "empty", reason: "expression is provably the empty set" };
50988
+ }
50989
+ if (v.kind === "ill-typed") {
50990
+ return { status: "ill-typed", reason: v.reason };
50991
+ }
50992
+ return { status: "unknown" };
50993
+ }
50994
+ // Arity of an Abstract when known; -1 means "not determinable".
50995
+ static arityOf(v) {
50996
+ if (v.kind === "typed")
50997
+ return v.arity;
50998
+ if (v.kind === "bool" || v.kind === "num")
50999
+ return 1; // singleton sets
51000
+ if (v.kind === "empty")
51001
+ return -1; // empty has no committed arity
51002
+ return -1;
51003
+ }
51004
+ defaultResult() {
51005
+ return UNKNOWN;
51006
+ }
51007
+ // Return the first ill-typed value among the args, or undefined. Used by
51008
+ // every operator handler to surface a statically malformed sub-expression
51009
+ // before any fold (including short-circuits like `false AND ?` and `true OR
51010
+ // ?`) could mask it.
51011
+ static bailIfIllTyped(...vals) {
51012
+ for (const v of vals) {
51013
+ if (v.kind === "ill-typed")
51014
+ return v;
51015
+ }
51016
+ return undefined;
51017
+ }
51018
+ // Prefer the more specific child result when aggregating across siblings.
51019
+ // For pass-through expr layers there's exactly one meaningful child; for
51020
+ // wrapper nodes like `( expr )` the terminals contribute UNKNOWN and the
51021
+ // real value comes from the single non-terminal child. Ill-typed always
51022
+ // wins — if any subtree is statically malformed, the parent is too.
51023
+ aggregateResult(aggregate, nextResult) {
51024
+ if (aggregate.kind === "ill-typed")
51025
+ return aggregate;
51026
+ if (nextResult.kind === "ill-typed")
51027
+ return nextResult;
51028
+ if (aggregate.kind === "unknown")
51029
+ return nextResult;
51030
+ return aggregate;
51031
+ }
51032
+ // Let/bind introduce data-dependent bindings; we don't fold through them.
51033
+ // Quantifiers we *do* fold in the cases where the verdict is independent of
51034
+ // the binding: an empty quantified domain, or a body that's a literal.
51035
+ visitExpr(ctx) {
51036
+ if (ctx.LET_TOK() || ctx.BIND_TOK())
51037
+ return UNKNOWN;
51038
+ const quant = ctx.quant();
51039
+ if (quant)
51040
+ return this.foldQuantifier(ctx, quant);
51041
+ return this.visitChildren(ctx);
51042
+ }
51043
+ foldQuantifier(ctx, quant) {
51044
+ const blockOrBar = ctx.blockOrBar();
51045
+ if (!blockOrBar)
51046
+ return UNKNOWN;
51047
+ // Reserved-name check at the binder: under a schema, type/relation names
51048
+ // cannot be reused as quantified variables.
51049
+ const declListTop = ctx.quantDeclList();
51050
+ if (declListTop) {
51051
+ const boundHere = new Set();
51052
+ this.collectBoundNames(declListTop, boundHere);
51053
+ const reservedError = this.illTypedIfBindsReservedName(boundHere);
51054
+ if (reservedError)
51055
+ return reservedError;
51056
+ }
51057
+ // Determine if any quantified domain is statically empty.
51058
+ let domainEmpty = false;
51059
+ let declList = ctx.quantDeclList();
51060
+ while (declList) {
51061
+ const setExpr = declList.quantDecl().expr();
51062
+ const v = this.visit(setExpr);
51063
+ if (v.kind === "ill-typed")
51064
+ return v;
51065
+ if (v.kind === "empty") {
51066
+ domainEmpty = true;
51067
+ break;
51068
+ }
51069
+ const next = declList.quantDeclList();
51070
+ if (!next)
51071
+ break;
51072
+ declList = next;
51073
+ }
51074
+ // Body analysis (only attempt the `| expr` form; blocks are conjunctions
51075
+ // and would need the binding to be meaningful).
51076
+ let body = UNKNOWN;
51077
+ if (blockOrBar.BAR_TOK() && blockOrBar.expr()) {
51078
+ body = this.visit(blockOrBar.expr());
51079
+ }
51080
+ const mult = quant.mult();
51081
+ const isAll = quant.ALL_TOK() !== undefined;
51082
+ const isNo = quant.NO_TOK() !== undefined;
51083
+ const isSome = mult?.SOME_TOK() !== undefined;
51084
+ const isOne = mult?.ONE_TOK() !== undefined;
51085
+ const isTwo = mult?.TWO_TOK() !== undefined;
51086
+ const isLone = mult?.LONE_TOK() !== undefined;
51087
+ if (domainEmpty) {
51088
+ // ∀ over empty is vacuously true; ∃ over empty is false.
51089
+ if (isAll || isNo || isLone)
51090
+ return { kind: "bool", value: true };
51091
+ if (isSome || isOne || isTwo)
51092
+ return { kind: "bool", value: false };
51093
+ }
51094
+ if (body.kind === "bool") {
51095
+ if (!body.value) {
51096
+ // body always false: no x satisfies, regardless of domain
51097
+ if (isNo || isLone)
51098
+ return { kind: "bool", value: true };
51099
+ if (isSome || isOne || isTwo)
51100
+ return { kind: "bool", value: false };
51101
+ // all x | false → data-dependent (vacuous if domain empty, else false)
51102
+ }
51103
+ else {
51104
+ // body always true: every x satisfies
51105
+ if (isAll)
51106
+ return { kind: "bool", value: true };
51107
+ // some/one/no etc. depend on domain size — data-dependent
51108
+ }
51109
+ }
51110
+ return UNKNOWN;
51111
+ }
51112
+ // A block { e1; e2; ...; en } is a conjunction of its expressions.
51113
+ visitBlock(ctx) {
51114
+ let result = { kind: "bool", value: true };
51115
+ for (const e of ctx.expr()) {
51116
+ const v = this.visit(e);
51117
+ if (v.kind === "ill-typed")
51118
+ return v; // ill-typed wins
51119
+ if (v.kind === "bool" && !v.value)
51120
+ return v; // any false → false
51121
+ if (v.kind !== "bool")
51122
+ result = UNKNOWN; // forget the running true
51123
+ }
51124
+ return result;
51125
+ }
51126
+ visitExpr1(ctx) {
51127
+ if (ctx.OR_TOK()) {
51128
+ const leftCtx = ctx.expr1();
51129
+ const rightCtx = ctx.expr1_5();
51130
+ const l = this.visit(leftCtx);
51131
+ const r = this.visit(rightCtx);
51132
+ const bail = ForgeExprStaticAnalyzer.bailIfIllTyped(l, r);
51133
+ if (bail)
51134
+ return bail;
51135
+ // X or X → X
51136
+ if (sameSubtree(leftCtx, rightCtx))
51137
+ return l;
51138
+ if (l.kind === "bool" && l.value)
51139
+ return l;
51140
+ if (r.kind === "bool" && r.value)
51141
+ return r;
51142
+ if (l.kind === "bool" && r.kind === "bool") {
51143
+ return { kind: "bool", value: l.value || r.value };
51144
+ }
51145
+ // X or not X / not X or X → true (classical tautology)
51146
+ const lNeg = negationOperandText(leftCtx);
51147
+ const rNeg = negationOperandText(rightCtx);
51148
+ if (lNeg !== undefined && lNeg === rightCtx.text)
51149
+ return { kind: "bool", value: true };
51150
+ if (rNeg !== undefined && rNeg === leftCtx.text)
51151
+ return { kind: "bool", value: true };
51152
+ return UNKNOWN;
51153
+ }
51154
+ return this.visitChildren(ctx);
51155
+ }
51156
+ visitExpr1_5(ctx) {
51157
+ if (ctx.XOR_TOK()) {
51158
+ const leftCtx = ctx.expr1_5();
51159
+ const rightCtx = ctx.expr2();
51160
+ const l = this.visit(leftCtx);
51161
+ const r = this.visit(rightCtx);
51162
+ const bail = ForgeExprStaticAnalyzer.bailIfIllTyped(l, r);
51163
+ if (bail)
51164
+ return bail;
51165
+ // X xor X → false
51166
+ if (sameSubtree(leftCtx, rightCtx))
51167
+ return { kind: "bool", value: false };
51168
+ if (l.kind === "bool" && r.kind === "bool") {
51169
+ return { kind: "bool", value: l.value !== r.value };
51170
+ }
51171
+ return UNKNOWN;
51172
+ }
51173
+ return this.visitChildren(ctx);
51174
+ }
51175
+ visitExpr2(ctx) {
51176
+ if (ctx.IFF_TOK()) {
51177
+ const leftCtx = ctx.expr2();
51178
+ const rightCtx = ctx.expr3();
51179
+ const l = this.visit(leftCtx);
51180
+ const r = this.visit(rightCtx);
51181
+ const bail = ForgeExprStaticAnalyzer.bailIfIllTyped(l, r);
51182
+ if (bail)
51183
+ return bail;
51184
+ // X iff X → true
51185
+ if (sameSubtree(leftCtx, rightCtx))
51186
+ return { kind: "bool", value: true };
51187
+ // X iff not X / not X iff X → false
51188
+ const lNeg = negationOperandText(leftCtx);
51189
+ const rNeg = negationOperandText(rightCtx);
51190
+ if (lNeg !== undefined && lNeg === rightCtx.text)
51191
+ return { kind: "bool", value: false };
51192
+ if (rNeg !== undefined && rNeg === leftCtx.text)
51193
+ return { kind: "bool", value: false };
51194
+ if (l.kind === "bool" && r.kind === "bool") {
51195
+ return { kind: "bool", value: l.value === r.value };
51196
+ }
51197
+ return UNKNOWN;
51198
+ }
51199
+ return this.visitChildren(ctx);
51200
+ }
51201
+ visitExpr3(ctx) {
51202
+ if (ctx.IMP_TOK()) {
51203
+ const ant = this.visit(ctx.expr4());
51204
+ const exprs3 = ctx.expr3();
51205
+ // Implication or implication-with-else. Visit all branches up front so
51206
+ // an ill-typed sub-expression is reported even when a short-circuit
51207
+ // (false antecedent, etc.) would otherwise fold the verdict.
51208
+ if (ctx.ELSE_TOK()) {
51209
+ const thenBranch = this.visit(exprs3[0]);
51210
+ const elseBranch = this.visit(exprs3[1]);
51211
+ const bail = ForgeExprStaticAnalyzer.bailIfIllTyped(ant, thenBranch, elseBranch);
51212
+ if (bail)
51213
+ return bail;
51214
+ if (ant.kind === "bool")
51215
+ return ant.value ? thenBranch : elseBranch;
51216
+ return UNKNOWN;
51217
+ }
51218
+ const conseqCtx = exprs3[0];
51219
+ const con = this.visit(conseqCtx);
51220
+ const bail = ForgeExprStaticAnalyzer.bailIfIllTyped(ant, con);
51221
+ if (bail)
51222
+ return bail;
51223
+ // antecedent false → vacuously true
51224
+ if (ant.kind === "bool" && !ant.value)
51225
+ return { kind: "bool", value: true };
51226
+ // true => X ≡ X
51227
+ if (ant.kind === "bool" && ant.value)
51228
+ return con;
51229
+ // _ => true ≡ true
51230
+ if (con.kind === "bool" && con.value)
51231
+ return con;
51232
+ return UNKNOWN;
51233
+ }
51234
+ return this.visitChildren(ctx);
51235
+ }
51236
+ visitExpr4(ctx) {
51237
+ if (ctx.AND_TOK()) {
51238
+ const leftCtx = ctx.expr4();
51239
+ const rightCtx = ctx.expr4_5();
51240
+ const l = this.visit(leftCtx);
51241
+ const r = this.visit(rightCtx);
51242
+ const bail = ForgeExprStaticAnalyzer.bailIfIllTyped(l, r);
51243
+ if (bail)
51244
+ return bail;
51245
+ // X and X → X
51246
+ if (sameSubtree(leftCtx, rightCtx))
51247
+ return l;
51248
+ if (l.kind === "bool" && !l.value)
51249
+ return l;
51250
+ if (r.kind === "bool" && !r.value)
51251
+ return r;
51252
+ if (l.kind === "bool" && r.kind === "bool") {
51253
+ return { kind: "bool", value: l.value && r.value };
51254
+ }
51255
+ // X and not X / not X and X → false
51256
+ const lNeg = negationOperandText(leftCtx);
51257
+ const rNeg = negationOperandText(rightCtx);
51258
+ if (lNeg !== undefined && lNeg === rightCtx.text)
51259
+ return { kind: "bool", value: false };
51260
+ if (rNeg !== undefined && rNeg === leftCtx.text)
51261
+ return { kind: "bool", value: false };
51262
+ return UNKNOWN;
51263
+ }
51264
+ return this.visitChildren(ctx);
51265
+ }
51266
+ visitExpr5(ctx) {
51267
+ if (ctx.NEG_TOK()) {
51268
+ const inner = this.visit(ctx.expr5());
51269
+ if (inner.kind === "ill-typed")
51270
+ return inner;
51271
+ if (inner.kind === "bool")
51272
+ return { kind: "bool", value: !inner.value };
51273
+ return UNKNOWN;
51274
+ }
51275
+ // Temporal operators are not implemented; bail out conservatively.
51276
+ if (ctx.ALWAYS_TOK() ||
51277
+ ctx.EVENTUALLY_TOK() ||
51278
+ ctx.AFTER_TOK() ||
51279
+ ctx.BEFORE_TOK() ||
51280
+ ctx.ONCE_TOK() ||
51281
+ ctx.HISTORICALLY_TOK()) {
51282
+ return UNKNOWN;
51283
+ }
51284
+ return this.visitChildren(ctx);
51285
+ }
51286
+ visitExpr6(ctx) {
51287
+ const op = ctx.compareOp();
51288
+ if (!op)
51289
+ return this.visitChildren(ctx);
51290
+ const leftCtx = ctx.expr6();
51291
+ const rightCtx = ctx.expr7();
51292
+ const l = this.visit(leftCtx);
51293
+ const r = this.visit(rightCtx);
51294
+ const bail = ForgeExprStaticAnalyzer.bailIfIllTyped(l, r);
51295
+ if (bail)
51296
+ return bail;
51297
+ const negate = ctx.NEG_TOK() !== undefined;
51298
+ const opText = op.text;
51299
+ // `ni` is non-membership (the codebase's evaluator implements it as
51300
+ // !(in)). We fold it by computing the corresponding `in` verdict and
51301
+ // letting `finalize` invert. This keeps the static verdict in agreement
51302
+ // with runtime semantics for expressions like `X ni X` (false) and
51303
+ // `none ni 1` (true).
51304
+ const isNi = opText === "ni";
51305
+ const opForLogic = isNi ? "in" : opText;
51306
+ const finalize = (value) => {
51307
+ let v = value;
51308
+ if (isNi)
51309
+ v = !v;
51310
+ if (negate)
51311
+ v = !v;
51312
+ return { kind: "bool", value: v };
51313
+ };
51314
+ // Same-subtree shortcuts hold regardless of unknown values.
51315
+ if (sameSubtree(leftCtx, rightCtx)) {
51316
+ switch (opForLogic) {
51317
+ case "=":
51318
+ case "<=":
51319
+ case ">=":
51320
+ case "in":
51321
+ return finalize(true);
51322
+ case "<":
51323
+ case ">":
51324
+ return finalize(false);
51325
+ }
51326
+ }
51327
+ // Numeric literal folding.
51328
+ if (l.kind === "num" && r.kind === "num") {
51329
+ switch (opForLogic) {
51330
+ case "=":
51331
+ return finalize(l.value === r.value);
51332
+ case "<":
51333
+ return finalize(l.value < r.value);
51334
+ case ">":
51335
+ return finalize(l.value > r.value);
51336
+ case "<=":
51337
+ return finalize(l.value <= r.value);
51338
+ case ">=":
51339
+ return finalize(l.value >= r.value);
51340
+ }
51341
+ }
51342
+ // Boolean literal equality.
51343
+ if (l.kind === "bool" && r.kind === "bool" && opForLogic === "=") {
51344
+ return finalize(l.value === r.value);
51345
+ }
51346
+ // Empty / singleton comparisons.
51347
+ const lIsKnownSingleton = l.kind === "num" || l.kind === "bool";
51348
+ const rIsKnownSingleton = r.kind === "num" || r.kind === "bool";
51349
+ if (opForLogic === "=") {
51350
+ if (l.kind === "empty" && r.kind === "empty")
51351
+ return finalize(true);
51352
+ if (l.kind === "empty" && rIsKnownSingleton)
51353
+ return finalize(false);
51354
+ if (lIsKnownSingleton && r.kind === "empty")
51355
+ return finalize(false);
51356
+ }
51357
+ if (opForLogic === "in") {
51358
+ // empty set is a subset of every set
51359
+ if (l.kind === "empty")
51360
+ return finalize(true);
51361
+ // a non-empty singleton cannot be contained in the empty set
51362
+ if (lIsKnownSingleton && r.kind === "empty")
51363
+ return finalize(false);
51364
+ }
51365
+ // Arity mismatch on arity-sensitive comparisons → ill-typed.
51366
+ if (opText === "=" || opText === "in" || opText === "ni") {
51367
+ const la = ForgeExprStaticAnalyzer.arityOf(l);
51368
+ const ra = ForgeExprStaticAnalyzer.arityOf(r);
51369
+ if (la > 0 && ra > 0 && la !== ra) {
51370
+ return {
51371
+ kind: "ill-typed",
51372
+ reason: `arity mismatch in '${opText}': left has arity ${la}, right has arity ${ra}`,
51373
+ };
51374
+ }
51375
+ }
51376
+ // Schema-driven: subtype tautologies for `in` (and `ni` via finalize
51377
+ // inversion when A ⊆ B → A in B is true → A ni B is false). We
51378
+ // deliberately do NOT positively fold `ni` from the lattice — concluding
51379
+ // "not (A ⊆ B)" requires disjointness reasoning we don't perform here.
51380
+ if (this.schema && l.kind === "typed" && r.kind === "typed") {
51381
+ const lCols = l.columnTypes;
51382
+ const rCols = r.columnTypes;
51383
+ if (lCols && rCols && lCols.length === rCols.length) {
51384
+ const colWiseSubtype = lCols.every((lc, i) => this.schema.isSubtypeOf(lc, rCols[i]));
51385
+ if (opForLogic === "in" && colWiseSubtype)
51386
+ return finalize(true);
51387
+ }
51388
+ }
51389
+ return UNKNOWN;
51390
+ }
51391
+ visitExpr7(ctx) {
51392
+ const inner = this.visit(ctx.expr8());
51393
+ if (inner.kind === "ill-typed")
51394
+ return inner;
51395
+ if (ctx.SET_TOK())
51396
+ return inner;
51397
+ if (inner.kind === "empty") {
51398
+ if (ctx.NO_TOK())
51399
+ return { kind: "bool", value: true };
51400
+ if (ctx.LONE_TOK())
51401
+ return { kind: "bool", value: true };
51402
+ if (ctx.SOME_TOK())
51403
+ return { kind: "bool", value: false };
51404
+ if (ctx.ONE_TOK())
51405
+ return { kind: "bool", value: false };
51406
+ if (ctx.TWO_TOK())
51407
+ return { kind: "bool", value: false };
51408
+ }
51409
+ // If we can't say anything specific about the multiplicity, the whole
51410
+ // expression is still a boolean — but its value is unknown.
51411
+ if (ctx.NO_TOK() ||
51412
+ ctx.LONE_TOK() ||
51413
+ ctx.SOME_TOK() ||
51414
+ ctx.ONE_TOK() ||
51415
+ ctx.TWO_TOK()) {
51416
+ return UNKNOWN;
51417
+ }
51418
+ return inner;
51419
+ }
51420
+ visitExpr8(ctx) {
51421
+ if (ctx.MINUS_TOK()) {
51422
+ const leftCtx = ctx.expr8();
51423
+ const rightCtx = ctx.expr10();
51424
+ const l = this.visit(leftCtx);
51425
+ const r = this.visit(rightCtx);
51426
+ const bail = ForgeExprStaticAnalyzer.bailIfIllTyped(l, r);
51427
+ if (bail)
51428
+ return bail;
51429
+ // X - X → empty (set difference) or 0 (numeric)
51430
+ if (sameSubtree(leftCtx, rightCtx)) {
51431
+ if (l.kind === "num")
51432
+ return { kind: "num", value: 0 };
51433
+ return { kind: "empty" };
51434
+ }
51435
+ // Numeric folding for literal subtraction.
51436
+ if (l.kind === "num" && r.kind === "num") {
51437
+ return { kind: "num", value: l.value - r.value };
51438
+ }
51439
+ // empty - X → empty; X - empty → X
51440
+ if (l.kind === "empty")
51441
+ return { kind: "empty" };
51442
+ if (r.kind === "empty")
51443
+ return l;
51444
+ // Subset patterns: L ⊆ R ⇒ L - R = empty.
51445
+ // (R & ?) - R or (? & R) - R (intersection is a subset)
51446
+ if (intersectionInvolves(leftCtx, rightCtx.text))
51447
+ return { kind: "empty" };
51448
+ // L - (L + ?) or L - (? + L) (L is a subset of the union)
51449
+ if (unionContains(rightCtx, leftCtx.text))
51450
+ return { kind: "empty" };
51451
+ // Arity mismatch is a static type error.
51452
+ const lArity = ForgeExprStaticAnalyzer.arityOf(l);
51453
+ const rArity = ForgeExprStaticAnalyzer.arityOf(r);
51454
+ if (lArity > 0 && rArity > 0 && lArity !== rArity) {
51455
+ return {
51456
+ kind: "ill-typed",
51457
+ reason: `arity mismatch in '-': left has arity ${lArity}, right has arity ${rArity}`,
51458
+ };
51459
+ }
51460
+ return UNKNOWN;
51461
+ }
51462
+ if (ctx.PLUS_TOK()) {
51463
+ const l = this.visit(ctx.expr8());
51464
+ const r = this.visit(ctx.expr10());
51465
+ const bail = ForgeExprStaticAnalyzer.bailIfIllTyped(l, r);
51466
+ if (bail)
51467
+ return bail;
51468
+ // Numeric literal addition.
51469
+ if (l.kind === "num" && r.kind === "num") {
51470
+ return { kind: "num", value: l.value + r.value };
51471
+ }
51472
+ // Empty is the identity of set union: X + none ≡ X, none + X ≡ X.
51473
+ if (l.kind === "empty" && r.kind === "empty")
51474
+ return { kind: "empty" };
51475
+ if (l.kind === "empty")
51476
+ return r;
51477
+ if (r.kind === "empty")
51478
+ return l;
51479
+ // Arity mismatch is a static type error.
51480
+ const lArity = ForgeExprStaticAnalyzer.arityOf(l);
51481
+ const rArity = ForgeExprStaticAnalyzer.arityOf(r);
51482
+ if (lArity > 0 && rArity > 0 && lArity !== rArity) {
51483
+ return {
51484
+ kind: "ill-typed",
51485
+ reason: `arity mismatch in '+': left has arity ${lArity}, right has arity ${rArity}`,
51486
+ };
51487
+ }
51488
+ if (lArity > 0 && rArity > 0) {
51489
+ return { kind: "typed", arity: lArity };
51490
+ }
51491
+ return UNKNOWN;
51492
+ }
51493
+ return this.visitChildren(ctx);
51494
+ }
51495
+ visitExpr9(ctx) {
51496
+ if (ctx.CARD_TOK()) {
51497
+ const inner = this.visit(ctx.expr9());
51498
+ if (inner.kind === "ill-typed")
51499
+ return inner;
51500
+ if (inner.kind === "empty")
51501
+ return { kind: "num", value: 0 };
51502
+ return UNKNOWN;
51503
+ }
51504
+ return this.visitChildren(ctx);
51505
+ }
51506
+ visitExpr12(ctx) {
51507
+ if (ctx.arrowOp()) {
51508
+ // Cartesian product: if either side is empty, the product is empty.
51509
+ const l = this.visit(ctx.expr12());
51510
+ const r = this.visit(ctx.expr13());
51511
+ const bail = ForgeExprStaticAnalyzer.bailIfIllTyped(l, r);
51512
+ if (bail)
51513
+ return bail;
51514
+ if (l.kind === "empty" || r.kind === "empty")
51515
+ return { kind: "empty" };
51516
+ // Propagate combined arity / column types.
51517
+ if (l.kind === "typed" && r.kind === "typed") {
51518
+ const cols = l.columnTypes && r.columnTypes
51519
+ ? [...l.columnTypes, ...r.columnTypes]
51520
+ : undefined;
51521
+ return { kind: "typed", arity: l.arity + r.arity, columnTypes: cols };
51522
+ }
51523
+ const la = ForgeExprStaticAnalyzer.arityOf(l);
51524
+ const ra = ForgeExprStaticAnalyzer.arityOf(r);
51525
+ if (la > 0 && ra > 0)
51526
+ return { kind: "typed", arity: la + ra };
51527
+ return UNKNOWN;
51528
+ }
51529
+ return this.visitChildren(ctx);
51530
+ }
51531
+ visitExpr11(ctx) {
51532
+ if (ctx.AMP_TOK()) {
51533
+ const leftCtx = ctx.expr11();
51534
+ const rightCtx = ctx.expr12();
51535
+ const l = this.visit(leftCtx);
51536
+ const r = this.visit(rightCtx);
51537
+ const bail = ForgeExprStaticAnalyzer.bailIfIllTyped(l, r);
51538
+ if (bail)
51539
+ return bail;
51540
+ // X & X → X (preserve "empty" if we know it; otherwise unknown).
51541
+ if (sameSubtree(leftCtx, rightCtx))
51542
+ return l;
51543
+ // empty & anything → empty
51544
+ if (l.kind === "empty" || r.kind === "empty")
51545
+ return { kind: "empty" };
51546
+ // Distinct numeric singletons are disjoint sets.
51547
+ if (l.kind === "num" && r.kind === "num" && l.value !== r.value) {
51548
+ return { kind: "empty" };
51549
+ }
51550
+ // X & (... - X) → empty (subtrahend strips X out).
51551
+ if (subtractsTarget(rightCtx, leftCtx.text))
51552
+ return { kind: "empty" };
51553
+ if (subtractsTarget(leftCtx, rightCtx.text))
51554
+ return { kind: "empty" };
51555
+ // Arity mismatch → ill-typed.
51556
+ const la = ForgeExprStaticAnalyzer.arityOf(l);
51557
+ const ra = ForgeExprStaticAnalyzer.arityOf(r);
51558
+ if (la > 0 && ra > 0 && la !== ra) {
51559
+ return {
51560
+ kind: "ill-typed",
51561
+ reason: `arity mismatch in '&': left has arity ${la}, right has arity ${ra}`,
51562
+ };
51563
+ }
51564
+ // Schema-driven column-wise disjointness.
51565
+ if (this.schema && l.kind === "typed" && r.kind === "typed" && l.columnTypes && r.columnTypes) {
51566
+ if (l.columnTypes.length === r.columnTypes.length) {
51567
+ const anyDisjoint = l.columnTypes.some((lc, i) => this.schema.areDisjoint(lc, r.columnTypes[i]));
51568
+ if (anyDisjoint)
51569
+ return { kind: "empty" };
51570
+ }
51571
+ }
51572
+ return UNKNOWN;
51573
+ }
51574
+ return this.visitChildren(ctx);
51575
+ }
51576
+ visitExpr14(ctx) {
51577
+ if (ctx.LEFT_SQUARE_TOK()) {
51578
+ // Box join f[a, b, ...] ≡ ...b.a.f
51579
+ const fn = this.visit(ctx.expr14());
51580
+ if (fn.kind === "ill-typed")
51581
+ return fn;
51582
+ if (fn.kind === "empty")
51583
+ return { kind: "empty" };
51584
+ // walk the comma-separated argument list
51585
+ let list = ctx.exprList();
51586
+ while (list) {
51587
+ const arg = this.visit(list.expr());
51588
+ if (arg.kind === "ill-typed")
51589
+ return arg;
51590
+ if (arg.kind === "empty")
51591
+ return { kind: "empty" };
51592
+ list = list.exprList();
51593
+ }
51594
+ return UNKNOWN;
51595
+ }
51596
+ return this.visitChildren(ctx);
51597
+ }
51598
+ visitExpr15(ctx) {
51599
+ if (ctx.DOT_TOK()) {
51600
+ const l = this.visit(ctx.expr15());
51601
+ const r = this.visit(ctx.expr16());
51602
+ const bail = ForgeExprStaticAnalyzer.bailIfIllTyped(l, r);
51603
+ if (bail)
51604
+ return bail;
51605
+ if (l.kind === "empty" || r.kind === "empty")
51606
+ return { kind: "empty" };
51607
+ // Joining two singletons / unary expressions yields arity 0 — ill-typed.
51608
+ const la = ForgeExprStaticAnalyzer.arityOf(l);
51609
+ const ra = ForgeExprStaticAnalyzer.arityOf(r);
51610
+ if (la === 1 && ra === 1) {
51611
+ return {
51612
+ kind: "ill-typed",
51613
+ reason: "dot join of two unary expressions produces arity 0",
51614
+ };
51615
+ }
51616
+ // Schema-aware column-type check on the join boundary.
51617
+ if (this.schema &&
51618
+ l.kind === "typed" && r.kind === "typed" &&
51619
+ l.columnTypes && r.columnTypes &&
51620
+ l.columnTypes.length > 0 && r.columnTypes.length > 0) {
51621
+ const leftLast = l.columnTypes[l.columnTypes.length - 1];
51622
+ const rightFirst = r.columnTypes[0];
51623
+ if (this.schema.areDisjoint(leftLast, rightFirst)) {
51624
+ return { kind: "empty" };
51625
+ }
51626
+ // Propagate typed-ness through the join when possible.
51627
+ const newCols = [
51628
+ ...l.columnTypes.slice(0, -1),
51629
+ ...r.columnTypes.slice(1),
51630
+ ];
51631
+ if (newCols.length > 0) {
51632
+ return { kind: "typed", arity: newCols.length, columnTypes: newCols };
51633
+ }
51634
+ }
51635
+ // Otherwise propagate arity if we have it on both sides.
51636
+ if (la > 0 && ra > 0) {
51637
+ return { kind: "typed", arity: la + ra - 2 };
51638
+ }
51639
+ return UNKNOWN;
51640
+ }
51641
+ // name LEFT_SQUARE_TOK exprList RIGHT_SQUARE_TOK is a named pred call —
51642
+ // body unknown, so we can't fold even if args are empty.
51643
+ if (ctx.LEFT_SQUARE_TOK())
51644
+ return UNKNOWN;
51645
+ return this.visitChildren(ctx);
51646
+ }
51647
+ visitExpr17(ctx) {
51648
+ if (ctx.TILDE_TOK() || ctx.EXP_TOK()) {
51649
+ // transpose ~X and transitive closure ^X both preserve emptiness
51650
+ const inner = this.visit(ctx.expr17());
51651
+ if (inner.kind === "ill-typed")
51652
+ return inner;
51653
+ if (inner.kind === "empty")
51654
+ return { kind: "empty" };
51655
+ return UNKNOWN;
51656
+ }
51657
+ if (ctx.STAR_TOK()) {
51658
+ // reflexive transitive closure *X = iden ∪ ^X — *none = iden, which is
51659
+ // generally non-empty, so we cannot conclude empty here.
51660
+ return UNKNOWN;
51661
+ }
51662
+ if (ctx.GET_LABEL_TOK() ||
51663
+ ctx.GET_LABEL_STR_TOK() ||
51664
+ ctx.GET_LABEL_BOOL_TOK() ||
51665
+ ctx.GET_LABEL_NUM_TOK()) {
51666
+ // Label lookups depend on per-atom labels in the data instance.
51667
+ return UNKNOWN;
51668
+ }
51669
+ return this.visitChildren(ctx);
51670
+ }
51671
+ visitExpr18(ctx) {
51672
+ if (ctx.LEFT_PAREN_TOK()) {
51673
+ return this.visit(ctx.expr());
51674
+ }
51675
+ if (ctx.const()) {
51676
+ const c = ctx.const();
51677
+ if (c.NONE_TOK())
51678
+ return { kind: "empty" };
51679
+ if (c.number()) {
51680
+ const n = Number(c.number().text);
51681
+ const value = c.MINUS_TOK() ? -n : n;
51682
+ return { kind: "num", value };
51683
+ }
51684
+ // iden, univ are data-dependent
51685
+ return UNKNOWN;
51686
+ }
51687
+ if (ctx.qualName()) {
51688
+ // qualName resolves to either INT_TOK / SUM_TOK (data-dependent) or a
51689
+ // plain name — descend so visitName can fold `true` / `false`.
51690
+ return this.visit(ctx.qualName());
51691
+ }
51692
+ if (ctx.LEFT_CURLY_TOK()) {
51693
+ // Set comprehension `{x : S | body}` is empty if any quantified set is
51694
+ // empty, or if the body is statically false. We also enforce the
51695
+ // reserved-name policy here: under a schema, type/relation names cannot
51696
+ // be reused as binder variables.
51697
+ const declList = ctx.quantDeclList();
51698
+ const boundHere = new Set();
51699
+ if (declList) {
51700
+ let cur = declList;
51701
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
51702
+ while (cur) {
51703
+ const setExpr = cur.quantDecl().expr();
51704
+ const v = this.visit(setExpr);
51705
+ if (v.kind === "empty")
51706
+ return { kind: "empty" };
51707
+ if (v.kind === "ill-typed")
51708
+ return v;
51709
+ this.collectNamesFromList(cur.quantDecl().nameList(), boundHere);
51710
+ const next = cur.quantDeclList();
51711
+ if (!next)
51712
+ break;
51713
+ cur = next;
51714
+ }
51715
+ }
51716
+ const reservedError = this.illTypedIfBindsReservedName(boundHere);
51717
+ if (reservedError)
51718
+ return reservedError;
51719
+ const blockOrBar = ctx.blockOrBar();
51720
+ if (blockOrBar && blockOrBar.BAR_TOK() && blockOrBar.expr()) {
51721
+ const body = this.visit(blockOrBar.expr());
51722
+ if (body.kind === "ill-typed")
51723
+ return body;
51724
+ if (body.kind === "bool" && !body.value)
51725
+ return { kind: "empty" };
51726
+ }
51727
+ return UNKNOWN;
51728
+ }
51729
+ if (ctx.block()) {
51730
+ return this.visit(ctx.block());
51731
+ }
51732
+ // sexpr, AT_TOK, BACKQUOTE_TOK, THIS_TOK — unsupported.
51733
+ return UNKNOWN;
51734
+ }
51735
+ // `true` / `false` are parsed as names rather than `const` literals — the
51736
+ // evaluator handles them in visitName, so we do the same. When a schema is
51737
+ // available, type and relation names resolve to `typed` with arity and
51738
+ // column types from the schema.
51739
+ visitName(ctx) {
51740
+ const id = (0, utils_1.getIdentifierName)(ctx);
51741
+ if (id === "true")
51742
+ return { kind: "bool", value: true };
51743
+ if (id === "false")
51744
+ return { kind: "bool", value: false };
51745
+ if (this.schema) {
51746
+ const t = this.schema.getType(id);
51747
+ if (t)
51748
+ return { kind: "typed", arity: 1, columnTypes: [t.id] };
51749
+ const r = this.schema.getRelation(id);
51750
+ if (r) {
51751
+ const cols = r.types;
51752
+ return { kind: "typed", arity: cols.length, columnTypes: cols };
51753
+ }
51754
+ }
51755
+ return UNKNOWN;
51756
+ }
51757
+ }
51758
+ exports.ForgeExprStaticAnalyzer = ForgeExprStaticAnalyzer;
51759
+
51760
+
50804
51761
  /***/ }),
50805
51762
 
50806
51763
  /***/ "./src/NumericConstraintOptimizer.ts":
@@ -62246,13 +63203,16 @@ exports.FORGE_RESERVED_KEYWORDS = new Set([
62246
63203
  "use strict";
62247
63204
 
62248
63205
  Object.defineProperty(exports, "__esModule", ({ value: true }));
62249
- exports.FORGE_RESERVED_KEYWORDS = exports.quoteIfReserved = exports.getIdentifierName = exports.SelectorSynthesisError = exports.synthesizeSelectorWithWhy = exports.synthesizeBinaryRelationWithWhy = exports.synthesizeBinaryRelation = exports.synthesizeSelector = exports.SimpleGraphQueryEvaluator = void 0;
63206
+ exports.FORGE_RESERVED_KEYWORDS = exports.quoteIfReserved = exports.getIdentifierName = exports.SelectorSynthesisError = exports.synthesizeSelectorWithWhy = exports.synthesizeBinaryRelationWithWhy = exports.synthesizeBinaryRelation = exports.synthesizeSelector = exports.ForgeExprStaticAnalyzer = exports.SimpleGraphQueryEvaluator = void 0;
63207
+ exports.analyzeForgeExpression = analyzeForgeExpression;
62250
63208
  const antlr4ts_1 = __webpack_require__(/*! antlr4ts */ "./node_modules/antlr4ts/index.js");
62251
63209
  const ForgeParser_1 = __webpack_require__(/*! ./forge-antlr/ForgeParser */ "./src/forge-antlr/ForgeParser.ts");
62252
63210
  const ForgeLexer_1 = __webpack_require__(/*! ./forge-antlr/ForgeLexer */ "./src/forge-antlr/ForgeLexer.ts");
62253
63211
  const ForgeListenerImpl_1 = __webpack_require__(/*! ./forge-antlr/ForgeListenerImpl */ "./src/forge-antlr/ForgeListenerImpl.ts");
62254
63212
  const ParseTreeWalker_1 = __webpack_require__(/*! antlr4ts/tree/ParseTreeWalker */ "./node_modules/antlr4ts/tree/ParseTreeWalker.js");
62255
63213
  const ForgeExprEvaluator_1 = __webpack_require__(/*! ./ForgeExprEvaluator */ "./src/ForgeExprEvaluator.ts");
63214
+ const ForgeExprStaticAnalyzer_1 = __webpack_require__(/*! ./ForgeExprStaticAnalyzer */ "./src/ForgeExprStaticAnalyzer.ts");
63215
+ Object.defineProperty(exports, "ForgeExprStaticAnalyzer", ({ enumerable: true, get: function () { return ForgeExprStaticAnalyzer_1.ForgeExprStaticAnalyzer; } }));
62256
63216
  const errorListener_1 = __webpack_require__(/*! ./errorListener */ "./src/errorListener.ts");
62257
63217
  function createForgeParser(input) {
62258
63218
  const inputStream = antlr4ts_1.CharStreams.fromString(input);
@@ -62264,14 +63224,56 @@ function createForgeParser(input) {
62264
63224
  parser.addErrorListener(new errorListener_1.ParseErrorListener());
62265
63225
  return parser;
62266
63226
  }
63227
+ /**
63228
+ * Evaluates Forge expressions against an `IDataInstance`.
63229
+ *
63230
+ * ## Caching contract (important for correctness)
63231
+ *
63232
+ * For performance, this class caches an inner evaluator (with its relation
63233
+ * index and subexpression cache) across `evaluateExpression` calls. The
63234
+ * cache is invalidated when the `datum` field is reassigned to a different
63235
+ * reference, but **not** when the underlying `IDataInstance` is mutated in
63236
+ * place (e.g. adding/removing atoms or tuples on the same object).
63237
+ *
63238
+ * If you mutate the underlying data in place, you **must** call
63239
+ * {@link invalidate} (or reassign `datum` to a new object) before the next
63240
+ * `evaluateExpression` call. Otherwise queries may return stale results.
63241
+ *
63242
+ * Recommended patterns:
63243
+ * - Treat `IDataInstance` as immutable; create a new instance on data
63244
+ * changes and construct a new `SimpleGraphQueryEvaluator` (or assign to
63245
+ * `.datum`).
63246
+ * - Or, if you mutate in place, call `invalidate()` after each mutation
63247
+ * batch.
63248
+ */
62267
63249
  class SimpleGraphQueryEvaluator {
62268
63250
  constructor(datum) {
62269
63251
  this.forgeListener = new ForgeListenerImpl_1.ForgeListenerImpl();
62270
63252
  this.walker = new ParseTreeWalker_1.ParseTreeWalker();
62271
63253
  // Cache for parsed expressions to avoid re-parsing the same expression
62272
63254
  this.parseTreeCache = new Map();
63255
+ // Cached inner evaluator. Reused across evaluateExpression calls so its
63256
+ // relation cache / relation index / subexpression cache survive between
63257
+ // calls. Invalidated when the `datum` reference changes, or explicitly
63258
+ // via `invalidate()`. NOT invalidated by in-place mutation of the
63259
+ // underlying IDataInstance -- callers must signal that themselves.
63260
+ this.cachedEvaluator = null;
63261
+ this.cachedEvaluatorDatum = null;
62273
63262
  this.datum = datum;
62274
63263
  }
63264
+ /**
63265
+ * Discard the cached inner evaluator (and its relation index /
63266
+ * subexpression cache). Call this after mutating the underlying
63267
+ * `IDataInstance` in place, so the next `evaluateExpression` sees the
63268
+ * updated data.
63269
+ *
63270
+ * Cheap: just nulls a couple of references. The caches rebuild lazily
63271
+ * on the next query.
63272
+ */
63273
+ invalidate() {
63274
+ this.cachedEvaluator = null;
63275
+ this.cachedEvaluatorDatum = null;
63276
+ }
62275
63277
  getExpressionParseTree(forgeExpr) {
62276
63278
  const parser = createForgeParser(forgeExpr);
62277
63279
  const tree = parser.parseExpr();
@@ -62302,13 +63304,27 @@ class SimpleGraphQueryEvaluator {
62302
63304
  };
62303
63305
  }
62304
63306
  }
62305
- const evaluator = new ForgeExprEvaluator_1.ForgeExprEvaluator(this.datum);
63307
+ // Reuse the inner evaluator across calls. Rebuild only when the datum
63308
+ // reference has changed (the contract: callers signal "data changed"
63309
+ // by swapping the datum reference, not by mutating it in place).
63310
+ if (this.cachedEvaluator === null || this.cachedEvaluatorDatum !== this.datum) {
63311
+ this.cachedEvaluator = new ForgeExprEvaluator_1.ForgeExprEvaluator(this.datum);
63312
+ this.cachedEvaluatorDatum = this.datum;
63313
+ }
63314
+ const evaluator = this.cachedEvaluator;
62306
63315
  try {
62307
63316
  let result = evaluator.visit(tree);
62308
63317
  // ensure we're visiting an ExprContext
62309
63318
  return result;
62310
63319
  }
62311
63320
  catch (error) {
63321
+ // The visit threw mid-traversal, so the evaluator's transient state
63322
+ // (environment stack) may be inconsistent. Discard the cached
63323
+ // evaluator so the next call rebuilds from a clean slate. The
63324
+ // relation cache will be rebuilt lazily on first use, which is the
63325
+ // same as the pre-change behavior.
63326
+ this.cachedEvaluator = null;
63327
+ this.cachedEvaluatorDatum = null;
62312
63328
  if (error instanceof ForgeExprEvaluator_1.NameNotFoundError) {
62313
63329
  // Return an empty EvalResult for undefined names
62314
63330
  let emptyResult = [];
@@ -62329,6 +63345,33 @@ class SimpleGraphQueryEvaluator {
62329
63345
  }
62330
63346
  }
62331
63347
  exports.SimpleGraphQueryEvaluator = SimpleGraphQueryEvaluator;
63348
+ /**
63349
+ * Run a static analysis on a Forge expression.
63350
+ *
63351
+ * Returns `unsat` when the expression provably reduces to `false`, `empty`
63352
+ * when it provably reduces to the empty set, `tautology` when it provably
63353
+ * reduces to `true`, `ill-typed` for static type errors (e.g. arity
63354
+ * mismatch), and `unknown` otherwise (including parse errors).
63355
+ *
63356
+ * When `schema` is provided, the analyzer also uses the type lattice and
63357
+ * relation declarations to detect type-disjoint intersections, subtype
63358
+ * tautologies in `in`, join column-type mismatches, and arity errors.
63359
+ * Disjointness uses a closed-world rule (A ∩ B = ∅ iff no type in the
63360
+ * lattice has both A and B in its lineage).
63361
+ */
63362
+ function analyzeForgeExpression(forgeExpr, schema) {
63363
+ try {
63364
+ const parser = createForgeParser(forgeExpr);
63365
+ const tree = parser.parseExpr();
63366
+ if (!tree || tree.childCount === 0) {
63367
+ return { status: "unknown" };
63368
+ }
63369
+ return new ForgeExprStaticAnalyzer_1.ForgeExprStaticAnalyzer(schema).analyze(tree);
63370
+ }
63371
+ catch {
63372
+ return { status: "unknown" };
63373
+ }
63374
+ }
62332
63375
  var SelectorSynthesizer_1 = __webpack_require__(/*! ./SelectorSynthesizer */ "./src/SelectorSynthesizer.ts");
62333
63376
  Object.defineProperty(exports, "synthesizeSelector", ({ enumerable: true, get: function () { return SelectorSynthesizer_1.synthesizeSelector; } }));
62334
63377
  Object.defineProperty(exports, "synthesizeBinaryRelation", ({ enumerable: true, get: function () { return SelectorSynthesizer_1.synthesizeBinaryRelation; } }));
package/dist/types.d.ts CHANGED
@@ -25,3 +25,7 @@ export interface IDataInstance {
25
25
  getAtoms(): readonly IAtom[];
26
26
  getRelations(): readonly IRelation[];
27
27
  }
28
+ export interface IForgeSchema {
29
+ getTypes(): readonly IType[];
30
+ getRelations(): readonly IRelation[];
31
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "simple-graph-query",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "description": "TypeScript evaluator for Forge expressions with browser-compatible UMD bundle",
5
5
  "main": "dist/simple-graph-query.bundle.js",
6
6
  "types": "dist/index.d.ts",