@totalreclaw/totalreclaw 1.6.0 → 3.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAWHUB.md +134 -0
- package/README.md +407 -64
- package/SKILL.md +1032 -0
- package/api-client.ts +5 -5
- package/claims-helper.ts +686 -0
- package/config.ts +211 -0
- package/consolidation.ts +141 -33
- package/contradiction-sync.ts +1389 -0
- package/crypto.ts +63 -261
- package/digest-sync.ts +516 -0
- package/embedding.ts +69 -46
- package/extractor.ts +1307 -84
- package/hot-cache-wrapper.ts +1 -1
- package/import-adapters/gemini-adapter.ts +243 -0
- package/import-adapters/index.ts +3 -0
- package/import-adapters/types.ts +1 -1
- package/index.ts +1887 -323
- package/llm-client.ts +106 -53
- package/lsh.ts +21 -210
- package/package.json +20 -7
- package/pin.ts +502 -0
- package/reranker.ts +96 -124
- package/skill.json +213 -0
- package/subgraph-search.ts +112 -5
- package/subgraph-store.ts +559 -275
- package/consolidation.test.ts +0 -356
- package/extractor-dedup.test.ts +0 -168
- package/import-adapters/import-adapters.test.ts +0 -1123
- package/lsh.test.ts +0 -463
- package/pocv2-e2e-test.ts +0 -917
- package/porter-stemmer.d.ts +0 -4
- package/reranker.test.ts +0 -594
- package/semantic-dedup.test.ts +0 -392
- package/setup.sh +0 -19
- package/store-dedup-wiring.test.ts +0 -186
|
@@ -0,0 +1,1389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TotalReclaw Plugin — contradiction detection + auto-resolution (Phase 2 Slice 2d).
|
|
3
|
+
*
|
|
4
|
+
* Runs after store-time dedup (>= 0.85 cosine) and before the canonical Claim
|
|
5
|
+
* is encrypted + written. For every entity on the new claim, fetches existing
|
|
6
|
+
* active claims that reference the same entity via the entity trapdoor, decrypts
|
|
7
|
+
* them, and asks the WASM core to detect contradictions in the [0.3, 0.85) band.
|
|
8
|
+
* Each contradicting pair is then resolved via the P2-3 formula; the winner is
|
|
9
|
+
* kept on-chain, the loser is queued for tombstoning. Pinned claims are never
|
|
10
|
+
* touched — a contradiction against a pinned claim always causes the new write
|
|
11
|
+
* to be skipped with reason `existing_pinned`.
|
|
12
|
+
*
|
|
13
|
+
* This module mirrors the structure of `digest-sync.ts`: pure functions at the
|
|
14
|
+
* core, I/O behind dependency injection so the test file can run the real WASM
|
|
15
|
+
* while stubbing subgraph + filesystem.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import os from 'node:os';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import {
|
|
23
|
+
computeEntityTrapdoor,
|
|
24
|
+
isDigestBlob,
|
|
25
|
+
type AutoResolveMode,
|
|
26
|
+
} from './claims-helper.js';
|
|
27
|
+
|
|
28
|
+
const requireWasm = createRequire(import.meta.url);
|
|
29
|
+
let _wasm: typeof import('@totalreclaw/core') | null = null;
|
|
30
|
+
function getWasm() {
|
|
31
|
+
if (!_wasm) _wasm = requireWasm('@totalreclaw/core');
|
|
32
|
+
return _wasm!;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Types
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
export interface ContradictionLogger {
|
|
40
|
+
info: (msg: string) => void;
|
|
41
|
+
warn: (msg: string) => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
45
|
+
export type CanonicalClaim = Record<string, any>;
|
|
46
|
+
|
|
47
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
48
|
+
export type WeightsFile = Record<string, any>;
|
|
49
|
+
|
|
50
|
+
/** Per-component score breakdown, mirroring Rust `ScoreComponents`. */
|
|
51
|
+
export interface ScoreComponents {
|
|
52
|
+
confidence: number;
|
|
53
|
+
corroboration: number;
|
|
54
|
+
recency: number;
|
|
55
|
+
validation: number;
|
|
56
|
+
weighted_total: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* What action the write path should take for a given candidate existing claim.
|
|
61
|
+
*
|
|
62
|
+
* - `no_contradiction`: nothing to do, proceed with the normal write.
|
|
63
|
+
* - `supersede_existing`: the new claim wins, tombstone the named existing fact.
|
|
64
|
+
* - `skip_new`: an existing claim wins (or is pinned) — skip the new write entirely.
|
|
65
|
+
* - `tie_leave_both`: the formula scores are within TIE_ZONE_SCORE_TOLERANCE;
|
|
66
|
+
* treat the "contradiction" as rounding noise and leave both claims active.
|
|
67
|
+
* Emitted by the TypeScript tie-zone guard after `resolveWithCore` returns,
|
|
68
|
+
* never by the Rust core directly. The write path ignores this variant.
|
|
69
|
+
*/
|
|
70
|
+
export type ResolutionDecision =
|
|
71
|
+
| { action: 'no_contradiction' }
|
|
72
|
+
| {
|
|
73
|
+
action: 'supersede_existing';
|
|
74
|
+
existingFactId: string;
|
|
75
|
+
existingClaim: CanonicalClaim;
|
|
76
|
+
entityId: string;
|
|
77
|
+
similarity: number;
|
|
78
|
+
winnerScore: number;
|
|
79
|
+
loserScore: number;
|
|
80
|
+
winnerComponents: ScoreComponents;
|
|
81
|
+
loserComponents: ScoreComponents;
|
|
82
|
+
}
|
|
83
|
+
| {
|
|
84
|
+
action: 'skip_new';
|
|
85
|
+
reason: 'existing_pinned' | 'existing_wins';
|
|
86
|
+
existingFactId: string;
|
|
87
|
+
entityId: string;
|
|
88
|
+
similarity: number;
|
|
89
|
+
winnerScore?: number;
|
|
90
|
+
loserScore?: number;
|
|
91
|
+
winnerComponents?: ScoreComponents;
|
|
92
|
+
loserComponents?: ScoreComponents;
|
|
93
|
+
}
|
|
94
|
+
| {
|
|
95
|
+
action: 'tie_leave_both';
|
|
96
|
+
existingFactId: string;
|
|
97
|
+
entityId: string;
|
|
98
|
+
similarity: number;
|
|
99
|
+
winnerScore: number;
|
|
100
|
+
loserScore: number;
|
|
101
|
+
winnerComponents: ScoreComponents;
|
|
102
|
+
loserComponents: ScoreComponents;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** Row format for `decisions.jsonl`. */
|
|
106
|
+
export interface DecisionLogEntry {
|
|
107
|
+
ts: number;
|
|
108
|
+
entity_id: string;
|
|
109
|
+
new_claim_id: string;
|
|
110
|
+
existing_claim_id: string;
|
|
111
|
+
similarity: number;
|
|
112
|
+
action: 'supersede_existing' | 'skip_new' | 'shadow' | 'tie_leave_both';
|
|
113
|
+
reason?: 'existing_pinned' | 'existing_wins' | 'new_wins' | 'tie_below_tolerance';
|
|
114
|
+
winner_score?: number;
|
|
115
|
+
loser_score?: number;
|
|
116
|
+
/**
|
|
117
|
+
* Per-component score breakdown for the formula winner. Added in Slice 2f
|
|
118
|
+
* so the feedback-tuning loop can reconstruct counterexamples from the log.
|
|
119
|
+
* Optional for backwards-compat with pre-2f log rows.
|
|
120
|
+
*/
|
|
121
|
+
winner_components?: ScoreComponents;
|
|
122
|
+
/** Per-component score breakdown for the formula loser. See winner_components. */
|
|
123
|
+
loser_components?: ScoreComponents;
|
|
124
|
+
/**
|
|
125
|
+
* Full canonical Claim JSON for the formula loser (the existing claim that
|
|
126
|
+
* got tombstoned by `supersede_existing`). Populated only on supersede rows;
|
|
127
|
+
* `tie_leave_both` and `skip_new` rows leave this undefined.
|
|
128
|
+
*
|
|
129
|
+
* Recovery use case: the on-chain blob for a superseded fact becomes a 1-byte
|
|
130
|
+
* `0x00` tombstone, which is unrecoverable via decryption. When the user later
|
|
131
|
+
* pins the tombstoned fact id (overriding the auto-resolution), the pin tool
|
|
132
|
+
* needs the original plaintext to rebuild a canonical Claim. Carrying the
|
|
133
|
+
* loser JSON here at decision time gives the pin tool a recovery path that
|
|
134
|
+
* does not depend on a live blob. Without this field, `feedback.jsonl` is
|
|
135
|
+
* structurally unreachable for the override-after-supersede case, and the
|
|
136
|
+
* weight-tuning loop never receives gradient signal from real corrections.
|
|
137
|
+
*/
|
|
138
|
+
loser_claim_json?: string;
|
|
139
|
+
mode: AutoResolveMode;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Paths + file I/O
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
/** Where feedback, decisions, and weights live. `~/.totalreclaw/` by default. */
|
|
147
|
+
function resolveStateDir(): string {
|
|
148
|
+
const override = process.env.TOTALRECLAW_STATE_DIR;
|
|
149
|
+
if (override && override.length > 0) return override;
|
|
150
|
+
return path.join(os.homedir(), '.totalreclaw');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function ensureStateDir(): string {
|
|
154
|
+
const dir = resolveStateDir();
|
|
155
|
+
try {
|
|
156
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
157
|
+
} catch {
|
|
158
|
+
// Caller handles downstream read/write failures.
|
|
159
|
+
}
|
|
160
|
+
return dir;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function decisionsLogPath(): string {
|
|
164
|
+
return path.join(resolveStateDir(), 'decisions.jsonl');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function weightsFilePath(): string {
|
|
168
|
+
return path.join(resolveStateDir(), 'weights.json');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Cap on the decisions.jsonl log — oldest lines are dropped above this. */
|
|
172
|
+
export const DECISION_LOG_MAX_LINES = 10_000;
|
|
173
|
+
|
|
174
|
+
/** Soft cap on candidates fetched per entity during contradiction detection. */
|
|
175
|
+
export const CONTRADICTION_CANDIDATE_CAP = 20;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Minimum score gap required to auto-resolve a `supersede_existing` decision.
|
|
179
|
+
*
|
|
180
|
+
* When the formula winner beats the loser by less than this amount, the
|
|
181
|
+
* decision is treated as a tie and both claims stay active. The tie is still
|
|
182
|
+
* logged to decisions.jsonl (with action='tie_leave_both') for audit.
|
|
183
|
+
*
|
|
184
|
+
* Rationale: the scoring formula (confidence + corroboration + recency +
|
|
185
|
+
* validation) produces near-identical scores when two claims describe
|
|
186
|
+
* different use cases of the same overall concept (e.g., Postgres for OLTP
|
|
187
|
+
* and DuckDB for OLAP — both explicitly-stated, both recent, both with
|
|
188
|
+
* single-occurrence corroboration). Auto-superseding in this zone promotes
|
|
189
|
+
* rounding noise into "the user changed their mind" and produces false
|
|
190
|
+
* positives that the weight-tuning loop cannot correct (see project
|
|
191
|
+
* memory: tombstoned claims are unrecoverable via pin).
|
|
192
|
+
*
|
|
193
|
+
* 0.01 (1%) is a deliberately tight guard: it catches noise-margin ties
|
|
194
|
+
* without blocking genuine narrow wins. Calibrated against the 2026-04-14
|
|
195
|
+
* Postgres/DuckDB false positive where the gap was 9 parts per million.
|
|
196
|
+
*/
|
|
197
|
+
export const TIE_ZONE_SCORE_TOLERANCE = 0.01;
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Append one entry to the decision log, rotating if it grows past the cap.
|
|
201
|
+
* Never throws — logging is best-effort.
|
|
202
|
+
*/
|
|
203
|
+
export async function appendDecisionLog(entry: DecisionLogEntry): Promise<void> {
|
|
204
|
+
try {
|
|
205
|
+
const dir = ensureStateDir();
|
|
206
|
+
const p = path.join(dir, 'decisions.jsonl');
|
|
207
|
+
let existing = '';
|
|
208
|
+
try {
|
|
209
|
+
existing = fs.readFileSync(p, 'utf-8');
|
|
210
|
+
} catch {
|
|
211
|
+
existing = '';
|
|
212
|
+
}
|
|
213
|
+
const line = JSON.stringify(entry);
|
|
214
|
+
let next = existing;
|
|
215
|
+
if (next.length === 0) {
|
|
216
|
+
next = line + '\n';
|
|
217
|
+
} else if (next.endsWith('\n')) {
|
|
218
|
+
next = next + line + '\n';
|
|
219
|
+
} else {
|
|
220
|
+
next = next + '\n' + line + '\n';
|
|
221
|
+
}
|
|
222
|
+
// Rotate via the WASM core helper (same primitive used by feedback.jsonl).
|
|
223
|
+
// `rotateFeedbackLog` expects a BigInt for max_lines.
|
|
224
|
+
const rotated = getWasm().rotateFeedbackLog(next, BigInt(DECISION_LOG_MAX_LINES));
|
|
225
|
+
fs.writeFileSync(p, rotated, 'utf-8');
|
|
226
|
+
} catch {
|
|
227
|
+
// Logging failures are never fatal.
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Load the per-user weights file, falling back to defaults when the file
|
|
233
|
+
* does not exist or is malformed. Never throws.
|
|
234
|
+
*/
|
|
235
|
+
export async function loadWeightsFile(nowUnixSeconds: number): Promise<WeightsFile> {
|
|
236
|
+
const core = getWasm();
|
|
237
|
+
const p = weightsFilePath();
|
|
238
|
+
try {
|
|
239
|
+
const raw = fs.readFileSync(p, 'utf-8');
|
|
240
|
+
const parsedJson = core.parseWeightsFile(raw);
|
|
241
|
+
return JSON.parse(parsedJson) as WeightsFile;
|
|
242
|
+
} catch {
|
|
243
|
+
// File missing / malformed / wrong version → return fresh defaults.
|
|
244
|
+
const freshJson = core.defaultWeightsFile(BigInt(Math.floor(nowUnixSeconds)));
|
|
245
|
+
return JSON.parse(freshJson) as WeightsFile;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export async function saveWeightsFile(file: WeightsFile): Promise<void> {
|
|
250
|
+
const core = getWasm();
|
|
251
|
+
try {
|
|
252
|
+
const dir = ensureStateDir();
|
|
253
|
+
const p = path.join(dir, 'weights.json');
|
|
254
|
+
const serialized = core.serializeWeightsFile(JSON.stringify(file));
|
|
255
|
+
fs.writeFileSync(p, serialized, 'utf-8');
|
|
256
|
+
} catch {
|
|
257
|
+
// best-effort
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
// Candidate fetching (subgraph + decrypt)
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
export interface SubgraphSearchFn {
|
|
266
|
+
(
|
|
267
|
+
owner: string,
|
|
268
|
+
trapdoors: string[],
|
|
269
|
+
maxCandidates: number,
|
|
270
|
+
authKeyHex?: string,
|
|
271
|
+
): Promise<Array<{
|
|
272
|
+
id: string;
|
|
273
|
+
encryptedBlob: string;
|
|
274
|
+
encryptedEmbedding?: string | null;
|
|
275
|
+
timestamp?: string;
|
|
276
|
+
isActive?: boolean;
|
|
277
|
+
}>>;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export interface DecryptFn {
|
|
281
|
+
(hexBlob: string, key: Buffer): string;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export interface CandidateClaim {
|
|
285
|
+
/** The canonical Claim JSON object (parsed from the decrypted blob). */
|
|
286
|
+
claim: CanonicalClaim;
|
|
287
|
+
/** Subgraph fact id — the existing-fact id we would tombstone on supersede. */
|
|
288
|
+
id: string;
|
|
289
|
+
/** Embedding if we could recover it (plain JSON array of numbers). */
|
|
290
|
+
embedding: number[];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Parse a decrypted Claim blob into a `{claim, status}` pair.
|
|
295
|
+
*
|
|
296
|
+
* Accepts the canonical short-key Claim shape (`{t, c, cf, i, sa, ea, ...}`).
|
|
297
|
+
* Returns null for legacy docs, digest blobs, entity-infrastructure claims,
|
|
298
|
+
* or anything that fails to parse cleanly — these are all excluded from
|
|
299
|
+
* contradiction detection.
|
|
300
|
+
*/
|
|
301
|
+
export function parseCandidateClaim(decryptedJson: string): CanonicalClaim | null {
|
|
302
|
+
if (isDigestBlob(decryptedJson)) return null;
|
|
303
|
+
let obj: Record<string, unknown>;
|
|
304
|
+
try {
|
|
305
|
+
obj = JSON.parse(decryptedJson) as Record<string, unknown>;
|
|
306
|
+
} catch {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
if (typeof obj.t !== 'string' || typeof obj.c !== 'string') return null;
|
|
310
|
+
// Filter out infra categories — digests and entity rollup claims are not
|
|
311
|
+
// user-facing knowledge and must never be considered contradictions.
|
|
312
|
+
if (obj.c === 'dig' || obj.c === 'ent') return null;
|
|
313
|
+
return obj as CanonicalClaim;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Is this candidate claim pinned (status `p`)? Delegates to WASM core. */
|
|
317
|
+
export function isPinnedClaim(claim: CanonicalClaim): boolean {
|
|
318
|
+
try {
|
|
319
|
+
return getWasm().isPinnedClaim(JSON.stringify(claim));
|
|
320
|
+
} catch {
|
|
321
|
+
// Fallback: local check if WASM fails (e.g. malformed claim).
|
|
322
|
+
return typeof claim.st === 'string' && claim.st === 'p';
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Shape of the `existing_json` expected by the WASM `detectContradictions` call.
|
|
328
|
+
*
|
|
329
|
+
* Matches `DetectContradictionsItem` in `rust/totalreclaw-core/src/wasm.rs`.
|
|
330
|
+
*/
|
|
331
|
+
interface WasmExistingItem {
|
|
332
|
+
claim: CanonicalClaim;
|
|
333
|
+
id: string;
|
|
334
|
+
embedding: number[];
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Collect active claim candidates for every entity on the new claim.
|
|
339
|
+
*
|
|
340
|
+
* For each entity:
|
|
341
|
+
* 1. Compute its trapdoor (same primitive as the write path).
|
|
342
|
+
* 2. Query the subgraph for facts matching that single-element trapdoor.
|
|
343
|
+
* 3. Decrypt + parse each row, filtering infra claims and bad blobs.
|
|
344
|
+
* 4. Recover the embedding (reusing the stored one when possible).
|
|
345
|
+
*
|
|
346
|
+
* Deduplicates by subgraph fact id across entities so the same claim is
|
|
347
|
+
* never processed twice. Caps the total number of candidates per entity at
|
|
348
|
+
* `CONTRADICTION_CANDIDATE_CAP` to keep the write-path cost bounded.
|
|
349
|
+
*/
|
|
350
|
+
export async function collectCandidatesForEntities(
|
|
351
|
+
newClaim: CanonicalClaim,
|
|
352
|
+
newClaimId: string,
|
|
353
|
+
subgraphOwner: string,
|
|
354
|
+
authKeyHex: string,
|
|
355
|
+
encryptionKey: Buffer,
|
|
356
|
+
deps: {
|
|
357
|
+
searchSubgraph: SubgraphSearchFn;
|
|
358
|
+
decryptFromHex: DecryptFn;
|
|
359
|
+
},
|
|
360
|
+
logger: ContradictionLogger,
|
|
361
|
+
): Promise<CandidateClaim[]> {
|
|
362
|
+
const entities = Array.isArray(newClaim.e) ? newClaim.e : [];
|
|
363
|
+
if (entities.length === 0) return [];
|
|
364
|
+
|
|
365
|
+
const seenIds = new Set<string>();
|
|
366
|
+
const out: CandidateClaim[] = [];
|
|
367
|
+
|
|
368
|
+
for (const entity of entities) {
|
|
369
|
+
const name = typeof entity?.n === 'string' ? entity.n : null;
|
|
370
|
+
if (!name) continue;
|
|
371
|
+
const trapdoor = computeEntityTrapdoor(name);
|
|
372
|
+
let rows: Awaited<ReturnType<SubgraphSearchFn>> = [];
|
|
373
|
+
try {
|
|
374
|
+
rows = await deps.searchSubgraph(
|
|
375
|
+
subgraphOwner,
|
|
376
|
+
[trapdoor],
|
|
377
|
+
CONTRADICTION_CANDIDATE_CAP,
|
|
378
|
+
authKeyHex,
|
|
379
|
+
);
|
|
380
|
+
} catch (err) {
|
|
381
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
382
|
+
logger.warn(`Contradiction: subgraph query failed for entity "${name}": ${msg}`);
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
for (const row of rows) {
|
|
387
|
+
if (!row || !row.id || row.id === newClaimId) continue;
|
|
388
|
+
if (row.isActive === false) continue;
|
|
389
|
+
if (seenIds.has(row.id)) continue;
|
|
390
|
+
seenIds.add(row.id);
|
|
391
|
+
|
|
392
|
+
let decrypted: string;
|
|
393
|
+
try {
|
|
394
|
+
decrypted = deps.decryptFromHex(row.encryptedBlob, encryptionKey);
|
|
395
|
+
} catch {
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
const parsed = parseCandidateClaim(decrypted);
|
|
399
|
+
if (!parsed) continue;
|
|
400
|
+
|
|
401
|
+
let embedding: number[] = [];
|
|
402
|
+
if (row.encryptedEmbedding) {
|
|
403
|
+
try {
|
|
404
|
+
const emb = JSON.parse(deps.decryptFromHex(row.encryptedEmbedding, encryptionKey));
|
|
405
|
+
if (Array.isArray(emb) && emb.every((x) => typeof x === 'number')) {
|
|
406
|
+
embedding = emb;
|
|
407
|
+
}
|
|
408
|
+
} catch {
|
|
409
|
+
embedding = [];
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
out.push({ claim: parsed, id: row.id, embedding });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return out;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ---------------------------------------------------------------------------
|
|
421
|
+
// Pure resolver: given new claim + candidates + weights, return decisions
|
|
422
|
+
// ---------------------------------------------------------------------------
|
|
423
|
+
|
|
424
|
+
export interface ResolveWithCoreInput {
|
|
425
|
+
newClaim: CanonicalClaim;
|
|
426
|
+
newClaimId: string;
|
|
427
|
+
newEmbedding: number[];
|
|
428
|
+
candidates: CandidateClaim[];
|
|
429
|
+
weightsJson: string;
|
|
430
|
+
thresholdLower: number;
|
|
431
|
+
thresholdUpper: number;
|
|
432
|
+
nowUnixSeconds: number;
|
|
433
|
+
mode: AutoResolveMode;
|
|
434
|
+
logger: ContradictionLogger;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Run WASM contradiction detection + resolution on in-memory data only.
|
|
439
|
+
*
|
|
440
|
+
* Tries `core.resolveWithCandidates()` first (full pipeline in one call:
|
|
441
|
+
* detect contradictions + resolve pairs + pin check + tie-zone guard).
|
|
442
|
+
* Falls back to the legacy `detectContradictions` + `resolvePair` approach
|
|
443
|
+
* when `resolveWithCandidates` is not available (core < 1.5.0).
|
|
444
|
+
*
|
|
445
|
+
* Split out from `detectAndResolveContradictions` so the write path can test
|
|
446
|
+
* the decision logic without mocking file I/O or the subgraph.
|
|
447
|
+
*/
|
|
448
|
+
export function resolveWithCore(input: ResolveWithCoreInput): ResolutionDecision[] {
|
|
449
|
+
const {
|
|
450
|
+
newClaim,
|
|
451
|
+
newClaimId,
|
|
452
|
+
newEmbedding,
|
|
453
|
+
candidates,
|
|
454
|
+
weightsJson,
|
|
455
|
+
thresholdLower,
|
|
456
|
+
thresholdUpper,
|
|
457
|
+
nowUnixSeconds,
|
|
458
|
+
mode,
|
|
459
|
+
logger,
|
|
460
|
+
} = input;
|
|
461
|
+
|
|
462
|
+
if (mode === 'off') return [];
|
|
463
|
+
if (candidates.length === 0) return [];
|
|
464
|
+
if (newEmbedding.length === 0) {
|
|
465
|
+
logger.warn('Contradiction: new claim has no embedding; skipping detection');
|
|
466
|
+
return [];
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const core = getWasm();
|
|
470
|
+
|
|
471
|
+
// Build the WASM candidates payload. Drop any candidate whose embedding
|
|
472
|
+
// is missing — the core short-circuits on empty vectors but we also drop
|
|
473
|
+
// them from the lookup map so we don't waste a reference.
|
|
474
|
+
const items: WasmExistingItem[] = candidates
|
|
475
|
+
.filter((c) => c.embedding.length > 0)
|
|
476
|
+
.map((c) => ({ claim: c.claim, id: c.id, embedding: c.embedding }));
|
|
477
|
+
|
|
478
|
+
if (items.length === 0) return [];
|
|
479
|
+
|
|
480
|
+
// Index candidates by id for fast lookup during resolve.
|
|
481
|
+
const byId = new Map<string, CandidateClaim>();
|
|
482
|
+
for (const c of items) byId.set(c.id, { claim: c.claim, id: c.id, embedding: c.embedding });
|
|
483
|
+
|
|
484
|
+
// Try the unified core.resolveWithCandidates() (available in core >= 1.5.0).
|
|
485
|
+
// Falls back to legacy detectContradictions + resolvePair if not available.
|
|
486
|
+
if (typeof core.resolveWithCandidates === 'function') {
|
|
487
|
+
return _resolveWithCandidatesCore(core, input, items, byId);
|
|
488
|
+
}
|
|
489
|
+
return _resolveWithLegacyPipeline(core, input, items, byId);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Core >= 1.5.0 path: single resolveWithCandidates call. */
|
|
493
|
+
function _resolveWithCandidatesCore(
|
|
494
|
+
core: ReturnType<typeof getWasm>,
|
|
495
|
+
input: ResolveWithCoreInput,
|
|
496
|
+
items: WasmExistingItem[],
|
|
497
|
+
byId: Map<string, CandidateClaim>,
|
|
498
|
+
): ResolutionDecision[] {
|
|
499
|
+
const { newClaim, newClaimId, newEmbedding, weightsJson, thresholdLower, thresholdUpper, nowUnixSeconds, logger } = input;
|
|
500
|
+
|
|
501
|
+
let actionsJson: string;
|
|
502
|
+
try {
|
|
503
|
+
actionsJson = core.resolveWithCandidates(
|
|
504
|
+
JSON.stringify(newClaim),
|
|
505
|
+
newClaimId,
|
|
506
|
+
JSON.stringify(newEmbedding),
|
|
507
|
+
JSON.stringify(items),
|
|
508
|
+
weightsJson,
|
|
509
|
+
thresholdLower,
|
|
510
|
+
thresholdUpper,
|
|
511
|
+
Math.floor(nowUnixSeconds),
|
|
512
|
+
TIE_ZONE_SCORE_TOLERANCE,
|
|
513
|
+
);
|
|
514
|
+
} catch (err) {
|
|
515
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
516
|
+
logger.warn(`Contradiction: resolveWithCandidates failed: ${msg}`);
|
|
517
|
+
return [];
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
let actions: Array<Record<string, unknown>>;
|
|
521
|
+
try {
|
|
522
|
+
actions = JSON.parse(actionsJson);
|
|
523
|
+
} catch {
|
|
524
|
+
return [];
|
|
525
|
+
}
|
|
526
|
+
if (actions.length === 0) return [];
|
|
527
|
+
|
|
528
|
+
// Map core ResolutionAction (tagged enum with "type" field) → local ResolutionDecision.
|
|
529
|
+
const decisions: ResolutionDecision[] = [];
|
|
530
|
+
for (const action of actions) {
|
|
531
|
+
const type = action.type as string;
|
|
532
|
+
if (type === 'supersede_existing') {
|
|
533
|
+
const existing = byId.get(action.existing_id as string);
|
|
534
|
+
decisions.push({
|
|
535
|
+
action: 'supersede_existing',
|
|
536
|
+
existingFactId: action.existing_id as string,
|
|
537
|
+
existingClaim: existing?.claim ?? {},
|
|
538
|
+
entityId: (action.entity_id as string) ?? '',
|
|
539
|
+
similarity: (action.similarity as number) ?? 0,
|
|
540
|
+
winnerScore: (action.winner_score as number) ?? 0,
|
|
541
|
+
loserScore: (action.loser_score as number) ?? 0,
|
|
542
|
+
winnerComponents: action.winner_components as ScoreComponents,
|
|
543
|
+
loserComponents: action.loser_components as ScoreComponents,
|
|
544
|
+
});
|
|
545
|
+
} else if (type === 'skip_new') {
|
|
546
|
+
const reason = action.reason as string;
|
|
547
|
+
decisions.push({
|
|
548
|
+
action: 'skip_new',
|
|
549
|
+
reason: reason === 'existing_pinned' ? 'existing_pinned' : 'existing_wins',
|
|
550
|
+
existingFactId: action.existing_id as string,
|
|
551
|
+
entityId: (action.entity_id as string) ?? '',
|
|
552
|
+
similarity: (action.similarity as number) ?? 0,
|
|
553
|
+
winnerScore: action.winner_score as number | undefined,
|
|
554
|
+
loserScore: action.loser_score as number | undefined,
|
|
555
|
+
winnerComponents: action.winner_components as ScoreComponents | undefined,
|
|
556
|
+
loserComponents: action.loser_components as ScoreComponents | undefined,
|
|
557
|
+
});
|
|
558
|
+
} else if (type === 'tie_leave_both') {
|
|
559
|
+
decisions.push({
|
|
560
|
+
action: 'tie_leave_both',
|
|
561
|
+
existingFactId: action.existing_id as string,
|
|
562
|
+
entityId: (action.entity_id as string) ?? '',
|
|
563
|
+
similarity: (action.similarity as number) ?? 0,
|
|
564
|
+
winnerScore: (action.winner_score as number) ?? 0,
|
|
565
|
+
loserScore: (action.loser_score as number) ?? 0,
|
|
566
|
+
winnerComponents: action.winner_components as ScoreComponents,
|
|
567
|
+
loserComponents: action.loser_components as ScoreComponents,
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
// NoContradiction actions are ignored — same as before.
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
return decisions;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** Legacy path (core < 1.5.0): detectContradictions + resolvePair loop. */
|
|
577
|
+
function _resolveWithLegacyPipeline(
|
|
578
|
+
core: ReturnType<typeof getWasm>,
|
|
579
|
+
input: ResolveWithCoreInput,
|
|
580
|
+
items: WasmExistingItem[],
|
|
581
|
+
byId: Map<string, CandidateClaim>,
|
|
582
|
+
): ResolutionDecision[] {
|
|
583
|
+
const { newClaim, newClaimId, newEmbedding, weightsJson, thresholdLower, thresholdUpper, nowUnixSeconds, logger } = input;
|
|
584
|
+
|
|
585
|
+
let contradictionsJson: string;
|
|
586
|
+
try {
|
|
587
|
+
contradictionsJson = core.detectContradictions(
|
|
588
|
+
JSON.stringify(newClaim),
|
|
589
|
+
newClaimId,
|
|
590
|
+
JSON.stringify(newEmbedding),
|
|
591
|
+
JSON.stringify(items),
|
|
592
|
+
thresholdLower,
|
|
593
|
+
thresholdUpper,
|
|
594
|
+
);
|
|
595
|
+
} catch (err) {
|
|
596
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
597
|
+
logger.warn(`Contradiction: detectContradictions failed: ${msg}`);
|
|
598
|
+
return [];
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
let contradictions: Array<{
|
|
602
|
+
claim_a_id: string;
|
|
603
|
+
claim_b_id: string;
|
|
604
|
+
entity_id: string;
|
|
605
|
+
similarity: number;
|
|
606
|
+
}>;
|
|
607
|
+
try {
|
|
608
|
+
contradictions = JSON.parse(contradictionsJson);
|
|
609
|
+
} catch {
|
|
610
|
+
return [];
|
|
611
|
+
}
|
|
612
|
+
if (contradictions.length === 0) return [];
|
|
613
|
+
|
|
614
|
+
const decisions: ResolutionDecision[] = [];
|
|
615
|
+
const nowSecondsBig = BigInt(Math.floor(nowUnixSeconds));
|
|
616
|
+
|
|
617
|
+
for (const contradiction of contradictions) {
|
|
618
|
+
const existing = byId.get(contradiction.claim_b_id);
|
|
619
|
+
if (!existing) continue;
|
|
620
|
+
|
|
621
|
+
// Pinned existing claims are untouchable.
|
|
622
|
+
if (isPinnedClaim(existing.claim)) {
|
|
623
|
+
decisions.push({
|
|
624
|
+
action: 'skip_new',
|
|
625
|
+
reason: 'existing_pinned',
|
|
626
|
+
existingFactId: existing.id,
|
|
627
|
+
entityId: contradiction.entity_id,
|
|
628
|
+
similarity: contradiction.similarity,
|
|
629
|
+
});
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
let outcomeJson: string;
|
|
634
|
+
try {
|
|
635
|
+
outcomeJson = core.resolvePair(
|
|
636
|
+
JSON.stringify(newClaim),
|
|
637
|
+
newClaimId,
|
|
638
|
+
JSON.stringify(existing.claim),
|
|
639
|
+
existing.id,
|
|
640
|
+
nowSecondsBig,
|
|
641
|
+
weightsJson,
|
|
642
|
+
);
|
|
643
|
+
} catch (err) {
|
|
644
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
645
|
+
logger.warn(`Contradiction: resolvePair failed for ${existing.id.slice(0, 10)}…: ${msg}`);
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
let outcome: {
|
|
650
|
+
winner_id: string;
|
|
651
|
+
loser_id: string;
|
|
652
|
+
winner_score: number;
|
|
653
|
+
loser_score: number;
|
|
654
|
+
winner_components: ScoreComponents;
|
|
655
|
+
loser_components: ScoreComponents;
|
|
656
|
+
};
|
|
657
|
+
try {
|
|
658
|
+
outcome = JSON.parse(outcomeJson);
|
|
659
|
+
} catch {
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (outcome.winner_id === newClaimId) {
|
|
664
|
+
decisions.push({
|
|
665
|
+
action: 'supersede_existing',
|
|
666
|
+
existingFactId: existing.id,
|
|
667
|
+
existingClaim: existing.claim,
|
|
668
|
+
entityId: contradiction.entity_id,
|
|
669
|
+
similarity: contradiction.similarity,
|
|
670
|
+
winnerScore: outcome.winner_score,
|
|
671
|
+
loserScore: outcome.loser_score,
|
|
672
|
+
winnerComponents: outcome.winner_components,
|
|
673
|
+
loserComponents: outcome.loser_components,
|
|
674
|
+
});
|
|
675
|
+
} else {
|
|
676
|
+
decisions.push({
|
|
677
|
+
action: 'skip_new',
|
|
678
|
+
reason: 'existing_wins',
|
|
679
|
+
existingFactId: existing.id,
|
|
680
|
+
entityId: contradiction.entity_id,
|
|
681
|
+
similarity: contradiction.similarity,
|
|
682
|
+
winnerScore: outcome.winner_score,
|
|
683
|
+
loserScore: outcome.loser_score,
|
|
684
|
+
winnerComponents: outcome.winner_components,
|
|
685
|
+
loserComponents: outcome.loser_components,
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
return decisions;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// ---------------------------------------------------------------------------
|
|
694
|
+
// Top-level entry point for the write path
|
|
695
|
+
// ---------------------------------------------------------------------------
|
|
696
|
+
|
|
697
|
+
export interface DetectAndResolveDeps {
|
|
698
|
+
searchSubgraph: SubgraphSearchFn;
|
|
699
|
+
decryptFromHex: DecryptFn;
|
|
700
|
+
/** Now in Unix seconds — overridable for deterministic tests. */
|
|
701
|
+
nowUnixSeconds?: number;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
export interface DetectAndResolveInput {
|
|
705
|
+
newClaim: CanonicalClaim;
|
|
706
|
+
newClaimId: string;
|
|
707
|
+
newEmbedding: number[];
|
|
708
|
+
subgraphOwner: string;
|
|
709
|
+
authKeyHex: string;
|
|
710
|
+
encryptionKey: Buffer;
|
|
711
|
+
deps: DetectAndResolveDeps;
|
|
712
|
+
logger: ContradictionLogger;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Write-path entry point. See Slice 2d of the Phase 2 design doc.
|
|
717
|
+
*
|
|
718
|
+
* Returns a list of `ResolutionDecision`s:
|
|
719
|
+
* - `supersede_existing`: caller queues a tombstone for `existingFactId`
|
|
720
|
+
* - `skip_new`: caller skips the new write (existing wins or is pinned)
|
|
721
|
+
* - `no_contradiction`: never explicitly returned — an empty list means this
|
|
722
|
+
*
|
|
723
|
+
* Never throws. On any failure (subgraph, decrypt, WASM), returns `[]` so the
|
|
724
|
+
* write path falls back to Phase 1 behaviour.
|
|
725
|
+
*/
|
|
726
|
+
export async function detectAndResolveContradictions(
|
|
727
|
+
input: DetectAndResolveInput,
|
|
728
|
+
): Promise<ResolutionDecision[]> {
|
|
729
|
+
const {
|
|
730
|
+
newClaim,
|
|
731
|
+
newClaimId,
|
|
732
|
+
newEmbedding,
|
|
733
|
+
subgraphOwner,
|
|
734
|
+
authKeyHex,
|
|
735
|
+
encryptionKey,
|
|
736
|
+
deps,
|
|
737
|
+
logger,
|
|
738
|
+
} = input;
|
|
739
|
+
|
|
740
|
+
// Read env per-call so tests can toggle without module reload.
|
|
741
|
+
const raw = (process.env.TOTALRECLAW_AUTO_RESOLVE_MODE ?? '').trim().toLowerCase();
|
|
742
|
+
const mode: AutoResolveMode =
|
|
743
|
+
raw === 'off' ? 'off' : raw === 'shadow' ? 'shadow' : 'active';
|
|
744
|
+
|
|
745
|
+
if (mode === 'off') return [];
|
|
746
|
+
|
|
747
|
+
// No entities → nothing to check (same contract as detect_contradictions).
|
|
748
|
+
const entities = Array.isArray(newClaim.e) ? newClaim.e : [];
|
|
749
|
+
if (entities.length === 0) return [];
|
|
750
|
+
|
|
751
|
+
const nowUnixSeconds =
|
|
752
|
+
typeof deps.nowUnixSeconds === 'number'
|
|
753
|
+
? deps.nowUnixSeconds
|
|
754
|
+
: Math.floor(Date.now() / 1000);
|
|
755
|
+
|
|
756
|
+
// Load per-user weights file (defaults if missing/malformed).
|
|
757
|
+
const weightsFile = await loadWeightsFile(nowUnixSeconds);
|
|
758
|
+
const weightsJson = JSON.stringify(weightsFile.weights ?? {});
|
|
759
|
+
const thresholdLower =
|
|
760
|
+
typeof weightsFile.threshold_lower === 'number' ? weightsFile.threshold_lower : 0.3;
|
|
761
|
+
const thresholdUpper =
|
|
762
|
+
typeof weightsFile.threshold_upper === 'number' ? weightsFile.threshold_upper : 0.85;
|
|
763
|
+
|
|
764
|
+
// Retrieve + decrypt candidates.
|
|
765
|
+
let candidates: CandidateClaim[];
|
|
766
|
+
try {
|
|
767
|
+
candidates = await collectCandidatesForEntities(
|
|
768
|
+
newClaim,
|
|
769
|
+
newClaimId,
|
|
770
|
+
subgraphOwner,
|
|
771
|
+
authKeyHex,
|
|
772
|
+
encryptionKey,
|
|
773
|
+
deps,
|
|
774
|
+
logger,
|
|
775
|
+
);
|
|
776
|
+
} catch (err) {
|
|
777
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
778
|
+
logger.warn(`Contradiction: candidate retrieval failed: ${msg}`);
|
|
779
|
+
return [];
|
|
780
|
+
}
|
|
781
|
+
if (candidates.length === 0) return [];
|
|
782
|
+
|
|
783
|
+
const rawDecisions = resolveWithCore({
|
|
784
|
+
newClaim,
|
|
785
|
+
newClaimId,
|
|
786
|
+
newEmbedding,
|
|
787
|
+
candidates,
|
|
788
|
+
weightsJson,
|
|
789
|
+
thresholdLower,
|
|
790
|
+
thresholdUpper,
|
|
791
|
+
nowUnixSeconds,
|
|
792
|
+
mode,
|
|
793
|
+
logger,
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
// Tie-zone guard: when the formula winner beats the loser by less than
|
|
797
|
+
// TIE_ZONE_SCORE_TOLERANCE, the "contradiction" is rounding noise between
|
|
798
|
+
// two complementary claims (e.g. Postgres for OLTP, DuckDB for OLAP — both
|
|
799
|
+
// recent, both explicit, both single-corroboration). Mark these as ties so
|
|
800
|
+
// the caller does not tombstone either claim. The tie is still logged for
|
|
801
|
+
// operator visibility and for future feedback-loop calibration.
|
|
802
|
+
//
|
|
803
|
+
// When using core >= 1.5.0 (resolveWithCandidates), the tie-zone guard is
|
|
804
|
+
// already applied by the core and this map is a no-op (no supersede decisions
|
|
805
|
+
// with sub-tolerance gaps will appear). For the legacy path, this remains
|
|
806
|
+
// necessary.
|
|
807
|
+
const decisions = rawDecisions.map((d): ResolutionDecision => {
|
|
808
|
+
if (
|
|
809
|
+
d.action === 'supersede_existing' &&
|
|
810
|
+
Math.abs(d.winnerScore - d.loserScore) < TIE_ZONE_SCORE_TOLERANCE
|
|
811
|
+
) {
|
|
812
|
+
return {
|
|
813
|
+
action: 'tie_leave_both',
|
|
814
|
+
existingFactId: d.existingFactId,
|
|
815
|
+
entityId: d.entityId,
|
|
816
|
+
similarity: d.similarity,
|
|
817
|
+
winnerScore: d.winnerScore,
|
|
818
|
+
loserScore: d.loserScore,
|
|
819
|
+
winnerComponents: d.winnerComponents,
|
|
820
|
+
loserComponents: d.loserComponents,
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
return d;
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
// Build decision log entries. Try core.buildDecisionLogEntries (>= 1.5.0)
|
|
827
|
+
// first; fall back to inline logging if not available or on failure.
|
|
828
|
+
const core = getWasm();
|
|
829
|
+
const useCoreDecisionLog = typeof core.buildDecisionLogEntries === 'function';
|
|
830
|
+
|
|
831
|
+
if (useCoreDecisionLog) {
|
|
832
|
+
try {
|
|
833
|
+
const coreActions = _decisionsToCoreActions(decisions, newClaimId);
|
|
834
|
+
const existingClaimsMap: Record<string, string> = {};
|
|
835
|
+
for (const d of decisions) {
|
|
836
|
+
if (d.action === 'supersede_existing') {
|
|
837
|
+
try { existingClaimsMap[d.existingFactId] = JSON.stringify(d.existingClaim); } catch { /* skip */ }
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
const entriesJson = core.buildDecisionLogEntries(
|
|
841
|
+
JSON.stringify(coreActions),
|
|
842
|
+
JSON.stringify(newClaim),
|
|
843
|
+
JSON.stringify(existingClaimsMap),
|
|
844
|
+
mode === 'shadow' ? 'shadow' : mode,
|
|
845
|
+
Math.floor(nowUnixSeconds),
|
|
846
|
+
);
|
|
847
|
+
const entries: DecisionLogEntry[] = JSON.parse(entriesJson);
|
|
848
|
+
for (const entry of entries) {
|
|
849
|
+
await appendDecisionLog(entry);
|
|
850
|
+
if (entry.action === 'tie_leave_both') {
|
|
851
|
+
logger.info(
|
|
852
|
+
`Contradiction: tie (gap=${Math.abs((entry.winner_score ?? 0) - (entry.loser_score ?? 0)).toFixed(6)} < ${TIE_ZONE_SCORE_TOLERANCE}, sim=${entry.similarity.toFixed(3)}, entity=${entry.entity_id}) — leaving both active`,
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
} catch (err) {
|
|
857
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
858
|
+
logger.warn(`Contradiction: buildDecisionLogEntries failed, falling back to inline: ${msg}`);
|
|
859
|
+
await _appendDecisionLogInline(decisions, newClaimId, nowUnixSeconds, mode, logger);
|
|
860
|
+
}
|
|
861
|
+
} else {
|
|
862
|
+
await _appendDecisionLogInline(decisions, newClaimId, nowUnixSeconds, mode, logger);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// Shadow mode filtering. Try core.filterShadowMode (>= 1.5.0) first.
|
|
866
|
+
if (typeof core.filterShadowMode === 'function') {
|
|
867
|
+
try {
|
|
868
|
+
const coreActions = _decisionsToCoreActions(decisions, newClaimId);
|
|
869
|
+
const filteredJson = core.filterShadowMode(JSON.stringify(coreActions), mode);
|
|
870
|
+
const filteredActions: Array<Record<string, unknown>> = JSON.parse(filteredJson);
|
|
871
|
+
|
|
872
|
+
const byExistingId = new Map<string, ResolutionDecision>();
|
|
873
|
+
for (const d of decisions) {
|
|
874
|
+
if (d.action === 'supersede_existing' || d.action === 'skip_new' || d.action === 'tie_leave_both') {
|
|
875
|
+
byExistingId.set(d.existingFactId, d);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
return filteredActions
|
|
879
|
+
.map((a) => byExistingId.get(a.existing_id as string))
|
|
880
|
+
.filter((d): d is ResolutionDecision => d !== undefined);
|
|
881
|
+
} catch {
|
|
882
|
+
// Fall through to local filtering.
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// Local fallback: shadow → empty, active → filter out ties.
|
|
887
|
+
if (mode === 'shadow') return [];
|
|
888
|
+
return decisions.filter((d) => d.action !== 'tie_leave_both');
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/** Convert ResolutionDecision[] to core ResolutionAction JSON format. */
|
|
892
|
+
function _decisionsToCoreActions(
|
|
893
|
+
decisions: ResolutionDecision[],
|
|
894
|
+
newClaimId: string,
|
|
895
|
+
): Array<Record<string, unknown>> {
|
|
896
|
+
return decisions.map((d) => {
|
|
897
|
+
if (d.action === 'supersede_existing') {
|
|
898
|
+
return {
|
|
899
|
+
type: 'supersede_existing',
|
|
900
|
+
existing_id: d.existingFactId, new_id: newClaimId,
|
|
901
|
+
similarity: d.similarity, score_gap: Math.abs(d.winnerScore - d.loserScore),
|
|
902
|
+
entity_id: d.entityId, winner_score: d.winnerScore, loser_score: d.loserScore,
|
|
903
|
+
winner_components: d.winnerComponents, loser_components: d.loserComponents,
|
|
904
|
+
};
|
|
905
|
+
} else if (d.action === 'skip_new') {
|
|
906
|
+
return {
|
|
907
|
+
type: 'skip_new', reason: d.reason,
|
|
908
|
+
existing_id: d.existingFactId, new_id: newClaimId,
|
|
909
|
+
entity_id: d.entityId, similarity: d.similarity,
|
|
910
|
+
winner_score: d.winnerScore, loser_score: d.loserScore,
|
|
911
|
+
winner_components: d.winnerComponents, loser_components: d.loserComponents,
|
|
912
|
+
};
|
|
913
|
+
} else if (d.action === 'tie_leave_both') {
|
|
914
|
+
return {
|
|
915
|
+
type: 'tie_leave_both',
|
|
916
|
+
existing_id: d.existingFactId, new_id: newClaimId,
|
|
917
|
+
similarity: d.similarity, score_gap: Math.abs(d.winnerScore - d.loserScore),
|
|
918
|
+
entity_id: d.entityId, winner_score: d.winnerScore, loser_score: d.loserScore,
|
|
919
|
+
winner_components: d.winnerComponents, loser_components: d.loserComponents,
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
return { type: 'no_contradiction' };
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/** Inline decision log building fallback (core < 1.5.0 or on failure). */
|
|
927
|
+
async function _appendDecisionLogInline(
|
|
928
|
+
decisions: ResolutionDecision[],
|
|
929
|
+
newClaimId: string,
|
|
930
|
+
nowUnixSeconds: number,
|
|
931
|
+
mode: AutoResolveMode,
|
|
932
|
+
logger: ContradictionLogger,
|
|
933
|
+
): Promise<void> {
|
|
934
|
+
for (const d of decisions) {
|
|
935
|
+
if (d.action === 'supersede_existing') {
|
|
936
|
+
let loserClaimJson: string | undefined;
|
|
937
|
+
try { loserClaimJson = JSON.stringify(d.existingClaim); } catch { loserClaimJson = undefined; }
|
|
938
|
+
await appendDecisionLog({
|
|
939
|
+
ts: nowUnixSeconds, entity_id: d.entityId,
|
|
940
|
+
new_claim_id: newClaimId, existing_claim_id: d.existingFactId,
|
|
941
|
+
similarity: d.similarity,
|
|
942
|
+
action: mode === 'shadow' ? 'shadow' : 'supersede_existing',
|
|
943
|
+
reason: 'new_wins',
|
|
944
|
+
winner_score: d.winnerScore, loser_score: d.loserScore,
|
|
945
|
+
winner_components: d.winnerComponents, loser_components: d.loserComponents,
|
|
946
|
+
loser_claim_json: loserClaimJson, mode,
|
|
947
|
+
});
|
|
948
|
+
} else if (d.action === 'skip_new') {
|
|
949
|
+
await appendDecisionLog({
|
|
950
|
+
ts: nowUnixSeconds, entity_id: d.entityId,
|
|
951
|
+
new_claim_id: newClaimId, existing_claim_id: d.existingFactId,
|
|
952
|
+
similarity: d.similarity,
|
|
953
|
+
action: mode === 'shadow' ? 'shadow' : 'skip_new',
|
|
954
|
+
reason: d.reason,
|
|
955
|
+
winner_score: d.winnerScore, loser_score: d.loserScore,
|
|
956
|
+
winner_components: d.winnerComponents, loser_components: d.loserComponents,
|
|
957
|
+
mode,
|
|
958
|
+
});
|
|
959
|
+
} else if (d.action === 'tie_leave_both') {
|
|
960
|
+
await appendDecisionLog({
|
|
961
|
+
ts: nowUnixSeconds, entity_id: d.entityId,
|
|
962
|
+
new_claim_id: newClaimId, existing_claim_id: d.existingFactId,
|
|
963
|
+
similarity: d.similarity,
|
|
964
|
+
action: 'tie_leave_both', reason: 'tie_below_tolerance',
|
|
965
|
+
winner_score: d.winnerScore, loser_score: d.loserScore,
|
|
966
|
+
winner_components: d.winnerComponents, loser_components: d.loserComponents,
|
|
967
|
+
mode,
|
|
968
|
+
});
|
|
969
|
+
logger.info(
|
|
970
|
+
`Contradiction: tie (gap=${Math.abs(d.winnerScore - d.loserScore).toFixed(6)} < ${TIE_ZONE_SCORE_TOLERANCE}, sim=${d.similarity.toFixed(3)}, entity=${d.entityId}) — leaving both active`,
|
|
971
|
+
);
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// ---------------------------------------------------------------------------
|
|
977
|
+
// Slice 2f: feedback wiring (pin path) + weight-tuning loop
|
|
978
|
+
// ---------------------------------------------------------------------------
|
|
979
|
+
|
|
980
|
+
/** Path to `~/.totalreclaw/feedback.jsonl` honouring TOTALRECLAW_STATE_DIR. */
|
|
981
|
+
export function feedbackLogPath(): string {
|
|
982
|
+
return path.join(resolveStateDir(), 'feedback.jsonl');
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/** Cap on feedback.jsonl lines; oldest dropped above this. */
|
|
986
|
+
export const FEEDBACK_LOG_MAX_LINES = 10_000;
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* Default minimum seconds between consecutive tuning-loop runs.
|
|
990
|
+
*
|
|
991
|
+
* Production uses one hour to protect against hot loops during rapid debugging.
|
|
992
|
+
* QA workflows that need to validate a feedback → weight-tuning cycle in a
|
|
993
|
+
* single agent session can override this via
|
|
994
|
+
* `TOTALRECLAW_TUNING_MIN_INTERVAL_OVERRIDE_SECONDS`. The override is
|
|
995
|
+
* unbounded — set it to 1 if you want every pin to immediately tune.
|
|
996
|
+
*/
|
|
997
|
+
export const TUNING_LOOP_MIN_INTERVAL_SECONDS = 3600;
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* Read the effective tuning-loop rate-limit interval, honouring the QA override.
|
|
1001
|
+
*
|
|
1002
|
+
* Reads the env var per-call so tests can flip it without module reload.
|
|
1003
|
+
* Returns `TUNING_LOOP_MIN_INTERVAL_SECONDS` if the override is missing,
|
|
1004
|
+
* empty, non-numeric, or negative.
|
|
1005
|
+
*/
|
|
1006
|
+
export function getTuningLoopMinIntervalSeconds(): number {
|
|
1007
|
+
const raw = process.env.TOTALRECLAW_TUNING_MIN_INTERVAL_OVERRIDE_SECONDS;
|
|
1008
|
+
if (raw === undefined || raw === null || raw === '') {
|
|
1009
|
+
return TUNING_LOOP_MIN_INTERVAL_SECONDS;
|
|
1010
|
+
}
|
|
1011
|
+
const parsed = Number(raw);
|
|
1012
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
1013
|
+
return TUNING_LOOP_MIN_INTERVAL_SECONDS;
|
|
1014
|
+
}
|
|
1015
|
+
return parsed;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/** A single row from feedback.jsonl — matches Rust `FeedbackEntry`. */
|
|
1019
|
+
export interface FeedbackEntry {
|
|
1020
|
+
ts: number;
|
|
1021
|
+
claim_a_id: string;
|
|
1022
|
+
claim_b_id: string;
|
|
1023
|
+
formula_winner: 'a' | 'b';
|
|
1024
|
+
user_decision: 'pin_a' | 'pin_b' | 'pin_both' | 'unpin';
|
|
1025
|
+
winner_components: ScoreComponents;
|
|
1026
|
+
loser_components: ScoreComponents;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* Walk `decisions.jsonl` in reverse and find the most recent `supersede_existing`
|
|
1031
|
+
* entry that the target fact participated in. Slice 2f uses this to decide
|
|
1032
|
+
* whether a pin/unpin call is a real counterexample (gradient signal) or a
|
|
1033
|
+
* voluntary pin (no signal).
|
|
1034
|
+
*
|
|
1035
|
+
* `role` selects which side of the decision to match: `'loser'` finds entries
|
|
1036
|
+
* where the fact was tombstoned by the formula (regular pin-after-override),
|
|
1037
|
+
* `'winner'` finds entries where the fact was the formula's pick (reverse
|
|
1038
|
+
* unpin-the-winner path).
|
|
1039
|
+
*
|
|
1040
|
+
* Returns null if the log is absent, empty, or has no matching entry with the
|
|
1041
|
+
* Slice 2f component breakdown.
|
|
1042
|
+
*/
|
|
1043
|
+
export function findDecisionForPin(
|
|
1044
|
+
factId: string,
|
|
1045
|
+
role: 'loser' | 'winner',
|
|
1046
|
+
logContent: string,
|
|
1047
|
+
): DecisionLogEntry | null {
|
|
1048
|
+
if (!logContent || logContent.length === 0) return null;
|
|
1049
|
+
try {
|
|
1050
|
+
const result = getWasm().findDecisionForPin(factId, role, logContent);
|
|
1051
|
+
if (result === 'null') return null;
|
|
1052
|
+
return JSON.parse(result) as DecisionLogEntry;
|
|
1053
|
+
} catch {
|
|
1054
|
+
// Fallback: local implementation if WASM fails.
|
|
1055
|
+
const lines = logContent.split('\n').filter((l) => l.length > 0);
|
|
1056
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1057
|
+
let entry: DecisionLogEntry;
|
|
1058
|
+
try {
|
|
1059
|
+
entry = JSON.parse(lines[i]) as DecisionLogEntry;
|
|
1060
|
+
} catch {
|
|
1061
|
+
continue;
|
|
1062
|
+
}
|
|
1063
|
+
if (entry.action !== 'supersede_existing') continue;
|
|
1064
|
+
if (!entry.winner_components || !entry.loser_components) continue;
|
|
1065
|
+
if (role === 'loser' && entry.existing_claim_id === factId) return entry;
|
|
1066
|
+
if (role === 'winner' && entry.new_claim_id === factId) return entry;
|
|
1067
|
+
}
|
|
1068
|
+
return null;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/**
|
|
1073
|
+
* Walk `decisions.jsonl` in reverse and return the most recent canonical Claim
|
|
1074
|
+
* JSON for a fact that was tombstoned by a `supersede_existing` decision.
|
|
1075
|
+
*
|
|
1076
|
+
* Used by the pin tool's recovery path: when `decryptBlob` fails on a
|
|
1077
|
+
* tombstoned (`0x00`) on-chain blob, the pin tool falls back to this lookup
|
|
1078
|
+
* to reconstruct the loser plaintext from the decision log instead of failing
|
|
1079
|
+
* outright. See Phase 2.1 in the implementation plan.
|
|
1080
|
+
*
|
|
1081
|
+
* Reads `decisions.jsonl` synchronously from the active state dir. Returns
|
|
1082
|
+
* null when the log is missing, empty, or has no matching row. Never throws —
|
|
1083
|
+
* the recovery path treats any failure as "no recovery available".
|
|
1084
|
+
*
|
|
1085
|
+
* Only matches `supersede_existing` rows (not `tie_leave_both` or `skip_new`)
|
|
1086
|
+
* because those are the only rows that actually tombstone the existing fact.
|
|
1087
|
+
* Only returns rows that have `loser_claim_json` populated — pre-Phase-2.1
|
|
1088
|
+
* supersede rows do not carry the field and cannot be recovered from.
|
|
1089
|
+
*/
|
|
1090
|
+
export function findLoserClaimInDecisionLog(factId: string): string | null {
|
|
1091
|
+
let logContent = '';
|
|
1092
|
+
try {
|
|
1093
|
+
logContent = fs.readFileSync(decisionsLogPath(), 'utf-8');
|
|
1094
|
+
} catch {
|
|
1095
|
+
return null;
|
|
1096
|
+
}
|
|
1097
|
+
if (!logContent || logContent.length === 0) return null;
|
|
1098
|
+
try {
|
|
1099
|
+
const result = getWasm().findLoserClaimInDecisionLog(factId, logContent);
|
|
1100
|
+
return result === 'null' ? null : result;
|
|
1101
|
+
} catch {
|
|
1102
|
+
// Fallback: local implementation if WASM fails.
|
|
1103
|
+
const lines = logContent.split('\n').filter((l) => l.length > 0);
|
|
1104
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1105
|
+
let entry: DecisionLogEntry;
|
|
1106
|
+
try {
|
|
1107
|
+
entry = JSON.parse(lines[i]) as DecisionLogEntry;
|
|
1108
|
+
} catch {
|
|
1109
|
+
continue;
|
|
1110
|
+
}
|
|
1111
|
+
if (entry.action !== 'supersede_existing') continue;
|
|
1112
|
+
if (entry.existing_claim_id !== factId) continue;
|
|
1113
|
+
if (typeof entry.loser_claim_json !== 'string' || entry.loser_claim_json.length === 0) {
|
|
1114
|
+
continue;
|
|
1115
|
+
}
|
|
1116
|
+
return entry.loser_claim_json;
|
|
1117
|
+
}
|
|
1118
|
+
return null;
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/**
|
|
1123
|
+
* Build a `FeedbackEntry` from a matching decision-log row + pin action.
|
|
1124
|
+
*
|
|
1125
|
+
* For `supersede_existing`, the formula's winner is always the new claim
|
|
1126
|
+
* (`new_claim_id`) and the loser is the existing claim. If the user later
|
|
1127
|
+
* pins the loser, `user_decision = 'pin_a'` with `claim_a` = the loser
|
|
1128
|
+
* (so claim_a is what the user wants kept). If the user later unpins the
|
|
1129
|
+
* winner — an inverse override — we record `user_decision = 'pin_b'` to
|
|
1130
|
+
* keep the schema symmetrical.
|
|
1131
|
+
*/
|
|
1132
|
+
export function buildFeedbackFromDecision(
|
|
1133
|
+
decision: DecisionLogEntry,
|
|
1134
|
+
action: 'pin_loser' | 'unpin_winner',
|
|
1135
|
+
nowUnixSeconds: number,
|
|
1136
|
+
): FeedbackEntry | null {
|
|
1137
|
+
if (!decision.winner_components || !decision.loser_components) return null;
|
|
1138
|
+
try {
|
|
1139
|
+
const result = getWasm().buildFeedbackFromDecision(
|
|
1140
|
+
JSON.stringify(decision),
|
|
1141
|
+
action,
|
|
1142
|
+
Math.floor(nowUnixSeconds),
|
|
1143
|
+
);
|
|
1144
|
+
if (result === 'null') return null;
|
|
1145
|
+
return JSON.parse(result) as FeedbackEntry;
|
|
1146
|
+
} catch {
|
|
1147
|
+
// Fallback: local implementation if WASM fails.
|
|
1148
|
+
if (action === 'pin_loser') {
|
|
1149
|
+
return {
|
|
1150
|
+
ts: nowUnixSeconds,
|
|
1151
|
+
claim_a_id: decision.existing_claim_id,
|
|
1152
|
+
claim_b_id: decision.new_claim_id,
|
|
1153
|
+
formula_winner: 'b',
|
|
1154
|
+
user_decision: 'pin_a',
|
|
1155
|
+
winner_components: decision.winner_components,
|
|
1156
|
+
loser_components: decision.loser_components,
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
return {
|
|
1160
|
+
ts: nowUnixSeconds,
|
|
1161
|
+
claim_a_id: decision.existing_claim_id,
|
|
1162
|
+
claim_b_id: decision.new_claim_id,
|
|
1163
|
+
formula_winner: 'b',
|
|
1164
|
+
user_decision: 'pin_b',
|
|
1165
|
+
winner_components: decision.winner_components,
|
|
1166
|
+
loser_components: decision.loser_components,
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
/**
|
|
1172
|
+
* Append one feedback entry to `~/.totalreclaw/feedback.jsonl`, rotating if
|
|
1173
|
+
* over the cap. Uses the WASM core bindings so the file format is byte-for-byte
|
|
1174
|
+
* compatible with the Rust + Python clients. Never throws.
|
|
1175
|
+
*/
|
|
1176
|
+
export async function appendFeedbackLog(entry: FeedbackEntry): Promise<void> {
|
|
1177
|
+
try {
|
|
1178
|
+
const core = getWasm();
|
|
1179
|
+
const dir = ensureStateDir();
|
|
1180
|
+
const p = path.join(dir, 'feedback.jsonl');
|
|
1181
|
+
let existing = '';
|
|
1182
|
+
try {
|
|
1183
|
+
existing = fs.readFileSync(p, 'utf-8');
|
|
1184
|
+
} catch {
|
|
1185
|
+
existing = '';
|
|
1186
|
+
}
|
|
1187
|
+
const appended = core.appendFeedbackToJsonl(existing, JSON.stringify(entry));
|
|
1188
|
+
const rotated = core.rotateFeedbackLog(appended, BigInt(FEEDBACK_LOG_MAX_LINES));
|
|
1189
|
+
fs.writeFileSync(p, rotated, 'utf-8');
|
|
1190
|
+
} catch {
|
|
1191
|
+
// Best-effort; feedback logging is never fatal.
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/**
|
|
1196
|
+
* Slice 2f glue: on pin/unpin, consult `decisions.jsonl` and write a feedback
|
|
1197
|
+
* row if the user override contradicts a prior formula decision.
|
|
1198
|
+
*
|
|
1199
|
+
* Returns the entry that was appended, or null when the pin/unpin was
|
|
1200
|
+
* voluntary (no matching decision row). Logs info-level on voluntary pins
|
|
1201
|
+
* and debug-level on each counterexample written.
|
|
1202
|
+
*/
|
|
1203
|
+
export async function maybeWriteFeedbackForPin(
|
|
1204
|
+
factId: string,
|
|
1205
|
+
targetStatus: 'pinned' | 'active',
|
|
1206
|
+
nowUnixSeconds: number,
|
|
1207
|
+
logger: ContradictionLogger,
|
|
1208
|
+
): Promise<FeedbackEntry | null> {
|
|
1209
|
+
let logContent = '';
|
|
1210
|
+
try {
|
|
1211
|
+
logContent = fs.readFileSync(decisionsLogPath(), 'utf-8');
|
|
1212
|
+
} catch {
|
|
1213
|
+
logContent = '';
|
|
1214
|
+
}
|
|
1215
|
+
// For pin: the user is saying the loser was right → match loser.
|
|
1216
|
+
// For unpin: the user is flipping the winner back → match winner.
|
|
1217
|
+
const role: 'loser' | 'winner' = targetStatus === 'pinned' ? 'loser' : 'winner';
|
|
1218
|
+
const decision = findDecisionForPin(factId, role, logContent);
|
|
1219
|
+
if (!decision) {
|
|
1220
|
+
logger.info(
|
|
1221
|
+
targetStatus === 'pinned'
|
|
1222
|
+
? `Pin feedback: no matching auto-resolution for ${factId.slice(0, 10)}… (voluntary pin, no tuning signal)`
|
|
1223
|
+
: `Unpin feedback: no matching auto-resolution for ${factId.slice(0, 10)}… (voluntary unpin, no tuning signal)`,
|
|
1224
|
+
);
|
|
1225
|
+
return null;
|
|
1226
|
+
}
|
|
1227
|
+
const action = targetStatus === 'pinned' ? 'pin_loser' : 'unpin_winner';
|
|
1228
|
+
const entry = buildFeedbackFromDecision(decision, action, nowUnixSeconds);
|
|
1229
|
+
if (!entry) return null;
|
|
1230
|
+
await appendFeedbackLog(entry);
|
|
1231
|
+
logger.info(
|
|
1232
|
+
`Pin feedback: recorded counterexample (${entry.user_decision}) for ${factId.slice(0, 10)}…`,
|
|
1233
|
+
);
|
|
1234
|
+
return entry;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/**
|
|
1238
|
+
* Result of running the weight-tuning loop — exposed for tests and logging.
|
|
1239
|
+
*/
|
|
1240
|
+
export interface TuningLoopResult {
|
|
1241
|
+
processed: number;
|
|
1242
|
+
gradientSteps: number;
|
|
1243
|
+
skipped: 'rate-limited' | 'no-new-entries' | 'no-weights' | null;
|
|
1244
|
+
lastTuningTs: number;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/**
|
|
1248
|
+
* Core of the weight-tuning loop. Pure enough to test in isolation: reads
|
|
1249
|
+
* `feedback.jsonl`, replays every entry newer than `weightsFile.last_tuning_ts`
|
|
1250
|
+
* through the WASM `feedbackToCounterexample` + `applyFeedback` pair, writes
|
|
1251
|
+
* back the adjusted weights. Idempotent: re-running with the same feedback
|
|
1252
|
+
* file does nothing because the timestamp advances each pass.
|
|
1253
|
+
*
|
|
1254
|
+
* Rate-limited: if the current `updated_at` is within
|
|
1255
|
+
* `TUNING_LOOP_MIN_INTERVAL_SECONDS` of `nowUnixSeconds`, returns early with
|
|
1256
|
+
* `skipped: 'rate-limited'`. Never throws.
|
|
1257
|
+
*/
|
|
1258
|
+
export async function runWeightTuningLoop(
|
|
1259
|
+
nowUnixSeconds: number,
|
|
1260
|
+
logger: ContradictionLogger,
|
|
1261
|
+
): Promise<TuningLoopResult> {
|
|
1262
|
+
const core = getWasm();
|
|
1263
|
+
let weightsFile: WeightsFile;
|
|
1264
|
+
try {
|
|
1265
|
+
weightsFile = await loadWeightsFile(nowUnixSeconds);
|
|
1266
|
+
} catch {
|
|
1267
|
+
return { processed: 0, gradientSteps: 0, skipped: 'no-weights', lastTuningTs: 0 };
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// Rate limit: if the weights file was touched very recently and there is a
|
|
1271
|
+
// last_tuning_ts, skip — protects against hot loops during rapid debugging.
|
|
1272
|
+
// QA can lower the interval via TOTALRECLAW_TUNING_MIN_INTERVAL_OVERRIDE_SECONDS.
|
|
1273
|
+
const updatedAt = typeof weightsFile.updated_at === 'number' ? weightsFile.updated_at : 0;
|
|
1274
|
+
const priorTuningTs =
|
|
1275
|
+
typeof weightsFile.last_tuning_ts === 'number' ? weightsFile.last_tuning_ts : 0;
|
|
1276
|
+
const minInterval = getTuningLoopMinIntervalSeconds();
|
|
1277
|
+
if (
|
|
1278
|
+
priorTuningTs > 0 &&
|
|
1279
|
+
updatedAt > 0 &&
|
|
1280
|
+
nowUnixSeconds - updatedAt < minInterval
|
|
1281
|
+
) {
|
|
1282
|
+
return {
|
|
1283
|
+
processed: 0,
|
|
1284
|
+
gradientSteps: 0,
|
|
1285
|
+
skipped: 'rate-limited',
|
|
1286
|
+
lastTuningTs: priorTuningTs,
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// Read feedback.jsonl.
|
|
1291
|
+
let feedbackContent = '';
|
|
1292
|
+
try {
|
|
1293
|
+
feedbackContent = fs.readFileSync(feedbackLogPath(), 'utf-8');
|
|
1294
|
+
} catch {
|
|
1295
|
+
feedbackContent = '';
|
|
1296
|
+
}
|
|
1297
|
+
if (!feedbackContent || feedbackContent.length === 0) {
|
|
1298
|
+
return {
|
|
1299
|
+
processed: 0,
|
|
1300
|
+
gradientSteps: 0,
|
|
1301
|
+
skipped: 'no-new-entries',
|
|
1302
|
+
lastTuningTs: priorTuningTs,
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
let parsed: { entries: FeedbackEntry[]; warnings: string[] };
|
|
1307
|
+
try {
|
|
1308
|
+
parsed = JSON.parse(core.readFeedbackJsonl(feedbackContent)) as {
|
|
1309
|
+
entries: FeedbackEntry[];
|
|
1310
|
+
warnings: string[];
|
|
1311
|
+
};
|
|
1312
|
+
} catch (err) {
|
|
1313
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1314
|
+
logger.warn(`Tuning loop: failed to parse feedback.jsonl: ${msg}`);
|
|
1315
|
+
return {
|
|
1316
|
+
processed: 0,
|
|
1317
|
+
gradientSteps: 0,
|
|
1318
|
+
skipped: 'no-new-entries',
|
|
1319
|
+
lastTuningTs: priorTuningTs,
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
for (const w of parsed.warnings) logger.warn(`Tuning loop: ${w}`);
|
|
1324
|
+
|
|
1325
|
+
const newEntries = parsed.entries.filter((e) => e.ts > priorTuningTs);
|
|
1326
|
+
if (newEntries.length === 0) {
|
|
1327
|
+
return {
|
|
1328
|
+
processed: 0,
|
|
1329
|
+
gradientSteps: 0,
|
|
1330
|
+
skipped: 'no-new-entries',
|
|
1331
|
+
lastTuningTs: priorTuningTs,
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
let weightsJson = JSON.stringify(weightsFile.weights ?? {});
|
|
1336
|
+
let gradientSteps = 0;
|
|
1337
|
+
let maxTs = priorTuningTs;
|
|
1338
|
+
for (const entry of newEntries) {
|
|
1339
|
+
if (entry.ts > maxTs) maxTs = entry.ts;
|
|
1340
|
+
let cxJson: string;
|
|
1341
|
+
try {
|
|
1342
|
+
cxJson = core.feedbackToCounterexample(JSON.stringify(entry));
|
|
1343
|
+
} catch (err) {
|
|
1344
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1345
|
+
logger.warn(`Tuning loop: feedbackToCounterexample failed: ${msg}`);
|
|
1346
|
+
continue;
|
|
1347
|
+
}
|
|
1348
|
+
if (cxJson === 'null') continue;
|
|
1349
|
+
try {
|
|
1350
|
+
weightsJson = core.applyFeedback(weightsJson, cxJson);
|
|
1351
|
+
gradientSteps += 1;
|
|
1352
|
+
} catch (err) {
|
|
1353
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1354
|
+
logger.warn(`Tuning loop: applyFeedback failed: ${msg}`);
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
let adjustedWeights: unknown;
|
|
1359
|
+
try {
|
|
1360
|
+
adjustedWeights = JSON.parse(weightsJson);
|
|
1361
|
+
} catch {
|
|
1362
|
+
return {
|
|
1363
|
+
processed: newEntries.length,
|
|
1364
|
+
gradientSteps,
|
|
1365
|
+
skipped: 'no-weights',
|
|
1366
|
+
lastTuningTs: maxTs,
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
const nextFile: WeightsFile = {
|
|
1371
|
+
...weightsFile,
|
|
1372
|
+
weights: adjustedWeights,
|
|
1373
|
+
updated_at: nowUnixSeconds,
|
|
1374
|
+
last_tuning_ts: maxTs,
|
|
1375
|
+
feedback_count:
|
|
1376
|
+
(typeof weightsFile.feedback_count === 'number' ? weightsFile.feedback_count : 0) +
|
|
1377
|
+
newEntries.length,
|
|
1378
|
+
};
|
|
1379
|
+
await saveWeightsFile(nextFile);
|
|
1380
|
+
logger.info(
|
|
1381
|
+
`Tuning loop: processed ${newEntries.length} feedback entries, applied ${gradientSteps} gradient steps`,
|
|
1382
|
+
);
|
|
1383
|
+
return {
|
|
1384
|
+
processed: newEntries.length,
|
|
1385
|
+
gradientSteps,
|
|
1386
|
+
skipped: null,
|
|
1387
|
+
lastTuningTs: maxTs,
|
|
1388
|
+
};
|
|
1389
|
+
}
|