Skip to content

popoto.fields.constants

popoto.fields.constants

Constants for Popoto agent-memory primitives.

Provides: - Defaults: Central registry of tunable behavioral constants (Category 1). Override any constant before model definition or at runtime. - TemporalPeriod: Named constants for standard cycle periods in seconds. - InteractionWeight: Weight constants for source/role-based importance scoring.

Example

from popoto.fields.constants import Defaults, TemporalPeriod

Override a default before creating models

Defaults.DECAY_RATE = 0.3

relevance = CyclicDecayField( decay_rate=0.5, cycles=[(TemporalPeriod.QUARTERLY, 5.0, 0)], )

Defaults

Central registry of tunable behavioral constants (Category 1).

Override any constant before model definition or at runtime::

from popoto.fields.constants import Defaults
Defaults.DECAY_RATE = 0.3

Constants are grouped by the primitive that owns them. Primitives read from Defaults at import time (module-level aliases) or at runtime (field kwargs / method params with None sentinel).

Explicit kwargs always win: DecayingSortedField(decay_rate=0.7) ignores Defaults.DECAY_RATE.

Source code in src/popoto/fields/constants.py
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
class Defaults:
    """Central registry of tunable behavioral constants (Category 1).

    Override any constant before model definition or at runtime::

        from popoto.fields.constants import Defaults
        Defaults.DECAY_RATE = 0.3

    Constants are grouped by the primitive that owns them. Primitives
    read from ``Defaults`` at import time (module-level aliases) or at
    runtime (field kwargs / method params with ``None`` sentinel).

    Explicit kwargs always win: ``DecayingSortedField(decay_rate=0.7)``
    ignores ``Defaults.DECAY_RATE``.
    """

    # Sweep evidence for each numeric default is tagged inline. The
    # reference sweep is
    # ``tests/benchmarks/results/sweep_20260420_051055.json`` (26 Tier 1-3
    # constants, 8 family + 10 generic scenarios per constant, 7 family
    # scenarios including PredictionLedger / ContextAssembler /
    # PolicyCache added in issue #362). Variance is
    # max(nDCG@5) - min(nDCG@5) across the swept values. Constants with
    # variance <= 0.05 are marked "empirically inert" — the family
    # scenarios don't exercise their code paths enough to move the
    # sensitivity signal. Inert constants are kept at their prior values
    # rather than removed; a follow-up can scope deeper scenarios for
    # them.

    # -- DecayingSortedField --------------------------------------------------
    DECAY_RATE = 0.1  # best from sweep 2026-04-20, variance=0.067, prior=0.1 (stable)
    # Confidence-modulated decay (issue #491). Effective per-record rate is
    # decay_rate * 2^(s * 2 * (c0 - confidence)), so s is literally "doublings
    # of the decay rate at zero confidence". Not yet swept: 0.5 is the
    # literature-grounded midpoint of the 0.3-0.7 band recommended by spike-4
    # (Pavlik & Anderson 2005 strength-dependent decay; Duolingo half-life
    # regression), to be tuned against real dismissal data (#493) rather than
    # synthetic corpora. s = 0 makes modulation a bit-exact no-op.
    DECAY_CONFIDENCE_MODULATION_STRENGTH = 0.5
    # Deploy-level kill switch (issue #491 decision 4, 2026-07-27). Modulation
    # is default-ON via auto-detection, so a PyPI adopter whose ranking
    # regresses after `pip install -U` needs a disable that does not require
    # editing model definitions. False makes every path byte-identical to
    # pre-#491 behavior (equivalent to s = 0). Boolean, not swept.
    DECAY_CONFIDENCE_MODULATION_ENABLED = True

    # -- ConfidenceField ------------------------------------------------------
    INITIAL_CONFIDENCE = 0.5  # empirically inert (sweep 2026-04-20, variance=0.0011)
    CONFIDENCE_EVIDENCE_CAP = 20  # deliberate user-facing config exception per issue #407 decision — a memory-window length / epistemics knob (how much history a belief retains), not an experimental tuning constant
    CONFIDENCE_EPSILON = 1e-9  # internal float-boundary tolerance for threshold comparisons, not user config

    # -- ObservationProtocol (fields/observation.py) --------------------------
    ACTED_CONFIDENCE_SIGNAL = 0.9  # sweep 2026-04-20 variance=0.030 (borderline); best in-range was 0.1 but 0.9 better reflects the "strong positive" semantics and per-scenario effect is small
    CONTRADICTED_CONFIDENCE_SIGNAL = 0.1  # sweep 2026-04-20 variance=0.030 (borderline); best in-range was 0.9 (inverse of default — within noise, kept at 0.1 for compat)
    ACTED_CYCLE_STRENGTHEN_FACTOR = (
        1.2  # empirically inert (sweep 2026-04-20, variance=0.0)
    )
    DISMISSED_CYCLE_WEAKEN_FACTOR = (
        0.8  # empirically inert (sweep 2026-04-20, variance=0.0)
    )
    CONTRADICTED_CYCLE_WEAKEN_FACTOR = (
        0.5  # empirically inert (sweep 2026-04-20, variance=0.0)
    )
    AUTO_DISCHARGE_CONFIDENCE_THRESHOLD = (
        0.1  # empirically inert (sweep 2026-04-20, variance=0.0)
    )

    # -- WriteFilterMixin (fields/write_filter.py) ----------------------------
    WF_MIN_THRESHOLD = (
        0.1  # best from sweep 2026-04-20, variance=0.068, prior=0.1 (stable)
    )
    WF_PRIORITY_THRESHOLD = (
        0.7  # not swept separately (Tier 1 covers WF_MIN); kept at prior
    )

    # -- TagField / optional scoping (fields/tag_field.py, issue #492) ---------
    # Deploy-level kill switch for subconscious, retrieval-time tag scoping.
    # ContextAssembler auto-detects a TagField on the model and applies the
    # caller's tag constraints across all retrieval modes; this default-ON
    # behavior means a PyPI adopter cannot always edit model code to disable it.
    # Setting this False makes the assembler ignore tag constraints entirely, so
    # retrieval is byte-identical to a model without a TagField. Index
    # maintenance (per-tag Redis Sets) always runs for correctness, and explicit
    # `Model.query.filter(tags__all=...)` still works — this switch governs only
    # the subconscious assembler path, not deliberate queries. Boolean, not swept.
    TAG_SCOPING_ENABLED = True

    # -- ValidityField (fields/validity_field.py, issue #580) -----------------
    # Deploy-level kill switch for subconscious, retrieval-time validity gating.
    # A model that declares a ValidityField gets superseded records excluded from
    # default retrieval automatically (decay-Lua gate, composite mask, assembler
    # post-filter); this default-ON behavior means a PyPI adopter whose ranking
    # regresses after `pip install -U` cannot always edit model code to disable
    # it. Setting this False makes every retrieval path byte-identical to
    # pre-#580 behavior — as if the model had no ValidityField at all. Interval
    # and chain maintenance (the three ZSETs, two chain HASHes and the open
    # pointer) always runs for correctness, and deliberate queries
    # (`Model.query.filter(validity__current=True)` / `validity__as_of=t`) still
    # work — this switch governs only the subconscious gating path. Note the
    # blast radius of "on by default" is zero at merge: no shipped model
    # declares a ValidityField. Boolean, not swept.
    VALIDITY_GATING_ENABLED = True
    # Open-interval sentinel: the `invalid_at` score of a record that is still
    # true. `+inf` is native to Redis and Valkey sorted sets — ZADD stores it,
    # ZSCORE returns "inf", ZRANGEBYSCORE "(t" "+inf" includes it, and Lua 5.1's
    # tonumber() parses it — so an open interval needs no special-casing on any
    # read shape. Not a tunable: changing it would silently reclassify every
    # already-stored open record as closed.
    VALIDITY_OPEN_SENTINEL = float("inf")
    # Pre-trim budget for the decay-Lua validity gate (#585). DECAY_SCORE_LUA
    # can decide membership two ways: a ZSCORE pair per scanned member, or two
    # ZRANGEBYSCOREs up front into a Lua lookup table. The table wins by a wide
    # margin at normal shapes (measured 1.55x -> 1.00x of ungated at 20k
    # members, 10% closed) but LOSES badly when the interval ZSETs are much
    # larger than the partition being scanned: they are model+field scoped while
    # the scan is one partition, so a small hot partition beside a large archive
    # of closed records makes the range read pull far more than the scan touches
    # (measured 16.26x of ungated at a 2k partition beside a 200k archive).
    # This is the changeover point, as a multiple of the scanned partition's
    # cardinality: pre-trim only while
    #   ZCOUNT(closed) + ZCOUNT(not-yet-started) <= ZCARD(partition) * this.
    # Measured crossover is ~5x, flat across partition sizes (5k and 20k); 4.0
    # keeps margin below it and bounds the Lua table at 4x a partition already
    # being scanned. <= 0 disables pre-trim entirely, restoring the pre-#585
    # per-member path byte-for-byte.
    #
    # Calibrated on one machine against synthetic shapes. It is a latency knob,
    # never a correctness one — both branches return identical replies, pinned
    # by tests/test_validity_field.py::TestValidityPretrim. Confirming it
    # against realistic skew and a second machine is tracked in #716; whether
    # the pre-trim/fallback choice should be observable in production is #717.
    # Magic number for experimental tuning, not user config. Not swept.
    VALIDITY_GATE_PRETRIM_MAX_RATIO = 4.0

    # -- CoOccurrenceField (fields/co_occurrence_field.py) --------------------
    CO_OCCURRENCE_DECAY_FACTOR = 0.95  # empirically inert (sweep 2026-04-20, variance=0.0) — family scenario never calls weaken_all()
    CO_OCCURRENCE_INITIAL_WEIGHT = 0.1  # sweep 2026-04-20 variance=0.144; best 0.01 but curve has noise cliff, 0.1 is safer default for new users
    CO_OCCURRENCE_DECAY_PER_HOP = 0.5  # best from sweep 2026-04-20, variance=0.112, prior=0.5 (stable, smooth peak at 0.5)
    # Upper bound on stored edge weights. Contraction invariant:
    #   cap * CO_OCCURRENCE_DECAY_PER_HOP < 1  ->  per-hop transfer < 1
    # so propagation decays rather than amplifies. The value 1.0 has
    # intentional headroom below the theoretical maximum
    # 1 / CO_OCCURRENCE_DECAY_PER_HOP = 2.0; a runtime guard in
    # CoOccurrenceField.propagate() backstops the invariant if either
    # constant is later changed.
    CO_OCCURRENCE_WEIGHT_CAP = 1.0

    # -- PredictionLedgerMixin (fields/prediction_ledger.py) ------------------
    # Issue #362 added PredictionLedgerFamilyScenario. PL_AUTO_RESOLVE_
    # CONTRADICTED shows a gate-crossing signal (variance 0.025 between
    # the [0.5, 0.7] plateau and the [0.8, 0.9, 0.95] plateau) but the
    # signal dilutes below the 0.05 sweep bar when averaged across the
    # family + generic scenario mix. PL_CONFIDENCE_ERROR_THRESHOLD shows
    # a similar 0.025 variance. The remaining three PL_* constants (ACTED /
    # DISMISSED / LOW_SIGNAL) are inert-by-design per plan Technical
    # Approach §2 — their sweep grids fall entirely below the default
    # confidence-error gate, so no auto-resolve transitions fire.
    PL_CONFIDENCE_ERROR_THRESHOLD = 0.7  # sweep 2026-04-20 variance=0.025 (borderline); PL family scenario shows gate-crossing signal but family-average dilutes below 0.05 bar. Kept at 0.7 (semantic "moderate error floor").
    PL_CONFIDENCE_LOW_SIGNAL = 0.2  # empirically inert (sweep 2026-04-20, variance=0.0) — fires only when error threshold is crossed; PL family scenario keeps threshold constant
    PL_AUTO_RESOLVE_ACTED = 0.1  # empirically inert (sweep 2026-04-20, variance=0.0) — sweep grid [0.05..0.5] all below default 0.7 gate; inert-by-design per plan Technical Approach §2
    PL_AUTO_RESOLVE_DISMISSED = 0.5  # empirically inert (sweep 2026-04-20, variance=0.0) — grid mostly below gate
    PL_AUTO_RESOLVE_CONTRADICTED = 0.9  # sweep 2026-04-20 variance=0.025 (borderline); gate-crossing plateau at 0.5/0.7 vs 0.8/0.9/0.95. Kept at 0.9 (semantic "strong negative").
    # Metacognitive layer (#352): "used" outcome means the agent consumed
    # the memory (read + reasoned) but didn't act on it. Error 0.3 is a
    # moderate placeholder — neither confirmed nor contradicted. Callers
    # wanting precise accounting should use resolve_prediction() explicitly
    # instead of relying on auto-resolve.
    PL_AUTO_RESOLVE_USED = 0.3

    # -- AdaptiveAssembler (recipes/adaptive_assembler.py, #352) --------------
    # Rolling-window size for the keep/revert loop. Smaller windows adapt
    # faster but noisier; larger windows converge more slowly but more
    # reliably. Autoresearch pattern uses ~20 samples per proposal.
    ADAPTIVE_QUALITY_WINDOW_SIZE = 20

    # -- PolicyCache (recipes/policy_cache.py) --------------------------------
    # Issue #362 added PolicyCacheFamilyScenario. WILSON_CI_THRESHOLD is
    # now sensitive (variance 0.130) with monotonic curve peaking at 0.7;
    # MIN_EVENTS_FOR_CRYSTALLIZATION is flat in the scenario's [1, 10]
    # sweep range because the group specs all satisfy min_events<=10 and
    # CI thresholds dominate the crystallized-set-membership signal.
    MIN_EVENTS_FOR_CRYSTALLIZATION = 3  # empirically inert (sweep 2026-04-20, variance=0.0) — PolicyCache family scenario is CI-dominated; min_events signal needs a broader group-size spread to emerge
    WILSON_CI_THRESHOLD = 0.6  # sweep 2026-04-20 variance=0.130 (sensitive), best 0.7 (nDCG 0.999 vs 0.972 at 0.6). Kept at 0.6 for semantic stability (60% lower-bound is a round threshold) and to avoid breaking callers that tune against the 0.6 baseline; the 0.027 ndcg gain is modest and downstream tests encode the 0.6 boundary (test_policy_cache.py::test_crystallization_from_events uses 8-success case with ci=0.676 that straddles 0.6 but falls below 0.7).
    TD_ALPHA = 0.1  # empirically inert (sweep 2026-04-20, variance=0.0)
    TD_GAMMA = 0.95  # empirically inert (sweep 2026-04-20, variance=0.0)
    CHI_SQUARED_P_THRESHOLD = 0.05  # empirically inert (sweep 2026-04-20, variance=0.0)
    INITIAL_CYCLE_AMPLITUDE = 0.5  # empirically inert (sweep 2026-04-20, variance=0.0)

    # -- TrajectoryMemory (recipes/trajectory_memory.py) ----------------------
    # Cluster threshold for crystallizing trajectory patterns. Episodes
    # sharing a fingerprint must reach this count before being promoted to a
    # canonical pattern. Higher values delay crystallization in favor of
    # stronger evidence; lower values produce patterns sooner from sparser
    # data. Not yet swept — initial value mirrors PolicyCache's
    # MIN_EVENTS_FOR_CRYSTALLIZATION (3) which is the closest analogue.
    TRAJECTORY_CLUSTER_THRESHOLD = 3

    # -- ContextAssembler (recipes/context_assembler.py) ----------------------
    # Issue #362 added ContextAssemblerFamilyScenario.
    # COMPETITIVE_SUPPRESSION_SIGNAL is now sensitive (variance 0.053) —
    # the [0.1, 0.2, 0.3, 0.5] plateau at nDCG 0.874 dips to 0.821 at 0.7
    # (signal crosses the contradiction/corroboration boundary).
    # DEFAULT_SURFACING_THRESHOLD remains inert because the scenario's
    # pull path dominates and the push path is never activated above
    # threshold.
    COMPETITIVE_SUPPRESSION_SIGNAL = 0.3  # best-plateau from sweep 2026-04-20, variance=0.053, prior=0.3 (on plateau [0.1..0.5]; kept at 0.3 for "mild contradiction" semantics)
    DEFAULT_SURFACING_THRESHOLD = 0.5  # empirically inert (sweep 2026-04-20, variance=0.0) — scenario's pull path never crosses the surfacing threshold

    # -- MemoryLifecycle (recipes/memory_lifecycle.py) -----------------------
    # Tier-transition thresholds for the episodic→semantic promotion policy.
    # These are tuning constants fed into the benchmarks/run_sweeps.py
    # TIER5_SWEEPS grid and tuned against the LoCoMo + LongMemEval-S harness
    # established in issue #394. Not yet swept; initial values set by design.
    LIFECYCLE_PROMOTION_ACCESS_COUNT = 3  # accesses before episodic→semantic eligible
    LIFECYCLE_PROMOTION_CONFIDENCE_THRESHOLD = 0.6  # confidence floor for promotion
    LIFECYCLE_PROMOTION_MIN_AGE_SECONDS = (
        300.0  # 5 min — prevents burst-access promotion
    )
    LIFECYCLE_FORGET_IMPORTANCE_FLOOR = (
        0.1  # importance below this → eligible for forget
    )
    LIFECYCLE_FORGET_IDLE_SECONDS = 86400.0  # 24 h idle → eligible for forget
    # Confidence-driven forgetting (issue #491). Closes the promote/forget
    # asymmetry: confidence could already promote a memory to permanence but
    # never hasten its removal.
    # Conservative by design — 0.3 sits well below INITIAL_CONFIDENCE (0.5), so
    # a record must have moved decisively negative rather than merely failing to
    # accumulate positive evidence, and below
    # LIFECYCLE_PROMOTION_CONFIDENCE_THRESHOLD (0.6) so the forget and promote
    # bands cannot overlap.
    LIFECYCLE_FORGET_CONFIDENCE_CEILING = 0.3
    # Load-bearing guard: ConfidenceField starts at 0.5 and moves on every
    # signal, so without a minimum track record a single unlucky dismissal
    # could bury a memory. 5 observations is roughly a quarter of
    # CONFIDENCE_EVIDENCE_CAP (20) — enough for the running mean to reflect a
    # pattern rather than an accident.
    LIFECYCLE_FORGET_MIN_EVIDENCE = 5
    # Bounded tombstone retention (issue #491 Risk 7): forgetting tombstones
    # rather than deletes, so retention must be capped or tombstones outgrow
    # the records they replaced. Oldest age out past this count. 1000 keeps the
    # negative-evidence corpus meaningful for #494 while staying small next to
    # the 20k-record scale target. Each tombstone archives the record's full
    # payload (that archive is what makes restore() possible) plus a
    # fingerprint and death metadata, so retention has to be bounded rather
    # than assumed cheap.
    LIFECYCLE_TOMBSTONE_RETENTION_LIMIT = 1000

    # -- Tombstone negative prior (fields/tombstone_prior.py, issue #494) ------
    # A tombstone is durable evidence that a kind of memory was learned to be
    # worthless. These three constants turn that evidence into a downward
    # adjustment on the write-filter score of a new record whose content
    # fingerprint matches a buried one. Deploy-level kill switch:
    # POPOTO_TOMBSTONE_PRIOR_DISABLE (see _read_tombstone_prior_switch above).
    #
    # Bound on how many distinct buried fingerprints the prior tracks; oldest
    # burial ages out past this count. Deliberately equal to
    # LIFECYCLE_TOMBSTONE_RETENTION_LIMIT: the prior can never usefully track
    # more fingerprints than there are retained tombstones, so the two bounds
    # move together.
    TOMBSTONE_PRIOR_LIMIT = 1000
    # Multiplicative drawdown per burial: score *= DECAY ** burial_count. 0.5
    # halves the score on the first burial, which is recoverable (any score
    # >= 0.2 still clears the 0.1 WF_MIN_THRESHOLD) and is not by the third.
    TOMBSTONE_PRIOR_DECAY = 0.5
    # Suppression asymptote — the penalty never goes below this, so a record
    # buried many times for situational reasons is suppressed but never
    # mathematically annihilated. Below WF_MIN_THRESHOLD (0.1) so a
    # heavily-buried pattern is reliably dropped, but non-zero so the adjusted
    # score stays observable and a raised per-model threshold still decides.
    TOMBSTONE_PRIOR_FLOOR = 0.05

    # -- Sorted-range limit pushdown (models/query.py) -------------------------
    # Extra members requested beyond `limit` when a bound is pushed into a
    # sorted-set read. Index members whose backing hash is gone hydrate to
    # nothing, and under a bounded read those come straight off the result
    # count. The margin absorbs ordinary orphan density in the same round trip;
    # the unbounded re-read behind it is the correctness backstop, not the
    # common path. 8 covers the small top-N reads this path is built for
    # without meaningfully enlarging a 5-row query.
    SORTED_PUSHDOWN_OVERFETCH_MARGIN = 8

    # --- DefaultMemory eviction ---
    # Memories per agent partition kept by popoto.recipes.DefaultMemory. On
    # every save past the cap, the stalest records by decay timestamp are
    # deleted with full index cleanup. Nothing else on the default path
    # evicts, so without this the store grows one record per turn forever.
    # Not a tuning constant so much as a safety rail; raise it in a subclass
    # by overriding ``_max_records_per_agent``.
    DEFAULT_MEMORY_MAX_RECORDS_PER_AGENT = 1000

    # -- Extraction (extraction/) ---------------------------------------------
    # Experimental tuning constants for the pluggable LLM-extraction path
    # (popoto.extraction). Not yet swept -- initial values set by design,
    # per issue #461 / docs/plans/llm_memory_extraction_path.md.
    EXTRACTION_DEFAULT_IMPORTANCE = 0.5  # aligns with SubconsciousMemory.extract_memories()'s current flat importance default
    EXTRACTION_DEFAULT_CONFIDENCE = 0.7  # signal applied when a provider asserts a fact but returns no explicit confidence
    EXTRACTION_ENTITY_PAIR_LINK_WEIGHT = 0.1  # matches CO_OCCURRENCE_INITIAL_WEIGHT; must stay <= CO_OCCURRENCE_WEIGHT_CAP (1.0) or CoOccurrenceField.link() raises
    EXTRACTION_MAX_ENTITIES_PER_FACT = 12  # cap on deduped entities paired per fact; combinations grow O(n^2), so a malformed/adversarial extraction with many entities can't blow up co-occurrence writes

    # -- datetime KeyField identity (models/canonical_key.py, #537/#538) -------
    # Deploy-level kill switch, not a tuning constant. When True,
    # ``canonical_key_str`` falls back to ``str(value)`` for datetimes, which
    # reproduces 1.8.2 key bytes exactly. Default is False (canonicalization
    # ON) per the repo's default-on doctrine; the switch exists so an adopter
    # can roll readers forward to >= 1.9.0 *before* any key byte moves, then
    # run the migration, then lift it. Read from the environment at import so
    # it can be set without editing model code; assign it directly to override
    # at runtime. See migration cookbook recipe 19.
    DATETIME_KEY_LEGACY = _read_legacy_datetime_key_switch()

    # -- never-record firewall (privacy/never_record.py, #561) ----------------
    # Shortest whitespace token the entropy backstop will consider. Below 20
    # chars, ordinary base64-ish words (identifiers, hashes-of-hashes in
    # prose) dominate and the detector becomes noise rather than a backstop.
    NR_ENTROPY_MIN_TOKEN_LEN = 20
    # Shannon entropy in bits/char at or above which a credential-charset
    # token is treated as an unknown-prefix secret. Random base64 sits near
    # 5.5-6.0 and random hex near 4.0; English text over the same alphabet
    # sits well below 3.5. Not corpus-tuned -- deliberately conservative in
    # the over-blocking direction, per #561 ("over-blocking accepted").
    NR_ENTROPY_MIN_BITS = 3.5
    # Shortest value after ``password=``/``token:`` that counts as a secret.
    # Also the shortest URL-userinfo password.
    NR_ASSIGNMENT_MIN_VALUE_LEN = 6
    # Cap on the capped-LIST audit log of content-free drop tombstones. The
    # HASH counters are unbounded and authoritative; this list is a recent
    # window for eyeballing drop cadence.
    NR_TOMBSTONE_LOG_MAX = 1000
    # Deploy-level kill switch, not a tuning constant. False disables the
    # never-record firewall entirely. Default is enabled per the repo's
    # default-on doctrine; the switch exists because a PyPI adopter cannot
    # always edit model code to remove a mixin. Read from the environment at
    # import (``POPOTO_NEVER_RECORD_DISABLE``); assign directly to override
    # at runtime.
    NEVER_RECORD_ENABLED = _read_never_record_switch()

    # -- provenance journal (recipes/provenance_journal.py, #560) -------------
    # Deploy-level kill switch, not a tuning constant. When True (the default),
    # a ``supersede``/``retract`` annotation closes its target's validity
    # interval in the same MULTI/EXEC that appends the annotation, so the
    # target leaves ``validity__current`` membership immediately. When False,
    # the annotation entry is still appended and still carries its ``target``,
    # but the target's interval is left open: membership degrades to
    # "everything ever appended", which is pre-#560 behavior. The degraded mode
    # is observable without reading Redis --
    # ``AnnotationResult.target_closed`` is False and
    # ``AnnotationResult.coupling_enabled`` is False -- specifically so this
    # switch cannot reproduce #588's silent-no-op shape. Read from the
    # environment at import (``POPOTO_JOURNAL_COUPLING_DISABLE``) because a
    # PyPI adopter cannot always edit model code; assign directly to override
    # at runtime. Boolean, not swept.
    JOURNAL_VALIDITY_COUPLING_ENABLED = _read_journal_coupling_switch()
    # Core annotation-kind vocabulary for ``JournalEntry.kind``. Not a
    # tunable: changing it reclassifies already-stored entries. ``assert`` is
    # an original capture and carries no target; the other three are
    # annotations and each names exactly one target entry. Downstream modules
    # that need more kinds (M5 merge/equivalence, M7 queueing, M8 exposure)
    # extend via ``JournalEntry.register_kind(name, targetless=, closing=)``,
    # which adds to the vocabulary in place rather than editing this tuple.
    # (Registration rather than a model subclass because Popoto's ModelBase
    # metaclass does not inherit Field attributes, so a JournalEntry subclass
    # has an empty field set.) Reader rule: an entry whose ``kind`` a reader
    # does not recognize is inert for membership -- never silently treated as
    # ``supersede`` or ``retract``.
    JOURNAL_KINDS = ("assert", "confirm", "supersede", "retract")

    # -- auditable extraction (extraction/decision_log.py, #562) --------------
    # Lifetime of the assembly claim one runner takes on a candidate before
    # writing to the provenance journal, in milliseconds. A **liveness** bound,
    # not a correctness one: long enough that the common case never expires
    # mid-flight, finite so a crashed runner's claim cannot wedge a candidate
    # forever. Correctness under a claim that does expire mid-flight comes from
    # the identity probe on the ``cand:`` subject tag, which makes the residual
    # race converge on the existing entry instead of duplicating it. Pinned
    # in-repo rather than exposed on ``AuditableExtractionConfig``, per the
    # magic-number rule.
    M3_ASSEMBLY_CLAIM_TTL_MS = 30_000

    # -- belief-sheet view resolver (recipes/view_resolver.py, #565) ---------
    # Policy numerics for the M6 read path. Read directly from Defaults at
    # call time (per-resolve policy resolution), never via a module-level
    # alias: an import-time-bound alias would defeat runtime overrides and
    # per-call policy dicts. Registered as exemptions in
    # tests/benchmarks/test_defaults_sync.py, same shape as the validity and
    # journal switches above.
    # Per-record staleness cutoff: a claim whose decayed relevance is below
    # this is annotated stale. Mirrors DEFAULT_SURFACING_THRESHOLD (0.5).
    VIEW_RESOLVER_STALENESS_THRESHOLD = 0.5
    # Retrieval widening while the reader gate is active: the resolver asks
    # the assembler for max_items * this many candidates so pre-truncation
    # gating can back-fill from headroom instead of returning short.
    VIEW_RESOLVER_GATE_OVERFETCH_MULTIPLIER = 2
    # Capped extra pulls when gate rejection still leaves the sheet short:
    # at most this many re-retrievals excluding already-seen keys.
    VIEW_RESOLVER_MAX_BACKFILL_PULLS = 1

    # -- reference resolution (extraction/resolution.py, #563) --
    # Deploy-level kill switch, not a tuning constant. Default True per the
    # repo's default-on doctrine; a PyPI adopter who cannot edit model code
    # still needs a way to turn the stage off (e.g. no `anthropic` client
    # available). Read from the environment at import
    # (``POPOTO_M4_RESOLUTION_ENABLED``); assign directly to override at
    # runtime.
    M4_RESOLUTION_ENABLED = _read_m4_resolution_switch()
    # Window truncation bound 1 of 2, in turns. A turn count alone does not
    # bound prompt size, so it is paired with a char bound below; whichever
    # binds first truncates oldest-first (Decision 1).
    M4_WINDOW_MAX_TURNS = 8
    # Window truncation bound 2 of 2, in characters. A char bound alone can
    # slice a single turn in half, so it is paired with the turn-count bound
    # above (Decision 1).
    M4_WINDOW_MAX_CHARS = 4000
    # Cap on references returned per candidate. Re-validation rejects a reply
    # over this cap rather than truncating it, so a runaway model response
    # cannot silently balloon a single candidate's evidence.
    M4_MAX_REFERENCES_PER_CANDIDATE = 8
    # Lower bound on ``evidence_gap`` candidate referents. Below this there is
    # no genuine ambiguity to report -- a single candidate is a resolution,
    # not a gap.
    M4_EVIDENCE_GAP_MIN_CANDIDATES = 2
    # Upper bound on ``evidence_gap`` candidate referents. Above this the
    # model is listing possibilities rather than narrowing them, which is not
    # useful evidence for the one clarifying question the record carries.
    M4_EVIDENCE_GAP_MAX_CANDIDATES = 4
    # Max length of an ``assumed`` status's one-line assumption. Re-validation
    # enforces this (and no newlines) so the assumption stays a scannable
    # audit line, not free-form prose.
    M4_ASSUMPTION_MAX_CHARS = 200
    # Max length of an ``evidence_gap`` status's clarifying question, for the
    # same scannability reason as ``M4_ASSUMPTION_MAX_CHARS`` above.
    M4_QUESTION_MAX_CHARS = 200
    # Multiplicative term of the ``statement`` length bound relative to
    # ``verbatim`` (paired with ``M4_STATEMENT_MAX_GROWTH_CHARS`` below).
    # Re-validation enforces this so the model cannot turn a clause into a
    # paragraph of invention.
    M4_STATEMENT_MAX_GROWTH_FACTOR = 2.0
    # Additive term of the ``statement`` length bound relative to
    # ``verbatim``; see ``M4_STATEMENT_MAX_GROWTH_FACTOR`` above. The additive
    # term keeps very short verbatims from being bounded to near-zero growth.
    M4_STATEMENT_MAX_GROWTH_CHARS = 120
    # Temporal roles that emit ``valid_from`` (Decision 4). Onsets only, by
    # deliberate narrowing of the amendment that also named deadlines:
    # emitting a future deadline as ``valid_from`` would hide the obligation
    # from as-of retrieval until the deadline arrives, which is a data error.
    # A constant rather than a literal in the emission code so a maintainer
    # reversal ("deadlines also emit") is a one-tuple change, not a rewrite;
    # a parameterised test flips it to ("onset", "deadline") and asserts a
    # deadline reference then does emit.
    M4_VALID_FROM_ROLES = ("onset",)

    # -- reconciliation (recipes/reconciliation.py, #564) ---------------------
    # Upper bound on candidate classes the shortlist hands the judge for one
    # entry. Bounds judge-call cost per entry at 2x this number (one forward
    # ask plus one swapped-order symmetry probe per candidate), which is the
    # bound AC5 asserts. Also bounds the degraded same-subject+type index scan
    # used when no embedding provider is available.
    M5_SHORTLIST_CAP = 8
    # Whether a forward "same" verdict is re-asked with the claim order
    # swapped before a join commits. On by default: the probe converts the
    # worst failure mode (a silent mega-class built out of non-transitive
    # "same" verdicts) into the safe one (an explicit disjunct pair). Pinned
    # rather than exposed as a constructor kwarg per the magic-number rule;
    # flipping it off is a measurement action, not a deployment one.
    M5_SYMMETRY_PROBE_ENABLED = True
    # Pinned model for the one sameness-judge call, mirroring
    # ``extraction/verdict.py``'s ``VERDICT_MODEL``. A constrained two-value
    # enum classification, not open-ended generation, so the smaller model is
    # the right one.
    M5_JUDGE_MODEL = "claude-haiku-4-5-20251001"
    # Pinned max_tokens for the sameness-judge call. The reply is one enum
    # key, so this mirrors ``VERDICT_MAX_TOKENS`` rather than sizing for prose.
    M5_JUDGE_MAX_TOKENS = 256
    # Name of the ``JournalEntry`` field the replay watermark filters on with a
    # strict ``>`` (borrowing ``crystallize``'s watermark shape). A constant
    # rather than a literal so the field rename is one edit.
    M5_REPLAY_WATERMARK_FIELD = "captured_at"
    # Joins into one class, within one reconciler pass, past which a
    # class-size-velocity signal is logged. **Telemetry only, never a gate**
    # (Risk 1): a legitimately large class must not be blocked, so this
    # threshold reports and never refuses.
    MEGA_CLASS_VELOCITY_ALERT = 5

    # --- Question queue (recipes/question_queue.py, #566) ---
    # All counted in host-supplied turns: the library owns no turn counter.
    # The deploy-level kill switch is the call-time env reader
    # ``question_queue_enabled()`` above, deliberately not an attribute here.
    #
    # K: at most one question delivered per this many turns, per agent. The
    # token bucket's Lua grants only when ``turn >= last_ask_turn + K``, so the
    # ration is enforced by the data structure, not by callers remembering.
    QUESTION_BUDGET_TURNS = 5
    # N: a pending candidate not delivered within this many turns of its
    # proposal expires silently (status "expired", retained, never deleted
    # here — deletion is ``prune()``'s job).
    QUESTION_EXPIRY_TURNS = 20
    # The VOI gate's impact factor: a candidate is "recently used" when
    # ``turn - last_seen_turn`` is at most this. ``last_seen_turn`` is bumped
    # by a producer re-observing the ambiguity or by ``note_use()``.
    QUESTION_RECENT_USE_TURNS = 5
    # W: total confidence observations an ``answered`` reply writes per target
    # that declares a ConfidenceField — the one from the acted/contradicted
    # outcome applier plus W - 1 extra ``update_confidence`` calls. Stands in
    # for a weight parameter ``update_confidence`` does not have. Past
    # ``evidence_cap`` each call has gain 1/(cap+1), so a later contradiction
    # keeps its full gain: "W strong observations", never an overwrite.
    QUESTION_ANSWER_WEIGHT = 3
    # Non-pending candidates (answered, expired, cooled, delivered) older than
    # this many turns are deleted by ``prune()``. Also bounds how long an
    # answered candidate suppresses a duplicate proposal. Privacy-adjacent: a
    # question's text can echo sensitive content, so retention is bounded.
    QUESTION_RETENTION_TURNS = 500
    # Re-ask cooldown after a deflected or unrecognized reply: the candidate is
    # not deliverable again until ``turn >= cooldown_until``.
    QUESTION_COOLDOWN_TURNS = 10
    # Wall-clock TTL backstop on the per-agent token-bucket key. A host whose
    # turn counter reset after a restart is locked out (regressed turns never
    # grant and never rewind the bucket) for at most this long, never forever.
    QUESTION_BUCKET_TTL_SECONDS = 604800

    # -- Postgres backend (#759 M1b) -----------------------------------------
    # Pool size per (DSN, pid). One central database serves every agent, so
    # demand is processes x this and must stay under max_connections; past a
    # few dozen clients put PgBouncer (transaction mode) in front (plan §3).
    PG_POOL_MAX_SIZE = 4
    # Seconds to wait for a connection (connect, or a free pool slot) before
    # the call raises BackendUnavailableError.
    PG_CONNECT_TIMEOUT_SECONDS = 5.0
    # Per-transaction statement timeout, applied with SET LOCAL so it is
    # PgBouncer transaction-mode safe (no session state). 0 disables.
    PG_STATEMENT_TIMEOUT_MS = 30000
    # An outage is logged at ERROR once per this many seconds, however many
    # calls fail inside the window (the health record still counts each).
    PG_OUTAGE_LOG_WINDOW_SECONDS = 60.0
    # Deadlock / serialization-failure retries for a single autocommit
    # statement (inside transaction() these propagate to the caller).
    PG_TRANSACTION_RETRIES = 3

    # -- Postgres search (#759 M2b) --------------------------------------------
    # Magic numbers for the vector, BM25 and fusion arms on Postgres, pinned
    # in-repo for tuning (CLAUDE.md "Numeric constants"), not constructor
    # kwargs. Each is read when a search runs, so a test may patch it.
    #
    # The vector arm scans exactly (ORDER BY (v <=> q) + 0, which no HNSW
    # index can serve) while at most this many rows in scope hold a vector,
    # and uses the HNSW index above it (#758 D5). Spike-4 measured exact at
    # 2.6 ms p50 for ~1k rows and 37 ms for ~12k; 5,000 keeps the exact path
    # inside the 15 ms recall budget on a 1536-d corpus (re-pinned from the
    # M2b benchmark, PR body).
    PG_VECTOR_EXACT_MAX = 5000
    # hnsw.ef_search for the HNSW path, set with SET LOCAL beside
    # hnsw.iterative_scan = relaxed_order. Spike-4's 0.0-recall query happened
    # at 100 as well, which is why the recall guard exists: when the HNSW arm
    # returns fewer rows than min(limit, rows with a vector), it is re-run
    # exactly.
    PG_HNSW_EF_SEARCH = 100
    # How deep each recall() arm ranks before RRF fusion: max(limit, this).
    PG_RECALL_ARM_DEPTH = 50
    # Piggybacked embedding backfill (#758 D7): after a save whose own
    # embedding call succeeded, embed at most this many rows of the same scope
    # whose vector is NULL or was made by another model...
    PG_BACKFILL_BATCH = 4
    # ...and stop after this many wall-clock seconds, whichever comes first. A
    # provider call that outlives the budget is abandoned, so a slow provider
    # cannot push save() past it.
    PG_BACKFILL_BUDGET_SECONDS = 1.0

    # -- Postgres TTL (#759 M5) ------------------------------------------------
    # The automatic reaper (#755 Q3: no cron, no manual job). After a record
    # write on a Meta.ttl model commits, it deletes at most this many expired
    # rows of that table (their side rows cascade) in a transaction of its
    # own, in the caller's thread, like the embedding backfill. Measured (M1
    # Max, PostgreSQL 18.6, localhost): a save that reaps 20 rows costs
    # ~1.1 ms p50 against ~0.37 ms for one with nothing due; 100 rows cost
    # ~2.2-3.5 ms. A save creates at most one expiring row, so 20 per write
    # drains twenty times faster than expiries can accrue.
    PG_REAPER_BATCH = 20
    # ...at most once per this many seconds per table and process, unless the
    # last run deleted a full batch (a backlog), when the next write reaps
    # again. Reads never wait for it: an expired row is invisible to every
    # read from the instant it expires, reaped or not.
    PG_REAPER_INTERVAL_SECONDS = 1.0
    # The reaper never waits behind a caller: record-key locks are try-locks
    # (a contended record is skipped), rows are locked SKIP LOCKED, and any
    # other lock it would wait on longer than this aborts its run -- shorter
    # than deadlock_timeout (1 s), so the reaper, never a caller, is the one
    # that gives up. A free pool connection is waited on for at most this
    # long too; a busy pool skips the run.
    PG_REAPER_LOCK_TIMEOUT_MS = 50

    # -- Postgres graph (#759 M4) ----------------------------------------------
    # CoOccurrenceField.propagate() on Postgres answers with one WITH RECURSIVE
    # statement while it expands at most this many BFS layers (ceil(depth)),
    # and otherwise one visited-pruned statement per layer. The recursive
    # statement cannot see earlier layers, so it re-expands every reached node
    # on every layer; up to two layers that is exactly the pruned work (layer
    # one expands the seeds, layer two every first arrival), past it the work
    # grows with depth x fan-out where PROPAGATE_BFS_LUA's visited map stops
    # (#781 review: a 400-node clique at depth 50 took 24.9 s against Redis's
    # 0.10 s). Lowering it to 0 sends every call down the pruned path.
    PG_GRAPH_RECURSIVE_MAX_LAYERS = 2

