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