Skip to main content

contextgraph_host/
ingest.rs

1//! Prompt ingestion as a local provider ([ADR 0006]).
2//!
3//! The one input CGP never disciplined is the largest: the text a user pastes
4//! into a prompt. A realistic turn mixes four different things under one blob —
5//! a log, a table, a directory reference, and the actual ask — and only the last
6//! is *intent*. Pasted whole, it is re-sent verbatim every turn (no cache, no
7//! dedup), its cost is never accounted, nothing is content-addressed, and the
8//! model is handed material it must itself decide is mostly irrelevant.
9//!
10//! This module is the ingestion-side dual of [`compose_context`](crate::compose):
11//! host-side reference behavior, **not** wire protocol. It turns a paste into an
12//! ordinary [`ContextProvider`]:
13//!
14//! - **intent** passes through *verbatim* as [`ContextQuery::goal`] — the one
15//!   thing the mechanism must never rewrite;
16//! - **directory references** become [`ContextQuery::anchors`] (zero tokens; the
17//!   graph provider resolves them better than pasted text could);
18//! - **evidence** (logs, tables, code, notes) becomes content-addressed frames,
19//!   served [`compact`](Representation::Compact) by default with the full bytes
20//!   retrievable losslessly by re-querying for [`full`](Representation::Full).
21//!
22//! The guarantee is not "zero wasted tokens" — relevance is only knowable
23//! downstream. It is **bounded default cost with lossless retrieval**: the model
24//! sees a distilled, budgeted rendering; the full bytes stay content-addressed
25//! and pullable. Every emitted frame is honest by construction — `token_cost`
26//! and the inline `content_digest` are recomputed for the exact representation
27//! served (§B3), and every frame satisfies its
28//! [`representation_invariants`](ContextFrame::representation_invariants).
29//!
30//! # Classification precedence
31//!
32//! `classify` walks a ladder from the least ambiguous shape to the most, and
33//! the *order* is load-bearing rather than incidental — several of these shapes
34//! can imitate each other:
35//!
36//! 1. a fenced ```` ``` ```` region → `Code` (the user drew the box themselves);
37//! 2. a lone path-shaped token → `PathRef`;
38//! 3. an exception header plus stack frames → `StackTrace`;
39//! 4. a **timestamped** log — half the lines open with a clock and some line
40//!    carries a level token — → `Log`, deliberately *ahead* of table detection,
41//!    because a pipe-delimited log (`ts | LEVEL | msg`) otherwise reads as a
42//!    table purely for sharing a delimiter count;
43//! 5. an explicitly delimited table (`|`, tab, comma) → `Table`;
44//! 6. a weaker log (level tokens or bracketed prefixes, no timestamps) → `Log`;
45//! 7. a whitespace-aligned table → `Table`, the weakest tabular signal and
46//!    therefore the last one tried: the two spaces after a padded `INFO ` look
47//!    exactly like a column break;
48//! 8. unfenced but code-shaped lines → `Code`; anything left is `Prose`.
49//!
50//! ## Known limits
51//!
52//! Heuristics this cheap misread things, and the misreads below are *accepted*
53//! rather than unnoticed. None of them can produce a dishonest frame — cost,
54//! digest, and provenance are computed from the bytes actually emitted whatever
55//! the kind — and every one of them is visible in the [`SegmentReport`] pill, so
56//! a host UI can offer the correction rather than the user discovering it later:
57//!
58//! - **A CSV of sentences reads as prose.** Comma detection requires cells that
59//!   read like values (see `MAX_CELL_WORDS`), because English is full of
60//!   commas. The block still becomes a verbatim `doc` frame — losing the column
61//!   summary, not the evidence.
62//! - **A scheme-less `example.com/path.rs` still reads as an anchor.** A
63//!   hostname-shaped first segment is now rejected (`looks_like_path`), so
64//!   `example.com/path` is prose; but a token ending in a source extension is
65//!   taken as a path, because in a workspace paste that is overwhelmingly what
66//!   it is.
67//! - **Syslog and bare clocks identify a log but never bound it.** `Jul 20
68//!   18:00:01` names no year and `18:00:01` names no day; F4 has no spelling for
69//!   "no date", so the line counts as timestamped for classification while
70//!   `valid_from`/`valid_to` stay empty rather than carry an invented instant.
71//! - **A zone-less timestamp is read as UTC.** See `zone_is_utc` for why that
72//!   assumption is the honest one and a numeric offset is refused instead.
73//!
74//! [ADR 0006]: https://github.com/macanderson/context-graph-protocol/blob/main/docs/adr/0006-prompt-ingestion-as-a-local-provider.md
75
76use std::collections::{BTreeSet, HashSet};
77
78use async_trait::async_trait;
79use serde::{Deserialize, Serialize};
80use sha2::{Digest, Sha256};
81
82use contextgraph_types::{
83    Capabilities, ContentFidelity, ContentRef, ContextFrame, ContextQuery, ContextQueryResult,
84    DataFlow, EgressScope, FrameKind, FrameVerdict, InlineContentRequirement, Provenance,
85    ProviderInfo, QueryCapability, Representation, Transform, Verdict, VerifyRequest,
86    VerifyResponse, budget_tokens, is_protocol_timestamp,
87};
88
89use crate::error::HostError;
90use crate::provider::{ContextProvider, frame_kind_name};
91
92/// Below this canonical cost a compact rendering is not worth producing — the
93/// artifact is served verbatim (fidelity `exact`). ~256 source bytes.
94const COMPACT_MIN_TOKENS: u32 = 64;
95/// Lines of context kept on each side of an alert line when distilling a log.
96const LOG_CONTEXT: usize = 2;
97/// Head/tail lines kept when a log has no alert lines to anchor on.
98const LOG_HEAD: usize = 8;
99const LOG_TAIL: usize = 4;
100/// Data rows shown in a distilled table sample.
101const TABLE_SAMPLE: usize = 5;
102/// Rows a block needs before an *ambiguous* delimiter (a comma, a run of
103/// spaces) is allowed to make it a table. Two lines that share a comma are a
104/// coincidence; three are a shape.
105const MIN_AMBIGUOUS_TABLE_ROWS: usize = 3;
106/// Longest a cell may be, in words, for an ambiguously-delimited block to still
107/// read as tabular. Data cells are short; clauses are not, and this is the guard
108/// that keeps a comma-spliced paragraph out of the table distiller.
109const MAX_CELL_WORDS: usize = 4;
110/// Stack frames kept from the top of a distilled trace. The top is where the
111/// fault is; the tail is framework and runtime.
112const STACK_FRAMES: usize = 8;
113/// Head/tail lines kept when distilling an oversized code block.
114const CODE_HEAD: usize = 20;
115const CODE_TAIL: usize = 8;
116/// Version stamped into every [`Transform`] this module emits, so a consumer can
117/// tell which distiller produced an inline rendering.
118const TRANSFORM_VERSION: &str = "1";
119/// The transform implementation identity.
120const TRANSFORM_IMPL: &str = "contextgraph-host/ingest";
121/// Default provider id / consent key for an ingested paste.
122pub const DEFAULT_PROVIDER_ID: &str = "prompt-ingest";
123
124// ---------------------------------------------------------------------------
125// Content addressing
126// ---------------------------------------------------------------------------
127
128fn sha256_hex(bytes: &[u8]) -> String {
129    let digest = Sha256::digest(bytes);
130    let mut hex = String::with_capacity(64);
131    for byte in digest {
132        // `sha256:<64 lowercase hex>` — lowercase is mandated by §F5, and the
133        // whole dedup/cache story depends on the same bytes hashing identically.
134        hex.push(char::from_digit((byte >> 4) as u32, 16).unwrap());
135        hex.push(char::from_digit((byte & 0x0f) as u32, 16).unwrap());
136    }
137    hex
138}
139
140/// A protocol content digest over `s`: `sha256:<64 lowercase hex>` (§F5).
141fn sha256_digest(s: &str) -> String {
142    format!("sha256:{}", sha256_hex(s.as_bytes()))
143}
144
145/// The 12-hex-character short form used to build a stable, content-addressed
146/// frame id. Same bytes ⇒ same id ⇒ one deduplicated frame.
147fn short_hash(digest: &str) -> &str {
148    let hex = digest.strip_prefix("sha256:").unwrap_or(digest);
149    &hex[..hex.len().min(12)]
150}
151
152// ---------------------------------------------------------------------------
153// Public surface
154// ---------------------------------------------------------------------------
155
156/// A user's paste, decomposed into the three things it actually is.
157///
158/// `intent` is sacrosanct — it becomes [`ContextQuery::goal`] byte-for-byte and
159/// is never mediated. `anchors` are focal URIs the host already knows (open
160/// files, mentioned symbols); path references discovered inside `attachments`
161/// are appended to them. `attachments` are the pasted evidence blobs, each
162/// segmented and content-addressed.
163#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
164pub struct PasteIngest {
165    /// The user's own words — passed through verbatim as the query goal.
166    pub intent: String,
167    /// Focal URIs the host already considers relevant.
168    #[serde(default)]
169    pub anchors: Vec<String>,
170    /// Raw pasted evidence blobs (a log, a table, a code block, …).
171    #[serde(default)]
172    pub attachments: Vec<String>,
173}
174
175impl PasteIngest {
176    /// A paste with just intent and one evidence blob — the common case.
177    pub fn new(intent: impl Into<String>, attachment: impl Into<String>) -> Self {
178        Self {
179            intent: intent.into(),
180            anchors: Vec::new(),
181            attachments: vec![attachment.into()],
182        }
183    }
184}
185
186/// Knobs for [`ingest_paste`].
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct IngestConfig {
189    /// The provider's host-facing id and consent key.
190    pub provider_id: String,
191}
192
193impl Default for IngestConfig {
194    fn default() -> Self {
195        Self {
196            provider_id: DEFAULT_PROVIDER_ID.to_string(),
197        }
198    }
199}
200
201/// The classification a segment received. Deterministic and heuristic — the
202/// same posture as `validate.rs`, reproducible from the bytes alone.
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
204#[serde(rename_all = "snake_case")]
205pub enum SegmentKind {
206    /// A log capture → an `episode` frame.
207    Log,
208    /// An exception or panic with its stack → an `episode` frame, distilled by
209    /// the trace-aware distiller rather than the line-salience one.
210    StackTrace,
211    /// Delimited tabular data → a `fact` frame.
212    Table,
213    /// A source-code block → a `snippet` frame.
214    Code,
215    /// Free text the user attached as evidence → a `doc` frame.
216    Prose,
217    /// A filesystem path or directory reference → a query anchor, not a frame.
218    PathRef,
219}
220
221impl SegmentKind {
222    fn frame_kind(self) -> Option<FrameKind> {
223        match self {
224            // A stack trace is an episode for the same reason a log is: it is a
225            // capture of something that *happened*, at an instant, not a
226            // standing fact about the workspace.
227            SegmentKind::Log | SegmentKind::StackTrace => Some(FrameKind::Episode),
228            SegmentKind::Table => Some(FrameKind::Fact),
229            SegmentKind::Code => Some(FrameKind::Snippet),
230            SegmentKind::Prose => Some(FrameKind::Doc),
231            SegmentKind::PathRef => None,
232        }
233    }
234
235    fn citation_label(self) -> &'static str {
236        match self {
237            SegmentKind::Log => "pasted log",
238            SegmentKind::StackTrace => "pasted stack trace",
239            SegmentKind::Table => "pasted table",
240            SegmentKind::Code => "pasted code",
241            SegmentKind::Prose => "pasted note",
242            SegmentKind::PathRef => "pasted path",
243        }
244    }
245
246    /// A static per-kind relevance prior. Ranking is provider-private; this is a
247    /// defensible default, always in `[0, 1]` (§F1).
248    fn score(self) -> f32 {
249        match self {
250            // A traceback outranks a log: someone who pastes one has already
251            // done the filtering, and it names the failure directly.
252            SegmentKind::StackTrace => 0.85,
253            SegmentKind::Log => 0.8,
254            SegmentKind::Code => 0.75,
255            SegmentKind::Table => 0.7,
256            SegmentKind::Prose => 0.5,
257            SegmentKind::PathRef => 0.0,
258        }
259    }
260}
261
262/// What one classified segment became — the payload of a [`SegmentReport`], and
263/// the "visible and correctable" surface a host UI renders as a pill.
264#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
265#[serde(tag = "outcome", rename_all = "snake_case")]
266pub enum SegmentOutcome {
267    /// Routed to [`ContextQuery::anchors`] — zero content, zero tokens.
268    Anchor { uri: String },
269    /// Turned into a content-addressed frame.
270    Frame {
271        id: String,
272        /// The representation the default query serves it as.
273        representation: Representation,
274        /// Budget cost of the inline (distilled) rendering the model sees.
275        inline_tokens: u32,
276        /// Budget cost of the full source — what the compact rendering saved.
277        source_tokens: u32,
278    },
279    /// Byte-identical to an earlier segment; collapsed to one frame.
280    Duplicate { id: String },
281}
282
283/// One line of the ingestion report: what a segment was classified as and what
284/// it became. Surfaced so a host never transforms input invisibly.
285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
286pub struct SegmentReport {
287    pub kind: SegmentKind,
288    /// A one-line human summary for the UI pill (e.g. `"log · 75 lines"`).
289    pub summary: String,
290    pub became: SegmentOutcome,
291}
292
293/// The result of [`ingest_paste`]: a ready-to-fan-out query, the local provider
294/// that answers it, and the classification report.
295pub struct IngestBundle {
296    /// `goal` = the intent verbatim; `anchors` include discovered paths;
297    /// `representation_preferences` prefer compact, then full.
298    pub query: ContextQuery,
299    /// The local, egress-free provider serving the pasted evidence.
300    pub provider: IngestProvider,
301    /// One entry per segment, in paste order.
302    pub report: Vec<SegmentReport>,
303}
304
305/// Turn a decomposed paste into a query + a local provider + a report.
306///
307/// Intent is preserved verbatim; evidence is segmented, content-addressed, and
308/// deduplicated by content. The returned [`IngestBundle::provider`] plugs into a
309/// [`Host`](crate::Host) like any other provider.
310pub fn ingest_paste(input: PasteIngest, config: IngestConfig) -> IngestBundle {
311    let PasteIngest {
312        intent,
313        mut anchors,
314        attachments,
315    } = input;
316
317    let mut artifacts: Vec<Artifact> = Vec::new();
318    let mut report: Vec<SegmentReport> = Vec::new();
319    let mut seen: HashSet<String> = HashSet::new();
320
321    for attachment in &attachments {
322        for block in split_blocks(attachment) {
323            let text = block.text();
324            if text.trim().is_empty() {
325                continue;
326            }
327            let kind = classify(&block);
328
329            if kind == SegmentKind::PathRef {
330                let uri = text.trim().to_string();
331                report.push(SegmentReport {
332                    kind,
333                    summary: format!("anchor · {uri}"),
334                    became: SegmentOutcome::Anchor { uri: uri.clone() },
335                });
336                if !anchors.contains(&uri) {
337                    anchors.push(uri);
338                }
339                continue;
340            }
341
342            let artifact = Artifact::build(kind, text);
343            if seen.contains(&artifact.id) {
344                report.push(SegmentReport {
345                    kind,
346                    summary: format!("duplicate · deduplicated to {}", artifact.id),
347                    became: SegmentOutcome::Duplicate { id: artifact.id },
348                });
349                continue;
350            }
351            seen.insert(artifact.id.clone());
352            report.push(SegmentReport {
353                kind,
354                summary: artifact.summary.clone(),
355                became: SegmentOutcome::Frame {
356                    id: artifact.id.clone(),
357                    representation: Representation::Compact,
358                    inline_tokens: budget_tokens(&artifact.inline_content),
359                    source_tokens: budget_tokens(&artifact.full_content),
360                },
361            });
362            artifacts.push(artifact);
363        }
364    }
365
366    // Canonical id order: deterministic query output, stable across runs.
367    artifacts.sort_by(|a, b| a.id.cmp(&b.id));
368
369    let provider = IngestProvider::new(config.provider_id, artifacts);
370    let query = ContextQuery {
371        goal: intent,
372        query_text: None,
373        embedding: None,
374        kinds: Vec::new(),
375        anchors,
376        max_frames: provider.artifacts.len() as u32,
377        max_tokens: provider.default_budget_tokens(),
378        as_of: None,
379        representation_preferences: vec![Representation::Compact, Representation::Full],
380    };
381
382    IngestBundle {
383        query,
384        provider,
385        report,
386    }
387}
388
389// ---------------------------------------------------------------------------
390// Segmentation
391// ---------------------------------------------------------------------------
392
393/// A raw block of a paste before it is classified: a run of non-blank lines, or
394/// the body of a fenced code region.
395struct RawBlock {
396    lines: Vec<String>,
397    fenced_code: bool,
398}
399
400impl RawBlock {
401    fn text(&self) -> String {
402        self.lines.join("\n")
403    }
404}
405
406/// Push `buf` as a block (moving its lines out) if it is non-empty.
407fn flush_block(buf: &mut Vec<String>, fenced_code: bool, blocks: &mut Vec<RawBlock>) {
408    if !buf.is_empty() {
409        blocks.push(RawBlock {
410            lines: std::mem::take(buf),
411            fenced_code,
412        });
413    }
414}
415
416/// Split a paste into blocks: fenced ```code``` regions are atomic; everything
417/// else is grouped into paragraphs separated by blank lines.
418fn split_blocks(text: &str) -> Vec<RawBlock> {
419    let mut blocks = Vec::new();
420    let mut current: Vec<String> = Vec::new();
421    let mut fence: Vec<String> = Vec::new();
422    let mut in_fence = false;
423
424    for line in text.lines() {
425        if line.trim_start().starts_with("```") {
426            if in_fence {
427                flush_block(&mut fence, true, &mut blocks);
428                in_fence = false;
429            } else {
430                flush_block(&mut current, false, &mut blocks);
431                in_fence = true;
432            }
433            continue;
434        }
435        if in_fence {
436            fence.push(line.to_string());
437        } else if line.trim().is_empty() {
438            flush_block(&mut current, false, &mut blocks);
439        } else {
440            current.push(line.to_string());
441        }
442    }
443    // Unterminated fence: keep what we captured rather than dropping it.
444    flush_block(&mut fence, in_fence, &mut blocks);
445    flush_block(&mut current, false, &mut blocks);
446    blocks
447}
448
449/// Classify a block. Order matters: the most specific, least-ambiguous shapes
450/// are tested first, and the full ladder — with the misreads it knowingly
451/// accepts — is documented under [Classification precedence](self#classification-precedence).
452fn classify(block: &RawBlock) -> SegmentKind {
453    if block.fenced_code {
454        return SegmentKind::Code;
455    }
456    let lines: Vec<&str> = block.lines.iter().map(String::as_str).collect();
457    if lines.len() == 1 && looks_like_path(lines[0]) {
458        return SegmentKind::PathRef;
459    }
460    if looks_like_stack_trace(&lines) {
461        return SegmentKind::StackTrace;
462    }
463    // A timestamped log outranks table detection. `ts | LEVEL | msg` shares a
464    // delimiter count with a table, but a clock at the head of every line is by
465    // far the stronger signal — and getting it right routes the block to the
466    // `episode` frame and the log distiller instead of a column summary that
467    // would describe a log as if it were data.
468    if looks_like_timestamped_log(&lines) {
469        return SegmentKind::Log;
470    }
471    if delimited_table_delimiter(&lines).is_some() {
472        return SegmentKind::Table;
473    }
474    if looks_like_log(&lines) {
475        return SegmentKind::Log;
476    }
477    // Whitespace alignment is the weakest tabular signal — the padding after a
478    // fixed-width `INFO ` is indistinguishable from a column break — so it is
479    // offered the block only once the log heuristics have declined it.
480    if aligned_table_delimiter(&lines).is_some() {
481        return SegmentKind::Table;
482    }
483    if looks_like_code(&lines) {
484        return SegmentKind::Code;
485    }
486    SegmentKind::Prose
487}
488
489const PATH_EXTENSIONS: &[&str] = &[
490    "rs", "ts", "tsx", "js", "jsx", "py", "go", "rb", "java", "kt", "c", "h", "cc", "cpp", "hpp",
491    "cs", "md", "toml", "json", "yaml", "yml", "txt", "sh", "sql", "lock", "cfg", "ini",
492];
493
494/// Whether a single line is a bare filesystem path or directory reference.
495fn looks_like_path(line: &str) -> bool {
496    let s = line.trim();
497    if s.is_empty() || s.chars().any(char::is_whitespace) {
498        return false;
499    }
500    // A network URL is not a workspace anchor; a `file://` URI is.
501    if s.starts_with("http://") || s.starts_with("https://") {
502        return false;
503    }
504    if s.starts_with("file://") {
505        return true;
506    }
507    let rooted =
508        s.starts_with("./") || s.starts_with("../") || s.starts_with("~/") || s.starts_with('/');
509    let has_extension = s
510        .rsplit('/')
511        .next()
512        .and_then(|name| name.rsplit_once('.'))
513        .is_some_and(|(_, ext)| PATH_EXTENSIONS.contains(&ext));
514    // `example.com/path` is a URL that lost its scheme, not a directory: a first
515    // segment carrying a dot is a hostname far more often than it is a folder.
516    // A rooted prefix (`./example.com/x`) or a known source extension still
517    // wins, because those are unambiguous even with a dotted first segment.
518    let host_like =
519        !rooted && !has_extension && s.split('/').next().is_some_and(|first| first.contains('.'));
520    if host_like {
521        return false;
522    }
523    // A slash makes it a path; a rooted prefix or a known extension makes a
524    // slashless token (`net.rs`, `src`) a path too.
525    (s.contains('/') && (rooted || has_extension || s.matches('/').count() >= 1))
526        || (rooted && !s.contains(' '))
527        || has_extension
528}
529
530// ---------------------------------------------------------------------------
531// Tabular shapes
532// ---------------------------------------------------------------------------
533
534/// How a tabular block separates its columns.
535///
536/// The variants are ordered by how unambiguous the signal is, and that ordering
537/// is why they are a type rather than a `char`: a `|` or a tab repeated the same
538/// number of times on every line is almost never prose, while a comma or a run
539/// of spaces frequently is — so the last two carry extra guards both in
540/// detection and in `classify`'s precedence.
541#[derive(Debug, Clone, Copy, PartialEq, Eq)]
542enum TableDelimiter {
543    /// `| a | b |` — markdown, psql, and most CLI table output.
544    Pipe,
545    /// Tab-separated: a spreadsheet copy/paste.
546    Tab,
547    /// `a,b,c` — CSV, minus the quoting rules (see [`split_row`]).
548    Comma,
549    /// Columns padded apart with runs of spaces: `ps`, `df`, `kubectl get`.
550    Whitespace,
551}
552
553/// The delimiter of a table whose columns are separated *explicitly*.
554///
555/// `|` and tab need only two rows: prose does not accidentally carry the same
556/// number of pipes on every line. A comma is a different animal — English is
557/// full of them — so CSV additionally wants a third row and cells that read like
558/// values rather than clauses.
559fn delimited_table_delimiter(lines: &[&str]) -> Option<TableDelimiter> {
560    if lines.len() < 2 {
561        return None;
562    }
563    if rows_agree_on_delimiter_count(lines, '|') {
564        return Some(TableDelimiter::Pipe);
565    }
566    // A tab at the *head* of a line is indentation, not an empty first column —
567    // without this, a tab-indented stack trace or code block reads as a
568    // two-column TSV purely because every line starts with one.
569    let indented = lines.iter().filter(|l| l.starts_with('\t')).count();
570    if rows_agree_on_delimiter_count(lines, '\t') && indented * 10 < lines.len() * 7 {
571        return Some(TableDelimiter::Tab);
572    }
573    if lines.len() >= MIN_AMBIGUOUS_TABLE_ROWS
574        && rows_agree_on_delimiter_count(lines, ',')
575        && rows_read_as_values(lines, TableDelimiter::Comma)
576    {
577        return Some(TableDelimiter::Comma);
578    }
579    None
580}
581
582/// The delimiter of a table whose columns are padded apart with spaces.
583///
584/// Kept separate from [`delimited_table_delimiter`] because `classify` needs to
585/// try it *after* the log heuristics — the space padding of a fixed-width level
586/// column is exactly this shape.
587fn aligned_table_delimiter(lines: &[&str]) -> Option<TableDelimiter> {
588    if lines.len() < MIN_AMBIGUOUS_TABLE_ROWS {
589        return None;
590    }
591    let counts: Vec<usize> = lines
592        .iter()
593        .map(|l| split_row(l, TableDelimiter::Whitespace).len())
594        .collect();
595    let common = most_common(&counts)?;
596    // One column is not a table, it is a list of lines.
597    if common < 2 || !majority_agrees(&counts, common) {
598        return None;
599    }
600    rows_read_as_values(lines, TableDelimiter::Whitespace).then_some(TableDelimiter::Whitespace)
601}
602
603/// The delimiter this block parses with, whichever family it belongs to.
604fn table_delimiter(lines: &[&str]) -> Option<TableDelimiter> {
605    delimited_table_delimiter(lines).or_else(|| aligned_table_delimiter(lines))
606}
607
608/// Whether ≥70 % of rows carry the same non-zero count of `delimiter`. A shared
609/// count is what distinguishes a table from lines that merely happen to contain
610/// the character.
611fn rows_agree_on_delimiter_count(lines: &[&str], delimiter: char) -> bool {
612    let counts: Vec<usize> = lines.iter().map(|l| l.matches(delimiter).count()).collect();
613    most_common(&counts).is_some_and(|common| common >= 1 && majority_agrees(&counts, common))
614}
615
616/// Whether at least 70 % of `counts` equal `common`.
617fn majority_agrees(counts: &[usize], common: usize) -> bool {
618    let agree = counts.iter().filter(|&&c| c == common).count();
619    agree * 10 >= counts.len() * 7
620}
621
622/// Whether every cell reads like a *value* rather than a clause.
623///
624/// This is the guard that keeps a comma-spliced paragraph, or prose that happens
625/// to be padded, out of the table distiller: real cells are short, sentences are
626/// not. It costs a genuine CSV whose last column is a free-text message — that
627/// block becomes a verbatim `doc` frame instead, which loses the column summary
628/// and none of the evidence.
629fn rows_read_as_values(lines: &[&str], delimiter: TableDelimiter) -> bool {
630    lines.iter().all(|line| {
631        split_row(line, delimiter)
632            .iter()
633            .all(|cell| cell.split_whitespace().count() <= MAX_CELL_WORDS)
634    })
635}
636
637/// Split one row into trimmed cells.
638///
639/// CSV quoting is deliberately not implemented: a quoted field containing a
640/// comma splits into two cells here. The compact rendering is a *sample* whose
641/// job is to convey shape and types, and the exact bytes stay content-addressed
642/// one `[full]` re-query away — so a mis-split costs fidelity in the preview and
643/// nothing at all in the evidence.
644fn split_row(line: &str, delimiter: TableDelimiter) -> Vec<String> {
645    match delimiter {
646        TableDelimiter::Pipe => {
647            let mut cells: Vec<String> = line.split('|').map(|c| c.trim().to_string()).collect();
648            // Pipe tables usually have leading/trailing delimiters → empty edges.
649            if cells.first().is_some_and(String::is_empty) {
650                cells.remove(0);
651            }
652            if cells.last().is_some_and(String::is_empty) {
653                cells.pop();
654            }
655            cells
656        }
657        TableDelimiter::Tab => line.split('\t').map(|c| c.trim().to_string()).collect(),
658        TableDelimiter::Comma => line.split(',').map(|c| c.trim().to_string()).collect(),
659        // Two spaces is the narrowest gap a column ever gets; a single space is
660        // just a space. Empty fragments come from wider padding, not from empty
661        // cells, so they are dropped rather than counted as columns.
662        TableDelimiter::Whitespace => line
663            .split("  ")
664            .map(str::trim)
665            .filter(|c| !c.is_empty())
666            .map(str::to_string)
667            .collect(),
668    }
669}
670
671const LOG_LEVELS: &[&str] = &[
672    "ERROR", "ERR", "WARN", "WARNING", "INFO", "DEBUG", "TRACE", "FATAL", "CRITICAL", "CRIT",
673    "PANIC", "PANICKED", "SEVERE", "NOTICE",
674];
675const ALERT_LEVELS: &[&str] = &[
676    "ERROR", "ERR", "WARN", "WARNING", "FATAL", "CRITICAL", "CRIT", "PANIC", "PANICKED", "SEVERE",
677];
678/// Fragments that mark a line as belonging to a trace *within* a log. A whole
679/// trace is its own [`SegmentKind::StackTrace`]; these keep the log heuristic
680/// from rejecting the traceback a log happens to contain.
681const STACK_MARKERS: &[&str] = &[
682    "at ",
683    "File \"",
684    "Traceback",
685    "panicked at",
686    "-->",
687    "Caused by",
688    "thread '",
689];
690
691/// Whether at least half of the non-empty lines look like log or trace lines.
692fn looks_like_log(lines: &[&str]) -> bool {
693    let non_empty: Vec<&str> = non_empty_lines(lines);
694    if non_empty.is_empty() {
695        return false;
696    }
697    let matched = non_empty.iter().filter(|l| is_log_line(l)).count();
698    matched * 2 >= non_empty.len()
699}
700
701/// Whether the block is a log that *stamps its lines*: at least half open with a
702/// recognizable timestamp, and some line carries a level token.
703///
704/// This is the strong log signal, and it is what lets `classify` put logs ahead
705/// of table detection without a genuine table falling through — a markdown row
706/// or a CSV row opens with its delimiter or its first cell, not with a clock.
707fn looks_like_timestamped_log(lines: &[&str]) -> bool {
708    let non_empty: Vec<&str> = non_empty_lines(lines);
709    if non_empty.is_empty() {
710        return false;
711    }
712    let stamped = non_empty
713        .iter()
714        .filter(|l| leading_timestamp(l).is_some())
715        .count();
716    stamped * 2 >= non_empty.len() && non_empty.iter().any(|l| has_level_token(l, LOG_LEVELS))
717}
718
719fn non_empty_lines<'a>(lines: &[&'a str]) -> Vec<&'a str> {
720    lines
721        .iter()
722        .copied()
723        .filter(|l| !l.trim().is_empty())
724        .collect()
725}
726
727fn is_log_line(line: &str) -> bool {
728    let t = line.trim_start();
729    if t.is_empty() {
730        return false;
731    }
732    if STACK_MARKERS.iter().any(|m| t.starts_with(m)) {
733        return true;
734    }
735    if has_level_token(t, LOG_LEVELS) {
736        return true;
737    }
738    if leading_timestamp(t).is_some() {
739        return true;
740    }
741    let first = t.split_whitespace().next().unwrap_or("");
742    if first.starts_with('[') {
743        return true;
744    }
745    // A leading timestamp-ish token the parser above declined to recognize:
746    // begins with a digit and carries a `:` or `-` (a clock or a date).
747    first.chars().next().is_some_and(|c| c.is_ascii_digit())
748        && (first.contains(':') || first.contains('-'))
749}
750
751// ---------------------------------------------------------------------------
752// Stack traces
753// ---------------------------------------------------------------------------
754
755/// Whether the block *is* a stack trace, rather than merely containing one.
756///
757/// Three conditions together, because each alone misfires: a header naming the
758/// failure, at least two frame lines, and frames making up at least a quarter of
759/// the block. The last one is what keeps a 300-line log with one embedded
760/// traceback classified as a log — the trace is a small part of what the user
761/// pasted, and the salience distiller is the right one for the whole.
762fn looks_like_stack_trace(lines: &[&str]) -> bool {
763    let non_empty = non_empty_lines(lines).len();
764    if non_empty == 0 {
765        return false;
766    }
767    let frames = lines.iter().filter(|l| is_stack_frame_line(l)).count();
768    frames >= 2 && frames * 4 >= non_empty && lines.iter().any(|l| is_exception_header(l))
769}
770
771/// Whether a line names the failure a trace is about.
772///
773/// Both dominant conventions are covered, and they disagree about *where* the
774/// line goes: Java, JavaScript, and Rust put it first; Python puts it last,
775/// after the frames.
776fn is_exception_header(line: &str) -> bool {
777    // A trace pasted out of a log wears the log's ceremony: `2026-07-20
778    // 18:00:01 ERROR java.lang.IllegalStateException: …`. Stripping it first is
779    // what keeps the clock's own colons from being read as the exception's.
780    let t = strip_log_prefix(line.trim());
781    if t.starts_with("Traceback (most recent call last)")
782        || t.starts_with("thread '")
783        || t.contains("panicked at")
784        || t.starts_with("Caused by")
785        || (t.starts_with("goroutine ") && t.contains("[running]"))
786    {
787        return true;
788    }
789    // `java.lang.NullPointerException: …`, `ValueError: boom`, `Uncaught
790    // TypeError: …`. The type has to be one or two tokens: a prose sentence
791    // ("the config had an Error: see below") carries more words before the
792    // colon, and reading it as a header would drag whole paragraphs into the
793    // trace distiller.
794    let head = t.split_once(':').map_or(t, |(before, _)| before);
795    let words: Vec<&str> = head.split_whitespace().collect();
796    if words.is_empty() || words.len() > 2 {
797        return false;
798    }
799    let name = words[words.len() - 1];
800    // Trailing segment only: `java.lang.IllegalStateException` is qualified.
801    let name = name.rsplit('.').next().unwrap_or(name);
802    name.ends_with("Error") || name.ends_with("Exception")
803}
804
805/// Strip a log line's ceremonial prefix — a timestamp, a level, a bracketed
806/// thread or logger name — leaving the message.
807///
808/// Bounded to a few tokens so it can never eat the message itself: the prefix of
809/// a real log line is a timestamp (at most two tokens), a level, and maybe one
810/// bracketed name.
811fn strip_log_prefix(line: &str) -> &str {
812    let mut rest = line.trim_start();
813    for _ in 0..4 {
814        let ceremonial = leading_timestamp(rest).is_some()
815            || rest.starts_with('[')
816            || rest
817                .split_whitespace()
818                .next()
819                .is_some_and(|token| has_level_token(token, LOG_LEVELS));
820        if !ceremonial {
821            break;
822        }
823        let Some((_, tail)) = rest.split_once(char::is_whitespace) else {
824            break;
825        };
826        rest = tail.trim_start();
827    }
828    rest
829}
830
831/// Whether a line names one frame of a stack.
832fn is_stack_frame_line(line: &str) -> bool {
833    let t = line.trim_start();
834    // Java / JavaScript / .NET, and the source lines of a Rust backtrace.
835    if t.starts_with("at ") {
836        return true;
837    }
838    // Python.
839    if t.starts_with("File \"") {
840        return true;
841    }
842    // Ruby: `from app.rb:3:in 'foo'`.
843    if t.starts_with("from ") && t.contains(':') {
844        return true;
845    }
846    // Go: a tab-indented source location under the function that called it.
847    if line.starts_with('\t') && t.contains(".go:") {
848        return true;
849    }
850    // Rust's numbered backtrace frames: `  12: core::panicking::panic_fmt`.
851    let digits = t.bytes().take_while(u8::is_ascii_digit).count();
852    digits > 0 && t[digits..].starts_with(": ")
853}
854
855fn is_alert_line(line: &str) -> bool {
856    has_level_token(line.trim_start(), ALERT_LEVELS)
857}
858
859/// Whether any whole word in `s` (uppercased) is in `set`. Whole-word matching
860/// keeps `"information"` from matching `INFO`.
861fn has_level_token(s: &str, set: &[&str]) -> bool {
862    s.split(|c: char| !c.is_ascii_alphanumeric())
863        .filter(|w| !w.is_empty())
864        .any(|w| set.contains(&w.to_ascii_uppercase().as_str()))
865}
866
867/// A conservative unfenced-code check: ≥3 lines, most of them structurally
868/// code-shaped. Misclassification only routes a `snippet` to `doc` or back;
869/// both are full evidence frames, so the bar is set to avoid eating prose.
870fn looks_like_code(lines: &[&str]) -> bool {
871    if lines.len() < 3 {
872        return false;
873    }
874    const PREFIXES: &[&str] = &[
875        "fn ",
876        "def ",
877        "class ",
878        "import ",
879        "const ",
880        "let ",
881        "var ",
882        "pub ",
883        "function ",
884        "#include",
885        "package ",
886        "func ",
887        "return ",
888        "if ",
889        "for ",
890        "while ",
891        "@",
892    ];
893    let codey = lines
894        .iter()
895        .filter(|l| {
896            let t = l.trim();
897            let te = l.trim_end();
898            te.ends_with(';')
899                || te.ends_with('{')
900                || te.ends_with('}')
901                || te.ends_with("=>")
902                || te.ends_with("):")
903                || PREFIXES.iter().any(|p| t.starts_with(p))
904        })
905        .count();
906    codey * 2 >= lines.len()
907}
908
909fn most_common(values: &[usize]) -> Option<usize> {
910    let mut best: Option<(usize, usize)> = None; // (value, count)
911    for &v in values {
912        let count = values.iter().filter(|&&x| x == v).count();
913        match best {
914            Some((_, bc)) if bc >= count => {}
915            _ => best = Some((v, count)),
916        }
917    }
918    best.map(|(v, _)| v)
919}
920
921// ---------------------------------------------------------------------------
922// Distillation
923// ---------------------------------------------------------------------------
924
925/// Pick the singular or plural noun for `count`. A distilled rendering that
926/// says "1 lines elided" reads as a bug in the distiller, which is not a thought
927/// to put in a reader's head about the evidence they are being shown.
928fn plural<'a>(count: usize, one: &'a str, many: &'a str) -> &'a str {
929    if count == 1 { one } else { many }
930}
931
932/// A run of identical consecutive log lines, collapsed to one representative.
933///
934/// A retry loop that logs the same line four hundred times should cost one line
935/// plus a count — not four hundred lines, and above all not four hundred *slots*
936/// in the salience budget, crowding out the one line that differs.
937struct LogRun<'a> {
938    text: &'a str,
939    repeats: usize,
940}
941
942fn collapse_runs<'a>(lines: &[&'a str]) -> Vec<LogRun<'a>> {
943    let mut runs: Vec<LogRun<'a>> = Vec::new();
944    for &line in lines {
945        match runs.last_mut() {
946            Some(run) if run.text == line => run.repeats += 1,
947            _ => runs.push(LogRun {
948                text: line,
949                repeats: 1,
950            }),
951        }
952    }
953    runs
954}
955
956/// The distilled inline rendering of an oversized log: a header plus the alert
957/// lines with context, or head/tail when there are no alerts, gaps elided.
958///
959/// Runs of identical lines collapse *before* selection, so both the elision
960/// counts and the header keep quoting source lines even though the selection
961/// works over distinct ones.
962fn distill_log(full: &str) -> (String, Option<String>, Option<String>) {
963    let lines: Vec<&str> = full.lines().collect();
964    let source_lines = lines.len();
965    if source_lines == 0 {
966        return (String::new(), None, None);
967    }
968    let runs = collapse_runs(&lines);
969    let total = runs.len();
970    let alerts: Vec<usize> = (0..total)
971        .filter(|&i| is_alert_line(runs[i].text))
972        .collect();
973
974    let mut keep: BTreeSet<usize> = BTreeSet::new();
975    keep.insert(0);
976    keep.insert(total - 1);
977    if alerts.is_empty() {
978        for i in 0..LOG_HEAD.min(total) {
979            keep.insert(i);
980        }
981        for i in total.saturating_sub(LOG_TAIL)..total {
982            keep.insert(i);
983        }
984    } else {
985        for &a in &alerts {
986            let lo = a.saturating_sub(LOG_CONTEXT);
987            let hi = (a + LOG_CONTEXT).min(total - 1);
988            for i in lo..=hi {
989                keep.insert(i);
990            }
991        }
992    }
993
994    let mut out = String::new();
995    let alert_note = if alerts.is_empty() {
996        String::new()
997    } else {
998        // Source lines, not runs: "3 error line(s)" that were the same line
999        // three times is still three lines of the log the user pasted.
1000        let alert_lines: usize = alerts.iter().map(|&i| runs[i].repeats).sum();
1001        format!(", {alert_lines} error/warn line(s)")
1002    };
1003    out.push_str(&format!("[{source_lines}-line log{alert_note}]\n"));
1004
1005    let mut prev: Option<usize> = None;
1006    for &i in &keep {
1007        if let Some(p) = prev
1008            && i > p + 1
1009        {
1010            let elided: usize = runs[p + 1..i].iter().map(|r| r.repeats).sum();
1011            out.push_str(&format!(
1012                "… ({elided} {} elided) …\n",
1013                plural(elided, "line", "lines")
1014            ));
1015        }
1016        out.push_str(runs[i].text);
1017        out.push('\n');
1018        if runs[i].repeats > 1 {
1019            out.push_str(&format!("… (×{})\n", runs[i].repeats));
1020        }
1021        prev = Some(i);
1022    }
1023
1024    let (valid_from, valid_to) = temporal_window(&lines);
1025    (out.trim_end().to_string(), valid_from, valid_to)
1026}
1027
1028/// The distilled inline rendering of a stack trace: every non-frame line — the
1029/// exception, its message, the `Caused by` chain — plus the top [`STACK_FRAMES`]
1030/// frames, then a count of the frames dropped.
1031///
1032/// Non-frame lines are kept at *both* ends because the two dominant conventions
1033/// disagree about where the exception goes (Java first, Python last), and losing
1034/// either end would lose the one line that says what went wrong.
1035fn distill_stack_trace(full: &str) -> String {
1036    let lines: Vec<&str> = full.lines().collect();
1037    let frames: BTreeSet<usize> = (0..lines.len())
1038        .filter(|&i| is_stack_frame_line(lines[i]))
1039        .collect();
1040    let (Some(&first), Some(&last)) = (frames.first(), frames.last()) else {
1041        return full.to_string();
1042    };
1043    let kept: BTreeSet<usize> = frames.iter().take(STACK_FRAMES).copied().collect();
1044    let elided = frames.len() - kept.len();
1045
1046    let mut out = String::new();
1047    let mut previous_kept = true;
1048    let mut noted = false;
1049    for (i, line) in lines.iter().enumerate() {
1050        let keep = if i < first || i > last {
1051            // The header block above the stack and the trailing block below it.
1052            true
1053        } else if frames.contains(&i) {
1054            kept.contains(&i)
1055        } else {
1056            // A continuation of the frame above it — Python's source line, a
1057            // Rust `at …` path — travels with the frame it belongs to.
1058            previous_kept
1059        };
1060        if keep {
1061            out.push_str(line);
1062            out.push('\n');
1063        } else if !noted && elided > 0 {
1064            out.push_str(&format!(
1065                "… ({elided} more {})\n",
1066                plural(elided, "frame", "frames")
1067            ));
1068            noted = true;
1069        }
1070        previous_kept = keep;
1071    }
1072    out.trim_end().to_string()
1073}
1074
1075/// The temporal window a capture's first and last lines imply, both ends in the
1076/// §F4 profile or absent.
1077///
1078/// The ends are ordered rather than assigned positionally: a
1079/// reverse-chronological capture — `journalctl -r`, and most log UIs — would
1080/// otherwise yield `valid_from > valid_to`, a window no reader can use.
1081fn temporal_window(lines: &[&str]) -> (Option<String>, Option<String>) {
1082    let first = leading_instant(lines.first().copied());
1083    let last = leading_instant(lines.last().copied());
1084    match (&first, &last) {
1085        (Some(f), Some(t)) if f > t => (last, first),
1086        _ => (first, last),
1087    }
1088}
1089
1090/// The §F4 instant a line opens with, if it opens with one at all — the guarded
1091/// feed for a frame's `valid_from` / `valid_to`.
1092fn leading_instant(line: Option<&str>) -> Option<String> {
1093    leading_timestamp(line?)?.normalized
1094}
1095
1096/// A timestamp recognized at the head of a line.
1097struct LeadingTimestamp {
1098    /// The §F4 spelling of the instant — `YYYY-MM-DDTHH:MM:SS(.f+)?Z` — when one
1099    /// can be derived *without inventing information*.
1100    ///
1101    /// `None` for shapes that are unmistakably timestamps but name no day
1102    /// (syslog's `Jul 20 18:00:01`, a bare `18:00:01` clock, a date with no
1103    /// clock): they still identify the line as a log, but F4 has no spelling for
1104    /// a partial instant, and filling in the missing year or hour would put a
1105    /// fabricated instant into a frame's temporal bound.
1106    normalized: Option<String>,
1107}
1108
1109/// Recognize a timestamp at the start of `line`, normalizing to §F4 where the
1110/// shape allows.
1111///
1112/// This is the one place the module reads a clock, and it is **guarded at the
1113/// exit**: every candidate is run through [`is_protocol_timestamp`] before it is
1114/// returned, so an out-of-range date (`2026-02-30`) or a shape this parser
1115/// mis-assembles yields no window rather than an invalid F4 string (§F4).
1116fn leading_timestamp(line: &str) -> Option<LeadingTimestamp> {
1117    let t = line.trim_start();
1118    // A bracketed timestamp is the same timestamp wearing punctuation:
1119    // `[2026-07-20 18:00:01] INFO …`. Only the bracket's contents are offered to
1120    // the parser, so a `[worker-3]` prefix cannot bleed into the clock.
1121    let candidate = match t.strip_prefix('[') {
1122        Some(rest) => rest.split_once(']')?.0,
1123        None => t,
1124    };
1125    let candidate = candidate.trim_start();
1126    if let Some(dated) = parse_dated_timestamp(candidate) {
1127        return Some(dated);
1128    }
1129    // Syslog (RFC 3164), `Jul 20 18:00:01`, and a bare clock, `18:00:01.123`:
1130    // recognized so the line still reads as a log, never normalized.
1131    if is_syslog_timestamp(candidate) || parse_clock(candidate).is_some() {
1132        return Some(LeadingTimestamp { normalized: None });
1133    }
1134    None
1135}
1136
1137/// `YYYY-MM-DD` or `YYYY/MM/DD`, then `T` or a space, then a clock, then an
1138/// optional zone. The only family that can produce an F4 string, because it is
1139/// the only one that names a day.
1140fn parse_dated_timestamp(s: &str) -> Option<LeadingTimestamp> {
1141    let b = s.as_bytes();
1142    if b.len() < 10 {
1143        return None;
1144    }
1145    let separator = b[4];
1146    if (separator != b'-' && separator != b'/') || b[7] != separator {
1147        return None;
1148    }
1149    if !b[..4].iter().all(u8::is_ascii_digit)
1150        || !b[5..7].iter().all(u8::is_ascii_digit)
1151        || !b[8..10].iter().all(u8::is_ascii_digit)
1152    {
1153        return None;
1154    }
1155    let date = format!("{}-{}-{}", &s[..4], &s[5..7], &s[8..10]);
1156    // Everything below is a *recognized* timestamp; the question from here is
1157    // only whether it can be spelled in F4 without guessing.
1158    let unnormalized = Some(LeadingTimestamp { normalized: None });
1159
1160    // A bare date carries no clock, and midnight would be a guess.
1161    let Some(after_separator) = s[10..].strip_prefix(['T', 't', ' ']) else {
1162        return unnormalized;
1163    };
1164    let Some((clock, tail)) = parse_clock(after_separator) else {
1165        return unnormalized;
1166    };
1167    if !zone_is_utc(tail) {
1168        return unnormalized;
1169    }
1170    let candidate = format!("{date}T{clock}Z");
1171    if is_protocol_timestamp(&candidate) {
1172        return Some(LeadingTimestamp {
1173            normalized: Some(candidate),
1174        });
1175    }
1176    unnormalized
1177}
1178
1179/// `HH:MM:SS` with an optional fraction, returned in F4 spelling along with
1180/// whatever followed it.
1181///
1182/// A comma decimal separator (`18:00:01,123` — logback, .NET, and most of
1183/// Europe) normalizes to a point, which is the only spelling F4 accepts.
1184fn parse_clock(s: &str) -> Option<(String, &str)> {
1185    let b = s.as_bytes();
1186    if b.len() < 8 || b[2] != b':' || b[5] != b':' {
1187        return None;
1188    }
1189    if !(b[..2].iter().all(u8::is_ascii_digit)
1190        && b[3..5].iter().all(u8::is_ascii_digit)
1191        && b[6..8].iter().all(u8::is_ascii_digit))
1192    {
1193        return None;
1194    }
1195    let mut clock = s[..8].to_string();
1196    let mut rest = &s[8..];
1197    if let Some(fraction) = rest.strip_prefix(['.', ',']) {
1198        let digits = fraction.bytes().take_while(u8::is_ascii_digit).count();
1199        if digits > 0 {
1200            clock.push('.');
1201            clock.push_str(&fraction[..digits]);
1202            rest = &fraction[digits..];
1203        }
1204    }
1205    Some((clock, rest))
1206}
1207
1208/// Whether what follows a clock denotes UTC — the only zone this module will
1209/// normalize.
1210///
1211/// Two different decisions live here, and the asymmetry is the point. A
1212/// **zone-less** timestamp is *read* as UTC: that is an assumption, and the
1213/// honest one, because every line in a paste shares one clock, so the window's
1214/// duration and the ordering of its ends stay correct even when the absolute
1215/// offset does not — whereas refusing it drops the bi-temporal bound for the
1216/// overwhelmingly common case of a log with no zone at all. A **numeric offset**
1217/// is refused rather than assumed: converting `+02:00` to UTC needs date
1218/// arithmetic (month ends, leap years) that this module has no business
1219/// hand-rolling, and a silently wrong instant is worse than no window.
1220fn zone_is_utc(tail: &str) -> bool {
1221    let t = tail.trim_start();
1222    if t.is_empty() {
1223        return true;
1224    }
1225    let ends_token = |rest: &str| rest.is_empty() || rest.starts_with(char::is_whitespace);
1226    if let Some(rest) = t.strip_prefix(['Z', 'z']) {
1227        return ends_token(rest);
1228    }
1229    for utc in ["+00:00", "-00:00", "+0000", "-0000"] {
1230        if let Some(rest) = t.strip_prefix(utc) {
1231            return ends_token(rest);
1232        }
1233    }
1234    // `+02:00`, `-0500` — the offsets we decline to convert.
1235    if t.starts_with(['+', '-']) {
1236        return false;
1237    }
1238    if let Some(rest) = t.strip_prefix("UTC").or_else(|| t.strip_prefix("GMT")) {
1239        return ends_token(rest);
1240    }
1241    // Anything else is the rest of the log line, not a zone.
1242    true
1243}
1244
1245const MONTH_ABBREVIATIONS: &[&str] = &[
1246    "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
1247];
1248
1249/// RFC 3164 syslog: `Jul 20 18:00:01` (the day is space-padded when single
1250/// digit, hence the whitespace split rather than fixed offsets).
1251fn is_syslog_timestamp(s: &str) -> bool {
1252    let mut tokens = s.split_whitespace();
1253    let Some(month) = tokens.next() else {
1254        return false;
1255    };
1256    if !MONTH_ABBREVIATIONS.contains(&month.to_ascii_lowercase().as_str()) {
1257        return false;
1258    }
1259    let Some(day) = tokens.next() else {
1260        return false;
1261    };
1262    if day.is_empty() || day.len() > 2 || !day.bytes().all(|b| b.is_ascii_digit()) {
1263        return false;
1264    }
1265    tokens
1266        .next()
1267        .is_some_and(|clock| parse_clock(clock).is_some())
1268}
1269
1270/// The distilled inline rendering of a table: shape, inferred column types, and
1271/// a small sample of rows.
1272fn distill_table(full: &str) -> String {
1273    let lines: Vec<&str> = full.lines().filter(|l| !l.trim().is_empty()).collect();
1274    // The detector already agreed this block is tabular; the fallback covers a
1275    // block that reached the distiller by another route (a paste truncated
1276    // mid-row, say), where a slightly wrong sample beats losing the block.
1277    let delimiter = table_delimiter(&lines).unwrap_or(if lines.iter().any(|l| l.contains('|')) {
1278        TableDelimiter::Pipe
1279    } else {
1280        TableDelimiter::Tab
1281    });
1282
1283    let mut rows: Vec<Vec<String>> = lines.iter().map(|l| split_row(l, delimiter)).collect();
1284    // Drop a markdown separator row (`---|:--:|---`).
1285    rows.retain(|r| !r.iter().all(|c| is_separator_cell(c)));
1286    if rows.is_empty() {
1287        return full.to_string();
1288    }
1289
1290    let header = rows.remove(0);
1291    let cols = header.len();
1292    let data = rows;
1293
1294    let mut column_summaries: Vec<String> = Vec::with_capacity(cols);
1295    for (idx, name) in header.iter().enumerate() {
1296        // Every data row contributes, *including* the ones with nothing in this
1297        // column — a short row is a hole, and holes are what make a column
1298        // nullable.
1299        let cells: Vec<&str> = data
1300            .iter()
1301            .map(|r| r.get(idx).map_or("", String::as_str))
1302            .collect();
1303        column_summaries.push(format!("{name} ({})", infer_column_type(&cells)));
1304    }
1305
1306    let mut out = String::new();
1307    out.push_str(&format!("[{} rows × {cols} columns]\n", data.len()));
1308    out.push_str(&format!("columns: {}\n", column_summaries.join(", ")));
1309    out.push_str("sample:\n");
1310    out.push_str(&header.join(" | "));
1311    out.push('\n');
1312    for row in data.iter().take(TABLE_SAMPLE) {
1313        out.push_str(&row.join(" | "));
1314        out.push('\n');
1315    }
1316    if data.len() > TABLE_SAMPLE {
1317        out.push_str(&format!("… ({} more rows)", data.len() - TABLE_SAMPLE));
1318    }
1319    out.trim_end().to_string()
1320}
1321
1322fn is_separator_cell(cell: &str) -> bool {
1323    let c = cell.trim();
1324    !c.is_empty() && c.chars().all(|ch| ch == '-' || ch == ':')
1325}
1326
1327/// A column's inferred type, for the distilled header line.
1328///
1329/// Two properties beyond the scalar families earn the handful of bytes they
1330/// cost, because each changes how the sample below them should be read:
1331/// `currency` and `percent` are numbers whose *unit lives in the cell* (`12%` is
1332/// neither the integer 12 nor free text), and a trailing `?` marks a column with
1333/// holes — five sampled rows can easily all be populated while the other four
1334/// thousand are not, and "this column is sometimes missing" is exactly the kind
1335/// of thing a model should not have to infer from a five-row window.
1336fn infer_column_type(cells: &[&str]) -> String {
1337    let values: Vec<&str> = cells.iter().copied().filter(|c| !is_null_cell(c)).collect();
1338    let nullable = values.len() < cells.len();
1339    if values.is_empty() {
1340        return "empty".to_string();
1341    }
1342    let all = |predicate: fn(&str) -> bool| values.iter().all(|s| predicate(s));
1343    let base = if all(is_percent) {
1344        "percent"
1345    } else if all(is_currency) {
1346        "currency"
1347    } else if all(|s| number_shape(s) == Some(NumberShape::Integer)) {
1348        "int"
1349    } else if all(|s| number_shape(s).is_some()) {
1350        "float"
1351    } else if all(|s| matches!(s.to_ascii_lowercase().as_str(), "true" | "false")) {
1352        "bool"
1353    } else if all(looks_like_datetime) {
1354        "timestamp"
1355    } else {
1356        "text"
1357    };
1358    if nullable {
1359        format!("{base}?")
1360    } else {
1361        base.to_string()
1362    }
1363}
1364
1365/// Cell spellings that mean "no value here".
1366///
1367/// Deliberately short: an over-eager null list erases legitimate values (`NA`
1368/// really is North America in some tables). These are the spellings common
1369/// enough across CSV exports, database dumps, and CLI output that missing them
1370/// would mislabel most real columns.
1371fn is_null_cell(cell: &str) -> bool {
1372    let c = cell.trim();
1373    c.is_empty()
1374        || matches!(
1375            c.to_ascii_lowercase().as_str(),
1376            "null" | "nil" | "none" | "n/a" | "na" | "nan" | "-" | "—"
1377        )
1378}
1379
1380/// Whether a number carries a fractional part — the whole difference between an
1381/// `int` column and a `float` one.
1382#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1383enum NumberShape {
1384    Integer,
1385    Fractional,
1386}
1387
1388/// Parse a decimal number, tolerating `1,234,567` thousands grouping (which is
1389/// presentation, not a different value).
1390fn number_shape(s: &str) -> Option<NumberShape> {
1391    let body = s.trim();
1392    let body = body.strip_prefix(['-', '+']).unwrap_or(body);
1393    let (integer, fraction) = match body.split_once('.') {
1394        Some((integer, fraction)) => (integer, Some(fraction)),
1395        None => (body, None),
1396    };
1397    if !is_grouped_digits(integer) {
1398        return None;
1399    }
1400    match fraction {
1401        None => Some(NumberShape::Integer),
1402        Some(f) if !f.is_empty() && f.bytes().all(|b| b.is_ascii_digit()) => {
1403            Some(NumberShape::Fractional)
1404        }
1405        Some(_) => None,
1406    }
1407}
1408
1409/// Digits, optionally in `1,234,567` thousands groups. Insisting the groups be
1410/// exactly three digits is what keeps `1,2,3` — three CSV cells that lost their
1411/// delimiter — from reading as one number.
1412fn is_grouped_digits(s: &str) -> bool {
1413    if s.is_empty() {
1414        return false;
1415    }
1416    if !s.contains(',') {
1417        return s.bytes().all(|b| b.is_ascii_digit());
1418    }
1419    let mut groups = s.split(',');
1420    let head = groups.next().unwrap_or("");
1421    if head.is_empty() || head.len() > 3 || !head.bytes().all(|b| b.is_ascii_digit()) {
1422        return false;
1423    }
1424    groups.all(|g| g.len() == 3 && g.bytes().all(|b| b.is_ascii_digit()))
1425}
1426
1427/// `42%`, `-3.5 %`.
1428fn is_percent(s: &str) -> bool {
1429    s.trim()
1430        .strip_suffix('%')
1431        .is_some_and(|number| number_shape(number).is_some())
1432}
1433
1434const CURRENCY_SYMBOLS: &[char] = &['$', '€', '£', '¥', '₹', '₽'];
1435
1436/// `$1,234.56`, `-€10`, `1234.56 USD`, and the accountant's parenthesized
1437/// negative `(1,200.00)` — provided a symbol or an ISO code is present, since
1438/// the bare parenthesized form is indistinguishable from a footnote.
1439fn is_currency(s: &str) -> bool {
1440    let t = s.trim();
1441    let t = t
1442        .strip_prefix('(')
1443        .and_then(|inner| inner.strip_suffix(')'))
1444        .unwrap_or(t);
1445    let body = t.strip_prefix(['-', '+']).unwrap_or(t);
1446    if let Some(rest) = body.strip_prefix(CURRENCY_SYMBOLS) {
1447        return number_shape(rest.trim_start()).is_some();
1448    }
1449    if let Some(rest) = body.strip_suffix(CURRENCY_SYMBOLS) {
1450        return number_shape(rest.trim_end()).is_some();
1451    }
1452    body.rsplit_once(' ').is_some_and(|(number, code)| {
1453        code.len() == 3
1454            && code.bytes().all(|b| b.is_ascii_uppercase())
1455            && number_shape(number).is_some()
1456    })
1457}
1458
1459fn looks_like_datetime(s: &str) -> bool {
1460    if is_protocol_timestamp(s) {
1461        return true;
1462    }
1463    // Loose `YYYY-MM-DD`-ish: starts with four digits then a dash.
1464    let b = s.as_bytes();
1465    b.len() >= 8 && b[..4].iter().all(u8::is_ascii_digit) && b.get(4) == Some(&b'-')
1466}
1467
1468/// The distilled inline rendering of an oversized code block: head and tail with
1469/// the middle elided.
1470fn distill_code(full: &str) -> String {
1471    let lines: Vec<&str> = full.lines().collect();
1472    let total = lines.len();
1473    if total <= CODE_HEAD + CODE_TAIL {
1474        return full.to_string();
1475    }
1476    let mut out = String::new();
1477    for line in &lines[..CODE_HEAD] {
1478        out.push_str(line);
1479        out.push('\n');
1480    }
1481    out.push_str(&format!(
1482        "… ({} lines elided) …\n",
1483        total - CODE_HEAD - CODE_TAIL
1484    ));
1485    for line in &lines[total - CODE_TAIL..] {
1486        out.push_str(line);
1487        out.push('\n');
1488    }
1489    out.trim_end().to_string()
1490}
1491
1492// ---------------------------------------------------------------------------
1493// Artifacts
1494// ---------------------------------------------------------------------------
1495
1496/// One content-addressed piece of pasted evidence. Immutable: its bytes and
1497/// therefore its hashes never change, which is what makes `verify` exact.
1498struct Artifact {
1499    id: String,
1500    kind: FrameKind,
1501    title: String,
1502    citation_label: String,
1503    score: f32,
1504    /// The exact source bytes, stored so a `full` re-query rehydrates losslessly.
1505    full_content: String,
1506    /// `sha256:<hex>` over `full_content` — the store key and the id seed.
1507    address_hash: String,
1508    /// The inline rendering the model sees by default. Equal to `full_content`
1509    /// when the artifact was too small to be worth compacting.
1510    inline_content: String,
1511    transform: Transform,
1512    fidelity: ContentFidelity,
1513    /// Whether `inline_content` is a genuine distillation (vs. verbatim).
1514    compacted: bool,
1515    valid_from: Option<String>,
1516    valid_to: Option<String>,
1517    summary: String,
1518}
1519
1520impl Artifact {
1521    fn build(kind: SegmentKind, full_content: String) -> Self {
1522        let frame_kind = kind
1523            .frame_kind()
1524            .expect("PathRef is routed to anchors before build");
1525        let address_hash = sha256_digest(&full_content);
1526        let id = format!("frm_{}", short_hash(&address_hash));
1527
1528        // Distill, then decide whether the distillation actually pays.
1529        let (distilled, verbatim_transform, distilled_transform, distilled_fidelity, vf, vt) =
1530            match kind {
1531                SegmentKind::Log => {
1532                    let (inline, vf, vt) = distill_log(&full_content);
1533                    (
1534                        inline,
1535                        verbatim_transform(),
1536                        transform("extractive_summary"),
1537                        ContentFidelity::Summarized,
1538                        vf,
1539                        vt,
1540                    )
1541                }
1542                SegmentKind::StackTrace => {
1543                    // A traceback is one instant, not a span: when the capture
1544                    // opens with a timestamp, both ends of the window are it.
1545                    let at = leading_instant(full_content.lines().next());
1546                    (
1547                        distill_stack_trace(&full_content),
1548                        verbatim_transform(),
1549                        transform("stack_frame_head"),
1550                        ContentFidelity::Summarized,
1551                        at.clone(),
1552                        at,
1553                    )
1554                }
1555                SegmentKind::Table => (
1556                    distill_table(&full_content),
1557                    verbatim_transform(),
1558                    transform("tabular_sample"),
1559                    ContentFidelity::Summarized,
1560                    None,
1561                    None,
1562                ),
1563                SegmentKind::Code => (
1564                    distill_code(&full_content),
1565                    verbatim_transform(),
1566                    transform("truncation"),
1567                    ContentFidelity::Summarized,
1568                    None,
1569                    None,
1570                ),
1571                // Prose is never distilled — it is the user's own attached words.
1572                SegmentKind::Prose | SegmentKind::PathRef => (
1573                    full_content.clone(),
1574                    verbatim_transform(),
1575                    verbatim_transform(),
1576                    ContentFidelity::Exact,
1577                    None,
1578                    None,
1579                ),
1580            };
1581
1582        let full_tokens = budget_tokens(&full_content);
1583        let worth_compacting = kind != SegmentKind::Prose
1584            && full_tokens > COMPACT_MIN_TOKENS
1585            && budget_tokens(&distilled) < full_tokens;
1586
1587        let (inline_content, transform, fidelity, compacted) = if worth_compacting {
1588            (distilled, distilled_transform, distilled_fidelity, true)
1589        } else {
1590            (
1591                full_content.clone(),
1592                verbatim_transform,
1593                ContentFidelity::Exact,
1594                false,
1595            )
1596        };
1597
1598        let line_count = full_content.lines().count();
1599        let title = match kind {
1600            SegmentKind::Log => format!("log · {line_count} lines"),
1601            SegmentKind::StackTrace => format!("stack trace · {line_count} lines"),
1602            SegmentKind::Table => format!("table · {line_count} lines"),
1603            SegmentKind::Code => format!("code · {line_count} lines"),
1604            SegmentKind::Prose => "note".to_string(),
1605            SegmentKind::PathRef => "path".to_string(),
1606        };
1607        let summary = if compacted {
1608            format!(
1609                "{title} · {} → {} tokens",
1610                full_tokens,
1611                budget_tokens(&inline_content)
1612            )
1613        } else {
1614            format!("{title} · {full_tokens} tokens")
1615        };
1616
1617        Self {
1618            id,
1619            kind: frame_kind,
1620            title,
1621            citation_label: kind.citation_label().to_string(),
1622            score: kind.score(),
1623            full_content,
1624            address_hash,
1625            inline_content,
1626            transform,
1627            fidelity,
1628            compacted,
1629            valid_from: vf,
1630            valid_to: vt,
1631            summary,
1632        }
1633    }
1634
1635    /// The default budget cost of this artifact (its compact/inline rendering).
1636    fn inline_tokens(&self) -> u32 {
1637        budget_tokens(&self.inline_content)
1638    }
1639
1640    fn content_ref(&self, provider_id: &str) -> ContentRef {
1641        ContentRef {
1642            provider_id: provider_id.to_string(),
1643            // Opaque resolver handle, distinct from any source `uri`.
1644            uri: format!("context://{provider_id}/artifacts/{}", self.address_hash),
1645            expires_at: None,
1646        }
1647    }
1648
1649    /// Provenance for pasted evidence: kind `derivation`, *not* `file`. Pasted
1650    /// text has no URI a host can re-read, so a `file` digest would be a lie and
1651    /// would trip §F5. The real hash lives in `canonical_content_hash`.
1652    fn provenance(&self) -> Provenance {
1653        Provenance {
1654            kind: "derivation".to_string(),
1655            uri: None,
1656            range: None,
1657            digest: None,
1658            method: Some("paste".to_string()),
1659            by: Some(TRANSFORM_IMPL.to_string()),
1660        }
1661    }
1662
1663    /// The digests a host might legitimately hold for a frame this artifact
1664    /// served — its full-source hash, plus the inline hash of a real compaction.
1665    fn served_digests(&self) -> Vec<String> {
1666        let mut digests = vec![self.address_hash.clone()];
1667        if self.compacted {
1668            let inline = sha256_digest(&self.inline_content);
1669            if inline != self.address_hash {
1670                digests.push(inline);
1671            }
1672        }
1673        digests
1674    }
1675
1676    fn apply_common(&self, frame: &mut ContextFrame) {
1677        frame.citation_label = Some(self.citation_label.clone());
1678        frame.provenance = vec![self.provenance()];
1679        frame.inline_content_requirement =
1680            Some(InlineContentRequirement::ResolvableReferenceAllowed);
1681        frame.valid_from = self.valid_from.clone();
1682        frame.valid_to = self.valid_to.clone();
1683    }
1684
1685    /// A `full` frame: the exact source bytes inline. This is the rehydration
1686    /// path — the callable answer to a `[full]` representation preference.
1687    fn as_full(&self) -> ContextFrame {
1688        let content = self.full_content.clone();
1689        let cost = budget_tokens(&content);
1690        let mut frame = ContextFrame::full(
1691            self.id.clone(),
1692            self.kind.clone(),
1693            self.title.clone(),
1694            content,
1695            self.score,
1696            cost,
1697        );
1698        frame.content_digest = Some(self.address_hash.clone());
1699        frame.content_fidelity = Some(ContentFidelity::Exact);
1700        self.apply_common(&mut frame);
1701        frame
1702    }
1703
1704    /// A `compact` frame: the distilled inline rendering plus the resolver handle
1705    /// and the canonical hash. `token_cost` and `content_digest` are recomputed
1706    /// over the inline bytes actually emitted (§B3).
1707    fn as_compact(&self, provider_id: &str) -> ContextFrame {
1708        let inline = self.inline_content.clone();
1709        let cost = budget_tokens(&inline);
1710        let mut frame = ContextFrame::full(
1711            self.id.clone(),
1712            self.kind.clone(),
1713            self.title.clone(),
1714            inline.clone(),
1715            self.score,
1716            cost,
1717        );
1718        frame.representation = Representation::Compact;
1719        frame.content_digest = Some(sha256_digest(&inline));
1720        frame.canonical_content_hash = Some(self.address_hash.clone());
1721        frame.canonical_token_cost = Some(budget_tokens(&self.full_content));
1722        frame.transform = Some(self.transform.clone());
1723        frame.content_ref = Some(self.content_ref(provider_id));
1724        frame.content_fidelity = Some(self.fidelity);
1725        self.apply_common(&mut frame);
1726        frame
1727    }
1728
1729    /// A `reference` frame: no inline content, only the resolver handle and the
1730    /// canonical hash. `token_cost` is 0 — nothing is inlined.
1731    fn as_reference(&self, provider_id: &str) -> ContextFrame {
1732        let mut frame = ContextFrame::reference(
1733            self.id.clone(),
1734            self.kind.clone(),
1735            self.title.clone(),
1736            self.content_ref(provider_id),
1737            self.address_hash.clone(),
1738            self.score,
1739        );
1740        frame.canonical_token_cost = Some(budget_tokens(&self.full_content));
1741        frame.content_fidelity = Some(ContentFidelity::Omitted);
1742        self.apply_common(&mut frame);
1743        frame
1744    }
1745
1746    fn as_representation(&self, provider_id: &str, representation: Representation) -> ContextFrame {
1747        match representation {
1748            Representation::Full => self.as_full(),
1749            Representation::Compact => self.as_compact(provider_id),
1750            Representation::Reference => self.as_reference(provider_id),
1751        }
1752    }
1753}
1754
1755fn transform(method: &str) -> Transform {
1756    Transform {
1757        method: method.to_string(),
1758        implementation: TRANSFORM_IMPL.to_string(),
1759        version: TRANSFORM_VERSION.to_string(),
1760    }
1761}
1762
1763fn verbatim_transform() -> Transform {
1764    transform("verbatim")
1765}
1766
1767// ---------------------------------------------------------------------------
1768// The provider
1769// ---------------------------------------------------------------------------
1770
1771/// A local, egress-free [`ContextProvider`] serving one paste's evidence.
1772///
1773/// It advertises `full`/`compact`/`reference` and `resolve`, and answers a
1774/// `[full]`-preference query straight from its immutable artifact store — the
1775/// working rehydration path behind the `resolve` capability (see [ADR 0006] on
1776/// why this is not an ADR 0004 dead flag). Because artifacts are
1777/// content-addressed and immutable, `verify` is exact.
1778pub struct IngestProvider {
1779    id: String,
1780    info: ProviderInfo,
1781    capabilities: Capabilities,
1782    artifacts: Vec<Artifact>,
1783}
1784
1785impl IngestProvider {
1786    fn new(id: impl Into<String>, artifacts: Vec<Artifact>) -> Self {
1787        let id = id.into();
1788        let mut kinds: Vec<String> = artifacts
1789            .iter()
1790            .map(|a| frame_kind_name(&a.kind).to_string())
1791            .collect();
1792        kinds.sort();
1793        kinds.dedup();
1794
1795        let info = ProviderInfo {
1796            name: DEFAULT_PROVIDER_ID.to_string(),
1797            version: env!("CARGO_PKG_VERSION").to_string(),
1798            // Local-only: the whole point is that a typed paste never leaves the
1799            // machine, so the provider is auto-permitted (§C1 gates egress only).
1800            data_flow: DataFlow {
1801                reads: true,
1802                writes: false,
1803                egress: false,
1804                egress_scopes: vec![EgressScope::LocalOnly],
1805            },
1806        };
1807        let capabilities = Capabilities {
1808            query: QueryCapability { kinds },
1809            correlation: false,
1810            graph: false,
1811            embeddings_fingerprint: None,
1812            verify: true,
1813            representations: vec![
1814                Representation::Full,
1815                Representation::Compact,
1816                Representation::Reference,
1817            ],
1818            resolve: true,
1819        };
1820        Self {
1821            id,
1822            info,
1823            capabilities,
1824            artifacts,
1825        }
1826    }
1827
1828    /// Sum of the default (compact) budget cost of every artifact — the
1829    /// `max_tokens` the bundle query uses so a default fan-out returns them all.
1830    fn default_budget_tokens(&self) -> u32 {
1831        self.artifacts.iter().map(Artifact::inline_tokens).sum()
1832    }
1833
1834    /// How many artifacts this provider holds.
1835    pub fn len(&self) -> usize {
1836        self.artifacts.len()
1837    }
1838
1839    pub fn is_empty(&self) -> bool {
1840        self.artifacts.is_empty()
1841    }
1842}
1843
1844#[async_trait]
1845impl ContextProvider for IngestProvider {
1846    fn id(&self) -> &str {
1847        &self.id
1848    }
1849
1850    fn info(&self) -> &ProviderInfo {
1851        &self.info
1852    }
1853
1854    fn capabilities(&self) -> &Capabilities {
1855        &self.capabilities
1856    }
1857
1858    async fn query(&self, query: &ContextQuery) -> Result<ContextQueryResult, HostError> {
1859        // The first supported representation the host prefers; `[full]` by
1860        // default. A `[full]` preference is the rehydration path.
1861        let representation = query
1862            .select_representation(&[
1863                Representation::Full,
1864                Representation::Compact,
1865                Representation::Reference,
1866            ])
1867            .unwrap_or(Representation::Full);
1868
1869        let mut candidates: Vec<ContextFrame> = self
1870            .artifacts
1871            .iter()
1872            .filter(|a| query.kinds.is_empty() || query.kinds.contains(&a.kind))
1873            .map(|a| a.as_representation(&self.id, representation))
1874            .collect();
1875
1876        // Rank by score, breaking ties by id so the output is deterministic.
1877        candidates.sort_by(|a, b| {
1878            b.score
1879                .partial_cmp(&a.score)
1880                .unwrap_or(std::cmp::Ordering::Equal)
1881                .then_with(|| a.id.cmp(&b.id))
1882        });
1883
1884        // Greedy fit under the query's budget and frame cap (§B1, §B4). Every
1885        // frame's `token_cost` is honest (§B3), so the host's audit passes.
1886        let mut frames: Vec<ContextFrame> = Vec::new();
1887        let mut used: u64 = 0;
1888        let mut dropped: u32 = 0;
1889        for frame in candidates {
1890            if frames.len() as u32 >= query.max_frames {
1891                dropped += 1;
1892                continue;
1893            }
1894            let cost = frame.token_cost as u64;
1895            if used + cost > query.max_tokens as u64 {
1896                dropped += 1;
1897                continue;
1898            }
1899            used += cost;
1900            frames.push(frame);
1901        }
1902
1903        Ok(ContextQueryResult {
1904            frames,
1905            truncated: dropped > 0,
1906            dropped_estimate: (dropped > 0).then_some(dropped),
1907            ..Default::default()
1908        })
1909    }
1910
1911    async fn verify(&self, request: &VerifyRequest) -> Result<VerifyResponse, HostError> {
1912        let verdicts = request
1913            .frames
1914            .iter()
1915            .map(|held| {
1916                let verdict = match self.artifacts.iter().find(|a| a.id == held.frame_id) {
1917                    // Immutable + content-addressed: a matching digest is
1918                    // provably still valid, no source re-read required.
1919                    Some(artifact) => match &held.content_digest {
1920                        Some(digest) if artifact.served_digests().contains(digest) => {
1921                            Verdict::Valid
1922                        }
1923                        Some(_) => Verdict::Stale {
1924                            replacement_digest: Some(artifact.address_hash.clone()),
1925                        },
1926                        // Digestless identities are filtered by the host before
1927                        // `verify`; if one arrives anyway, we cannot vouch.
1928                        None => Verdict::Unknown,
1929                    },
1930                    // The store is authoritative-complete for this session, so an
1931                    // unknown id is genuinely not ours to serve.
1932                    None => Verdict::Gone,
1933                };
1934                FrameVerdict::new(held.clone(), verdict)
1935            })
1936            .collect();
1937        Ok(VerifyResponse::new(verdicts))
1938    }
1939}
1940
1941#[cfg(test)]
1942mod tests;