TemporalPeriod

Named constants for common temporal cycle periods in seconds.

These values represent the approximate duration of each period

DAILY = 86,400 seconds (24 hours) WEEKLY = 604,800 seconds (7 days) MONTHLY = 2,592,000 seconds (30 days) QUARTERLY = 7,776,000 seconds (90 days) YEARLY = 31,536,000 seconds (365 days)

Source code in src/popoto/fields/constants.py
class TemporalPeriod:
    """Named constants for common temporal cycle periods in seconds.

    These values represent the approximate duration of each period:
        DAILY     = 86,400 seconds (24 hours)
        WEEKLY    = 604,800 seconds (7 days)
        MONTHLY   = 2,592,000 seconds (30 days)
        QUARTERLY = 7,776,000 seconds (90 days)
        YEARLY    = 31,536,000 seconds (365 days)
    """

    DAILY = 86_400
    WEEKLY = 604_800
    MONTHLY = 2_592_000
    QUARTERLY = 7_776_000
    YEARLY = 31_536_000

InteractionWeight

Weight constants for source/role-based importance scoring.

Two axes combined by addition: - Source axis: what kind of entity (HUMAN, AGENT, SYSTEM) - Role axis: authority level (EXECUTIVE, MANAGER, PEER, SUBORDINATE)

