@polintpro/proposit-core 0.6.6 → 0.7.3

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.
Files changed (81) hide show
  1. package/README.md +194 -0
  2. package/dist/extensions/basics/schemata.d.ts +5 -0
  3. package/dist/extensions/basics/schemata.d.ts.map +1 -1
  4. package/dist/lib/consts.d.ts.map +1 -1
  5. package/dist/lib/consts.js +21 -2
  6. package/dist/lib/consts.js.map +1 -1
  7. package/dist/lib/core/argument-engine.d.ts +51 -2
  8. package/dist/lib/core/argument-engine.d.ts.map +1 -1
  9. package/dist/lib/core/argument-engine.js +764 -227
  10. package/dist/lib/core/argument-engine.js.map +1 -1
  11. package/dist/lib/core/change-collector.d.ts +1 -0
  12. package/dist/lib/core/change-collector.d.ts.map +1 -1
  13. package/dist/lib/core/change-collector.js +3 -0
  14. package/dist/lib/core/change-collector.js.map +1 -1
  15. package/dist/lib/core/claim-library.d.ts +4 -0
  16. package/dist/lib/core/claim-library.d.ts.map +1 -1
  17. package/dist/lib/core/claim-library.js +126 -59
  18. package/dist/lib/core/claim-library.js.map +1 -1
  19. package/dist/lib/core/claim-source-library.d.ts +4 -0
  20. package/dist/lib/core/claim-source-library.d.ts.map +1 -1
  21. package/dist/lib/core/claim-source-library.js +114 -38
  22. package/dist/lib/core/claim-source-library.js.map +1 -1
  23. package/dist/lib/core/diff.d.ts +10 -0
  24. package/dist/lib/core/diff.d.ts.map +1 -1
  25. package/dist/lib/core/diff.js +114 -21
  26. package/dist/lib/core/diff.js.map +1 -1
  27. package/dist/lib/core/expression-manager.d.ts +11 -0
  28. package/dist/lib/core/expression-manager.d.ts.map +1 -1
  29. package/dist/lib/core/expression-manager.js +379 -20
  30. package/dist/lib/core/expression-manager.js.map +1 -1
  31. package/dist/lib/core/interfaces/argument-engine.interfaces.d.ts +9 -2
  32. package/dist/lib/core/interfaces/argument-engine.interfaces.d.ts.map +1 -1
  33. package/dist/lib/core/interfaces/library.interfaces.d.ts +19 -0
  34. package/dist/lib/core/interfaces/library.interfaces.d.ts.map +1 -1
  35. package/dist/lib/core/interfaces/premise-engine.interfaces.d.ts +22 -0
  36. package/dist/lib/core/interfaces/premise-engine.interfaces.d.ts.map +1 -1
  37. package/dist/lib/core/invariant-violation-error.d.ts +6 -0
  38. package/dist/lib/core/invariant-violation-error.d.ts.map +1 -0
  39. package/dist/lib/core/invariant-violation-error.js +12 -0
  40. package/dist/lib/core/invariant-violation-error.js.map +1 -0
  41. package/dist/lib/core/parser/formula.d.ts.map +1 -1
  42. package/dist/lib/core/parser/formula.js +2 -2
  43. package/dist/lib/core/parser/formula.js.map +1 -1
  44. package/dist/lib/core/premise-engine.d.ts +10 -0
  45. package/dist/lib/core/premise-engine.d.ts.map +1 -1
  46. package/dist/lib/core/premise-engine.js +699 -536
  47. package/dist/lib/core/premise-engine.js.map +1 -1
  48. package/dist/lib/core/source-library.d.ts +4 -0
  49. package/dist/lib/core/source-library.d.ts.map +1 -1
  50. package/dist/lib/core/source-library.js +126 -59
  51. package/dist/lib/core/source-library.js.map +1 -1
  52. package/dist/lib/core/variable-manager.d.ts +7 -0
  53. package/dist/lib/core/variable-manager.d.ts.map +1 -1
  54. package/dist/lib/core/variable-manager.js +65 -1
  55. package/dist/lib/core/variable-manager.js.map +1 -1
  56. package/dist/lib/index.d.ts +4 -1
  57. package/dist/lib/index.d.ts.map +1 -1
  58. package/dist/lib/index.js +4 -1
  59. package/dist/lib/index.js.map +1 -1
  60. package/dist/lib/schemata/argument.d.ts +2 -0
  61. package/dist/lib/schemata/argument.d.ts.map +1 -1
  62. package/dist/lib/schemata/argument.js +6 -0
  63. package/dist/lib/schemata/argument.js.map +1 -1
  64. package/dist/lib/schemata/propositional.d.ts +41 -0
  65. package/dist/lib/schemata/propositional.d.ts.map +1 -1
  66. package/dist/lib/schemata/propositional.js +34 -0
  67. package/dist/lib/schemata/propositional.js.map +1 -1
  68. package/dist/lib/types/diff.d.ts +6 -0
  69. package/dist/lib/types/diff.d.ts.map +1 -1
  70. package/dist/lib/types/fork.d.ts +32 -0
  71. package/dist/lib/types/fork.d.ts.map +1 -0
  72. package/dist/lib/types/fork.js +2 -0
  73. package/dist/lib/types/fork.js.map +1 -0
  74. package/dist/lib/types/grammar.d.ts +5 -4
  75. package/dist/lib/types/grammar.d.ts.map +1 -1
  76. package/dist/lib/types/grammar.js.map +1 -1
  77. package/dist/lib/types/validation.d.ts +46 -0
  78. package/dist/lib/types/validation.d.ts.map +1 -0
  79. package/dist/lib/types/validation.js +41 -0
  80. package/dist/lib/types/validation.js.map +1 -0
  81. package/package.json +1 -1
