cctally 1.115.0 → 1.116.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 (55) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/bin/_cctally_cache.py +85 -4
  3. package/bin/_cctally_completion.py +80 -12
  4. package/bin/_cctally_core.py +106 -9
  5. package/bin/_cctally_dashboard.py +6 -9
  6. package/bin/_cctally_dashboard_envelope.py +26 -32
  7. package/bin/_cctally_db.py +85 -4
  8. package/bin/_cctally_doctor.py +38 -5
  9. package/bin/_cctally_five_hour.py +83 -1017
  10. package/bin/_cctally_journal.py +1307 -180
  11. package/bin/_cctally_journal_repair.py +9 -7
  12. package/bin/_cctally_oauth_samples.py +280 -0
  13. package/bin/_cctally_parser.py +5 -0
  14. package/bin/_cctally_quarantine.py +14 -4
  15. package/bin/_cctally_quota.py +70 -8
  16. package/bin/_cctally_quota_model.py +35 -4
  17. package/bin/_cctally_record.py +414 -123
  18. package/bin/_cctally_rederive.py +632 -235
  19. package/bin/_cctally_refresh.py +29 -0
  20. package/bin/_cctally_statusline.py +984 -47
  21. package/bin/_cctally_store.py +972 -58
  22. package/bin/_cctally_tui.py +62 -7
  23. package/bin/_cctally_update.py +25 -0
  24. package/bin/_lib_account_protocol.py +201 -18
  25. package/bin/_lib_clone_copy.py +46 -11
  26. package/bin/_lib_dashboard_sources.py +57 -15
  27. package/bin/_lib_detector_access_registry.py +273 -52
  28. package/bin/_lib_doctor.py +302 -1
  29. package/bin/_lib_journal.py +145 -25
  30. package/bin/_lib_journal_router.py +166 -48
  31. package/bin/_lib_oauth_samples.py +238 -0
  32. package/bin/_lib_pricing_check.py +0 -12
  33. package/bin/_lib_provider_activity.py +770 -0
  34. package/bin/_lib_quarantine.py +3 -2
  35. package/bin/_lib_quota_model.py +96 -6
  36. package/bin/_lib_rebuild_progress.py +146 -0
  37. package/bin/_lib_record.py +22 -0
  38. package/bin/_lib_rederive.py +299 -3
  39. package/bin/_lib_snapshot_cache.py +43 -1
  40. package/bin/_lib_stale_proof.py +708 -0
  41. package/bin/_lib_statusline_candidates.py +65 -5
  42. package/bin/_lib_worker_launches.py +315 -0
  43. package/bin/cctally +6 -11
  44. package/dashboard/static/assets/{ConversationsView-Bc8LCOAZ.js → ConversationsView-Dflb_myA.js} +1 -1
  45. package/dashboard/static/assets/DoctorModal-DFX5CfIs.js +1 -0
  46. package/dashboard/static/assets/{ModalRoot-DLPgyAM_.js → ModalRoot-HlxoagIa.js} +1 -1
  47. package/dashboard/static/assets/{ProjectsDrillPanel-z53a4v4M.js → ProjectsDrillPanel-C3PJy1xx.js} +1 -1
  48. package/dashboard/static/assets/{SourceDetailModal-Bw0T8W-W.js → SourceDetailModal-BczsWKhD.js} +1 -1
  49. package/dashboard/static/assets/{UpdateModal-D5AKx4Yw.js → UpdateModal-Os-Lo5cs.js} +1 -1
  50. package/dashboard/static/assets/{index-BXDOzSng.js → index-CYZ3NLqF.js} +3 -3
  51. package/dashboard/static/assets/{outlineNavigation-JObY7cUY.js → outlineNavigation-k-zIif6W.js} +1 -1
  52. package/dashboard/static/dashboard.html +1 -1
  53. package/package.json +7 -2
  54. package/bin/_lib_merge_gate.py +0 -452
  55. package/dashboard/static/assets/DoctorModal-D--qVBwe.js +0 -1
@@ -9,8 +9,9 @@ Stdlib only. An entry's `id` takes one of two forms:
9
9
  the `NAME` of `bin/cctally-NAME-test` and `<case>` is one scenario of that
10
10
  harness's own case inventory (`bin/cctally-NAME-test --list-cases`).