With decay_rate=0.5, effective lifetime ~ score^2 days.

Source code in src/popoto/fields/constants.py
class InteractionWeight:
    """Weight constants for source/role-based importance scoring.

    Two axes combined by addition:
    - Source axis: what kind of entity (HUMAN, AGENT, SYSTEM)
    - Role axis: authority level (EXECUTIVE, MANAGER, PEER, SUBORDINATE)

    With decay_rate=0.5, effective lifetime ~ score^2 days.
    """

    HUMAN = 6.0
    AGENT = 1.0
    SYSTEM = 0.2

    EXECUTIVE = 44.0
    MANAGER = 16.0
    PEER = 6.0
    SUBORDINATE = 1.0

    @staticmethod
    def combine(source: float, role: float) -> float:
        """Return the combined weight for a source/role pair.

        The two axes are additive: a HUMAN (6.0) PEER (6.0) interaction
        yields weight 12.0, while an AGENT (1.0) SUBORDINATE (1.0)
        interaction yields weight 2.0.
        """
        return source + role

combine(source, role) staticmethod

Return the combined weight for a source/role pair.

The two axes are additive: a HUMAN (6.0) PEER (6.0) interaction yields weight 12.0, while an AGENT (1.0) SUBORDINATE (1.0) interaction yields weight 2.0.

