kairos-chain 3.79.0 → 3.80.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 22b92a50151e63d6f243858700e0a0982dd569ce6df05133362dcda4077d6fd6
4
- data.tar.gz: d6d5559901257355633ba3a9d26a43af62293757b67f98c011bd795be4dee8f7
3
+ metadata.gz: e2c2d4657c313e3452dbdfaa619a7949d9682961fe4e9ccc15e772ff580f300a
4
+ data.tar.gz: f276e35c55991a4ea269309e7ff6f2a94208a4e86967dc9754685a258525645c
5
5
  SHA512:
6
- metadata.gz: 8f1bdd48fc2dd110b151e1bc2a8aff18278ea2bc0d97d801dcf3bdd086462e8d6d97b27fdd0a81b75dac820f84aa0786a076eeef84b8ae44d5012e1e435141c0
7
- data.tar.gz: 0c401f4330cff3dc4af3bf6a4277fcf68412e93fec79acc6eb50a12f7e19c84c6eec298ba5527938e8fc8e2dc983a192383f7babec0c1741dde0e47aca993985
6
+ metadata.gz: d10d880b482015c17e13014e432bf383cb8003f947ce1caabc4f3f24e775228facb81f042466917f530aa4c4018443cb33567c7fd8ba41c2e3a56149a4db2417
7
+ data.tar.gz: 2f4b29826187f483df3a95d601623f587e28eb5575670a235706c4bc7bc8bbc73d285f3ab60f9012fd0ae391a36f294c6a28866fb9a777d60bd1b361c7e9e987
data/CHANGELOG.md CHANGED
@@ -4,6 +4,65 @@ All notable changes to the `kairos-chain` gem will be documented in this file.
4
4
 
5
5
  This project follows [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [3.80.0] - 2026-08-27
8
+
9
+ ### Changed
10
+
11
+ - **`readable_gate`: the measurement bound is the hook's own remaining time,
12
+ not a mode's declaration.** `measure_timeout_seconds` is retired, and its
13
+ whole observable surface goes with it — the clamp, the type-error clause,
14
+ and the silent death where `0` or a negative passed the Integer check,
15
+ reached `Regexp.timeout=` and killed the gate with exit 0, empty stdout and
16
+ no log file. A mode file must not be able to silence the gate by arithmetic
17
+ (operator ruling, 2026-08-27). A config still declaring the key flows
18
+ through `checked()` as an unknown key and is read by nobody.
19
+
20
+ Time protection is now `bounded_match`, the one seam every mode-supplied
21
+ match passes through: remaining computed from an absolute deadline fixed at
22
+ `main`'s first statement, a floor at one millisecond (`Regexp.timeout=`
23
+ silently stores nil for positive values under 1e-9), the grant set to the
24
+ remaining time, and the grant revoked as the match ends — no match runs
25
+ under an authorisation granted to another. Announce, specimen, shorthand and
26
+ both gloss operands route through it; `each_match` starts every match inside
27
+ the seam, so no ordering exists in which a match begins before the clock is
28
+ read. The recheck's poll is charged as time actually spent, which retires
29
+ the old static arithmetic that billed a first read for the four seconds only
30
+ a recheck uses. `HOOK_TIMEOUT_MARGIN` drops from 1.0 to 0.5 (start-up
31
+ measured at a 0.033s median), recovering half the band the old margin
32
+ discarded.
33
+
34
+ **Three declared output differences** against the pre-bound gate: a config
35
+ declaring the key loses its problem clause and the gate runs when no other
36
+ problem remains; the measurement-exceeded banner quotes elapsed time against
37
+ the live budget instead of a mode-declared value; and the budget itself
38
+ grows from a flat 5s to the deadline's remainder (≈9.4s on a first read,
39
+ ≈5.4s on a recheck), so a measurement finishing between 5s and the budget
40
+ now yields a verdict where it used to yield NOT RUN. Probed at 10, 12 and 14
41
+ configurations by three independent contexts: no undeclared difference.
42
+
43
+ Design frozen at v0.7 after six review rounds; implementation conformance
44
+ round 1 REVISE, round 2 APPROVE 3/3 with zero DOES NOT HOLD. Suite 152 runs
45
+ / 948 assertions green; the mutation harness gains 19 rows paired one-to-one
46
+ with the design's table (anchors verified 72/72), plus a label filter and
47
+ early exit so a narrowed sweep is possible and says so in its summary.
48
+
49
+ ### Fixed
50
+
51
+ - **`readable_gate`: the carry-over note's truncation fallback refuses to
52
+ follow a symlink.** When a stale note cannot be deleted the gate empties it
53
+ in place, and that write followed whatever the note's name pointed at: with
54
+ a symlink planted at the note key and the notes directory made unwritable so
55
+ the unlink is refused, the emptying truncated a file outside the notes
56
+ directory. The open now carries `File::NOFOLLOW`, so a final-component
57
+ symlink fails with `ELOOP` and lands in the existing refusal arm — the
58
+ leaning is to refuse, never to write through a name to somewhere the path
59
+ did not say. The leftover link stays unspent and every later recheck refuses
60
+ it. A hard link and an intermediate-component symlink still reach through;
61
+ both need the same outside hand and are declared rather than closed.
62
+
63
+ - `kairos_hook_projector` SkillSet version 0.5.0 → 0.6.0, so instances
64
+ actually receive the two changes above on `skillset upgrade`.
65
+
7
66
  ## [3.79.0] - 2026-08-26
