traceforge-toolkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. traceforge/__init__.py +72 -0
  2. traceforge/__main__.py +5 -0
  3. traceforge/_generated.py +254 -0
  4. traceforge/adapters/__init__.py +12 -0
  5. traceforge/adapters/base.py +82 -0
  6. traceforge/adapters/genai_otel.py +164 -0
  7. traceforge/adapters/mapped_json.py +297 -0
  8. traceforge/adapters/otel.py +220 -0
  9. traceforge/boundary/__init__.py +46 -0
  10. traceforge/boundary/data/boundary-model.joblib +0 -0
  11. traceforge/boundary/decode.py +87 -0
  12. traceforge/boundary/features.py +137 -0
  13. traceforge/boundary/inference.py +267 -0
  14. traceforge/boundary/inferencer.py +146 -0
  15. traceforge/classify/__init__.py +95 -0
  16. traceforge/classify/cmd.py +109 -0
  17. traceforge/classify/coding.py +213 -0
  18. traceforge/classify/config.py +554 -0
  19. traceforge/classify/core.py +266 -0
  20. traceforge/classify/data/binary_info.yaml +358 -0
  21. traceforge/classify/data/canonical_tools.yaml +251 -0
  22. traceforge/classify/data/effect_overrides.yaml +480 -0
  23. traceforge/classify/data/mcp_profiles.yaml +711 -0
  24. traceforge/classify/data/recommendation_rules.yaml +111 -0
  25. traceforge/classify/data/risk.yaml +534 -0
  26. traceforge/classify/data/shell_defaults.yaml +95 -0
  27. traceforge/classify/data/shell_rules.yaml +1192 -0
  28. traceforge/classify/data/tool_classifications.yaml +215 -0
  29. traceforge/classify/data/verb_inference.yaml +218 -0
  30. traceforge/classify/mcp.py +181 -0
  31. traceforge/classify/phases.py +75 -0
  32. traceforge/classify/powershell.py +93 -0
  33. traceforge/classify/registry.py +138 -0
  34. traceforge/classify/risk.py +553 -0
  35. traceforge/classify/rules.py +215 -0
  36. traceforge/classify/schema.yaml +282 -0
  37. traceforge/classify/shell.py +503 -0
  38. traceforge/classify/tools.py +91 -0
  39. traceforge/classify/workflow.py +32 -0
  40. traceforge/cli/__init__.py +42 -0
  41. traceforge/cli/config_cmd.py +130 -0
  42. traceforge/cli/detect.py +46 -0
  43. traceforge/cli/download_cmd.py +74 -0
  44. traceforge/cli/factory.py +60 -0
  45. traceforge/cli/gate_cmd.py +30 -0
  46. traceforge/cli/init_cmd.py +86 -0
  47. traceforge/cli/replay.py +130 -0
  48. traceforge/cli/runner.py +181 -0
  49. traceforge/cli/score.py +217 -0
  50. traceforge/cli/status.py +65 -0
  51. traceforge/cli/watch.py +297 -0
  52. traceforge/config/__init__.py +80 -0
  53. traceforge/config/defaults.py +149 -0
  54. traceforge/config/loader.py +239 -0
  55. traceforge/config/mappings.py +104 -0
  56. traceforge/config/models.py +579 -0
  57. traceforge/enricher.py +576 -0
  58. traceforge/formatting/__init__.py +12 -0
  59. traceforge/formatting/budget.py +92 -0
  60. traceforge/formatting/density.py +154 -0
  61. traceforge/gate/__init__.py +17 -0
  62. traceforge/gate/client.py +145 -0
  63. traceforge/gate/external.py +305 -0
  64. traceforge/gate/registry.py +108 -0
  65. traceforge/gate/server.py +174 -0
  66. traceforge/gates/__init__.py +8 -0
  67. traceforge/gates/pii.py +352 -0
  68. traceforge/gates/pii_patterns.yaml +228 -0
  69. traceforge/governance/__init__.py +121 -0
  70. traceforge/governance/assessor.py +441 -0
  71. traceforge/governance/budget.py +59 -0
  72. traceforge/governance/canonical.py +56 -0
  73. traceforge/governance/codec.py +414 -0
  74. traceforge/governance/context.py +249 -0
  75. traceforge/governance/drift.py +196 -0
  76. traceforge/governance/emitter.py +234 -0
  77. traceforge/governance/envelope.py +138 -0
  78. traceforge/governance/ifc.py +364 -0
  79. traceforge/governance/integrity.py +162 -0
  80. traceforge/governance/labeler.py +250 -0
  81. traceforge/governance/mcp_drift.py +328 -0
  82. traceforge/governance/monitor.py +539 -0
  83. traceforge/governance/observer.py +276 -0
  84. traceforge/governance/persistence.py +401 -0
  85. traceforge/governance/phase1.py +77 -0
  86. traceforge/governance/pii.py +276 -0
  87. traceforge/governance/pipeline.py +840 -0
  88. traceforge/governance/registry.py +110 -0
  89. traceforge/governance/results.py +183 -0
  90. traceforge/governance/risk_wrapper.py +113 -0
  91. traceforge/governance/rules.py +368 -0
  92. traceforge/governance/scorer.py +262 -0
  93. traceforge/governance/shield.py +318 -0
  94. traceforge/governance/state.py +466 -0
  95. traceforge/governance/types.py +176 -0
  96. traceforge/mappings/__init__.py +1 -0
  97. traceforge/mappings/aider.yaml +216 -0
  98. traceforge/mappings/aider_markdown.yaml +113 -0
  99. traceforge/mappings/amazonq.yaml +56 -0
  100. traceforge/mappings/antigravity.yaml +88 -0
  101. traceforge/mappings/claude.yaml +93 -0
  102. traceforge/mappings/cline.yaml +286 -0
  103. traceforge/mappings/codex.yaml +158 -0
  104. traceforge/mappings/continue_dev.yaml +57 -0
  105. traceforge/mappings/copilot.yaml +266 -0
  106. traceforge/mappings/copilot_markdown.yaml +72 -0
  107. traceforge/mappings/copilot_vscode.yaml +181 -0
  108. traceforge/mappings/crewai.yaml +592 -0
  109. traceforge/mappings/goose.yaml +128 -0
  110. traceforge/mappings/langgraph.yaml +165 -0
  111. traceforge/mappings/maf.yaml +90 -0
  112. traceforge/mappings/maf_transcript.yaml +99 -0
  113. traceforge/mappings/openai_agents.yaml +144 -0
  114. traceforge/mappings/opencode.yaml +473 -0
  115. traceforge/mappings/openhands.yaml +320 -0
  116. traceforge/mappings/pydantic_ai.yaml +183 -0
  117. traceforge/mappings/smolagents.yaml +89 -0
  118. traceforge/mappings/sweagent.yaml +82 -0
  119. traceforge/migrations/__init__.py +1 -0
  120. traceforge/migrations/env.py +60 -0
  121. traceforge/migrations/models.py +145 -0
  122. traceforge/migrations/runner.py +44 -0
  123. traceforge/migrations/script.py.mako +25 -0
  124. traceforge/migrations/versions/0001_initial.py +176 -0
  125. traceforge/migrations/versions/__init__.py +4 -0
  126. traceforge/models.py +29 -0
  127. traceforge/parsers/__init__.py +21 -0
  128. traceforge/parsers/aider.py +416 -0
  129. traceforge/parsers/base.py +226 -0
  130. traceforge/parsers/copilot.py +314 -0
  131. traceforge/phase/__init__.py +50 -0
  132. traceforge/phase/data/phase-model.joblib +0 -0
  133. traceforge/phase/data/potion-base-8M/README.md +99 -0
  134. traceforge/phase/data/potion-base-8M/config.json +13 -0
  135. traceforge/phase/data/potion-base-8M/model.safetensors +0 -0
  136. traceforge/phase/data/potion-base-8M/modules.json +14 -0
  137. traceforge/phase/data/potion-base-8M/tokenizer.json +1 -0
  138. traceforge/phase/event_rows.py +107 -0
  139. traceforge/phase/features.py +468 -0
  140. traceforge/phase/inference.py +279 -0
  141. traceforge/phase/inferencer.py +171 -0
  142. traceforge/phase/segmentation.py +258 -0
  143. traceforge/pipeline.py +891 -0
  144. traceforge/preprocessors/__init__.py +34 -0
  145. traceforge/preprocessors/amazonq.py +224 -0
  146. traceforge/preprocessors/antigravity.py +116 -0
  147. traceforge/preprocessors/claude.py +95 -0
  148. traceforge/preprocessors/cline.py +36 -0
  149. traceforge/preprocessors/codex.py +311 -0
  150. traceforge/preprocessors/continue_dev.py +119 -0
  151. traceforge/preprocessors/copilot_vscode.py +171 -0
  152. traceforge/preprocessors/goose.py +156 -0
  153. traceforge/preprocessors/maf_transcript.py +84 -0
  154. traceforge/preprocessors/openai_agents.py +86 -0
  155. traceforge/preprocessors/opencode.py +85 -0
  156. traceforge/preprocessors/openhands.py +36 -0
  157. traceforge/preprocessors/pydantic_ai.py +62 -0
  158. traceforge/preprocessors/registry.py +24 -0
  159. traceforge/preprocessors/smolagents.py +90 -0
  160. traceforge/py.typed +0 -0
  161. traceforge/sdk/__init__.py +59 -0
  162. traceforge/sdk/gate_policy.py +63 -0
  163. traceforge/sdk/gate_types.py +140 -0
  164. traceforge/sdk/pipeline.py +265 -0
  165. traceforge/sdk/verdict.py +81 -0
  166. traceforge/sinks/__init__.py +23 -0
  167. traceforge/sinks/base.py +95 -0
  168. traceforge/sinks/callback.py +132 -0
  169. traceforge/sinks/console.py +112 -0
  170. traceforge/sinks/factory.py +94 -0
  171. traceforge/sinks/jsonl.py +125 -0
  172. traceforge/sinks/otel_exporter.py +212 -0
  173. traceforge/sinks/parquet.py +260 -0
  174. traceforge/sinks/s3.py +206 -0
  175. traceforge/sinks/sqlite_output.py +234 -0
  176. traceforge/sinks/webhook.py +136 -0
  177. traceforge/sources/__init__.py +18 -0
  178. traceforge/sources/auto_detect.py +173 -0
  179. traceforge/sources/base.py +45 -0
  180. traceforge/sources/file_poll.py +136 -0
  181. traceforge/sources/file_watch.py +221 -0
  182. traceforge/sources/http_poll.py +141 -0
  183. traceforge/sources/replay.py +63 -0
  184. traceforge/sources/sqlite.py +187 -0
  185. traceforge/sources/sse.py +198 -0
  186. traceforge/telemetry/__init__.py +192 -0
  187. traceforge/title/__init__.py +23 -0
  188. traceforge/title/_resolve.py +79 -0
  189. traceforge/title/context.py +295 -0
  190. traceforge/title/data/boilerplate_files.json +14 -0
  191. traceforge/title/heuristics.py +429 -0
  192. traceforge/title/hygiene.py +90 -0
  193. traceforge/title/inference.py +314 -0
  194. traceforge/title/inferencer.py +477 -0
  195. traceforge/title/naming.py +398 -0
  196. traceforge/trace.py +291 -0
  197. traceforge/tracking/__init__.py +30 -0
  198. traceforge/tracking/models.py +115 -0
  199. traceforge/tracking/phase_tracker.py +288 -0
  200. traceforge/types.py +315 -0
  201. traceforge_toolkit-0.1.0.dist-info/METADATA +188 -0
  202. traceforge_toolkit-0.1.0.dist-info/RECORD +205 -0
  203. traceforge_toolkit-0.1.0.dist-info/WHEEL +4 -0
  204. traceforge_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
  205. traceforge_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,314 @@
