@mikeargento/bitgraph-player 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +98 -0
- package/SPEC.md +234 -0
- package/dist/__tests__/cast-order.test.d.ts +2 -0
- package/dist/__tests__/cast-order.test.d.ts.map +1 -0
- package/dist/__tests__/cast-order.test.js +434 -0
- package/dist/__tests__/cast-order.test.js.map +1 -0
- package/dist/__tests__/evaluate-verdict.test.d.ts +2 -0
- package/dist/__tests__/evaluate-verdict.test.d.ts.map +1 -0
- package/dist/__tests__/evaluate-verdict.test.js +228 -0
- package/dist/__tests__/evaluate-verdict.test.js.map +1 -0
- package/dist/__tests__/fixtures.d.ts +63 -0
- package/dist/__tests__/fixtures.d.ts.map +1 -0
- package/dist/__tests__/fixtures.js +150 -0
- package/dist/__tests__/fixtures.js.map +1 -0
- package/dist/__tests__/logic-rule.test.d.ts +2 -0
- package/dist/__tests__/logic-rule.test.d.ts.map +1 -0
- package/dist/__tests__/logic-rule.test.js +190 -0
- package/dist/__tests__/logic-rule.test.js.map +1 -0
- package/dist/cast.d.ts +29 -0
- package/dist/cast.d.ts.map +1 -0
- package/dist/cast.js +85 -0
- package/dist/cast.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +130 -0
- package/dist/cli.js.map +1 -0
- package/dist/evaluate.d.ts +30 -0
- package/dist/evaluate.d.ts.map +1 -0
- package/dist/evaluate.js +177 -0
- package/dist/evaluate.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/logic.d.ts +16 -0
- package/dist/logic.d.ts.map +1 -0
- package/dist/logic.js +29 -0
- package/dist/logic.js.map +1 -0
- package/dist/order.d.ts +50 -0
- package/dist/order.d.ts.map +1 -0
- package/dist/order.js +290 -0
- package/dist/order.js.map +1 -0
- package/dist/rule.d.ts +40 -0
- package/dist/rule.d.ts.map +1 -0
- package/dist/rule.js +324 -0
- package/dist/rule.js.map +1 -0
- package/dist/types.d.ts +205 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +16 -0
- package/dist/types.js.map +1 -0
- package/dist/verdict.d.ts +14 -0
- package/dist/verdict.d.ts.map +1 -0
- package/dist/verdict.js +146 -0
- package/dist/verdict.js.map +1 -0
- package/package.json +42 -0
- package/src/__tests__/cast-order.test.ts +473 -0
- package/src/__tests__/evaluate-verdict.test.ts +266 -0
- package/src/__tests__/fixtures.ts +206 -0
- package/src/__tests__/logic-rule.test.ts +221 -0
- package/src/cast.ts +120 -0
- package/src/cli.ts +143 -0
- package/src/evaluate.ts +238 -0
- package/src/index.ts +38 -0
- package/src/logic.ts +39 -0
- package/src/order.ts +380 -0
- package/src/rule.ts +340 -0
- package/src/types.ts +224 -0
- package/src/verdict.ts +159 -0
package/src/cast.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cast resolution: digest -> recording.
|
|
5
|
+
*
|
|
6
|
+
* A digest identifies bits; a BitGraph recording identifies an OCCURRENCE
|
|
7
|
+
* of those bits at a causal position. The same bits can hold many
|
|
8
|
+
* positions by design (the ledger's by-digest index is one entry per
|
|
9
|
+
* causal position), so a role must resolve to exactly one recording:
|
|
10
|
+
*
|
|
11
|
+
* exactly 1 verified match -> resolved
|
|
12
|
+
* 0 matches, optional -> absent (a definite fact under the closed world)
|
|
13
|
+
* 0 matches, required -> absent (evaluates to UNDETERMINED)
|
|
14
|
+
* 2+ matches, no pin -> ambiguous (UNDETERMINED, never a silent pick)
|
|
15
|
+
* 2+ matches, pin selects exactly 1 -> resolved
|
|
16
|
+
*
|
|
17
|
+
* Only proofs whose canonical verification passed (status "verified", at
|
|
18
|
+
* either tier) count as recordings. A file shaped like a proof that fails
|
|
19
|
+
* verification is noise, not evidence — but if matches exist and ALL fail
|
|
20
|
+
* verification, that is surfaced as invalid rather than silently treated
|
|
21
|
+
* as absence, because "the bundle contains only broken recordings of this
|
|
22
|
+
* digest" and "the bundle contains no recordings of this digest" are
|
|
23
|
+
* different situations and only one of them supports a closed-world
|
|
24
|
+
* absence claim.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { AuditResult, ObservedProof } from "@mikeargento/bitgraph-audit";
|
|
28
|
+
import { decodeDigestBytes, normalizeDigest } from "./rule.js";
|
|
29
|
+
import type { CastEntry, Resolution } from "./types.js";
|
|
30
|
+
|
|
31
|
+
function matchesPin(proof: ObservedProof, pin: NonNullable<CastEntry["at"]>): boolean {
|
|
32
|
+
if ("proofHash" in pin) return proof.proofHash === pin.proofHash;
|
|
33
|
+
return proof.epochId === pin.epochId && proof.counter === pin.counter;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A recording counts when its canonical verification PASSED at either
|
|
38
|
+
* tier: "verified" is the full-tier pass (artifact bytes present and
|
|
39
|
+
* matching), "artifact-unavailable" is the integrity-tier pass (the proof
|
|
40
|
+
* verifies bytes-free; the digest is inside the signed body either way).
|
|
41
|
+
*/
|
|
42
|
+
function verificationPassed(proof: ObservedProof): boolean {
|
|
43
|
+
const status = proof.verification?.status;
|
|
44
|
+
return status === "verified" || status === "artifact-unavailable";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function resolveRole(role: string, entry: CastEntry, audit: AuditResult): Resolution {
|
|
48
|
+
const digestB64 = normalizeDigest(entry.digest);
|
|
49
|
+
if (digestB64 === undefined) {
|
|
50
|
+
// parseRule already rejects malformed digests; this guards direct API use.
|
|
51
|
+
return { kind: "invalid", role, reason: "digest is not a well-formed 32-byte SHA-256" };
|
|
52
|
+
}
|
|
53
|
+
const targetBytes = decodeDigestBytes(digestB64) as Buffer;
|
|
54
|
+
|
|
55
|
+
const anyMatch: ObservedProof[] = [];
|
|
56
|
+
const verified: ObservedProof[] = [];
|
|
57
|
+
for (const proof of audit.ingest.proofs) {
|
|
58
|
+
// Byte-level match: a hostile file may spell its digest any way it
|
|
59
|
+
// likes, and "only broken recordings exist" must not masquerade as
|
|
60
|
+
// "no recordings exist".
|
|
61
|
+
const proofDigest = decodeDigestBytes(proof.proof.artifact.digestB64);
|
|
62
|
+
if (proofDigest === undefined || !proofDigest.equals(targetBytes)) continue;
|
|
63
|
+
anyMatch.push(proof);
|
|
64
|
+
if (verificationPassed(proof)) verified.push(proof);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (verified.length === 0) {
|
|
68
|
+
if (anyMatch.length > 0) {
|
|
69
|
+
return {
|
|
70
|
+
kind: "invalid",
|
|
71
|
+
role,
|
|
72
|
+
reason: `${anyMatch.length} recording(s) of this digest are present but none passed verification`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return { kind: "absent", role, optional: entry.optional === true };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let candidates = verified;
|
|
79
|
+
if (entry.at !== undefined) {
|
|
80
|
+
const pin = entry.at;
|
|
81
|
+
candidates = verified.filter((p) => matchesPin(p, pin));
|
|
82
|
+
if (candidates.length === 0) {
|
|
83
|
+
return {
|
|
84
|
+
kind: "invalid",
|
|
85
|
+
role,
|
|
86
|
+
reason: `"at" pin matches none of the ${verified.length} verified recording(s) of this digest`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (candidates.length > 1) {
|
|
92
|
+
return {
|
|
93
|
+
kind: "ambiguous",
|
|
94
|
+
role,
|
|
95
|
+
matchCount: candidates.length,
|
|
96
|
+
candidates: candidates.map((p) => p.proofHash).sort(),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const proof = candidates[0] as ObservedProof;
|
|
101
|
+
return {
|
|
102
|
+
kind: "resolved",
|
|
103
|
+
role,
|
|
104
|
+
proof,
|
|
105
|
+
verificationTier: proof.verification?.tier ?? "integrity",
|
|
106
|
+
matchCount: verified.length,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Resolve every declared role. Deterministic: iterates the cast in declaration order. */
|
|
111
|
+
export function resolveCast(
|
|
112
|
+
cast: Record<string, CastEntry>,
|
|
113
|
+
audit: AuditResult
|
|
114
|
+
): Map<string, Resolution> {
|
|
115
|
+
const out = new Map<string, Resolution>();
|
|
116
|
+
for (const [role, entry] of Object.entries(cast)) {
|
|
117
|
+
out.set(role, resolveRole(role, entry, audit));
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* bitgraph-play <rule.json> <bundle> [--out <file>] [--summary]
|
|
6
|
+
*
|
|
7
|
+
* Evaluates a bitgraph-player/1 rule against a proof bundle (directory,
|
|
8
|
+
* .tar, .tar.gz, or .tgz) and writes the verdict JSON to stdout (or
|
|
9
|
+
* --out). Offline: the audit pipeline and the evaluator make no network
|
|
10
|
+
* requests of any kind.
|
|
11
|
+
*
|
|
12
|
+
* Exit codes: 0 TRUE, 1 FALSE, 2 UNDETERMINED, 3 error.
|
|
13
|
+
* Diagnostics go to stderr; stdout carries verdict bytes only.
|
|
14
|
+
*
|
|
15
|
+
* The process exits by setting process.exitCode and returning, never by
|
|
16
|
+
* process.exit(): exiting early would truncate a verdict still draining
|
|
17
|
+
* to a pipe.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { runAudit } from "@mikeargento/bitgraph-audit";
|
|
23
|
+
import type { AuditResult } from "@mikeargento/bitgraph-audit";
|
|
24
|
+
import { resolveCast } from "./cast.js";
|
|
25
|
+
import { evaluate } from "./evaluate.js";
|
|
26
|
+
import { parseRule, RuleError } from "./rule.js";
|
|
27
|
+
import { buildVerdict, serializeVerdict } from "./verdict.js";
|
|
28
|
+
|
|
29
|
+
function usage(): number {
|
|
30
|
+
process.stderr.write(
|
|
31
|
+
"usage: bitgraph-play <rule.json> <bundle> [--out <file>] [--summary]\n" +
|
|
32
|
+
" exit codes: 0 TRUE, 1 FALSE, 2 UNDETERMINED, 3 error\n"
|
|
33
|
+
);
|
|
34
|
+
return 3;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function printSummary(audit: AuditResult): void {
|
|
38
|
+
const lines: string[] = [];
|
|
39
|
+
const c = audit.ingest.counts;
|
|
40
|
+
lines.push(
|
|
41
|
+
`bundle: ${c.observed} observed proof(s), ${c.artifacts} artifact(s), ${c.witnesses} witness file(s)`
|
|
42
|
+
);
|
|
43
|
+
for (const partition of audit.reconstruction.partitions) {
|
|
44
|
+
const key = partition.key;
|
|
45
|
+
lines.push(
|
|
46
|
+
`partition epoch=${key.epochId ?? "(none)"} chain=${key.chainId} key=${key.publicKeyB64.slice(0, 12)}…: ` +
|
|
47
|
+
`${partition.memberProofHashes.length} proof(s) in ${partition.components.length} component(s)`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
const rel = audit.reconstruction.epochRelationships;
|
|
51
|
+
lines.push(
|
|
52
|
+
`epochs: ${rel.epochs.length} observed, ${rel.orderedPairs.length} lineage-ordered pair(s), ` +
|
|
53
|
+
`${audit.temporal.anchorOrderedPairs.length} anchor-ordered pair(s)`
|
|
54
|
+
);
|
|
55
|
+
lines.push(
|
|
56
|
+
`unchained: ${audit.reconstruction.unchainedProofHashes.length}, unpartitioned: ${audit.reconstruction.unpartitionedProofHashes.length}`
|
|
57
|
+
);
|
|
58
|
+
process.stderr.write(lines.join("\n") + "\n");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function main(): Promise<number> {
|
|
62
|
+
const args = process.argv.slice(2);
|
|
63
|
+
const positional: string[] = [];
|
|
64
|
+
let outFile: string | undefined;
|
|
65
|
+
let summary = false;
|
|
66
|
+
for (let i = 0; i < args.length; i++) {
|
|
67
|
+
const arg = args[i] as string;
|
|
68
|
+
if (arg === "--out") {
|
|
69
|
+
const next = args[++i];
|
|
70
|
+
if (next === undefined || next.startsWith("-")) return usage();
|
|
71
|
+
outFile = next;
|
|
72
|
+
} else if (arg === "--summary") {
|
|
73
|
+
summary = true;
|
|
74
|
+
} else if (arg.startsWith("-")) {
|
|
75
|
+
return usage();
|
|
76
|
+
} else {
|
|
77
|
+
positional.push(arg);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (positional.length !== 2) return usage();
|
|
81
|
+
const [rulePath, bundlePath] = positional as [string, string];
|
|
82
|
+
|
|
83
|
+
let ruleBytes: Buffer;
|
|
84
|
+
try {
|
|
85
|
+
ruleBytes = readFileSync(rulePath);
|
|
86
|
+
} catch (err) {
|
|
87
|
+
process.stderr.write(`error: cannot read rule file: ${(err as Error).message}\n`);
|
|
88
|
+
return 3;
|
|
89
|
+
}
|
|
90
|
+
const ruleSha256Hex = createHash("sha256").update(ruleBytes).digest("hex");
|
|
91
|
+
|
|
92
|
+
let rule;
|
|
93
|
+
try {
|
|
94
|
+
rule = parseRule(ruleBytes.toString("utf8"));
|
|
95
|
+
} catch (err) {
|
|
96
|
+
if (err instanceof RuleError) {
|
|
97
|
+
process.stderr.write("error: invalid rule\n");
|
|
98
|
+
for (const issue of err.issues) process.stderr.write(` - ${issue}\n`);
|
|
99
|
+
} else {
|
|
100
|
+
process.stderr.write(`error: ${(err as Error).message}\n`);
|
|
101
|
+
}
|
|
102
|
+
return 3;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let audit: AuditResult;
|
|
106
|
+
try {
|
|
107
|
+
audit = await runAudit(bundlePath);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
process.stderr.write(`error: bundle audit failed: ${(err as Error).message}\n`);
|
|
110
|
+
return 3;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const resolutions = resolveCast(rule.cast, audit);
|
|
114
|
+
const evaluation = evaluate(rule, resolutions, audit);
|
|
115
|
+
const verdict = buildVerdict(rule, ruleSha256Hex, resolutions, evaluation, audit);
|
|
116
|
+
const bytes = serializeVerdict(verdict);
|
|
117
|
+
|
|
118
|
+
if (outFile !== undefined) {
|
|
119
|
+
writeFileSync(outFile, bytes);
|
|
120
|
+
} else {
|
|
121
|
+
process.stdout.write(bytes);
|
|
122
|
+
}
|
|
123
|
+
if (summary) printSummary(audit);
|
|
124
|
+
|
|
125
|
+
switch (evaluation.result) {
|
|
126
|
+
case "TRUE":
|
|
127
|
+
return 0;
|
|
128
|
+
case "FALSE":
|
|
129
|
+
return 1;
|
|
130
|
+
case "UNDETERMINED":
|
|
131
|
+
return 2;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
main().then(
|
|
136
|
+
(code) => {
|
|
137
|
+
process.exitCode = code;
|
|
138
|
+
},
|
|
139
|
+
(err: unknown) => {
|
|
140
|
+
process.stderr.write(`error: ${(err as Error).message}\n`);
|
|
141
|
+
process.exitCode = 3;
|
|
142
|
+
}
|
|
143
|
+
);
|
package/src/evaluate.ts
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The evaluator: walks the claim tree over resolved recordings and the
|
|
5
|
+
* ordering adapter, under strong Kleene semantics.
|
|
6
|
+
*
|
|
7
|
+
* Deliberate properties:
|
|
8
|
+
*
|
|
9
|
+
* - FULL WALK, no short-circuit. Every sub-claim is evaluated and recorded
|
|
10
|
+
* so the verdict is a complete replayable trace, not just an outcome.
|
|
11
|
+
*
|
|
12
|
+
* - CLOSED WORLD, scoped to the cast. An absent OPTIONAL role is a
|
|
13
|
+
* definite absence within the declared universe: positive claims about
|
|
14
|
+
* it are FALSE, so negatives over it hold. An absent REQUIRED role, an
|
|
15
|
+
* ambiguous role, and a role the cast never declared are UNDETERMINED —
|
|
16
|
+
* the evidence does not decide, and saying FALSE would be a lie.
|
|
17
|
+
*
|
|
18
|
+
* - THE FLOOR GATES EVIDENCE, NOT POLARITY. When an ordering answer rests
|
|
19
|
+
* on a tier below requires.ordering, the answer is UNDETERMINED whether
|
|
20
|
+
* it would have been TRUE or FALSE: the rule author said not to trust
|
|
21
|
+
* that evidence in either direction.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { AuditResult, ObservedProof } from "@mikeargento/bitgraph-audit";
|
|
25
|
+
import { kleeneAll, kleeneAny, kleeneNot } from "./logic.js";
|
|
26
|
+
import { compare } from "./order.js";
|
|
27
|
+
import { basisTier, meetsFloor } from "./types.js";
|
|
28
|
+
import type {
|
|
29
|
+
Claim,
|
|
30
|
+
DerivedStep,
|
|
31
|
+
EvidenceTier,
|
|
32
|
+
Resolution,
|
|
33
|
+
Rule,
|
|
34
|
+
ThreeValued,
|
|
35
|
+
} from "./types.js";
|
|
36
|
+
|
|
37
|
+
export interface Evaluation {
|
|
38
|
+
result: ThreeValued;
|
|
39
|
+
steps: DerivedStep[];
|
|
40
|
+
/** Weakest tier among ordering answers that actually decided a step. */
|
|
41
|
+
weakestEvidence?: EvidenceTier;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface Ctx {
|
|
45
|
+
rule: Rule;
|
|
46
|
+
resolutions: Map<string, Resolution>;
|
|
47
|
+
audit: AuditResult;
|
|
48
|
+
steps: DerivedStep[];
|
|
49
|
+
usedTiers: Set<EvidenceTier>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function positionOf(proof: ObservedProof): Record<string, unknown> {
|
|
53
|
+
const out: Record<string, unknown> = { proofHash: proof.proofHash };
|
|
54
|
+
if (proof.epochId !== undefined) out["epochId"] = proof.epochId;
|
|
55
|
+
out["chainId"] = proof.chainId;
|
|
56
|
+
if (proof.counter !== undefined) out["counter"] = proof.counter;
|
|
57
|
+
if (proof.slotCounter !== undefined) out["slotCounter"] = proof.slotCounter;
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Classify a role for predicate purposes.
|
|
63
|
+
* resolved -> use the recording
|
|
64
|
+
* definitely-absent -> optional and absent: closed-world FALSE material
|
|
65
|
+
* undetermined -> everything else, with the reason
|
|
66
|
+
*/
|
|
67
|
+
function classify(
|
|
68
|
+
ctx: Ctx,
|
|
69
|
+
role: string
|
|
70
|
+
):
|
|
71
|
+
| { kind: "resolved"; proof: ObservedProof }
|
|
72
|
+
| { kind: "definitely-absent" }
|
|
73
|
+
| { kind: "undetermined"; reason: string } {
|
|
74
|
+
const res = ctx.resolutions.get(role);
|
|
75
|
+
if (res === undefined) {
|
|
76
|
+
return { kind: "undetermined", reason: `role "${role}" is not declared in the cast` };
|
|
77
|
+
}
|
|
78
|
+
switch (res.kind) {
|
|
79
|
+
case "resolved":
|
|
80
|
+
return { kind: "resolved", proof: res.proof };
|
|
81
|
+
case "absent":
|
|
82
|
+
return res.optional
|
|
83
|
+
? { kind: "definitely-absent" }
|
|
84
|
+
: {
|
|
85
|
+
kind: "undetermined",
|
|
86
|
+
reason: `required role "${role}" has no verified recording in the bundle`,
|
|
87
|
+
};
|
|
88
|
+
case "ambiguous":
|
|
89
|
+
return {
|
|
90
|
+
kind: "undetermined",
|
|
91
|
+
reason: `role "${role}" matches ${res.matchCount} recordings and no "at" pin selects one`,
|
|
92
|
+
};
|
|
93
|
+
case "invalid":
|
|
94
|
+
return { kind: "undetermined", reason: `role "${role}": ${res.reason}` };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function record(ctx: Ctx, step: DerivedStep): ThreeValued {
|
|
99
|
+
ctx.steps.push(step);
|
|
100
|
+
return step.result;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function evalExists(ctx: Ctx, role: string): ThreeValued {
|
|
104
|
+
const c = classify(ctx, role);
|
|
105
|
+
const claim = `exists(${role})`;
|
|
106
|
+
if (c.kind === "resolved") {
|
|
107
|
+
return record(ctx, {
|
|
108
|
+
claim,
|
|
109
|
+
result: "TRUE",
|
|
110
|
+
because: { recording: positionOf(c.proof) },
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
if (c.kind === "definitely-absent") {
|
|
114
|
+
return record(ctx, {
|
|
115
|
+
claim,
|
|
116
|
+
result: "FALSE",
|
|
117
|
+
because: {
|
|
118
|
+
reason: `no verified recording of the declared digest is in the bundle; definite absence within the declared closed world`,
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return record(ctx, { claim, result: "UNDETERMINED", because: { reason: c.reason } });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Shared body of before/after: asks whether `x` precedes `y`. */
|
|
126
|
+
function evalPrecedes(ctx: Ctx, claim: string, x: string, y: string): ThreeValued {
|
|
127
|
+
const cx = classify(ctx, x);
|
|
128
|
+
const cy = classify(ctx, y);
|
|
129
|
+
|
|
130
|
+
for (const [role, c] of [
|
|
131
|
+
[x, cx],
|
|
132
|
+
[y, cy],
|
|
133
|
+
] as const) {
|
|
134
|
+
if (c.kind === "undetermined") {
|
|
135
|
+
return record(ctx, {
|
|
136
|
+
claim,
|
|
137
|
+
result: "UNDETERMINED",
|
|
138
|
+
because: { reason: c.reason, role },
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (cx.kind === "definitely-absent" || cy.kind === "definitely-absent") {
|
|
143
|
+
const absent = cx.kind === "definitely-absent" ? x : y;
|
|
144
|
+
return record(ctx, {
|
|
145
|
+
claim,
|
|
146
|
+
result: "FALSE",
|
|
147
|
+
because: {
|
|
148
|
+
reason: `role "${absent}" is definitely absent within the declared closed world; no ordering claim about it can hold`,
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const a = (cx as { kind: "resolved"; proof: ObservedProof }).proof;
|
|
154
|
+
const b = (cy as { kind: "resolved"; proof: ObservedProof }).proof;
|
|
155
|
+
const order = compare(a, b, ctx.audit);
|
|
156
|
+
const because: Record<string, unknown> = {
|
|
157
|
+
a: positionOf(a),
|
|
158
|
+
b: positionOf(b),
|
|
159
|
+
relation: order.relation,
|
|
160
|
+
detail: order.detail,
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
if (order.relation === "same") {
|
|
164
|
+
return record(ctx, { claim, result: "FALSE", because });
|
|
165
|
+
}
|
|
166
|
+
if (order.relation === "unordered") {
|
|
167
|
+
return record(ctx, { claim, result: "UNDETERMINED", because });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const basis = order.basis as NonNullable<typeof order.basis>;
|
|
171
|
+
// The tier the specific answer rests on — carried by the adapter, not
|
|
172
|
+
// recomputed from the basis family (lineage coverage can downgrade it).
|
|
173
|
+
const tier = order.tier ?? basisTier(basis);
|
|
174
|
+
because["weaker"] = order.weaker;
|
|
175
|
+
because["assumptionDependent"] = order.assumptionDependent;
|
|
176
|
+
|
|
177
|
+
if (!meetsFloor(tier, ctx.rule.requires.ordering)) {
|
|
178
|
+
because["floor"] = ctx.rule.requires.ordering;
|
|
179
|
+
return record(ctx, {
|
|
180
|
+
claim,
|
|
181
|
+
result: "UNDETERMINED",
|
|
182
|
+
basis,
|
|
183
|
+
evidenceTier: tier,
|
|
184
|
+
because: {
|
|
185
|
+
...because,
|
|
186
|
+
reason: `ordering evidence ("${basis}", tier "${tier}") is below the rule's declared floor ("${ctx.rule.requires.ordering}")`,
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
ctx.usedTiers.add(tier);
|
|
192
|
+
return record(ctx, {
|
|
193
|
+
claim,
|
|
194
|
+
result: order.relation === "before" ? "TRUE" : "FALSE",
|
|
195
|
+
basis,
|
|
196
|
+
evidenceTier: tier,
|
|
197
|
+
because,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function evalClaim(ctx: Ctx, claim: Claim): ThreeValued {
|
|
202
|
+
if ("exists" in claim) return evalExists(ctx, claim.exists);
|
|
203
|
+
if ("before" in claim) {
|
|
204
|
+
const [x, y] = claim.before;
|
|
205
|
+
return evalPrecedes(ctx, `before(${x}, ${y})`, x, y);
|
|
206
|
+
}
|
|
207
|
+
if ("after" in claim) {
|
|
208
|
+
const [x, y] = claim.after;
|
|
209
|
+
// after(x, y) holds exactly when y precedes x.
|
|
210
|
+
return evalPrecedes(ctx, `after(${x}, ${y})`, y, x);
|
|
211
|
+
}
|
|
212
|
+
if ("between" in claim) {
|
|
213
|
+
const [subject, lower, upper] = claim.between;
|
|
214
|
+
// between(x, a, b) := after(x, a) AND before(x, b), recorded as two steps.
|
|
215
|
+
const afterPart = evalPrecedes(ctx, `between(${subject}, ${lower}, ${upper}): after(${subject}, ${lower})`, lower, subject);
|
|
216
|
+
const beforePart = evalPrecedes(ctx, `between(${subject}, ${lower}, ${upper}): before(${subject}, ${upper})`, subject, upper);
|
|
217
|
+
return kleeneAll([afterPart, beforePart]);
|
|
218
|
+
}
|
|
219
|
+
if ("all" in claim) return kleeneAll(claim.all.map((c) => evalClaim(ctx, c)));
|
|
220
|
+
if ("any" in claim) return kleeneAny(claim.any.map((c) => evalClaim(ctx, c)));
|
|
221
|
+
return kleeneNot(evalClaim(ctx, claim.not));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function evaluate(
|
|
225
|
+
rule: Rule,
|
|
226
|
+
resolutions: Map<string, Resolution>,
|
|
227
|
+
audit: AuditResult
|
|
228
|
+
): Evaluation {
|
|
229
|
+
const ctx: Ctx = { rule, resolutions, audit, steps: [], usedTiers: new Set() };
|
|
230
|
+
const result = evalClaim(ctx, rule.claim);
|
|
231
|
+
const evaluation: Evaluation = { result, steps: ctx.steps };
|
|
232
|
+
if (ctx.usedTiers.has("assumption-dependent")) {
|
|
233
|
+
evaluation.weakestEvidence = "assumption-dependent";
|
|
234
|
+
} else if (ctx.usedTiers.has("hash-linked")) {
|
|
235
|
+
evaluation.weakestEvidence = "hash-linked";
|
|
236
|
+
}
|
|
237
|
+
return evaluation;
|
|
238
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @mikeargento/bitgraph-player
|
|
5
|
+
*
|
|
6
|
+
* Deterministic evaluation of causal rules over BitGraph proof bundles.
|
|
7
|
+
*
|
|
8
|
+
* BitGraph records. Player executes.
|
|
9
|
+
*
|
|
10
|
+
* Player is a pure function over `runAudit()`'s output: same rule, same
|
|
11
|
+
* bundle, same verdict, on anyone's machine, with no network access at
|
|
12
|
+
* evaluation time. It decides; it does not enforce — no field in the rule
|
|
13
|
+
* format is capable of causing an action.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type {
|
|
17
|
+
CastEntry,
|
|
18
|
+
CastPin,
|
|
19
|
+
Claim,
|
|
20
|
+
DeclaredEntry,
|
|
21
|
+
DerivedStep,
|
|
22
|
+
EvidenceTier,
|
|
23
|
+
OrderBasis,
|
|
24
|
+
OrderResult,
|
|
25
|
+
Resolution,
|
|
26
|
+
Rule,
|
|
27
|
+
ThreeValued,
|
|
28
|
+
Verdict,
|
|
29
|
+
} from "./types.js";
|
|
30
|
+
export { basisTier, meetsFloor } from "./types.js";
|
|
31
|
+
|
|
32
|
+
export { parseRule, normalizeDigest, RuleError } from "./rule.js";
|
|
33
|
+
export { resolveCast, resolveRole } from "./cast.js";
|
|
34
|
+
export { compare } from "./order.js";
|
|
35
|
+
export { kleeneAll, kleeneAny, kleeneNot } from "./logic.js";
|
|
36
|
+
export { evaluate } from "./evaluate.js";
|
|
37
|
+
export type { Evaluation } from "./evaluate.js";
|
|
38
|
+
export { buildVerdict, serializeVerdict, playerVersion } from "./verdict.js";
|
package/src/logic.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Strong Kleene three-valued connectives.
|
|
5
|
+
*
|
|
6
|
+
* all: FALSE if any FALSE; else UNDETERMINED if any UNDETERMINED; else TRUE
|
|
7
|
+
* any: TRUE if any TRUE; else UNDETERMINED if any UNDETERMINED; else FALSE
|
|
8
|
+
* not: TRUE <-> FALSE; UNDETERMINED stays UNDETERMINED
|
|
9
|
+
*
|
|
10
|
+
* UNDETERMINED is not a nuisance value: it is the honest answer wherever
|
|
11
|
+
* the evidence does not decide, and these tables are what keep it from
|
|
12
|
+
* being laundered into FALSE by composition.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ThreeValued } from "./types.js";
|
|
16
|
+
|
|
17
|
+
export function kleeneNot(v: ThreeValued): ThreeValued {
|
|
18
|
+
if (v === "TRUE") return "FALSE";
|
|
19
|
+
if (v === "FALSE") return "TRUE";
|
|
20
|
+
return "UNDETERMINED";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function kleeneAll(values: readonly ThreeValued[]): ThreeValued {
|
|
24
|
+
let sawUndetermined = false;
|
|
25
|
+
for (const v of values) {
|
|
26
|
+
if (v === "FALSE") return "FALSE";
|
|
27
|
+
if (v === "UNDETERMINED") sawUndetermined = true;
|
|
28
|
+
}
|
|
29
|
+
return sawUndetermined ? "UNDETERMINED" : "TRUE";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function kleeneAny(values: readonly ThreeValued[]): ThreeValued {
|
|
33
|
+
let sawUndetermined = false;
|
|
34
|
+
for (const v of values) {
|
|
35
|
+
if (v === "TRUE") return "TRUE";
|
|
36
|
+
if (v === "UNDETERMINED") sawUndetermined = true;
|
|
37
|
+
}
|
|
38
|
+
return sawUndetermined ? "UNDETERMINED" : "FALSE";
|
|
39
|
+
}
|