@@ -1,9 +1,12 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { CorePropositionalExpressionSchema } from "../schemata/index.js";
2
3
  import { getOrCreate } from "../utils/collections.js";
3
4
  import { DEFAULT_POSITION_CONFIG, midpoint, } from "../utils/position.js";
4
5
  import { DEFAULT_CHECKSUM_CONFIG, normalizeChecksumConfig, serializeChecksumConfig, } from "../consts.js";
5
6
  import { entityChecksum, computeHash, canonicalSerialize } from "./checksum.js";
6
7
  import { DEFAULT_GRAMMAR_CONFIG, } from "../types/grammar.js";
8
+ import { Value } from "typebox/value";
9
+ import { EXPR_SCHEMA_INVALID, EXPR_DUPLICATE_ID, EXPR_SELF_REFERENTIAL_PARENT, EXPR_PARENT_NOT_FOUND, EXPR_PARENT_NOT_CONTAINER, EXPR_ROOT_ONLY_VIOLATED, EXPR_FORMULA_BETWEEN_OPERATORS_VIOLATED, EXPR_CHILD_LIMIT_EXCEEDED, EXPR_POSITION_DUPLICATE, EXPR_CHECKSUM_MISMATCH, } from "../types/validation.js";
7
10
  const PERMITTED_OPERATOR_SWAPS = {
8
11
  and: "or",
9
12
  or: "and",
@@ -94,6 +97,9 @@ export class ExpressionManager {
94
97
  const expr = this.expressions.get(id);
95
98
  if (!expr)
96
99
  continue;
100
+ const oldChecksum = expr.checksum;
101
+ const oldDescendantChecksum = expr.descendantChecksum;
102
+ const oldCombinedChecksum = expr.combinedChecksum;
97
103
  const metaChecksum = entityChecksum(expr, fields);
98
104
  const childIds = this.childExpressionIdsByParentId.get(id);
99
105
  let descendantChecksum = null;
@@ -116,6 +122,18 @@ export class ExpressionManager {
116
122
  descendantChecksum,
117
123
  combinedChecksum,
118
124
  });
125
+ if (this.collector &&
126
+ !this.collector.isExpressionAdded(expr.id) &&
127
+ (metaChecksum !== oldChecksum ||
128
+ descendantChecksum !== oldDescendantChecksum ||
129
+ combinedChecksum !== oldCombinedChecksum)) {
130
+ this.collector.modifiedExpression({
131
+ ...expr,
132
+ checksum: metaChecksum,
133
+ descendantChecksum,
134
+ combinedChecksum,
135
+ });
136
+ }
119
137
  }
