marque_engine/options.rs
1// SPDX-FileCopyrightText: 2026 Knitli Inc.
2//
3// SPDX-License-Identifier: LicenseRef-MarqueLicense-1.0
4
5//! Per-call options for `Engine::lint_with_options` and
6//! `Engine::fix_with_options` (spec 005).
7//!
8//! These types are the durable surface for runtime budgets and
9//! per-call overrides. Phase 1 lands the type surface with no
10//! observable behavior change — the deadline field is plumbed but
11//! not yet honored. Phase 2 wires cooperative cancellation against
12//! `LintOptions::deadline` / `FixOptions::deadline` per spec §R3.
13//!
14//! Both structs are `#[non_exhaustive]` so future fields (cancellation
15//! tokens, memory budgets, per-rule deadlines) can land without a
16//! semver-breaking change. From outside the engine crate, construct
17//! via `Default::default()` + public field assignment:
18//!
19//! Use `marque_engine::Instant` (a `web_time` re-export) rather than
20//! `std::time::Instant` so the example works on every supported
21//! target. On native the two are the same type (literal `pub use`),
22//! so this is also drop-in for native-only callers; on
23//! `wasm32-unknown-unknown`, `std::time::Instant::now()` panics with
24//! "time not implemented on this platform" while `web_time` polyfills
25//! via `Performance.now()`.
26//!
27//! ```
28//! use marque_engine::{Instant, LintOptions, FixOptions};
29//! use std::time::Duration;
30//!
31//! let mut lint = LintOptions::default();
32//! lint.deadline = Some(Instant::now() + Duration::from_secs(1));
33//!
34//! let mut fix = FixOptions::default();
35//! fix.deadline = Some(Instant::now() + Duration::from_secs(1));
36//! fix.threshold_override = Some(0.85);
37//! ```
38//!
39//! In-crate code (engine internals, this crate's tests) may still use
40//! struct-update syntax — `#[non_exhaustive]` only restricts
41//! construction across crate boundaries.
42
43// `web_time::Instant` is `std::time::Instant` on native targets and a
44// Performance.now() polyfill on wasm32-unknown-unknown. Identical type
45// on native (literal `pub use` re-export), so this is source-compatible
46// with any caller that previously constructed an `Instant` from
47// `std::time`.
48use web_time::Instant;
49
50/// Per-call options for [`Engine::lint_with_options`].
51///
52/// **Phase 1 status (current build):** the type surface ships, but
53/// `Engine::lint_with_options` IGNORES `deadline`. The pass always
54/// runs to completion, returns `truncated: false`, and leaves
55/// `candidates_processed` / `candidates_total` at `0`. The semantics
56/// below describe the *Phase 2* behavior that lands in tasks
57/// T007–T009; consult the changelog (Appendix C in the security
58/// whitepaper) before relying on deadline behavior in production.
59///
60/// `deadline` is an absolute wall-clock instant after which the
61/// engine MUST abort cooperatively. Spec §R1, §R3:
62///
63/// - `None` (default) — no budget; lint runs to completion.
64/// - `Some(d)` where `d <= Instant::now()` — pre-pass abort returns
65/// immediately with `LintResult { truncated: true,
66/// candidates_processed: 0, candidates_total: 0, diagnostics:
67/// vec![] }`.
68/// - `Some(d)` where `d > Instant::now()` — engine checks the deadline
69/// at each candidate boundary; on expiry the loop breaks and
70/// `LintResult.truncated` is set to `true` with partial counts.
71///
72/// The choice of `Instant` over `Duration` is deliberate: callers
73/// stamp the deadline once at the boundary they care about
74/// (request arrival, document permit acquisition for batch) and
75/// the engine carries no implicit clock. This makes the budget
76/// composable across `BatchEngine` permit waits and HTTP middleware.
77///
78/// [`Engine::lint_with_options`]: crate::Engine::lint_with_options
79#[non_exhaustive]
80#[derive(Debug, Clone, Default)]
81pub struct LintOptions {
82 /// Absolute wall-clock deadline after which the lint pass MUST
83 /// abort cooperatively. See struct-level docs for semantics —
84 /// **and the Phase 1 status note**: the current build ignores
85 /// this field, deadline-driven cancellation lands in Phase 2.
86 pub deadline: Option<Instant>,
87}
88
89/// Per-call options for [`Engine::fix_with_options`].
90///
91/// **Phase 1 status (current build):** `Engine::fix_with_options`
92/// IGNORES `deadline` (the field is plumbed but not honored), so
93/// `EngineError::DeadlineExceeded` cannot be observed yet. The
94/// `threshold_override` field IS active from Phase 1: invalid values
95/// produce `EngineError::InvalidThreshold` immediately. Deadline
96/// enforcement and the asymmetric `Err(DeadlineExceeded)` response
97/// described below land in Phase 2 (tasks T010–T012).
98///
99/// Carries both the deadline (spec §R3) and the per-call confidence
100/// threshold override that previously lived on
101/// [`Engine::fix_with_threshold`]. The two are combined here so
102/// future per-call concerns (per-rule overrides, dry-run-without-mode
103/// flag) can join without further signature churn.
104///
105/// `deadline` semantics: same as [`LintOptions::deadline`], but the
106/// engine returns `Err(EngineError::DeadlineExceeded { partial_lint })`
107/// rather than a partial `FixResult`. Spec §R4 (asymmetric response):
108/// a partial `FixResult` would commit half a fix to the audit stream,
109/// which violates Constitution V Principle V (audit-record integrity).
110///
111/// `threshold_override`:
112/// - `None` (default) — falls back to `Config::confidence_threshold`.
113/// - `Some(value)` — replaces the config threshold for this call only;
114/// validated against `[0.0, 1.0]`. Out-of-range / NaN values produce
115/// `EngineError::InvalidThreshold` at the start of the call.
116///
117/// [`Engine::fix_with_options`]: crate::Engine::fix_with_options
118/// [`Engine::fix_with_threshold`]: crate::Engine::fix_with_threshold
119/// [`LintOptions::deadline`]: crate::LintOptions::deadline
120#[non_exhaustive]
121#[derive(Debug, Clone, Default)]
122pub struct FixOptions {
123 /// Absolute wall-clock deadline. See [`LintOptions::deadline`] for
124 /// the semantic shape; the difference for `fix` is that expiry
125 /// returns `Err(EngineError::DeadlineExceeded)`, not a partial
126 /// success.
127 ///
128 /// **Phase 1 status:** ignored by the current build; deadline
129 /// enforcement lands in Phase 2.
130 ///
131 /// [`LintOptions::deadline`]: crate::LintOptions::deadline
132 pub deadline: Option<Instant>,
133 /// Per-call confidence threshold override; `None` = use config.
134 /// Values outside `[0.0, 1.0]` (including NaN) produce
135 /// `EngineError::InvalidThreshold`. Active from Phase 1.
136 pub threshold_override: Option<f32>,
137}
138
139#[cfg(test)]
140#[cfg_attr(coverage_nightly, coverage(off))]
141mod tests {
142 use super::*;
143
144 #[test]
145 fn lint_options_default_yields_no_deadline() {
146 let opts = LintOptions::default();
147 assert!(opts.deadline.is_none());
148 }
149
150 #[test]
151 fn fix_options_default_yields_no_deadline_and_no_threshold_override() {
152 let opts = FixOptions::default();
153 assert!(opts.deadline.is_none());
154 assert!(opts.threshold_override.is_none());
155 }
156
157 #[test]
158 fn lint_options_supports_struct_update_syntax() {
159 // Forward-compat smoke test — `#[non_exhaustive]` requires
160 // struct-update syntax for in-crate construction with new
161 // fields. Verifying the pattern compiles documents the
162 // expected idiom for callers.
163 let now = Instant::now();
164 let opts = LintOptions {
165 deadline: Some(now),
166 ..Default::default()
167 };
168 assert_eq!(opts.deadline, Some(now));
169 }
170
171 #[test]
172 fn fix_options_supports_struct_update_syntax() {
173 let now = Instant::now();
174 let opts = FixOptions {
175 deadline: Some(now),
176 threshold_override: Some(0.5),
177 ..Default::default()
178 };
179 assert_eq!(opts.deadline, Some(now));
180 assert_eq!(opts.threshold_override, Some(0.5));
181 }
182}