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 +4 -4
- data/CHANGELOG.md +65 -0
- data/lib/kairos_mcp/version.rb +1 -1
- data/templates/skillsets/kairos_hook_projector/hooks/readable_gate.rb +135 -4
- data/templates/skillsets/kairos_hook_projector/mode_hooks/_EXAMPLE.json +3 -1
- data/templates/skillsets/kairos_hook_projector/skillset.json +1 -1
- data/templates/skillsets/kairos_hook_projector/test/mutation_check_readable_gate.rb +89 -4
- data/templates/skillsets/kairos_hook_projector/test/test_readable_gate.rb +180 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e601238b25375836907f7eec155beb0297e940cd5fd419ea855b7d48a3335993
|
|
4
|
+
data.tar.gz: 1c720eaa88d3204eedc436e38cdabaa3e79df3b9b5aca6260c925ec4173c6e62
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/kairos_mcp/version.rb
CHANGED
|
@@ -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\
|
|
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
|
-
|
|
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.
|
|
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{
|
|
94
|
-
metrics['
|
|
95
|
-
%q{
|
|
96
|
-
metrics['
|
|
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
|