kairos-chain 3.82.0 → 3.83.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: 4da090fdc44c60e73073401b9987dbe17377bda02e95048966ecdf187f63b71e
4
- data.tar.gz: 6d24ce2aeab418e8c2a012ec7f1dd43285ede720db03bc5802f75defde87507b
3
+ metadata.gz: e601238b25375836907f7eec155beb0297e940cd5fd419ea855b7d48a3335993
4
+ data.tar.gz: 1c720eaa88d3204eedc436e38cdabaa3e79df3b9b5aca6260c925ec4173c6e62
5
5
  SHA512:
6
- metadata.gz: 5f50569f19bf383b545aab43f6a831419f60cb3d156e15a385e98c68d175fc1ae1c6b7fd4bf288af49b975fcaec18b212ed0aecfcc77df09125aed8b29e2599b
7
- data.tar.gz: 5782f2d19a495a354d62a622c7fa25d3494e10aafeb0513f9c8c47677a511c5615cbe6ef8065687113ac5482278c9b85dea44aad80cc21abd81154de49557a17
6
+ metadata.gz: d13dbb392110ebc2f5f913bc38e370f7c4c36ee8067d69d060ab4379f1a34cc9db98a37ee09b845d633a4c76821194bb5aa8eaeef6db6c77e43784f7831150f7
7
+ data.tar.gz: 34eca89babce68b01e805b6510967f0d1e2dca0faa0a856d1ec7a8881fd7e4a00cf90932c69ed7685cefe89e482144566ca4733d1efc433c2bf0b0038d9080af
data/CHANGELOG.md CHANGED
@@ -4,6 +4,71 @@ 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.83.0] - 2026-09-01
8
+
9
+ ### Added — readable_gate measures sentence length
10
+
11
+ `kairos_hook_projector` 0.6.0 → 0.7.0. The gate now reports three sentence
12
+ metrics on every turn (`sentences`, `sentence_p90`, `sentence_max`) and honours
13
+ two new thresholds, both off unless a mode names them:
14
+ `max_sentence_chars_p90` and `sentence_min_count`.
15
+
16
+ Why it was missing: the readable-output norm this gate exists to enforce names
17
+ long sentences as its first observed failure form, and says "split overlong
18
+ sentences" outright. Nothing in `measure()` was sentence-aware. The one symptom
19
+ the norm names by hand was the one nothing measured, while the line cap — which
20
+ the same norm calls a soft target rather than a gate — was enforced as a hard
21
+ block.
22
+
23
+ The cap is on the 90th percentile, not the mean and not the maximum: a mean
24
+ hides the tail that does the damage, and a maximum fails a good answer for its
25
+ one long sentence. Nearest-rank, so the number reported is the length of a
26
+ sentence actually in the message. The length announcement does not clear it,
27
+ for the diagram floor's reason — announcing that a message is long says nothing
28
+ about whether its sentences are.
29
+
30
+ `sentence_min_count` is not a convenience. Nearest-rank puts the 90th
31
+ percentile at the last element for every count under ten, so below that floor
32
+ the cap would be a maximum cap under another name.
33
+
34
+ What counts as a sentence, each rule with a killed mutation behind it:
35
+
36
+ - Sentences never span a line. A bullet with no terminating punctuation is one
37
+ sentence the length of its line, which is what it costs to read rather than
38
+ an artefact of the author's punctuation.
39
+ - Headings, table rows, horizontal rules and blockquotes are not sentences. A
40
+ seven-column table row measured as one reads as a single 200-character
41
+ sentence and takes over every percentile in the message; and a quotation
42
+ cannot be shortened without cutting quoted facts, which a mode's own rewrite
43
+ instruction is likely to forbid — leaving "stop quoting sources" as the only
44
+ compliance.
45
+ - A list marker is stripped rather than skipped. The text after it is prose the
46
+ reader reads, and skipping list items would exempt most bullet-written output.
47
+ - A URL collapses to one character: it is clicked, not read.
48
+ - An ASCII full stop splits only before whitespace. Without that condition
49
+ `readable_gate.rb`, `v0.4.6` and `3.82.0` shatter into fragments and the
50
+ measured tail collapses towards zero — a metric reporting every message as
51
+ readable.
52
+
53
+ Declared and not closed: prose moved inside a fenced block is not measured.
54
+ That is the hole the line count has always had, and closing it for one metric
55
+ only would leave the two disagreeing about what the message contains.
56
+
57
+ The core ships no number. `--report` gained `sent`, `sent_p90` and `sent_max`
58
+ columns and is where a mode's cap comes from; `mode_hooks/_EXAMPLE.json`
59
+ documents both keys and deliberately omits values, since the right one depends
60
+ on what the author writes and in which script.
61
+
62
+ - Cost: 0.9 ms of a 3.2 ms full measurement on a 25,719-character message,
63
+ against the hook's 9.5 s budget.
64
+ - Tests: 168 runs, 997 assertions, 0 failures (was 152 runs; 16 new fixtures).
65
+ Mutation sweep filtered to the new family — 19/19 killed, 0 survived, 0
66
+ anchor-not-found, source restored byte-identical, 11-file baseline green at
67
+ 325 runs.
68
+ - One existing mutation anchor (M39) was retargeted: the sentence columns were
69
+ inserted into the argument list it points at, and a stale anchor reports
70
+ ANCHOR NOT FOUND rather than a survivor.
71
+
7
72
  ## [3.82.0] - 2026-08-31
