// The ledger, adjudicated, turned into two running totals and five coherence // flags. // // Plain ESM with no I/O and no rendering: `umtool`'s decisions reducer imports // it from a Next server component, `compose-chrome.mjs` imports it from a CLI, // and `node --test` runs it with a literal array. Every consumer gets the same // arithmetic, which is the only way the chart band and the inbox can agree. // // --------------------------------------------------------------------------- // Why an adjudication is required before any of this runs // --------------------------------------------------------------------------- // Both totals used to rest on `ledger[].company`, an undocumented interpretation // that quietly mixed four different things: // // * SCOPE AMBIGUITY -- "I have 10 employees, my coffee company employees, my // editors" is all-companies or coffee-only depending on where the comma // falls, and nothing recorded which reading was taken. // * DERIVED VALUES -- 18 and 20 are OUR sums of his per-company claims. He // never utters either. The old manifest called 18 "the only explicit sum in // the corpus", which is exactly backwards. // * POPULATION DRIFT -- "full-time salaried", "employees", "all basically // contractors" and "all 1099 and not full-time" were compared as one series. // * SYNTHETIC VALUES -- 10.5 for "about 10 people, 11 people" and 5.5 for // "five or six" are midpoints we invented and then attributed to him. // // So the STATED series may contain only a figure he utters as a single number // for a named scope. Our arithmetic lives in the IMPLIED series, which says on // screen that it is ours. That weakens "his stated total swings wildly" a little // and makes it survive scrutiny. export const SCOPES = ["media", "coffee", "publica", "all"]; /** The three that sum. `all` is a claim ABOUT the sum, never a term in it. */ export const COMPANY_SCOPES = ["media", "coffee", "publica"]; export const POPULATIONS = ["employees", "full-time", "salaried", "contractor", "1099", "people"]; export const VALUE_KINDS = ["uttered", "derived", "synthetic"]; export const SCOPE_CONFIDENCE = ["clear", "read", "unresolved"]; /** The six fields an entry must carry before it may feed a total. */ export const ADJUDICATION_FIELDS = [ "scope", "scopeBasis", "scopeConfidence", "population", "valueKind", "flags", ]; const isStr = (v) => typeof v === "string" && v.trim().length > 0; /** * Which of the six an entry is missing or has wrong. Empty array == adjudicated. * * Returned rather than thrown because this is what the inbox renders: one * `claim-unadjudicated` row per entry, naming the fields still outstanding. */ export function adjudicationGaps(entry) { const gaps = []; if (!SCOPES.includes(entry?.scope)) gaps.push("scope"); // The phrase that settles it. An adjudication with no basis is an opinion, // and the whole point of the exercise was to stop shipping those. if (!isStr(entry?.scopeBasis)) gaps.push("scopeBasis"); if (!SCOPE_CONFIDENCE.includes(entry?.scopeConfidence)) gaps.push("scopeConfidence"); if (!POPULATIONS.includes(entry?.population)) gaps.push("population"); if (!VALUE_KINDS.includes(entry?.valueKind)) gaps.push("valueKind"); if (!Array.isArray(entry?.flags)) gaps.push("flags"); return gaps; } export const isAdjudicated = (entry) => adjudicationGaps(entry).length === 0; /** Entries that carry a number nobody has ruled on yet. */ export const unadjudicatedOf = (ledger) => (ledger ?? []).filter((e) => !isAdjudicated(e)); export class UnadjudicatedLedger extends Error { constructor(entries) { const ids = entries.map((e) => e?.id ?? "?"); super( `${ids.length} ledger entr${ids.length === 1 ? "y is" : "ies are"} unadjudicated ` + `(${ids.slice(0, 6).join(", ")}${ids.length > 6 ? ", …" : ""}). ` + "Both totals lie if you act on an unadjudicated ledger — work the inbox first.", ); this.name = "UnadjudicatedLedger"; this.entries = entries; this.ids = ids; } } // --------------------------------------------------------------------------- // Dates. Two entries are month-only ("2024-02", "2025-06") because that is all // the source supports; both are qualitative, so they order the rail without // touching a total. Sorting pads them to the first of the month, which puts them // before any dated claim in the same month rather than guessing a day. // --------------------------------------------------------------------------- export function dateKey(d) { const s = String(d ?? ""); const m = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?$/.exec(s); if (!m) return "9999-99-99"; return `${m[1]}-${m[2] ?? "01"}-${m[3] ?? "01"}`; } /** Chronological, ties broken by ledger id so the order is total and stable. */ export const chronological = (ledger) => [...(ledger ?? [])].sort((a, b) => { const d = dateKey(a.date).localeCompare(dateKey(b.date)); return d !== 0 ? d : String(a.id).localeCompare(String(b.id)); }); // --------------------------------------------------------------------------- // The five predicates. // // "Doesn't make sense" is COMPUTED, never asserted. Each one is named, each one // renders as plain words on screen, and each one is a decision the human takes // rather than a fact the video states. // // Deliberately NOT a rule: a large rise or fall between claims. Fluctuation is // the SUBJECT of the video, not a defect, and flagging it would be putting a // thumb on the scale. // --------------------------------------------------------------------------- const SCOPE_LABEL = { media: "The Quartering", coffee: "Coffee Brand Coffee", publica: "The Publica", all: "all companies", }; /** Populations that describe someone who is not, on his own account, staff. */ const UNDERCUTTING = new Set(["contractor", "1099"]); export const PREDICATES = [ "contradicts_component", "same_day_conflict", "self_negating", "population_mismatch", "not_his_number", "status_flip", ]; // --------------------------------------------------------------------------- // Employment status, as two camps. // // `employees` and `people` are in NEITHER. They are what he says when he is not // making a claim about status at all, and reading them as one side or the other // would manufacture reversals out of a change of vocabulary. // --------------------------------------------------------------------------- const STAFF = new Set(["full-time", "salaried"]); const CONTRACT = new Set(["contractor", "1099"]); const campOf = (p) => (STAFF.has(p) ? "staff" : CONTRACT.has(p) ? "contract" : null); const STATUS_WORD = { "full-time": "full time", salaried: "salaried", contractor: "contractors", 1099: "1099, not full-time", }; /** * Whether two claims are about overlapping people. * * An `all` claim is about every payroll he has, so it is comparable with any * company; two different companies are not comparable with each other. Without * that asymmetry the corpus's clearest reversal -- December 2024's ten at the * channel are "all basically contractors", May 2025's ten or eleven are "full * time" -- is invisible, because one is scoped to the channel and the other to * everything. */ const comparableScopes = (a, b) => a === b || a === "all" || b === "all"; /** * A stated total below his own most recent claim for ONE company. * * The canonical case: 2023-04-21 "how will I pay my six employees?" against * 2023-04-19 "already… almost 10 staff members" at The Publica, two days * earlier. Six cannot contain ten. */ function contradictsComponent(step, state) { if (step.scope !== "all" || step.value == null) return null; const worst = COMPANY_SCOPES.map((c) => state[c]) .filter((s) => s && s.value != null && s.value > step.value) .sort((a, b) => b.value - a.value)[0]; if (!worst) return null; return { rule: "contradicts_component", text: `he says ${fmt(step.value)} total; he said ${fmt(worst.value)} at ` + `${SCOPE_LABEL[worst.scope]} ${gapWords(worst.date, step.date)}`, against: worst.id, }; } /** Two claims, same scope, same date, different values. */ function sameDayConflict(step, byScopeDate) { if (step.value == null) return null; const peers = (byScopeDate.get(`${step.scope}|${step.date}`) ?? []).filter( (o) => o.id !== step.id && o.value != null && o.value !== step.value, ); if (!peers.length) return null; const other = peers[0]; return { rule: "same_day_conflict", text: step.scope === "all" ? `two different totals the same day — ${fmt(step.value)} and ${fmt(other.value)}` : `two different ${SCOPE_LABEL[step.scope]} counts the same day — ` + `${fmt(step.value)} and ${fmt(other.value)}`, against: other.id, }; } /** * The quote's own qualifier undercuts the count: he names a number of * "employees" and then says in the same breath that they are not employees. * * Derived from the adjudicated `population`, not from a regex over the quote — * whether "basically contractors" undercuts "10 employees" is exactly the * reading a human is signing off on. */ function selfNegating(step) { if (!UNDERCUTTING.has(step.population)) return null; if (step.value == null) return null; return { rule: "self_negating", text: `${fmt(step.value)} ${step.population === "1099" ? "— “all 1099 and not full-time”" : "— “all basically contractors”"}`, }; } /** A different denominator from the rest of its own series. */ function populationMismatch(step, baseline) { const base = baseline.get(step.scope) ?? "employees"; if (step.population === base) return null; return { rule: "population_mismatch", text: `${step.population}, not ${base}`, baseline: base, }; } /** * The same people, described as staff and then as contractors, or the reverse. * * Only against the MOST RECENT comparable claim that carries a camp at all -- * not against every earlier one. Fire on every pair and a single 2022 "all 1099 * and not full-time" flags each of the next seven claims in turn, which reads * as seven findings when it is one. Bounded this way, the predicate fires at * the TRANSITIONS, which is what a flip-flop is. */ function statusFlip(step, priorCamps) { const camp = campOf(step.population); if (!camp) return null; let prev = null; for (let i = priorCamps.length - 1; i >= 0; i -= 1) { if (comparableScopes(priorCamps[i].scope, step.scope)) { prev = priorCamps[i]; break; } } if (!prev || prev.camp === camp) return null; const when = gapWords(prev.date, step.date); const same = prev.value != null && step.value != null && prev.value === step.value ? `the same ${fmt(step.value)} were` : "they were"; return { rule: "status_flip", text: `${when} ${same} ${STATUS_WORD[prev.population]}, now ${STATUS_WORD[step.population]}`, against: prev.id, }; } /** Our arithmetic or our midpoint, wearing his voice. */ function notHisNumber(step) { if (step.valueKind === "uttered") return null; return { rule: "not_his_number", text: step.valueKind === "derived" ? "our sum, not his figure" : "our midpoint, not his figure", }; } const fmt = (n) => (Number.isInteger(n) ? String(n) : String(n)); function gapWords(from, to) { const a = new Date(`${dateKey(from)}T00:00:00Z`).getTime(); const b = new Date(`${dateKey(to)}T00:00:00Z`).getTime(); const days = Math.round((b - a) / 86400000); if (!Number.isFinite(days)) return "earlier"; if (days <= 0) return "the same day"; if (days === 1) return "a day earlier"; if (days < 31) return `${days} days earlier`; const months = Math.round(days / 30.44); if (months < 24) return `${months} month${months === 1 ? "" : "s"} earlier`; return `${Math.round(days / 365.25)} years earlier`; } // --------------------------------------------------------------------------- // The walk. // --------------------------------------------------------------------------- /** * Running per-company state, both totals, per-step deltas and every fired * predicate — one pass, in date order. * * @param {Array} ledger * @param {{ strict?: boolean }} [opts] strict:false computes over whatever is * adjudicated so the inbox can show its own coherence rows while the rest of * the ledger is still being worked. Any renderer must use strict:true. */ export function ledgerTotals(ledger, { strict = true } = {}) { const all = chronological(ledger); const pending = all.filter((e) => !isAdjudicated(e)); if (strict && pending.length) throw new UnadjudicatedLedger(pending); // Only adjudicated entries take part. An `unresolved` scope is a legitimate // outcome and MUST NOT silently feed a total, so it is carried on the step // (the rail still shows the row) and skipped by both series. const usable = all.filter(isAdjudicated); // The baseline population per scope: the most common one in that scope's own // series, ties going to `employees`. Computed over uttered values only, so a // pile of derived rows cannot move the baseline the real claims are judged by. const baseline = new Map(); for (const scope of SCOPES) { const counts = new Map(); for (const e of usable) { if (e.scope !== scope || e.valueKind !== "uttered") continue; counts.set(e.population, (counts.get(e.population) ?? 0) + 1); } let best = "employees"; let bestN = counts.get("employees") ?? 0; for (const [p, n] of counts) if (n > bestN) [best, bestN] = [p, n]; baseline.set(scope, best); } const byScopeDate = new Map(); for (const e of usable) { if (e.scopeConfidence === "unresolved") continue; const k = `${e.scope}|${e.date}`; if (!byScopeDate.has(k)) byScopeDate.set(k, []); byScopeDate.get(k).push(e); } /** Most recent usable claim per company scope, as we walk. */ const state = { media: null, coffee: null, publica: null }; /** Every claim so far that says what its people ARE, in order. */ const priorCamps = []; let stated = null; let implied = null; const steps = []; const series = { media: [], coffee: [], publica: [], stated: [], implied: [] }; for (const e of usable) { const counts = e.value != null; const usableHere = counts && e.scopeConfidence !== "unresolved"; const flags = []; // `not_his_number` and `population_mismatch` judge the entry alone; // `contradicts_component` and `same_day_conflict` judge it against the walk. if (usableHere) { const f1 = contradictsComponent({ ...e }, state); if (f1) flags.push(f1); const f2 = sameDayConflict({ ...e }, byScopeDate); if (f2) flags.push(f2); const f3 = selfNegating(e); if (f3) flags.push(f3); const f5 = notHisNumber(e); if (f5) flags.push(f5); } // OUTSIDE the `usableHere` gate, deliberately. "All my workers are contract // workers" carries no figure at all, and it is the single clearest status // claim in the corpus — gating this on a number would drop exactly the // rows the predicate exists to read. if (e.scopeConfidence !== "unresolved") { const f6 = statusFlip(e, priorCamps); if (f6) flags.push(f6); } // Recorded AFTER the predicate reads it, so a claim never flips against // itself, and only for entries whose scope is settled -- an unresolved // scope cannot say whose status reversed. if (e.scopeConfidence !== "unresolved" && campOf(e.population)) { priorCamps.push({ id: e.id, scope: e.scope, date: e.date, value: e.value ?? null, population: e.population, camp: campOf(e.population), }); } const f4 = populationMismatch(e, baseline); if (f4) flags.push(f4); // Anything the adjudicator wrote by hand rides alongside the computed ones. for (const raw of e.flags ?? []) { if (isStr(raw)) flags.push({ rule: "adjudicator", text: raw }); } const beforeStated = stated; const beforeImplied = implied; if (usableHere && COMPANY_SCOPES.includes(e.scope)) { state[e.scope] = { id: e.id, scope: e.scope, value: e.value, date: e.date }; series[e.scope].push({ id: e.id, date: e.date, value: e.value }); } // The IMPLIED total is our sum of his most recent per-company claims. It is // recomputed after every company step, and it is defined as soon as ONE // company has a number — a sum of one is still our sum. const basis = {}; let sum = null; for (const c of COMPANY_SCOPES) { basis[c] = state[c] ? { ...state[c] } : null; if (state[c]) sum = (sum ?? 0) + state[c].value; } implied = sum; // The STATED total moves only on an `all`-scope claim he actually utters. if (usableHere && e.scope === "all" && e.valueKind === "uttered") { stated = e.value; series.stated.push({ id: e.id, date: e.date, value: e.value }); } if (implied !== beforeImplied) { series.implied.push({ id: e.id, date: e.date, value: implied }); } const movedStated = stated !== beforeStated; const movedImplied = implied !== beforeImplied; steps.push({ id: e.id, date: e.date, scope: e.scope, scopeConfidence: e.scopeConfidence, scopeBasis: e.scopeBasis, population: e.population, valueKind: e.valueKind, value: e.value ?? null, display: e.display ?? null, label: e.label ?? null, quote: e.quote ?? null, src: e.src ?? null, roles: Array.isArray(e.roles) && e.roles.length ? e.roles : null, entryId: e.entryId ?? null, // No y position, ever. "several", "very few" and "+1" are a tick below the // time axis and a rail row; forcing them onto the value axis would be // inventing a number, which is the thing this whole module exists to stop. qualitative: !counts, stated, implied, statedDelta: movedStated && beforeStated != null ? stated - beforeStated : null, impliedDelta: movedImplied && beforeImplied != null ? implied - beforeImplied : null, gap: stated != null && implied != null ? implied - stated : null, impliedBasis: basis, // Which number rolled. A `value: null` claim moves neither, and the // readout must hold both rather than animate a change that did not happen. moved: movedStated && movedImplied ? "both" : movedStated ? "stated" : movedImplied ? "implied" : null, flags, }); } return { steps, series, baseline: Object.fromEntries(baseline), unresolved: usable.filter((e) => e.scopeConfidence === "unresolved").map((e) => e.id), unadjudicated: pending.map((e) => e.id), final: { stated, implied, gap: stated != null && implied != null ? implied - stated : null, }, }; } // --------------------------------------------------------------------------- // The roster // --------------------------------------------------------------------------- // Every time he enumerates WHO works for him it is two video editors and a // graphics designer -- in July 2023, September 2023, October 2023, December // 2023 and December 2024. The totals attached to that roster are three, then // four, then ten. So the roster is the control: it is the thing that does not // move while the numbers above it do, and drawing it is what makes that // visible without the video having to assert it. // // `roles` is OPTIONAL and is not one of the six. A claim with no roster is not // unadjudicated -- most claims are a number and nothing else, and gating on a // field that only five entries can ever carry would block the inbox forever. /** One enumerated role. `verbatim` is his words; the rest is our reading. */ export const ROLE_FIELDS = ["role", "count", "verbatim"]; /** Is this a usable roster? Returns the reasons it is not. */ export function rolesGaps(roles) { if (roles === undefined) return []; if (!Array.isArray(roles) || !roles.length) return ["roles must be a non-empty array"]; const bad = []; roles.forEach((r, i) => { if (!isStr(r?.role)) bad.push(`roles[${i}].role`); if (!Number.isFinite(r?.count) || r.count < 0) bad.push(`roles[${i}].count`); if (!isStr(r?.verbatim)) bad.push(`roles[${i}].verbatim`); }); return bad; } /** * The roster he last enumerated at or before `date`, or null. * * One implementation, because the rail draws it under the tally and a chapter * card states it in words, and the two disagreeing would be the video arguing * with itself on screen. */ export function rosterAt(ledger, date) { const key = dateKey(date); let best = null; for (const e of chronological(ledger)) { if (!Array.isArray(e.roles) || !e.roles.length) continue; if (dateKey(e.date) > key) break; best = e; } return best ? { id: best.id, date: best.date, roles: best.roles } : null; } /** * `2 editors · 1 designer` — the roster in as few words as it can be said in. * * The LAST word of the role is the label, because that is the part that carries * the meaning ("video editor" → editors, "graphics designer" → designers) and * the rail column has room for nothing else. */ export function rosterLine(roles) { if (!Array.isArray(roles) || !roles.length) return null; return roles .map((r) => { const head = String(r.role).trim().split(/\s+/).pop(); return `${r.count} ${head}${r.count === 1 ? "" : "s"}`; }) .join(" · "); } /** Every fired predicate, flattened — what the `claim-incoherent` rows are. */ export function coherenceFlags(ledger, opts) { return ledgerTotals(ledger, opts).steps.flatMap((s) => s.flags.map((f) => ({ id: s.id, date: s.date, scope: s.scope, ...f })), ); }