Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 100 additions & 18 deletions workers/godlock-uk/factor-detail.test.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { scoreDebateText } from "./src/debateScore.js";
import { factorDetail, storedFactorView, factorDetailSealed, NOT_STORED, NOT_SEALED_LABEL, FACTOR_KEYS } from "./src/factorDetail.js";
import { factorDetail, storedFactorView, factorDetailSealed, ledgerRescoreLines, reasoningSealed, NOT_STORED, NOT_SEALED_LABEL, FACTOR_KEYS } from "./src/factorDetail.js";
import { hashPayload, canonicalJson, ZERO } from "./src/ledger.js";
import { receiptLedgerAndProof, _resetProofCache, HEAD_SQL, TAIL_SQL, BY_RECEIPT_SQL } from "./src/receiptProof.js";
import { threadView, threadExpandScript } from "./src/ui.js";
Expand Down Expand Up @@ -38,26 +38,17 @@ describe("factor detail at write time", () => {
it("no triad means no detail", () => assert.equal(factorDetail({}), null));
});

function mkDetail(vals) {
const factors = {};
for (const k of Object.keys(vals)) factors[k] = { value: vals[k] };
return { schema: "godlock.factor_detail.v1", cycle_factors: Object.keys(vals), factors, pairings: [], omitted: [] };
}

describe("stored factor view (read time, no recompute)", () => {
it("old receipt without stored detail says so", () => {
const v = storedFactorView([{ reason: "initial", triad_json: JSON.stringify({ factors: { spre: 0.1 } }) }]);
assert.equal(v.factor_detail, null); assert.equal(v.factor_detail_note, NOT_STORED);
});
it("rescore rows show which factors changed and by how much", () => {
const v = storedFactorView([
{ reason: "initial", triad_json: JSON.stringify({ factors: { spre: 0.15, bayesian: 0.5, clce: null } }) },
{ reason: "supersedes", created_utc: "t2", triad_json: JSON.stringify({ factors: { spre: 0.3, bayesian: 0.5, clce: 0.2 } }) },
]);
assert.deepEqual(v.rescore_factor_changes[0].changes, [
{ factor: "clce", before: null, after: 0.2, delta: null },
{ factor: "spre", before: 0.15, after: 0.3, delta: 0.15 },
]);
});
it("rescore without stored factors is not guessed", () => {
const v = storedFactorView([{ reason: "initial", triad_json: null }, { reason: "x", triad_json: JSON.stringify({ factors: { spre: 1 } }) }]);
assert.equal(v.rescore_factor_changes[0].changes, null);
assert.equal(v.rescore_factor_changes[0].note, NOT_STORED);
});
});

