Skip to main content

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