120
138
  this.dirtyExpressionIds.clear();
121
139
  }
@@ -887,25 +905,43 @@ export class ExpressionManager {
887
905
  throw new Error(`Operator expression "${expression.id}" with "${expression.operator}" must be a root expression (parentId must be null).`);
888
906
  }
889
907
  // 10a. Non-not operators cannot be direct children of operators.
908
+ // Track which children need formula buffers (Site 2) for post-reparent insertion.
909
+ let needsParentFormulaBuffer = false;
910
+ const childrenNeedingFormulaBuffer = [];
890
911
  if (this.grammarConfig.enforceFormulaBetweenOperators) {
891
- // Check 1: new expression as child of anchor's parent.
912
+ // Check 1 (Site 1): new expression as child of anchor's parent.
892
913
  if (anchor.parentId !== null &&
893
914
  expression.type === "operator" &&
894
915
  expression.operator !== "not") {
895
916
  const anchorParent = this.expressions.get(anchor.parentId);
896
917
  if (anchorParent && anchorParent.type === "operator") {
897
- throw new Error(`Non-not operator expressions cannot be direct children of operator expressions — wrap in a formula node`);
918
+ if (this.grammarConfig.autoNormalize) {
919
+ needsParentFormulaBuffer = true;
920
+ }
921
+ else {
922
+ throw new Error(`Non-not operator expressions cannot be direct children of operator expressions — wrap in a formula node`);
923
+ }
898
924
  }
899
925
  }
900
- // Check 2: left/right nodes as children of the new expression.
926
+ // Check 2 (Site 2): left/right nodes as children of the new expression.
901
927
  if (expression.type === "operator") {
902
928
  if (leftNode?.type === "operator" &&
903
929
  leftNode.operator !== "not") {
904
- throw new Error(`Non-not operator expressions cannot be direct children of operator expressions — wrap in a formula node`);
930
+ if (this.grammarConfig.autoNormalize) {
931
+ childrenNeedingFormulaBuffer.push(leftNodeId);
932
+ }
933
+ else {
934
+ throw new Error(`Non-not operator expressions cannot be direct children of operator expressions — wrap in a formula node`);
935
+ }
905
936
  }
906
937
  if (rightNode?.type === "operator" &&
907
938
  rightNode.operator !== "not") {
908
- throw new Error(`Non-not operator expressions cannot be direct children of operator expressions — wrap in a formula node`);
939
+ if (this.grammarConfig.autoNormalize) {
940
+ childrenNeedingFormulaBuffer.push(rightNodeId);
941
+ }
942
+ else {
943
+ throw new Error(`Non-not operator expressions cannot be direct children of operator expressions — wrap in a formula node`);
944
+ }
909
945
  }
910
946
  }
911
947
  }