8
73
 
9
74
  ### Added — L1 knowledge: html_slide_deck_authoring
@@ -1,4 +1,4 @@
1
1
  module KairosMcp
2
- VERSION = "3.82.0"
2
+ VERSION = "3.83.0"
3
3
  CHANGELOG_URL = "https://github.com/masaomi/KairosChain_2026/blob/main/CHANGELOG.md"
4
4
  end
@@ -92,6 +92,47 @@ module KairosHookProjector
92
92
  HEADING = Regexp.new('\A#{1,6}\s+\S')
93
93
  TABLE_SEP = Regexp.new('\A\s*\|?[\s:|-]*-[\s:|-]*\|[\s:|-]*\z')
94
94
 
95
+ # --- sentence measurement ------------------------------------------------
96
+ #
97
+ # Every pattern below is the core's own and fixed, so none of it goes
98
+ # through bounded_match. That seam exists for mode-supplied patterns, whose
99
+ # backtracking the core cannot bound by inspection; these are constant and
100
+ # linear, exactly like FENCE, HEADING and TABLE_SEP above.
101
+ #
102
+ # A table row and a horizontal rule carry no sentence, and measuring one as
103
+ # a sentence is the first thing that goes wrong when a length metric meets
104
+ # this project's output: a seven-column row reads as a single 200-character
105
+ # sentence and dominates every percentile taken over the message.
106
+ TABLE_ROW = Regexp.new('\A\s*\|')
107
+ RULE = Regexp.new('\A\s*(?:[-*_]\s*){3,}\z')
108
+ # A blockquote is not the author's sentence. Dropping it is not leniency:
109
+ # the mode's own rewrite instruction forbids cutting quoted facts, so a cap
110
+ # that fires on a quotation demands the one repair the instruction refuses,
111
+ # and the only compliance left is to stop quoting sources. Verbatim source
112
+ # text was one of the two false-positive classes in the 2026-09-01
113
+ # calibration.
114
+ QUOTE = Regexp.new('\A\s*>')
115
+ # A URL is one token however long it is: it is clicked, not read. Collapsed
116
+ # rather than dropped, so the sentence around it is still measured — the
117
+ # other false-positive class was the citation list, 226 to 333 characters
118
+ # of which almost none was prose.
119
+ URL = Regexp.new('(?:https?|ftp)://[^\s)]*')
120
+ # A list marker is stripped, not skipped. The text after it is prose the
121
+ # reader reads, and a bullet that runs to 150 characters is precisely what
122
+ # this metric exists to catch; skipping list items would have exempted most
123
+ # of this instance's output, which is written in bullets.
124
+ LIST_MARKER = Regexp.new('\A\s*(?:[-*+]|[0-9]+[.)])\s+')
125
+ # Where one sentence ends and the next begins. Two arms, because the corpus
126
+ # is mixed script. A CJK full stop needs no following space; an ASCII one
127
+ # does, and without that condition `readable_gate.rb`, `v0.4.6` and `3.82.0`
128
+ # each split into fragments and the measured tail collapses. Both arms are
129
+ # fixed-width lookbehind, which is all Ruby permits.
130
+ #
131
+ # Sentences never span a line. A bullet with no terminating punctuation is
132
+ # then one sentence the length of its line, which is the honest measure of
133
+ # what it costs to read rather than an artefact of the author's punctuation.
134
+ SENTENCE_BREAK = Regexp.new('(?<=[。.!?])|(?<=[.!?])(?=\s)')
135
+
95
136
  DEFAULTS = {
96
137
  'max_lines' => nil,
97
138
  'max_headings' => nil,
@@ -100,6 +141,20 @@ module KairosHookProjector
100
141
  # diagram is what the rule is about. Off unless the mode names a number,
101
142
  # like every other threshold here.
102
143
  'diagram_required_over_lines' => nil,
144
+ # The tail of the sentence-length distribution. The mode body names long
145
+ # sentences as the first observed failure form and says "split overlong
146
+ # sentences" in so many words, and until 2026-09-01 nothing here was
147
+ # sentence-aware: the one symptom the norm names by hand was the one
148
+ # nothing measured. Off unless the mode names a number, like every other
149
+ # threshold here.
150
+ 'max_sentence_chars_p90' => nil,
151
+ # Below this many measured sentences the cap above does not apply. Not a
152
+ # convenience. Nearest-rank puts the 90th percentile at index
153
+ # ceil(0.9n) - 1, which is the last element for every n under ten — so on
154
+ # a short message the percentile IS the maximum, and a cap applied there
155
+ # is a maximum cap by another name, failing a good answer for one long
156
+ # sentence. That is the instrument the metric was chosen to avoid.
157
+ 'sentence_min_count' => 1,
103
158
  'announce_patterns' => [],
104
159
  'shorthand_patterns' => [],
105
160
  'gloss_patterns' => [],
@@ -124,6 +179,7 @@ module KairosHookProjector
124
179
  }.freeze
125
180
 
126
181
  INT_KEYS = %w[max_lines max_headings max_tables diagram_required_over_lines
182
+ max_sentence_chars_p90 sentence_min_count
127
183
  vocab_min_lines log_max_bytes].freeze