8
67
 
9
68
  ### Fixed
@@ -1,4 +1,4 @@
1
1
  module KairosMcp
2
- VERSION = "3.79.0"
2
+ VERSION = "3.80.0"
3
3
  CHANGELOG_URL = "https://github.com/masaomi/KairosChain_2026/blob/main/CHANGELOG.md"
4
4
  end
@@ -64,30 +64,26 @@ module KairosHookProjector
64
64
  # the limit emits nothing at all — no verdict, no banner, no log line — so
65
65
  # the gate stops enforcing and the measurement loses rows, both silently.
66
66
  HOOK_TIMEOUT = 10.0
67
- # Held back from the pair for interpreter start-up and for the poll's own
68
- # overhead. Measured 2026-08-22 on Ruby 3.4.10: 0.030s for the first read's
69
- # 15 tail reads, 0.075s for the recheck's 40 plus turn_marker, and roughly
70
- # 0.3s more for interpreter start-up. An earlier version of this line said
71
- # 0.2-0.6s for the poll alone, which the shipped code cannot reproduce.
72
- #
73
- # Round 5 measured start-up itself at a median 0.033s through the projected
74
- # `sh -c ruby …` form, so 1.0 is about thirty times what it holds back. Left
75
- # as it stands: the value is the measurement bound's, and the bound is no
76
- # longer part of this design. See docs/drafts on the bound.
77
- HOOK_TIMEOUT_MARGIN = 1.0
78
- # What is left for measurement once the recheck has spent its budget. A mode
79
- # asking for more than this is honoured up to here and no further: the
80
- # alternative is a mode file being able to silence the gate by arithmetic.
81
- #
82
- # A constant, and wrong in a way that is known and recorded: it charges a
83
- # first read for the four seconds only a recheck spends. Rounds 4 and 5
84
- # replaced it twice one ceiling per read, then a clock read at the top of
85
- # main and round 5's review falsified both, the second because a bound
86
- # applied per match bounds the number this file quotes and not the time it
87
- # spends. The bound is therefore carved out of this design and carried in
88
- # its own, and this line is back to the shape shipped since 6d7fe02.
89
- MEASURE_TIMEOUT_CEILING =
90
- HOOK_TIMEOUT - HOOK_TIMEOUT_MARGIN - (RECHECK_POLL_ATTEMPTS * POLL_DELAY)
67
+ # Held back for what the deadline cannot see: interpreter start-up before
68
+ # main's first clock read, and the output written after measurement ends.
69
+ # Start-up measured at 0.033s median over 8 runs through the projected
70
+ # `sh -c ruby …` form (2026-08-27, consistent with round 5's 0.033-0.039s);
71
+ # the log append and JSON emit are under 10ms. The previous value, 1.0, was
72
+ # about thirty times the measured start-up and discarded ~0.95s in which
73
+ # the pre-bound gate would have measured and blocked. Pinned by an equality
74
+ # assert, not by the wall-time band: the band's flake headroom and kill
75
+ # margin always sum to exactly this value, so no band can pin it robustly
76
+ # from both sides.
77
+ HOOK_TIMEOUT_MARGIN = 0.5
78
+ # The least remaining time bounded_match will grant. Two grounds. First,
79
+ # Regexp.timeout= silently stores nil no bound at all for positive
80
+ # values below 1e-9 (measured on Ruby 3.4.10: 1e-9 is kept, 7.5e-10 reads
81
+ # back nil), so a remaining that small must refuse rather than arm nothing.
82
+ # Second, a measurement with under a millisecond left cannot produce a
83
+ # verdict anyway; raising is the honest outcome. Pinned by equality in both
84
+ # directions: lowering it re-opens the silent-nil window, raising it
85
+ # quietly shrinks every budget.
86
+ MIN_TIMEOUT = 0.001
91
87
 
92
88
  # Built from single-quoted strings so that `#{` in a pattern stays literal
93
89
  # rather than becoming interpolation, and anchored with \A because Python's
