cctally 1.92.2 → 1.93.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 (36) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/bin/_cctally_cache.py +354 -0
  3. package/bin/_cctally_core.py +180 -3
  4. package/bin/_cctally_dashboard.py +71 -1
  5. package/bin/_cctally_dashboard_envelope.py +28 -2
  6. package/bin/_cctally_dashboard_share.py +75 -19
  7. package/bin/_cctally_dashboard_sources.py +12 -0
  8. package/bin/_cctally_db.py +89 -1
  9. package/bin/_cctally_doctor.py +31 -0
  10. package/bin/_cctally_forecast.py +4 -2
  11. package/bin/_cctally_journal.py +3482 -258
  12. package/bin/_cctally_journal_repair.py +123 -32
  13. package/bin/_cctally_milestone_history.py +4 -1
  14. package/bin/_cctally_project.py +8 -6
  15. package/bin/_cctally_quota.py +420 -20
  16. package/bin/_cctally_rederive.py +57 -23
  17. package/bin/_cctally_reporting.py +8 -6
  18. package/bin/_cctally_share.py +74 -37
  19. package/bin/_cctally_source_analytics.py +6 -8
  20. package/bin/_cctally_store.py +13 -2
  21. package/bin/_cctally_tui.py +53 -0
  22. package/bin/_lib_cache_coverage.py +547 -0
  23. package/bin/_lib_doctor.py +54 -2
  24. package/bin/_lib_journal.py +235 -95
  25. package/bin/_lib_journal_router.py +21 -0
  26. package/bin/_lib_segment_summary.py +374 -0
  27. package/bin/_lib_selector_state.py +959 -0
  28. package/bin/_lib_share.py +1073 -165
  29. package/bin/_lib_share_templates.py +35 -11
  30. package/bin/_lib_stats_wal.py +327 -0
  31. package/bin/_lib_view_models.py +2 -1
  32. package/dashboard/static/assets/index-DwWJOYxd.css +1 -0
  33. package/dashboard/static/assets/{index-Dat-mza6.js → index-HlIK7k8Q.js} +47 -47
  34. package/dashboard/static/dashboard.html +2 -2
  35. package/package.json +5 -1
  36. package/dashboard/static/assets/index-DnWdv8um.css +0 -1
@@ -242,6 +242,46 @@ def bootstrap_id(table: str, rowid: int) -> str:
242
242
  return f"b:{table}:{rowid}"
243
243
 
244
244
 
245
+ def reusable_bootstrap_name(candidate_digest, candidate_size, existing):
246
+ """The already-published bootstrap segment a cutover may reuse verbatim.
247
+
248
+ `existing` is `(name, byte_length_or_None, sha256_hex_or_None)` for EVERY
249
+ published segment the caller found. A `None` digest means the caller did not
250
+ read that segment, which it does only when the length already differs; a
251
+ `None` length means it could not stat the file at all. Neither can match, and
252
+ reporting the segment anyway is required — see the ordering rule below.
253
+
254
+ Reuse requires the CANONICALLY NEWEST bootstrap to be the exact match, on
255
+ both length and digest. A crash-after-rename retry re-exports byte-identical
256
+ lines, so reusing that orphan makes the retry idempotent on disk instead of
257
+ only idempotent on fold (#496 S5 §3), and timestamps increase monotonically,
258
+ so the orphan an immediately-prior attempt left IS the newest bootstrap.
259
+
260
+ Reusing an older match instead would stamp the cursor behind a bootstrap the
261
+ cursor does not cover, and the next ingest would fold that stale bootstrap's
262
+ records into stats.db. Writing a fresh segment is the pre-reuse behaviour and
263
+ restores the pre-reuse invariant, because a minted name always sorts last.
264
+
265
+ Returns None when the newest bootstrap does not match, which covers the
266
+ ordinary first-cutover path, the genuinely-differing-export path, and the
267
+ stale-match path alike.
268
+ """
269
+ bootstraps = [
270
+ (name, size, digest)
271
+ for name, size, digest in existing
272
+ if name.startswith(BOOTSTRAP_PREFIX)
273
+ ]
274
+ if not bootstraps:
275
+ return None
276
+ name, size, digest = max(
277
+ bootstraps, key=lambda entry: segment_sort_key(entry[0]))
278
+ if size is None or digest is None:
279
+ return None
280
+ if size == candidate_size and digest == candidate_digest:
281
+ return name
282
+ return None
283
+
284
+
245
285
  def evt_id(kind: str, *parts: object) -> str:
246
286
  """Natural-key id for an evt line: ``"<kind>:" + ":".join(str(p) …)``.
247
287
 
@@ -708,113 +748,167 @@ def _is_legacy_quota_arming_state(candidate: EffectiveEvent) -> bool:
708
748
  )
709
749
 
710
750
 
711
- def resolve_effective_events(
712
- records,
713
- *,
714
- protocol_prefix_evidence=(),
715
- ) -> EffectiveSelection:
716
- """Validate correction batches and select one highest revision per evt id.
751
+ class SelectorFold:
752
+ """The six accumulators `resolve_effective_events` builds over the stream.
717
753
 
718
- Three failure classes, deliberately asymmetric (#374 §5, #402 Task A):
754
+ Named and made seedable for #496 S5b: durable selector state reproduces
755
+ exactly these six, and an incremental pass has to continue the same fold
756
+ from them rather than re-running it over the whole journal prefix. Keeping
757
+ ONE implementation is the point — a second copy of the taint rules in the
758
+ incremental path would be a second thing to keep correct.
719
759
 
720
- - Divergent same-revision EVENTS are **quarantined**: the lowest-sequence
721
- candidate becomes the provisional winner and the group is reported on
722
- `EffectiveSelection.conflicts`. The journal is append-only, so a divergent
723
- line can never be un-written; raising here wedged every rebuild forever.
724
- - The seven enumerated **structural** correction-batch violations taint their
725
- entire batch. No action from it is a candidate; the selector continues
726
- with distinct valid batches and reports a bounded `ProtocolViolation`.
727
- - Invalid marker/action field shapes and every other out-of-scope
728
- `JournalProtocolError` remain fatal. Unknown record types remain ignored.
760
+ `completed` is filled by :func:`resolve_batches`, not by the record fold.
729
761
  """
730
- candidates: list[EffectiveEvent] = []
731
- markers: dict[str, dict[str, tuple[dict, str, str, int]]] = {}
732
- actions: dict[str, dict[int, tuple[dict, str, int]]] = {}
733
- resolutions: list[tuple[int, dict]] = []
734
- tainted_batches: set[str] = set()
735
- violations: dict[tuple[str, str, str], ProtocolViolation] = {}
736
- violation_available_after: dict[str, int] = {}
737
-
738
- def taint(violation: ProtocolViolation, *, available_after: int) -> None:
762
+
763
+ __slots__ = (
764
+ "candidates",
765
+ "markers",
766
+ "actions",
767
+ "resolutions",
768
+ "tainted_batches",
769
+ "violations",
770
+ "violation_available_after",
771
+ "completed",
772
+ )
773
+
774
+ def __init__(self) -> None:
775
+ #: one entry per evt record and per action of a completed batch
776
+ self.candidates: list[EffectiveEvent] = []
777
+ #: batch id -> phase -> (normalized core, record digest, identity
778
+ #: digest, sequence)
779
+ self.markers: dict[str, dict[str, tuple[dict, str, str, int]]] = {}
780
+ #: batch id -> action seq -> (normalized record, record digest, sequence)
781
+ self.actions: dict[str, dict[int, tuple[dict, str, int]]] = {}
782
+ self.resolutions: list[tuple[int, dict]] = []
783
+ self.tainted_batches: set[str] = set()
784
+ self.violations: dict[tuple[str, str, str], ProtocolViolation] = {}
785
+ self.violation_available_after: dict[str, int] = {}
786
+ self.completed: set[str] = set()
787
+
788
+ def taint(self, violation: ProtocolViolation, *, available_after: int) -> None:
739
789
  """Taint one batch and retain every distinct violation identity."""