@@ -918,18 +954,70 @@ export class ExpressionManager {
918
954
  if (leftNodeId !== undefined) {
919
955
  this.reparent(leftNodeId, expression.id, 0);
920
956
  }
921
- // Store the new expression in the freed anchor slot.
957
+ // Determine the slot for the new expression. If a parent formula buffer
958
+ // is needed, the formula takes the anchor slot and the expression goes under it.
959
+ let finalParentId = anchorParentId;
960
+ let finalPosition = anchorPosition;
961
+ if (needsParentFormulaBuffer) {
962
+ const formulaId = randomUUID();
963
+ const formulaExpr = this.attachChecksum({
964
+ id: formulaId,
965
+ type: "formula",
966
+ argumentId: expression.argumentId,
967
+ argumentVersion: expression.argumentVersion,
968
+ premiseId: expression
969
+ .premiseId,
970
+ parentId: anchorParentId,
971
+ position: anchorPosition,
972
+ });
973
+ this.expressions.set(formulaId, formulaExpr);
974
+ this.collector?.addedExpression({
975
+ ...formulaExpr,
976
+ });
977
+ getOrCreate(this.childExpressionIdsByParentId, anchorParentId, () => new Set()).add(formulaId);
978
+ getOrCreate(this.childPositionsByParentId, anchorParentId, () => new Set()).add(anchorPosition);
979
+ finalParentId = formulaId;
980
+ finalPosition = 0;
981
+ }
982
+ // Store the new expression in its slot.
922
983
  const stored = this.attachChecksum({
923
984
  ...expression,
924
- parentId: anchorParentId,
925
- position: anchorPosition,
985
+ parentId: finalParentId,
986
+ position: finalPosition,
926
987
  });
927
988
  this.expressions.set(expression.id, stored);
928
989
  this.collector?.addedExpression({
929
990
  ...stored,
930
991
  });
931
- getOrCreate(this.childExpressionIdsByParentId, anchorParentId, () => new Set()).add(expression.id);
932
- getOrCreate(this.childPositionsByParentId, anchorParentId, () => new Set()).add(anchorPosition);
992
+ getOrCreate(this.childExpressionIdsByParentId, finalParentId, () => new Set()).add(expression.id);
993
+ getOrCreate(this.childPositionsByParentId, finalParentId, () => new Set()).add(finalPosition);
994
+ // Site 2: auto-insert formula buffers between the new expression and
995
+ // any offending operator children.
996
+ for (const childId of childrenNeedingFormulaBuffer) {
997
+ const child = this.expressions.get(childId);
998
+ const childPosition = child.position;
999
+ const formulaId = randomUUID();
1000
+ // Reparent the child under the formula first. This detaches the child
1001
+ // from expression.id's tracking (removing its position from the set).
1002
+ this.reparent(childId, formulaId, 0);
1003
+ // Now create and register the formula at the freed position under expression.id.
1004
+ const formulaExpr = this.attachChecksum({
1005
+ id: formulaId,
1006
+ type: "formula",
1007
+ argumentId: expression.argumentId,
1008
+ argumentVersion: expression.argumentVersion,
1009
+ premiseId: expression
1010
+ .premiseId,
1011
+ parentId: expression.id,
1012
+ position: childPosition,
1013
+ });
1014
+ this.expressions.set(formulaId, formulaExpr);
1015
+ this.collector?.addedExpression({
1016
+ ...formulaExpr,
1017
+ });
1018
+ getOrCreate(this.childExpressionIdsByParentId, expression.id, () => new Set()).add(formulaId);
1019
+ getOrCreate(this.childPositionsByParentId, expression.id, () => new Set()).add(childPosition);
1020
+ }
933
1021
  // Mark the new expression and its ancestors dirty for hierarchical checksum recomputation.
934
1022
  // Note: reparent() already marks children dirty, so this propagates from the new expression up.
935
1023
  this.markExpressionDirty(expression.id);
@@ -1005,23 +1093,43 @@ export class ExpressionManager {
1005
1093
  throw new Error(`Sibling expression "${newSibling.id}" with "${newSibling.operator}" cannot be subordinated (it must remain a root expression).`);
1006
1094
  }
1007
1095
  // 10a. Non-not operators cannot be direct children of operators.
1096
+ // Track which sites need formula buffers for post-mutation insertion.
1097
+ let needsParentFormulaBuffer = false;
1098
+ let existingNodeNeedsFormulaBuffer = false;
1099
+ let siblingNeedsFormulaBuffer = false;
1008
1100
  if (this.grammarConfig.enforceFormulaBetweenOperators) {
1009
- // Check 1: new operator as child of existing node's parent.
1101
+ // Check 1 (Site 1): new operator as child of existing node's parent.
1010
1102
  // Note: step 7 already rejects `not`, so operator.operator is always non-not here.
1011
1103
  if (existingNode.parentId !== null) {
1012
1104
  const existingParent = this.expressions.get(existingNode.parentId);
1013
1105
  if (existingParent && existingParent.type === "operator") {
1014
- throw new Error(`Non-not operator expressions cannot be direct children of operator expressions — wrap in a formula node`);
1106
+ if (this.grammarConfig.autoNormalize) {
1107
+ needsParentFormulaBuffer = true;
1108
+ }
1109
+ else {
1110
+ throw new Error(`Non-not operator expressions cannot be direct children of operator expressions — wrap in a formula node`);
1111
+ }
1015
1112
  }
1016
1113
  }
1017
- // Check 2: existing node and new sibling as children of the new operator.
1114
+ // Check 2 (Site 2): existing node as child of new operator.
1018
1115
  if (existingNode.type === "operator" &&
1019
1116
  existingNode.operator !== "not") {
1020
- throw new Error(`Non-not operator expressions cannot be direct children of operator expressions — wrap in a formula node`);
1117
+ if (this.grammarConfig.autoNormalize) {
1118
+ existingNodeNeedsFormulaBuffer = true;
1119
+ }
1120
+ else {
1121
+ throw new Error(`Non-not operator expressions cannot be direct children of operator expressions — wrap in a formula node`);
1122
+ }
1021
1123
  }
1124
+ // Check 3 (Site 3): new sibling as child of new operator.
1022
1125
  if (newSibling.type === "operator" &&
1023
1126
  newSibling.operator !== "not") {
1024
- throw new Error(`Non-not operator expressions cannot be direct children of operator expressions — wrap in a formula node`);
1127
+ if (this.grammarConfig.autoNormalize) {
1128
+ siblingNeedsFormulaBuffer = true;
1129
+ }
1130
+ else {
1131
+ throw new Error(`Non-not operator expressions cannot be direct children of operator expressions — wrap in a formula node`);
1132
+ }
1025
1133
  }
1026
1134
  }
1027
1135
  // Save the existing node's slot (the operator will inherit it).
@@ -1048,18 +1156,93 @@ export class ExpressionManager {
1048
1156
  });
