mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* feat(schema): migration v40 — takes_resolved_quality + drift_decisions Slice A1 of the v0.30 wave. Bundles all wave schema in one migration so A2/B1/C1 carry no schema of their own (codex F6 schema-first ordering). - takes.resolved_quality TEXT with CHECK (correct/incorrect/partial). - takes_resolution_consistency CHECK enforces (quality, outcome) tuple consistency at the DB layer. partial → outcome=NULL. - One-shot backfill maps legacy resolved_outcome → resolved_quality so v0.28 brains keep working with no manual reclassification. - idx_takes_scorecard partial index on (holder, kind, resolved_quality) WHERE resolved_quality IS NOT NULL — scorecard hot path. - drift_decisions audit table (consumed by Slice C1 in v0.30.3). - PGLite branch via sqlFor.pglite mirrors the same shape; RLS DO-block is Postgres-only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(takes-fence): extend ParsedTake + parser + conditional renderer (codex F3) A codex consult on the v0.30 plan caught a real bug: the v0.28 parser had no concept of resolution columns, so every cmdUpdate after a cmdResolve silently deleted resolution data on the next render. This commit kills that data-loss path. ParsedTake gains optional resolvedAt, resolvedQuality, resolvedOutcome, resolvedEvidence, resolvedValue, resolvedUnit, resolvedBy. parseTakesFence detects v0.30-shape headers and reads resolution cells when present; v0.28 7-column fences round-trip byte-identical. renderTakesFence emits the resolution columns ONLY when at least one row on the page has resolvedQuality set — pages with no resolved rows keep the narrow shape exactly as before. 11 new test cases including the round-trip preservation regression gate. Without those tests, the silent-delete bug returns the moment the parser shape drifts. Tests cover: parsing v0.30 + v0.28 shapes, conditional rendering, partial quality round-trip, upsertTakeRow + supersedeRow preservation when a page already has resolved rows. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(engine): getScorecard + getCalibrationCurve + 3-state TakeResolution Adds the calibration aggregate methods on BrainEngine. Both engines implement them with SQL-level allow-list filtering inside the GROUP BY (D4 fail-closed): hidden-holder rows contribute zero to aggregates. TakeResolution gains optional `quality` (correct|incorrect|partial). When both quality and outcome are supplied AND inconsistent, the engine throws TAKE_RESOLUTION_INVALID rather than silently overwriting. resolveTake writes both columns: quality directly, outcome derived (correct→true, incorrect→false, partial→NULL). Schema CHECK is the defense-in-depth backstop. Brier scope (D5 + D11): the SQL aggregation excludes partial rows from the Brier denominator — partial isn't a binary outcome to compare a probability against. partial_rate is reported alongside as a separate counter so hedging behavior stays visible. The 20% threshold lives in src/core/takes-resolution.ts and the CLI surfaces it in v0.30.0's cmdScorecard. New module src/core/takes-resolution.ts holds shared pure helpers (deriveResolutionTuple, finalizeScorecard) consumed by both engines so the math stays identical across backends. takeRowToTake (utils.ts) reads resolved_quality through to the Take row shape. 23 new test cases: 16 for the helpers (Brier hand-calc against a 4-bet reference at 0.205, n=0 no-divide, contradictory-input rejection, partial-exclusion contract, threshold constant); 7 against PGLite for the engine path (3-state quality writes, contradictory throws, scorecard hand-calc, n=0, SQL-level allow-list privacy filter). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cli): gbrain takes resolve --quality, takes scorecard, takes calibration cmdResolve widened: --quality correct|incorrect|partial is the new primary input. --outcome true|false stays as a back-compat alias auto-mapping to quality, with a stderr deprecation warning on use. Mutually exclusive with --quality. --evidence is a semantic alias for --source on the resolve subcommand. cmdResolve mirrors resolution metadata into the takes-fence on disk via the page-lock-aware path. Round-trip preservation through parseTakesFence + renderTakesFence keeps resolution data intact across unrelated edits to other rows on the same page. Removes the v0.28 deferred-rendering warning. cmdScorecard prints `correct | incorrect | partial`, accuracy, Brier (correct ∨ incorrect only; lower is better; 0.25 = always-50% baseline), and partial_rate. When partial_rate > 20% the CLI prints "[!] partial_rate is high — calibration may be optimistic" so hedging behavior stays visible even though it doesn't enter the math (D11). Small-N note when resolved < 100. JSON output via --json. cmdCalibration bins resolved correct/incorrect bets by stated weight (--bucket-size, default 0.1) and prints observed vs predicted vs delta per bucket. Diagonal alignment = perfect calibration. Both new subcommands wire allow-list as undefined for local CLI callers (trusted); MCP path will thread it from access_tokens.permissions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(mcp): register takes_scorecard + takes_calibration ops Both ops are read-scope, MCP-callable, allow-list-honoring. Handlers thread ctx.takesHoldersAllowList into the engine method's required allowList parameter, which applies WHERE holder = ANY at SQL aggregation level (D4 fail-closed). Local CLI callers leave the allow-list undefined and see all holders. Updates the OperationContext.takesHoldersAllowList contract comment to list the new aggregate ops alongside takes_list, takes_search, query. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.30.0 release: calibration core (Slice A1 of v0.30 wave) VERSION + package.json bump. CHANGELOG entry covers the release-summary (headline + math table + privacy note + data-loss-bug-killed note), "## To take advantage of v0.30.0" upgrade path, and itemized changes. llms-full.txt regenerated to capture the v0.28.x annotations that had been merged but not yet rolled into the docs bundle. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(e2e): scorecard + calibration parity on real Postgres + NUMERIC fix Adds end-to-end coverage for v0.30.0 (Slice A1) against real Postgres: - test/e2e/takes-scorecard-parity.test.ts (new): seeds the same 6-bet fixture (4 binary garry + 1 partial garry + 1 binary harj) into both Postgres and PGLite, asserts getScorecard + getCalibrationCurve return byte-identical results across engines, runs the 4-bet hand-calc Brier reference (0.205) on real PG, and verifies the SQL-level allow-list filter strictly subtracts hidden-holder rows on both engines. - test/e2e/takes-postgres.test.ts: extended with 8 v0.30 cases — quality semantics (correct/partial/back-compat) writes the expected (quality, outcome) tuple on real PG; the takes_resolution_consistency CHECK constraint actually fires on a contradictory raw UPDATE; getScorecard + getCalibrationCurve coherent shape + ordered-bucket invariants; PRIVACY allow-list filter on real PG; MCP dispatch path for takes_scorecard + takes_calibration with allow-list threading. While writing the parity test, the e2e harness caught a real bug PGLite tolerated: postgres.js sends scalar `${bucketSize}` params as text by default, so `FLOOR(weight / $N)` tried to coerce '0.1' to integer and threw `invalid input syntax for type integer: "0.1"`. The NUMERIC fix also kills a separate FP-precision divergence — `FLOOR(0.7 / 0.1)` returns 6 on real PG (IEEE 754 rounds 0.7/0.1 to 6.9999...) and 7 on PGLite. Both engines now bucket via `weight::numeric / $N::numeric` which is exact decimal arithmetic and engine-agnostic. This is the v0.30.0 wave's first cross-engine parity test. Same shape will guard A2's getTrajectory + getAnnualReview when those land. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(changelog): correct v40→v43 reference and expand v0.30.0 test note Two fixes to the v0.30.0 entry after the master merge renumbered the migration: - The "#### Added" bullet still said "Schema migration v40"; bumped to v43. - The "#### Tests" section only enumerated unit tests. The PR also ships 19 E2E cases (11 in takes-scorecard-parity, 8 extending takes-postgres) that exercise the calibration math against real Postgres and the PG↔PGLite engine parity. Added the count + a note about the two real bugs the parity test caught (postgres.js string-typed scalar params and IEEE 754 bucketing divergence) that PGLite tolerated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
410 lines
16 KiB
TypeScript
410 lines
16 KiB
TypeScript
import { describe, test, expect } from 'bun:test';
|
|
import {
|
|
parseTakesFence,
|
|
renderTakesFence,
|
|
upsertTakeRow,
|
|
supersedeRow,
|
|
stripTakesFence,
|
|
TAKES_FENCE_BEGIN,
|
|
TAKES_FENCE_END,
|
|
} from '../src/core/takes-fence.ts';
|
|
|
|
const SAMPLE_BODY = `# Alice Example
|
|
|
|
Some prose at the top.
|
|
|
|
## Takes
|
|
|
|
${TAKES_FENCE_BEGIN}
|
|
| # | claim | kind | who | weight | since | source |
|
|
|---|-------|------|-----|--------|-------|--------|
|
|
| 1 | CEO of Acme | fact | world | 1.0 | 2017-01 | Crustdata |
|
|
| 2 | Strong technical founder | take | garry | 0.85 | 2026-04-29 | OH 2026-04-29 |
|
|
| 3 | ~~Will reach $50B~~ | bet | garry | 0.7 | 2026-04-29 → 2026-06 | superseded by #4 |
|
|
| 4 | Will reach $30B | bet | garry | 0.55 | 2026-06 | revised after Q2 |
|
|
${TAKES_FENCE_END}
|
|
|
|
## Notes
|
|
|
|
Other content below the fence.
|
|
`;
|
|
|
|
describe('parseTakesFence', () => {
|
|
test('parses canonical-form table', () => {
|
|
const { takes, warnings } = parseTakesFence(SAMPLE_BODY);
|
|
expect(warnings).toEqual([]);
|
|
expect(takes).toHaveLength(4);
|
|
expect(takes[0]).toMatchObject({
|
|
rowNum: 1,
|
|
claim: 'CEO of Acme',
|
|
kind: 'fact',
|
|
holder: 'world',
|
|
weight: 1.0,
|
|
sinceDate: '2017-01',
|
|
source: 'Crustdata',
|
|
active: true,
|
|
});
|
|
});
|
|
|
|
test('strikethrough → active=false; claim text stripped', () => {
|
|
const { takes } = parseTakesFence(SAMPLE_BODY);
|
|
const row3 = takes.find(t => t.rowNum === 3)!;
|
|
expect(row3.active).toBe(false);
|
|
expect(row3.claim).toBe('Will reach $50B');
|
|
});
|
|
|
|
test('date range splits into since + until', () => {
|
|
const { takes } = parseTakesFence(SAMPLE_BODY);
|
|
const row3 = takes.find(t => t.rowNum === 3)!;
|
|
expect(row3.sinceDate).toBe('2026-04-29');
|
|
expect(row3.untilDate).toBe('2026-06');
|
|
});
|
|
|
|
test('returns empty + no warnings when no fence present', () => {
|
|
const { takes, warnings } = parseTakesFence('# Just prose\n\nNo takes here.');
|
|
expect(takes).toEqual([]);
|
|
expect(warnings).toEqual([]);
|
|
});
|
|
|
|
test('warns on unbalanced fence (missing end)', () => {
|
|
const body = `## Takes\n\n${TAKES_FENCE_BEGIN}\n| # | claim | kind | who | weight | since | source |\n`;
|
|
const { takes, warnings } = parseTakesFence(body);
|
|
expect(takes).toEqual([]);
|
|
expect(warnings.some(w => w.includes('TAKES_FENCE_UNBALANCED'))).toBe(true);
|
|
});
|
|
|
|
test('skips malformed rows + records TAKES_TABLE_MALFORMED warnings', () => {
|
|
const body = `${TAKES_FENCE_BEGIN}
|
|
| # | claim | kind | who | weight | since | source |
|
|
|---|-------|------|-----|--------|-------|--------|
|
|
| 1 | Valid row | fact | world | 1.0 | 2026-01 | source |
|
|
| 2 | Bad weight | take | garry | not-a-number | 2026-01 | x |
|
|
| 3 | Unknown kind | wibble | garry | 0.5 | 2026-01 | x |
|
|
| zzz | Bad rownum | fact | world | 1.0 | 2026-01 | x |
|
|
${TAKES_FENCE_END}`;
|
|
const { takes, warnings } = parseTakesFence(body);
|
|
expect(takes).toHaveLength(1);
|
|
expect(takes[0].claim).toBe('Valid row');
|
|
expect(warnings.length).toBeGreaterThanOrEqual(3);
|
|
expect(warnings.some(w => w.includes('non-numeric weight'))).toBe(true);
|
|
expect(warnings.some(w => w.includes('unknown kind'))).toBe(true);
|
|
expect(warnings.some(w => w.includes('invalid row_num'))).toBe(true);
|
|
});
|
|
|
|
test('flags TAKES_ROW_NUM_COLLISION on duplicate row_num', () => {
|
|
const body = `${TAKES_FENCE_BEGIN}
|
|
| # | claim | kind | who | weight | since | source |
|
|
|---|-------|------|-----|--------|-------|--------|
|
|
| 1 | First | fact | world | 1.0 | | |
|
|
| 1 | Duplicate | fact | world | 1.0 | | |
|
|
${TAKES_FENCE_END}`;
|
|
const { takes, warnings } = parseTakesFence(body);
|
|
expect(takes).toHaveLength(1);
|
|
expect(warnings.some(w => w.includes('TAKES_ROW_NUM_COLLISION'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('renderTakesFence', () => {
|
|
test('round-trip preserves all fields', () => {
|
|
const original = parseTakesFence(SAMPLE_BODY);
|
|
const rendered = renderTakesFence(original.takes);
|
|
expect(rendered.startsWith(TAKES_FENCE_BEGIN)).toBe(true);
|
|
expect(rendered.endsWith(TAKES_FENCE_END)).toBe(true);
|
|
// Re-parse the rendered fence and confirm round-trip equivalence.
|
|
const reparsed = parseTakesFence(rendered);
|
|
expect(reparsed.warnings).toEqual([]);
|
|
expect(reparsed.takes).toHaveLength(original.takes.length);
|
|
for (let i = 0; i < original.takes.length; i++) {
|
|
const before = original.takes[i];
|
|
const after = reparsed.takes[i];
|
|
expect(after.rowNum).toBe(before.rowNum);
|
|
expect(after.claim).toBe(before.claim);
|
|
expect(after.kind).toBe(before.kind);
|
|
expect(after.holder).toBe(before.holder);
|
|
expect(after.weight).toBe(before.weight);
|
|
expect(after.active).toBe(before.active);
|
|
expect(after.sinceDate).toBe(before.sinceDate);
|
|
expect(after.untilDate).toBe(before.untilDate);
|
|
expect(after.source).toBe(before.source);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('upsertTakeRow', () => {
|
|
test('appends to existing fence at next row_num', () => {
|
|
const { body, rowNum } = upsertTakeRow(SAMPLE_BODY, {
|
|
claim: 'Best founder I have met this batch',
|
|
kind: 'take',
|
|
holder: 'garry',
|
|
weight: 0.95,
|
|
sinceDate: '2026-05-01',
|
|
source: 'OH 2026-05-01',
|
|
active: true,
|
|
});
|
|
expect(rowNum).toBe(5);
|
|
const { takes } = parseTakesFence(body);
|
|
expect(takes).toHaveLength(5);
|
|
expect(takes[4].claim).toBe('Best founder I have met this batch');
|
|
expect(takes[4].rowNum).toBe(5);
|
|
});
|
|
|
|
test('creates a new Takes section when no fence exists', () => {
|
|
const fresh = '# New Page\n\nSome content.\n';
|
|
const { body, rowNum } = upsertTakeRow(fresh, {
|
|
claim: 'First take',
|
|
kind: 'fact',
|
|
holder: 'world',
|
|
weight: 1.0,
|
|
active: true,
|
|
});
|
|
expect(rowNum).toBe(1);
|
|
expect(body).toContain('## Takes');
|
|
expect(body).toContain(TAKES_FENCE_BEGIN);
|
|
const { takes } = parseTakesFence(body);
|
|
expect(takes).toHaveLength(1);
|
|
});
|
|
|
|
test('row_num is monotonic — never reuses gaps', () => {
|
|
// Body where rows 2 and 4 are present (1 and 3 deleted by hand-edit)
|
|
const body = `## Takes
|
|
|
|
${TAKES_FENCE_BEGIN}
|
|
| # | claim | kind | who | weight | since | source |
|
|
|---|-------|------|-----|--------|-------|--------|
|
|
| 2 | Two | fact | world | 1.0 | 2026-01 | x |
|
|
| 4 | Four | fact | world | 1.0 | 2026-01 | x |
|
|
${TAKES_FENCE_END}
|
|
`;
|
|
const { rowNum } = upsertTakeRow(body, {
|
|
claim: 'Five',
|
|
kind: 'fact',
|
|
holder: 'world',
|
|
weight: 1.0,
|
|
active: true,
|
|
});
|
|
expect(rowNum).toBe(5); // max(2,4)+1, NOT 1 (gap-fill would break refs)
|
|
});
|
|
});
|
|
|
|
describe('supersedeRow', () => {
|
|
test('strikes old row + appends new at end', () => {
|
|
const { body, oldRowNum, newRowNum } = supersedeRow(SAMPLE_BODY, 2, {
|
|
claim: 'Strongest technical founder I have met',
|
|
kind: 'take',
|
|
holder: 'garry',
|
|
weight: 0.95,
|
|
sinceDate: '2026-05-01',
|
|
source: 'OH 2026-05-01',
|
|
});
|
|
expect(oldRowNum).toBe(2);
|
|
expect(newRowNum).toBe(5);
|
|
const { takes } = parseTakesFence(body);
|
|
const old = takes.find(t => t.rowNum === 2)!;
|
|
expect(old.active).toBe(false);
|
|
const fresh = takes.find(t => t.rowNum === 5)!;
|
|
expect(fresh.claim).toBe('Strongest technical founder I have met');
|
|
expect(fresh.active).toBe(true);
|
|
});
|
|
|
|
test('throws when target row not found', () => {
|
|
expect(() =>
|
|
supersedeRow(SAMPLE_BODY, 999, {
|
|
claim: 'x',
|
|
kind: 'fact',
|
|
holder: 'world',
|
|
weight: 1.0,
|
|
}),
|
|
).toThrow();
|
|
});
|
|
});
|
|
|
|
describe('stripTakesFence', () => {
|
|
test('removes the fence block from the body (privacy fix)', () => {
|
|
const stripped = stripTakesFence(SAMPLE_BODY);
|
|
expect(stripped).not.toContain(TAKES_FENCE_BEGIN);
|
|
expect(stripped).not.toContain(TAKES_FENCE_END);
|
|
expect(stripped).not.toContain('Strong technical founder');
|
|
expect(stripped).not.toContain('Will reach $50B');
|
|
// Surrounding prose preserved.
|
|
expect(stripped).toContain('Some prose at the top.');
|
|
expect(stripped).toContain('## Notes');
|
|
expect(stripped).toContain('Other content below the fence.');
|
|
});
|
|
|
|
test('returns body unchanged when no fence present', () => {
|
|
const body = '# Plain page\n\nNo takes here.';
|
|
expect(stripTakesFence(body)).toBe(body);
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// v0.30.0 (Slice A1): resolution columns + round-trip preservation.
|
|
// The round-trip preservation tests are the codex-F3 regression gate.
|
|
// Without these, every `gbrain takes update` after a resolve silently
|
|
// deletes the resolution data on the next render.
|
|
// ============================================================
|
|
|
|
describe('v0.30.0 resolution columns', () => {
|
|
const RESOLVED_BODY = `# Some Page\n\n## Takes\n\n${TAKES_FENCE_BEGIN}
|
|
| # | claim | kind | who | weight | since | source | resolved | quality | evidence | value | unit | by |
|
|
|---|-------|------|-----|--------|-------|--------|----------|---------|----------|-------|------|----|
|
|
| 1 | First bet | bet | garry | 0.7 | 2026-04 | OH | 2026-04-30 | correct | Series A closed | 50 | usd | garry |
|
|
| 2 | Pending bet | bet | garry | 0.6 | 2026-04 | OH | | | | | | |
|
|
${TAKES_FENCE_END}\n`;
|
|
|
|
test('parses v0.30-shape fence: resolved row has resolution fields populated', () => {
|
|
const { takes, warnings } = parseTakesFence(RESOLVED_BODY);
|
|
expect(warnings).toEqual([]);
|
|
expect(takes).toHaveLength(2);
|
|
expect(takes[0]).toMatchObject({
|
|
rowNum: 1,
|
|
resolvedAt: '2026-04-30',
|
|
resolvedQuality: 'correct',
|
|
resolvedOutcome: true,
|
|
resolvedEvidence: 'Series A closed',
|
|
resolvedValue: 50,
|
|
resolvedUnit: 'usd',
|
|
resolvedBy: 'garry',
|
|
});
|
|
});
|
|
|
|
test('parses v0.30-shape fence: unresolved row has resolution fields undefined', () => {
|
|
const { takes } = parseTakesFence(RESOLVED_BODY);
|
|
expect(takes[1].resolvedQuality).toBeUndefined();
|
|
expect(takes[1].resolvedOutcome).toBeUndefined();
|
|
expect(takes[1].resolvedAt).toBeUndefined();
|
|
});
|
|
|
|
test('renderer: page with no resolved rows keeps narrow 7-column shape', () => {
|
|
const { takes } = parseTakesFence(SAMPLE_BODY);
|
|
const rendered = renderTakesFence(takes);
|
|
expect(rendered).toContain('| # | claim | kind | who | weight | since | source |');
|
|
expect(rendered).not.toContain('quality');
|
|
expect(rendered).not.toContain('resolved');
|
|
});
|
|
|
|
test('renderer: any resolved row triggers wide 13-column shape', () => {
|
|
const { takes } = parseTakesFence(SAMPLE_BODY);
|
|
// Mutate one row to have resolution data.
|
|
takes[0] = {
|
|
...takes[0],
|
|
resolvedAt: '2026-05-01',
|
|
resolvedQuality: 'correct',
|
|
resolvedOutcome: true,
|
|
resolvedEvidence: 'verified',
|
|
};
|
|
const rendered = renderTakesFence(takes);
|
|
expect(rendered).toContain('quality');
|
|
expect(rendered).toContain('evidence');
|
|
expect(rendered).toContain('correct');
|
|
expect(rendered).toContain('verified');
|
|
});
|
|
|
|
// ============================================================
|
|
// CODEX F3 REGRESSION GATE — DATA-LOSS BUG GUARD
|
|
// ============================================================
|
|
// Without these tests, `gbrain takes update --row 2` on a page where row 1
|
|
// is resolved would render only the 7-column shape on parse + render,
|
|
// silently deleting row 1's resolution cells on the next disk write.
|
|
// ============================================================
|
|
test('REGRESSION (codex F3): round-trip preserves resolution fields on a resolved row', () => {
|
|
const { takes } = parseTakesFence(RESOLVED_BODY);
|
|
const rendered = renderTakesFence(takes);
|
|
const { takes: roundTripped } = parseTakesFence(rendered);
|
|
expect(roundTripped).toHaveLength(2);
|
|
expect(roundTripped[0].resolvedQuality).toBe('correct');
|
|
expect(roundTripped[0].resolvedOutcome).toBe(true);
|
|
expect(roundTripped[0].resolvedAt).toBe('2026-04-30');
|
|
expect(roundTripped[0].resolvedEvidence).toBe('Series A closed');
|
|
expect(roundTripped[0].resolvedValue).toBe(50);
|
|
expect(roundTripped[0].resolvedUnit).toBe('usd');
|
|
expect(roundTripped[0].resolvedBy).toBe('garry');
|
|
});
|
|
|
|
test('REGRESSION (codex F3): updating an unrelated row preserves resolution on the resolved row', () => {
|
|
const { takes } = parseTakesFence(RESOLVED_BODY);
|
|
// Simulate cmdUpdate's spread pattern on row 2 (the unresolved one).
|
|
const updated = takes.map(t =>
|
|
t.rowNum === 2 ? { ...t, weight: 0.95 } : t,
|
|
);
|
|
const rendered = renderTakesFence(updated);
|
|
const { takes: after } = parseTakesFence(rendered);
|
|
// Row 1 (resolved) — resolution survives intact.
|
|
expect(after[0].resolvedQuality).toBe('correct');
|
|
expect(after[0].resolvedEvidence).toBe('Series A closed');
|
|
// Row 2 — weight changed, no resolution.
|
|
expect(after[1].weight).toBe(0.95);
|
|
expect(after[1].resolvedQuality).toBeUndefined();
|
|
});
|
|
|
|
test('REGRESSION: parsing a v0.28-shape fence (no resolution columns) round-trips byte-identical narrow shape', () => {
|
|
const { takes } = parseTakesFence(SAMPLE_BODY);
|
|
const rendered = renderTakesFence(takes);
|
|
expect(rendered).not.toContain('quality');
|
|
expect(rendered).not.toContain('| resolved |');
|
|
// Re-parse verifies fidelity.
|
|
const { takes: roundTripped } = parseTakesFence(rendered);
|
|
expect(roundTripped).toHaveLength(takes.length);
|
|
for (let i = 0; i < takes.length; i++) {
|
|
expect(roundTripped[i].claim).toBe(takes[i].claim);
|
|
expect(roundTripped[i].weight).toBe(takes[i].weight);
|
|
expect(roundTripped[i].active).toBe(takes[i].active);
|
|
}
|
|
});
|
|
|
|
test('partial quality renders + parses correctly (outcome left empty)', () => {
|
|
const { takes } = parseTakesFence(SAMPLE_BODY);
|
|
takes[0] = {
|
|
...takes[0],
|
|
resolvedAt: '2026-05-01',
|
|
resolvedQuality: 'partial',
|
|
resolvedOutcome: undefined, // partial has no boolean outcome
|
|
resolvedEvidence: 'kind of right',
|
|
};
|
|
const rendered = renderTakesFence(takes);
|
|
expect(rendered).toContain('partial');
|
|
const { takes: roundTripped } = parseTakesFence(rendered);
|
|
expect(roundTripped[0].resolvedQuality).toBe('partial');
|
|
expect(roundTripped[0].resolvedOutcome).toBeUndefined();
|
|
});
|
|
|
|
test('upsertTakeRow on a page with resolved rows preserves the resolution + uses wide shape', () => {
|
|
const { body: nextBody, rowNum } = upsertTakeRow(RESOLVED_BODY, {
|
|
claim: 'Brand new bet',
|
|
kind: 'bet',
|
|
holder: 'garry',
|
|
weight: 0.4,
|
|
active: true,
|
|
});
|
|
expect(rowNum).toBe(3);
|
|
const { takes } = parseTakesFence(nextBody);
|
|
expect(takes).toHaveLength(3);
|
|
// Row 1's resolution survived the upsert.
|
|
expect(takes[0].resolvedQuality).toBe('correct');
|
|
expect(takes[0].resolvedEvidence).toBe('Series A closed');
|
|
// New row is unresolved.
|
|
expect(takes[2].resolvedQuality).toBeUndefined();
|
|
// Wide shape was emitted (resolution columns visible).
|
|
expect(nextBody).toContain('quality');
|
|
});
|
|
|
|
test('supersedeRow preserves resolution on the row being struck through', () => {
|
|
// Note: superseding a RESOLVED bet would normally throw at the engine
|
|
// layer (TAKE_RESOLVED_IMMUTABLE). The fence-layer supersedeRow doesn't
|
|
// enforce that — it just strikes through. The point of this test is
|
|
// that whatever resolution data lives on the old row survives the strike.
|
|
const { body: nextBody } = supersedeRow(RESOLVED_BODY, 1, {
|
|
claim: 'Updated bet',
|
|
kind: 'bet',
|
|
holder: 'garry',
|
|
weight: 0.5,
|
|
});
|
|
const { takes } = parseTakesFence(nextBody);
|
|
const oldRow = takes.find(t => t.rowNum === 1)!;
|
|
expect(oldRow.active).toBe(false);
|
|
// Resolution data on the old (struck) row preserved.
|
|
expect(oldRow.resolvedQuality).toBe('correct');
|
|
expect(oldRow.resolvedEvidence).toBe('Series A closed');
|
|
});
|
|
});
|