1
+ """Torch-free, CPU-only title generation for activity/step spans.
2
+
3
+ Serves the tiny T5 titler via :mod:`onnxruntime` + :mod:`tokenizers` + numpy
4
+ only -- no torch / transformers at inference (RSS ~250MB vs ~1GB with torch).
5
+ Single-pass beam search: the model emits ``num_beams`` candidates per span and
6
+ :func:`traceforge.title.hygiene.best_of` picks the first non-degenerate one.
7
+
8
+ The candidate-fusion 2-pass variant was evaluated and dropped: it overfit the
9
+ torch beam-search candidate distribution and degraded on the ORT generator, so
10
+ the robust shipped path is single-pass (see research notes / experiments).
11
+
12
+ Inputs are *distilled context* strings (intent / actions / files / symbols /
13
+ notes slots) produced upstream by the span feature builder. Generation params
14
+ mirror the training/eval config (no_repeat_ngram_size=2, repetition_penalty=1.3,
15
+ length_penalty=0.8) so served titles match offline evaluation.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import re
22
+ from pathlib import Path
23
+
24
+ import numpy as np
25
+
26
+ from .hygiene import best_of
27
+
28
+ #: T5 special ids: pad doubles as the decoder start token; eos == </s>.
29
+ _PAD, _EOS = 0, 1
30
+ #: Task prefix the titler was fine-tuned with (learned; must match training).
31
+ _PREFIX = "summarize agent step: "
32
+ #: Encoder truncation; the saved tokenizer.json otherwise bakes in max_length=20.
33
+ _MAX_SRC = 512
34
+
35
+
36
+ def _logsoftmax(x: np.ndarray) -> np.ndarray:
37
+ x = x - x.max(-1, keepdims=True)
38
+ return x - np.log(np.exp(x).sum(-1, keepdims=True))
39
+
40
+
41
+ #: Surface-level grounding. A tiny seq2seq model under noisy contexts invents
42
+ #: identifier-shaped tokens that the span never mentions (e.g. ``_init_admissment``,
43
+ #: ``github-mcp-server-ample``). These are, by definition, ungrounded: a faithful
44
+ #: title can only name identifiers that appear in its own distilled context. The
45
+ #: rule is structural (no source tags, no thresholds, no tuned magic): a content
46
+ #: word carrying an identifier signal -- snake/path separator, hyphenation, an
47
+ #: internal capital, a digit, or a dotted extension -- must occur verbatim in the
48
+ #: context or the candidate that emitted it is demoted below grounded siblings.
49
+ #: Gated by ``TITLE_GROUND`` (default on) purely so the effect can be A/B measured.
50
+ _GROUND_DEFAULT = os.environ.get("TITLE_GROUND", "1") != "0"
51
+ #: Beam width / candidate pool. Wider gives the grounding gate more grounded
52
+ #: specifics to promote over a collapsed hallucination, at linear decode cost;
53
+ #: env-overridable purely so the footprint/quality trade can be measured.
54
+ _BEAMS_DEFAULT = int(os.environ.get("TITLE_BEAMS", "5"))
55
+ #: Adaptive escalation ceiling. When *every* base-width beam invents an
56
+ #: out-of-context identifier (a decode collapse), and only then, the pool is
57
+ #: re-decoded at this width so grounding has a faithful candidate to promote.
58
+ #: The grounded majority never pays it. Equal to base width disables escalation.
59
+ _BEAMS_MAX = int(os.environ.get("TITLE_BEAMS_MAX", str(2 * _BEAMS_DEFAULT)))
60
+ _WORD_RE = re.compile(r"[A-Za-z0-9_./\\-]+")
61
+ _ID_RE = re.compile(r"[_/\\-]|[a-z][A-Z]|\d|\.[A-Za-z]")
62
+ _GROUND_STOP = frozenset(
63
+ {
64
+ "the",
65
+ "a",
66
+ "an",
67
+ "to",
68
+ "of",
69
+ "in",
70
+ "on",
71
+ "for",
72
+ "and",
73
+ "or",
74
+ "with",
75
+ "from",
76
+ "into",
77
+ "by",
78
+ "at",
79
+ "as",
80
+ "its",
81
+ "their",
82
+ "this",
83
+ "that",
84
+ "these",
85
+ "those",
86
+ }
87
+ )
88
+
89
+
90
+ def _identifier_words(title: str) -> list[str]:
91
+ """Identifier-shaped content words of a title (skipping the leading verb)."""
92
+ ws = _WORD_RE.findall(title)
93
+ return [w for w in ws[1:] if w.lower() not in _GROUND_STOP and _ID_RE.search(w)]
94
+
95
+
96
+ def _is_grounded(title: str, ctx_lower: str) -> bool:
97
+ """True iff every identifier-shaped content word appears in the context."""
98
+ return all(w.lower() in ctx_lower for w in _identifier_words(title))
99
+
100
+
101
+ def _ground_order(cands: list[str], context: str) -> list[str]:
102
+ """Stable-reorder candidates so context-grounded ones come first.
103
+
104
+ Ungrounded candidates are appended rather than dropped, so selection never
105
+ returns empty when *every* beam hallucinates -- the best-scoring (still
106
+ ungrounded) title is then surfaced, and downstream hygiene cleans it.
107
+ """
108
+ ctx_lower = context.lower()
109
+ grounded: list[str] = []
110
+ ungrounded: list[str] = []
111
+ for c in cands:
112
+ (grounded if _is_grounded(c, ctx_lower) else ungrounded).append(c)
113
+ return grounded + ungrounded
114
+
115
+
116
+ class TitleModel:
117
+ """Loaded ORT titler. Construct via :meth:`load`; call :meth:`title`."""
118
+
119
+ def __init__(self, enc, dec, tok, prefix: str = _PREFIX) -> None:
120
+ self._enc = enc
121
+ self._dec = dec
122
+ self._tok = tok
123
+ self._prefix = prefix
124
+
125
+ # ----------------------------------------------------------------- loading
126
+ @classmethod
127
+ def load(
128
+ cls, model_dir: str | os.PathLike[str] | None = None, threads: int = 1
129
+ ) -> "TitleModel":
130
+ """Load the packaged int8 titler (or a custom ``model_dir``).
131
+
132
+ ``threads`` caps onnxruntime intra-op threads to keep the live CPU
133
+ footprint near-zero (fan-spin / RAM starvation is a hard failure). The
134
+ default of ``1`` matches what the live :class:`TitleInferencer` serves, so
135
+ direct callers and footprint benchmarks measure the shipped config.
136
+ """
137
+ import onnxruntime as ort
138
+ from tokenizers import Tokenizer
139
+
140
+ from ._resolve import INSTALL_HINT, span_dir
141
+
142
+ if model_dir is not None:
143
+ d = Path(model_dir)
144
+ else:
145
+ resolved = span_dir()
146
+ if resolved is None:
147
+ raise FileNotFoundError(INSTALL_HINT)
148
+ d = resolved
149
+ so = ort.SessionOptions()
150
+ so.intra_op_num_threads = threads
151
+ so.inter_op_num_threads = 1
152
+ providers = ["CPUExecutionProvider"]
153
+ enc = ort.InferenceSession(str(d / "encoder.onnx"), so, providers=providers)
154
+ dec = ort.InferenceSession(str(d / "decoder.onnx"), so, providers=providers)
155
+ tok = Tokenizer.from_file(str(d / "tokenizer.json"))
156
+ tok.enable_truncation(max_length=_MAX_SRC)
157
+ return cls(enc, dec, tok)
158
+
159
+ # -------------------------------------------------------------- generation
160
+ def _encode(self, text: str):
161
+ e = self._tok.encode(self._prefix + text)
162
+ ids = np.asarray([e.ids], dtype=np.int64)
163
+ mask = np.asarray([e.attention_mask], dtype=np.int64)
164
+ h = self._enc.run(None, {"input_ids": ids, "attention_mask": mask})[0]
165
+ return h, mask
166
+
167
+ def _beams(
168
+ self,
169
+ text: str,
170
+ num_beams: int,
171
+ num_return: int,
172
+ max_new: int,
173
+ no_repeat: int,
174
+ rep_pen: float,
175
+ len_pen: float,
176
+ ) -> list[str]:
177
+ h, mask = self._encode(text)
178
+ B = num_beams
179
+ hB = np.repeat(h, B, axis=0)
180
+ mB = np.repeat(mask, B, axis=0)
181
+ beams: list[list[int]] = [[_PAD]]
182
+ scores = np.zeros(1, dtype=np.float64)
183
+ done: list[tuple[float, list[int]]] = []
184
+ for _ in range(max_new):
185
+ n = len(beams)
186
+ ids = np.asarray(beams, dtype=np.int64)
187
+ raw = self._dec.run(
188
+ None,
189
+ {
190
+ "input_ids": ids,
191
+ "encoder_hidden_states": hB[:n],
192
+ "encoder_attention_mask": mB[:n],
193
+ },
194
+ )[0][:, -1, :].astype(np.float64)
195
+ for i, toks in enumerate(beams):
196
+ for t in set(toks): # HF repetition penalty on raw logits
197
+ raw[i, t] = raw[i, t] / rep_pen if raw[i, t] > 0 else raw[i, t] * rep_pen
198
+ if no_repeat and len(toks) >= no_repeat: # ban repeated n-grams
199
+ pref = tuple(toks[-(no_repeat - 1) :]) if no_repeat > 1 else ()
200
+ for k in range(len(toks) - no_repeat + 1):
201
+ if tuple(toks[k : k + no_repeat - 1]) == pref:
202
+ raw[i, toks[k + no_repeat - 1]] = -1e9
203
+ lp = _logsoftmax(raw)
204
+ cand = (scores[:n, None] + lp).reshape(-1)
205
+ V = lp.shape[1]
206
+ order = np.argpartition(cand, -2 * B)[-2 * B :]
207
+ order = order[np.argsort(cand[order])[::-1]]
208
+ new_beams: list[list[int]] = []
209
+ new_scores: list[float] = []
210
+ for idx in order:
211
+ bi, tok = idx // V, int(idx % V)
212
+ toks = beams[bi] + [tok]
213
+ if tok == _EOS:
214
+ done.append((cand[idx] / (len(toks) ** len_pen), toks))
215
+ else:
216
+ new_beams.append(toks)
217
+ new_scores.append(cand[idx])
218
+ if len(new_beams) >= B:
219
+ break
220
+ if not new_beams:
221
+ break
222
+ beams, scores = new_beams, np.asarray(new_scores)
223
+ if len(done) >= B: # early_stopping=True: B finished hypotheses
224
+ break
225
+ # Prefer FINISHED hypotheses; only fall back to unfinished active beams
226
+ # if too few finished (avoids truncated titles).
227
+ done.sort(key=lambda x: x[0], reverse=True)
228
+ if len(done) < num_return:
229
+ done = done + sorted(
230
+ ((s / (len(t) ** len_pen), t) for s, t in zip(scores, beams)),
231
+ key=lambda x: x[0],
232
+ reverse=True,
233
+ )
234
+ outs: list[str] = []
235
+ seen: set[str] = set()
236
+ for _score, toks in done:
237
+ t = self._tok.decode([x for x in toks if x not in (_PAD, _EOS)]).strip()
238
+ if t not in seen:
239
+ seen.add(t)
240
+ outs.append(t)
241
+ if len(outs) >= num_return:
242
+ break
243
+ return outs
244
+
245
+ def _decode_grounded(
246
+ self,
247
+ context: str,
248
+ num_beams: int,
249
+ max_new: int,
250
+ no_repeat: int,
251
+ rep_pen: float,
252
+ len_pen: float,
253
+ ground: bool,
254
+ ) -> list[str]:
255
+ """Decode candidates, grounding-ordered, with adaptive escalation.
256
+
257
+ Base-width decode first. Only if grounding is on and *no* base candidate
258
+ is grounded (every beam invented an out-of-context identifier) is the
259
+ pool re-decoded at :data:`_BEAMS_MAX` -- so the faithful majority keeps
260
+ the base footprint and only the collapsing minority pays for more beams.
261
+ """
262
+ cands = self._beams(context, num_beams, num_beams, max_new, no_repeat, rep_pen, len_pen)
263
+ if not ground:
264
+ return cands
265
+ ctx_low = context.lower()
266
+ if not any(_is_grounded(c, ctx_low) for c in cands) and _BEAMS_MAX > num_beams:
267
+ cands = self._beams(
268
+ context, _BEAMS_MAX, _BEAMS_MAX, max_new, no_repeat, rep_pen, len_pen
269
+ )
270
+ return _ground_order(cands, context)
271
+
272
+ def title(
273
+ self,
274
+ context: str,
275
+ num_beams: int = _BEAMS_DEFAULT,
276
+ max_new: int = 32,
277
+ no_repeat: int = 2,
278
+ rep_pen: float = 1.3,
279
+ len_pen: float = 0.8,
280
+ ground: bool | None = None,
281
+ ) -> str:
282
+ """Return a short imperative title for a distilled span ``context``.
283
+
284
+ Generates ``num_beams`` candidates and returns the first non-degenerate
285
+ one after decode hygiene. When ``ground`` (default :data:`_GROUND_DEFAULT`)
286
+ is on, candidates that name identifiers absent from ``context`` are
287
+ demoted below grounded ones first (with adaptive beam escalation on a
288
+ full collapse).
289
+ """
290
+ if not context or not context.strip():
291
+ return ""
292
+ g = _GROUND_DEFAULT if ground is None else ground
293
+ cands = self._decode_grounded(context, num_beams, max_new, no_repeat, rep_pen, len_pen, g)
294
+ return best_of(cands)
295
+
296
+ def candidates(
297
+ self,
298
+ context: str,
299
+ num_beams: int = _BEAMS_DEFAULT,
300
+ max_new: int = 32,
301
+ no_repeat: int = 2,
302
+ rep_pen: float = 1.3,
303
+ len_pen: float = 0.8,
304
+ ground: bool | None = None,
305
+ ) -> list[str]:
306
+ """Return the cleaned beam candidates (for sibling de-dup via hygiene).
307
+
308
+ Grounded candidates are ordered first (see :meth:`title`) so both
309
+ :func:`best_of` and :func:`pick_distinct` prefer faithful titles.
310
+ """
311
+ if not context or not context.strip():
312
+ return []
313
+ g = _GROUND_DEFAULT if ground is None else ground
314
+ return self._decode_grounded(context, num_beams, max_new, no_repeat, rep_pen, len_pen, g)