cctally 1.114.0 → 1.115.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.
Files changed (64) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/bin/_cctally_account.py +32 -8
  3. package/bin/_cctally_cache.py +4394 -279
  4. package/bin/_cctally_completion.py +1287 -0
  5. package/bin/_cctally_config.py +37 -0
  6. package/bin/_cctally_core.py +178 -2
  7. package/bin/_cctally_dashboard.py +2 -2
  8. package/bin/_cctally_dashboard_conversation.py +292 -23
  9. package/bin/_cctally_dashboard_envelope.py +16 -3
  10. package/bin/_cctally_dashboard_sources.py +18 -9
  11. package/bin/_cctally_db.py +316 -5
  12. package/bin/_cctally_doctor.py +436 -27
  13. package/bin/_cctally_five_hour.py +82 -31
  14. package/bin/_cctally_forecast.py +81 -9
  15. package/bin/_cctally_journal.py +3884 -112
  16. package/bin/_cctally_journal_repair.py +101 -12
  17. package/bin/_cctally_parser.py +42 -0
  18. package/bin/_cctally_project.py +8 -2
  19. package/bin/_cctally_quarantine.py +652 -0
  20. package/bin/_cctally_quota.py +26 -4
  21. package/bin/_cctally_quota_model.py +360 -83
  22. package/bin/_cctally_record.py +677 -149
  23. package/bin/_cctally_rederive.py +297 -32
  24. package/bin/_cctally_refresh.py +84 -13
  25. package/bin/_cctally_source_analytics.py +23 -2
  26. package/bin/_cctally_statusline.py +6137 -517
  27. package/bin/_cctally_store.py +287 -48
  28. package/bin/_cctally_transcript.py +8 -7
  29. package/bin/_cctally_tui.py +57 -53
  30. package/bin/_lib_account_binding.py +152 -0
  31. package/bin/_lib_account_protocol.py +386 -0
  32. package/bin/_lib_aggregators.py +4 -0
  33. package/bin/_lib_cache_coverage.py +13 -1
  34. package/bin/_lib_cache_writer_lock.py +19 -3
  35. package/bin/_lib_clone_copy.py +142 -0
  36. package/bin/_lib_codex_conversation_query.py +133 -19
  37. package/bin/_lib_codex_conversation_watch.py +59 -14
  38. package/bin/_lib_codex_drain_admission.py +214 -0
  39. package/bin/_lib_codex_metadata.py +18 -1
  40. package/bin/_lib_codex_publication_barrier.py +277 -0
  41. package/bin/_lib_codex_rollout_container.py +502 -0
  42. package/bin/_lib_conversation_dispatch.py +3 -0
  43. package/bin/_lib_conversation_query.py +11 -3
  44. package/bin/_lib_detector_access_registry.py +3817 -0
  45. package/bin/_lib_doctor.py +452 -1
  46. package/bin/_lib_five_hour.py +126 -0
  47. package/bin/_lib_ingest_frontier.py +414 -20
  48. package/bin/_lib_journal.py +141 -10
  49. package/bin/_lib_jsonl.py +5 -4
  50. package/bin/_lib_legacy_spool.py +126 -0
  51. package/bin/_lib_legacy_writer_evidence.py +136 -0
  52. package/bin/_lib_merge_gate.py +29 -302
  53. package/bin/_lib_meter_rate_change.py +10 -0
  54. package/bin/_lib_outcome_quarantine.py +690 -0
  55. package/bin/_lib_pricing.py +7 -2
  56. package/bin/_lib_quarantine.py +154 -0
  57. package/bin/_lib_quota_calibration.py +53 -8
  58. package/bin/_lib_quota_copy.py +2 -1
  59. package/bin/_lib_quota_model.py +288 -46
  60. package/bin/_lib_rederive.py +94 -5
  61. package/bin/_lib_segment_summary.py +35 -2
  62. package/bin/_lib_statusline_candidates.py +292 -23
  63. package/bin/cctally +23 -1
  64. package/package.json +14 -1
