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.
- package/CHANGELOG.md +37 -2
- package/bin/_cctally_cache.py +354 -0
- package/bin/_cctally_core.py +180 -3
- package/bin/_cctally_dashboard.py +71 -1
- package/bin/_cctally_dashboard_envelope.py +28 -2
- package/bin/_cctally_dashboard_share.py +75 -19
- package/bin/_cctally_dashboard_sources.py +12 -0
- package/bin/_cctally_db.py +89 -1
- package/bin/_cctally_doctor.py +31 -0
- package/bin/_cctally_forecast.py +4 -2
- package/bin/_cctally_journal.py +3326 -215
- package/bin/_cctally_milestone_history.py +4 -1
- package/bin/_cctally_project.py +8 -6
- package/bin/_cctally_quota.py +420 -20
- package/bin/_cctally_reporting.py +8 -6
- package/bin/_cctally_share.py +74 -37
- package/bin/_cctally_source_analytics.py +6 -8
- package/bin/_cctally_store.py +13 -2
- package/bin/_cctally_tui.py +53 -0
- package/bin/_lib_cache_coverage.py +547 -0
- package/bin/_lib_doctor.py +54 -2
- package/bin/_lib_journal.py +195 -95
- package/bin/_lib_journal_router.py +21 -0
- package/bin/_lib_segment_summary.py +374 -0
- package/bin/_lib_selector_state.py +959 -0
- package/bin/_lib_share.py +1073 -165
- package/bin/_lib_share_templates.py +35 -11
- package/bin/_lib_stats_wal.py +327 -0
- package/bin/_lib_view_models.py +2 -1
- package/dashboard/static/assets/index-DwWJOYxd.css +1 -0
- package/dashboard/static/assets/{index-Dat-mza6.js → index-HlIK7k8Q.js} +47 -47
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +5 -1
- package/dashboard/static/assets/index-DnWdv8um.css +0 -1
package/bin/_lib_journal.py
CHANGED
|
@@ -748,113 +748,167 @@ def _is_legacy_quota_arming_state(candidate: EffectiveEvent) -> bool:
|
|
|
748
748
|
)
|
|
749
749
|
|
|
750
750
|
|
|
751
|
-
|
|
752
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
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
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
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
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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
|
-
|
|
857
|
-
|
|
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
|
-
|
|
877
|
-
|
|
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)
|