@avantf/dsh-mem 0.9.0 → 0.10.0

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.
@@ -2,12 +2,13 @@
2
2
  * `facts` — the memory store's main table.
3
3
  *
4
4
  * Started with the READ paths the detector, the diagnostics and the clock self-heal need;
5
- * the write path (`persistFact`, archive/restore/feedback, vector repair) moves here next, so
5
+ * the write path (`persistFact`, archive/restore, vector repair) moves here next, so
6
6
  * the store ends up holding business rules only and no SQL. See DESIGN §19.
7
7
  */
8
8
  import type { Db } from '../port.js';
9
9
  import { type TimeRange, type TimeSql } from '../../store/time_filter.js';
10
10
  import type { MemoryKind } from '../../../mem-contract/index.js';
11
+ import { type TrustCurve } from '../../../mem-contract/index.js';
11
12
  /**
12
13
  * Every `facts` column EXCEPT the two BLOBs (`hrr_vector` ~8 KB, `semantic_vector` ~2 KB per row)
13
14
  * and the two DEPRECATED provenance columns (`mirror_source` / `mirror_target`, see below).
@@ -28,7 +29,7 @@ import type { MemoryKind } from '../../../mem-contract/index.js';
28
29
  * `test/db_lifecycle.spec.ts` asserts this list against `PRAGMA table_info(facts)` MINUS those
29
30
  * two deprecations, so a new column fails a test instead of silently vanishing from these reads.
30
31
  */
31
- export declare const FACT_COLUMNS_NO_BLOB = "fact_id, content, kind, title, tags, trust_score, settle_clock,\n pinned, pinned_at, bonus_count, bonus_window_at, last_reinforced_at, archived_clock,\n retrieval_count, helpful_count, last_retrieved_at, embedding_model, vector_store, status,\n supersedes_id, entities_version, conflict_checked, archived_at, archive_reason, ttl_days,\n valid_from, valid_to, valid_from_ms, valid_to_ms, assert_count, created_at, updated_at";
32
+ export declare const FACT_COLUMNS_NO_BLOB = "fact_id, content, kind, title, tags, trust_score, settle_clock,\n pinned, pinned_at, bonus_count, bonus_window_at, last_reinforced_at, archived_clock,\n retrieval_count, helpful_count, last_retrieved_at, embedding_model, vector_store, status,\n supersedes_id, entities_version, conflict_checked, archived_at, archive_reason, ttl_days,\n valid_from, valid_from_ms, assert_count, created_at, updated_at";
32
33
  export declare const FTS_SEARCH_SQL: string;
33
34
  /**
34
35
  * {@link FTS_SEARCH_SQL} with the P-08 source predicate, as a SEPARATE statement rather than one
@@ -191,7 +192,6 @@ export declare class FactsDao {
191
192
  created_at: string;
192
193
  updated_at: string | null;
193
194
  valid_from: string | null;
194
- valid_to: string | null;
195
195
  title: string | null;
196
196
  }>;
197
197
  /** Active rows that carry a persisted vector (index classification). */
@@ -233,14 +233,16 @@ export declare class FactsDao {
233
233
  * `createdAt` is named EXPLICITLY rather than left to the column DEFAULT, because a
234
234
  * superseding revision must INHERIT the archived row's `created_at` (the memory's first
235
235
  * assertion date, distinct from when its text was rewritten). `updated_at` and
236
- * `last_retrieved_at` are stamped `CURRENT_TIMESTAMP` in the same statement: a revision
237
- * whose `created_at` is old must not be judged idle by the next tick, where "idle" reads
238
- * `COALESCE(last_retrieved_at, created_at)`.
236
+ * `last_retrieved_at` are stamped `CURRENT_TIMESTAMP` in the same statement, so a revision
237
+ * carries a fresh "last used" observation like the row it replaces (that column no longer
238
+ * feeds any decision — see TRUST_MODEL.md §2.6).
239
+ *
240
+ * `ttl_days` is NOT named: TTL is gone (v2), the column keeps its DEFAULT 0 and nothing
241
+ * reads or writes it any more.
239
242
  */