@@ -110,12 +106,14 @@ module KairosHookProjector
110
106
  'specimen_patterns' => [],
111
107
  'vocab_min_lines' => 1,
112
108
  'blocking' => true,
113
- # A mode-supplied pattern with nested quantifiers backtracks without
114
- # bound: one such pattern had not returned after two minutes on a
115
- # 74-character line, burning the hook's whole budget every turn. Ruby
116
- # bounds each match with Regexp.timeout; the deadline in measure() keeps
117
- # the total bounded too, which is what the Python SIGALRM did.
118
- 'measure_timeout_seconds' => 5,
109
+ # measure_timeout_seconds is deliberately absent. It was a mode-declared
110
+ # bound on measurement time, and its whole observable surface the
111
+ # clamp, the type-error clause, the 0-or-negative silent death, and the
112
+ # 5-second budget itself was retired by the 2026-08-27 operator ruling
113
+ # ("no mode-facing bound setting"): a mode file must not be able to
114
+ # silence the gate by arithmetic. A config still declaring it is passed
115
+ # through checked() as an unknown key and read by nobody. Time protection
116
+ # is bounded_match's, derived from the hook's own remaining time.
119
117
  'log_max_bytes' => 1024 * 1024,
120
118
  'banner_prefix' => 'gate',
121
119
  # nil, and pinned here, because nil is what note() reads as "this mode
@@ -126,7 +124,7 @@ module KairosHookProjector
126
124
  }.freeze
127
125
 
128
126
  INT_KEYS = %w[max_lines max_headings max_tables diagram_required_over_lines
129
- vocab_min_lines measure_timeout_seconds log_max_bytes].freeze
127
+ vocab_min_lines log_max_bytes].freeze
130
128
  LIST_KEYS = %w[announce_patterns shorthand_patterns gloss_patterns
131
129
  specimen_patterns].freeze
132
130
  # log_path is deliberately absent. It was added here once, and the cost was
@@ -151,7 +149,7 @@ module KairosHookProjector
151
149
  :max_lines, :max_headings, :max_tables, :diagram_over_lines,
152
150
  :vocab_min_lines,
153
151
  :blocking, :banner_prefix, :rewrite_instruction, :log_path,
154
- :announce, :shorthand, :gloss, :specimen, :measure_timeout,
152
+ :announce, :shorthand, :gloss, :specimen,
155
153
  :log_max_bytes
156
154
 
157
155
  def initialize(raw, path)
@@ -188,18 +186,6 @@ module KairosHookProjector
188
186
  # list. Text explaining the vocabulary rule has to name the shapes it
189
187
  # governs, and naming them is not using them.
190
188
  @specimen = merged['specimen_patterns'].map { |p| Regexp.new(p) }
191
- # Clamped, not rejected: a mode that asks for 6 seconds gets what fits
192
- # rather than a refusal it cannot act on. 4 seconds of recheck poll plus
193
- # 6 of measurement is 10.4 against a 10-second hook limit, and the hook
194
- # dies with no output, so the gate would silently stop enforcing for
195
- # exactly the modes whose patterns are expensive enough to need the time.
196
- requested = merged['measure_timeout_seconds']
197
- @measure_timeout =
198
- if requested.is_a?(Numeric) && requested > MEASURE_TIMEOUT_CEILING
199
- MEASURE_TIMEOUT_CEILING
200
- else
201
- requested
202
- end
203
189
  @log_max_bytes = merged['log_max_bytes']
204
190
  end
205
191
 
@@ -474,6 +460,16 @@ module KairosHookProjector
474
460
  # the parse on every later read whatever the directory permits by then.
475
461
  # Only when both are refused does the leftover stay valid, and that state
476
462
  # is declared in design §7 rather than closed.
463
+ #
464
+ # The truncation refuses to follow a symlink. The note's name sits in
465
+ # operator space, and round 4 planted a link there — the directory then
466
+ # made unwritable so the unlink cannot spend it — and pointed the old
467
+ # File.write at a file outside the notes directory. NOFOLLOW turns that
468
+ # open into an ELOOP, which lands in the refusal arm below: the leaning is
469
+ # to refuse, never to write through a name to somewhere the path did not
470
+ # say. A hard link, or a link planted higher up the path, still reaches
471
+ # through — each needs the same outside hand that plants the link, and both
472
+ # are declared rather than closed.
477
473
  def spend_stale_note(path, reason)
478
474
  File.unlink(path)
479
475
  "#{reason}; a stale note was deleted"
@@ -481,7 +477,7 @@ module KairosHookProjector
481
477
  reason
482
478
  rescue StandardError
483
479
  begin
484
- File.write(path, '')
480
+ File.open(path, File::WRONLY | File::TRUNC | File::NOFOLLOW).close
485
481
  "#{reason}; a stale note could not be deleted and was emptied instead"