@@ -0,0 +1,277 @@
1
+ """Pure decisions of the #968 conversation publication barrier (spec 5.11).
2
+
3
+ No I/O and no SQLite. `_cctally_cache` owns the durable cache.db state (the
4
+ membership index, readiness targets, population, certificates and repair
5
+ queue) and the conversation pass owns the publication records; both call these
6
+ functions with plain values, so the readiness rule, the thread-metadata rule
7
+ and the certificate format are stated exactly once.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from typing import Iterable, NamedTuple, Optional
13
+
14
+ CERTIFICATE_VERSION = 1
15
+
16
+ READY = "ready"
17
+ REQUIRED = "required"
18
+ SUSPENDED = "suspended"
19
+
20
+ #: The pending hold (5.11 U6 table, first row): the candidate stays a
21
+ #: dependency of the next revision; pending work is never an exclusion.
22
+ PENDING_HOLD = "verification-pending"
23
+ #: Holds that suspend the contributor's current-generation readiness
24
+ #: obligation (U6 table, second row). Retained accepted membership is kept;
25
+ #: "excluded" never means deleting rows or forgetting membership.
26
+ #: `container-absent` is an accounted identity whose container is gone (an
27
+ #: orphan awaiting the off-hook prune, or one #485 refuses to prune).
28
+ SUSPENSION_HOLDS = frozenset({
29
+ "decoder-unavailable", "decode-error", "verification-unverified",
30
+ "container-absent",
31
+ })
32
+
33
+ #: The twelve source columns of a `codex_conversation_threads` row, in column
34
+ #: order. The two timestamp columns are bookkeeping, carried separately.
35
+ THREAD_FIELDS = (
36
+ "conversation_key", "source_root_key", "native_thread_id",
37
+ "root_thread_id", "parent_thread_id", "source_path", "cwd", "git_json",
38
+ "source_kind", "thread_source_json", "model_provider", "context_window",
39
+ )
40
+
41
+ #: The generation token of a membership row that records a key a suspended
42
+ #: contributor of unknown membership (no watermark) is KNOWN to feed, from its
43
+ #: retained rows (review N2). Never equal to a real generation token, so it
44
+ #: never feeds a READY contributor; it is such an exclusion's effective
45
+ #: generation, and the row carries the key's retained thread as metadata when
46
+ #: that row names this contributor (5.11 U6 "using retained accepted facts
47
+ #: where they exist"). The rows are deleted when the membership is proven.
48
+ RETAINED_GENERATION = "retained"
49
+
50
+
51
+ class Target(NamedTuple):
52
+ """One contributor's readiness target (a `codex_barrier_targets` row)."""
53
+ identity: str
54
+ source_path: str
55
+ generation: str
56
+ #: Offset after the last newline-terminated line of the captured target,
57
+ #: set once accounting reached that target; None while it has not.
58
+ complete_line_end: Optional[int]
59
+ hold: Optional[str]
60
+ #: Generation and decoded offset through which the production parser has
61
+ #: recorded this identity's membership from offset 0.
62
+ watermark_generation: Optional[str]
63
+ watermark_offset: Optional[int]
64
+
65
+
66
+ class TargetVerdict(NamedTuple):
67
+ state: str
68
+ reason: Optional[str]
69
+
70
+
71
+ def classify_target(target: Target) -> TargetVerdict:
72
+ """Readiness of one contributor (5.11 "The readiness target", U6).
73
+
74
+ A watermark of another generation proves nothing, whatever its offset
75
+ (968-SR-012: a G0 cursor never satisfies G1's target). A suspension is
76
+ SUSPENDED whether or not its membership was ever proven (U6: "degraded
77
+ contributors degrade; they do not freeze", review N2): one of unknown
78
+ membership is an exclusion that blocks no key, and its keys keep the
79
+ thread its retained facts supply (``decide_seal``).
80
+ """
81
+ if target.hold in SUSPENSION_HOLDS:
82
+ return TargetVerdict(SUSPENDED, target.hold)
83
+ if target.hold == PENDING_HOLD:
84
+ return TargetVerdict(REQUIRED, PENDING_HOLD)
85
+ if target.hold is not None:
86
+ raise ValueError(f"unknown barrier hold {target.hold!r}")
87
+ if (target.watermark_generation != target.generation
88
+ or target.watermark_offset is None):
89
+ return TargetVerdict(REQUIRED, "membership-unproven")
90
+ if target.complete_line_end is None:
91
+ return TargetVerdict(REQUIRED, "accounting-incomplete")
92
+ if target.watermark_offset < target.complete_line_end:
93
+ return TargetVerdict(REQUIRED, "membership-unproven")
94
+ return TargetVerdict(READY, None)
95
+
96
+
97
+ class MemberFact(NamedTuple):
98
+ """One `codex_barrier_membership` row."""
99
+ identity: str
100
+ generation: str
101
+ source_path: str
102
+ metadata_json: Optional[str]
103
+ metadata_offset: Optional[int]
104
+
105
+
106
+ def metadata_from_thread(thread) -> str:
107
+ """The generation-bound metadata one `session_meta` supplies (JSON).
108
+
109
+ ``ensure_ascii`` escapes a lone surrogate (#845's undecodable cwd), and
110
+ ``json.loads`` restores the identical string.
111
+ """
112
+ return json.dumps({name: getattr(thread, name) for name in THREAD_FIELDS},
113
+ sort_keys=True, separators=(",", ":"), ensure_ascii=True)
114
+
115
+
116
+ def metadata_from_row(row: dict) -> str:
117
+ """``metadata_from_thread``'s JSON for a retained thread row (a dict)."""
118
+ return json.dumps({name: row.get(name) for name in THREAD_FIELDS},
119
+ sort_keys=True, separators=(",", ":"), ensure_ascii=True)
120
+
121
+
122
+ def select_thread(candidates: Iterable[MemberFact], *,
123
+ retained: Optional[dict] = None) -> Optional[dict]:
124
+ """The conversation's thread metadata.
125
+
126
+ Review P2-2: when the retained thread row (the entry snapshot, else the
127
+ current accounting row) equals the metadata some candidate supplies, it
128
+ is kept, so certifying a legacy conversation changes nothing visible.
129
+ Measured on the frozen 2026-10-14 baseline: of 243 multi-file keys the
130
+ stored row equals the maximum-path choice for 236 and another
131
+ contributor's metadata for 7. Otherwise (a new publication, or a retained
132
+ row no contributor supplies) the rule is what a complete walk writes
133
+ LAST: a full walk visits a root's rollouts in logical-path order and a
134
+ file's records in offset order, so the last writer is the maximum of
135
+ ``(source_path, metadata_offset)``, independent of the order in which
136
+ healing reads the files.
137
+ """
138
+ facts = [f for f in candidates
139
+ if f.metadata_json is not None and f.metadata_offset is not None]
140
+ if retained is not None:
141
+ want = {name: retained.get(name) for name in THREAD_FIELDS}
142
+ for fact in facts:
143
+ meta = json.loads(fact.metadata_json)
144
+ if {name: meta.get(name) for name in THREAD_FIELDS} == want:
145
+ return meta
146
+ best = None
147
+ for fact in facts:
148
+ rank = (fact.source_path, int(fact.metadata_offset))
149
+ if best is None or rank > best[0]:
150
+ best = (rank, fact.metadata_json)
151
+ return None if best is None else json.loads(best[1])
152
+
153
+
154
+ class SealDecision(NamedTuple):
155
+ sealable: bool
156
+ blocking: tuple
157
+ certificate: Optional[dict]
158
+
159
+
160
+ def decide_seal(conversation_key: str, revision: int,
161
+ targets: Iterable[Target], members: Iterable[MemberFact], *,
162
+ timestamps: "tuple[str | None, str | None] | None" = None,
163
+ retained_thread: Optional[dict] = None,
164
+ ) -> SealDecision:
165
+ """Seal a certificate for one conversation, or name what blocks it.
166
+
167
+ ``targets`` holds every identity with any membership row for the key
168
+ (``RETAINED_GENERATION`` rows included); ``members`` those rows. A
169
+ REQUIRED contributor blocks (the transient pending hold included); a
170
+ SUSPENDED one is an explicit exclusion whose retained accepted facts
171
+ still supply metadata (U6): its watermark generation's rows when its
172
+ membership was proven, else its ``RETAINED_GENERATION`` rows (review N2).
173
+ ``retained_thread`` is the key's retained thread row: preferred when a
174
+ candidate supplies it (P2-2), and kept when an exclusion leaves no
175
+ metadata, so a seal never retracts a published thread to None because of
176
+ an excluded contributor (P1-1, N2).
177
+ """
178
+ by_identity = {t.identity: t for t in targets}
179
+ verdicts = {i: classify_target(t) for i, t in by_identity.items()}
180
+ blocking = tuple(sorted((i, v.reason) for i, v in verdicts.items()
181
+ if v.state == REQUIRED))
182
+ if blocking:
183
+ return SealDecision(False, blocking, None)
184
+ facts = list(members)
185
+ contributors, exclusions, candidates = [], [], []
186
+ for identity in sorted(by_identity):
187
+ target, verdict = by_identity[identity], verdicts[identity]
188
+ if verdict.state == READY:
189
+ contributors.append({
190
+ "identity": identity, "path": target.source_path,
191
+ "generation": target.generation,
192
+ "completeLineEnd": int(target.complete_line_end)})
193
+ effective = target.generation
194
+ else:
195
+ exclusions.append({
196
+ "identity": identity, "path": target.source_path,
197
+ "reason": verdict.reason,
198
+ "acceptedGeneration": target.watermark_generation})
199
+ effective = (target.watermark_generation
200
+ if target.watermark_generation is not None
201
+ else RETAINED_GENERATION)
202
+ candidates.extend(f for f in facts if f.identity == identity
203
+ and f.generation == effective)
204
+ thread = select_thread(candidates, retained=retained_thread)
205
+ if thread is None and retained_thread is not None and exclusions:
206
+ # U6 / reviews P1-1, N2: an unreadable contributor never changes an
207
+ # existing publication on its own, and it never freezes the key.
208
+ # When no readable contributor and no retained fact supplies a
209
+ # thread, the retained one is kept. Thread None is sealed only when
210
+ # every contributor is readable and none supplies metadata, or when
211
+ # there is no retained thread to retract.
212
+ thread = {name: retained_thread.get(name) for name in THREAD_FIELDS}
213
+ if thread is not None and timestamps is not None:
214
+ thread = dict(thread, firstSeenUtc=timestamps[0],
215
+ lastSeenUtc=timestamps[1])
216
+ return SealDecision(True, (), {
217
+ "version": CERTIFICATE_VERSION,
218
+ "conversationKey": conversation_key,
219
+ "revision": int(revision),
220
+ "contributors": contributors,
221
+ "exclusions": exclusions,
222
+ "thread": thread,
223
+ })
224
+
225
+
226
+ def certificate_json(cert: dict) -> str:
227
+ return json.dumps(cert, sort_keys=True, separators=(",", ":"),
228
+ ensure_ascii=True)
229
+
230
+
231
+ def parse_certificate(text) -> Optional[dict]:
232
+ try:
233
+ cert = json.loads(text)
234
+ except (TypeError, ValueError):
235
+ return None
236
+ if not isinstance(cert, dict) or cert.get("version") != CERTIFICATE_VERSION:
237
+ return None
238
+ return cert
239
+
240
+
241
+ def certificate_covers(cert: dict, source_path: str,
242
+ generation: str) -> Optional[int]:
243
+ """The certified complete-line end for this file at this generation."""
244
+ for contributor in cert.get("contributors", ()):
245
+ if (contributor.get("path") == source_path
246
+ and contributor.get("generation") == generation):
247
+ return int(contributor["completeLineEnd"])
248
+ return None
249
+
250
+
251
+ def certificate_excludes(cert: dict, source_path: str) -> bool:
252
+ return any(e.get("path") == source_path for e in cert.get("exclusions", ()))
253
+
254
+
255
+ def publication_advances(published_revision, candidate_revision) -> bool:
256
+ """Monotonic publication: only a strictly newer revision may publish."""
257
+ return int(candidate_revision) > int(published_revision or 0)
258
+
259
+
260
+ #: cache.db ``cache_meta``: a fresh token written by every seal batch that
261
+ #: sealed anything (a token, not a counter: a deleted cache.db must never
262
+ #: repeat a value a conversations store has already acknowledged).
263
+ SEAL_SEQ_KEY = "codex_barrier_seal_seq"
264
+ #: conversations.db ``cache_meta``: the seal token whose certificates a
265
+ #: complete publication phase has considered.
266
+ PUBLISHED_SEQ_KEY = "codex_publication_seal_seq"
267
+ #: The TEMP view every conversations connection carries (5.11 "Reader
268
+ #: admission: last-good"); ``codex_conversation_threads`` shadows to it.
269
+ LAST_GOOD_THREADS_VIEW = "codex_last_good_threads"
270
+ #: Certificates newer than their publication, or never published. Run on a
271
+ #: conversations connection with cache.db attached as ``cache_db``.
272
+ BACKLOG_CERTIFICATES_SQL = (
273
+ "SELECT c.conversation_key, c.revision, c.certificate_json "
274
+ "FROM cache_db.codex_barrier_certificates c "
275
+ "LEFT JOIN main.codex_conversation_publications p "
276
+ "ON p.conversation_key = c.conversation_key "
277
+ "WHERE p.conversation_key IS NULL OR c.revision > p.published_revision")