128
184
  LIST_KEYS = %w[announce_patterns shorthand_patterns gloss_patterns
129
185
  specimen_patterns].freeze
@@ -147,6 +203,7 @@ module KairosHookProjector
147
203
  class Config
148
204
  attr_reader :problems, :path, :mode_name, :mode_version, :section,
149
205
  :max_lines, :max_headings, :max_tables, :diagram_over_lines,
206
+ :max_sentence_p90, :sentence_min_count,
150
207
  :vocab_min_lines,
151
208
  :blocking, :banner_prefix, :rewrite_instruction, :log_path,
152
209
  :announce, :shorthand, :gloss, :specimen,
@@ -169,6 +226,12 @@ module KairosHookProjector
169
226
  @max_headings = merged['max_headings']
170
227
  @max_tables = merged['max_tables']
171
228
  @diagram_over_lines = merged['diagram_required_over_lines']
229
+ # Named max_sentence_p90 on this side and max_sentence_chars_p90 on the
230
+ # mode's. The config key says what the number is measured in, because a
231
+ # mode author writing `"max_sentence_p90": 90` has no way to know from
232
+ # the name whether 90 is characters or a rank.
233
+ @max_sentence_p90 = merged['max_sentence_chars_p90']
234
+ @sentence_min_count = merged['sentence_min_count']
172
235
  @vocab_min_lines = merged['vocab_min_lines']
173
236
  # Declared by the mode, written into this config by the compiler, and
174
237
  # honoured here. A mode that declares blocking:false gets the verdict
@@ -769,6 +832,45 @@ module KairosHookProjector
769
832
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
770
833
  end
771
834
 
835
+ # The character length of every sentence in the measured prose, unsorted.
836
+ #
837
+ # Fenced blocks never reach here — `prose` has already dropped them — which
838
+ # is a way past this metric: prose moved inside a fence stops being
839
+ # measured. Declared rather than closed, because it is the same hole
840
+ # `lines` has had since the gate was written, and closing it for one metric
841
+ # only would leave the two disagreeing about what the message contains.
842
+ def sentence_lengths(prose)
843
+ out = []
844
+ prose.each do |line|
845
+ next if HEADING.match(line) || TABLE_ROW.match(line) ||
846
+ RULE.match(line) || QUOTE.match(line)
847
+
848
+ body = line.sub(LIST_MARKER, '').gsub(URL, '#').strip
849
+ next if body.empty?
850
+
851
+ body.split(SENTENCE_BREAK).each do |sentence|
852
+ sentence = sentence.strip
853
+ out << sentence.length unless sentence.empty?
854
+ end
855
+ end
856
+ out
857
+ end
858
+
859
+ # Nearest-rank percentile over an unsorted list, and 0 for an empty one.
860
+ #
861
+ # A percentile rather than a mean or a maximum: a mean hides the tail that
862
+ # does the damage, and a maximum fails a good answer for its one long
863
+ # sentence. Nearest-rank rather than an interpolating definition, so the
864
+ # number the operator is shown is the length of a sentence that is actually
865
+ # in the message and can be found by reading it.
866
+ def percentile(values, fraction)
867
+ return 0 if values.empty?
868
+
869
+ sorted = values.sort
870
+ index = (fraction * sorted.length).ceil - 1
871
+ sorted[index.clamp(0, sorted.length - 1)]
872
+ end
873
+
772
874
  # Pure. [metrics, failures]. The whole judgement lives here.
773
875
  def measure(text, cfg, deadline)
774
876
  raw = text.split("\n", -1)
@@ -789,6 +891,7 @@ module KairosHookProjector
789
891
 
790
892
  headings = prose.count { |l| HEADING.match(l) }
791
893
  tables = prose.count { |l| TABLE_SEP.match(l) }
894
+ lengths = sentence_lengths(prose)
792
895
 
793
896
  first = raw.find { |l| !l.strip.empty? } || ''
794
897
  announced = !(cfg.announce && bounded_match(cfg.announce, first, 0, deadline)).nil?
@@ -827,6 +930,9 @@ module KairosHookProjector
827
930
  'headings' => headings,
828
931
  'tables' => tables,
829
932
  'diagrams' => diagrams,
933
+ 'sentences' => lengths.length,
934
+ 'sentence_p90' => percentile(lengths, 0.9),
935
+ 'sentence_max' => lengths.max || 0,
830
936
  'announced' => announced,
831
937
  'unglossed' => unglossed
832
938
  }
@@ -844,6 +950,21 @@ module KairosHookProjector
844
950
  if cfg.max_tables && tables > cfg.max_tables
845
951
  failures << format('TABLES: %d (cap %d).', tables, cfg.max_tables)
846
952
  end
953
+ # The announcement does not clear this one either, for the diagram
954
+ # floor's reason: saying a message is long says nothing about whether its
955
+ # sentences are, and the two exemptions must not be shared. The count and
956
+ # the maximum travel with the verdict because the cap alone does not say
957
+ # how far off the message is, or whether one sentence or twenty carried it
958
+ # there.
959
+ if cfg.max_sentence_p90 && metrics['sentences'] >= cfg.sentence_min_count &&
960
+ metrics['sentence_p90'] > cfg.max_sentence_p90
961
+ failures << format(
962
+ 'SENTENCES: the 90th-percentile sentence is %d characters (cap %d); ' \
963
+ 'the longest is %d, over %d sentences. Split the long ones.',
964
+ metrics['sentence_p90'], cfg.max_sentence_p90,
965
+ metrics['sentence_max'], metrics['sentences']
966
+ )
967
+ end
847
968
  # A floor, and the announcement does not clear it: announcing that a