486
482
  rescue StandardError => e
487
483
  "#{reason}; a stale note could not be deleted (#{e.class})"
@@ -694,44 +690,74 @@ module KairosHookProjector
694
690
 
695
691
  # --- measurement ---------------------------------------------------------
696
692
 
697
- # The deadline reaches here because one pattern can produce many matches on
698
- # one line, and the specimen scan runs its own patterns before the shorthand
699
- # loop is entered at all. Bounding only the shorthand loop, which is what the
700
- # first fix did, left both of those unbounded — the same invariant-as-an-AND
701
- # with one half done that this work keeps producing.
702
- def each_match(regexp, string, deadline = nil)
703
- pos = 0
704
- while pos <= string.length && (m = regexp.match(string, pos))
705
- raise MeasureTimeout if deadline && monotonic > deadline
693
+ # The one gate every mode-supplied match passes through. The invariant is
694
+ # over match operations, not call sites: no mode pattern ever starts a
695
+ # match without a bound computed from the time actually remaining, and no
696
+ # match runs under an authorisation granted to another — the grant is
697
+ # revoked as the match ends, whichever way it ends. Both halves are
698
+ # review-taught: naming three sites and bounding those left announce and
699
+ # both gloss operands unbounded, and granting without revoking let a match
700
+ # outside the seam inherit the previous grant, which made every bypass
701
+ # unobservable (measured at +65µs against a band needing +0.25s).
702
+ #
703
+ # The floor: a remaining below MIN_TIMEOUT raises instead of arming the
704
+ # bound, because Regexp.timeout= silently stores nil — no bound at all —
705
+ # for positive values under 1e-9, and a sub-millisecond measurement cannot
706
+ # finish regardless. The check precedes the match, so no ordering exists
707
+ # in which a match starts and the clock is read only after it.
708
+ #
709
+ # A nil deadline is the --report path: offline calibration, run by the
710
+ # operator outside the hook limit, deliberately unbounded.
711
+ #
712
+ # monotonic is read through the module helper and must stay that way: the
713
+ # floor fixture stubs it and counts the calls, so inlining the clock here
714
+ # is a mutation that fixture kills.
715
+ def bounded_match(regexp, string, pos, deadline)
716
+ return regexp.match(string, pos) if deadline.nil?
717
+
718
+ remaining = deadline - monotonic
719
+ raise MeasureTimeout if remaining < MIN_TIMEOUT
720
+
721
+ Regexp.timeout = remaining
722
+ begin
723
+ regexp.match(string, pos)
724
+ ensure
725
+ Regexp.timeout = nil
726
+ end
727
+ end
728
+
729
+ # In a named helper so the formula is assertable: an assert against an
730
+ # inline expression only re-derives it, which is a test that cannot fail.
731
+ def deadline_for(started)
732
+ started + (HOOK_TIMEOUT - HOOK_TIMEOUT_MARGIN)
733
+ end
706
734
 
735
+ def each_match(regexp, string, deadline)
736
+ pos = 0
737
+ while pos <= string.length && (m = bounded_match(regexp, string, pos, deadline))
707
738
  yield m
708
739
  pos = m.end(0) > m.begin(0) ? m.end(0) : m.begin(0) + 1
709
740
  end
710
741
  end
711
742
 
712
- def specimen_spans(line, cfg, deadline = nil)
743
+ def specimen_spans(line, cfg, deadline)
713
744
  spans = []
714
745
  cfg.specimen.each do |pattern|
715
- raise MeasureTimeout if deadline && monotonic > deadline
716
-
717
746
  each_match(pattern, line, deadline) { |m| spans << [m.begin(0), m.end(0)] }
718
747
  end
719
748
  spans
720
749
  end
721
750
 
722
- # measure() under a wall-clock bound.
723
- #
724
- # Two bounds, because they catch different things. Regexp.timeout stops one
725
- # pattern that backtracks without returning. The deadline inside measure
726
- # stops many patterns that each return slowly which is what the Python
727
- # SIGALRM bounded, and what a per-operation limit alone would not.
728
- def measure_bounded(text, cfg)
729
- return measure(text, cfg, nil) unless cfg.measure_timeout
730
-
751
+ # measure() with the deadline threaded through. This wrapper owns the two
752
+ # things that sit outside any single match: whatever Regexp.timeout the
753
+ # caller had is saved and restored here, outside every per-match grant,
754
+ # and a match cut by Ruby's own Regexp::TimeoutError maps to the same
755
+ # MeasureTimeout the deadline raises one banner for both ways of
756
+ # running out.
757
+ def measure_bounded(text, cfg, deadline)
731
758
  previous = Regexp.timeout
732
- Regexp.timeout = cfg.measure_timeout
733
759
  begin