1049
1157
  getOrCreate(this.childExpressionIdsByParentId, operator.id, () => new Set()).add(newSibling.id);
1050
1158
  getOrCreate(this.childPositionsByParentId, operator.id, () => new Set()).add(siblingPosition);
1051
- // Store operator in the existing node's former slot.
1159
+ // Determine the operator's slot. If a parent formula buffer is needed,
1160
+ // the formula takes the anchor slot and the operator goes under it.
1161
+ let operatorParentId = anchorParentId;
1162
+ let operatorPosition = anchorPosition;
1163
+ if (needsParentFormulaBuffer) {
1164
+ const formulaId = randomUUID();
1165
+ const formulaExpr = this.attachChecksum({
1166
+ id: formulaId,
1167
+ type: "formula",
1168
+ argumentId: operator.argumentId,
1169
+ argumentVersion: operator.argumentVersion,
1170
+ premiseId: operator
1171
+ .premiseId,
1172
+ parentId: anchorParentId,
1173
+ position: anchorPosition,
1174
+ });
1175
+ this.expressions.set(formulaId, formulaExpr);
1176
+ this.collector?.addedExpression({
1177
+ ...formulaExpr,
1178
+ });
1179
+ getOrCreate(this.childExpressionIdsByParentId, anchorParentId, () => new Set()).add(formulaId);
1180
+ getOrCreate(this.childPositionsByParentId, anchorParentId, () => new Set()).add(anchorPosition);
1181
+ operatorParentId = formulaId;
1182
+ operatorPosition = 0;
1183
+ }
1184
+ // Store operator in its slot.
1052
1185
  const storedOperator = this.attachChecksum({
1053
1186
  ...operator,
1054
- parentId: anchorParentId,
1055
- position: anchorPosition,
1187
+ parentId: operatorParentId,
1188
+ position: operatorPosition,
1056
1189
  });