240
243
  insertRevision(v: {
241
244
  content: string;
242
245
  kind: MemoryKind;
243
- ttlDays: number;
244
246
  supersedesId: number | null;
245
247
  hrr: Uint8Array;
246
248
  trust: number;
@@ -256,37 +258,32 @@ export declare class FactsDao {
256
258
  */
257
259
  title?: string | null;
258
260
  /**
259
- * P-13: the caller's event time / known end, written straight onto the new row. `null` means
260
- * "unknown" and is what an omitted argument leaves (the column has no default on purpose).
261
+ * P-13: the caller's event time, written straight onto the new row. `null` means "unknown" and
262
+ * is what an omitted argument leaves (the column has no default on purpose).
261
263
  * `assert_count` is deliberately NOT named: the column default (1) is the semantics — "this
262
264
  * content has been asserted once" — and naming it here would be a second place to keep in step.
263
265
  */
264
266
  validFrom?: string | null;
265
- validTo?: string | null;
266
267
  /**
267
- * The INSTANTS the two text bounds name (`valid_from_ms`/`valid_to_ms`).
268
+ * The INSTANT the text bound names (`valid_from_ms`).
268
269
  *
269
270
  * Written in the same statement as the text, never computed later: the text is what a reader
270
271
  * displays and the epoch is what an `event` window compares, so writing them apart would let the
271
272
  * two describe different times. `null` = "no event time" (the axis then excludes the row, D5).
272
273
  */
273
274
  validFromMs?: number | null;
274
- validToMs?: number | null;
275
275
  }): {
276
276
  changes: number;
277
277
  lastInsertRowid: number | bigint;
278
278
  };
279
279
  /**
280
- * P-10: land an explicitly given validity on a row that already exists (a duplicate `add` whose
281
- * caller named `event_date` / `valid_until`). Omitted arguments keep what the row has, exactly
282
- * like `kind`/`ttl_days` (see `add`'s doc).
280
+ * P-10: land an explicitly given event time on a row that already exists (a duplicate `add`
281
+ * whose caller named `event_date`). An omitted argument keeps what the row has, exactly like
282
+ * `kind` (see `add`'s doc).
283
283
  */
284
284
  applyValidity(factId: number, validFrom: {
285
285
  text: string;
286
286
  ms: number;
287
- } | undefined, validTo: {
288
- text: string;
289
- ms: number;
290
287
  } | undefined): void;
291
288
  /** P-10: the row's assertion counter (`1` when the column is somehow NULL on a legacy row). */
292
289
  assertCount(factId: number): number;
@@ -299,12 +296,6 @@ export declare class FactsDao {
299
296
  * two processes cannot interleave between them.
300
297
  */
301
298
  incrementAssertCount(factId: number): number;
302
- /**
303
- * P-07 audit: stamp `valid_to` on a row that is leaving the active corpus (supersede / a
304
- * contradiction verdict). `COALESCE` keeps an end the caller already declared (`valid_until`):
305
- * overwriting it with the archival instant would replace a real answer with an administrative one.
306
- */
307
- markValidTo(factId: number, at: string, atMs?: number | null): void;
308
299
  /**
309
300
  * P-07: "who superseded this fact", derived from the existing `supersedes_id` link — no
310
301
  * redundant column. `MIN` is deterministic when several revisions named the same predecessor
@@ -418,10 +409,15 @@ export declare class FactsDao {
418
409
  * reported zero behind. Same rule as {@link setSemanticVector}: what the check SAW changed.
419
410
  */
420
411
  requeueConflictCheck(ids: readonly number[]): number;
421
- /** Revive an archived row (R18/R19: refresh the idle clock, clear the archive bookkeeping). */
422
- revive(factId: number, kind: MemoryKind, ttlDays: number, trust: number, clock: number): number;
423
- /** Land explicit kind/TTL on a row that already exists (pure duplicate). */
424
- applyValues(factId: number, kind: MemoryKind, ttlDays: number): void;
412
+ /**
413
+ * Revive an archived row (R19: clear the archive bookkeeping / refresh `last_retrieved_at`).
414
+ *
415
+ * `ttl_days` is deliberately NOT named any more: an omitted column keeps whatever the row has
416
+ * (the old writing value is preserved as data), and there is no TTL left to inherit.
417
+ */
418
+ revive(factId: number, kind: MemoryKind, trust: number, clock: number): number;
419
+ /** Land an explicit kind on a row that already exists (pure duplicate). */
420
+ applyValues(factId: number, kind: MemoryKind): void;
425
421
  /**
426
422
  * Land a title on a row that already exists — the ONE write behind every "backfill" path
427
423
  * (JOURNAL_TITLE_SEARCH §11.2/§11.3): a duplicate `add` that names a title for an existing
@@ -478,22 +474,14 @@ export declare class FactsDao {
478
474
  archiveSuperseded(factId: number, clock: number): number;
479
475
  linkSupersede(factId: number, supersedesId: number): void;
480
476
  /**
481
- * Restore an archived fact, settling its trust and refreshing the idle clock (spec §2.6).
477
+ * Restore an archived fact, settling its trust and refreshing `last_retrieved_at` (spec §2.6).
482
478
  *
483
479
  * `AND status = 'archived'` is load-bearing, like the guard on every sibling above: this UPDATE
484
480
  * clears the archive bookkeeping and refreshes `last_retrieved_at`, so ungated it would take an
485
- * ACTIVE row and (a) push its idle clock forward — silently extending the life of a fact the next
486
- * tick was about to re-archive — and (b) reset `trust_score` to the settled value it computed. It
487
- * returned `1` either way, so the caller could not tell that a row it never asked about had moved.
481
+ * ACTIVE row and reset `trust_score` to the settled value it computed. It returned `1` either
482
+ * way, so the caller could not tell that a row it never asked about had moved.
488
483
  */
489
484
  restore(factId: number, trust: number, clock: number): number;
490
- bumpHelpful(factId: number, delta: number): void;
491
- /** Feedback outcome: pin at the permanent threshold (trust snaps to 1). */
492
- pinOutcome(factId: number, clock: number, bonusCount: number, windowAt: string): void;
493
- /** Feedback outcome: the forget line was reached — archive in the same statement. */
494
- forgetOutcome(factId: number, trust: number, clock: number, bonusCount: number, windowAt: string): void;
495
- /** Feedback outcome: reinforced (or decayed) but still active. */
496
- reinforceOutcome(factId: number, trust: number, clock: number, bonusCount: number, windowAt: string): void;
497
485
  pin(factId: number, clock: number): number;
498
486
  unpin(factId: number, clock: number): number;
499
487
  /**
@@ -534,13 +522,24 @@ export declare class FactsDao {
534
522
  /** ACTIVE and permanent — pinned facts are exempt from decay (the report's `pinned`). */
535
523
  countPinnedActive(): number;
536
524
  /**
537
- * ACTIVE facts whose decayed trust would reach zero within `withinDays` ACTIVE days —
538
- * i.e. `trust_score - step × (clock - settle_clock) <= step × withinDays`.
525
+ * ACTIVE, unpinned facts whose decayed trust reaches zero within `withinDays` ACTIVE days.
526
+ *
527
+ * EXACT for every shape, not a linear approximation: the predicate is
528
+ * `trustAfterDays(eff(now), withinDays, curve) <= 0`, which is the same question the tick asks
529
+ * when it archives a row. The caller passes the THRESHOLD (`ceilingForDays(withinDays, curve)`)
530
+ * because "days until zero" is no longer `trust ÷ slope` under `decay_shape: 'accelerating'`;
531
+ * a slope-based SQL predicate under-reported every fact in `(slope × 7, ceilingForDays(7)]`
532
+ * (0.0277 … 0.0515 at the shipped defaults) once accelerating became the default.
533
+ *
534
+ * COST: one row per active unpinned fact, evaluated in JS — the predicate needs `exp`, which the
535
+ * SQL layer cannot express portably. `trust_diagnose` is an operator-only diagnostic and the
536
+ * active set is what `mem_admin list` already walks; if it ever shows up in a profile, prefilter
537
+ * with an index-friendly bound rather than going back to a slope approximation.
539
538
  */
540
539
  countForgettingWithin(p: {
541
- step: number;
540
+ threshold: number;
542
541
  clock: number;
543
- withinDays: number;
542
+ curve: TrustCurve;
544
543
  }): number;
545
544
  /** Facts with a still-live (calendar 24h) reinforcement window — the per-fact quota. */
546
545
  /**
@@ -555,18 +554,15 @@ export declare class FactsDao {
555
554
  count: number;
556
555
  granted: number;
557
556
  };
558
- /** ACTIVE, unpinned facts unused for `days` CALENDAR days (the idle-archival fallback). */
559
- countIdleCandidates(days: number): number;
560
557
  /**
561
- * Rows still due for ②③④ after a budgeted tick — what the NEXT pass will archive.
558
+ * Rows still due for ② after a budgeted tick — what the NEXT pass will archive.
562
559
  *
563
- * One statement over the three predicates (they are alternatives, and the sets are disjoint:
564
- * a row has either a TTL, a forgotten trust score, or an idle clock). Only called when a step
565
- * actually filled its budget, so the common tick pays nothing for it.
560
+ * The same predicate the archival statement runs (`FORGET_PREDICATE`), so "backlog" and "what
561
+ * `archiveForgotten` will do next" cannot disagree. Only called when ② actually filled its
562
+ * budget, so the common tick pays nothing for it.
566
563
  */
567
564
  countArchiveBacklog(p: {
568
565
  forgetThreshold: number;
569
- idleCalendarDays: number;
570
566
  }): number;
571
567
  /**
572
568
  * Drop unusable (stale-dim) vectors so a re-encode can replace them — and re-queue the same
@@ -753,35 +749,50 @@ export declare class FactsDao {
753
749
  * row, and `settle_clock < c` is a seek + range scan of exactly the pending rows.
754
750
  */
755
751
  private static readonly SETTLE_PREDICATE;
756
- /** ① materialize decay, bounded to `budget` rows. */
752
+ /**
753
+ * The ONE forgetting predicate, shared by the archival statement and its backlog count.
754
+ *
755
+ * Kept as a constant for the same reason as {@link SETTLE_PREDICATE}: the count answers "how many
756
+ * will the next pass archive", so it must be the same WHERE body the archival itself runs —
757
+ * otherwise `archived_deferred` and the actual sweep would drift apart.
758
+ */
759
+ private static readonly FORGET_PREDICATE;
760
+ /**
761
+ * ① materialize decay, bounded to `budget` rows.
762
+ *
763
+ * PER ROW, through `trustAfterDays` — not one UPDATE with an inline `trust_score − step·Δ`. The
764
+ * formula lives in `contract/src/trust_curve.ts` and nowhere else; the `linear` branch of that
765
+ * function is bit-identical to the statement this replaced (`T − days·slope`, the same two
766
+ * operations in the same order), so P3's equivalent tier materializes exactly the same numbers.
767
+ *
768
+ * The SELECT is the old UPDATE's WHERE body with the old subquery's ORDER/LIMIT, so the row SET is
769
+ * the same one the statement would have touched (and `settled` still counts rows matched, not rows
770
+ * whose value happened to move).
771
+ *
772
+ * COST: one `UPDATE` PER ROW inside the tick's single `IMMEDIATE` transaction (the accelerating
773
+ * formula needs `exp`, which SQL cannot express here, and a second SQL implementation would
774
+ * drift). That is why every caller is budgeted by `trust.tick_max_facts` (5000 by default) and
775
+ * `admin maintenance` is the only unbudgeted pass — measured ~22 ms per 5000 rows, inside the
776
+ * tick's budget; it is a deliberate trade, not a regression.
777
+ */
757
778
  settleBudgeted(p: {
758
- step: number;
779
+ curve: TrustCurve;
759
780
  clock: number;
760
781
  budget: number;
761
782
  }): number;
762
783
  /** ① materialize decay for every pending row (no budget). */
763
784
  settleAll(p: {
764
- step: number;
785
+ curve: TrustCurve;
765
786
  clock: number;
766
787
  }): number;
788
+ /** The shared per-row write of ①: settle the curve at `clock`, then stamp `settle_clock`. */
789
+ private settleRows;
767
790
  /** Rows still awaiting ① (the budget ran out). */
768
791
  countPendingSettle(p: {
769
792
  clock: number;
770
793
  }): number;
771
794
  /**
772
- * ② calendar TTL — explicit instruction, so `pinned` is NOT exempt.
773
- *
774
- * `budget` (0 = none) caps how many rows ONE tick may archive: the tick is a single IMMEDIATE
775
- * transaction shared by several processes, so an unbudgeted pass over a large backlog held the
776
- * write lock for as long as it took (measured 14400 rows in one tick). The remainder stays due
777
- * and is reported as a deferred count, then archived by the next tick.
778
- */
779
- archiveExpiredByTtl(p: {
780
- clock: number;
781
- budget?: number;
782
- }): number[];
783
- /**
784
- * ③ trust-driven forgetting (① already clamped trust to the threshold).
795
+ * ② trust-driven forgetting (① already clamped trust to the threshold).
785
796
  *
786
797
  * Sargable as written (`status`/`pinned` equality + `trust_score <= ?`), and served by
787
798
  * `idx_facts_forget(status, pinned, trust_score)`: before that index this statement read every
@@ -793,20 +804,7 @@ export declare class FactsDao {
793
804
  budget?: number;
794
805
  }): number[];
795
806
  /**
796
- * ④ calendar fallback: never touched for `idle_calendar_days`.
797
- *
798
- * The comparison is written as `<expression> < julianday('now', :modifier)` — the same
799
- * expression `idx_facts_idle_cutoff` indexes — so it is a range seek. The old form
800
- * (`julianday('now') - <expression> > :days`) called `julianday` on EVERY active row and could
801
- * not use any index (measured 33–212 ms at 180k).
802
- */
803
- archiveIdle(p: {
804
- clock: number;
805
- idleCalendarDays: number;
806
- budget?: number;
807
- }): number[];
808
- /**
809
- * ⑤ physical delete of archived rows past the retention window.
807
+ * ③ physical delete of archived rows past the retention window.
810
808
  *
811
809
  * TWO statements in this order, sharing one predicate:
812
810
  *
@@ -815,7 +813,7 @@ export declare class FactsDao {
815
813
  * DELETE below failed with `SQLITE_CONSTRAINT_FOREIGNKEY` whenever a revision chain
816
814
  * crossed `purge_after_archived_days`. Because the whole tick is ONE transaction and it
817
815
  * runs from the store's constructor, that failure did not merely skip the purge: it rolled
818
- * back settle/TTL/idle with it and left the process unable to start, permanently.
816
+ * back settle/forget with it and left the process unable to start, permanently.
819
817
  * 2. delete them.
820
818
  *
821
819
  * The predicate is built once and interpolated into both statements, so "which rows are
@@ -832,7 +830,7 @@ export declare class FactsDao {
832
830
  /** The two mutually exclusive purge branches (see `purgeArchived` for why they are split). */
833
831
  private static purgeBranches;
834
832
  private static purgeParams;
835
- /** Archived rows still due for ⑤ after a budgeted pass (0 when the pass finished them). */
833
+ /** Archived rows still due for ③ after a budgeted pass (0 when the pass finished them). */
836
834
  countPurgeBacklog(p: {
837
835
  clock: number;
838
836
  enabled: number;
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * @module dbdrivers/sqlite/facts
10
10
  */
11
- import type { MemoryKind } from '../../../mem-contract/index.js';
11
+ import type { MemoryKind, TrustCurve } from '../../../mem-contract/index.js';
12
12
  import { type TimeRange } from '../../store/time_filter.js';
13
13
  import type { FactHitMeta, FactRow, FactVectorRow, FactVectorSpaceRow, HrrVectorRow, InsertRevisionOutcome, MemoryRepository } from '../../interfaces/repositories.js';
14
14
  import type { FactsDao } from './dao/index.js';
@@ -31,14 +31,12 @@ export declare class SqliteMemoryRepository implements MemoryRepository {
31
31
  countInStatus(status: string, kind?: MemoryKind, source?: string): number;
32
32
  countActiveWithValidFrom(): number;
33
33
  countForgettingWithin(p: {
34
- step: number;
34
+ threshold: number;
35
35
  clock: number;
36
- withinDays: number;
36
+ curve: TrustCurve;
37
37
  }): number;
38
- countIdleCandidates(days: number): number;
39
38
  countArchiveBacklog(p: {
40
39
  forgetThreshold: number;
41
- idleCalendarDays: number;
42
40
  }): number;
43
41
  countPendingConflicts(): number;
44
42
  countStaleEntities(version: number): number;
@@ -86,25 +84,20 @@ export declare class SqliteMemoryRepository implements MemoryRepository {
86
84
  hrrRowsForFacts(ids: readonly number[], kind?: MemoryKind, source?: string): HrrVectorRow[];
87
85
  activeIdsIn(ids: readonly number[], kind?: MemoryKind, source?: string, time?: TimeRange): number[];
88
86
  insertRevision(v: Parameters<FactsDao['insertRevision']>[0]): InsertRevisionOutcome;
89
- applyValidity(factId: number, validFrom: Parameters<FactsDao['applyValidity']>[1], validTo: Parameters<FactsDao['applyValidity']>[2]): void;
90
- applyValues(factId: number, kind: MemoryKind, ttlDays: number): void;
87
+ applyValidity(factId: number, validFrom: Parameters<FactsDao['applyValidity']>[1]): void;
88
+ applyValues(factId: number, kind: MemoryKind): void;
91
89
  setTitle(factId: number, title: string): void;
92
90
  insertTitleAlias(factId: number, title: string): void;
93
91
  copyTitleAliases(fromFactId: number, toFactId: number, currentTitle: string): void;
94
92
  dropTitleAlias(factId: number, title: string): void;
95
93
  incrementAssertCount(factId: number): number;
96
- markValidTo(factId: number, at: string, atMs?: number | null): void;
97
94
  linkSupersede(factId: number, supersedesId: number): void;
98
95
  archiveSuperseded(factId: number, clock: number): number;
99
- revive(factId: number, kind: MemoryKind, ttlDays: number, trust: number, clock: number): number;
96
+ revive(factId: number, kind: MemoryKind, trust: number, clock: number): number;
100
97
  archive(factId: number, clock: number, reason: string): number;
101
98
  restore(factId: number, trust: number, clock: number): number;
102
- bumpHelpful(factId: number, delta: number): void;
103
99
  pin(factId: number, clock: number): number;
104
100
  unpin(factId: number, clock: number): number;
105
- pinOutcome(factId: number, clock: number, bonusCount: number, windowAt: string): void;
106
- forgetOutcome(factId: number, trust: number, clock: number, bonusCount: number, windowAt: string): void;
107
- reinforceOutcome(factId: number, trust: number, clock: number, bonusCount: number, windowAt: string): void;
108
101
  touchUsage(ids: readonly number[]): void;
109
102
  settleTrust(factId: number, trust: number, clock: number): void;
110
103
  reinforce(factId: number, trust: number, clock: number, bonusCount: number, windowAt: string): void;
@@ -181,6 +181,10 @@ export interface MetadataDropped {
181
181
  helpful_count: number;
182
182
  pinned: number;
183
183
  supersedes: number;
184
+ /** v2: the imported row carried a `ttl_days` value; TTL is gone, so it is not written back. */
185
+ ttl_days: number;
186
+ /** v2: the imported row carried a `valid_to` value; the known end is gone. */
187
+ valid_to: number;
184
188
  }
185
189
  /** The single JSON object `import` prints (`file` is added by the CLI, which knows the argument). */
186
190
  export interface ImportReport {
@@ -14,7 +14,7 @@
14
14
  *
15
15
  * @module interfaces/repositories
16
16
  */
17
- import type { ContradictionRecord, FactSourceView, MemoryKind, RelationInput } from '../../mem-contract/index.js';
17
+ import type { ContradictionRecord, FactSourceView, MemoryKind, RelationInput, TrustCurve } from '../../mem-contract/index.js';
18
18
  import type { GraphReader } from '../../graph-recall/index.js';
19
19
  import type { RelationVocabulary, SurfaceMatch } from '../store/relation_planner.js';
20
20
  import type { ExtractedEntity } from '../entities/extract.js';
@@ -36,7 +36,6 @@ export interface FactHitMeta {
36
36
  created_at: string;
37
37
  updated_at: string | null;
38
38
  valid_from: string | null;
39
- valid_to: string | null;
40
39
  title: string | null;
41
40
  }
42
41
  /** One persisted semantic vector, as the reload/repair flows read it. */
@@ -87,14 +86,12 @@ export interface MemoryRepository {
87
86
  countInStatus(status: string, kind?: MemoryKind, source?: string): Awaitable<number>;
88
87
  countActiveWithValidFrom(): Awaitable<number>;
89
88
  countForgettingWithin(p: {
90
- step: number;
89
+ threshold: number;
91
90
  clock: number;
92
- withinDays: number;
91
+ curve: TrustCurve;
93
92
  }): Awaitable<number>;
94
- countIdleCandidates(days: number): Awaitable<number>;
95
93
  countArchiveBacklog(p: {
96
94
  forgetThreshold: number;
97
- idleCalendarDays: number;
98
95
  }): Awaitable<number>;
99
96
  countPendingConflicts(): Awaitable<number>;
100
97
  countStaleEntities(version: number): Awaitable<number>;
@@ -146,7 +143,6 @@ export interface MemoryRepository {
146
143
  insertRevision(v: {
147
144
  content: string;
148
145
  kind: MemoryKind;
149
- ttlDays: number;
150
146
  supersedesId: number | null;
151
147
  hrr: Uint8Array;
152
148
  trust: number;
@@ -157,18 +153,13 @@ export interface MemoryRepository {
157
153
  createdAt: string;
158
154
  title?: string | null;
159
155
  validFrom?: string | null;
160
- validTo?: string | null;
161
156
  validFromMs?: number | null;
162
- validToMs?: number | null;
163
157
  }): Awaitable<InsertRevisionOutcome>;
164
158
  applyValidity(factId: number, validFrom: {
165
159
  text: string;
166
160
  ms: number;
167
- } | undefined, validTo: {
168
- text: string;
169
- ms: number;
170
161
  } | undefined): Awaitable<void>;
171
- applyValues(factId: number, kind: MemoryKind, ttlDays: number): Awaitable<void>;
162
+ applyValues(factId: number, kind: MemoryKind): Awaitable<void>;
172
163
  setTitle(factId: number, title: string): Awaitable<void>;
173
164
  /** v17: record a handle this fact used to carry, so an old title search still finds it. */
174
165
  insertTitleAlias(factId: number, title: string): Awaitable<void>;
@@ -177,18 +168,13 @@ export interface MemoryRepository {
177
168
  /** v17: drop an alias that has become the row's current title (a correction back to it). */
178
169
  dropTitleAlias(factId: number, title: string): Awaitable<void>;
179
170
  incrementAssertCount(factId: number): Awaitable<number>;
180
- markValidTo(factId: number, at: string, atMs?: number | null): Awaitable<void>;
181
171
  linkSupersede(factId: number, supersedesId: number): Awaitable<void>;
182
172
  archiveSuperseded(factId: number, clock: number): Awaitable<number>;
183
- revive(factId: number, kind: MemoryKind, ttlDays: number, trust: number, clock: number): Awaitable<number>;
173
+ revive(factId: number, kind: MemoryKind, trust: number, clock: number): Awaitable<number>;
184
174
  archive(factId: number, clock: number, reason: string): Awaitable<number>;
185
175
  restore(factId: number, trust: number, clock: number): Awaitable<number>;
186
- bumpHelpful(factId: number, delta: number): Awaitable<void>;
187
176
  pin(factId: number, clock: number): Awaitable<number>;
188
177
  unpin(factId: number, clock: number): Awaitable<number>;
189
- pinOutcome(factId: number, clock: number, bonusCount: number, windowAt: string): Awaitable<void>;
190
- forgetOutcome(factId: number, trust: number, clock: number, bonusCount: number, windowAt: string): Awaitable<void>;
191
- reinforceOutcome(factId: number, trust: number, clock: number, bonusCount: number, windowAt: string): Awaitable<void>;
192
178
  touchUsage(ids: readonly number[]): Awaitable<void>;
193
179
  settleTrust(factId: number, trust: number, clock: number): Awaitable<void>;
194
180
  reinforce(factId: number, trust: number, clock: number, bonusCount: number, windowAt: string): Awaitable<void>;
@@ -4,8 +4,10 @@
4
4
  *
5
5
  * Surface stability (D10): the original report keys are all preserved
6
6
  * (`decayed / archived_ttl / archived_age / purged / purged_ids / archived_ids`)
7
- * and the new detail is purely additive. `archived_age` now means "non-TTL
8
- * automatic archival" = `forgot + idle`.
7
+ * and the new detail is purely additive. `archived_ttl` / `archived_idle` are kept as
8
+ * CONSTANT 0 and marked `@deprecated`: TTL and idle archival are gone (v2), but a report key
9
+ * is a surface scripts read — deleting it would break them for nothing. `archived_age` now
10
+ * means "the automatic archival that still exists" = `forgot`.
9
11
  *
10
12
  * The `maintenance` action forces a FULL pass (no settle budget); the automatic
11
13
  * startup/heartbeat passes use `tick_max_facts`.
@@ -15,8 +17,9 @@ import type { Db } from '../db/conn.js';
15
17
  export interface MaintenanceResult {
16
18
  /** Rows whose trust was materialized by the settle sweep (see tick.ts). */
17
19
  decayed: number;
20
+ /** @deprecated TTL archival is gone (v2); always 0, kept so existing report readers keep working. */
18
21
  archived_ttl: number;
19
- /** Non-TTL automatic archival = `forgot + idle` (kept name, widened meaning). */
22
+ /** The automatic archival this pass performed = `forgot` (name kept, D10). */
20
23
  archived_age: number;
21
24
  purged: number;
22
25
  /** Fact ids physically deleted by the purge — callers evict their vectors from the live index. */
@@ -26,10 +29,11 @@ export interface MaintenanceResult {
26
29
  settled: number;
27
30
  clock: number;
28
31
  archived_forgot: number;
32
+ /** @deprecated Idle archival is gone (v2); always 0, kept for the same reason as `archived_ttl`. */
29
33
  archived_idle: number;
30
34
  /** Rows still pending settle because the budget ran out (always 0 for a forced pass). */
31
35
  skipped: number;
32
- /** Rows still due for TTL/forgot/idle because the budget ran out (0 for a forced pass). */
36
+ /** Rows still due for forgot because the budget ran out (0 for a forced pass). */
33
37
  archived_deferred: number;
34
38
  /** Rows still due for purge when the budget ran out (0 for a forced pass). */
35
39
  purged_deferred: number;
@@ -1,16 +1,19 @@
1
1
  /**
2
- * One trust/lifecycle tick (spec docs/TRUST_MODEL.md §4) — five ordered statements:
2
+ * One trust/lifecycle tick (spec docs/TRUST_MODEL.md §4) — three ordered statements:
3
3
  *
4
4
  * ① settle — materialize decay for every row whose ACTIVE DAY changed
5
- * ② TTL — calendar expiry (explicit instruction; pinned is not exempt)
6
- * ③ forgot — trust ≤ forget_threshold after settling (pinned exempt)
7
- * ④ idle — calendar fallback: unused for `idle_calendar_days`
8
- * ⑤ purge — physical delete of archived rows past the window
5
+ * ② forgot — trust ≤ forget_threshold after settling (pinned exempt)
6
+ * ③ purge — physical delete of archived rows past the window
7
+ *
8
+ * The calendar clocks are GONE (v2): there is no TTL step and no idle fallback, so a fact's
9
+ * lifetime is decided by the active-day curve alone and "expired" is expressed by an explicit
10
+ * `update` / `archive` (docs/TRUST_MODEL.md §3.4). `pinned` is therefore never archived by an
11
+ * automatic pass.
9
12
  *
10
13
  * EVERY step is budgeted by the same `tick_max_facts`, not just ①.
11
14
  *
12
15
  * It used to say "only ① is budgeted; ②③④⑤ only ever touch critical rows", and that reading is
13
- * wrong in the direction that matters: ②③④⑤ each run inside the tick's ONE `IMMEDIATE` transaction,
16
+ * wrong in the direction that matters: every step runs inside the tick's ONE `IMMEDIATE` transaction,
14
17
  * which holds the write lock for its whole duration, so an unbounded archive or purge pass over a
15
18
  * large corpus blocks every other process (and the startup path, which is where the tick runs) for
16
19
  * as long as it takes. Bounding each step is what keeps that lock short; `archived_deferred` and
@@ -24,17 +27,15 @@ export interface TickResult {
24
27
  clock: number;
25
28
  /** Rows materialized by ① (equals "trust actually changed" — see the note in runTrustTick). */
26
29
  settled: number;
27
- archived_ttl: number;
28
30
  archived_forgot: number;
29
- archived_idle: number;
30
31
  purged: number;
31
32
  purged_ids: number[];
32
33
  archived_ids: number[];
33
34
  /** Rows still awaiting ① when the budget ran out (0 for a full pass). */
34
35
  skipped: number;
35
- /** Rows still due for ②③④ when the budget ran out (0 when the pass finished them). */
36
+ /** Rows still due for ② when the budget ran out (0 when the pass finished them). */
36
37
  archived_deferred: number;
37
- /** Rows still due for ⑤ when the budget ran out (0 when the pass finished them). */
38
+ /** Rows still due for ③ when the budget ran out (0 when the pass finished them). */
38
39
  purged_deferred: number;
39
40
  }
40
41
  export declare function runTrustTick(db: Db, cfg: Config, opts: {
@@ -3,13 +3,22 @@
3
3
  * (`clock` / `nowMs` are always injected, so every rule is unit-testable).
4
4
  *
5
5
  * Spec: docs/TRUST_MODEL.md §2. Key rules encoded here:
6
- * - `eff = pinned ? 1 : clamp(trust − step × (clock − settle_clock), 0, 1)` (§2.2)
6
+ * - `eff = pinned ? 1 : clamp(T(clock − settle_clock), 0, 1)` (§2.2), where `T(days)` is the decay
7
+ * curve of `contract/src/trust_curve.ts` — the ONE implementation of the formula
7
8
  * - recall only ever RAISES trust (`max(eff, …)`, R2) and never pins (D11)
8
9
  * - zero-gain recalls neither consume quota nor stamp `last_reinforced_at` (R10)
9
- * - explicit feedback can pin (snap to 1.0) or forget immediately (§2.4)
10
+ * - permanent is set ONLY by an explicit `pin`; no write path moves a fact across that line (§2.4)
10
11
  * - `enabled=false` degrades `eff()` to the stored value (D12)
11
12
  */
12
13
  import type { Config } from '../../mem-contract/index.js';
14
+ import { formatUtcTs, parseUtcTs, type TrustCurve } from '../../mem-contract/index.js';
15
+ /**
16
+ * The UTC stamp parse/render pair is `@avantf/mem-contract/time` — ONE implementation, shared with
17
+ * the two display faces (`formatOffsetIso` for the model, `formatLocalHuman` for the UI), so the
18
+ * "a zone-less stamp means UTC" rule cannot drift between the trust math and what a reader sees.
19
+ * Re-exported here because this module is where the trust code (and its specs) have always found it.
20
+ */
21
+ export { formatUtcTs, parseUtcTs };
13
22
  export declare const DAY_MS = 86400000;
14
23
  /** Guard for "did the number actually move" comparisons. */
15
24
  export declare const EPSILON = 1e-9;
@@ -24,20 +33,9 @@ export interface TrustRow {
24
33
  status: string;
25
34
  }
26
35
  export declare function clamp01(v: number): number;
36
+ /** The curve of one `trust` section — the single mapping from config keys to the math. */
37
+ export declare function trustCurveOf(cfg: TrustConfig): TrustCurve;
27
38
  export declare function isPinned(row: Pick<TrustRow, 'pinned'>): boolean;
28
- /**
29
- * `'YYYY-MM-DD HH:MM:SS'` (SQLite UTC) or ISO → epoch ms; null/garbage → null.
30
- *
31
- * A stamp with NO zone designator is read as UTC, not as local time. `Date.parse` treats a zone-less
32
- * ISO string as LOCAL, and everything in this store is written in UTC (`CURRENT_TIMESTAMP`, and
33
- * {@link formatUtcTs}), so handing one over as-is would shift every quota window and every decay
34
- * measurement by the host's UTC offset — a fact reinforced at 23:00 UTC+8 would look reinforced 8
35
- * hours earlier, and the 24h cap would open or close at the wrong moment. An explicit `Z` or
36
- * `±HH:MM` is left alone.
37
- */
38
- export declare function parseUtcTs(ts: string | null | undefined): number | null;
39
- /** Epoch ms → `'YYYY-MM-DD HH:MM:SS'` (the SQLite `CURRENT_TIMESTAMP` shape, UTC). */
40
- export declare function formatUtcTs(ms: number): string;
41
39
  /** eff(f) — the trust a caller should act on right now (spec §2.2). */
42
40
  export declare function effectiveTrust(row: TrustRow, clock: number, cfg: TrustConfig, enabled?: boolean): number;
43
41
  /**
@@ -48,8 +46,9 @@ export declare function effectiveTrust(row: TrustRow, clock: number, cfg: TrustC
48
46
  */
49
47
  export declare function displayTrust(row: TrustRow, clock: number, cfg: TrustConfig, enabled?: boolean): number;
50
48
  /**
51
- * Remaining active days: `eff / step`. `null` for pinned rows, non-active rows and
52
- * when trust is disabled — the contract's `remaining_days` (spec §6 / R11).
49
+ * Remaining active days: the inverse of the curve at the current eff (`daysToZero`).
50
+ * `null` for pinned rows, non-active rows and when trust is disabled — the contract's
51
+ * `remaining_days` (spec §6 / R11).
53
52
  */
54
53
  export declare function remainingDays(row: TrustRow, clock: number, cfg: TrustConfig, enabled?: boolean): number | null;
55
54
  /** Everything a recall write needs to persist (spec §2.3). */
@@ -66,25 +65,13 @@ export interface RecallOutcome {
66
65
  * Apply one recall to a fact: settle, then grant a bonus inside the daily quota.
67
66
  * Returns `next === settled` when the quota is exhausted, the ceiling is reached,
68
67
  * the fact is pinned, or the gain would be zero (R2/R10/D11).
69
- */
70
- export declare function grantRecallBonus(row: TrustRow, clock: number, nowMs: number, cfg: TrustConfig, enabled?: boolean): RecallOutcome;
71
- /** Everything a feedback write needs to persist (spec §2.4). */
72
- interface FeedbackOutcome {
73
- settled: number;
74
- next: number;
75
- pin: boolean;
76
- forget: boolean;
77
- /** true when the row was left untouched (pinned / archived). */
78
- untouched: boolean;
79
- bonusCount: number;
80
- windowAt: string;
81
- }
82
- /**
83
- * Apply one explicit `helpful` (+1) / `unhelpful` (-1) step.
84
68
  *
85
- * Pinned and archived rows are untouched (R7): only `helpful_count` changes for
86
- * them, so a pinned fact can never end up storing 0.95 while displaying 1.0.
87
- * When `feedback_daily_cap > 0` feedback shares the per-fact 24h window counter.
69
+ * The gain is SEGMENTED at the frozen anchor `BASE_TRUST` (D20):
70
+ * - strictly below the base: `min(BASE_TRUST, T + recall_gain_below_base)`, so the shipped
71
+ * `recall_gain_below_base: 0.1` takes 0.05 → 0.5 in five recalls and never past the base;
72
+ * - AT OR ABOVE the base: `recall_gain_above_base × marginal_decay^count`, capped at the ceiling
73
+ * INVERTED from `max_retention_days` (there is no `recall_ceiling` key any more, D16). The
74
+ * boundary is strictly `<` on purpose: a fact sitting exactly on the base (a fresh one, on the
75
+ * first recall after it has finished settling) takes the above-base branch and starts climbing.
88
76
  */
89
- export declare function applyFeedbackDelta(row: TrustRow, clock: number, delta: number, nowMs: number, cfg: TrustConfig, enabled?: boolean): FeedbackOutcome;
90
- export {};
77
+ export declare function grantRecallBonus(row: TrustRow, clock: number, nowMs: number, cfg: TrustConfig, enabled?: boolean): RecallOutcome;