734
- measure(text, cfg, monotonic + cfg.measure_timeout)
760
+ measure(text, cfg, deadline)
735
761
  rescue Regexp::TimeoutError
736
762
  raise MeasureTimeout
737
763
  ensure
@@ -744,7 +770,7 @@ module KairosHookProjector
744
770
  end
745
771
 
746
772
  # Pure. [metrics, failures]. The whole judgement lives here.
747
- def measure(text, cfg, deadline = nil)
773
+ def measure(text, cfg, deadline)
748
774
  raw = text.split("\n", -1)
749
775
 
750
776
  prose = []
@@ -765,27 +791,20 @@ module KairosHookProjector
765
791
  tables = prose.count { |l| TABLE_SEP.match(l) }
766
792
 
767
793
  first = raw.find { |l| !l.strip.empty? } || ''
768
- announced = !(cfg.announce && cfg.announce.match(first)).nil?
794
+ announced = !(cfg.announce && bounded_match(cfg.announce, first, 0, deadline)).nil?
769
795
 
770
796
  seen = {}
771
797
  unglossed = []
772
798
  if !cfg.shorthand.empty? && prose.length >= cfg.vocab_min_lines
773
799
  prose.each_with_index do |line, i|
774
- # No per-line check. There was one here, and round 3 showed nothing
775
- # could falsify it: the specimen scan and the loop between patterns
776
- # both read the clock, and one of them runs before anything on a line
777
- # can be slow. An unfalsifiable guard is not defence, it is a claim,
778
- # so it is deleted rather than given a test that cannot fail.
800
+ # No per-line or per-pattern check. Every match on this line starts
801
+ # inside bounded_match, which reads the clock before it; a check here
802
+ # would be a guard no fixture can redden the shape this file
803
+ # deleted once already ("an unfalsifiable guard is not defence, it
804
+ # is a claim") and declines to reintroduce.
779
805
  nxt = i + 1 < prose.length ? prose[i + 1] : ''
780
806
  spans = specimen_spans(line, cfg, deadline)
781
807
  cfg.shorthand.each do |pattern|
782
- # Per pattern, not only per line. Regexp.timeout bounds one match,
783
- # so a line carrying n patterns could overshoot the deadline by
784
- # n times that bound while the clock was read only once, between
785
- # lines. The check above still stands: it catches a line whose
786
- # specimen scan alone is what runs long.
787
- raise MeasureTimeout if deadline && monotonic > deadline
788
-
789
808
  each_match(pattern, line, deadline) do |m|
790
809
  tok = m.size > 1 ? m[1] : m[0]
791
810
  next if tok.nil? || seen.key?(tok)
@@ -794,7 +813,8 @@ module KairosHookProjector
794
813
 
795
814
  seen[tok] = true
796
815
  after = line[m.end(0)..] || ''
797
- if cfg.gloss && !(cfg.gloss.match(after) || cfg.gloss.match(nxt))
816
+ if cfg.gloss && !(bounded_match(cfg.gloss, after, 0, deadline) ||
817
+ bounded_match(cfg.gloss, nxt, 0, deadline))
798
818
  unglossed << tok
799
819
  end
800
820
  end
@@ -990,6 +1010,14 @@ module KairosHookProjector
990
1010
  end
991
1011
 
992
1012
  def main(argv = ARGV, stdin = $stdin)
1013
+ # The first executable statement, before config or stdin are read: the
1014
+ # deadline is absolute, so everything main does — config load, transcript
1015
+ # reads, the recheck's poll sleeps — is charged against it as time
1016
+ # actually spent, and measurement bounds itself by whatever is left.
1017
+ # This is what retires the old static arithmetic that pre-charged a
1018
+ # first read for the four seconds only a recheck spends.
1019
+ started = monotonic
1020
+ deadline = deadline_for(started)
993
1021
  config_path, report_paths = parse_args(argv)
994
1022
  if config_path.nil?
995
1023
  # Zero, not two. The header two hundred lines up says the exit status is
@@ -1084,13 +1112,18 @@ module KairosHookProjector
1084
1112
  end
1085
1113
 
1086
1114
  begin
1087
- metrics, failures = measure_bounded(text, cfg)
1115
+ metrics, failures = measure_bounded(text, cfg, deadline)
1088
1116
  rescue MeasureTimeout
1089
1117
  lost = note(cfg, 'SKIP-measure-timeout')