1057
1190
  this.expressions.set(operator.id, storedOperator);
1058
1191
  this.collector?.addedExpression({
1059
1192
  ...storedOperator,
1060
1193
  });
1061
- getOrCreate(this.childExpressionIdsByParentId, anchorParentId, () => new Set()).add(operator.id);
1062
- getOrCreate(this.childPositionsByParentId, anchorParentId, () => new Set()).add(anchorPosition);
1194
+ getOrCreate(this.childExpressionIdsByParentId, operatorParentId, () => new Set()).add(operator.id);
1195
+ getOrCreate(this.childPositionsByParentId, operatorParentId, () => new Set()).add(operatorPosition);
1196
+ // Site 2: auto-insert formula buffer between operator and existing node.
1197
+ if (existingNodeNeedsFormulaBuffer) {
1198
+ const existingChild = this.expressions.get(existingNodeId);
1199
+ const childPosition = existingChild.position;
1200
+ const formulaId = randomUUID();
1201
+ // Reparent existing node under formula first (frees position in operator's tracking).
1202
+ this.reparent(existingNodeId, formulaId, 0);
1203
+ // Register formula at the freed position under operator.
1204
+ const formulaExpr = this.attachChecksum({
1205
+ id: formulaId,
1206
+ type: "formula",
1207
+ argumentId: operator.argumentId,
1208
+ argumentVersion: operator.argumentVersion,
1209
+ premiseId: operator
1210
+ .premiseId,
1211
+ parentId: operator.id,
1212
+ position: childPosition,
1213
+ });
1214
+ this.expressions.set(formulaId, formulaExpr);
1215
+ this.collector?.addedExpression({
1216
+ ...formulaExpr,
1217
+ });
1218
+ getOrCreate(this.childExpressionIdsByParentId, operator.id, () => new Set()).add(formulaId);
1219
+ getOrCreate(this.childPositionsByParentId, operator.id, () => new Set()).add(childPosition);
1220
+ }
1221
+ // Site 3: auto-insert formula buffer between operator and new sibling.
1222
+ if (siblingNeedsFormulaBuffer) {
1223
+ const siblingChild = this.expressions.get(newSibling.id);
1224
+ const childPosition = siblingChild.position;
1225
+ const formulaId = randomUUID();
1226
+ // Reparent sibling under formula first (frees position in operator's tracking).
1227
+ this.reparent(newSibling.id, formulaId, 0);
1228
+ // Register formula at the freed position under operator.
1229
+ const formulaExpr = this.attachChecksum({
1230
+ id: formulaId,
1231
+ type: "formula",
1232
+ argumentId: operator.argumentId,
1233
+ argumentVersion: operator.argumentVersion,
1234
+ premiseId: operator
1235
+ .premiseId,
1236
+ parentId: operator.id,
1237
+ position: childPosition,
1238
+ });
1239
+ this.expressions.set(formulaId, formulaExpr);
1240
+ this.collector?.addedExpression({
1241
+ ...formulaExpr,
1242
+ });
1243
+ getOrCreate(this.childExpressionIdsByParentId, operator.id, () => new Set()).add(formulaId);
1244
+ getOrCreate(this.childPositionsByParentId, operator.id, () => new Set()).add(childPosition);
1245
+ }
1063
1246
  // Mark the new operator (and ancestors), the new sibling, and the reparented existing node dirty.
1064
1247
  // reparent() already marks the existing node dirty; mark the operator and sibling as well.
1065
1248
  this.markExpressionDirty(newSibling.id);