848
969
  # message is long says nothing about whether prose was the right carrier
849
970
  # for what is in it.
@@ -883,8 +1004,10 @@ module KairosHookProjector
883
1004
  detail += "\trec=#{record_id[0, 8]}" if record_id.is_a?(String) && !record_id.empty?
884
1005
  if metrics
885
1006
  detail += format(
886
- "\tlines=%d\theadings=%d\ttables=%d\tdiagrams=%d\tunglossed=%s",
1007
+ "\tlines=%d\theadings=%d\ttables=%d\tdiagrams=%d" \
1008
+ "\tsent=%d\tsent_p90=%d\tsent_max=%d\tunglossed=%s",
887
1009
  metrics['lines'], metrics['headings'], metrics['tables'], metrics['diagrams'],
1010
+ metrics['sentences'], metrics['sentence_p90'], metrics['sentence_max'],
888
1011
  metrics['unglossed'].empty? ? '-' : metrics['unglossed'].join(',')
889
1012
  )
890
1013
  end
@@ -936,9 +1059,10 @@ module KairosHookProjector
936
1059
  end
937
1060
 
938
1061
  def banner(cfg, verdict, metrics, failures, rechecked, log_failure = nil)
939
- shape = format('%d lines / %d headings / %d tables / %d diagrams',
1062
+ shape = format('%d lines / %d headings / %d tables / %d diagrams / ' \
1063
+ 'sentence p90 %d chars',
940
1064
  metrics['lines'], metrics['headings'], metrics['tables'],
941
- metrics['diagrams'])
1065
+ metrics['diagrams'], metrics['sentence_p90'])
942
1066
  notes = []
943
1067
  notes << failures.map { |f| f.split(':').first }.join(' / ') unless failures.empty?
944
1068
  notes << log_note(log_failure) if log_failure
@@ -977,8 +1101,15 @@ module KairosHookProjector
977
1101
  next unless text
978
1102
 
979
1103
  m, f = measure(text, cfg, nil)