1118
+ # Quotes the time actually spent and the budget it ran out of. The old
1119
+ # banner quoted the mode's declared bound — post-clamp, so a declared
1120
+ # 60 read "exceeded 5.0s" — and both the declaration and the clamp are
1121
+ # gone with the key.
1090
1122
  emit(
1091
1123
  'systemMessage' =>
1092
- "#{cfg.banner_prefix}: NOT RUN — measurement exceeded #{cfg.measure_timeout}s; " \
1093
- "check the mode's patterns for unbounded backtracking" +
1124
+ format('%s: NOT RUN — measurement ran out of the hook budget (%.1fs elapsed ' \
1125
+ "of %.1fs); check the mode's patterns for unbounded backtracking",
1126
+ cfg.banner_prefix, monotonic - started, deadline - started) +
1094
1127
  (lost ? "; #{log_note(lost)}" : '')
1095
1128
  )
1096
1129
  return 0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kairos_hook_projector",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Projects an instruction mode's declared hooks into the harness configuration. Stage 1 compiles a mode_hooks declaration to a hook artifact plus a compile record. Stage 2 installs it: mode_hooks_project proposes by default and writes nothing, applying only when the caller echoes back the artifact hash from the proposal, and rewriting only hook groups carrying this SkillSet's marker for that mode — hand-added hooks and other modes' hooks are counted and left alone. Ships the generic readable_gate implementation; a mode supplies its own thresholds, the core supplies none.",
5
5
  "author": "Masaomi Hatakeyama",
6
6
  "layer": "L1",
@@ -17,7 +17,14 @@
17
17
  # The harness is this SkillSet's own suite, unmodified: `SCRIPT` in the test
18
18
  # files resolves from `__dir__`, so the copied tests drive the copied hook.
19
19
  #
20
- # ruby test/mutation_check_readable_gate.rb
20
+ # ruby test/mutation_check_readable_gate.rb # full sweep
21
+ # ruby test/mutation_check_readable_gate.rb 'B\\d+' # rows whose label matches
22
+ #
23
+ # A label filter narrows the sweep to the rows named; the summary line then
24
+ # says FILTERED so a narrowed run can never be mistaken for a full one. The
25
+ # per-mutation suite run stops at the first file with a red — the question
26
+ # per row is binary — and the hook's own test file runs first, which is where
27
+ # a readable_gate mutation reds if it reds at all.
21
28
  #
22
29
  require 'fileutils'
23
30
  require 'tmpdir'
