Skip to main content

mant_query/projection/
references.rs

1//! Budgeted occurrence projection; never loads destinations or builds `SemanticIndex`.
2
3mod resolution;
4#[cfg(test)]
5mod tests;
6
7use mant_ir::{
8    ContentRevealRef, Document, DocumentAddress, LinkOccurrenceRef, LinkTarget, NavigationEvent,
9    NavigationScanOptions, ReferenceFormAssociationState, ReferenceLinkFilter, ReferenceScanLimits,
10    ReferenceScope, ReferenceTargetType, ReferenceWorkBudget, reference_form_associations,
11    scan_navigation_scope_with_budget,
12};
13use mant_protocol::{
14    ReferenceAssociation, ReferenceCount, ReferenceCoverage, ReferenceInventory,
15    ReferencePageLimit, ReferenceProjection, ReferenceProjectionMode, ReferenceRecord,
16    ReferenceUnknownReason,
17};
18use std::{collections::BTreeSet, ops::ControlFlow};
19
20/// Independent reference operation budgets; input-file size is not a substitute.
21#[derive(Debug, Clone, Copy)]
22pub struct ReferenceProjectionLimits {
23    /// Shared traversal, label, form and local-destination inspection limits.
24    pub scan: ReferenceScanLimits,
25    /// Distinct typed-target keys; clamped to 4,096.
26    pub distinct_targets: usize,
27    /// Total retained distinct key text/metadata; clamped to 1 MiB.
28    pub distinct_bytes: usize,
29    /// Total materialized records/positions/labels; default 256 KiB, ceiling 1 MiB.
30    pub materialization_bytes: usize,
31}
32
33impl Default for ReferenceProjectionLimits {
34    fn default() -> Self {
35        Self {
36            scan: ReferenceScanLimits::default(),
37            distinct_targets: 4096,
38            distinct_bytes: 1024 * 1024,
39            materialization_bytes: 256 * 1024,
40        }
41    }
42}
43
44/// Project selected original links without catalog I/O or implicit document loading.
45/// Invalid policies return an unscanned inventory; process boundaries validate first.
46#[must_use]
47pub fn project_references(
48    document: &Document,
49    source_address: Option<&DocumentAddress>,
50    scope: ReferenceScope<'_>,
51    policy: &ReferenceProjection,
52) -> ReferenceInventory {
53    project_references_with_limits(
54        document,
55        source_address,
56        scope,
57        policy,
58        ReferenceProjectionLimits::default(),
59    )
60}
61
62/// Project under explicit hard-clamped budgets, including all skipped offset work.
63#[must_use]
64pub fn project_references_with_limits(
65    document: &Document,
66    source_address: Option<&DocumentAddress>,
67    scope: ReferenceScope<'_>,
68    policy: &ReferenceProjection,
69    mut limits: ReferenceProjectionLimits,
70) -> ReferenceInventory {
71    limits.distinct_targets = limits.distinct_targets.min(4096);
72    limits.distinct_bytes = limits.distinct_bytes.min(1024 * 1024);
73    limits.materialization_bytes = limits.materialization_bytes.min(1024 * 1024);
74    if policy.validate().is_err() {
75        return invalid_policy(policy);
76    }
77    let mut result = ReferenceInventory::not_scanned(policy.clone());
78    if policy.mode == ReferenceProjectionMode::None {
79        return result;
80    }
81    let mut budget = ReferenceWorkBudget::new(limits.scan);
82    let mut targets = BTreeSet::new();
83    let mut target_bytes = 0usize;
84    let mut targets_complete = true;
85    let mut occurrences = 0usize;
86    let mut retained_bytes = 0usize;
87    let source_bytes = source_address.map_or(0, address_bytes);
88    let report = scan_navigation_scope_with_budget(
89        document,
90        scope,
91        &mut budget,
92        NavigationScanOptions {
93            links: ReferenceLinkFilter::from_types(&policy.target_types),
94            ..Default::default()
95        },
96        |event, budget| {
97            let NavigationEvent::Link(occurrence) = event else {
98                return ControlFlow::Continue(());
99            };
100            let index = occurrences;
101            occurrences += 1;
102            if targets_complete {
103                // At most 4,096 keys: reserve bounded comparisons and insert
104                // inspection, not just the single scanner visit to this text.
105                if budget
106                    .consume(
107                        occurrence.location.depth(),
108                        1,
109                        target_size(occurrence.target).saturating_mul(16),
110                    )
111                    .is_err()
112                {
113                    return ControlFlow::Break(());
114                }
115                let key = target_key(occurrence.target);
116                if !targets.contains(&key) {
117                    let bytes = target_size(occurrence.target).saturating_add(32);
118                    if targets.len() >= limits.distinct_targets
119                        || bytes > limits.distinct_bytes.saturating_sub(target_bytes)
120                    {
121                        targets_complete = false;
122                    } else {
123                        targets.insert(key);
124                        target_bytes += bytes;
125                    }
126                }
127            }
128            if policy.mode != ReferenceProjectionMode::All
129                || index < (policy.offset as usize)
130                || result.page.limited.is_some()
131            {
132                return ControlFlow::Continue(());
133            }
134            if result.records.len() >= policy.limit as usize {
135                result.page.limited = Some(ReferencePageLimit::Records);
136                return ControlFlow::Continue(());
137            }
138            match materialize(
139                occurrence,
140                source_address,
141                source_bytes,
142                budget,
143                &mut retained_bytes,
144                limits.materialization_bytes,
145            ) {
146                Ok(record) => result.records.push(record),
147                Err(limit) => result.page.limited = Some(limit),
148            }
149            ControlFlow::Continue(())
150        },
151    );
152    finish_inventory(
153        &mut result,
154        report,
155        occurrences,
156        targets.len(),
157        targets_complete,
158    );
159    result.target_coverage = resolution::validate_local(
160        document,
161        source_address,
162        &mut result.records,
163        &mut budget,
164        &mut retained_bytes,
165        limits.materialization_bytes,
166    );
167    result
168}
169
170fn invalid_policy(policy: &ReferenceProjection) -> ReferenceInventory {
171    // Never clone an invalid caller-provided, potentially huge type vector.
172    let mut invalid = ReferenceInventory::not_scanned(ReferenceProjection {
173        mode: policy.mode,
174        target_types: Vec::new(),
175        offset: policy.offset,
176        limit: policy.limit.clamp(1, 1000),
177    });
178    invalid.occurrences = ReferenceCount::Unknown {
179        reason: ReferenceUnknownReason::InvalidPolicy,
180    };
181    invalid.targets = ReferenceCount::Unknown {
182        reason: ReferenceUnknownReason::InvalidPolicy,
183    };
184    invalid
185}
186
187fn finish_inventory(
188    result: &mut ReferenceInventory,
189    report: mant_ir::ReferenceScanReport,
190    occurrences: usize,
191    targets: usize,
192    targets_complete: bool,
193) {
194    let count = |value, exact| {
195        if exact {
196            ReferenceCount::Exact { value }
197        } else {
198            ReferenceCount::LowerBound { value }
199        }
200    };
201    result.occurrences = count(occurrences as u64, report.complete());
202    result.targets = count(targets as u64, report.complete() && targets_complete);
203    result.coverage = ReferenceCoverage::from_report(report);
204    result.page.returned = u32::try_from(result.records.len()).unwrap_or(u32::MAX);
205    if !report.complete()
206        && result.policy.mode == ReferenceProjectionMode::All
207        && result.page.limited.is_none()
208    {
209        result.page.limited = Some(ReferencePageLimit::Scan);
210    }
211    let next = result.policy.offset.saturating_add(result.page.returned);
212    // Only a completed scan proves that a fresh operation can reach the next
213    // source position; zero-progress materialization never advertises a loop.
214    if report.complete() && result.page.returned > 0 && (next as usize) < occurrences {
215        result.page.next_offset = Some(next);
216    }
217}
218
219fn materialize(
220    occurrence: LinkOccurrenceRef<'_, '_>,
221    source_address: Option<&DocumentAddress>,
222    source_bytes: usize,
223    budget: &mut ReferenceWorkBudget,
224    retained: &mut usize,
225    limit: usize,
226) -> Result<ReferenceRecord, ReferencePageLimit> {
227    let owner = occurrence
228        .content_owner
229        .map(|owner| ContentRevealRef::Owner(owner.location));
230    let semantic_owner = occurrence
231        .semantic_owner
232        .map(|owner| ContentRevealRef::Owner(owner.location));
233    budget
234        .consume(
235            occurrence.location.depth(),
236            occurrence
237                .location
238                .depth()
239                .saturating_add(owner.map_or(0, ContentRevealRef::depth))
240                .saturating_add(semantic_owner.map_or(0, ContentRevealRef::depth))
241                .saturating_mul(4),
242            source_bytes,
243        )
244        .map_err(|_| ReferencePageLimit::Scan)?;
245    let owner_bytes = owner.map_or(0, ContentRevealRef::encoded_size_bound);
246    let forms_reservation = occurrence
247        .semantic_owner
248        .and_then(|owner| owner.owner.facts())
249        .map_or(0, |facts| {
250            facts
251                .forms
252                .len()
253                .min(mant_ir::MAX_REFERENCE_FORM_ASSOCIATIONS)
254                .saturating_mul(12)
255        });
256    let bytes = occurrence
257        .location
258        .encoded_len()
259        .saturating_mul(2)
260        .saturating_add(owner_bytes)
261        .saturating_add(semantic_owner.map_or(0, ContentRevealRef::encoded_size_bound))
262        .saturating_add(target_size(occurrence.target).saturating_mul(3))
263        .saturating_add(source_bytes.saturating_mul(2))
264        .saturating_add(forms_reservation)
265        .saturating_add(256);
266    if bytes > limit.saturating_sub(*retained) {
267        return Err(ReferencePageLimit::MaterializationBytes);
268    }
269    let origin = occurrence
270        .location
271        .to_owned()
272        .ok_or(ReferencePageLimit::Position)?;
273    let owner = match owner {
274        Some(owner) => Some(owner.to_owned().ok_or(ReferencePageLimit::Position)?),
275        None => None,
276    };
277    let label = mant_ir::reference_label(
278        occurrence.label,
279        occurrence.location.depth(),
280        budget,
281        4096.min(limit.saturating_sub(*retained).saturating_sub(bytes)),
282    )
283    .map_err(|_| ReferencePageLimit::Scan)?;
284    let label_truncated = label.truncated;
285    let label = label.text;
286    let association = reference_form_associations(occurrence, budget);
287    let association = match association.state {
288        ReferenceFormAssociationState::Unrecorded => ReferenceAssociation::Unrecorded {},
289        ReferenceFormAssociationState::Complete => ReferenceAssociation::Valid {
290            forms: association.forms,
291            owner: semantic_owner
292                .and_then(ContentRevealRef::to_owned)
293                .ok_or(ReferencePageLimit::Position)?,
294        },
295        ReferenceFormAssociationState::Invalid => ReferenceAssociation::Invalid {},
296        ReferenceFormAssociationState::Limited(_)
297        | ReferenceFormAssociationState::MaterializationLimit => ReferenceAssociation::Limited {},
298    };
299    let total = bytes.saturating_add(label.len());
300    if total > limit.saturating_sub(*retained) {
301        return Err(ReferencePageLimit::MaterializationBytes);
302    }
303    *retained += total;
304    Ok(ReferenceRecord {
305        source_read: source_read(occurrence.location),
306        origin,
307        owner,
308        label,
309        label_truncated,
310        target: occurrence.target.clone(),
311        association,
312        resolution: resolution::initial(occurrence.target, source_address),
313    })
314}
315
316fn source_read(location: mant_ir::ContentLocationRef<'_>) -> mant_protocol::ContentSelector {
317    use std::fmt::Write;
318    let sections = match location {
319        mant_ir::ContentLocationRef::DocumentHeading { .. } => &[][..],
320        mant_ir::ContentLocationRef::SectionHeading { sections, .. }
321        | mant_ir::ContentLocationRef::Content { sections, .. } => sections,
322    };
323    let mut path = String::new();
324    for index in sections {
325        let number = u64::from(*index) + 1;
326        let digits = number.ilog10() as usize + 1;
327        // Deep positions can exceed the readable selector's 512-byte contract;
328        // return the nearest representable ancestor, not an invalid selector.
329        if path.len() + usize::from(!path.is_empty()) + digits > 512 {
330            break;
331        }
332        if !path.is_empty() {
333            path.push('.');
334        }
335        write!(&mut path, "{number}").expect("String write cannot fail");
336    }
337    mant_protocol::ContentSelector::path(if path.is_empty() {
338        "root".to_owned()
339    } else {
340        path
341    })
342}
343
344fn target_key(target: &LinkTarget) -> (ReferenceTargetType, &str, Option<&str>) {
345    match target {
346        LinkTarget::Document { name, fragment } => {
347            (ReferenceTargetType::Document, name, fragment.as_deref())
348        }
349        LinkTarget::Manual {
350            name,
351            manual_section,
352        } => (ReferenceTargetType::Manual, name, manual_section.as_deref()),
353        LinkTarget::Section { id } => (ReferenceTargetType::Local, id.as_str(), None),
354        LinkTarget::External { uri } => (ReferenceTargetType::External, uri, None),
355        LinkTarget::Email { address } => (ReferenceTargetType::Email, address, None),
356    }
357}
358
359fn target_size(target: &LinkTarget) -> usize {
360    let (_, a, b) = target_key(target);
361    a.len().saturating_add(b.map_or(0, str::len))
362}
363fn address_bytes(address: &DocumentAddress) -> usize {
364    match address {
365        DocumentAddress::Manual {
366            name,
367            manual_section,
368        } => name.len().saturating_add(manual_section.len()),
369        DocumentAddress::Markdown { path, origin } => path.len().saturating_add(match origin {
370            mant_ir::MarkdownOrigin::Documents => 0,
371            mant_ir::MarkdownOrigin::Source { name } => name.len(),
372        }),
373    }
374}