mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
fix(chronicle): pre-landing review — conflict validity, parse-barrier date, remote conflict redaction (#2390)
Three bugs caught by the codex pre-landing review on the diff: 1. findOntologyConflicts ignored valid_until, so a normal forward supersession (founder→advisor from two sources) falsely reported as a live conflict. Now restricted to currently-open rows (valid_until IS NULL) in both engines. 2. The extractor parse barrier accepted any when-string >= 4 chars; a non-date value slipped past, wrote the event page, then threw on the projection's ::date cast (partial write). isValidProposal now requires a real parseable date. 3. The ontology_conflicts op had no remote diary redaction (ontology_get did); remote callers now get diary-sourced values filtered, and conflicts that lose their disagreement after redaction are dropped. Three regression tests added; 29 chronicle tests + eval 6/6 green; typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
d63f605e26
commit
d1bd5f9d05
@@ -73,6 +73,9 @@ export function isValidProposal(e: unknown): e is ChronicleEventProposal {
|
||||
const o = e as Record<string, unknown>;
|
||||
return (
|
||||
typeof o.when === 'string' && o.when.length >= 4 &&
|
||||
// Must be a REAL parseable date — otherwise isoDay()/::date would write a
|
||||
// garbage event page and then throw on the projection cast (partial write).
|
||||
!Number.isNaN(new Date(o.when).getTime()) &&
|
||||
typeof o.what === 'string' && o.what.trim().length > 0 &&
|
||||
Array.isArray(o.who) && o.who.every((w) => typeof w === 'string') &&
|
||||
typeof o.kind === 'string'
|
||||
|
||||
+12
-4
@@ -5217,10 +5217,18 @@ const ontology_conflicts: Operation = {
|
||||
params: {
|
||||
min_confidence: { type: 'number', description: 'Only consider observations at/above this confidence (0..1).' },
|
||||
},
|
||||
handler: async (ctx, p) => ctx.engine.findOntologyConflicts({
|
||||
minConfidence: typeof p.min_confidence === 'number' ? p.min_confidence : undefined,
|
||||
...sourceScopeOpts(ctx),
|
||||
}),
|
||||
handler: async (ctx, p) => {
|
||||
const conflicts = await ctx.engine.findOntologyConflicts({
|
||||
minConfidence: typeof p.min_confidence === 'number' ? p.min_confidence : undefined,
|
||||
...sourceScopeOpts(ctx),
|
||||
});
|
||||
if (ctx.remote === false) return conflicts;
|
||||
// Remote: redact diary-sourced values; drop conflicts that no longer have
|
||||
// ≥2 distinct values once diary provenance is removed (no leak via conflicts).
|
||||
return conflicts
|
||||
.map((c) => ({ ...c, values: c.values.filter((v) => !(v.source ?? '').startsWith('life/diary/')) }))
|
||||
.filter((c) => new Set(c.values.map((v) => v.value)).size >= 2);
|
||||
},
|
||||
cliHints: { name: 'ontology-contradictions' },
|
||||
};
|
||||
|
||||
|
||||
@@ -3627,7 +3627,7 @@ export class PGLiteEngine implements BrainEngine {
|
||||
const r = await this.db.query(
|
||||
`WITH cur AS (
|
||||
SELECT entity_slug, dimension, value, source_markdown_slug AS source, confidence, id AS fact_id
|
||||
FROM facts WHERE dimension IS NOT NULL AND expired_at IS NULL
|
||||
FROM facts WHERE dimension IS NOT NULL AND expired_at IS NULL AND valid_until IS NULL
|
||||
AND (dim_status IS NULL OR dim_status = 'active') AND confidence >= $1 ${scope}
|
||||
)
|
||||
SELECT entity_slug, dimension,
|
||||
|
||||
@@ -3681,7 +3681,7 @@ export class PostgresEngine implements BrainEngine {
|
||||
WITH cur AS (
|
||||
SELECT entity_slug, dimension, value, source_markdown_slug AS source, confidence, id AS fact_id
|
||||
FROM facts
|
||||
WHERE dimension IS NOT NULL AND expired_at IS NULL
|
||||
WHERE dimension IS NOT NULL AND expired_at IS NULL AND valid_until IS NULL
|
||||
AND (dim_status IS NULL OR dim_status = 'active')
|
||||
AND confidence >= ${minConf} ${scope}
|
||||
)
|
||||
|
||||
@@ -97,6 +97,15 @@ describe('runChronicleExtract', () => {
|
||||
expect(await countEvents()).toBe(before); // no partial write
|
||||
});
|
||||
|
||||
test('parse barrier: a non-date `when` writes NOTHING (codex fix #2)', async () => {
|
||||
const before = await countEvents();
|
||||
const badDate: ChronicleJudge = async () => ({ events: [{ when: 'not-a-date', who: [], what: 'x', kind: 'meeting' }] });
|
||||
const r = await runChronicleExtract(engine, { slug: 'meetings/2026-06-18-sync', judge: badDate });
|
||||
expect(r.status).toBe('skipped');
|
||||
expect(r.reason).toBe('malformed_proposal');
|
||||
expect(await countEvents()).toBe(before);
|
||||
});
|
||||
|
||||
test('no events → no_events status', async () => {
|
||||
const none: ChronicleJudge = async () => ({ events: [] });
|
||||
const r = await runChronicleExtract(engine, { slug: 'meetings/2026-06-18-sync', judge: none });
|
||||
|
||||
@@ -40,6 +40,16 @@ describe('ontology ops', () => {
|
||||
expect(remote.some((r) => (r.source ?? '').startsWith('life/diary/'))).toBe(false);
|
||||
});
|
||||
|
||||
test('remote ontology_conflicts redacts diary-sourced disagreement (codex fix #3)', async () => {
|
||||
// A conflict where one side is diary-sourced: advisor (meeting) vs founder (diary).
|
||||
await engine.mergeOntologyFact({ entitySlug: 'people/y', dimension: 'role', value: 'advisor', source: 'meetings/a', validFrom: '2026-05-01' });
|
||||
await engine.mergeOntologyFact({ entitySlug: 'people/y', dimension: 'role', value: 'founder', source: 'life/diary/2026-06-18', validFrom: '2026-01-01' });
|
||||
const local = await operationsByName.ontology_conflicts.handler(ctx(false), {}) as { entity_slug: string }[];
|
||||
expect(local.some((c) => c.entity_slug === 'people/y')).toBe(true);
|
||||
const remote = await operationsByName.ontology_conflicts.handler(ctx(true), {}) as { entity_slug: string }[];
|
||||
expect(remote.some((c) => c.entity_slug === 'people/y')).toBe(false); // diary side redacted → no longer a disagreement
|
||||
});
|
||||
|
||||
test('ontology_dimensions + ontology_conflicts surface via ops', async () => {
|
||||
await engine.mergeOntologyFact({ entitySlug: SARAH, dimension: 'role', value: 'advisor', source: 'meetings/a', validFrom: '2026-05-01' });
|
||||
await engine.mergeOntologyFact({ entitySlug: SARAH, dimension: 'role', value: 'founder', source: 'meetings/b', validFrom: '2026-01-01' });
|
||||
|
||||
@@ -82,6 +82,15 @@ describe('findOntologyConflicts + discoverOntologyDimensions', () => {
|
||||
expect(bobRole!.values.map((v) => v.value).sort()).toEqual(['advisor', 'founder']);
|
||||
});
|
||||
|
||||
test('forward supersession is NOT reported as a conflict (only live disagreement is)', async () => {
|
||||
// founder (2024) → advisor (2026): the founder row is closed via valid_until,
|
||||
// so it must NOT count as a current conflict (codex pre-landing fix #1).
|
||||
await engine.mergeOntologyFact({ entitySlug: SARAH, dimension: 'role', value: 'founder', source: 'm/a', validFrom: '2024-01-01' });
|
||||
await engine.mergeOntologyFact({ entitySlug: SARAH, dimension: 'role', value: 'advisor', source: 'm/b', validFrom: '2026-05-01' });
|
||||
const conflicts = await engine.findOntologyConflicts();
|
||||
expect(conflicts.some((c) => c.entity_slug === SARAH && c.dimension === 'role')).toBe(false);
|
||||
});
|
||||
|
||||
test('discoverOntologyDimensions rolls up by dimension', async () => {
|
||||
await engine.mergeOntologyFact({ entitySlug: SARAH, dimension: 'role', value: 'founder', source: 'meetings/a' });
|
||||
await engine.mergeOntologyFact({ entitySlug: 'people/bob', dimension: 'role', value: 'advisor', source: 'meetings/b' });
|
||||
|
||||
Reference in New Issue
Block a user