From 2528a93a40898f9afa99a2504b5fbb2db1bd4dbe Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 16:37:43 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20team=20memory=20=E2=80=94=20ledger=20me?= =?UTF-8?q?rge/blame/query,=20attribution,=20per-author=20trust=20(substra?= =?UTF-8?q?te-v2=20P2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PCM ledger becomes shared memory (docs/plans/substrate-v2/02): - forge ledger merge : semilattice merge of any other ledger tree (teammate checkout, worktree, backup) — idempotent, order-independent, conflict-free; identical knowledge minted independently converges to one claim with every author kept in its provenance log. On-disk convergence is the P2 acceptance gate and is tested (A<-B then B<-A then C in the opposite order are canonically identical) - forge ledger blame : the accountability view — every mint, oracle outcome, and retraction with authors, plus earned per-author trust (17:36's audit trail made a command) - forge ledger query "": Eq. 3 retrieval over live claims - attribution: every claim, evidence record, and tombstone carries the git identity (FORGE_AUTHOR override, cached per process, best-effort) - per-author trust u(author) in [0.5, 1]: Beta-smoothed oracle track record of the claims an author minted — bootstrap 1.0 (never punish the new teammate), floor 0.5 (never silence anyone), self-confirmation excluded (C12); val() accepts a trust map to weight evidence by its appender - forge doctor: checks the union-merge driver is present and the ledger's normal form 274/274 tests, biome + tsc clean, two-teammate flow smoke-tested live. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Fc2MvWJbQ1cyNQ76ymv4hs --- CHANGELOG.md | 15 ++++++++ src/cli.js | 75 +++++++++++++++++++++++++++++++++++++-- src/doctor.js | 29 +++++++++++++++ src/ledger.js | 42 ++++++++++++++++++++-- src/ledger_bridge.js | 16 ++++++--- src/ledger_store.js | 42 ++++++++++++++++++++-- src/util.js | 21 +++++++++++ test/ledger.test.js | 43 ++++++++++++++++++++++ test/ledger_store.test.js | 69 ++++++++++++++++++++++++++++++++++- 9 files changed, 339 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5a90f9..de2d767 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- **Team memory (P2 of the substrate-v2 plan).** The PCM ledger becomes shared: + `forge ledger merge ` performs the conflict-free semilattice merge of any other + ledger tree (a teammate's checkout, a worktree, a backup) — identical knowledge minted + independently converges to one claim with every author preserved in its provenance log. + `forge ledger blame ` is the accountability view (every mint, every oracle outcome, + every retraction, per-author trust). `forge ledger query ""` ranks live claims by + the paper's Eq. 3. Every claim, evidence record, and tombstone now carries the git + identity (`FORGE_AUTHOR` override; cached; best-effort). **Per-author trust** + `u(author) ∈ [0.5, 1]` is computed from the oracle track record of the claims an author + minted — smoothed to 1.0 for new teammates, floored at 0.5, self-confirmation excluded — + and optionally weights `val()`. `forge doctor` now checks the union-merge driver is + present (a populated ledger without it WILL conflict) and the ledger's normal form. + ### Fixed - **PCM ledger hardened after an 8-angle adversarial review of the P1 merge.** The diff --git a/src/cli.js b/src/cli.js index f82462d..6c5562d 100755 --- a/src/cli.js +++ b/src/cli.js @@ -19,7 +19,7 @@ const COMMANDS = { cost: "real per-day spend via ccusage + the cost ceiling", spec: "spec-as-contract — init (OpenSpec) / lock / check drift", cortex: "self-correcting project memory — status / why ", - ledger: "proof-carrying memory ledger — stats / verify / show / import", + ledger: "proof-carrying memory — stats / verify / show / blame / query / merge / import", preflight: "assumption check — what a task names that the repo doesn't define", route: "recommend the cheapest capable model for a task (+ gateway config)", impact: "predict blast radius for a symbol or file from the atlas graph", @@ -230,6 +230,77 @@ async function run(argv) { const { val } = await import("./ledger.js"); return console.log(JSON.stringify({ ...hit, val: val(hit, nowDay) }, null, 2)); } + if (sub === "merge") { + const src = args[2]; + const { existsSync } = await import("node:fs"); + if (!src || !existsSync(src)) { + console.error( + src + ? ` no ledger at ${src}` + : "usage: forge ledger merge (a teammate's checkout, a backup, a worktree)", + ); + process.exitCode = 1; + return; + } + const r = ls.mergeDirs(dir, src); + if (json) return console.log(JSON.stringify(r, null, 2)); + console.log(` merged: ${r.claims} new claim(s), ${r.records} new record(s) — conflict-free`); + return; + } + if (sub === "blame") { + const b = args[2] && args[2].length >= 2 ? ls.blame(dir, args[2], nowDay) : null; + if (!b) { + console.error( + args[2] ? ` no claim matching ${args[2]}` : "usage: forge ledger blame ", + ); + process.exitCode = 1; + return; + } + if (json) return console.log(JSON.stringify(b, null, 2)); + console.log(`${BRAND.brand} ledger blame — ${b.kind} ${b.id.slice(0, 12)}\n`); + console.log(` val ${b.val.toFixed(2)} (trust-weighted ${b.valTrustWeighted.toFixed(2)})`); + for (const p of b.minted) + console.log( + ` minted day ${p.t} by ${p.author || "(unknown)"}${p.agent ? ` · ${p.agent}` : ""}`, + ); + for (const e of b.evidence) + console.log( + ` ${e.result === "confirm" ? "confirm " : "contradic"} day ${e.t} ${e.oracle} → ${e.ref}${e.author ? ` by ${e.author}` : ""}`, + ); + for (const t of b.tombstones) + console.log(` retract day ${t.t} ${t.reason}${t.author ? ` by ${t.author}` : ""}`); + const trusts = Object.entries(b.trust); + if (trusts.length) { + console.log("\n author trust (earned from oracle outcomes on their claims):"); + for (const [a, u] of trusts) console.log(` ${u.toFixed(2)} ${a}`); + } + return; + } + if (sub === "query") { + const q = args.slice(2).join(" "); + if (!q) { + console.error('usage: forge ledger query ""'); + process.exitCode = 1; + return; + } + const { retrieve, claimText } = await import("./ledger.js"); + const claims = ls.loadClaims(dir); + const ranked = retrieve(q, claims, { nowDay, budget: 8 }); + if (json) + return console.log( + JSON.stringify( + ranked.map((r) => ({ id: r.claim.id, kind: r.claim.kind, score: r.score })), + null, + 2, + ), + ); + if (!ranked.length) return console.log(" no matching live claims"); + for (const r of ranked) + console.log( + ` ${r.score.toFixed(3)} ${r.claim.kind.padEnd(9)} ${r.claim.id.slice(0, 8)} ${claimText(r.claim).slice(0, 90)}`, + ); + return; + } if (sub === "import") { const b = await import("./ledger_bridge.js"); let r; @@ -249,7 +320,7 @@ async function run(argv) { return; } console.error( - `ledger: unknown subcommand "${sub}" (stats | verify | show | import) [--personal] [--json]`, + `ledger: unknown subcommand "${sub}" (stats | verify | show | blame | query | merge | import) [--personal] [--json]`, ); process.exitCode = 1; return; diff --git a/src/doctor.js b/src/doctor.js index fc40e9d..a73ea88 100644 --- a/src/doctor.js +++ b/src/doctor.js @@ -7,6 +7,7 @@ import { isStale, load as loadAtlas } from "./atlas.js"; import { BRAND } from "./brand.js"; import { summary as cortexSummary } from "./cortex.js"; import { extractHash, hashContent } from "./emit/_shared.js"; +import { verify as ledgerVerify, repoLedger } from "./ledger_store.js"; import { PRICING_VERIFIED } from "./model_tiers.js"; import { canonical } from "./sync.js"; @@ -171,6 +172,33 @@ function checkCortex(out, targetRoot) { ); } +// PCM ledger: a populated ledger with no union-merge driver WILL conflict the first +// time two teammates append to the same evidence log — the exact failure the ledger's +// design promises away. Also surface normal-form issues (forged/corrupt records). +function checkLedger(out, targetRoot) { + const dir = repoLedger(targetRoot); + if (!existsSync(join(dir, "claims"))) { + out.push(ok("ledger", "empty — claims appear as cortex/recall learn (`forge ledger`)")); + return; + } + const attrs = join(targetRoot, ".gitattributes"); + const hasRule = existsSync(attrs) && readFileSync(attrs, "utf8").includes(".forge/ledger/"); + out.push( + hasRule + ? ok("ledger merge", "union-merge driver present in .gitattributes") + : warn( + "ledger merge", + "no union-merge rule — run `forge init` or teammate merges will conflict", + ), + ); + const v = ledgerVerify(dir); + out.push( + v.ok + ? ok("ledger", `${v.claims} claim(s), ${v.outcomes} outcome(s) — normal form`) + : warn("ledger", `${v.issues.length} issue(s) — run \`forge ledger verify\` to list them`), + ); +} + export function doctor({ targetRoot = process.cwd() } = {}) { const results = []; checkNode(results); @@ -184,5 +212,6 @@ export function doctor({ targetRoot = process.cwd() } = {}) { checkPricing(results); checkMcp(results, targetRoot); checkCortex(results, targetRoot); + checkLedger(results, targetRoot); return { results, failed: results.filter((r) => r.status === "fail").length }; } diff --git a/src/ledger.js b/src/ledger.js index e7cd962..293e66d 100644 --- a/src/ledger.js +++ b/src/ledger.js @@ -175,20 +175,56 @@ const decayed = (outcome, nowDay, halfLife) => * Fresh claim → 0.5. Unreviewed evidence decays, pulling val back toward 0.5 * (uncertainty), never toward 0 — review (new evidence) is what restores weight. * Records that fail validOutcome (unknown oracle, malformed) are IGNORED, not - * trusted. Pure function of the evidence set ⇒ identical after any merge order. + * trusted. Optional `trust` (author → u, see authorTrust) scales each record by its + * appender's earned reliability. Pure function of (evidence set, trust map) ⇒ + * identical after any merge order. + * @param {any} claim + * @param {number} [nowDay] + * @param {{halfLife?: number, trust?: Record}} [opts] */ -export function val(claim, nowDay = 0, { halfLife = DEFAULT_HALF_LIFE_DAYS } = {}) { +export function val(claim, nowDay = 0, { halfLife = DEFAULT_HALF_LIFE_DAYS, trust } = {}) { let confirms = 0; let all = 0; for (const e of claim.evidence ?? []) { if (!validOutcome(e)) continue; - const d = decayed(e, nowDay, halfLife); + const d = decayed(e, nowDay, halfLife) * (trust?.[e.author ?? ""] ?? 1); all += d; if (e.result === "confirm") confirms += d; } return (1 + confirms) / (2 + all); } +/** + * Per-author trust u(author) ∈ [0.5, 1] — the historical confirm rate of the claims + * an author MINTED (docs/plans/substrate-v2/02-team-memory.md §3): authors whose + * claims keep being contradicted by oracles contribute less evidence weight going + * forward. Smoothed so a no-history author starts at 1.0 (never punish the new + * teammate) and floored at 0.5 (never silence anyone). + * u(a) = max(0.5, (confirms_a + s) / (confirms_a + contradictions_a + s)), s = 2 + * Counts are oracle-weighted, and an author's own evidence on their own claims is + * EXCLUDED — self-confirmation must not raise one's trust (C12 discipline). + * @returns {Record} author → u + */ +export function authorTrust(claims) { + const tally = new Map(); // author → {c, m} + for (const claim of claims) { + const author = claim.provenance?.author ?? ""; + if (!author) continue; + const t = tally.get(author) ?? { c: 0, m: 0 }; + for (const e of claim.evidence ?? []) { + if (!validOutcome(e) || (e.author ?? "") === author) continue; + const w = ORACLES[e.oracle].w; + if (e.result === "confirm") t.c += w; + else t.m += w; + } + tally.set(author, t); + } + /** @type {Record} */ + const out = {}; + for (const [a, { c, m }] of tally) out[a] = Math.max(0.5, (c + 2) / (c + m + 2)); + return out; +} + /** Recency — λ^(Δt/T) since the last evidence (or mint, if none). */ export function rec(claim, nowDay = 0, { halfLife = DEFAULT_HALF_LIFE_DAYS } = {}) { const last = Math.max(claim.provenance?.t ?? 0, ...(claim.evidence ?? []).map((e) => e.t ?? 0)); diff --git a/src/ledger_bridge.js b/src/ledger_bridge.js index 96a490b..95ab66a 100644 --- a/src/ledger_bridge.js +++ b/src/ledger_bridge.js @@ -19,7 +19,7 @@ import { } from "./ledger_store.js"; import { load as loadLessons } from "./lessons_store.js"; import { list as listFacts, readFact } from "./recall.js"; -import { epochDay } from "./util.js"; +import { epochDay, gitAuthor } from "./util.js"; /** One best-effort policy for the whole bridge (never throws into a caller). */ const bestEffort = (fn) => { @@ -51,7 +51,7 @@ export function lessonClaim(lesson, t = 0) { whatWentWrong: lesson.whatWentWrong ?? "", }, scope: { level: lesson.scope ?? "repo" }, - provenance: { agent: "cortex", author: "", task: lesson.id ?? "" }, + provenance: { agent: "cortex", author: gitAuthor(), task: lesson.id ?? "" }, t, }); } @@ -64,7 +64,7 @@ export function factClaim(name, text, t = 0) { kind: "fact", body: { name: String(name).trim(), text: String(text).trim() }, scope: { level: "repo" }, - provenance: { agent: "recall", author: "" }, + provenance: { agent: "recall", author: gitAuthor() }, t, }); } @@ -90,6 +90,7 @@ export function recordLessonEvent(root, lesson, ev = {}) { oracle: ev.oracle ?? "cortex.episode", result: ev.result, ref: ev.ref, + author: gitAuthor(), t: ev.t ?? 0, }); if (!o.ok) return { ok: false, reason: "reason" in o ? o.reason : "invalid outcome" }; @@ -117,6 +118,7 @@ export function supersedeLessonClaim(root, before, after, t = epochDay()) { if (oldC.ok && oldC.claim.id !== newC.claim.id) { for (const o of readEvidence(dir, oldC.claim.id)) appendEvidence(dir, newC.claim.id, o); tombstone(dir, oldC.claim.id, { + author: gitAuthor(), reason: `superseded-by:${newC.claim.id}`, t, }); @@ -144,7 +146,11 @@ export function shadowFact(ledgerDir, name, text, t = epochDay()) { c.id !== minted.claim.id && c.body?.name === minted.claim.body.name ) - tombstone(ledgerDir, c.id, { reason: `superseded-by:${minted.claim.id}`, t }); + tombstone(ledgerDir, c.id, { + author: gitAuthor(), + reason: `superseded-by:${minted.claim.id}`, + t, + }); } reindex(ledgerDir, t); return { ...put, id: minted.claim.id }; @@ -170,7 +176,7 @@ export function reconcileFacts(store, ledgerDir, t = epochDay()) { let removed = 0; for (const c of loadClaims(ledgerDir)) { if (c.kind === "fact" && !c.tombstone && !current.has(c.id)) { - tombstone(ledgerDir, c.id, { reason: "removed-from-store", t }); + tombstone(ledgerDir, c.id, { author: gitAuthor(), reason: "removed-from-store", t }); removed++; } } diff --git a/src/ledger_store.js b/src/ledger_store.js index b345257..75316b4 100644 --- a/src/ledger_store.js +++ b/src/ledger_store.js @@ -15,6 +15,7 @@ import { } from "node:fs"; import { join } from "node:path"; import { + authorTrust, canonicalize, claimId, DORMANT_VAL, @@ -179,8 +180,45 @@ export function getClaimByPrefix(dir, prefix) { return liveClaims(state)[0]; } -/** Semilattice import: merge another ledger state into this directory (P2's `forge - * ledger merge` core). Idempotent; safe to re-run. */ +/** `forge ledger merge ` — semilattice merge of another on-disk ledger into + * this one. Idempotent and order-independent by the CRDT property, so merging a + * teammate's checkout, a backup, or a branch worktree is always safe. */ +export function mergeDirs(dstDir, srcDir) { + return importState(dstDir, loadState(srcDir)); +} + +/** + * `forge ledger blame ` — the full accountability view of one claim: who + * minted it (every author, via the provenance log), every evidence record in (t, h) + * order, retractions, and the per-author trust the ledger has earned (17:36's audit + * trail: every channel the agent used can be questioned). + */ +export function blame(dir, prefix, nowDay = 0) { + const claim = getClaimByPrefix(dir, prefix); + if (!claim) return null; + const trust = authorTrust(loadClaims(dir)); + return { + id: claim.id, + kind: claim.kind, + body: claim.body, + scope: claim.scope, + minted: claim.provenanceAll, + evidence: claim.evidence, + tombstones: readLog(dir, "tombstones", claim.id), + val: val(claim, nowDay), + valTrustWeighted: val(claim, nowDay, { trust }), + trust: Object.fromEntries( + [ + ...new Set( + [...claim.provenanceAll, ...claim.evidence].map((r) => r.author).filter(Boolean), + ), + ].map((a) => [a, trust[a] ?? 1]), + ), + }; +} + +/** Semilattice import: merge another ledger state into this directory (the mergeDirs + * core). Idempotent; safe to re-run. */ export function importState(dir, other) { const merged = mergeStates(loadState(dir), other); let claims = 0; diff --git a/src/util.js b/src/util.js index f3f065a..c7f9988 100644 --- a/src/util.js +++ b/src/util.js @@ -29,6 +29,27 @@ export function contentHash(text) { return createHash("sha256").update(text).digest("hex"); } +let cachedAuthor; +/** The identity stamped on ledger provenance/evidence — FORGE_AUTHOR env override, + * else the git identity, else "" (attribution is best-effort, never a hard fail). + * Cached per process: hooks call this per event and `git config` is a subprocess. */ +export function gitAuthor() { + if (process.env.FORGE_AUTHOR !== undefined) return process.env.FORGE_AUTHOR; + if (cachedAuthor !== undefined) return cachedAuthor; + try { + const get = (k) => + execFileSync("git", ["config", "--get", k], { stdio: ["ignore", "pipe", "ignore"] }) + .toString() + .trim(); + const name = get("user.name"); + const email = get("user.email"); + cachedAuthor = email ? `${name} <${email}>` : name; + } catch { + cachedAuthor = ""; + } + return cachedAuthor; +} + export const IGNORE_DIRS = new Set([ "node_modules", ".git", diff --git a/test/ledger.test.js b/test/ledger.test.js index 6c9d477..5bf858d 100644 --- a/test/ledger.test.js +++ b/test/ledger.test.js @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { + authorTrust, canonicalize, claimId, claimText, @@ -335,3 +336,45 @@ test("mergeStates: evidence unions dedupe by hash; val is identical after any me "confidence is merge-order-independent", ); }); + +// --- per-author trust (P2) ------------------------------------------------------------- + +test("authorTrust: bootstrap 1.0, degrades with contradicted claims, floors at 0.5, ignores self-confirmation", () => { + const mint = (name, author) => + mintClaim({ + kind: "fact", + body: { name, text: `${name} content` }, + provenance: { author }, + t: 0, + }).claim; + const out = (result, ref, author) => + outcomeRecord({ oracle: "test.run", result, ref, author, t: 0 }).outcome; + + const fresh = { ...mint("a", "newbie"), evidence: [] }; + const good = { + ...mint("b", "alice"), + evidence: [out("confirm", "r1", "ci"), out("confirm", "r2", "ci")], + }; + const selfServing = { ...mint("c", "bob"), evidence: [out("confirm", "r3", "bob")] }; + const wrongOften = { + ...mint("d", "carol"), + evidence: Array.from({ length: 20 }, (_, i) => out("contradict", `r${i}`, "ci")), + }; + const trust = authorTrust([fresh, good, selfServing, wrongOften]); + assert.equal(trust.newbie, 1, "no history → full trust (never punish the new teammate)"); + assert.equal(trust.alice, 1, "only confirmations → full trust"); + assert.equal(trust.bob, 1, "self-confirmation is excluded, so bob has NO history — bootstrap"); + assert.equal(trust.carol, 0.5, "heavily contradicted → floored, never silenced"); + assert.ok(!("" in trust), "anonymous claims don't accumulate trust"); +}); + +test("val with trust: a distrusted author's evidence moves confidence less", () => { + const claim = mkClaim([ + outcomeRecord({ oracle: "test.run", result: "confirm", ref: "r", author: "carol", t: 0 }) + .outcome, + ]); + const flat = val(claim, 0); + const weighted = val(claim, 0, { trust: { carol: 0.5 } }); + assert.ok(weighted < flat, "trust scales the evidence weight down"); + assert.ok(weighted > 0.5, "but a confirmation still counts for something"); +}); diff --git a/test/ledger_store.test.js b/test/ledger_store.test.js index fbc1f1d..967f5bf 100644 --- a/test/ledger_store.test.js +++ b/test/ledger_store.test.js @@ -3,13 +3,22 @@ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; -import { mintClaim, outcomeRecord, sealRecord, val } from "../src/ledger.js"; +import { + canonicalize, + liveClaims, + mintClaim, + outcomeRecord, + sealRecord, + val, +} from "../src/ledger.js"; import { appendEvidence, + blame, getClaimByPrefix, importState, loadClaims, loadState, + mergeDirs, pruneToAttic, putClaim, readEvidence, @@ -213,3 +222,61 @@ test("pruneToAttic: moves the claim out of the live set but keeps the bytes", () assert.equal(loadClaims(dir).length, 0); assert.ok(readFileSync(join(dir, "attic", `${c.id}.json`), "utf8").includes("long dormant")); }); + +// --- team merge + blame (P2) ----------------------------------------------------------- + +test("mergeDirs: two replicas converge to canonically identical state in either merge order", () => { + const mkReplica = () => tmp(); + const a = mkReplica(); + const b = mkReplica(); + const shared = fact("shared", "both know this"); + const onlyA = fact("only-a", "alice learned this"); + const onlyB = fact("only-b", "bob learned this"); + // Replica A: shared + own claim + a confirm + a retraction of its own claim. + putClaim(a, shared); + putClaim(a, onlyA); + appendEvidence(a, shared.id, ev("confirm", "run:a", 1)); + tombstone(a, onlyA.id, { reason: "wrong", t: 2, author: "alice" }); + // Replica B: shared + own claim + a different confirm + a retraction of the SAME shared claim. + putClaim(b, shared); + putClaim(b, onlyB); + appendEvidence(b, shared.id, ev("contradict", "run:b", 3)); + tombstone(b, shared.id, { reason: "disputed", t: 4, author: "bob" }); + + mergeDirs(a, b); // A absorbs B + mergeDirs(b, a); // B absorbs (A ∪ B) + const canonState = (d) => canonicalize(liveClaims(loadState(d))); + assert.equal(canonState(a), canonState(b), "replicas are canonically identical"); + // And a third replica merging in the opposite order reaches the same state: + const c = mkReplica(); + mergeDirs(c, b); + mergeDirs(c, a); + assert.equal(canonState(c), canonState(a), "order of merges never matters"); + assert.equal(mergeDirs(a, b).claims + mergeDirs(a, b).records, 0, "re-merge is a no-op"); +}); + +test("blame: full accountability view — mints, evidence, retractions, per-author trust", () => { + const dir = tmp(); + const c = mintClaim({ + kind: "fact", + body: { name: "traced", text: "who said this and why do we believe it" }, + provenance: { author: "alice" }, + t: 1, + }).claim; + putClaim(dir, c); + appendEvidence( + dir, + c.id, + outcomeRecord({ oracle: "ci.run", result: "confirm", ref: "ci:42", author: "bob", t: 2 }) + .outcome, + ); + tombstone(dir, c.id, { reason: "superseded", t: 3, author: "carol" }); + const b = blame(dir, c.id.slice(0, 8), 3); + assert.equal(b.id, c.id); + assert.equal(b.minted[0].author, "alice"); + assert.equal(b.evidence[0].ref, "ci:42"); + assert.equal(b.tombstones[0].author, "carol"); + assert.ok(b.val > 0.5); + assert.ok(b.trust.alice >= 0.5 && b.trust.alice <= 1); + assert.equal(blame(dir, "zz", 3), null); +});