980
- puts format("%s\tlines=%d\theadings=%d\ttables=%d\tFAIL=%s",
1104
+ # The sentence columns are here for the reason the whole metric was
1105
+ # added off: no number was proposed with it, and the distribution
1106
+ # these three columns produce over a real corpus is where one comes
1107
+ # from. A cap picked from anything smaller repeats the mistake the
1108
+ # metric was written to correct.
1109
+ puts format("%s\tlines=%d\theadings=%d\ttables=%d" \
1110
+ "\tsent=%d\tsent_p90=%d\tsent_max=%d\tFAIL=%s",
981
1111
  path, m['lines'], m['headings'], m['tables'],
1112
+ m['sentences'], m['sentence_p90'], m['sentence_max'],
982
1113
  f.empty? ? '-' : f.map { |x| x.split(':').first }.join(','))
983
1114
  end
984
1115
  end
@@ -50,7 +50,7 @@
50
50
  "Stop": [
51
51
  {
52
52
  "gate": "readable_gate",
53
- "_description": "Measures the turn's last message against declared readability limits — length, headings, tables, undefined shorthand, a missing diagram — and reports to its log, or blocks the turn once blocking is true.",
53
+ "_description": "Measures the turn's last message against declared readability limits — length, headings, tables, sentence length, undefined shorthand, a missing diagram — and reports to its log, or blocks the turn once blocking is true.",
54
54
  "section": "§ Readable output",
55
55
 
56
56
  "_blocking_note": "false on purpose in the example. The vocabulary rule below is the one thing here that cannot be got right by reading: whether a shape is coined shorthand or an ordinary technical word depends on what you write about. Start in report-only mode, read your log for a week, then set this to true. Shipping the example with true meant a consumer who copied it as instructed was blocked on the first message mentioning a character encoding.",
@@ -63,6 +63,8 @@
63
63
  "_diagram_note": "A floor rather than a cap: over this many lines of prose, a message carrying no fenced block is reported. Omit the key to leave the floor off. Announcing the length does not clear it — an announcement says the message is long, not that prose was the right carrier.",
64
64
  "diagram_required_over_lines": 30,
65
65
 
66
+ "_sentence_note": "A cap on the 90th percentile of sentence length in characters, not the mean and not the maximum: a mean hides the tail that does the damage, and a maximum fails a good answer for its one long sentence. Both keys are omitted here on purpose, because the right number depends entirely on what you write — a Japanese sentence, an English one and a line of dense technical prose are not the same length at the same reading cost. Run `readable_gate.rb --config <cfg> --report <transcript.jsonl>` over your own answers first: it prints sent, sent_p90 and sent_max per message, and the number to set is read off that distribution. To turn it on, add: \"max_sentence_chars_p90\": <number>, \"sentence_min_count\": 10. The floor is not a convenience — nearest-rank puts the 90th percentile at the last element for every count under ten, so below it the cap is a maximum cap under another name. Table rows, headings, horizontal rules and blockquotes are not measured as sentences, a URL counts as one token however long, and a list marker is stripped rather than skipped so a long bullet is still measured.",
67
+
66
68
  "vocab_min_lines": 8,
67
69
  "banner_prefix": "readable gate",
68
70
  "announce_patterns": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kairos_hook_projector",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",
@@ -89,11 +89,21 @@ MUTATIONS = [
89
89
  ['M32 the first read waits a third as long',
90
90
  %q{ POLL_ATTEMPTS = 15},
91
91
  %q{ POLL_ATTEMPTS = 5}],
92
+ # Retargeted 2026-09-01 when the three sentence columns were inserted between
93
+ # diagrams= and unglossed=. The anchor is the whole argument list, so an
94
+ # anchor left pointing at the four-column form would have reported ANCHOR NOT
95
+ # FOUND rather than a survivor — visible, but only to someone reading the
96
+ # sweep output rather than its summary line.
92
97
  ['M39 the log swaps the headings and tables columns',
93
- %q{ "\tlines=%d\theadings=%d\ttables=%d\tdiagrams=%d\tunglossed=%s",
94
- metrics['lines'], metrics['headings'], metrics['tables'], metrics['diagrams'],},
95
- %q{ "\tlines=%d\theadings=%d\ttables=%d\tdiagrams=%d\tunglossed=%s",
96
- metrics['lines'], metrics['tables'], metrics['headings'], metrics['diagrams'],}],
98
+ %q{ metrics['lines'], metrics['headings'], metrics['tables'], metrics['diagrams'],
99
+ metrics['sentences'], metrics['sentence_p90'], metrics['sentence_max'],},
100
+ %q{ metrics['lines'], metrics['tables'], metrics['headings'], metrics['diagrams'],
101
+ metrics['sentences'], metrics['sentence_p90'], metrics['sentence_max'],}],
102
+ ['M60 the log swaps the sentence p90 and max columns',
103
+ %q{ metrics['sentences'], metrics['sentence_p90'], metrics['sentence_max'],
104
+ metrics['unglossed'].empty? ? '-' : metrics['unglossed'].join(',')},
105
+ %q{ metrics['sentences'], metrics['sentence_max'], metrics['sentence_p90'],
106
+ metrics['unglossed'].empty? ? '-' : metrics['unglossed'].join(',')}],
97
107
  ['M42 the unreadable exit polls before giving up',
98
108
  %q{ rows = tail_records(transcript_path)
99
109
  return [nil, 'unreadable', nil] if rows.nil?},
@@ -415,6 +425,81 @@ MUTATIONS = [
415
425
  end},
416
426
  %q{ Regexp.timeout = remaining
417
427
  regexp.match(string, pos)}],
428
+ # --- sentence length: the metric added 2026-09-01 --------------------------
429
+ #
430
+ # The norm this gate enforces names long sentences as its first failure form
431
+ # and says "split overlong sentences" outright, and nothing here was
432
+ # sentence-aware until now. Each row below is paired with the fixture that
433
+ # kills it; the pairing is what keeps "the metric is witnessed" from being a
434
+ # claim about assertion count.
435
+ ['S1 the sentence cap is never applied',
436
+ %q{ if cfg.max_sentence_p90 && metrics['sentences'] >= cfg.sentence_min_count &&},
437
+ %q{ if false && metrics['sentences'] >= cfg.sentence_min_count &&}],
438
+ ['S2 the cap reads the maximum instead of the 90th percentile',
439
+ %q{ metrics['sentence_p90'] > cfg.max_sentence_p90},
440
+ %q{ metrics['sentence_max'] > cfg.max_sentence_p90}],
441
+ ['S3 the count floor is ignored, so a short message is judged by its maximum',
442
+ %q{ if cfg.max_sentence_p90 && metrics['sentences'] >= cfg.sentence_min_count &&},
443
+ %q{ if cfg.max_sentence_p90 && true &&}],
444
+ ['S4 the cap boundary fires at the cap instead of above it',
445
+ %q{ metrics['sentence_p90'] > cfg.max_sentence_p90},
446
+ %q{ metrics['sentence_p90'] >= cfg.max_sentence_p90}],
447
+ ['S5 the announcement is made to clear the sentence cap too',
448
+ %q{ if cfg.max_sentence_p90 && metrics['sentences'] >= cfg.sentence_min_count &&},
449
+ %q{ if cfg.max_sentence_p90 && !announced && metrics['sentences'] >= cfg.sentence_min_count &&}],
450
+ ['S6 the percentile index loses its rank correction',
451
+ %q{ index = (fraction * sorted.length).ceil - 1},
452
+ %q{ index = (fraction * sorted.length).ceil}],
453
+ ['S7 the empty message is given a sentence length rather than none',
454
+ %q{ return 0 if values.empty?},
455
+ %q{ return 1 if values.empty?}],
456
+ ['S8 a table row is measured as a sentence',
457
+ %q{ next if HEADING.match(line) || TABLE_ROW.match(line) ||
458
+ RULE.match(line) || QUOTE.match(line)},
459
+ %q{ next if HEADING.match(line) ||
460
+ RULE.match(line) || QUOTE.match(line)}],
461
+ ['S9 a heading is measured as a sentence',
462
+ %q{ next if HEADING.match(line) || TABLE_ROW.match(line) ||
463
+ RULE.match(line) || QUOTE.match(line)},
464
+ %q{ next if TABLE_ROW.match(line) ||
465
+ RULE.match(line) || QUOTE.match(line)}],
466
+ ['S10 a quotation is measured as the author\'s own sentence',
467
+ %q{ next if HEADING.match(line) || TABLE_ROW.match(line) ||
468
+ RULE.match(line) || QUOTE.match(line)},
469
+ %q{ next if HEADING.match(line) || TABLE_ROW.match(line) ||
470
+ RULE.match(line)}],
471
+ ['S11 the list marker is skipped instead of stripped, exempting every bullet',
472
+ %q{ body = line.sub(LIST_MARKER, '').gsub(URL, '#').strip},
473
+ %q{ next if LIST_MARKER.match(line)
474
+ body = line.gsub(URL, '#').strip}],
475
+ ['S12 a URL carries its own length into the sentence',
476
+ %q{ body = line.sub(LIST_MARKER, '').gsub(URL, '#').strip},
477
+ %q{ body = line.sub(LIST_MARKER, '').strip}],
478
+ ['S13 a line is never split at its terminators',
479
+ %q{ body.split(SENTENCE_BREAK).each do |sentence|},
480
+ %q{ [body].each do |sentence|}],
481
+ ['S14 the ASCII arm loses its whitespace condition, shattering identifiers',
482
+ %q{ SENTENCE_BREAK = Regexp.new('(?<=[。.!?])|(?<=[.!?])(?=\s)')},
483
+ %q{ SENTENCE_BREAK = Regexp.new('(?<=[。.!?])|(?<=[.!?])')}],
484
+ ['S15 the CJK arm is dropped, so Japanese reads as one sentence per line',
485
+ %q{ SENTENCE_BREAK = Regexp.new('(?<=[。.!?])|(?<=[.!?])(?=\s)')},
486
+ %q{ SENTENCE_BREAK = Regexp.new('(?<=[.!?])(?=\s)')}],
487
+ ['S16 sentence_max reports the percentile, so the log has two of one column',
488
+ %q{ 'sentence_max' => lengths.max || 0,},
489
+ %q{ 'sentence_max' => percentile(lengths, 0.9),}],
490
+ ['S17 the banner stops naming the sentence measurement',
491
+ %q{ 'sentence p90 %d chars',},
492
+ %q{ 'sentence p90 unmeasured',}],
493
+ ['S18 the two sentence thresholds leave the type check',
494
+ %q{ INT_KEYS = %w[max_lines max_headings max_tables diagram_required_over_lines
495
+ max_sentence_chars_p90 sentence_min_count
496
+ vocab_min_lines log_max_bytes].freeze},
497
+ %q{ INT_KEYS = %w[max_lines max_headings max_tables diagram_required_over_lines
498
+ vocab_min_lines log_max_bytes].freeze}],
499
+ ['S19 the --report path stops printing the columns a cap is calibrated from',
500
+ %q{ "\tsent=%d\tsent_p90=%d\tsent_max=%d\tFAIL=%s",},
501
+ %q{ "\tFAIL=%s%s%s%s",}],
502
+
418
503
  ['B18 the revocation is demoted to a trailing statement, leaking on the cut exit',
419
504
  %q{ Regexp.timeout = remaining
420
505
  begin
@@ -201,6 +201,107 @@ class TestReadableGate < Minitest::Test
201
201
  assert_empty f, 'the core supplies no floor of its own'
202
202
  end
203
203
 
204
+ # --- sentence length ------------------------------------------------------
205
+ #
206
+ # The mode body names long sentences as the first observed failure form and
207
+ # says "split overlong sentences" outright, and until 2026-09-01 nothing here
208
+ # was sentence-aware. The rewrite that occasioned this metric met every cap
209
+ # and kept 36% of its sentences over 60 characters with a 177-character
210
+ # maximum: the caps were met by packing more into each line, and per-line
211
+ # effort is what the reader experienced.
212
+
213
+ # Three rules at once, on one fixture: a line splits at its terminators, a
214
+ # bullet is measured with its marker stripped rather than skipped, and a
215
+ # heading or a table row is not a sentence at all. Four different numbers so
216
+ # that dropping any one rule moves a count.
217
+ def test_what_counts_as_a_sentence
218
+ text = "これはテスト。次の文です。\n- 箇条書きの行\n| a | b |\n|---|---|\n# 見出し\n"
219
+ m, = measure(text)
220
+ assert_equal 3, m['sentences'],
221
+ "two sentences on the first line and the bullet: #{m.inspect}"
222
+ assert_equal 7, m['sentence_max'], 'これはテスト。 is the longest of the three'
223
+ end
224
+
225
+ # A table row measured as a sentence is what goes wrong first when a length
226
+ # metric meets this project's output: one seven-column row reads as a single
227
+ # 200-character sentence and takes over every percentile in the message.
228
+ def test_a_table_row_never_becomes_the_longest_sentence
229
+ row = "| #{(['very long cell text here'] * 7).join(' | ')} |"
230
+ m, = measure("短い文です。\n#{row}\n")
231
+ assert_equal 1, m['sentences'], "the row is not a sentence: #{m.inspect}"
232
+ assert_equal 6, m['sentence_max'], m.inspect
233
+ end
234
+
235
+ # The ASCII arm requires whitespace after the stop. Without that condition a
236
+ # file name, a version and a dotted number each shatter into fragments, and
237
+ # the measured tail collapses towards zero — a metric that reports every
238
+ # message as readable.
239
+ def test_an_identifier_full_stop_does_not_end_a_sentence
240
+ m, = measure("See readable_gate.rb now. Then v0.4.6 ships.\n")
241
+ assert_equal 2, m['sentences'], m.inspect
242
+ assert_equal 25, m['sentence_max'], 'See readable_gate.rb now. is the longer'
243
+ end
244
+
245
+ # Sentences do not span lines. A bullet with no terminating punctuation is
246
+ # one sentence the length of its line, which is what it costs to read; the
247
+ # alternative measures the author's punctuation habits instead.
248
+ def test_an_unterminated_line_is_one_sentence_of_its_own_length
249
+ m, = measure("- 終止符のない箇条書き\n- 二つ目\n")
250
+ assert_equal 2, m['sentences'], m.inspect
251
+ assert_equal 10, m['sentence_max'], 'the marker is stripped before the length is taken'
252
+ end
253
+
254
+ # Quoted source text is not the author's sentence, and the mode's own rewrite
255
+ # instruction forbids cutting quoted facts — so a cap that fired here would
256
+ # demand the one repair the instruction refuses, leaving "stop quoting
257
+ # sources" as the only compliance.
258
+ def test_a_blockquote_is_not_the_authors_sentence
259
+ m, = measure("短い文です。\n> #{'x' * 400}\n")
260
+ assert_equal 1, m['sentences'], m.inspect
261
+ assert_equal 6, m['sentence_max'], m.inspect
262
+ end
263
+
264
+ # A URL is clicked, not read. Collapsed rather than dropped, so the sentence
265
+ # around it is still measured: the citation list was one of the two
266
+ # false-positive classes the 2026-09-01 calibration turned up.
267
+ def test_a_url_counts_as_one_token_however_long
268
+ m, = measure("出典は https://example.com/#{'a' * 300} です。\n")
269
+ assert_equal 1, m['sentences'], m.inspect
270
+ assert_operator m['sentence_max'], :<, 20, "the URL must not carry its length: #{m.inspect}"
271
+ end
272
+
273
+ # The cap reads the 90th percentile, not the maximum. Every boundary fixture
274
+ # above uses sentences of one length, where the two coincide; this one is the
275
+ # only place they differ, and it is what tells a p90 cap from a max cap.
276
+ def test_the_cap_reads_the_percentile_and_not_the_maximum
277
+ text = (['x' * 10] * 9).join("\n") + "\n#{'y' * 400}"
278
+ m, f = measure(text, 'max_sentence_chars_p90' => 60, 'sentence_min_count' => 10)
279
+ assert_equal 10, m['sentence_p90'], m.inspect
280
+ assert_equal 400, m['sentence_max'], m.inspect
281
+ assert_empty f.select { |x| x.start_with?('SENTENCES') },
282
+ 'one long sentence among ten must not fail a good answer'
283
+ end
284
+
285
+ # The cap is type-checked where every other threshold is. A mode writing
286
+ # `"max_sentence_chars_p90": "160"` used to be the shape that raised on every
287
+ # turn: non-zero exit, no verdict, no log line.
288
+ def test_a_mistyped_sentence_cap_reports_instead_of_enforcing
289
+ out = decide((['x' * 100] * 12).join("\n"),
290
+ max_sentence_chars_p90: '60', sentence_min_count: '10')
291
+ refute out.key?('decision'), out.inspect
292
+ assert_includes out.fetch('systemMessage', ''), 'max_sentence_chars_p90'
293
+ assert_includes out.fetch('systemMessage', ''), 'sentence_min_count'
294
+ end
295
+
296
+ # Nearest-rank, and the degeneracy it carries. Below ten sentences the 90th
297
+ # percentile IS the maximum, which is the whole ground for sentence_min_count
298
+ # and the reason a short message must not be judged by this cap.
299
+ def test_the_percentile_is_nearest_rank_and_degenerates_under_ten
300
+ assert_equal 9, G.percentile((1..10).to_a, 0.9), 'the ninth of ten'
301
+ assert_equal 9, G.percentile((1..9).to_a, 0.9), 'under ten it is the maximum'
302
+ assert_equal 0, G.percentile([], 0.9), 'and an empty message has no sentence'
303
+ end
304
+
204
305
  # --- vocabulary: the case that misfired in production ---------------------
205
306
 
206
307
  MASA_SHORTHAND =
@@ -1925,6 +2026,84 @@ class TestReadableGate < Minitest::Test
1925
2026
  assert f.any? { |x| x.start_with?('DIAGRAM') }, f.inspect
1926
2027
  end
1927
2028
 
2029
+ def test_the_sentence_cap_boundary
2030
+ at_cap = (['x' * 60] * 10).join("\n")
2031
+ _, f = measure(at_cap, 'max_sentence_chars_p90' => 60, 'sentence_min_count' => 10)
2032
+ assert_empty f.select { |x| x.start_with?('SENTENCES') },
2033
+ 'a 60-character p90 at cap 60 is within the cap'
2034
+ over = (['x' * 61] * 10).join("\n")
2035
+ _, f = measure(over, 'max_sentence_chars_p90' => 60, 'sentence_min_count' => 10)
2036
+ assert f.any? { |x| x.start_with?('SENTENCES') }, f.inspect
2037
+ end
2038
+
2039
+ # The count floor under its own name. Its whole ground is that nearest-rank
2040
+ # puts the 90th percentile at the last element for every n under ten, so a
2041
+ # short message judged by this cap is judged by its maximum — the instrument
2042
+ # the metric was chosen over.
2043
+ def test_the_sentence_count_floor_boundary
2044
+ overrides = { 'max_sentence_chars_p90' => 60, 'sentence_min_count' => 10 }
2045
+ _, f = measure((['x' * 100] * 10).join("\n"), overrides)
2046
+ assert f.any? { |x| x.start_with?('SENTENCES') },
2047
+ "10 sentences at floor 10 is judged: #{f.inspect}"
2048
+ _, f = measure((['x' * 100] * 9).join("\n"), overrides)
2049
+ assert_empty f.select { |x| x.start_with?('SENTENCES') },
2050
+ '9 stays below the floor'
2051
+ end
2052
+
2053
+ # The length cap yields to an announcement; this one does not, for the
2054
+ # diagram floor's reason. Saying a message is long says nothing about whether
2055
+ # its sentences are, so the two exemptions must not be shared.
2056
+ def test_announcing_the_length_does_not_clear_the_sentence_cap
2057
+ text = "長いです。\n" + (['x' * 100] * 10).join("\n")
2058
+ _, f = measure(text, 'max_sentence_chars_p90' => 60, 'sentence_min_count' => 10,
2059
+ 'max_lines' => 5, 'announce_patterns' => ['長い'])
2060
+ assert_empty f.select { |x| x.start_with?('LENGTH') }, 'the announcement clears length'
2061
+ assert f.any? { |x| x.start_with?('SENTENCES') }, f.inspect
2062
+ end
2063
+
2064
+ def test_the_sentence_cap_is_off_unless_the_mode_names_a_number
2065
+ m, f = measure((['x' * 300] * 20).join("\n"))
2066
+ assert_empty f, 'the core supplies no sentence cap of its own'
2067
+ assert_equal 300, m['sentence_p90'], 'and measures anyway, which is what calibration reads'
2068
+ end
2069
+
2070
+ # The block a mode with this cap declared actually issues, driven end to end,
2071
+ # plus the banner shape the operator reads every turn.
2072
+ def test_a_message_whose_sentences_run_long_is_blocked
2073
+ out = decide((['x' * 100] * 12).join("\n"),
2074
+ max_sentence_chars_p90: 60, sentence_min_count: 10)
2075
+ assert_equal 'block', out['decision'], out.inspect
2076
+ assert_includes out.fetch('reason', ''), 'SENTENCES'
2077
+ assert_includes out.fetch('systemMessage', ''), 'sentence p90 100 chars'
2078
+ end
2079
+
2080
+ # Three different numbers, so a transposition of any pair is visible. p90 and
2081
+ # max coincide on any message under ten sentences, which is why this fixture
2082
+ # has ten.
2083
+ def test_the_log_names_each_sentence_metric_in_its_own_column
2084
+ text = (['ab。'] * 9).join("\n") + "\nabcdefghijk"
2085
+ _out, log = drive([row_for('assistant', text: text, uuid: 'AAA')],
2086
+ rechecked: false, max_headings: 3)
2087
+ assert_includes log, "\tsent=10\tsent_p90=3\tsent_max=11\t",
2088
+ "ten sentences, a p90 of 3 and a maximum of 11: #{log.inspect}"
2089
+ end
2090
+
2091
+ # The offline path is where a number for this cap comes from, so its columns
2092
+ # are load-bearing rather than decorative.
2093
+ def test_the_report_path_prints_the_sentence_columns
2094
+ Dir.mktmpdir do |tmp|
2095
+ cfg_path = File.join(tmp, 'cfg.json')
2096
+ tx = File.join(tmp, 't.jsonl')
2097
+ File.write(cfg_path, JSON.generate('mode_name' => 't'), encoding: 'UTF-8')
2098
+ text = (['ab。'] * 9).join("\n") + "\nabcdefghijk"
2099
+ File.write(tx, rows_json([row_for('assistant', text: text)]), encoding: 'UTF-8')
2100
+
2101
+ out, _err, status = run_script(cfg_path, '', ['--report', tx])
2102
+ assert_equal 0, status.exitstatus
2103
+ assert_includes out, "\tsent=10\tsent_p90=3\tsent_max=11\t", out.inspect
2104
+ end
2105
+ end
2106
+
1928
2107
  # The floor under its own name. Until now its loss reddened only tests about
1929
2108
  # the timeout machinery, so a maintainer who broke it was shown the wrong
1930
2109
  # defect entirely.
@@ -1976,6 +2155,7 @@ class TestReadableGate < Minitest::Test
1976
2155
  G.define_singleton_method(:measure) do |*|
1977
2156
  seen = Regexp.timeout
1978
2157
  [{ 'lines' => 1, 'headings' => 0, 'tables' => 0, 'diagrams' => 0,
2158
+ 'sentences' => 1, 'sentence_p90' => 1, 'sentence_max' => 1,
1979
2159
  'announced' => false, 'unglossed' => [] }, []]
1980
2160
  end
1981
2161
  begin
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kairos-chain
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.82.0
4
+ version: 3.83.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Masaomi Hatakeyama