@proposit/proposit-core 3.4.2 → 4.0.1

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 (57) hide show
  1. package/README.md +12 -6
  2. package/dist/cli/commands/analysis.d.ts.map +1 -1
  3. package/dist/cli/commands/analysis.js +7 -9
  4. package/dist/cli/commands/analysis.js.map +1 -1
  5. package/dist/cli/commands/graph.d.ts +1 -1
  6. package/dist/cli/commands/graph.d.ts.map +1 -1
  7. package/dist/cli/commands/graph.js +21 -15
  8. package/dist/cli/commands/graph.js.map +1 -1
  9. package/dist/lib/core/argument-engine.d.ts +22 -2
  10. package/dist/lib/core/argument-engine.d.ts.map +1 -1
  11. package/dist/lib/core/argument-engine.js +64 -11
  12. package/dist/lib/core/argument-engine.js.map +1 -1
  13. package/dist/lib/core/evaluation/argument-evaluation.d.ts +77 -14
  14. package/dist/lib/core/evaluation/argument-evaluation.d.ts.map +1 -1
  15. package/dist/lib/core/evaluation/argument-evaluation.js +402 -235
  16. package/dist/lib/core/evaluation/argument-evaluation.js.map +1 -1
  17. package/dist/lib/core/evaluation/belnap.d.ts +22 -0
  18. package/dist/lib/core/evaluation/belnap.d.ts.map +1 -0
  19. package/dist/lib/core/evaluation/belnap.js +86 -0
  20. package/dist/lib/core/evaluation/belnap.js.map +1 -0
  21. package/dist/lib/core/evaluation/premise-resolver.d.ts +13 -0
  22. package/dist/lib/core/evaluation/premise-resolver.d.ts.map +1 -0
  23. package/dist/lib/core/evaluation/premise-resolver.js +35 -0
  24. package/dist/lib/core/evaluation/premise-resolver.js.map +1 -0
  25. package/dist/lib/core/evaluation/satisfiability.d.ts +52 -0
  26. package/dist/lib/core/evaluation/satisfiability.d.ts.map +1 -0
  27. package/dist/lib/core/evaluation/satisfiability.js +212 -0
  28. package/dist/lib/core/evaluation/satisfiability.js.map +1 -0
  29. package/dist/lib/core/evaluation/validation.d.ts +4 -4
  30. package/dist/lib/core/evaluation/validation.d.ts.map +1 -1
  31. package/dist/lib/core/evaluation/validation.js +4 -4
  32. package/dist/lib/core/evaluation/validation.js.map +1 -1
  33. package/dist/lib/core/interfaces/argument-engine.interfaces.d.ts +46 -7
  34. package/dist/lib/core/interfaces/argument-engine.interfaces.d.ts.map +1 -1
  35. package/dist/lib/core/interfaces/premise-engine.interfaces.d.ts +19 -9
  36. package/dist/lib/core/interfaces/premise-engine.interfaces.d.ts.map +1 -1
  37. package/dist/lib/core/premise-engine.d.ts +4 -3
  38. package/dist/lib/core/premise-engine.d.ts.map +1 -1
  39. package/dist/lib/core/premise-engine.js +14 -17
  40. package/dist/lib/core/premise-engine.js.map +1 -1
  41. package/dist/lib/index.d.ts +3 -3
  42. package/dist/lib/index.d.ts.map +1 -1
  43. package/dist/lib/index.js +2 -2
  44. package/dist/lib/index.js.map +1 -1
  45. package/dist/lib/types/evaluation.d.ts +241 -35
  46. package/dist/lib/types/evaluation.d.ts.map +1 -1
  47. package/dist/lib/types/evaluation.js +13 -1
  48. package/dist/lib/types/evaluation.js.map +1 -1
  49. package/package.json +1 -1
  50. package/dist/lib/core/evaluation/grading.d.ts +0 -29
  51. package/dist/lib/core/evaluation/grading.d.ts.map +0 -1
  52. package/dist/lib/core/evaluation/grading.js +0 -50
  53. package/dist/lib/core/evaluation/grading.js.map +0 -1
  54. package/dist/lib/core/evaluation/kleene.d.ts +0 -12
  55. package/dist/lib/core/evaluation/kleene.d.ts.map +0 -1
  56. package/dist/lib/core/evaluation/kleene.js +0 -29
  57. package/dist/lib/core/evaluation/kleene.js.map +0 -1
@@ -1,8 +1,12 @@
1
1
  import { isClaimBound, isPremiseBound, } from "../../schemata/index.js";
2
- import { kleeneAnd, kleeneNot, kleeneOr, kleeneImplies, kleeneIff, } from "./kleene.js";
2
+ import { CONTESTED, } from "../../types/evaluation.js";
3
+ import { belnapAnd, belnapNot, belnapOr, belnapImplies, belnapIff, hasFalseComponent, hasTrueComponent, joinKnowledge, } from "./belnap.js";
4
+ import { createPremiseBoundResolver } from "./premise-resolver.js";
5
+ import { isPremiseSetSatisfiable } from "./satisfiability.js";
3
6
  import { makeErrorIssue, makeValidationResult } from "./validation.js";
