simple-graph-query 2.6.0 → 2.7.2

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