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
@@ -2,23 +2,19 @@
2
2
 
3
3
  This is a reviewed component policy, not proof of every possible dependency.
4
4
  Unknown paths and shared/infrastructure changes require the full estate. The
5
- full background/release runs remain the cross-component backstop.
5
+ scheduled full run and the release run remain the cross-component backstop.
6
6
  """
7
7
  from __future__ import annotations
8
8
 
9
9
  import argparse
10
- import base64
11
- from datetime import datetime, timezone
12
10
  import hashlib
13
11
  import importlib.util
14
12
  import json
15
13
  import os
16
14
  from pathlib import Path, PurePosixPath
17
- import re
18
15
  import signal
19
16
  import subprocess
20
17
  import sys
21
- import time
22
18
 
23
19
  GATE_ID = 'cctally-test-remote/merge-focused@1'
24
20
  MAX_SECONDS = 300
@@ -299,17 +295,6 @@ def select(repo, paths, *, irregular=None, instruction_evidence=None):
299
295
  if any(not (repo / p).is_file() for p in files):
300
296
  reasons.append('required merge pytest file absent')
301
297
  full = bool(reasons)
302
- # Whether the wrapper must read background full-verification health before
303
- # trusting this focus. Only a memory-only selection derived with its
304
- # irregular-file evidence is exempt: memory publication is certified by the
305
- # memory focus whatever the background state. Containing memory is not
306
- # enough, and a full selection has nothing to read health for.
307
- if full:
308
- background_health = 'not-applicable'
309
- elif sorted(components) == ['memory'] and memory:
310
- background_health = 'exempt'
311
- else:
312
- background_health = 'required'
313
298
  return {
314
299
  'mode': 'full' if full else 'merge-focused',
315
300
  'pytest': 'full' if full else 'selected',
@@ -317,7 +302,7 @@ def select(repo, paths, *, irregular=None, instruction_evidence=None):
317
302
  'omittedHarnesses': [n for n in names if not full and n not in harnesses],
318
303
  'selectedPytestFiles': [] if full else sorted(files),
319
304
  'components': sorted(components), 'reasons': reasons,
320
- 'policyDigest': policy_digest(repo), 'backgroundHealth': background_health,
305
+ 'policyDigest': policy_digest(repo),
321
306
  'maxWallSeconds': MAX_SECONDS,
322
307
  }
323
308
 
@@ -336,8 +321,13 @@ def plan(repo, base_rev, head='HEAD', *, working=True):
336
321
  return result
337
322
 
338
323
 
339
- def filter_legs(plan_record, directory):
340
- """Narrow both expected-node legs to selected files; retain serial ownership."""
324
+ def filter_legs(plan_record, directory, quarantined=()):
325
+ """Narrow both expected-node legs to selected files; retain serial ownership.
326
+
327
+ bin/cctally-test-all applies tests/quarantine.json to the legs first, so a
328
+ selected file whose every node is quarantined has none left. Such a file
329
+ (one that ``quarantined``, the list's node ids, names) contributes no
330
+ targets; any other selected file without a recorded node refuses."""
341
331
  directory = Path(directory)
342
332
  selected = set(plan_record['selectedPytestFiles'])
343
333
  found = set()
@@ -346,284 +336,20 @@ def filter_legs(plan_record, directory):
346
336
  nodes = [n for n in path.read_text().splitlines() if n.split('::')[0] in selected]
347
337
  found.update(n.split('::')[0] for n in nodes)
348
338
  path.write_text(''.join(n + '\n' for n in nodes))
349
- if found != selected:
350
- raise ValueError('selected files absent from recorded pytest estate: ' + ', '.join(sorted(selected - found)))
351
-
352
-
353
- # Health reads one unfiltered ci.yml page and selects main on the client:
354
- # GitHub's `branch=` index can lag the unfiltered listing by weeks (#892).
355
- _HEALTH_LISTED_RUNS = 100 # one page at GitHub's maximum size; never a second
356
- _HEALTH_MAIN_DEPTH = 20 # the retired branch-filtered read's depth: no older evidence
357
-
358
-
359
- def background_health(rows):
360
- """Newest completed full verification wins; running work hides nothing."""
361
- for row in rows:
362
- if row.get('head_branch') != 'main' or row.get('status') != 'completed':
363
- continue
364
- if row.get('event') not in ('schedule', 'workflow_dispatch') and row.get('fullSuite') is not True:
365
- continue
366
- passed = row.get('conclusion') == 'success'
367
- return {'state': 'passed' if passed else 'failed', 'allowFocused': passed,
368
- 'runId': row.get('id'), 'headSha': row.get('head_sha')}
369
- return {'state': 'unavailable', 'allowFocused': False, 'runId': None, 'headSha': None}
370
-
371
-
372
- # D1(b): a scheduled test-macos job whose suite failed only on the runtime
373
- # budget. ci.yml runs the marker step when the suite's outcome record says so;
374
- # the reader also requires the suite step to be the job's only failed step.
375
- _SUITE_STEP = 'Run cctally test suite'
376
- _BUDGET_MARKER_STEP = 'Runtime budget is the only failure'
377
- # D1(a): how many first-parent main commits are searched for a receipt.
378
- _HEALTH_RECEIPT_COMMITS = 20
379
- # D1(a) receipt discovery's overall time budget (#899 S2): once spent, the CI-derived
380
- # result stands; it never waits out twenty commits of slow reads.
381
- _HEALTH_RECEIPT_BUDGET_S = 60
382
- _RECEIPT_RUN_ID = re.compile(r'^(\d{8}T\d{6}Z)-')
383
-
384
-
385
- def _attempt_start(value):
386
- """The UTC instant of a GitHub ISO-8601 timestamp; ValueError when absent or malformed."""
387
- if not isinstance(value, str) or not value:
388
- raise ValueError('a run has no attempt start time')
389
- moment = datetime.fromisoformat(value.replace('Z', '+00:00'))
390
- if moment.tzinfo is None:
391
- raise ValueError('a run attempt start time carries no timezone')
392
- return moment.astimezone(timezone.utc)
393
-
394
-
395
- def _receipt_start(run_id):
396
- """The UTC start a receipt's run ID embeds, or None."""
397
- match = _RECEIPT_RUN_ID.match(run_id) if isinstance(run_id, str) else None
398
- if match is None:
399
- return None
400
- try:
401
- return datetime.strptime(match.group(1), '%Y%m%dT%H%M%SZ').replace(tzinfo=timezone.utc)
402
- except ValueError:
403
- return None
404
-
405
-
406
- def _budget_only(row, job):
407
- """D1(b): a scheduled run's failed test-macos whose only failure is the runtime budget."""
408
- if row.get('event') != 'schedule' or job.get('status') != 'completed' or job.get('conclusion') != 'failure':
409
- return False
410
- steps = [step for step in (job.get('steps') or []) if isinstance(step, dict)]
411
- markers = [step for step in steps if step.get('name') == _BUDGET_MARKER_STEP]
412
- failed = {step.get('name') for step in steps if step.get('conclusion') == 'failure'}
413
- return (len(markers) == 1 and markers[0].get('conclusion') == 'success'
414
- and failed == {_SUITE_STEP})
415
-
416
-
417
- def _run_item(row, jobs_of):
418
- """The full-verification item one main run contributes: 'pass', 'fail', 'unknown'
419
- (decisive, never a pass), or None when the run is skipped."""
420
- if row.get('status') != 'completed':
421
- # GitHub reuses the run ID on retry: the earlier failed attempt
422
- # disappears from this listing while the retry runs. Do not let that
423
- # mutation reveal an older successful run. A first attempt that is
424
- # still running hides nothing.
425
- return 'unknown' if row.get('run_attempt', 1) != 1 else None
426
- jobs = jobs_of(row)
427
- full = next((j for j in jobs if j.get('name') == 'test-macos'
428
- and j.get('status') == 'completed'
429
- and j.get('conclusion') != 'skipped'), None)
430
- if full is not None:
431
- if full.get('conclusion') == 'success' or _budget_only(row, full):
432
- return 'pass'
433
- return 'fail'
434
- if row.get('event') in ('schedule', 'workflow_dispatch'):
435
- # A scheduled/manual attempt must actually run the full job. Only
436
- # receipt-discharged ordinary pushes may be skipped while looking for
437
- # the last full verification.
438
- return 'unknown'
439
- # ci.yml can skip its declared full job on a successful private main push
440
- # only through a successful release/receipt gate. The skipped job plus
441
- # successful workflow is evidence of discharge; a merely successful
442
- # classifier job alone is not (it may emit discharged=false). Missing
443
- # jobs, cancellation, and failures must not silently inherit an older pass.
444
- discharged = (
445
- row.get('event') == 'push' and row.get('conclusion') == 'success'
446
- and any(j.get('name') == 'test-macos'
447
- and j.get('status') == 'completed'
448
- and j.get('conclusion') == 'skipped' for j in jobs)
449
- and any(j.get('name') in ('receipt-gate', 'release-stamp-gate')
450
- and j.get('status') == 'completed'
451
- and j.get('conclusion') == 'success' for j in jobs)
452
- )
453
- return None if discharged else 'unknown'
454
-
455
-
456
- def _ci_item(rows, jobs_of):
457
- """(kind, start, row) of the newest decisive run, or None.
458
-
459
- The window is sorted by attempt start, newest first, BEFORE the walk, so a
460
- retry of an older-created run is read in its true place. Equal starts are
461
- decided together and a non-pass among them wins.
462
- """
463
- dated = [(_attempt_start(row.get('run_started_at')), index, row) for index, row in enumerate(rows)]
464
- dated.sort(key=lambda item: (-item[0].timestamp(), item[1]))
465
- index = 0
466
- while index < len(dated):
467
- start = dated[index][0]
468
- group = []
469
- while index < len(dated) and dated[index][0] == start:
470
- group.append(dated[index][2])
471
- index += 1
472
- items = [(kind, row) for row in group if (kind := _run_item(row, jobs_of)) is not None]
473
- if items:
474
- kind, row = next((item for item in items if item[0] != 'pass'), items[0])
475
- return kind, start, row
476
- return None
477
-
339
+ absent = selected - found - {node.split('::')[0] for node in quarantined}
340
+ if absent:
341
+ raise ValueError('selected files absent from recorded pytest estate: ' + ', '.join(sorted(absent)))
478
342
 
479
- def _receipt_pass(gh, name, repo, newer_than, digest_of=None):
480
- """D1(a): (runId, commit sha) of the first valid passing full-suite receipt for
481
- the exact tree of a recent first-parent main commit, newer than `newer_than`
482
- (any age when None); None when there is none. Raises on any read error.
483
-
484
- `digest_of(sha)`, when given, reconstructs a commit's tree-manifest digest in
485
- its caller's stead (bounded by the caller's budget); by default it is built
486
- in-process."""
487
- # The validator of this reader's own tree, the one receipt-gate runs.
488
- spec = importlib.util.spec_from_file_location(
489
- '_merge_gate_receipts',
490
- Path(__file__).resolve().parents[1] / '.github' / 'scripts' / 'classify_receipt.py')
491
- receipts = importlib.util.module_from_spec(spec)
492
- spec.loader.exec_module(receipts)
493
- toolchain = receipts.toolchain_digest(str(repo))
494
- if toolchain is None:
495
- return None
496
- listing = gh('api', f'repos/{name}/commits?sha=main&per_page=100')
497
- by_sha = {commit['sha']: commit for commit in listing}
498
- commit = listing[0] if listing else None
499
- for _ in range(_HEALTH_RECEIPT_COMMITS):
500
- if commit is None:
501
- break
502
- sha, tree = commit['sha'], commit['commit']['tree']['sha']
503
- digest = None
504
- refs = gh('api', f'repos/{name}/git/matching-refs/cctally/receipts/{tree}/')
505
- for ref in sorted(refs, key=lambda r: r['ref'], reverse=True):
506
- run_id = ref['ref'].rsplit('/', 1)[-1]
507
- when = _receipt_start(run_id)
508
- if when is None or (newer_than is not None and when <= newer_than):
509
- continue
510
- blob = gh('api', f'repos/{name}/git/blobs/{ref["object"]["sha"]}')
511
- receipt = json.loads(base64.b64decode(blob['content']))
512
- if not isinstance(receipt, dict) or receipt.get('runId') != run_id:
513
- continue # dated by the run it records, and filed under that run
514
- if digest is None:
515
- digest = (digest_of(sha) if digest_of is not None
516
- else receipts.manifest_digest(receipts.reconstruct_manifest(sha, str(repo))))
517
- if receipts.verify_candidate(receipt, tree_oid=tree, ref_namespace=tree,
518
- reconstructed_digest=digest,
519
- toolchain_digest=toolchain) is None:
520
- return run_id, sha
521
- parents = commit.get('parents') or []
522
- commit = by_sha.get(parents[0]['sha']) if parents else None
523
- return None
524
343
 
344
+ def quarantine_ids(repo):
345
+ """The pytest node ids tests/quarantine.json lists; none when the tree carries no list.
525
346
 
526
- def read_background_health(repo):
527
- """The state of the newest completed full-verification item.
528
-
529
- Items are actual full test-macos job results, never a receipt-discharge
530
- skip (a successful workflow with a skipped full job is not full
531
- verification), with two additions (operator decision D1 of #899):
532
-
533
- (b) a scheduled run whose suite failed only on the runtime budget, proven by
534
- its marker step and its final step conclusions, is a pass;
535
- (a) a valid passing full-suite receipt for the exact tree of a recent
536
- first-parent main commit, newer than the newest CI item, is a pass.
537
-
538
- Runs are ordered by attempt start. A missing start, an unreadable listing
539
- and a malformed row are missing evidence; a failed receipt read leaves the
540
- CI-derived result unchanged and never manufactures a pass, and so does a
541
- receipt discovery that outlives its overall budget (_HEALTH_RECEIPT_BUDGET_S).
542
- That budget bounds the whole discovery, the reconstruction of a receipt's
543
- tree manifest included: it runs as a subprocess stopped when the budget is
544
- spent, after which nothing further is discovered (#899 S2).
545
- """
546
- def gh(*args):
547
- process = subprocess.run(['gh', *args], cwd=repo, capture_output=True,
548
- text=True, timeout=20)
549
- if process.returncode:
550
- raise ValueError('GitHub full-verification evidence is unavailable')
551
- return json.loads(process.stdout)
552
-
553
- def jobs_of(row):
554
- return gh('api', f'repos/{name}/actions/runs/{row["id"]}/jobs?per_page=100')['jobs']
555
- try:
556
- name = gh('repo', 'view', '--json', 'nameWithOwner')['nameWithOwner']
557
- listed = gh('api', f'repos/{name}/actions/workflows/ci.yml/runs'
558
- f'?per_page={_HEALTH_LISTED_RUNS}')['workflow_runs']
559
- # Other branches are never main's evidence, so they are dropped before
560
- # any rule below can decide on them or count toward the depth.
561
- rows = [row for row in listed if row.get('head_branch') == 'main']
562
- item = _ci_item(rows[:_HEALTH_MAIN_DEPTH], jobs_of)
563
- except (OSError, ValueError, KeyError, TypeError, AttributeError,
564
- subprocess.TimeoutExpired):
565
- # A malformed listing (a null or non-object row) is missing evidence.
566
- return background_health([])
567
- if item is not None and item[0] == 'pass':
568
- return background_health([dict(item[2], fullSuite=True, conclusion='success')])
569
- if item is not None and item[0] == 'fail':
570
- result = background_health([dict(item[2], fullSuite=True, conclusion='failure')])
571
- else:
572
- result = background_health([])
573
- budget_end = time.monotonic() + _HEALTH_RECEIPT_BUDGET_S
574
-
575
- def budgeted_gh(*args):
576
- # Every discovery read gets only what is left of the overall budget, and a read
577
- # that ends past it voids the discovery: the CI-derived result stands.
578
- left = budget_end - time.monotonic()
579
- if left <= 0:
580
- raise ValueError('the receipt discovery budget is spent')
581
- process = subprocess.run(['gh', *args], cwd=repo, capture_output=True, text=True,
582
- timeout=min(20.0, left))
583
- if process.returncode:
584
- raise ValueError('GitHub full-verification evidence is unavailable')
585
- if time.monotonic() > budget_end:
586
- raise ValueError('the receipt discovery budget is spent')
587
- return json.loads(process.stdout)
588
-
589
- def budgeted_digest(sha):
590
- # The tree-manifest reconstruction runs as the classifier's own `--self-test-manifest`
591
- # in a separate session, stopped (with every process it started) when the budget is spent.
592
- left = budget_end - time.monotonic()
593
- if left <= 0:
594
- raise ValueError('the receipt discovery budget is spent')
595
- script = Path(__file__).resolve().parents[1] / '.github' / 'scripts' / 'classify_receipt.py'
596
- process = subprocess.Popen([sys.executable, str(script), '--sha', sha, '--repo', str(repo),
597
- '--self-test-manifest'], stdout=subprocess.PIPE,
598
- stderr=subprocess.DEVNULL, text=True, start_new_session=True)
599
- try:
600
- out, _ = process.communicate(timeout=left)
601
- except subprocess.TimeoutExpired:
602
- try:
603
- os.killpg(process.pid, signal.SIGKILL)
604
- except (ProcessLookupError, PermissionError):
605
- pass
606
- try:
607
- process.communicate(timeout=5)
608
- except subprocess.TimeoutExpired:
609
- pass
610
- raise ValueError('the receipt discovery budget is spent') from None
611
- if process.returncode:
612
- raise ValueError('the tree manifest could not be reconstructed')
613
- if time.monotonic() > budget_end:
614
- raise ValueError('the receipt discovery budget is spent')
615
- return out.strip()
616
- try:
617
- found = _receipt_pass(budgeted_gh, name, repo, item[1] if item is not None else None,
618
- digest_of=budgeted_digest)
619
- if found is not None and time.monotonic() > budget_end:
620
- found = None # validated after the budget: the CI-derived result stands
621
- except Exception: # any read error, or a spent budget, leaves the CI-derived result unchanged
622
- found = None
623
- if found is None:
624
- return result
625
- run_id, sha = found
626
- return {'state': 'passed', 'allowFocused': True, 'runId': run_id, 'headSha': sha}
347
+ Parsed by the shared bin/_lib_quarantine.py; harness cases are not pytest
348
+ nodes and are left to their harness. bin/cctally-test-all validated the
349
+ list at admission, before this runs."""
350
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
351
+ import _lib_quarantine
352
+ return _lib_quarantine.pytest_ids(_lib_quarantine.load(repo))
627
353
 
628
354
 
629
355
  def supervise(command, seconds):
@@ -682,7 +408,6 @@ def main(argv=None):
682
408
  parser = argparse.ArgumentParser(description=__doc__)
683
409
  parser.add_argument('--repo', default=str(Path(__file__).resolve().parents[1]))
684
410
  parser.add_argument('--base')
685
- parser.add_argument('--health', action='store_true')
686
411
  parser.add_argument('--required-mode', action='store_true')
687
412
  parser.add_argument('--shell', action='store_true')
688
413
  parser.add_argument('--filter-legs')
@@ -693,18 +418,20 @@ def main(argv=None):
693
418
  if len(args.deadline) < 2:
694
419
  parser.error('deadline requires seconds and a command')
695
420
  return supervise(args.deadline[1:], int(args.deadline[0]))
696
- if args.health:
697
- health = read_background_health(args.repo)
698
- print(json.dumps(health, sort_keys=True))
699
- return 0 if health['allowFocused'] else 3
700
421
  if not args.base:
701
- parser.error('--base is required outside --health')
422
+ parser.error('--base is required')
702
423
  try:
703
424
  result = plan(args.repo, args.base)
704
425
  if args.required_mode:
705
- print(f"{result['mode']} {result['backgroundHealth']}")
426
+ # `<mode> <reason>`: the wrapper decides on the mode alone; the
427
+ # reason names the focused components for whoever reads the log.
428
+ if result['mode'] == 'full':
429
+ reason = 'reviewed-selection'
430
+ else:
431
+ reason = '+'.join(result['components']) or 'none'
432
+ print(f"{result['mode']} {reason}")
706
433
  elif args.filter_legs:
707
- filter_legs(result, args.filter_legs)
434
+ filter_legs(result, args.filter_legs, quarantined=quarantine_ids(args.repo))
708
435
  elif args.shell:
709
436
  print('mode\t' + result['mode'])
710
437
  print('harnesses\t' + ' '.join(result['selectedHarnesses']))
@@ -246,6 +246,12 @@ def _adjacent_pairs(regimes) -> dict:
246
246
  `now` with `status: "stale"` and appends a fresh regime starting at the
247
247
  analysis start, so the two are neither adjacent nor a rate transition. The
248
248
  status check is the second, independent guard.
249
+
250
+ #978 spec 4.5: a pair also requires one `fingerprint` and one
251
+ `coefficientEra`. Units-per-point from two eras measure two different
252
+ weightings, so their difference is a coefficient change and never a
253
+ metering-rate change. The comparison uses `.get()`, so a legacy pair in
254
+ which neither regime names an era keeps its treatment.
249
255
  """
250
256
  closed: dict = {}
251
257
  for regime in regimes:
@@ -266,6 +272,10 @@ def _adjacent_pairs(regimes) -> dict:
266
272
  predecessor = closed.get(start)
267
273
  if predecessor is None or predecessor is regime:
268
274
  continue
275
+ if predecessor.get("fingerprint") != regime.get("fingerprint") \
276
+ or predecessor.get("coefficientEra") \
277
+ != regime.get("coefficientEra"):
278
+ continue
269
279
  pairs[start] = (predecessor, regime)
270
280
  return pairs
271
281