describe("drop-down view", () => {
Expand All @@ -76,8 +67,8 @@ describe("drop-down view", () => {
assert.deepEqual(v.factors, [["Seal coverage", NOT_SEALED_LABEL], ["Per-factor", "per-factor detail not stored for this receipt"]]);
});
it("rescore changes render with signed deltas", () => {
const v = threadView({ ...base, receipt: { factor_detail: null, rescore_factor_changes: [{ created_utc: "t2", changes: [{ factor: "spre", before: 0.15, after: 0.3, delta: 0.15 }] }] } });
assert.deepEqual(v.factors[2], ["Rescore t2", "spre 0.15 → 0.3 (+0.15)"]);
const v = threadView({ ...base, receipt: { factor_detail: null, rescore_factor_changes: [{ ledger_seq: 7, ledger_timestamp_utc: "T7", created_utc: "IGNORED", changes: [{ factor: "spre", before: 0.15, after: 0.3, delta: 0.15 }] }] } });
assert.deepEqual(v.factors.find((x) => x[0].startsWith("Rescore")), ["Rescore T7 (ledger #7)", "spre 0.15 → 0.3 (+0.15) (stored data, not covered)"]);
});
it("script uses textContent only and still parses", () => {
const js = threadExpandScript();
Expand Down Expand Up @@ -145,3 +136,94 @@ describe("factor detail seal coverage", () => {
assert.deepEqual(v.factors.find((x) => x[0] === "Pairing a × b"), ["Pairing a × b", "0.1235"]);
});
});

describe("normalized detail and rescore sealing", () => {
it("factorDetail is JSON-normalized: no undefined fields, hash survives a store round trip", () => {
const review = scoreDebateText(TEXT, "");
review.spre.band = undefined; // checker leaves a nested field undefined
const fd = factorDetail(review);
const roundTrip = JSON.parse(JSON.stringify({ factor_detail: fd })).factor_detail;
assert.deepEqual(roundTrip, fd);
assert.equal(hashPayload(roundTrip), hashPayload(fd));
assert.ok(!("band" in fd.factors.spre.checker));
});
});

describe("rescore lines are ordered and paired by the hashed ledger", () => {
const d0 = mkDetail({ spre: 0.1 });
const d1 = mkDetail({ spre: 0.2 });
const d2 = mkDetail({ spre: 0.5 });
const L = (extra = []) => ledgerWith([
["SUBMIT", { receipt_id: "r1", factor_detail_sha256: hashPayload(d0) }],
["RESCORE", { receipt_id: "r1", rescore_id: "a", reason: "supersedes", factor_detail_sha256: hashPayload(d1) }],
...extra,
["RESCORE", { receipt_id: "r1", rescore_id: "b", reason: "same-succession", factor_detail_sha256: hashPayload(d2) }],
]);
const H = (tA = "t1", tB = "t2", omit) => [
{ id: "i", reason: "initial", created_utc: "t0", triad_json: JSON.stringify({ factor_detail: d0 }) },
{ id: "a", reason: "x", created_utc: tA, triad_json: JSON.stringify({ factor_detail: d1 }) },
{ id: "b", reason: "x", created_utc: tB, triad_json: JSON.stringify({ factor_detail: d2 }) },
].filter((h) => h.id !== omit);
async function run(ledger, hist) {
_resetProofCache();
const { rows, proof } = await receiptLedgerAndProof({ DB: fakeDb(ledger) }, "r1");
return ledgerRescoreLines(hist, rows, proof, "r1");
}
it("baseline: both covered, ledger order, ledger timestamps and reasons", async () => {
const lines = await run(L(), H());
assert.deepEqual(lines.map((l) => [l.id, l.ledger_seq, l.sealed, l.reason]), [["a", 2, true, "supersedes"], ["b", 3, true, "same-succession"]]);
assert.equal(lines[0].ledger_timestamp_utc, "t1");
assert.deepEqual(lines[1].changes, [{ factor: "spre", before: 0.2, after: 0.5, delta: 0.3 }]);
});
it("swapped created_utc: original ledger order and correct deltas", async () => {
const lines = await run(L(), H("t9", "t0"));
assert.deepEqual(lines.map((l) => l.id), ["a", "b"]);
assert.deepEqual(lines[0].changes, [{ factor: "spre", before: 0.1, after: 0.2, delta: 0.1 }]);
assert.deepEqual(lines[1].changes, [{ factor: "spre", before: 0.2, after: 0.5, delta: 0.3 }]);
assert.ok(lines.every((l) => l.sealed));
});
it("history order is irrelevant", async () => {
const lines = await run(L(), H().reverse());
assert.deepEqual(lines.map((l) => l.id), ["a", "b"]);
});
it("missing stored row for the middle rescore: it and the next line are not covered", async () => {
const lines = await run(L(), H("t1", "t2", "a"));
assert.equal(lines.find((l) => l.id === "a").sealed, false);
assert.equal(lines.find((l) => l.id === "b").sealed, false);
assert.equal(lines.find((l) => l.id === "b").changes, null);
});
it("stored rescore with no ledger row: listed, not covered", async () => {
const hist = H().concat([{ id: "ghost", reason: "x", created_utc: "t5", triad_json: JSON.stringify({ factor_detail: d2 }) }]);
const g = (await run(L(), hist)).find((l) => l.id === "ghost");
assert.equal(g.sealed, false); assert.equal(g.ledger_seq, null);
});
it("intervening RESCORE for this receipt: b pairs with the intervening row, not with a", async () => {
const dx = mkDetail({ spre: 0.9 });
const lines = await run(L([["RESCORE", { receipt_id: "r1", rescore_id: "x", factor_detail_sha256: hashPayload(dx) }]]), H());
const b = lines.find((l) => l.id === "b");
assert.equal(b.sealed, false, "x has no stored detail, so the link into b is not covered");
assert.equal(b.changes, null);
});
it("label uses the ledger timestamp, never created_utc", async () => {
_resetProofCache();
const { rows, proof } = await receiptLedgerAndProof({ DB: fakeDb(L()) }, "r1");
const lines = ledgerRescoreLines(H("FAKE1", "FAKE2"), rows, proof, "r1");
const v = threadView({ ok: true, proof, receipt: { rescore_factor_changes: lines } });
const labels = v.factors.filter((x) => x[0].startsWith("Rescore")).map((x) => x[0]);
assert.deepEqual(labels, ["Rescore t1 (ledger #2)", "Rescore t2 (ledger #3)"]);
assert.ok(!labels.join().includes("FAKE"));
});
});

describe("reasoning seal", () => {
const row = { id: "r1", created_utc: "c", text_sha256: "x", label: "Triad", summary: "S", explanation: "E", score_before: 50, score_after: 31, residual: 69, isolated: 0 };
const hash = (r) => hashPayload({ id: r.id, created_utc: r.created_utc, text_sha256: r.text_sha256, label: r.label, summary: r.summary, explanation: r.explanation, score_before: r.score_before, score_after: r.score_after, residual: r.residual, isolated: r.isolated });
it("covered when receipt row hashes to sealed SUBMIT content_sha256", async () => {
_resetProofCache();
const { rows, proof } = await receiptLedgerAndProof({ DB: fakeDb(ledgerWith([["SUBMIT", { receipt_id: "r1", content_sha256: hash(row) }]])) }, "r1");
assert.equal(reasoningSealed(row, rows, proof, hash), true);
assert.equal(reasoningSealed({ ...row, explanation: "edited" }, rows, proof, hash), false);
const v = threadView({ ok: true, proof, receipt: { summary: "S", reasoning_sealed: false } });
assert.match(v.reasoning.find((x) => x[0] === "Reasoning seal")[1], /not covered/);
});
});
143 changes: 119 additions & 24 deletions workers/godlock-uk/src/factorDetail.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,13 @@ function applicabilityReason(review, key) {
return src && typeof src.reason === "string" && src.reason ? src.reason : NO_REASON;
}

/** Build at write time. Returns null when the checker produced no triad. */
/** Build at write time, normalized through JSON so the stored bytes and the hashed value agree. */
export function factorDetail(review) {
const raw = rawFactorDetail(review);
return raw == null ? null : JSON.parse(JSON.stringify(raw));
}

function rawFactorDetail(review) {
const triad = review && review.triad;
if (!triad || !triad.factors) return null;
const flags = (review.applicability && review.applicability.flags) || {};
Expand Down Expand Up @@ -96,27 +101,7 @@ export function storedFactorView(rows) {
const detail = snapInitial && snapInitial.factor_detail && snapInitial.factor_detail.schema === FACTOR_DETAIL_SCHEMA
? snapInitial.factor_detail
: null;
const changes = [];
let prev = snapInitial;
for (const r of list) {
if (!r || r === initial) continue;
const snap = parse(r.triad_json);
const entry = { created_utc: r.created_utc, reason: r.reason, changes: null, note: null };
if (!prev || !prev.factors || !snap || !snap.factors) {
entry.note = NOT_STORED;
} else {
entry.changes = [];
for (const key of FACTOR_KEYS) {
const a = prev.factors[key] == null ? null : Number(prev.factors[key]);
const b = snap.factors[key] == null ? null : Number(snap.factors[key]);
if (a === b) continue;
entry.changes.push({ factor: key, before: a, after: b, delta: a != null && b != null ? Math.round((b - a) * 10000) / 10000 : null });
}
}
changes.push(entry);
if (snap) prev = snap;
}
return { factor_detail: detail, factor_detail_note: detail ? null : NOT_STORED, rescore_factor_changes: changes };
return { factor_detail: detail, factor_detail_note: detail ? null : NOT_STORED };
}

export const NOT_SEALED_LABEL = "Per-factor detail is stored data. It is not covered by the seal.";
Expand All @@ -127,14 +112,15 @@ export const SEALED_LABEL = "Per-factor detail is covered by the seal: its sha25
* matches the verified checkpoint, it carries factor_detail_sha256, and the
* sha256 of the stored factor_detail (canonical JSON) equals it. Else false.
*/
export function factorDetailSealed(detail, ledgerRows, proof) {
export function factorDetailSealed(detail, ledgerRows, proof, match) {
if (!detail || !proof || proof.status !== "verified") return false;
const isMine = typeof match === "function" ? match : (row) => String(row.action) === "SUBMIT";
const entries = Array.isArray(proof.entries) ? proof.entries : [];
const rows = Array.isArray(ledgerRows) ? ledgerRows : [];
for (const row of rows) {
if (String(row.action) !== "SUBMIT") continue;
let payload;
try { payload = JSON.parse(row.payload_json || "{}"); } catch { continue; }
if (!isMine(row, payload)) continue;
const want = payload && payload.factor_detail_sha256;
if (typeof want !== "string" || want.length !== 64) continue;
const e = entries.find((x) => Number(x.sequence) === Number(row.sequence) && x.entry_hash === row.entry_hash);
Expand All @@ -143,3 +129,112 @@ export function factorDetailSealed(detail, ledgerRows, proof) {
}
return false;
}

function detailOf(json) {
const snap = parse(json);
return snap && snap.factor_detail && snap.factor_detail.schema === FACTOR_DETAIL_SCHEMA ? snap.factor_detail : null;
}

function valueOf(d, key) {
const f = d && d.factors ? d.factors[key] : null;
return f && f.value != null ? Number(f.value) : null;
}

function payloadOf(row) {
try { return JSON.parse(row.payload_json || "{}"); } catch { return null; }
}

/** One ledger row is sealed for this detail: in the verified chain and its factor_detail_sha256 matches. */
function rowSealed(row, detail, proof) {
if (!row || !detail || !proof || proof.status !== "verified") return false;
const p = payloadOf(row);
const want = p && p.factor_detail_sha256;
if (typeof want !== "string" || want.length !== 64) return false;
const e = (Array.isArray(proof.entries) ? proof.entries : [])
.find((x) => Number(x.sequence) === Number(row.sequence) && x.entry_hash === row.entry_hash);
if (!e || !e.hash_ok || !e.in_verified_range || !e.matches_checkpoint) return false;
return hashPayload(detail) === want;
}

/**
* Rescore lines ordered and paired by the hashed ledger, never by unhashed
* receipt_rescores fields. history = receipt_rescores rows (any order; used
* only to look up stored detail by id). ledgerRows = this receipt's ledger rows.
* Line i compares the detail sealed by RESCORE row i with the detail sealed by
* the immediately preceding SUBMIT/RESCORE row for this receipt_id (adjacent in
* the filtered seq order, so nothing can sit between them). Labels and reason
* come from the hashed ledger row. Rescores with no ledger row are listed last
* as not covered.
*/
export function ledgerRescoreLines(history, ledgerRows, proof, receiptId) {
const hist = Array.isArray(history) ? history : [];
const byId = new Map();
let initialDetail = null;
for (const h of hist) {
if (!h) continue;
if (h.reason === "initial") initialDetail = detailOf(h.triad_json);
else if (h.id != null) byId.set(String(h.id), h);
}
const rid = String(receiptId || "");
const chain = (Array.isArray(ledgerRows) ? ledgerRows : [])
.map((row) => ({ row, p: payloadOf(row) }))
.filter(({ row, p }) => p && (!rid || p.receipt_id === rid) && (row.action === "SUBMIT" || row.action === "RESCORE"))
.sort((x, y) => Number(x.row.sequence) - Number(y.row.sequence));
const lines = [];
const seen = new Set();
let prev = null; // { row, detail }
for (const { row, p } of chain) {
if (row.action === "SUBMIT") { prev = { row, detail: initialDetail }; continue; }
const id = p.rescore_id == null ? null : String(p.rescore_id);
const h = id ? byId.get(id) : null;
if (id) seen.add(id);
const detail = h ? detailOf(h.triad_json) : null;
const line = {
id,
ledger_seq: Number(row.sequence),
ledger_timestamp_utc: row.timestamp_utc,
reason: typeof p.reason === "string" ? p.reason : null,
changes: null,
note: null,
sealed: false,
};
if (!prev || !prev.detail || !detail) {
line.note = NOT_STORED;
} else {
line.changes = [];
for (const key of FACTOR_KEYS) {
const x = valueOf(prev.detail, key);
const y = valueOf(detail, key);
if (x === y) continue;
line.changes.push({ factor: key, before: x, after: y, delta: x != null && y != null ? Math.round((y - x) * 10000) / 10000 : null });
}
}
line.sealed = !!prev && rowSealed(row, detail, proof) && rowSealed(prev.row, prev.detail, proof);
lines.push(line);
prev = { row, detail };
}
for (const [id] of byId) {
if (seen.has(id)) continue;
lines.push({ id, ledger_seq: null, ledger_timestamp_utc: null, reason: null, changes: null, note: "no ledger entry for this rescore", sealed: false });
}
return lines;
}

/**
* Summary/explanation are covered only when the SUBMIT row is in the verified
* chain and its content_sha256 equals the hash recomputed from the stored
* receipt row (hashFn = engine.hashReceipt). Else false.
*/
export function reasoningSealed(receiptRow, ledgerRows, proof, hashFn) {
if (!receiptRow || !proof || proof.status !== "verified" || typeof hashFn !== "function") return false;
const sub = (Array.isArray(ledgerRows) ? ledgerRows : []).find((r) => String(r.action) === "SUBMIT");
if (!sub) return false;
const p = payloadOf(sub);
if (!p || typeof p.content_sha256 !== "string") return false;
const e = (Array.isArray(proof.entries) ? proof.entries : [])
.find((x) => Number(x.sequence) === Number(sub.sequence) && x.entry_hash === sub.entry_hash);
if (!e || !e.hash_ok || !e.in_verified_range || !e.matches_checkpoint) return false;
let h;
try { h = hashFn(receiptRow); } catch { return false; }
return h === p.content_sha256;
}
5 changes: 3 additions & 2 deletions workers/godlock-uk/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { donateDoc, DONATE_RAILS, donateQrIdFromPath } from "./donate.js";
import { handleRuntimeRoot, isRuntimeRequest, runtimeCors } from "./runtimeRoot.js";
import { appendLedger, verifyLedger, ledgerEntriesForId, sha256hex, publicEntry, hashPayload } from "./ledger.js";
import { receiptLedgerAndProof } from "./receiptProof.js";
import { factorDetail, storedFactorView, factorDetailSealed } from "./factorDetail.js";
import { factorDetail, storedFactorView, factorDetailSealed, ledgerRescoreLines, reasoningSealed } from "./factorDetail.js";
import {
robotsTxt, sitemapXml, citeDoc, llmsDoc, aiDoc, siteOpenApi, mcpDiscoveryDoc, BANNER, DOWNLOAD, DOWNLOAD_STATS, DOWNLOAD_COUNT, GITHUB, AUTHOR,
indexNowKeyBody, INDEXNOW_KEY_PATH, INDEXNOW_WELL_KNOWN_PATH,
Expand Down Expand Up @@ -1288,7 +1288,8 @@ export default {
ok: true,
receipt: (() => {
const fv = storedFactorView(row.rescores);
return { ...publicPayload(row), ...fv, factor_detail_sealed: factorDetailSealed(fv.factor_detail, ledgerRows, proof) };
fv.rescore_factor_changes = ledgerRescoreLines(row.rescores, ledgerRows, proof, id);
return { ...publicPayload(row), ...fv, factor_detail_sealed: factorDetailSealed(fv.factor_detail, ledgerRows, proof), reasoning_sealed: reasoningSealed(row, ledgerRows, proof, hashReceipt) };
})(),
ledger: entries,
proof,
Expand Down
Loading
Loading