@@ -1151,6 +1334,182 @@ export class ExpressionManager {
1151
1334
  loadExpressions(expressions) {
1152
1335
  this.loadInitialExpressions(expressions);
1153
1336
  }
1337
+ /**
1338
+ * Performs a comprehensive validation sweep on all managed expressions.
1339
+ *
1340
+ * Collects ALL violations rather than failing on the first one. Checks:
1341
+ * schema validity, duplicate IDs, self-referential parents, parent
1342
+ * existence, parent container type, root-only operators, formula-between-
1343
+ * operators (when enabled), child limits, position uniqueness, and
1344
+ * checksum integrity.
1345
+ */
1346
+ validate() {
1347
+ const violations = [];
1348
+ const seenIds = new Set();
1349
+ // ── 1. Save pre-flush checksums for later comparison ──
1350
+ const preFlushChecksums = new Map();
1351
+ for (const [id, expr] of this.expressions) {
1352
+ if (expr.checksum != null) {
1353
+ preFlushChecksums.set(id, {
1354
+ checksum: expr.checksum,
1355
+ descendantChecksum: expr.descendantChecksum,
1356
+ combinedChecksum: expr.combinedChecksum,
1357
+ });
1358
+ }
1359
+ }
1360
+ // ── 2. Flush checksums to get fresh values ──
1361
+ // Mark all expressions dirty so flush recomputes everything
1362
+ for (const id of this.expressions.keys()) {
1363
+ this.dirtyExpressionIds.add(id);
1364
+ }
1365
+ this.flushExpressionChecksums();
1366
+ // ── 3. Per-expression checks ──
1367
+ // Build a sibling-position map for position uniqueness checks
1368
+ const positionsByParent = new Map();
1369
+ for (const [id, expr] of this.expressions) {
1370
+ // 3a. Schema check
1371
+ if (!Value.Check(CorePropositionalExpressionSchema, expr)) {
1372
+ violations.push({
1373
+ code: EXPR_SCHEMA_INVALID,
1374
+ message: `Expression "${id}" does not conform to CorePropositionalExpressionSchema.`,
1375
+ entityType: "expression",
1376
+ entityId: id,
1377
+ });
1378
+ }
1379
+ // 3b. Duplicate ID
1380
+ if (seenIds.has(id)) {
1381
+ violations.push({
1382
+ code: EXPR_DUPLICATE_ID,
1383
+ message: `Duplicate expression ID "${id}".`,
1384
+ entityType: "expression",
1385
+ entityId: id,
1386
+ });
1387
+ }
1388
+ seenIds.add(id);
1389
+ // 3c. Self-referential parent
1390
+ if (expr.parentId === id) {
1391
+ violations.push({
1392
+ code: EXPR_SELF_REFERENTIAL_PARENT,
1393
+ message: `Expression "${id}" references itself as parent.`,
1394
+ entityType: "expression",
1395
+ entityId: id,
1396
+ });
1397
+ }
1398
+ // 3d. Parent existence
1399
+ if (expr.parentId !== null &&
1400
+ !this.expressions.has(expr.parentId)) {
1401
+ violations.push({
1402
+ code: EXPR_PARENT_NOT_FOUND,
1403
+ message: `Expression "${id}" references non-existent parent "${expr.parentId}".`,
1404
+ entityType: "expression",
1405
+ entityId: id,
1406
+ });
1407
+ }
1408
+ // 3e. Parent is container (operator or formula)
1409
+ if (expr.parentId !== null && this.expressions.has(expr.parentId)) {
1410
+ const parent = this.expressions.get(expr.parentId);
1411
+ if (parent.type !== "operator" && parent.type !== "formula") {
1412
+ violations.push({
1413
+ code: EXPR_PARENT_NOT_CONTAINER,
1414
+ message: `Expression "${id}" has parent "${expr.parentId}" of type "${parent.type}" (expected operator or formula).`,
1415
+ entityType: "expression",
1416
+ entityId: id,
1417
+ });
1418
+ }
1419
+ }
1420
+ // 3f. Root-only: implies/iff must have parentId === null
1421
+ if (expr.type === "operator" &&
1422
+ (expr.operator === "implies" || expr.operator === "iff") &&
1423
+ expr.parentId !== null) {
1424
+ violations.push({
1425
+ code: EXPR_ROOT_ONLY_VIOLATED,
1426
+ message: `Root-only operator "${expr.operator}" expression "${id}" has non-null parentId "${expr.parentId}".`,
1427
+ entityType: "expression",
1428
+ entityId: id,
1429
+ });
1430
+ }
1431
+ // 3g. Formula-between-operators
1432
+ if (this.grammarConfig.enforceFormulaBetweenOperators &&
1433
+ expr.parentId !== null &&
1434
+ expr.type === "operator" &&
1435
+ expr.operator !== "not") {
1436
+ const parent = this.expressions.get(expr.parentId);
1437
+ if (parent && parent.type === "operator") {
1438
+ violations.push({
1439
+ code: EXPR_FORMULA_BETWEEN_OPERATORS_VIOLATED,
1440
+ message: `Non-not operator "${expr.operator}" expression "${id}" is a direct child of operator "${expr.parentId}".`,
1441
+ entityType: "expression",
1442
+ entityId: id,
1443
+ });
1444
+ }
1445
+ }
1446
+ // Collect positions for uniqueness check
1447
+ const parentKey = expr.parentId;
1448
+ let parentPositions = positionsByParent.get(parentKey);
1449
+ if (!parentPositions) {
1450
+ parentPositions = new Map();
1451
+ positionsByParent.set(parentKey, parentPositions);
1452
+ }
1453
+ const idsAtPosition = parentPositions.get(expr.position);
1454
+ if (idsAtPosition) {
1455
+ idsAtPosition.push(id);
1456
+ }
1457
+ else {
1458
+ parentPositions.set(expr.position, [id]);
1459
+ }
1460
+ // 3j. Checksum comparison
1461
+ const pre = preFlushChecksums.get(id);
1462
+ if (pre) {
1463
+ const fresh = this.expressions.get(id);
1464
+ if (pre.checksum !== fresh.checksum ||
1465
+ pre.descendantChecksum !== fresh.descendantChecksum ||
1466
+ pre.combinedChecksum !== fresh.combinedChecksum) {
1467
+ violations.push({
1468
+ code: EXPR_CHECKSUM_MISMATCH,
1469
+ message: `Expression "${id}" checksum mismatch: stored does not match recomputed.`,
1470
+ entityType: "expression",
1471
+ entityId: id,
1472
+ });
1473
+ }
1474
+ }
1475
+ }
1476
+ // ── 4. Child limit checks (not/formula: max 1 child) ──
1477
+ for (const [id, expr] of this.expressions) {
1478
+ if ((expr.type === "operator" && expr.operator === "not") ||
1479
+ expr.type === "formula") {
1480
+ const childIds = this.childExpressionIdsByParentId.get(id);
1481
+ const childCount = childIds?.size ?? 0;
1482
+ if (childCount > 1) {
1483
+ const label = expr.type === "formula" ? "Formula" : `Operator "not"`;
1484
+ violations.push({
1485
+ code: EXPR_CHILD_LIMIT_EXCEEDED,
1486
+ message: `${label} expression "${id}" has ${childCount} children (max 1).`,
1487
+ entityType: "expression",
1488
+ entityId: id,
1489
+ });
1490
+ }
1491
+ }
1492
+ }
1493
+ // ── 5. Position uniqueness ──
1494
+ for (const [, posMap] of positionsByParent) {
1495
+ for (const [position, ids] of posMap) {
1496
+ if (ids.length > 1) {
1497
+ for (const id of ids) {
1498
+ violations.push({
1499
+ code: EXPR_POSITION_DUPLICATE,
1500
+ message: `Position ${position} is shared by expressions [${ids.join(", ")}].`,
1501
+ entityType: "expression",
1502
+ entityId: id,
1503
+ });
1504
+ }
1505
+ }
1506
+ }
1507
+ }
1508
+ return {
1509
+ ok: violations.length === 0,
1510
+ violations,
1511
+ };
1512
+ }
1154
1513
  /** Returns a serializable snapshot of the current state. */
1155
1514
  snapshot() {
1156
1515
  return {