velesdb_memory/context/segment.rs
1//! Deterministic transcript segmentation for the `compile_transcript` MCP
2//! tool (V2b-2, see the crate's `PLAN.md`, section V2b).
3//!
4//! [`segment_transcript`] turns a raw agent-session transcript — plain text
5//! with role markers, or JSONL — into an ordered list of
6//! [`TranscriptSegment`]s, each wrapping an ordinary [`super::ContextFragment`]
7//! plus the audit metadata (`turn`, `role`, `kind`, byte range) the
8//! `compile_transcript` tool reports alongside the compiled context. The
9//! resulting fragments feed the existing, unmodified [`super::ContextCompiler`]
10//! pipeline — this module only decides *how to cut the transcript up*, never
11//! what to keep or drop.
12//!
13//! **Zero regex, zero clock, single linear scan per stage** — same
14//! determinism contract as [`super::chunk`]: the same transcript + the same
15//! [`SegmentationPolicy`] always segment byte-identically (see
16//! `segmentation_twice_is_byte_identical` in the test suite).
17//!
18//! # Pipeline
19//!
20//! 1. **Format detection** ([`detect_and_segment`]): `jsonl` when every
21//! non-empty line parses as a `{role, content}` JSON object, `plain`
22//! otherwise. A caller-forced format that does not parse is a hard error —
23//! never a silent fallback to the other format — surfaced as
24//! [`crate::error::MemoryError::SegmentationError`] (a FORMAT failure,
25//! distinct from a budget/cap breach; see below).
26//! 2. **Turns**: `jsonl` — one line, one turn, `role` taken directly from the
27//! parsed JSON. `plain` — a CLOSED table of markers (`"System:"`,
28//! `"User:"`, `"Human:"`, `"Assistant:"`, `"AI:"`, `"Tool:"`,
29//! `"### User"`, `"### Assistant"`), first match at the start of a line
30//! opens a new turn; a transcript with no marker at all is one turn with
31//! `role: None`.
32//! 3. **Sub-segmentation** (`plain` turns only — a `jsonl` turn's `content` is
33//! a JSON-decoded string, not a byte-aligned slice of the transcript, so
34//! it is never re-scanned; the underlying `content.contains("```")` /
35//! value-density rules in [`super::classify`] still see it, unaffected):
36//! fenced code blocks ([`super::chunk::fence_segments`]) become atomic
37//! `code` segments; runs of at least 8 consecutive log-like lines (a
38//! volatile timestamp/pid prefix — [`super::log_normalize::mask_volatile_prefix`]
39//! — or a raw-text repeat) become `log` segments; everything else is
40//! `body`.
41//! 4. **Normalization**: an unsplittable fence over
42//! [`crate::limits::MAX_FRAGMENT_BYTES`] is a hard error (never silently
43//! truncated); an oversized `body` segment is re-split with
44//! [`super::chunk_text`]; segments under
45//! [`SegmentationPolicy::min_segment_bytes`] merge into an adjacent
46//! segment of the *same turn and kind*; more than
47//! [`crate::limits::MAX_FRAGMENTS`] segments after merging is a hard,
48//! actionable error ("raise `min_segment_bytes`") — never a silent drop.
49//!
50//! A genuine budget/cap breach (transcript over
51//! [`crate::limits::MAX_TRANSCRIPT_BYTES`], an unsplittable oversized fence,
52//! or too many fragments after merging) surfaces as
53//! [`crate::error::MemoryError::ContextOverLimit`]; a FORMAT/parsing failure
54//! (a forced `jsonl` line that does not parse) surfaces as the distinct
55//! [`crate::error::MemoryError::SegmentationError`] (issue #1516, m2 — kept
56//! separate precisely so a caller filtering on the error message cannot
57//! confuse a malformed-input error for a size breach, even though both map
58//! to the same `INVALID_PARAMS`-category MCP code). A `path`-sourced
59//! transcript can additionally fail with
60//! [`crate::error::MemoryError::IngestDisabled`]/[`crate::error::MemoryError::IngestOutsideRoots`]/
61//! [`crate::error::MemoryError::IngestPath`], via
62//! [`super::ingest::resolve_transcript_path`].
63
64use std::collections::BTreeMap;
65use std::ops::Range;
66
67use schemars::JsonSchema;
68use serde::{Deserialize, Serialize};
69use serde_json::{Map, Value};
70
71use super::chunk::{self, chunk_text, ChunkBoundary, ChunkPolicy};
72use super::log_normalize::mask_volatile_prefix;
73use super::model::ContextFragment;
74use crate::error::MemoryError;
75use crate::limits::{MAX_FRAGMENTS, MAX_FRAGMENT_BYTES, MAX_TRANSCRIPT_BYTES};
76
77/// A contiguous run of at least this many candidate log lines becomes a
78/// `log` segment (see the module docs' step 3). Chosen high enough that an
79/// ordinary short warning burst stays `body` (nothing to abstract), low
80/// enough that a real log dump — which `abstract.log_dedup` exists to
81/// collapse — is reliably recognized.
82const MIN_LOG_RUN_LINES: usize = 8;
83
84/// Which transcript format to assume, or detect automatically.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
86#[serde(rename_all = "lowercase")]
87pub enum SegmentFormat {
88 /// Detect `jsonl` vs `plain` from the transcript itself (the default).
89 Auto,
90 /// Force plain-text, marker-based turn splitting — a transcript that
91 /// happens to also be valid JSONL is still segmented as plain text.
92 Plain,
93 /// Force one-line-one-turn JSONL parsing — a line that does not parse as
94 /// a `{role, content}` object is a hard error, never a silent fallback.
95 Jsonl,
96}
97
98/// What kind of content a sub-segment carries — decides whether it was cut
99/// out as an atomic fence, a detected log run, or ordinary prose/dialogue
100/// left for [`super::classify`]'s rule table to judge.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
102#[serde(rename_all = "lowercase")]
103pub enum SegmentKind {
104 /// Ordinary text — [`ContextFragment::kind`] stays `None`, so the
105 /// existing classification rules (code fence, URL, negative constraint,
106 /// value density, …) decide its fate exactly as for `compile_context`.
107 Body,
108 /// A triple-backtick-fenced block, cut out atomically by
109 /// [`super::chunk::fence_segments`]. Tagged `kind = "code"` so
110 /// [`super::classify::classify`]'s `preserve.code_fence` rule matches
111 /// even for a fence whose content does not itself literally contain
112 /// `` ``` `` (defense in depth; it usually does).
113 Code,
114 /// A run of at least [`MIN_LOG_RUN_LINES`] log-like lines. Tagged
115 /// `kind = "log"` so `abstract.log_dedup` can consider it for
116 /// repeated-line collapsing exactly like a caller-declared `kind: "log"`
117 /// fragment in `compile_context`.
118 Log,
119}
120
121impl SegmentKind {
122 /// The [`ContextFragment::kind`] hint this segment kind maps to —
123 /// `None` for `body` (let the rule table decide unconstrained).
124 fn fragment_kind(self) -> Option<&'static str> {
125 match self {
126 Self::Body => None,
127 Self::Code => Some("code"),
128 Self::Log => Some("log"),
129 }
130 }
131}
132
133/// Tuning knobs for [`segment_transcript`]. `Default` is the recommended
134/// profile.
135#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
136#[serde(default)]
137#[schemars(transform = crate::schema::strip_int_formats)]
138pub struct SegmentationPolicy {
139 /// Which format to assume (see [`SegmentFormat`]). Default [`SegmentFormat::Auto`].
140 pub format: SegmentFormat,
141 /// Segments under this many bytes merge into an adjacent segment of the
142 /// same turn and kind (see the module docs' step 4). Default `256`.
143 pub min_segment_bytes: usize,
144 /// When `true` (the default) and [`SegmentationPolicy::format`]
145 /// determines the FIRST turn's role is `"system"` (case-insensitive),
146 /// every segment of that turn is marked `metadata.cache = true` — the
147 /// same signal `compile_context`'s `cache.stable_prefix` rule reads, so
148 /// a system prompt turn becomes the compiled output's stable,
149 /// cache-friendly prefix without the caller hand-annotating it.
150 pub cache_system_turn: bool,
151}
152
153impl Default for SegmentationPolicy {
154 fn default() -> Self {
155 Self {
156 format: SegmentFormat::Auto,
157 min_segment_bytes: 256,
158 cache_system_turn: true,
159 }
160 }
161}
162
163/// One segmented piece of the transcript: an ordinary [`ContextFragment`]
164/// (ready to feed [`super::ContextCompiler`]) plus the audit metadata the
165/// `compile_transcript` tool reports in its `segmentation.segments` list.
166#[derive(Debug, Clone)]
167pub struct TranscriptSegment {
168 /// The fragment this segment produces — feed it straight into a
169 /// [`super::CompileRequest::fragments`] list.
170 pub fragment: ContextFragment,
171 /// Which turn (0-based, transcript order) this segment belongs to.
172 pub turn: usize,
173 /// The turn's role, when one was determined (a marker match in `plain`
174 /// mode, or the parsed `role` field in `jsonl` mode). `None` for a
175 /// `plain` transcript with no matching marker at all.
176 pub role: Option<String>,
177 /// What kind of content this segment carries.
178 pub kind: SegmentKind,
179 /// Start byte offset (inclusive) of this segment in the ORIGINAL
180 /// transcript text.
181 pub byte_start: usize,
182 /// End byte offset (exclusive) of this segment in the ORIGINAL
183 /// transcript text.
184 pub byte_end: usize,
185}
186
187/// The full result of [`segment_transcript`]: the detected format, the
188/// segments, and how much normalization merging did.
189#[derive(Debug, Clone)]
190pub struct SegmentationOutcome {
191 /// `jsonl` or `plain` — never [`SegmentFormat::Auto`], which only ever
192 /// names a caller's REQUEST, not a detected outcome.
193 pub format_detected: SegmentFormat,
194 /// The final segments, in transcript order.
195 pub segments: Vec<TranscriptSegment>,
196 /// How many segments the [`SegmentationPolicy::min_segment_bytes`] merge
197 /// step eliminated (`pieces_before_merge - segments.len()`).
198 pub merged_segments: usize,
199}
200
201/// Segment `text` under `policy` — see the module docs for the full
202/// pipeline. Pure: no I/O, no clock, no randomness; the same `text` +
203/// `policy` always produce byte-identical output.
204///
205/// # Errors
206/// [`MemoryError::ContextOverLimit`] when `text` exceeds
207/// [`MAX_TRANSCRIPT_BYTES`], when an unsplittable fence exceeds
208/// [`MAX_FRAGMENT_BYTES`], or when the segment count after merging still
209/// exceeds [`MAX_FRAGMENTS`] — all genuine budget/cap breaches.
210/// [`MemoryError::SegmentationError`] when [`SegmentFormat::Jsonl`] is forced
211/// but a line does not parse as a `{role, content}` object — a FORMAT
212/// failure, not a budget breach (issue #1516, m2).
213pub fn segment_transcript(
214 text: &str,
215 policy: &SegmentationPolicy,
216) -> Result<SegmentationOutcome, MemoryError> {
217 if text.len() > MAX_TRANSCRIPT_BYTES {
218 return Err(MemoryError::ContextOverLimit(format!(
219 "transcript of {} bytes exceeds the cap of {MAX_TRANSCRIPT_BYTES} bytes",
220 text.len()
221 )));
222 }
223
224 let (format_detected, pieces) = detect_and_segment(text, policy.format)?;
225 reject_oversized_fences(&pieces)?;
226 let pieces = resplit_oversized_bodies(text, pieces);
227 let pieces_before_merge = pieces.len();
228 let merged = merge_tiny(pieces, policy.min_segment_bytes);
229 if merged.len() > MAX_FRAGMENTS {
230 return Err(MemoryError::ContextOverLimit(format!(
231 "transcript segmented into {} fragments, exceeding the cap of {MAX_FRAGMENTS} — \
232 raise segmentation.min_segment_bytes to merge more small segments",
233 merged.len()
234 )));
235 }
236 let merged_segments = pieces_before_merge - merged.len();
237 let segments = merged
238 .into_iter()
239 .map(|piece| build_segment(text, piece, policy))
240 .collect();
241 Ok(SegmentationOutcome {
242 format_detected,
243 segments,
244 merged_segments,
245 })
246}
247
248// --- Raw (pre-normalization) pieces -----------------------------------------
249
250/// A sub-segment before normalization: still tied to the ORIGINAL text's byte
251/// range, except `content_override` — set only for a `jsonl` turn (and its
252/// re-split children), whose fragment content is a JSON-decoded string with
253/// no byte-aligned slice of the raw transcript (JSON escaping means the
254/// decoded text is not a substring of the source bytes). When set, `range`
255/// still names the raw JSON line's span (needed so the segmentation-wide
256/// byte ranges keep partitioning the transcript), but the fragment's
257/// `content` comes from `content_override`, never `text[range]`.
258struct RawPiece {
259 kind: SegmentKind,
260 range: Range<usize>,
261 turn: usize,
262 role: Option<String>,
263 content_override: Option<String>,
264}
265
266/// Detect the format and produce the initial (pre-normalization) pieces in
267/// one pass — for `jsonl` this avoids parsing every line twice (once to
268/// detect, once to build).
269fn detect_and_segment(
270 text: &str,
271 requested: SegmentFormat,
272) -> Result<(SegmentFormat, Vec<RawPiece>), MemoryError> {
273 match requested {
274 SegmentFormat::Plain => Ok((SegmentFormat::Plain, plain_pieces(text))),
275 SegmentFormat::Jsonl => {
276 let pieces = jsonl_pieces(text).map_err(MemoryError::SegmentationError)?;
277 Ok((SegmentFormat::Jsonl, pieces))
278 }
279 SegmentFormat::Auto => {
280 if !text.is_empty() {
281 if let Ok(pieces) = jsonl_pieces(text) {
282 return Ok((SegmentFormat::Jsonl, pieces));
283 }
284 }
285 Ok((SegmentFormat::Plain, plain_pieces(text)))
286 }
287 }
288}
289
290// --- JSONL -------------------------------------------------------------------
291
292/// One JSONL line's required shape. Both fields are mandatory: a line
293/// missing either — or not a JSON object at all — fails to parse, which
294/// [`detect_and_segment`] treats as "not jsonl" in [`SegmentFormat::Auto`]
295/// and as a hard error under a forced [`SegmentFormat::Jsonl`].
296#[derive(Deserialize)]
297struct JsonlLine {
298 role: String,
299 content: String,
300}
301
302/// Parse every non-blank line of `text` as one JSONL turn. A wholly empty
303/// line (`""` once the trailing `\r`/`\n` is stripped) never fails parsing
304/// and never opens a turn of its own — its bytes fold into the PRECEDING
305/// piece's range (or, for a leading blank run with no preceding piece yet,
306/// are deferred and prepended onto the first real turn once one arrives) so
307/// the byte ranges keep partitioning `text` exactly. Without this, a
308/// perfectly valid JSONL transcript that merely uses a blank line as a
309/// separator would fail to parse and (in [`SegmentFormat::Auto`]) silently
310/// fall back to a single roleless `plain` turn.
311///
312/// `Err` names the first (1-based) offending LINE — not turn — number: the
313/// first failure short-circuits, so a caller forcing `jsonl` on a bad
314/// transcript gets an actionable pointer instead of a generic "not jsonl".
315fn jsonl_pieces(text: &str) -> Result<Vec<RawPiece>, String> {
316 let mut pieces: Vec<RawPiece> = Vec::new();
317 let mut pending_prefix_start: Option<usize> = None;
318 let mut turn = 0_usize;
319 let mut cursor = 0_usize;
320 for (line_index, line) in text.split_inclusive('\n').enumerate() {
321 let start = cursor;
322 cursor += line.len();
323 let trimmed = line.trim_end_matches(['\r', '\n']);
324 if trimmed.is_empty() {
325 if let Some(last) = pieces.last_mut() {
326 last.range.end = cursor;
327 } else {
328 pending_prefix_start.get_or_insert(start);
329 }
330 continue;
331 }
332 let parsed: JsonlLine = serde_json::from_str(trimmed).map_err(|err| {
333 format!(
334 "jsonl line {}: not a valid {{role, content}} object: {err}",
335 line_index + 1
336 )
337 })?;
338 let piece_start = pending_prefix_start.take().unwrap_or(start);
339 pieces.push(RawPiece {
340 kind: SegmentKind::Body,
341 range: piece_start..cursor,
342 turn,
343 role: Some(parsed.role),
344 content_override: Some(parsed.content),
345 });
346 turn += 1;
347 }
348 if pieces.is_empty() {
349 // Every line (if any at all) was blank — nothing real to call
350 // jsonl; Auto mode falls back to plain, a forced jsonl request gets
351 // an honest error instead of a silently empty result.
352 return Err("no non-blank jsonl line found".to_owned());
353 }
354 Ok(pieces)
355}
356
357// --- Plain ---------------------------------------------------------------
358
359/// The CLOSED table of plain-text turn markers, checked in order — the first
360/// one a line starts with wins. Never a caller-supplied pattern, so turn
361/// detection stays deterministic and predictable (a "User:" cited in prose
362/// is a known, accepted false positive — see the crate README).
363const PLAIN_MARKERS: &[&str] = &[
364 "System:",
365 "User:",
366 "Human:",
367 "Assistant:",
368 "AI:",
369 "Tool:",
370 "### User",
371 "### Assistant",
372];
373
374/// The first [`PLAIN_MARKERS`] entry `line` starts with, if any.
375fn match_marker(line: &str) -> Option<&'static str> {
376 PLAIN_MARKERS
377 .iter()
378 .find(|marker| line.starts_with(*marker))
379 .copied()
380}
381
382/// A marker's role label: `"### User"` → `"User"`, `"System:"` → `"System"`.
383fn marker_role(marker: &str) -> String {
384 marker
385 .strip_prefix("### ")
386 .unwrap_or(marker)
387 .trim_end_matches(':')
388 .to_owned()
389}
390
391/// Split `text` into plain-format turns: `(byte_range, role)`, in order,
392/// partitioning `text` exactly. No marker anywhere in `text` yields exactly
393/// one turn covering the whole text with `role: None`.
394fn plain_turns(text: &str) -> Vec<(Range<usize>, Option<String>)> {
395 let mut turns = Vec::new();
396 let mut turn_start = 0_usize;
397 let mut pending_role: Option<String> = None;
398 let mut cursor = 0_usize;
399 for line in text.split_inclusive('\n') {
400 let line_start = cursor;
401 if let Some(marker) = match_marker(line) {
402 if line_start > turn_start {
403 turns.push((turn_start..line_start, pending_role.take()));
404 }
405 pending_role = Some(marker_role(marker));
406 turn_start = line_start;
407 }
408 cursor += line.len();
409 }
410 turns.push((turn_start..text.len(), pending_role));
411 turns
412}
413
414/// Build the initial pieces for a `plain` transcript: turns, then within
415/// each turn's slice, fences (atomic `code`) and log runs (`log`), the rest
416/// `body` — see the module docs' step 3.
417fn plain_pieces(text: &str) -> Vec<RawPiece> {
418 let mut pieces = Vec::new();
419 for (turn, (range, role)) in plain_turns(text).into_iter().enumerate() {
420 if range.is_empty() {
421 continue;
422 }
423 for segment in chunk::fence_segments(&text[range.clone()]) {
424 match segment {
425 chunk::Segment::Fence(relative) => pieces.push(RawPiece {
426 kind: SegmentKind::Code,
427 range: (range.start + relative.start)..(range.start + relative.end),
428 turn,
429 role: role.clone(),
430 content_override: None,
431 }),
432 chunk::Segment::Plain(relative) => {
433 let absolute = (range.start + relative.start)..(range.start + relative.end);
434 for (kind, sub_range) in log_split(text, absolute) {
435 pieces.push(RawPiece {
436 kind,
437 range: sub_range,
438 turn,
439 role: role.clone(),
440 content_override: None,
441 });
442 }
443 }
444 }
445 }
446 }
447 pieces
448}
449
450/// Split `range` of `text` into alternating `body`/`log` pieces: a maximal
451/// run of at least [`MIN_LOG_RUN_LINES`] consecutive "log-candidate" lines
452/// (a volatile timestamp/pid prefix, or a line that repeats elsewhere in
453/// `range`) becomes one `log` piece; every other line stays `body`,
454/// contiguous runs of it merged into one piece. Single linear scan.
455fn log_split(text: &str, range: Range<usize>) -> Vec<(SegmentKind, Range<usize>)> {
456 if range.is_empty() {
457 return Vec::new();
458 }
459 let slice = &text[range.clone()];
460 let mut lines: Vec<(Range<usize>, &str)> = Vec::new();
461 let mut cursor = range.start;
462 for line in slice.split_inclusive('\n') {
463 let end = cursor + line.len();
464 lines.push((cursor..end, line));
465 cursor = end;
466 }
467 if lines.is_empty() {
468 return Vec::new();
469 }
470
471 let trimmed: Vec<&str> = lines
472 .iter()
473 .map(|(_, line)| line.trim_end_matches(['\r', '\n']))
474 .collect();
475 let mut repeat_counts: BTreeMap<&str, usize> = BTreeMap::new();
476 for line in &trimmed {
477 *repeat_counts.entry(line).or_insert(0) += 1;
478 }
479 let candidate: Vec<bool> = trimmed
480 .iter()
481 .map(|line| {
482 !line.is_empty() && (mask_volatile_prefix(line).is_some() || repeat_counts[line] > 1)
483 })
484 .collect();
485
486 let mut pieces = Vec::new();
487 let mut body_start: Option<usize> = None;
488 let mut index = 0_usize;
489 while index < lines.len() {
490 if candidate[index] {
491 let run_start = index;
492 while index < lines.len() && candidate[index] {
493 index += 1;
494 }
495 if index - run_start >= MIN_LOG_RUN_LINES {
496 if let Some(start) = body_start.take() {
497 pieces.push((
498 SegmentKind::Body,
499 lines[start].0.start..lines[run_start - 1].0.end,
500 ));
501 }
502 pieces.push((
503 SegmentKind::Log,
504 lines[run_start].0.start..lines[index - 1].0.end,
505 ));
506 } else if body_start.is_none() {
507 body_start = Some(run_start);
508 }
509 } else {
510 if body_start.is_none() {
511 body_start = Some(index);
512 }
513 index += 1;
514 }
515 }
516 if let Some(start) = body_start {
517 pieces.push((
518 SegmentKind::Body,
519 lines[start].0.start..lines[lines.len() - 1].0.end,
520 ));
521 }
522 pieces
523}
524
525// --- Normalization -----------------------------------------------------------
526
527/// Reject an unsplittable fence over [`MAX_FRAGMENT_BYTES`] — a fence is
528/// always atomic (never cut, see [`super::chunk`]), so an oversized one
529/// cannot be brought under the cap the way a `body` piece can.
530///
531/// # Errors
532/// [`MemoryError::ContextOverLimit`] naming the first oversized fence found.
533fn reject_oversized_fences(pieces: &[RawPiece]) -> Result<(), MemoryError> {
534 if let Some(piece) = pieces
535 .iter()
536 .find(|piece| piece.kind == SegmentKind::Code && piece.range.len() > MAX_FRAGMENT_BYTES)
537 {
538 return Err(MemoryError::ContextOverLimit(format!(
539 "an unsplittable fenced code block of {} bytes exceeds the cap of {MAX_FRAGMENT_BYTES} bytes",
540 piece.range.len()
541 )));
542 }
543 Ok(())
544}
545
546/// Re-split every `body` or `log` piece over [`MAX_FRAGMENT_BYTES`] — see
547/// [`resplit_body`] and [`resplit_log`] for the two (deliberately different)
548/// strategies. A `code` piece is never touched here: it is atomic by
549/// construction (a fence is never cut, see [`super::chunk`]) and already
550/// rejected outright by [`reject_oversized_fences`] when oversized.
551fn resplit_oversized_bodies(text: &str, pieces: Vec<RawPiece>) -> Vec<RawPiece> {
552 let chunk_policy = ChunkPolicy {
553 max_chunk_bytes: MAX_FRAGMENT_BYTES,
554 overlap_bytes: 0,
555 boundary: ChunkBoundary::Paragraph,
556 };
557 pieces
558 .into_iter()
559 .flat_map(|piece| resplit_one(text, piece, &chunk_policy))
560 .collect()
561}
562
563fn resplit_one(text: &str, piece: RawPiece, chunk_policy: &ChunkPolicy) -> Vec<RawPiece> {
564 match piece.kind {
565 SegmentKind::Body => resplit_body(text, piece, chunk_policy),
566 SegmentKind::Log => resplit_log(text, piece),
567 SegmentKind::Code => vec![piece],
568 }
569}
570
571/// Re-split a `body` piece over [`MAX_FRAGMENT_BYTES`] with [`chunk_text`] —
572/// the same re-chunker `compile_context` itself uses for an oversized
573/// fragment. A `jsonl` piece's decoded `content_override` has no byte-exact
574/// mapping back to the raw (JSON-escaped) source line, so its re-split
575/// children cannot each carry a byte-precise slice of the line the way a
576/// plain-text `body` piece's children do; instead
577/// [`partition_range_by_weight`] divides the ORIGINAL line's byte range
578/// across the children proportionally to each child's share of the decoded
579/// content, which keeps the partition property `compile_transcript`
580/// advertises (no overlap, no gap, covers exactly the parent range) without
581/// claiming a provenance the JSON escaping makes impossible (issue #1516,
582/// m3 — previously every child kept the full, identical parent range,
583/// which duplicated it across `segmentation.segments`).
584fn resplit_body(text: &str, piece: RawPiece, chunk_policy: &ChunkPolicy) -> Vec<RawPiece> {
585 let effective_len = piece
586 .content_override
587 .as_ref()
588 .map_or(piece.range.len(), String::len);
589 if effective_len <= MAX_FRAGMENT_BYTES {
590 return vec![piece];
591 }
592 match &piece.content_override {
593 Some(content) => {
594 let chunks = chunk_text(content, chunk_policy);
595 let weights: Vec<usize> = chunks.iter().map(|chunk| chunk.text.len()).collect();
596 let ranges = partition_range_by_weight(&piece.range, &weights);
597 chunks
598 .into_iter()
599 .zip(ranges)
600 .map(|(chunk, range)| RawPiece {
601 kind: SegmentKind::Body,
602 range,
603 turn: piece.turn,
604 role: piece.role.clone(),
605 content_override: Some(chunk.text),
606 })
607 .collect()
608 }
609 None => chunk_text(&text[piece.range.clone()], chunk_policy)
610 .into_iter()
611 .map(|chunk| RawPiece {
612 kind: SegmentKind::Body,
613 range: (piece.range.start + chunk.byte_range.start)
614 ..(piece.range.start + chunk.byte_range.end),
615 turn: piece.turn,
616 role: piece.role.clone(),
617 content_override: None,
618 })
619 .collect(),
620 }
621}
622
623/// Divide `range` into `weights.len()` contiguous, non-overlapping
624/// sub-ranges that exactly partition it (no gap, no overlap, first starts at
625/// `range.start`, last ends at `range.end`), each sized proportionally to
626/// its matching entry in `weights` (typically a re-split child's decoded
627/// content length). Used by [`resplit_body`] for a `jsonl` piece's
628/// `content_override` children, whose decoded text has no byte-exact
629/// mapping back into the raw (JSON-escaped) source range: this gives each
630/// child a distinct, deterministic slice of the parent range instead of
631/// every child duplicating the whole thing (issue #1516, m3).
632///
633/// Every prefix sum is monotonically non-decreasing (weights are
634/// non-negative and `range.len()` and the total weight are both fixed), so
635/// the resulting boundaries never go backwards — the partition property
636/// holds even when a weight is `0` (an empty chunk gets an empty
637/// sub-range) or when `weights` sums to `0` (falls back to handing the
638/// whole range to the last entry, cursor stays at `range.start` for every
639/// other one).
640fn partition_range_by_weight(range: &Range<usize>, weights: &[usize]) -> Vec<Range<usize>> {
641 debug_assert!(!weights.is_empty(), "must have at least one child");
642 let total: usize = weights.iter().sum::<usize>().max(1);
643 let span = range.len();
644 let mut start = range.start;
645 let mut cumulative = 0_usize;
646 let last_index = weights.len() - 1;
647 weights
648 .iter()
649 .enumerate()
650 .map(|(index, weight)| {
651 cumulative += weight;
652 let end = if index == last_index {
653 range.end
654 } else {
655 range.start + (span * cumulative) / total
656 };
657 let sub_range = start..end;
658 start = end;
659 sub_range
660 })
661 .collect()
662}
663
664/// Re-split a `log` piece over [`MAX_FRAGMENT_BYTES`] on LINE boundaries —
665/// never mid-line, so each resulting sub-run stays meaningful to
666/// `abstract.log_dedup` (which classifies and dedups per fragment, not
667/// across a cut line). Unlike [`resplit_body`], never [`chunk_text`]
668/// directly: paragraph-boundary chunking has no notion of "line", and would
669/// happily cut a log line in half. A `log` piece never carries a
670/// `content_override` (only `jsonl` pieces do, and `jsonl` never produces
671/// `log` — see the module docs), so this always reads straight from `text`.
672///
673/// Lines are packed greedily into chunks of at most [`MAX_FRAGMENT_BYTES`];
674/// a single line that alone exceeds the cap (extreme edge case — one log
675/// line over 1 MiB) is hard-split at char boundaries as a last resort, the
676/// same fallback [`super::chunk::chunk_text`] uses for an oversized atomic
677/// unit.
678fn resplit_log(text: &str, piece: RawPiece) -> Vec<RawPiece> {
679 if piece.range.len() <= MAX_FRAGMENT_BYTES {
680 return vec![piece];
681 }
682 let hard_split_policy = ChunkPolicy {
683 max_chunk_bytes: MAX_FRAGMENT_BYTES,
684 overlap_bytes: 0,
685 boundary: ChunkBoundary::Fixed,
686 };
687 let mut result = Vec::new();
688 let mut chunk_start = piece.range.start;
689 let mut cursor = piece.range.start;
690 for line in text[piece.range.clone()].split_inclusive('\n') {
691 let line_start = cursor;
692 let line_end = line_start + line.len();
693 cursor = line_end;
694
695 if line_end - line_start > MAX_FRAGMENT_BYTES {
696 // The line itself is oversized: seal whatever came before it,
697 // hard-split the line alone, then resume after it.
698 if chunk_start < line_start {
699 result.push(log_piece(&piece, chunk_start..line_start));
700 }
701 for hard in chunk_text(&text[line_start..line_end], &hard_split_policy) {
702 result.push(log_piece(
703 &piece,
704 (line_start + hard.byte_range.start)..(line_start + hard.byte_range.end),
705 ));
706 }
707 chunk_start = line_end;
708 continue;
709 }
710
711 if line_end - chunk_start > MAX_FRAGMENT_BYTES {
712 // Adding this line would overflow the open chunk: seal it
713 // first — `chunk_start..line_start` is guaranteed non-empty
714 // here (a lone line never exceeds the cap in this branch).
715 result.push(log_piece(&piece, chunk_start..line_start));
716 chunk_start = line_start;
717 }
718 }
719 if chunk_start < piece.range.end {
720 result.push(log_piece(&piece, chunk_start..piece.range.end));
721 }
722 result
723}
724
725/// A `log`-kind [`RawPiece`] over `range`, inheriting `source`'s turn/role —
726/// the shared constructor [`resplit_log`]'s two push sites use.
727fn log_piece(source: &RawPiece, range: Range<usize>) -> RawPiece {
728 RawPiece {
729 kind: SegmentKind::Log,
730 range,
731 turn: source.turn,
732 role: source.role.clone(),
733 content_override: None,
734 }
735}
736
737/// Merge adjacent pieces of the SAME turn and kind when either side is under
738/// `min_bytes` — see the module docs' step 4. A `jsonl` piece never merges
739/// with another (each holds its own unique `turn`, since `jsonl` is
740/// one-line-one-turn by construction), nor does any piece carrying a
741/// `content_override` (merging would require re-deriving a combined decoded
742/// string, which is not meaningful once JSON escaping is involved).
743///
744/// **Never merges past [`MAX_FRAGMENT_BYTES`]** — a piece that survived
745/// [`resplit_body`]/[`resplit_log`] is only guaranteed to be AT MOST the
746/// cap, so blindly recombining it with even a tiny neighbor can push the
747/// result back over (a ~1 MiB chunk plus a few trailing bytes, or two
748/// adjacent fences each individually under the cap). Merging is an
749/// optimization (fewer, more useful fragments), never allowed to violate the
750/// one invariant every other normalization step exists to uphold.
751fn merge_tiny(pieces: Vec<RawPiece>, min_bytes: usize) -> Vec<RawPiece> {
752 let mut merged: Vec<RawPiece> = Vec::new();
753 for piece in pieces {
754 let mergeable = merged
755 .last()
756 .is_some_and(|last| can_absorb(last, &piece, min_bytes));
757 // `mergeable` is only true when `merged` is non-empty, but branch on
758 // the `Option` rather than assume it: a future edit to `mergeable`
759 // that breaks the invariant then drops nothing silently and panics
760 // nowhere, it just falls back to pushing `piece` as its own entry.
761 match merged.last_mut() {
762 Some(last) if mergeable => last.range.end = piece.range.end,
763 _ => merged.push(piece),
764 }
765 }
766 merged
767}
768
769/// [`merge_tiny`]'s absorption predicate, one clause per rule the doc above
770/// it narrates: same turn and kind, neither side override-bearing, byte
771/// ranges adjacent, the combined size under [`MAX_FRAGMENT_BYTES`], and at
772/// least one side actually tiny.
773fn can_absorb(last: &RawPiece, next: &RawPiece, min_bytes: usize) -> bool {
774 last.turn == next.turn
775 && last.kind == next.kind
776 && last.content_override.is_none()
777 && next.content_override.is_none()
778 && last.range.end == next.range.start
779 && last.range.len() + next.range.len() <= MAX_FRAGMENT_BYTES
780 && (last.range.len() < min_bytes || next.range.len() < min_bytes)
781}
782
783// --- Assembly ------------------------------------------------------------
784
785/// Build the final [`TranscriptSegment`] for one normalized piece:
786/// `metadata = {role, turn}`, plus `cache: true` when
787/// [`SegmentationPolicy::cache_system_turn`] applies (turn 0, role
788/// case-insensitively `"system"`).
789fn build_segment(text: &str, piece: RawPiece, policy: &SegmentationPolicy) -> TranscriptSegment {
790 let content = piece
791 .content_override
792 .clone()
793 .unwrap_or_else(|| text[piece.range.clone()].to_owned());
794
795 let mut metadata = Map::new();
796 metadata.insert(
797 "role".to_owned(),
798 piece.role.clone().map_or(Value::Null, Value::String),
799 );
800 metadata.insert("turn".to_owned(), Value::Number(piece.turn.into()));
801 let is_first_turn_system = piece.turn == 0
802 && piece
803 .role
804 .as_deref()
805 .is_some_and(|role| role.eq_ignore_ascii_case("system"));
806 if policy.cache_system_turn && is_first_turn_system {
807 metadata.insert("cache".to_owned(), Value::Bool(true));
808 }
809
810 let fragment = ContextFragment {
811 id: None,
812 content,
813 path: None,
814 kind: piece.kind.fragment_kind().map(str::to_owned),
815 priority: None,
816 metadata: Some(metadata),
817 media: None,
818 };
819 TranscriptSegment {
820 fragment,
821 turn: piece.turn,
822 role: piece.role,
823 kind: piece.kind,
824 byte_start: piece.range.start,
825 byte_end: piece.range.end,
826 }
827}
828
829#[cfg(test)]
830#[path = "segment_tests.rs"]
831mod tests;