740
- tainted_batches.add(violation.batch_id)
741
- violations[
790
+ self.tainted_batches.add(violation.batch_id)
791
+ self.violations[
742
792
  (violation.batch_id, violation.kind, violation.fingerprint)
743
793
  ] = violation
744
- violation_available_after[violation.fingerprint] = min(
794
+ self.violation_available_after[violation.fingerprint] = min(
745
795
  available_after,
746
- violation_available_after.get(
796
+ self.violation_available_after.get(
747
797
  violation.fingerprint,
748
798
  available_after,
749
799
  ),
750
800
  )
751
801
 
752
- for sequence, record in enumerate(records):
753
- if not isinstance(record, dict):
754
- continue
755
- record_type = record.get("t")
756
- if record_type == "evt":
757
- candidates.append(_candidate_from_evt(record, sequence))
758
- continue
759
- if (
760
- record_type == "op"
761
- and isinstance(record.get("payload"), dict)
762
- and record["payload"].get("kind") == _PROTOCOL_RESOLUTION_KIND
763
- ):
764
- resolutions.append(
765
- (sequence, _validate_protocol_resolution(record))
802
+
803
+ def fold_records(fold: SelectorFold, records, *, start_sequence: int = 0) -> int:
804
+ """Phase 1 — accumulate one record stream into ``fold``.
805
+
806
+ ``start_sequence`` is the sequence number the FIRST element takes. It is
807
+ non-zero only on an incremental pass continuing a durable prefix, and the
808
+ numbering is load-bearing: three of the seven structural violation kinds put
809
+ that number inside `ProtocolViolation.evidence`, which the fingerprint
810
+ hashes, and that fingerprint is stored durably and referenced by name from a
811
+ `journal_protocol_resolution` op.
812
+
813
+ Returns the sequence the NEXT stream must start at. Every element consumes
814
+ one sequence number, including a non-dict element, because the rebuild
815
+ appends an explicit placeholder for every valid decoded non-retained record.
816
+ """
817
+ sequence = start_sequence
818
+ for record in records:
819
+ _fold_one(fold, record, sequence)
820
+ sequence += 1
821
+ return sequence
822
+
823
+
824
+ def _fold_one(fold: SelectorFold, record, sequence: int) -> None:
825
+ candidates = fold.candidates
826
+ markers = fold.markers
827
+ actions = fold.actions
828
+ resolutions = fold.resolutions
829
+ taint = fold.taint
830
+ if not isinstance(record, dict):
831
+ return
832
+ record_type = record.get("t")
833
+ if record_type == "evt":
834
+ candidates.append(_candidate_from_evt(record, sequence))
835
+ return
836
+ if (
837
+ record_type == "op"
838
+ and isinstance(record.get("payload"), dict)
839
+ and record["payload"].get("kind") == _PROTOCOL_RESOLUTION_KIND
840
+ ):
841
+ resolutions.append(
842
+ (sequence, _validate_protocol_resolution(record))
843
+ )
844
+ return
845
+ if record_type == "correction_batch":
846
+ normalized = _validate_batch_marker(record)
847
+ batch_id = normalized["id"]
848
+ phase = record["phase"]
849
+ digest = _sha256_canonical(record)
850
+ marker_identity = dict(record)
851
+ marker_identity.pop("phase", None)
852
+ identity_digest = _sha256_canonical(marker_identity)
853
+ prior = markers.setdefault(batch_id, {}).get(phase)
854
+ if prior is not None and prior[1] != digest:
855
+ taint(
856
+ _protocol_violation(
857
+ batch_id,
858
+ "marker_conflict",
859
+ phase=phase,
860
+ firstRecordHash=prior[1],
861
+ conflictingRecordHash=digest,
862
+ ),
863
+ available_after=max(prior[3], sequence),
766
864
  )
767
- continue
768
- if record_type == "correction_batch":
769
- normalized = _validate_batch_marker(record)
770
- batch_id = normalized["id"]
771
- phase = record["phase"]
772
- digest = _sha256_canonical(record)
773
- marker_identity = dict(record)
774
- marker_identity.pop("phase", None)
775
- identity_digest = _sha256_canonical(marker_identity)
776
- prior = markers.setdefault(batch_id, {}).get(phase)
777
- if prior is not None and prior[1] != digest:
778
- taint(
779
- _protocol_violation(
780
- batch_id,
781
- "marker_conflict",
782
- phase=phase,
783
- firstRecordHash=prior[1],
784
- conflictingRecordHash=digest,
785
- ),
786
- available_after=max(prior[3], sequence),
787
- )
788
- if prior is None:
789
- markers[batch_id][phase] = (
790
- normalized,
791
- digest,
792
- identity_digest,
793
- sequence,
794
- )
795
- continue
796
- if record_type == "correction":
797
- normalized = _validate_correction_record(record)
798
- batch_id = normalized["batch"]
799
- seq = normalized["seq"]
800
- digest = _sha256_canonical(record)
801
- prior = actions.setdefault(batch_id, {}).get(seq)
802
- if prior is not None and prior[1] != digest:
803
- taint(
804
- _protocol_violation(
805
- batch_id,
806
- "action_sequence_conflict",
807
- actionSequence=seq,
808
- firstRecordHash=prior[1],
809
- conflictingRecordHash=digest,
810
- ),
811
- available_after=max(prior[2], sequence),
812
- )
813
- if prior is None:
814
- actions[batch_id][seq] = (normalized, digest, sequence)
865
+ if prior is None:
866
+ markers[batch_id][phase] = (
867
+ normalized,
868
+ digest,
869
+ identity_digest,
870
+ sequence,
871
+ )
872
+ return
873
+ if record_type == "correction":
874
+ normalized = _validate_correction_record(record)
875
+ batch_id = normalized["batch"]
876
+ seq = normalized["seq"]
877
+ digest = _sha256_canonical(record)
878
+ prior = actions.setdefault(batch_id, {}).get(seq)
879
+ if prior is not None and prior[1] != digest:
880
+ taint(
881
+ _protocol_violation(
882
+ batch_id,
883
+ "action_sequence_conflict",
884
+ actionSequence=seq,
885
+ firstRecordHash=prior[1],
886
+ conflictingRecordHash=digest,
887
+ ),
888
+ available_after=max(prior[2], sequence),
889
+ )
890
+ if prior is None:
891
+ actions[batch_id][seq] = (normalized, digest, sequence)
892
+
893
+
894
+ def resolve_batches(fold: SelectorFold, *, batch_ids=None) -> None:
895
+ """Phase 2 — decide completion or taint for each accumulated batch.
815
896
 
816
- completed: set[str] = set()
817
- for batch_id in sorted(set(markers) | set(actions)):
897
+ ``batch_ids`` restricts the pass. An incremental caller passes the batches
898
+ whose durable status is not already `completed`: a completed batch's action
899
+ cores are deliberately dropped once it completes, so its verdict is carried
900
+ forward rather than re-derived, and a later record that conflicts with it is
901
+ detected in phase 1 from the retained whole-record digest instead.
902
+ """
903
+ candidates = fold.candidates
904
+ markers = fold.markers
905
+ actions = fold.actions
906
+ tainted_batches = fold.tainted_batches
907
+ completed = fold.completed
908
+ taint = fold.taint
909
+ if batch_ids is None:
910
+ batch_ids = set(markers) | set(actions)
911
+ for batch_id in sorted(batch_ids):
818
912
  batch_markers = markers.get(batch_id, {})
819
913
  begin = batch_markers.get("begin")
820
914
  commit = batch_markers.get("commit")
@@ -833,8 +927,15 @@ def resolve_effective_events(
833
927
  if begin is None or commit is None:
834
928
  continue
835
929
  begin_core, _begin_hash, begin_identity, begin_sequence = begin
836
- commit_core, _commit_hash, commit_identity, commit_sequence = commit
837
- if begin_core != commit_core or begin_identity != commit_identity:
930
+ _commit_core, _commit_hash, commit_identity, commit_sequence = commit
931
+ # The identity digest covers the whole marker record MINUS its phase,
932
+ # and the normalized core is a pure function of fields inside that
933
+ # digest, so equal identities imply equal cores and unequal cores imply
934
+ # unequal identities. Comparing identities alone is therefore exactly
935
+ # the previous `core != core or identity != identity` condition, and it
936
+ # is what lets an incremental pass seed a marker from the durable row
937
+ # (#496 S5b §3.2), which stores the identity digest but not the core.
938
+ if begin_identity != commit_identity:
838
939
  taint(
839
940
  _protocol_violation(
840
941
  batch_id,
@@ -932,6 +1033,45 @@ def resolve_effective_events(
932
1033
  normalized, _digest, sequence = batch_actions[seq]
933
1034
  candidates.append(_candidate_from_correction(normalized, sequence))
934
1035
 
1036
+
1037
+ def resolve_effective_events(
1038
+ records,
1039
+ *,
1040
+ protocol_prefix_evidence=(),
1041
+ accumulators=None,
1042
+ ) -> EffectiveSelection:
1043
+ """Validate correction batches and select one highest revision per evt id.
1044
+
1045
+ Three failure classes, deliberately asymmetric (#374 §5, #402 Task A):
1046
+
1047
+ - Divergent same-revision EVENTS are **quarantined**: the lowest-sequence
1048
+ candidate becomes the provisional winner and the group is reported on
1049
+ `EffectiveSelection.conflicts`. The journal is append-only, so a divergent
1050
+ line can never be un-written; raising here wedged every rebuild forever.
1051
+ - The seven enumerated **structural** correction-batch violations taint their
1052
+ entire batch. No action from it is a candidate; the selector continues
1053
+ with distinct valid batches and reports a bounded `ProtocolViolation`.
1054
+ - Invalid marker/action field shapes and every other out-of-scope
1055
+ `JournalProtocolError` remain fatal. Unknown record types remain ignored.
1056
+
1057
+ ``accumulators`` (#496 S5b §3.1) is an optional out-dict. When supplied it
1058
+ is populated with the :class:`SelectorFold` this pass built, under the key
1059
+ ``"fold"``, so a caller can persist the six accumulators the summary
1060
+ discards. It is an out-parameter rather than a new `EffectiveSelection`
1061
+ field precisely so the frozen result shape and every existing caller stay
1062
+ byte-unaffected.
1063
+ """
1064
+ fold = SelectorFold()
1065
+ fold_records(fold, records)
1066
+ resolve_batches(fold)
1067
+ if accumulators is not None:
1068
+ accumulators["fold"] = fold
1069
+ candidates = fold.candidates
1070
+ resolutions = fold.resolutions
1071
+ violations = fold.violations
1072
+ violation_available_after = fold.violation_available_after
1073
+ completed = fold.completed
1074
+
935
1075
  violation_by_fingerprint = {
936
1076
  violation.fingerprint: violation for violation in violations.values()
937
1077
  }
@@ -84,6 +84,27 @@ class LastSeenAccumulator:
84
84
  if previous is None or at > previous:
85
85
  self.stamped[key] = at
86
86
 
87
+ def merge(self, stamped, legacy_claude_at=None, legacy_codex_at=None):
88
+ """Fold another accumulator's partial state into this one.
89
+
90
+ The fold is a per-key MAXIMUM over timestamps, and maximum is
91
+ associative and commutative, so a segment's partial contribution merges
92
+ in any order and produces the same map a single pass would. That is what
93
+ lets #496 S5b Stage 4 import an elided segment's stored contribution
94
+ instead of reading the segment (spec section 5.4).
95
+ """
96
+ for key, at in dict(stamped).items():
97
+ if isinstance(key, str) and key and isinstance(at, str) and at:
98
+ self._bump(key, at)
99
+ if legacy_claude_at is not None and (
100
+ self.legacy_claude_at is None
101
+ or legacy_claude_at > self.legacy_claude_at):
102
+ self.legacy_claude_at = legacy_claude_at
103
+ if legacy_codex_at is not None and (
104
+ self.legacy_codex_at is None
105
+ or legacy_codex_at > self.legacy_codex_at):
106
+ self.legacy_codex_at = legacy_codex_at
107
+
87
108
  def resolve(self, cutover_claude: str, unattributed: str) -> dict:
88
109
  """Apply the deferred legacy buckets and return the final MAX map."""
89
110
  out = dict(self.stamped)