4
7
  /**
5
- * Kleene-evaluate an expression subtree under a fixed variable assignment.
8
+ * Evaluate an expression subtree under a fixed variable assignment, using the
9
+ * four-valued Belnap connectives.
6
10
  *
7
11
  * Total and side-effect free: unknown/missing variables and empty operators
8
12
  * yield `null`, and `formula` wrappers pass through to their single child.
@@ -13,79 +17,122 @@ import { makeErrorIssue, makeValidationResult } from "./validation.js";
13
17
  * The operator base cases (`and` seeds `true`, `or` seeds `false`) match
14
18
  * `propagateOperatorConstraints`' internal resolver so the two agree.
15
19
  */
16
- export function evaluateSubtreeKleene(rootExpressionId, getExpression, getChildren, variables) {
20
+ export function evaluateSubtree(rootExpressionId, getExpression, getChildren, variables) {
17
21
  const expr = getExpression(rootExpressionId);
18
22
  if (!expr)
19
23
  return null;
20
24
  if (expr.type === "variable") {
21
25
  return variables[expr.variableId] ?? null;
22
26
  }
23
- const recurse = (child) => evaluateSubtreeKleene(child.id, getExpression, getChildren, variables);
27
+ const recurse = (child) => evaluateSubtree(child.id, getExpression, getChildren, variables);
24
28
  const children = getChildren(expr.id);
25
29
  if (expr.type === "formula") {
26
30
  return children.length > 0 ? recurse(children[0]) : null;
27
31
  }
28
32
  switch (expr.operator) {
29
33
  case "not":
30
- return children.length > 0 ? kleeneNot(recurse(children[0])) : null;
34
+ return children.length > 0 ? belnapNot(recurse(children[0])) : null;
31
35
  case "and":
32
- return children.reduce((acc, child) => kleeneAnd(acc, recurse(child)), true);
36
+ return children.reduce((acc, child) => belnapAnd(acc, recurse(child)), true);
33
37
  case "or":
34
- return children.reduce((acc, child) => kleeneOr(acc, recurse(child)), false);
38
+ return children.reduce((acc, child) => belnapOr(acc, recurse(child)), false);
35
39
  case "implies":
36
40
  return children.length >= 2
37
- ? kleeneImplies(recurse(children[0]), recurse(children[1]))
41
+ ? belnapImplies(recurse(children[0]), recurse(children[1]))
38
42
  : null;
39
43
  case "iff":
40
44
  return children.length >= 2
41
- ? kleeneIff(recurse(children[0]), recurse(children[1]))
45
+ ? belnapIff(recurse(children[0]), recurse(children[1]))
42
46
  : null;
43
47
  default:
44
48
  return null;
45
49
  }
46
50
  }
47
51
  /**
48
- * Run fixed-point constraint propagation over accepted/rejected operators.
49
- * Fills unknown (null) variable values based on operator semantics.
50
- * Never overwrites user-assigned values (true/false).
52
+ * Run constraint propagation to a fixed point over the operators the reader
53
+ * accepted, filling in variable values the granted steps force.
54
+ *
55
+ * Only acceptances propagate. A rejection is not a truth value: it strikes the
56
+ * premise it lives in, and the caller excludes that premise here via
57
+ * `options.excludedPremiseIds` — so nothing inside a struck premise
58
+ * contributes, and no value is ever forced `false` by a refusal.
59
+ *
60
+ * Each step **merges** what it forces into the variable's current value rather
61
+ * than overwriting it or declining to write, so two steps that force opposite
62
+ * values leave the variable `CONTESTED` instead of letting whichever step ran
63
+ * first decide. That merge is the join of the knowledge order, every rule's
64
+ * trigger is monotone in that same order, and the state space is finite — so
65
+ * the sweep converges to the least fixed point above the reader's assignment
66
+ * and reaches it whatever order premises, expressions and rules are visited
67
+ * in.
68
+ *
69
+ * Each rule moves **one truth component in one direction**, and that is not
70
+ * decoration: an accepted `A → B` fires forward on `A` being told true and
71
+ * merges told-true into `B`, and backward on `B` being told false merging
72
+ * told-false into `A`. Transferring both components at once would read the
73
+ * conditional as a biconditional and derive `B` false from `A` false. The
74
+ * one-directional pairing is what a material implication licenses; only `iff`
75
+ * carries both components both ways.
76
+ *
77
+ * Because only the told-true component travels forward, a contested variable
78
+ * can produce an uncontested `true` downstream and leave every aggregate fact
79
+ * reading clean. `evaluateArgument` reports `contestedVariableIds` so a
80
+ * conflict is never inferred from the aggregates. Attribution's counterfactual depends on that: withholding an assertion
81
+ * and re-closing must give one answer, and must not let mutually supporting
82
+ * premises certify each other.
83
+ *
84
+ * A reader's own assertion takes part in the merge like any other source. If
85
+ * the reader asserts a value that a granted step contradicts, the result is
86
+ * `CONTESTED` — the conflict is reported, not silently resolved in either
87
+ * direction.
51
88
  *
52
89
  * Axiomatic-bound variables are forced to `true` by `ArgumentEngine`'s
53
- * pre-pass before this function runs; they land in the propagator's
54
- * `userAssigned` set and are immune to overwrite. Rejecting an operator
55
- * whose only unknown child is axiom-bound is therefore a propagation no-op
56
- * on that branch — the axiom stays true and the rejection's downstream
57
- * propagation halts gracefully.
90
+ * pre-pass before this function runs, and are merged on the same footing.
58
91
  */
59
- export function propagateOperatorConstraints(ctx, assignment) {
92
+ export function propagateOperatorConstraints(ctx, assignment, options) {
93
+ return closeUnderAcceptedOperators(ctx, assignment, options).variables;
94
+ }
95
+ /**
96
+ * `propagateOperatorConstraints` plus the provenance of every value it saw or
97
+ * produced. The two share one closure, so a tag is recorded where the value is
98
+ * actually set rather than reconstructed afterwards.
99
+ */
100
+ export function closeUnderAcceptedOperators(ctx, assignment, options) {
60
101
  const vars = { ...assignment.variables };
102
+ for (const variableId of options?.withheldVariableIds ?? []) {
103
+ delete vars[variableId];
104
+ }
61
105
  const opAssignments = assignment.operatorAssignments;
106
+ const excludedPremiseIds = options?.excludedPremiseIds;
62
107
  // Collect all expressions across all premises, indexed by id
63
108
  const exprById = new Map();
109
+ // Expression id -> the premise it belongs to
110
+ const premiseIdOf = new Map();
64
111
  // Children lookup: parentId -> sorted children
65
112
  const childrenOf = new Map();
66
113
  for (const pm of ctx.listPremises()) {
114
+ if (excludedPremiseIds?.has(pm.getId()))
115
+ continue;
67
116
  for (const expr of pm.getExpressions()) {
68
117
  exprById.set(expr.id, expr);
69
- }
70
- // Build children map using getChildExpressions for each operator/formula
71
- for (const expr of pm.getExpressions()) {
118
+ premiseIdOf.set(expr.id, pm.getId());
119
+ // Build children map using getChildExpressions for each operator/formula
72
120
  if (expr.type === "operator" || expr.type === "formula") {
73
121
  childrenOf.set(expr.id, pm.getChildExpressions(expr.id));
74
122
  }
75
123
  }
76
124
  }
77
125
  /**
78
- * Resolve the current Kleene value of an expression subtree
79
- * given the current variable assignments. Does not force-accept
80
- * nested operators — evaluates them normally via Kleene logic.
126
+ * Resolve the current four-valued value of an expression subtree given the
127
+ * current variable assignments. Does not force-accept nested operators —
128
+ * evaluates them normally.
81
129
  */
82
130
  const resolveValue = (exprId) => {
83
131
  const expr = exprById.get(exprId);
84
132
  if (!expr)
85
133
  return null;
86
134
  if (expr.type === "variable") {
87
- return (vars[expr
88
- .variableId] ?? null);
135
+ return vars[expr.variableId] ?? null;
89
136
  }
90
137
  if (expr.type === "formula") {
91
138
  const children = childrenOf.get(expr.id) ?? [];
@@ -94,18 +141,27 @@ export function propagateOperatorConstraints(ctx, assignment) {
94
141
  // operator
95
142
  const op = expr.operator;
96
143
  const children = childrenOf.get(expr.id) ?? [];
144
+ // Arity is guarded rather than assumed: this function is reachable
145
+ // from the exported closure, which a caller may hand a tree that
146
+ // never passed `validateEvaluability()`.
97
147
  switch (op) {
98
148
  case "not":
99
- return kleeneNot(resolveValue(children[0].id));
149
+ return children.length > 0
150
+ ? belnapNot(resolveValue(children[0].id))
151
+ : null;
100
152
  case "and":
101
- return children.reduce((acc, child) => kleeneAnd(acc, resolveValue(child.id)), true);
153
+ return children.reduce((acc, child) => belnapAnd(acc, resolveValue(child.id)), true);
102
154
  case "or":
103
- return children.reduce((acc, child) => kleeneOr(acc, resolveValue(child.id)), false);
155
+ return children.reduce((acc, child) => belnapOr(acc, resolveValue(child.id)), false);
104
156
  case "implies": {
105
- return kleeneImplies(resolveValue(children[0].id), resolveValue(children[1].id));
157
+ return children.length >= 2
158
+ ? belnapImplies(resolveValue(children[0].id), resolveValue(children[1].id))
159
+ : null;
106
160
  }
107
161
  case "iff": {
108
- return kleeneIff(resolveValue(children[0].id), resolveValue(children[1].id));
162
+ return children.length >= 2
163
+ ? belnapIff(resolveValue(children[0].id), resolveValue(children[1].id))
164
+ : null;
109
165
  }
110
166
  }
111
167
  };
@@ -125,195 +181,185 @@ export function propagateOperatorConstraints(ctx, assignment) {
125
181
  }
126
182
  return null;
127
183
  };
128
- // Track which variable IDs were explicitly set by the user
129
- // (true or false). These are never overwritten by propagation.
184
+ /** Variable IDs in an expression subtree that currently hold a value. */
185
+ const collectValuedVariableIds = (exprId) => {
186
+ const expr = exprById.get(exprId);
187
+ if (!expr)
188
+ return [];
189
+ if (expr.type === "variable") {
190
+ return (vars[expr.variableId] ?? null) === null
191
+ ? []
192
+ : [expr.variableId];
193
+ }
194
+ return (childrenOf.get(expr.id) ?? []).flatMap((child) => collectValuedVariableIds(child.id));
195
+ };
196
+ // Variable IDs the reader supplied a value for. Propagation merges into
197
+ // them like any other source; the set only tags provenance.
130
198
  const userAssigned = new Set();
131
199
  for (const [varId, val] of Object.entries(vars)) {
132
200
  if (val !== null && val !== undefined)
133
201
  userAssigned.add(varId);
134
202
  }
135
203
  /**
136
- * Try to set a child expression's variable to a value.
137
- * Never overwrites user-assigned values.
138
- * False overrides propagated true (rejection wins).
139
- * Returns true if a value changed.
204
+ * Every granted step that contributed a component to a variable, keyed by
205
+ * the step's expression and the value it forced. A step is recorded each
206
+ * time its rule fires, whether or not the merge changed anything, and the
207
+ * record is overwritten — so at the fixed point every entry carries
208
+ * `fromVariableIds` read off the converged state rather than off whatever
209
+ * was known the first time the rule happened to run.
140
210
  */
141
- const trySetChild = (child, value) => {
211
+ const contributions = new Map();
212
+ /**
213
+ * Merge a value into a child expression's leaf variable and record the
214
+ * step that forced it. Returns true iff the variable gained a component
215
+ * it did not already have.
216
+ */
217
+ const mergeIntoChild = (child, value, step) => {
142
218
  const varId = resolveLeafVariableId(child);
143
- if (varId == null || userAssigned.has(varId))
219
+ if (varId == null)
144
220
  return false;
145
- const current = vars[varId] ?? null;
146
- if (current === null) {
147
- vars[varId] = value;
148
- return true;
221
+ let byStep = contributions.get(varId);
222
+ if (!byStep) {
223
+ byStep = new Map();
224
+ contributions.set(varId, byStep);
149
225
  }
150
- // False overrides propagated true
151
- if (value === false && current === true) {
152
- vars[varId] = false;
153
- return true;
154
- }
155
- return false;
226
+ byStep.set(`${step.expressionId}|${String(value)}`, { step, value });
227
+ const current = vars[varId] ?? null;
228
+ const merged = joinKnowledge(current, value);
229
+ if (merged === current)
230
+ return false;
231
+ vars[varId] = merged;
232
+ return true;
156
233
  };
157
- // Two-phase propagation: rejections first (to establish false values),
158
- // then acceptances (which only fill remaining unknowns).
159
- // This prevents acceptance from deriving values through chains that
160
- // are later invalidated by rejection.
161
- for (const phase of ["rejected", "accepted"]) {
162
- let changed = true;
163
- while (changed) {
164
- changed = false;
165
- for (const [exprId, expr] of exprById) {
166
- if (expr.type !== "operator")
167
- continue;
168
- const state = opAssignments[exprId];
169
- if (state !== phase)
170
- continue;
171
- const op = expr
172
- .operator;
173
- const children = childrenOf.get(exprId) ?? [];
174
- if (state === "accepted") {
175
- switch (op) {
176
- case "not": {
177
- // ¬A accepted (= true) => child must be false
178
- if (children.length > 0) {
179
- if (trySetChild(children[0], false))
180
- changed = true;
181
- }
182
- break;
183
- }
184
- case "and": {
185
- // A ∧ B accepted => all children must be true
186
- for (const child of children) {
187
- if (trySetChild(child, true))
188
- changed = true;
189
- }
190
- break;
191
- }
192
- case "or": {
193
- // A ∨ B accepted: if all-but-one are false, remaining must be true
194
- const unknownChildren = [];
195
- let allOthersAreFalse = true;
196
- for (const child of children) {
197
- const childValue = resolveValue(child.id);
198
- if (childValue === null) {
199
- unknownChildren.push(child);
200
- }
201
- else if (childValue !== false) {
202
- allOthersAreFalse = false;
203
- }
204
- }
205
- if (unknownChildren.length === 1 &&
206
- allOthersAreFalse) {
207
- if (trySetChild(unknownChildren[0], true))
208
- changed = true;
209
- }
210
- break;
211
- }
212
- case "implies": {
213
- // A → B accepted: if A=true => B=true; if B=false => A=false
214
- if (children.length >= 2) {
215
- const leftValue = resolveValue(children[0].id);
216
- const rightValue = resolveValue(children[1].id);
217
- if (leftValue === true) {
218
- if (trySetChild(children[1], true))
219
- changed = true;
220
- }
221
- if (rightValue === false) {
222
- if (trySetChild(children[0], false))
223
- changed = true;
224
- }
225
- }
226
- break;
227
- }
228
- case "iff": {
229
- // A ↔ B accepted: if A known => B matches; if B known => A matches
230
- if (children.length >= 2) {
231
- const leftValue = resolveValue(children[0].id);
232
- const rightValue = resolveValue(children[1].id);
233
- if (leftValue !== null) {
234
- if (trySetChild(children[1], leftValue))
235
- changed = true;
236
- }
237
- if (rightValue !== null) {
238
- if (trySetChild(children[0], rightValue))
239
- changed = true;
240
- }
241
- }
242
- break;
243
- }
234
+ // One pass over accepted operators; a rejection propagates nothing. Every
235
+ // trigger below reads a truth *component* rather than an exact value, so
236
+ // it can only start holding as the closure learns more, never stop.
237
+ let changed = true;
238
+ while (changed) {
239
+ changed = false;
240
+ for (const [exprId, expr] of exprById) {
241
+ if (expr.type !== "operator")
242
+ continue;
243
+ if (opAssignments[exprId] !== "accepted")
244
+ continue;
245
+ const op = expr.operator;
246
+ const children = childrenOf.get(exprId) ?? [];
247
+ const stepFrom = (consumedExpressionIds) => ({
248
+ expressionId: exprId,
249
+ premiseId: premiseIdOf.get(exprId),
250
+ fromVariableIds: [
251
+ ...new Set(consumedExpressionIds.flatMap(collectValuedVariableIds)),
252
+ ],
253
+ });
254
+ switch (op) {
255
+ case "not": {
256
+ // ¬A accepted (= true) => child must be false
257
+ if (children.length > 0) {
258
+ if (mergeIntoChild(children[0], false, stepFrom([])))
259
+ changed = true;
244
260
  }
261
+ break;
245
262
  }
246
- else {
247
- // state === "rejected" — expression forced false
248
- switch (op) {
249
- case "not": {
250
- // ¬A rejected (= false) => child must be true
251
- if (children.length > 0) {
252
- if (trySetChild(children[0], true))
253
- changed = true;
254
- }
255
- break;
263
+ case "and": {
264
+ // A ∧ B accepted => all children must be true
265
+ for (const child of children) {
266
+ if (mergeIntoChild(child, true, stepFrom([])))
267
+ changed = true;
268
+ }
269
+ break;
270
+ }
271
+ case "or": {
272
+ // A ∨ B accepted: a child whose every sibling is known
273
+ // false must itself be true.
274
+ const isFalse = children.map((child) => hasFalseComponent(resolveValue(child.id)));
275
+ for (const [index, child] of children.entries()) {
276
+ const siblingsAllFalse = isFalse.every((value, other) => other === index || value);
277
+ if (!siblingsAllFalse)
278
+ continue;
279
+ const consumed = children
280
+ .filter((_, other) => other !== index)
281
+ .map((sibling) => sibling.id);
282
+ if (mergeIntoChild(child, true, stepFrom(consumed)))
283
+ changed = true;
284
+ }
285
+ break;
286
+ }
287
+ case "implies": {
288
+ // A → B accepted: A true => B true; B false => A false
289
+ if (children.length >= 2) {
290
+ const leftValue = resolveValue(children[0].id);
291
+ const rightValue = resolveValue(children[1].id);
292
+ if (hasTrueComponent(leftValue)) {
293
+ if (mergeIntoChild(children[1], true, stepFrom([children[0].id])))
294
+ changed = true;
256
295
  }
257
- case "and": {
258
- // A ∧ B rejected (= false): if all-but-one are true, remaining must be false
259
- const unknownChildren = [];
260
- let allOthersAreTrue = true;
261
- for (const child of children) {
262
- const childValue = resolveValue(child.id);
263
- if (childValue === null) {
264
- unknownChildren.push(child);
265
- }
266
- else if (childValue !== true) {
267
- allOthersAreTrue = false;
268
- }
269
- }
270
- if (unknownChildren.length === 1 &&
271
- allOthersAreTrue) {
272
- if (trySetChild(unknownChildren[0], false))
273
- changed = true;
274
- }
275
- break;
296
+ if (hasFalseComponent(rightValue)) {
297
+ if (mergeIntoChild(children[0], false, stepFrom([children[1].id])))
298
+ changed = true;
276
299
  }
277
- case "or": {
278
- // A ∨ B rejected (= false) => all children must be false
279
- for (const child of children) {
280
- if (trySetChild(child, false))
300
+ }
301
+ break;
302
+ }
303
+ case "iff": {
304
+ // A ↔ B accepted: each side carries its components across
305
+ if (children.length >= 2) {
306
+ const sides = [
307
+ [children[0], children[1]],
308
+ [children[1], children[0]],
309
+ ];
310
+ for (const [source, target] of sides) {
311
+ const sourceValue = resolveValue(source.id);
312
+ const step = stepFrom([source.id]);
313
+ if (hasTrueComponent(sourceValue)) {
314
+ if (mergeIntoChild(target, true, step))
281
315
  changed = true;
282
316
  }
283
- break;
284
- }
285
- case "implies": {
286
- // A → B rejected (= false) => A must be true, B must be false
287
- if (children.length >= 2) {
288
- if (trySetChild(children[0], true))
317
+ if (hasFalseComponent(sourceValue)) {
318
+ if (mergeIntoChild(target, false, step))
289
319
  changed = true;
290
- if (trySetChild(children[1], false))
291
- changed = true;
292
- }
293
- break;
294
- }
295
- case "iff": {
296
- // A ↔ B rejected (= false): if A known => B is opposite; if B known => A is opposite
297
- if (children.length >= 2) {
298
- const leftValue = resolveValue(children[0].id);
299
- const rightValue = resolveValue(children[1].id);
300
- if (leftValue !== null) {
301
- if (trySetChild(children[1], !leftValue))
302
- changed = true;
303
- }
304
- if (rightValue !== null) {
305
- if (trySetChild(children[0], !rightValue))
306
- changed = true;
307
- }
308
320
  }
309
- break;
310
321
  }
311
322
  }
323
+ break;
312
324
  }
313
325
  }
314
326
  }
315
327
  }
316
- return vars;
328
+ /** Stable step order, so provenance never depends on visitation order. */
329
+ const sortSteps = (entries) => [...entries]
330
+ .sort((a, b) => a.step.premiseId.localeCompare(b.step.premiseId) ||
331
+ a.step.expressionId.localeCompare(b.step.expressionId) ||
332
+ String(a.value).localeCompare(String(b.value)))
333
+ .map((entry) => entry.step);
334
+ const provenance = {};
335
+ for (const varId of new Set([
336
+ ...Object.keys(vars),
337
+ ...contributions.keys(),
338
+ ])) {
339
+ const value = vars[varId] ?? null;
340
+ const steps = sortSteps([...(contributions.get(varId)?.values() ?? [])]);
341
+ if (value === CONTESTED) {
342
+ provenance[varId] = {
343
+ value,
344
+ origin: "contested",
345
+ contestedBy: steps,
346
+ };
347
+ }
348
+ else if (userAssigned.has(varId)) {
349
+ provenance[varId] = { value, origin: "asserted" };
350
+ }
351
+ else if (value !== null && steps.length > 0) {
352
+ provenance[varId] = {
353
+ value,
354
+ origin: "derived",
355
+ derivedBy: steps[0],
356
+ };
357
+ }
358
+ else {
359
+ provenance[varId] = { value, origin: "unassigned" };
360
+ }
361
+ }
362
+ return { variables: vars, provenance };
317
363
  }
318
364
  /**
319
365
  * Evaluates an argument under a three-valued expression assignment.
@@ -370,42 +416,49 @@ export function evaluateArgument(ctx, assignment, options) {
370
416
  return true;
371
417
  return false;
372
418
  });
373
- // Run operator constraint propagation
374
- const propagatedVars = propagateOperatorConstraints(ctx, assignment);
375
- const propagatedAssignment = {
376
- variables: propagatedVars,
377
- operatorAssignments: assignment.operatorAssignments,
378
- };
419
+ // A rejection strikes the premise it lives in: that premise stops
420
+ // constraining the evaluation and asserts nothing. The conclusion premise
421
+ // and derivation premises are exempt — a rejection recorded against
422
+ // either is ignored, and observably so, because the struck set is
423
+ // reported.
424
+ const struckPremiseIds = allRelevantPremises
425
+ .filter((pm) => pm.getId() !== ctx.conclusionPremiseId &&
426
+ pm.getPremiseType?.() !== "derivation" &&
427
+ pm
428
+ .getExpressions()
429
+ .some((expr) => assignment.operatorAssignments[expr.id] ===
430
+ "rejected"))
431
+ .map((pm) => pm.getId());
432
+ const struckIds = new Set(struckPremiseIds);
379
433
  try {
380
- // Build a resolver that lazily evaluates premise-bound variables
381
- // by evaluating their bound premise's expression tree under the
382
- // same assignment. Results are cached per-variable per-evaluate call.
383
- const resolverCache = new Map();
384
- const resolver = (variableId) => {
385
- if (resolverCache.has(variableId)) {
386
- return resolverCache.get(variableId);
387
- }
388
- const variable = ctx.getVariable(variableId);
389
- if (!variable ||
390
- !isPremiseBound(variable) ||
391
- variable.boundArgumentId !== ctx.argumentId) {
392
- // Claim-bound or externally-bound: read from assignment
393
- return propagatedAssignment.variables[variableId] ?? null;
394
- }
395
- // Internal premise-bound: lazy resolution
396
- const boundPremiseId = variable.boundPremiseId;
397
- const boundPremise = ctx.getPremise(boundPremiseId);
398
- if (!boundPremise) {
399
- resolverCache.set(variableId, null);
400
- return null;
401
- }
402
- const premiseResult = boundPremise.evaluate(propagatedAssignment, {
403
- resolver,
434
+ const premiseSetSatisfiable = options?.premiseSetSatisfiable !== undefined
435
+ ? options.premiseSetSatisfiable
436
+ : isPremiseSetSatisfiable(ctx, {
437
+ premises: [
438
+ ...supportingPremises,
439
+ ...constraintPremises,
440
+ ].filter((pm) => !struckIds.has(pm.getId())),
441
+ freeVariableIds: referencedVariableIds,
442
+ // Deliberately not `forcedTrueVariableIds`. That set
443
+ // also decides what counts as a reader's assertion, and
444
+ // a citation belongs in this question but not that one.
445
+ forcedTrueVariableIds: options?.satisfiabilityForcedTrueVariableIds ??
446
+ options?.forcedTrueVariableIds,
404
447
  });
405
- const value = premiseResult?.rootValue ?? null;
406
- resolverCache.set(variableId, value);
407
- return value;
448
+ // Contradicting premises license nothing: exclude every premise from
449
+ // the closure so the reader is shown only what they asserted.
450
+ const derivationSuppressed = premiseSetSatisfiable === false;
451
+ const closureExclusions = derivationSuppressed
452
+ ? new Set(ctx.listPremises().map((pm) => pm.getId()))
453
+ : struckIds;
454
+ const propagation = closeUnderAcceptedOperators(ctx, assignment, {
455
+ excludedPremiseIds: closureExclusions,
456
+ });
457
+ const propagatedAssignment = {
458
+ variables: propagation.variables,
459
+ operatorAssignments: assignment.operatorAssignments,
408
460
  };
461
+ const resolver = createPremiseBoundResolver(ctx, propagatedAssignment);
409
462
  const evalOpts = {
410
463
  strictUnknownKeys: options?.strictUnknownAssignmentKeys ?? false,
411
464
  resolver,
@@ -413,12 +466,100 @@ export function evaluateArgument(ctx, assignment, options) {
413
466
  const conclusionEvaluation = conclusion.evaluate(propagatedAssignment, evalOpts);
414
467
  const supportingEvaluations = supportingPremises.map((pm) => pm.evaluate(propagatedAssignment, evalOpts));
415
468
  const constraintEvaluations = constraintPremises.map((pm) => pm.evaluate(propagatedAssignment, evalOpts));
416
- const isAdmissibleAssignment = constraintEvaluations.reduce((acc, result) => kleeneAnd(acc, result.rootValue ?? null), true);
417
- const allSupportingPremisesTrue = supportingEvaluations.reduce((acc, result) => kleeneAnd(acc, result.rootValue ?? null), true);
469
+ const surviving = (results) => results.filter((result) => !struckIds.has(result.premiseId));
470
+ const isAdmissibleAssignment = surviving(constraintEvaluations).reduce((acc, result) => belnapAnd(acc, result.rootValue ?? null), true);
471
+ const survivingSupport = surviving(supportingEvaluations);
472
+ const survivingSupportingPremisesTrue = survivingSupport.reduce((acc, result) => belnapAnd(acc, result.rootValue ?? null), true);
418
473
  const conclusionTrue = conclusionEvaluation.rootValue ?? null;
419
- const isCounterexample = kleeneAnd(isAdmissibleAssignment, kleeneAnd(allSupportingPremisesTrue, kleeneNot(conclusionTrue)));
474
+ // `survivingSupportingPremisesTrue` folds an empty list to `true`, so
475
+ // when the reader struck every supporting premise there is no case left
476
+ // to weigh — say so rather than reporting that the premises held and
477
+ // the conclusion failed. An argument authored with no supporting
478
+ // premises is the entailment-from-nothing case and keeps its answer.
479
+ const allSupportStruck = supportingEvaluations.length > 0 && survivingSupport.length === 0;
480
+ const premisesHoldConclusionFalse = allSupportStruck
481
+ ? null
482
+ : belnapAnd(isAdmissibleAssignment, belnapAnd(survivingSupportingPremisesTrue, belnapNot(conclusionTrue)));
483
+ // Reported unconditionally, not behind `includeDiagnostics`: a
484
+ // contested value can leave every aggregate above reading clean, so
485
+ // this is the only fact that always records one.
486
+ const contestedVariableIds = Object.entries(propagation.provenance)
487
+ .filter(([, entry]) => entry.value === CONTESTED)
488
+ .map(([variableId]) => variableId)
489
+ .sort();
420
490
  const includeExpressionValues = options?.includeExpressionValues ?? true;
421
491
  const includeDiagnostics = options?.includeDiagnostics ?? true;
492
+ // Attribution: withhold an assertion, recompute closure from what is
493
+ // left, and ask again. Never delete a tag from an already-derived
494
+ // value — the intervention has to be re-derived, not un-derived.
495
+ const forcedTrueVariableIds = options?.forcedTrueVariableIds;
496
+ const isReaderAsserted = (variableId) => forcedTrueVariableIds?.has(variableId) !== true &&
497
+ (assignment.variables[variableId] ?? null) !== null;
498
+ const canDerive = !derivationSuppressed &&
499
+ Object.values(assignment.operatorAssignments).includes("accepted");
500
+ const withhold = (withheldVariableIds) => {
501
+ if (!canDerive) {
502
+ // Nothing can be derived, so closure is the seed minus what
503
+ // was withheld.
504
+ const reduced = { ...assignment.variables };
505
+ for (const variableId of withheldVariableIds)
506
+ delete reduced[variableId];
507
+ return reduced;
508
+ }
509
+ return closeUnderAcceptedOperators(ctx, assignment, {
510
+ excludedPremiseIds: closureExclusions,
511
+ withheldVariableIds,
512
+ }).variables;
513
+ };
514
+ const conclusionClaimVariableIds = [
515
+ ...new Set(conclusion
516
+ .getExpressions()
517
+ .filter((expr) => expr.type === "variable")
518
+ .map((expr) => expr.variableId)),
519
+ ].filter((vid) => {
520
+ if (forcedTrueVariableIds?.has(vid) === true)
521
+ return false;
522
+ const variable = ctx.getVariable(vid);
523
+ return variable != null && isClaimBound(variable);
524
+ });
525
+ let reachedWithoutAssertion = conclusionTrue === true;
526
+ if (conclusionClaimVariableIds.length > 0) {
527
+ const counterfactual = {
528
+ variables: withhold(new Set(conclusionClaimVariableIds)),
529
+ operatorAssignments: assignment.operatorAssignments,
530
+ };
531
+ const rootValue = conclusion.evaluate(counterfactual, {
532
+ strictUnknownKeys: false,
533
+ resolver: createPremiseBoundResolver(ctx, counterfactual),
534
+ }).rootValue;
535
+ reachedWithoutAssertion = (rootValue ?? null) === true;
536
+ }
537
+ const conclusionAttribution = {
538
+ assertedByReader: conclusionClaimVariableIds.some(isReaderAsserted),
539
+ reachedWithoutAssertion,
540
+ };
541
+ // One closure per reader-asserted claim, and only when something could
542
+ // have been derived at all.
543
+ const claimAttribution = includeDiagnostics && canDerive
544
+ ? Object.fromEntries(referencedVariableIds
545
+ .filter((vid) => {
546
+ const variable = ctx.getVariable(vid);
547
+ return (variable != null &&
548
+ isClaimBound(variable) &&
549
+ isReaderAsserted(vid));
550
+ })
551
+ .map((vid) => {
552
+ const asserted = assignment.variables[vid];
553
+ const reclosed = withhold(new Set([vid]));
554
+ return [
555
+ vid,
556
+ {
557
+ assertedByReader: true,
558
+ reachedWithoutAssertion: (reclosed[vid] ?? null) === asserted,
559
+ },
560
+ ];
561
+ }))
562
+ : undefined;
422
563
  const strip = (result) => ({
423
564
  ...result,
424
565
  expressionValues: includeExpressionValues
@@ -434,6 +575,15 @@ export function evaluateArgument(ctx, assignment, options) {
434
575
  propagatedAssignment.variables[vid] ?? null,
435
576
  ]))
436
577
  : undefined;
578
+ const variableProvenance = includeDiagnostics
579
+ ? Object.fromEntries(referencedVariableIds.map((vid) => [
580
+ vid,
581
+ propagation.provenance[vid] ?? {
582
+ value: null,
583
+ origin: "unassigned",
584
+ },
585
+ ]))
586
+ : undefined;
437
587
  return {
438
588
  ok: true,
439
589
  assignment: {
@@ -446,12 +596,18 @@ export function evaluateArgument(ctx, assignment, options) {
446
596
  conclusion: strip(conclusionEvaluation),
447
597
  supportingPremises: supportingEvaluations.map(strip),
448
598
  constraintPremises: constraintEvaluations.map(strip),
599
+ struckPremiseIds,
600
+ survivingSupportingPremiseCount: survivingSupport.length,
449
601
  isAdmissibleAssignment,
450
- allSupportingPremisesTrue,
602
+ survivingSupportingPremisesTrue,
451
603
  conclusionTrue,
452
- isCounterexample,
453
- preservesTruthUnderAssignment: kleeneNot(isCounterexample),
604
+ premisesHoldConclusionFalse,
605
+ contestedVariableIds,
606
+ conclusionAttribution,
607
+ claimAttribution,
608
+ premiseSetSatisfiable,
454
609
  propagatedVariableValues,
610
+ variableProvenance,
455
611
  };
456
612
  }
457
613
  catch (error) {
@@ -542,6 +698,15 @@ export function checkArgumentValidity(ctx, options) {
542
698
  ]),
543
699
  };
544
700
  }
701
+ // The generated assignments carry no operator decisions, so nothing is
702
+ // ever struck and the premise set is the same on every row. Computing it
703
+ // once here and threading it through keeps the search 2^n rather than
704
+ // 2^n × 2^n.
705
+ const premiseSetSatisfiable = isPremiseSetSatisfiable(ctx, {
706
+ premises: [...supportingPremises, ...constraintPremises],
707
+ freeVariableIds: checkedVariableIds,
708
+ forcedTrueVariableIds,
709
+ });
545
710
  const mode = options?.mode ?? "firstCounterexample";
546
711
  const maxAssignmentsChecked = options?.maxAssignmentsChecked;
547
712
  const counterexamples = [];
@@ -571,6 +736,8 @@ export function checkArgumentValidity(ctx, options) {
571
736
  validateFirst: false,
572
737
  includeExpressionValues: options?.includeCounterexampleEvaluations ?? false,
573
738
  includeDiagnostics: options?.includeCounterexampleEvaluations ?? false,
739
+ forcedTrueVariableIds,
740
+ premiseSetSatisfiable,
574
741
  });
575
742
  if (!result.ok) {
576
743
  return {
@@ -582,7 +749,7 @@ export function checkArgumentValidity(ctx, options) {
582
749
  if (result.isAdmissibleAssignment === true) {
583
750
  numAdmissibleAssignments += 1;
584
751
  }
585
- if (result.isCounterexample === true) {
752
+ if (result.premisesHoldConclusionFalse === true) {
586
753
  counterexamples.push({
587
754
  assignment: result.assignment,
588
755
  result,