@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.
@@ -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
+ }