cctally 1.92.3 → 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.
@@ -748,113 +748,167 @@ def _is_legacy_quota_arming_state(candidate: EffectiveEvent) -> bool:
748
748
  )
749
749
 
750
750
 
751
- def resolve_effective_events(
752
- records,
753
- *,
754
- protocol_prefix_evidence=(),
755
- ) -> EffectiveSelection:
756
- """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.
757
753
 
758
- 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.
759
759
 
760
- - Divergent same-revision EVENTS are **quarantined**: the lowest-sequence
761
- candidate becomes the provisional winner and the group is reported on
762
- `EffectiveSelection.conflicts`. The journal is append-only, so a divergent
763
- line can never be un-written; raising here wedged every rebuild forever.
764
- - The seven enumerated **structural** correction-batch violations taint their
765
- entire batch. No action from it is a candidate; the selector continues
766
- with distinct valid batches and reports a bounded `ProtocolViolation`.
767
- - Invalid marker/action field shapes and every other out-of-scope
768
- `JournalProtocolError` remain fatal. Unknown record types remain ignored.
760
+ `completed` is filled by :func:`resolve_batches`, not by the record fold.
769
761
  """
770
- candidates: list[EffectiveEvent] = []
771
- markers: dict[str, dict[str, tuple[dict, str, str, int]]] = {}
772
- actions: dict[str, dict[int, tuple[dict, str, int]]] = {}
773
- resolutions: list[tuple[int, dict]] = []
774
- tainted_batches: set[str] = set()
775
- violations: dict[tuple[str, str, str], ProtocolViolation] = {}
776
- violation_available_after: dict[str, int] = {}
777
-
778
- 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:
779
789
  """Taint one batch and retain every distinct violation identity."""
780
- tainted_batches.add(violation.batch_id)
781
- violations[
790
+ self.tainted_batches.add(violation.batch_id)
791
+ self.violations[
782
792
  (violation.batch_id, violation.kind, violation.fingerprint)
783
793
  ] = violation
784
- violation_available_after[violation.fingerprint] = min(
794
+ self.violation_available_after[violation.fingerprint] = min(
785
795
  available_after,
786
- violation_available_after.get(
796
+ self.violation_available_after.get(
787
797
  violation.fingerprint,
788
798
  available_after,
789
799
  ),
790
800
  )
791
801
 
792
- for sequence, record in enumerate(records):
793
- if not isinstance(record, dict):
794
- continue
795
- record_type = record.get("t")
796
- if record_type == "evt":
797
- candidates.append(_candidate_from_evt(record, sequence))
798
- continue
799
- if (
800
- record_type == "op"
801
- and isinstance(record.get("payload"), dict)
802
- and record["payload"].get("kind") == _PROTOCOL_RESOLUTION_KIND
803
- ):
804
- resolutions.append(
805
- (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),
806
864
  )
807
- continue
808
- if record_type == "correction_batch":
809
- normalized = _validate_batch_marker(record)
810
- batch_id = normalized["id"]
811
- phase = record["phase"]
812
- digest = _sha256_canonical(record)
813
- marker_identity = dict(record)
814
- marker_identity.pop("phase", None)
815
- identity_digest = _sha256_canonical(marker_identity)
816
- prior = markers.setdefault(batch_id, {}).get(phase)
817
- if prior is not None and prior[1] != digest:
818
- taint(
819
- _protocol_violation(
820
- batch_id,
821
- "marker_conflict",
822
- phase=phase,
823
- firstRecordHash=prior[1],
824
- conflictingRecordHash=digest,
825
- ),
826
- available_after=max(prior[3], sequence),
827
- )
828
- if prior is None:
829
- markers[batch_id][phase] = (
830
- normalized,
831
- digest,
832
- identity_digest,
833
- sequence,
834
- )
835
- continue
836
- if record_type == "correction":
837
- normalized = _validate_correction_record(record)
838
- batch_id = normalized["batch"]
839
- seq = normalized["seq"]
840
- digest = _sha256_canonical(record)
841
- prior = actions.setdefault(batch_id, {}).get(seq)
842
- if prior is not None and prior[1] != digest:
843
- taint(
844
- _protocol_violation(
845
- batch_id,
846
- "action_sequence_conflict",
847
- actionSequence=seq,
848
- firstRecordHash=prior[1],
849
- conflictingRecordHash=digest,
850
- ),
851
- available_after=max(prior[2], sequence),
852
- )
853
- if prior is None:
854
- 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
+
855
893
 
856
- completed: set[str] = set()
857
- for batch_id in sorted(set(markers) | set(actions)):
894
+ def resolve_batches(fold: SelectorFold, *, batch_ids=None) -> None:
895
+ """Phase 2 — decide completion or taint for each accumulated batch.
896
+
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):
858
912
  batch_markers = markers.get(batch_id, {})
859
913
  begin = batch_markers.get("begin")
860
914
  commit = batch_markers.get("commit")
@@ -873,8 +927,15 @@ def resolve_effective_events(
873
927
  if begin is None or commit is None:
874
928
  continue
875
929
  begin_core, _begin_hash, begin_identity, begin_sequence = begin
876
- commit_core, _commit_hash, commit_identity, commit_sequence = commit
877
- 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:
878
939
  taint(
879
940
  _protocol_violation(
880
941
  batch_id,
@@ -972,6 +1033,45 @@ def resolve_effective_events(
972
1033
  normalized, _digest, sequence = batch_actions[seq]
973
1034
  candidates.append(_candidate_from_correction(normalized, sequence))
974
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
+
975
1075
  violation_by_fingerprint = {
976
1076
  violation.fingerprint: violation for violation in violations.values()
977
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)