11
11
 
12
- `bin/cctally-test-all`, `bin/_lib_merge_gate.py` and the quick gate read the
13
- list through this module; the scheduled CI quarantine leg calls its CLI. An
12
+ `bin/cctally-test-all` (through `bin/_lib_test_selection.py`) and the quick
13
+ gate read the list through this module, and so does the nightly broad run's
14
+ quarantine leg (bin/cctally-suite-run). An
14
15
  absent list quarantines nothing (the public mirror may carry none); a list
15
16
  that exists but cannot be read raises QuarantineError, never "nothing listed".
16
17
 
@@ -21,6 +21,7 @@ from __future__ import annotations
21
21
  import bisect
22
22
  import dataclasses
23
23
  import datetime as dt
24
+ import decimal
24
25
  import enum
25
26
  import hashlib
26
27
  import json
@@ -1437,6 +1438,84 @@ def unattributed_units(segments, entries, *, now,
1437
1438
  return {"units": total, "entries": count}
1438
1439
 
1439
1440
 
1441
+ # ---------------------------------------------------------------------------
1442
+ # Correctly rounded logarithms and exponentials.
1443
+ #
1444
+ # `math.log`, `math.log1p` and `math.expm1` call the platform C library, and
1445
+ # C does not require those to be correctly rounded. glibc 2.39's `log1p` and
1446
+ # `expm1` are off by one ULP on inputs the eligibility fence reaches, which
1447
+ # moved quota fixture 12's `eligibilityFence` between Linux and macOS. These
1448
+ # compute in `decimal`, whose `ln` and `exp` are correctly rounded at the
1449
+ # context precision, from the float's exact value, and round once to a
1450
+ # double. At 80 significant digits that double rounding can differ from a
1451
+ # direct one only if the true value lies within 1e-80 of a midpoint between
1452
+ # two doubles, far below what any double input can produce. Each call uses
1453
+ # its own context, never the thread's, and the edge cases the decimal path
1454
+ # does not model are delegated to `math`, whose answers there are exact.
1455
+ # ---------------------------------------------------------------------------
1456
+ _CR_DIGITS = 80
1457
+ #: The exact sum of 1 and any double needs at most 1075 significant digits.
1458
+ _CR_EXACT = decimal.Context(prec=1200, Emin=-999999, Emax=999999,
1459
+ traps=[decimal.InvalidOperation])
1460
+ _CR_ONE = decimal.Decimal(1)
1461
+
1462
+
1463
+ def _cr_context(prec: int = _CR_DIGITS) -> decimal.Context:
1464
+ return decimal.Context(prec=prec, rounding=decimal.ROUND_HALF_EVEN,
1465
+ Emin=-999999, Emax=999999,
1466
+ traps=[decimal.InvalidOperation,
1467
+ decimal.DivisionByZero, decimal.Overflow])
1468
+
1469
+
1470
+ def correctly_rounded_log(x) -> float:
1471
+ """`math.log(x)` correctly rounded on every platform.
1472
+
1473
+ Zero, negatives, infinities and NaN go to `math.log`, so the domain error
1474
+ and the non-finite results are exactly the standard library's.
1475
+ """
1476
+ x = float(x)
1477
+ if not math.isfinite(x) or x <= 0.0:
1478
+ return math.log(x)
1479
+ return float(_cr_context().ln(decimal.Decimal(x)))
1480
+
1481
+
1482
+ def correctly_rounded_log1p(x) -> float:
1483
+ """`math.log1p(x)` correctly rounded on every platform.
1484
+
1485
+ `1 + x` is formed exactly, so a tiny `x` keeps every digit. Signed zeros,
1486
+ `x <= -1` (the domain error), infinities and NaN go to `math.log1p`.
1487
+ """
1488
+ x = float(x)
1489
+ if not math.isfinite(x) or x == 0.0 or x <= -1.0:
1490
+ return math.log1p(x)
1491
+ one_plus = _CR_EXACT.add(_CR_ONE, decimal.Decimal(x))
1492
+ return float(_cr_context().ln(one_plus))
1493
+
1494
+
1495
+ def correctly_rounded_expm1(x) -> float:
1496
+ """`math.expm1(x)` correctly rounded on every platform.
1497
+
1498
+ The working precision grows with `1/|x|`, so the subtraction of one keeps
1499
+ 80 significant digits however small `x` is. Below -64 the true value is
1500
+ within 1.6e-28 of -1, which rounds to exactly -1. A finite result too
1501
+ large for a double raises `OverflowError`, as `math.expm1` does. Signed
1502
+ zeros, infinities and NaN go to `math.expm1`.
1503
+ """
1504
+ x = float(x)
1505
+ if not math.isfinite(x) or x == 0.0:
1506
+ return math.expm1(x)
1507
+ if x < -64.0:
1508
+ return -1.0
1509
+ if x > 710.0:
1510
+ return math.expm1(x) # raises OverflowError
1511
+ d = decimal.Decimal(x)
1512
+ ctx = _cr_context(_CR_DIGITS + max(0, -d.adjusted()))
1513
+ result = float(ctx.subtract(ctx.exp(d), _CR_ONE))
1514
+ if math.isinf(result):
1515
+ raise OverflowError("math range error")
1516
+ return result
1517
+
1518
+
1440
1519
  # ---------------------------------------------------------------------------
1441
1520
  # Robust statistics, the eligibility fence, and composition support (§5, §15).
1442
1521
  # ---------------------------------------------------------------------------
@@ -1476,14 +1555,16 @@ def eligibility_fence(days) -> float | None:
1476
1555
  which carried between 0.8 and 1.2 million weighted units against a
1477
1556
  message claiming no local token history at all.
1478
1557
  """
1479
- xs = [math.log1p(u) for u in days if u is not None and u > 0.0]
1558
+ xs = [correctly_rounded_log1p(u)
1559
+ for u in days if u is not None and u > 0.0]
1480
1560
  if len(xs) < 2:
1481
1561
  return None
1482
1562
  centre = median(xs)
1483
1563
  spread = scaled_mad(xs, centre)
1484
1564
  if not math.isfinite(spread) or spread == 0.0:
1485
1565
  return None
1486
- return math.expm1(centre - ELIGIBILITY["fence_mads"] * spread)
1566
+ return correctly_rounded_expm1(
1567
+ centre - ELIGIBILITY["fence_mads"] * spread)
1487
1568
 
1488
1569
 
1489
1570
  def composition_centre(vectors) -> dict[str, float] | None:
@@ -1506,9 +1587,16 @@ def composition_centre(vectors) -> dict[str, float] | None:
1506
1587
 
1507
1588
 
1508
1589
  def tv_distance(p, centre) -> float:
1509
- """Total-variation distance, `0.5 * sum(|p_i - c_i|)`."""
1590
+ """Total-variation distance, `0.5 * sum(|p_i - c_i|)`.
1591
+
1592
+ `math.fsum`, not `sum`: the keys come from a set, whose iteration order
1593
+ follows PYTHONHASHSEED, and Python 3.11's float `sum()` is uncompensated,
1594
+ so a plain sum moved the published radius by an ULP from one process to
1595
+ the next. `fsum` is correctly rounded, so the result is the same in every
1596
+ order and on every supported interpreter.
1597
+ """
1510
1598
  keys = set(p) | set(centre)
1511
- return 0.5 * sum(abs(p.get(k, 0.0) - centre.get(k, 0.0)) for k in keys)
1599
+ return 0.5 * math.fsum(abs(p.get(k, 0.0) - centre.get(k, 0.0)) for k in keys)
1512
1600
 
1513
1601
 
1514
1602
  def support_radius(vectors, centre) -> float | None:
@@ -1696,7 +1784,9 @@ def jackknife_fit(days) -> tuple[float, Interval] | None:
1696
1784
  return None
1697
1785
  replicates.append((total_units - row.units) / rest_meter)
1698
1786
  mean = sum(replicates) / n
1699
- se = math.sqrt((n - 1) / n * sum((v - mean) ** 2 for v in replicates))
1787
+ # A product, not `** 2`, which is the C library's `pow`.
1788
+ se = math.sqrt((n - 1) / n * sum((v - mean) * (v - mean)
1789
+ for v in replicates))
1700
1790
  half = t_quantile_975(n - 1) * se
1701
1791
  return point, Interval(max(0.0, point - half), point + half)
1702
1792
 
@@ -1930,7 +2020,7 @@ def detect_change(series, *, override_split=None) -> DetectorResult:
1930
2020
  total = len(days)
1931
2021
  truncated = input_days - total
1932
2022
  scan_start = days[0].date if days else None
1933
- stats = [math.log(o.units / o.meter_delta) for o in days]
2023
+ stats = [correctly_rounded_log(o.units / o.meter_delta) for o in days]
1934
2024
  min_base = int(TRUST["min_detect_baseline_days"])
1935
2025
  min_watch = int(DETECTOR["min_watch_days"])
1936
2026
  fence_mads = DETECTOR["fence_mads"]
@@ -0,0 +1,146 @@
1
+ """Progress and resident memory for the stats index's full replays (#997).
2
+
3
+ The 2026-10-22 epoch rebuild ran 4.4 hours and died without a word, because
4
+ nothing it did between start and death reached a file. This module is the seam
5
+ a detached worker's state file hangs off (spec D9):
6
+
7
+ * ``install(sink)`` / ``uninstall()``: one process-wide sink, called as
8
+ ``sink(phase, counters, forced)``. Only a worker holding its flock installs
9
+ one; everywhere else every call below is a no-op.
10
+ * ``phase(name, **counters)``: where the replay is now. Always written
11
+ (``forced``); the counters start over with the ones given.
12
+ * ``tick(**counters)``: merges counters into the current phase's and writes
13
+ them at most every ``THROTTLE_SECONDS`` (``time.monotonic``), so a call
14
+ site can tick on every batch without turning progress into I/O.
15
+ * ``waiting(lock)``: a context for a blocking lock wait, written as the phase
16
+ ``waiting:<lock>`` and replaced by the interrupted phase when it ends.
17
+
18
+ A sink that raises is ignored: progress never fails a rebuild.
19
+
20
+ ``peak_resident_bytes`` is the process-lifetime peak the rebuild record and the
21
+ worker state report as ``peakResidentBytes`` (spec D11). ``peakHeapBytes``, a
22
+ ``tracemalloc`` peak that is zero unless tracing, never saw the memory that
23
+ pushed that rebuild into hours of compression.
24
+
25
+ Stdlib only, and importable on its own.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import contextlib
30
+ import os
31
+ import resource
32
+ import subprocess
33
+ import sys
34
+ import time
35
+
36
+ #: The longest a ticking replay goes without a write (spec D9).
37
+ THROTTLE_SECONDS = 10.0
38
+
39
+ _sink = None
40
+ _phase: "str | None" = None
41
+ _counters: dict = {}
42
+ _last_write: "float | None" = None
43
+
44
+
45
+ def install(sink) -> None:
46
+ """Route progress to ``sink(phase, counters, forced)`` from now on."""
47
+ global _sink, _phase, _counters, _last_write
48
+ _sink = sink
49
+ _phase = None
50
+ _counters = {}
51
+ _last_write = None
52
+
53
+
54
+ def uninstall() -> None:
55
+ """Stop routing progress; every call becomes a no-op again."""
56
+ global _sink, _phase, _counters, _last_write
57
+ _sink = None
58
+ _phase = None
59
+ _counters = {}
60
+ _last_write = None
61
+
62
+
63
+ def _emit(forced: bool) -> None:
64
+ global _last_write
65
+ _last_write = time.monotonic()
66
+ sink = _sink
67
+ if sink is None:
68
+ return
69
+ try:
70
+ sink(_phase, dict(_counters), forced)
71
+ except Exception: # progress never fails a rebuild
72
+ pass
73
+
74
+
75
+ def phase(name: str, **counters) -> None:
76
+ """Enter phase ``name`` and write it now."""
77
+ global _phase, _counters
78
+ if _sink is None:
79
+ return
80
+ _phase = name
81
+ _counters = dict(counters)
82
+ _emit(True)
83
+
84
+
85
+ def tick(**counters) -> None:
86
+ """Record ``counters`` and write them if the throttle allows."""
87
+ if _sink is None:
88
+ return
89
+ _counters.update(counters)
90
+ if (_last_write is not None
91
+ and time.monotonic() - _last_write < THROTTLE_SECONDS):
92
+ return
93
+ _emit(False)
94
+
95
+
96
+ @contextlib.contextmanager
97
+ def waiting(lock: str):
98
+ """Report a blocking wait on ``lock`` for as long as the body runs."""
99
+ global _phase, _counters
100
+ if _sink is None:
101
+ yield
102
+ return
103
+ previous, saved = _phase, dict(_counters)
104
+ phase(f"waiting:{lock}")
105
+ try:
106
+ yield
107
+ finally:
108
+ if _sink is not None:
109
+ _phase, _counters = previous, saved
110
+ _emit(True)
111
+
112
+
113
+ def peak_resident_bytes() -> int:
114
+ """``getrusage(RUSAGE_SELF).ru_maxrss`` in bytes.
115
+
116
+ macOS reports bytes and Linux reports KiB; the unit is decided per call
117
+ from ``sys.platform`` so a test can exercise both branches.
118
+ """
119
+ peak = int(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss)
120
+ if sys.platform == "darwin":
121
+ return peak
122
+ return peak * 1024
123
+
124
+
125
+ def current_resident_bytes() -> "int | None":
126
+ """This process's current resident set in bytes, or None when unknown.
127
+
128
+ Best effort, because no stdlib call reports it on macOS: Linux reads
129
+ ``/proc/self/statm``; elsewhere ``ps -o rss=`` (KiB) is asked under
130
+ ``LC_ALL=C``. Any failure answers None rather than raising, since a
131
+ progress reading must never fail a rebuild.
132
+ """
133
+ try:
134
+ with open("/proc/self/statm", "rb") as handle:
135
+ fields = handle.read().split()
136
+ return int(fields[1]) * os.sysconf("SC_PAGE_SIZE")
137
+ except (OSError, ValueError, IndexError):
138
+ pass
139
+ try:
140
+ out = subprocess.run(
141
+ ["ps", "-o", "rss=", "-p", str(os.getpid())],
142
+ capture_output=True, text=True, timeout=5,
143
+ env={**os.environ, "LC_ALL": "C"}, check=False)
144
+ return int(out.stdout.strip()) * 1024
145
+ except (OSError, ValueError, subprocess.SubprocessError):
146
+ return None
@@ -150,6 +150,7 @@ class FiveHourSourceDecision:
150
150
  def plan_five_hour_source_local_credit(
151
151
  *, baseline_pct, pending_low_pct, pending_observation_id,
152
152
  new_pct, observation_id, drop_threshold, window_live,
153
+ generation=None, baseline_generation=None, pending_generation=None,
153
154
  ):
154
155
  """Classify one observation against ONE contributor's own five-hour state
155
156
  (glue call site: ``detect_reset_and_credit``'s 5h branch).
@@ -173,9 +174,30 @@ def plan_five_hour_source_local_credit(
173
174
  is deliberately no maximum gap, no maximum climb and no confirmation-value
174
175
  band, because observations arrive at an unbounded interval and every finite
175
176
  threshold on the change between two of them has a legitimate crossing.
177
+
178
+ ``generation`` (#797/#823/#840 spec 4.9, D3/D11) is the account and
179
+ window's current five-hour credit generation, passed only by the v2
180
+ detector; the historical detector passes None and keeps every rule above
181
+ unchanged. ``baseline_generation`` and ``pending_generation`` are the
182
+ generations the source's baseline and its pending descent were
183
+ established under. A state established under another generation is
184
+ OBSOLETE: a credit has been recorded, corrected or tombstoned since, so
185
+ its baseline may be a pre-credit high and its descent may be one another
186
+ source already confirmed. It can neither arm nor confirm, and it is not
187
+ relabelled into the new generation; this observation re-establishes the
188
+ baseline instead. A state recorded without a generation (the historical
189
+ detector, an earlier binary, or a seeded fixture) is not obsolete by
190
+ this rule.
176
191
  """
177
192
  new_pct = float(new_pct)
178
193
 
194
+ if baseline_pct is not None and generation is not None and (
195
+ (baseline_generation is not None
196
+ and baseline_generation != generation)
197
+ or (pending_low_pct is not None and pending_generation is not None
198
+ and pending_generation != generation)):
199
+ return FiveHourSourceDecision(FIVE_HOUR_SET_BASELINE, new_pct)
200
+
179
201
  if baseline_pct is None:
180
202
  # No state for this source in this window. Establish the baseline and
181
203
  # emit nothing: a first reading is not a descent, whatever any other