Source code in src/popoto/fields/constants.py
@staticmethod
def combine(source: float, role: float) -> float:
    """Return the combined weight for a source/role pair.

    The two axes are additive: a HUMAN (6.0) PEER (6.0) interaction
    yields weight 12.0, while an AGENT (1.0) SUBORDINATE (1.0)
    interaction yields weight 2.0.
    """
    return source + role

question_queue_enabled()

Read POPOTO_QUESTION_QUEUE_DISABLE from the environment (#566).

Returns True when the M7 question queue is ENABLED — i.e. when producers may write QuestionCandidate records and next_question() evaluates the value-of-information gate. The env var is phrased as a disable so the default-on doctrine holds when it is unset: a PyPI adopter who cannot edit model code still gets a deploy-level escape hatch.

What defaults ON is the queue, not delivery: the module's contract ends at next_question(), and whether a question ever reaches a person is the host application's call.

This is a call-time function and deliberately not a Defaults class attribute (there is no Defaults.QUESTION_QUEUE_ENABLED), for the same reason :func:_read_decode_quarantine_switch documents: the class body is evaluated at import, which would bind the value once and make a deploy-time flip (or a monkeypatch.setenv) a no-op.

Source code in src/popoto/fields/constants.py
def question_queue_enabled() -> bool:
    """Read ``POPOTO_QUESTION_QUEUE_DISABLE`` from the environment (#566).

    Returns True when the M7 question queue is ENABLED — i.e. when producers
    may write ``QuestionCandidate`` records and ``next_question()`` evaluates
    the value-of-information gate. The env var is phrased as a *disable* so
    the default-on doctrine holds when it is unset: a PyPI adopter who cannot
    edit model code still gets a deploy-level escape hatch.

    What defaults ON is the *queue*, not delivery: the module's contract ends
    at ``next_question()``, and whether a question ever reaches a person is
    the host application's call.

    This is a call-time function and deliberately **not** a ``Defaults`` class
    attribute (there is no ``Defaults.QUESTION_QUEUE_ENABLED``), for the same
    reason :func:`_read_decode_quarantine_switch` documents: the class body is
    evaluated at import, which would bind the value once and make a
    deploy-time flip (or a ``monkeypatch.setenv``) a no-op.
    """
    value = os.environ.get("POPOTO_QUESTION_QUEUE_DISABLE", "").strip().lower()
    return value not in _TRUTHY