@@ -58,9 +65,11 @@ MUTATIONS = [
58
65
  ['M27 HOOK_TIMEOUT drifts from the limit the compiler declares',
59
66
  %q{ HOOK_TIMEOUT = 10.0},
60
67
  %q{ HOOK_TIMEOUT = 20.0}],
61
- ['M28 the interpreter start-up headroom is deleted',
62
- %q{ HOOK_TIMEOUT_MARGIN = 1.0},
63
- %q{ HOOK_TIMEOUT_MARGIN = 0.0}],
68
+ # M28 and M30 were the margin mutations against the old constant (1.0). The
69
+ # 2026-08-27 bound redesign moved the margin to 0.5 and pinned it by
70
+ # equality, and B14/B15 below carry both directions against the new value.
71
+ # Retired rather than retargeted: keeping two rows per direction would
72
+ # double-count one anchor.
64
73
 
65
74
  # --- round 3: the instrument the design reads its own evidence through -----
66
75
  ['M22 the waited token stops telling a wait from no wait',
@@ -74,9 +83,6 @@ MUTATIONS = [
74
83
  sleep(POLL_DELAY)
75
84
  next
76
85
  end}],
77
- ['M30 the start-up headroom shrinks but stays positive',
78
- %q{ HOOK_TIMEOUT_MARGIN = 1.0},
79
- %q{ HOOK_TIMEOUT_MARGIN = 0.05}],
80
86
  ['M31 the recheck budget grows instead of shrinking',
81
87
  %q{ RECHECK_POLL_ATTEMPTS = 40},
82
88
  %q{ RECHECK_POLL_ATTEMPTS = 95}],
@@ -306,7 +312,7 @@ MUTATIONS = [
306
312
  ['N32 the truncation fallback is deleted, so a lifted window revives the leftover',
307
313
  %q{ rescue StandardError
308
314
  begin
309
- File.write(path, '')
315
+ File.open(path, File::WRONLY | File::TRUNC | File::NOFOLLOW).close
310
316
  "#{reason}; a stale note could not be deleted and was emptied instead"
311
317
  rescue StandardError => e
312
318
  "#{reason}; a stale note could not be deleted (#{e.class})"
@@ -315,10 +321,118 @@ MUTATIONS = [
315
321
  %q{ rescue StandardError => e
316
322
  "#{reason}; a stale note could not be deleted (#{e.class})"
317
323
  end}],
324
+
325
+ # --- what round 4 of the conformance review found --------------------------
326
+ #
327
+ # The truncation is a write through a name in operator space. Two seats
328
+ # planted a symlink at the note key, made the directory unwritable so the
329
+ # unlink fails, and watched the old File.write empty a file outside the notes
330
+ # directory. This restores that write; the fix's NOFOLLOW is what it deletes.
331
+ ['N33 the truncation follows a symlink to whatever it names',
332
+ %q{ File.open(path, File::WRONLY | File::TRUNC | File::NOFOLLOW).close},
333
+ %q{ File.write(path, '')}],
334
+
335
+ # --- the measurement bound: the seam and its witnesses (design v0.7 §5-12) --
336
+ #
337
+ # Every mutation here is paired with the fixture the design names as its
338
+ # killer; the pairing is the §5-12 table, and anchor verification before a
339
+ # sweep is what keeps it honest. Mutations that unbound a runaway make the
340
+ # affected fixtures run to run_script's 30s outer kill, so those rows cost
341
+ # minutes each in a sweep — declared, not accidental.
342
+ ['B1 the floor predicate is loosened to <= 0',
343
+ %q{ raise MeasureTimeout if remaining < MIN_TIMEOUT},
344
+ %q{ raise MeasureTimeout if remaining <= 0}],
345
+ ['B2 the raise (floor included) is deleted',
346
+ %q{ raise MeasureTimeout if remaining < MIN_TIMEOUT},
347
+ %q{ # raise deleted}],
348
+ ['B3 the floor constant is lowered into the silent-nil window',
349
+ %q{ MIN_TIMEOUT = 0.001},
350
+ %q{ MIN_TIMEOUT = 1.0e-12}],
351
+ ['B4 the floor constant is raised, quietly shrinking every budget',
352
+ %q{ MIN_TIMEOUT = 0.001},
353
+ %q{ MIN_TIMEOUT = 1.0}],
354
+ ['B5 the grant is never armed',
355
+ %q{ Regexp.timeout = remaining
356
+ begin},
357
+ %q{ begin}],
358
+ ['B6 the TimeoutError-to-MeasureTimeout mapping is deleted',
359
+ %q{ rescue Regexp::TimeoutError
360
+ raise MeasureTimeout},
361
+ %q{ rescue Regexp::TimeoutError
362
+ raise}],
363
+ ['B7 the clock read is dropped from the remaining computation',
364
+ %q{ remaining = deadline - monotonic},
365
+ %q{ remaining = deadline}],
366
+ ['B8 each_match reverts to matching before the seam checks',
367
+ %q{ while pos <= string.length && (m = bounded_match(regexp, string, pos, deadline))
368
+ yield m},
369
+ %q{ while pos <= string.length && (m = regexp.match(string, pos))
370
+ raise MeasureTimeout if deadline && monotonic > deadline
371
+ yield m}],
372
+ ['B19 the seam inlines the clock, putting it beyond the floor fixture stub',
373
+ %q{ remaining = deadline - monotonic},
374
+ %q{ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)}],
375
+ # Five bypass targets, three dedicated rows: specimen and shorthand have no
376
+ # independent seam call site — both reach bounded_match only through
377
+ # each_match's single call — so their bypass IS the each_match reversion,
378
+ # which is B8. B10-B12 carry the three families with call sites of their own.
379
+ ['B9 the announce call drops its deadline argument',
380
+ %q{ announced = !(cfg.announce && bounded_match(cfg.announce, first, 0, deadline)).nil?},
381
+ %q{ announced = !(cfg.announce && bounded_match(cfg.announce, first, 0, nil)).nil?}],
382
+ ['B10 the announce match bypasses the seam entirely',
383
+ %q{ announced = !(cfg.announce && bounded_match(cfg.announce, first, 0, deadline)).nil?},
384
+ %q{ announced = !(cfg.announce && cfg.announce.match(first)).nil?}],
385
+ ['B11 the gloss after-operand bypasses the seam',
386
+ %q{ if cfg.gloss && !(bounded_match(cfg.gloss, after, 0, deadline) ||
387
+ bounded_match(cfg.gloss, nxt, 0, deadline))},
388
+ %q{ if cfg.gloss && !(cfg.gloss.match(after) ||
389
+ bounded_match(cfg.gloss, nxt, 0, deadline))}],
390
+ ['B12 the gloss nxt-operand bypasses the seam behind the short-circuit',
391
+ %q{ if cfg.gloss && !(bounded_match(cfg.gloss, after, 0, deadline) ||
392
+ bounded_match(cfg.gloss, nxt, 0, deadline))},
393
+ %q{ if cfg.gloss && !(bounded_match(cfg.gloss, after, 0, deadline) ||
394
+ cfg.gloss.match(nxt))}],
395
+ ['B13 the deadline is recomputed at the measurement call instead of main head',
396
+ %q{ metrics, failures = measure_bounded(text, cfg, deadline)},
397
+ %q{ metrics, failures = measure_bounded(text, cfg, deadline_for(monotonic))}],
398
+ ['B14 the margin collapses to zero',
399
+ %q{ HOOK_TIMEOUT_MARGIN = 0.5},
400
+ %q{ HOOK_TIMEOUT_MARGIN = 0.0}],
401
+ ['B15 the margin swallows half the budget',
402
+ %q{ HOOK_TIMEOUT_MARGIN = 0.5},
403
+ %q{ HOOK_TIMEOUT_MARGIN = 5.0}],
404
+ ['B16 the report path loses the nil-deadline passthrough',
405
+ %q{ return regexp.match(string, pos) if deadline.nil?
406
+
407
+ remaining = deadline - monotonic},
408
+ %q{ remaining = deadline - monotonic}],
409
+ ['B17 the revocation is deleted, so a grant outlives its match',
410
+ %q{ Regexp.timeout = remaining
411
+ begin
412
+ regexp.match(string, pos)
413
+ ensure
414
+ Regexp.timeout = nil
415
+ end},
416
+ %q{ Regexp.timeout = remaining
417
+ regexp.match(string, pos)}],
418
+ ['B18 the revocation is demoted to a trailing statement, leaking on the cut exit',
419
+ %q{ Regexp.timeout = remaining
420
+ begin
421
+ regexp.match(string, pos)
422
+ ensure
423
+ Regexp.timeout = nil
424
+ end},
425
+ %q{ Regexp.timeout = remaining
426
+ m = regexp.match(string, pos)
427
+ Regexp.timeout = nil
428
+ m}],
318
429
  ].freeze
319
430
 
320
- def run_suite(root)
431
+ def run_suite(root, early_exit: true)
321
432
  files = Dir[File.join(root, 'test', 'test_*.rb')].sort
433
+ # The hook's own suite first: a readable_gate mutation reds there if it reds
434
+ # at all, and the early exit below then skips the unrelated files.
435
+ files = files.partition { |f| File.basename(f) == 'test_readable_gate.rb' }.flatten
322
436
  runs = 0
323
437
  bad = 0
324
438
  unparseable = []
@@ -332,11 +446,18 @@ def run_suite(root)
332
446
  unparseable << File.basename(f)
333
447
  bad += 1 unless st.success?
334
448
  end
449
+ break if early_exit && bad.positive?
335
450
  end
336
451
  [runs, bad, unparseable, files.length]
337
452
  end
338
453
 
339
454
  exit_code = 0
455
+ label_filter = ARGV[0] && Regexp.new(ARGV[0])
456
+ selected = label_filter ? MUTATIONS.select { |l, _, _| l.match?(label_filter) } : MUTATIONS
457
+ if label_filter
458
+ puts format('FILTERED sweep: %d of %d rows match %s — NOT a full sweep',
459
+ selected.length, MUTATIONS.length, label_filter.inspect)
460
+ end
340
461
 
341
462
  Dir.mktmpdir do |tmp|
342
463
  root = File.join(tmp, File.basename(SKILLSET_ROOT))
@@ -344,7 +465,7 @@ Dir.mktmpdir do |tmp|
344
465
  hook = File.join(root, 'hooks', 'readable_gate.rb')
345
466
  pristine = File.read(hook)
346
467
 
347
- runs, bad, unparseable, nfiles = run_suite(root)
468
+ runs, bad, unparseable, nfiles = run_suite(root, early_exit: false)
348
469
  puts format('BASELINE (unmutated copy): %d test files, %d runs, %d failures+errors%s',
349
470
  nfiles, runs, bad, unparseable.empty? ? '' : " [unparseable: #{unparseable.join(', ')}]")
350
471
  puts
@@ -356,7 +477,7 @@ Dir.mktmpdir do |tmp|
356
477
  killed = 0
357
478
  survived = 0
358
479
  not_applied = 0
359
- MUTATIONS.each do |label, from, to|
480
+ selected.each do |label, from, to|
360
481
  unless pristine.include?(from)
361
482
  not_applied += 1
362
483
  puts format('%-70s ANCHOR NOT FOUND', label)
@@ -381,7 +502,8 @@ Dir.mktmpdir do |tmp|
381
502
 
382
503
  puts
383
504
  puts "source restored byte-identical: #{File.read(hook) == pristine}"
384
- puts "killed #{killed}/#{MUTATIONS.length}, survived #{survived}, anchor-not-found #{not_applied}"
505
+ scope = label_filter ? "FILTERED #{selected.length} of #{MUTATIONS.length}" : MUTATIONS.length.to_s
506
+ puts "killed #{killed}/#{scope}, survived #{survived}, anchor-not-found #{not_applied}"
385
507
  exit_code = 1 if survived.positive? || not_applied.positive?
386
508
  end
387
509