Skip to main content

devboy_core/
tool_value_model.rs

1//! Tool value model — Paper 3 §"how to build tools right".
2//!
3//! [`ToolValueModel`] is a machine-readable description that every
4//! provider attaches to each tool it ships. The Paper 3 enrichment
5//! planner reads these models to decide which tools to call, in what
6//! order, and which fields of each response are worth keeping under a
7//! given turn budget.
8//!
9//! Design echoes Paper 2's `[profiles.data]` axis: the model is plain
10//! `serde`-compatible data, ships with sensible per-provider defaults,
11//! and can be overridden through a `[tools.<name>]` block in
12//! `pipeline_config.toml` without recompiling. The schema lives here
13//! (in `devboy-core`) so provider crates can populate it without taking
14//! a dependency on the executor or the pipeline.
15//!
16//! See `docs/research/paper3_corpus_findings.md` for the empirical
17//! basis of the default values, and Paper 3 (issue tracker P-3) for
18//! the planner that consumes them.
19
20use std::collections::BTreeMap;
21
22use serde::{Deserialize, Serialize};
23
24/// How important the tool's output is to the agent's task.
25///
26/// The planner uses this as the *first-pass* filter when budget is
27/// tight: `Critical` tools are kept whatever the cost; `AuditOnly`
28/// tools never enter the budget calculation; `Supporting` and
29/// `Optional` are dropped in that order.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
31#[serde(rename_all = "snake_case")]
32pub enum ValueClass {
33    /// File contents, search results — must always be included.
34    #[default]
35    Critical,
36    /// Useful context that improves answers but is not load-bearing.
37    Supporting,
38    /// Nice-to-have. First to be dropped under tight budget.
39    Optional,
40    /// Metadata about the agent's own plan (TaskUpdate, TodoWrite,
41    /// telemetry pings). Kept in trace for analysis but never spent
42    /// against the per-turn budget.
43    AuditOnly,
44}
45
46/// Side-effect classification — controls whether a tool is safe to
47/// run *speculatively* (i.e. before the LLM asks for it).
48///
49/// Speculative pre-fetch is the killer feature of Paper 3, but it is
50/// only safe when re-issuing the call has **no observable consequence
51/// beyond what the LLM was going to do anyway**. Anything that mutates
52/// state (local files, remote APIs, user-visible objects) must never
53/// be speculated — otherwise we double-execute writes.
54///
55/// The default is the most conservative reading: `Indeterminate`. New
56/// tools are non-speculatable until a provider explicitly opts in.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
58#[serde(rename_all = "snake_case")]
59#[non_exhaustive]
60pub enum SideEffectClass {
61    /// Deterministic + idempotent: same input → same output, no
62    /// state. Safe to speculate freely. Examples: `Read` of an
63    /// unchanged file, hash computations, pure functions over args.
64    Pure,
65    /// No external mutation, but the result *can* change between
66    /// calls (TTL applies). Safe to speculate when `freshness_ttl_s`
67    /// has not expired. Examples: `get_issues`, `WebFetch`, `Glob`,
68    /// `Grep`, list-style endpoints. The bulk of the planner's wins.
69    ReadOnly,
70    /// Mutates host-local state (files, in-memory caches). Never
71    /// speculate — re-running would duplicate the edit. Examples:
72    /// `Edit`, `Write`, `MultiEdit`, `NotebookEdit`.
73    MutatesLocal,
74    /// Mutates remote state (creates issues, sends messages, runs
75    /// pipelines, `git push`). Never speculate — the consequence is
76    /// visible to other actors. Examples: `create_issue`,
77    /// `create_merge_request`, `add_issue_comment`, `Bash` for
78    /// destructive commands.
79    MutatesExternal,
80    /// Outcome cannot be classified statically (most prominently
81    /// `Bash` — its effect depends on the command string). Default
82    /// for any tool that has not been annotated. Treated as
83    /// non-speculatable; the planner only emits a hint to the LLM.
84    #[default]
85    Indeterminate,
86}
87
88impl SideEffectClass {
89    /// `true` iff the planner is allowed to issue this tool ahead of
90    /// the LLM asking for it. Currently `Pure` and `ReadOnly` only;
91    /// the other variants are bypassed even if `enrichment.enabled`
92    /// is on.
93    pub fn is_speculatable(&self) -> bool {
94        matches!(self, Self::Pure | Self::ReadOnly)
95    }
96}
97
98/// One named subset of fields from a tool's response. Providers carve
99/// the full result into groups so the planner can drop low-value
100/// fields without dropping the call entirely.
101///
102/// Conventionally a tool ships at least:
103///
104/// - `must_have` — fields required for the response to be useful;
105/// - `nice_to_have` — informative but droppable under budget;
106/// - `debug` — low-value diagnostics, dropped first.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct FieldGroup {
109    /// JSON pointer-style field paths (e.g. `"id"`, `"author.email"`).
110    /// Empty list means "all remaining fields" — used by the
111    /// `nice_to_have` group as a wildcard.
112    #[serde(default)]
113    pub fields: Vec<String>,
114
115    /// Expected value contribution of this group, on a 0.0–1.0 scale,
116    /// relative to the tool's total `must_have` value. The planner
117    /// multiplies this by the tool's `value_class` to get the absolute
118    /// value-per-token used in the knapsack.
119    #[serde(default = "default_estimated_value")]
120    pub estimated_value: f32,
121
122    /// Whether the planner should include this group by default.
123    /// `false` means "opt-in" — only included when the user intent
124    /// explicitly mentions one of the fields.
125    #[serde(default = "default_include_true")]
126    pub default_include: bool,
127}
128
129fn default_estimated_value() -> f32 {
130    0.5
131}
132fn default_include_true() -> bool {
133    true
134}
135
136impl Default for FieldGroup {
137    fn default() -> Self {
138        Self {
139            fields: Vec::new(),
140            estimated_value: default_estimated_value(),
141            default_include: default_include_true(),
142        }
143    }
144}
145
146/// What the call costs in tokens, latency, dollars, and how long the
147/// result stays valid in cache.
148///
149/// The numbers are the planner's *prior*; the actual telemetry from
150/// `PipelineEvent` updates them via `tune analyze` (Paper 2 idiom).
151#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
152pub struct CostModel {
153    /// Median response size in kilobytes — informs the knapsack
154    /// `cost` term. Anchored on the corpus mining in
155    /// `docs/research/paper3_corpus_findings.md`.
156    #[serde(default = "default_typical_kb")]
157    pub typical_kb: f32,
158
159    /// p99 response size — the planner uses this for the *worst-case*
160    /// budget reservation when it cannot afford to overshoot.
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub max_kb: Option<f32>,
163
164    /// Median end-to-end latency. `None` = unknown, treated as 0
165    /// in the planner's latency-aware mode.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub latency_ms_p50: Option<u32>,
168
169    /// Per-call dollar cost for paid APIs (Anthropic, OpenAI, …).
170    /// `None` = free.
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub dollars: Option<f32>,
173
174    /// How long a cached response stays valid before the planner must
175    /// refetch. Used by L0 dedup: a polling endpoint with
176    /// `freshness_ttl_s = 15` returns the cached body for 15 s and
177    /// then collapses to a near-ref hint.
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub freshness_ttl_s: Option<u32>,
180}
181
182fn default_typical_kb() -> f32 {
183    1.0
184}
185
186impl Default for CostModel {
187    fn default() -> Self {
188        Self {
189            typical_kb: default_typical_kb(),
190            max_kb: None,
191            latency_ms_p50: None,
192            dollars: None,
193            freshness_ttl_s: None,
194        }
195    }
196}
197
198/// Edge in the empirically observed follow-up graph. After tool A
199/// fires, the planner consults A's `follow_up` list to decide which
200/// tools to *speculatively* prefetch.
201///
202/// `Default` returns an empty link (`tool = ""`,
203/// `probability = default_followup_probability`). The implementation
204/// is mostly there so call sites can use struct-update syntax
205/// (`FollowUpLink { tool: …, probability: …, ..Default::default() }`)
206/// — the empty `tool` would never resolve in `enumerate_candidates`.
207#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
208pub struct FollowUpLink {
209    /// Name of the tool that typically fires next.
210    pub tool: String,
211
212    /// Probability of this follow-up firing, mined from corpus.
213    /// Range 0.0–1.0; 0.5+ is a reasonable prefetch threshold.
214    #[serde(default = "default_followup_probability")]
215    pub probability: f32,
216
217    /// Optional argument projection — name of the field from the
218    /// previous response to read. For example,
219    /// `Glob.follow_up = [{tool: "Read", projection: "match_path",
220    /// projection_arg: "file_path"}]` tells the planner to take each
221    /// glob result's `match_path` and feed it as the `file_path`
222    /// argument to `Read`.
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub projection: Option<String>,
225
226    /// Optional argument *name* on the follow-up tool that the
227    /// extracted `projection` value should populate. When `None`, the
228    /// provider's `ToolEnricher::project_args` is asked to build the
229    /// arguments instead — that's the right path for built-in tools
230    /// where mapping is hard-coded. Custom MCP tools that the user
231    /// annotates by hand in `pipeline_config.toml` should set both
232    /// `projection` and `projection_arg` so the planner can build the
233    /// follow-up args without provider code.
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub projection_arg: Option<String>,
236}
237
238fn default_followup_probability() -> f32 {
239    0.5
240}
241
242impl Default for FollowUpLink {
243    /// Empty link with the default probability — wouldn't resolve in
244    /// the planner. Provided so callers can use struct-update syntax:
245    /// `FollowUpLink { tool: …, probability: …, ..Default::default() }`
246    /// without spelling every optional field. `f32` has no useful
247    /// `Default::default()` here (would be `0.0`), so we hand-write
248    /// the impl rather than `derive`.
249    fn default() -> Self {
250        Self {
251            tool: String::new(),
252            probability: default_followup_probability(),
253            projection: None,
254            projection_arg: None,
255        }
256    }
257}
258
259/// Provider-shipped, user-overridable description of how a tool fits
260/// into the enrichment knapsack.
261///
262/// **Naming contract.** Keys in `AdaptiveConfig.tools` and in the
263/// `[tools.<name>]` TOML section are *runtime tool names* — exactly
264/// what the LLM sends in `tool_use.name` (`Read`, `Bash`,
265/// `mcp__gitlab__get_issue`, …). Do **not** anonymize them. The
266/// `mcp__p<hash6>__verb` form only appears in the public corpus
267/// aggregates under `docs/research/data/paper3_*.csv`; resolution
268/// (`AdaptiveConfig::effective_tool_value_model`), cross-tool
269/// invalidation (`invalidates = […]`) and the dedup cache all match
270/// on the live runtime name, so an anonymized key would never resolve.
271#[derive(Debug, Clone, Default, Serialize, Deserialize)]
272pub struct ToolValueModel {
273    /// First-pass importance class.
274    #[serde(default)]
275    pub value_class: ValueClass,
276
277    /// Named subsets of the response — `must_have`, `nice_to_have`,
278    /// `debug`, and any provider-specific groups.
279    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
280    pub field_groups: BTreeMap<String, FieldGroup>,
281
282    /// Token / latency / freshness model.
283    #[serde(default)]
284    pub cost_model: CostModel,
285
286    /// Empirically observed next tools — drives speculative prefetch.
287    #[serde(default, skip_serializing_if = "Vec::is_empty")]
288    pub follow_up: Vec<FollowUpLink>,
289
290    /// Tools whose cached responses become stale when *this* tool runs.
291    /// Mirrors the existing file-mutation hook in DedupCache: e.g.
292    /// `update_issue.invalidates = ["get_issue", "get_issues"]`.
293    #[serde(default, skip_serializing_if = "Vec::is_empty")]
294    pub invalidates: Vec<String>,
295
296    /// After how many consecutive empty (or no-change) calls the
297    /// planner should stop re-issuing this tool. `None` = never bail.
298    /// Set to `Some(2)` for `ToolSearch` per the corpus finding that
299    /// 50%+ of repeated `ToolSearch` calls return zero results.
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub fail_fast_after_n: Option<u32>,
302
303    /// Side-effect classification — gates speculative pre-fetch.
304    /// Default `Indeterminate` keeps unannotated tools off the
305    /// speculation path; only `Pure` / `ReadOnly` are eligible.
306    #[serde(default, skip_serializing_if = "is_default_side_effect")]
307    pub side_effect_class: SideEffectClass,
308
309    /// Optional default host for rate-limit grouping
310    /// (e.g. `"github.com"`, `"api.openai.com"`,
311    /// `"gitlab.example.com"`). The host's speculative dispatcher
312    /// caps in-flight prefetches per rate_limit_host; `None` means
313    /// no rate budget tracked for this tool.
314    ///
315    /// **Static vs runtime.** This is the *static* default. For tools
316    /// whose target host depends on runtime arguments (e.g. `WebFetch`
317    /// where the URL is per-call), the provider's
318    /// [`crate::ToolEnricher::rate_limit_host`] override returns the
319    /// runtime value; the static field is only consulted as a
320    /// fallback.
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub rate_limit_host: Option<String>,
323
324    /// Per-tool speculation override. When `Some(false)`, the planner
325    /// is forbidden from speculating this tool even if
326    /// `side_effect_class.is_speculatable()`. Set automatically by
327    /// `tune analyze`'s R7 rule when the observed `prefetch_hit_rate`
328    /// for this tool falls below the floor — i.e. the planner was
329    /// guessing wrong too often. `None` = honour `side_effect_class`.
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    pub speculate: Option<bool>,
332}
333
334fn is_default_side_effect(s: &SideEffectClass) -> bool {
335    matches!(s, SideEffectClass::Indeterminate)
336}
337
338impl ToolValueModel {
339    /// Ergonomic constructor for the most common case: a critical
340    /// tool with a known typical size and one likely follow-up.
341    pub fn critical_with_size(typical_kb: f32) -> Self {
342        Self {
343            value_class: ValueClass::Critical,
344            cost_model: CostModel {
345                typical_kb,
346                ..CostModel::default()
347            },
348            ..Self::default()
349        }
350    }
351
352    /// Ergonomic constructor for an `audit_only` tool (TaskUpdate,
353    /// TodoWrite). Such tools never enter the knapsack budget.
354    pub fn audit_only() -> Self {
355        Self {
356            value_class: ValueClass::AuditOnly,
357            ..Self::default()
358        }
359    }
360
361    /// True iff this tool's responses should be excluded from the
362    /// per-turn budget. Used by the planner's first-pass filter.
363    pub fn excluded_from_budget(&self) -> bool {
364        matches!(self.value_class, ValueClass::AuditOnly)
365    }
366
367    /// True iff the planner is allowed to issue this tool ahead of
368    /// the LLM's next message. Combines `side_effect_class` with the
369    /// per-tool `speculate` override — `Some(false)` always wins, so
370    /// `tune analyze`'s auto-disable rule cannot be bypassed by a
371    /// stale `Pure` annotation.
372    pub fn is_speculatable(&self) -> bool {
373        if matches!(self.speculate, Some(false)) {
374            return false;
375        }
376        if matches!(self.speculate, Some(true)) {
377            return true;
378        }
379        self.side_effect_class.is_speculatable()
380    }
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    #[test]
388    fn default_is_critical_with_one_kb() {
389        let m = ToolValueModel::default();
390        assert_eq!(m.value_class, ValueClass::Critical);
391        assert_eq!(m.cost_model.typical_kb, 1.0);
392        assert!(m.field_groups.is_empty());
393        assert!(m.follow_up.is_empty());
394        assert!(m.invalidates.is_empty());
395        assert!(!m.excluded_from_budget());
396    }
397
398    #[test]
399    fn audit_only_is_excluded_from_budget() {
400        assert!(ToolValueModel::audit_only().excluded_from_budget());
401        assert!(!ToolValueModel::critical_with_size(2.5).excluded_from_budget());
402    }
403
404    #[test]
405    fn critical_with_size_sets_typical_kb() {
406        let m = ToolValueModel::critical_with_size(2.5);
407        assert_eq!(m.value_class, ValueClass::Critical);
408        assert_eq!(m.cost_model.typical_kb, 2.5);
409    }
410
411    #[test]
412    fn round_trip_via_toml_default() {
413        // A blank table must deserialise into the default.
414        let m: ToolValueModel = toml::from_str("").unwrap();
415        assert_eq!(m.value_class, ValueClass::default());
416        assert_eq!(m.cost_model, CostModel::default());
417    }
418
419    #[test]
420    fn round_trip_via_toml_full() {
421        let m = ToolValueModel {
422            value_class: ValueClass::Supporting,
423            field_groups: {
424                let mut g = BTreeMap::new();
425                g.insert(
426                    "must_have".to_string(),
427                    FieldGroup {
428                        fields: vec!["title".into(), "url".into()],
429                        estimated_value: 1.0,
430                        default_include: true,
431                    },
432                );
433                g.insert(
434                    "nice_to_have".to_string(),
435                    FieldGroup {
436                        fields: vec!["snippet".into()],
437                        estimated_value: 0.3,
438                        default_include: false,
439                    },
440                );
441                g
442            },
443            cost_model: CostModel {
444                typical_kb: 3.1,
445                max_kb: Some(8.0),
446                latency_ms_p50: Some(900),
447                dollars: None,
448                freshness_ttl_s: Some(3600),
449            },
450            follow_up: vec![FollowUpLink {
451                tool: "WebFetch".into(),
452                probability: 0.65,
453                projection: Some("url".into()),
454                projection_arg: Some("url".into()),
455            }],
456            invalidates: vec![],
457            fail_fast_after_n: Some(2),
458            side_effect_class: SideEffectClass::ReadOnly,
459            rate_limit_host: Some("example.com".into()),
460            speculate: None,
461        };
462        let s = toml::to_string_pretty(&m).unwrap();
463        let back: ToolValueModel = toml::from_str(&s).unwrap();
464        assert_eq!(back.value_class, ValueClass::Supporting);
465        assert_eq!(back.field_groups.len(), 2);
466        assert_eq!(
467            back.field_groups.get("must_have").unwrap().fields,
468            vec!["title".to_string(), "url".to_string()]
469        );
470        assert_eq!(back.cost_model.typical_kb, 3.1);
471        assert_eq!(back.cost_model.max_kb, Some(8.0));
472        assert_eq!(back.follow_up[0].tool, "WebFetch");
473        assert_eq!(back.follow_up[0].projection.as_deref(), Some("url"));
474        assert_eq!(back.follow_up[0].projection_arg.as_deref(), Some("url"));
475        assert_eq!(back.fail_fast_after_n, Some(2));
476        assert_eq!(back.side_effect_class, SideEffectClass::ReadOnly);
477        assert_eq!(back.rate_limit_host.as_deref(), Some("example.com"));
478        assert!(back.is_speculatable());
479    }
480
481    // ─── Side-effect classification ──────────────────────────────────
482
483    #[test]
484    fn default_side_effect_class_is_indeterminate_and_blocks_speculation() {
485        let m = ToolValueModel::default();
486        assert_eq!(m.side_effect_class, SideEffectClass::Indeterminate);
487        assert!(
488            !m.is_speculatable(),
489            "Indeterminate must never be speculated"
490        );
491    }
492
493    #[test]
494    fn pure_and_read_only_are_speculatable() {
495        let pure = ToolValueModel {
496            side_effect_class: SideEffectClass::Pure,
497            ..Default::default()
498        };
499        let ro = ToolValueModel {
500            side_effect_class: SideEffectClass::ReadOnly,
501            ..Default::default()
502        };
503        assert!(pure.is_speculatable());
504        assert!(ro.is_speculatable());
505    }
506
507    #[test]
508    fn mutating_classes_block_speculation() {
509        for class in [
510            SideEffectClass::MutatesLocal,
511            SideEffectClass::MutatesExternal,
512        ] {
513            let m = ToolValueModel {
514                side_effect_class: class,
515                ..Default::default()
516            };
517            assert!(
518                !m.is_speculatable(),
519                "{class:?} must never be speculated — would duplicate writes"
520            );
521        }
522    }
523
524    #[test]
525    fn speculate_override_wins_over_side_effect_class() {
526        // `tune analyze`'s R7 disables a Pure tool whose hit rate
527        // dropped: must trump the static class.
528        let pure_but_disabled = ToolValueModel {
529            side_effect_class: SideEffectClass::Pure,
530            speculate: Some(false),
531            ..Default::default()
532        };
533        assert!(!pure_but_disabled.is_speculatable());
534
535        // Manual override forcing speculation on an Indeterminate tool
536        // (e.g. user knows their custom MCP shell wrapper is safe).
537        let forced_on = ToolValueModel {
538            side_effect_class: SideEffectClass::Indeterminate,
539            speculate: Some(true),
540            ..Default::default()
541        };
542        assert!(forced_on.is_speculatable());
543    }
544
545    #[test]
546    fn side_effect_class_serialises_snake_case() {
547        // Indeterminate is the default and is intentionally
548        // skip_serializing_if'd — covered by `default_indeterminate_skipped_on_serialise`.
549        for (class, expected) in [
550            (SideEffectClass::Pure, "pure"),
551            (SideEffectClass::ReadOnly, "read_only"),
552            (SideEffectClass::MutatesLocal, "mutates_local"),
553            (SideEffectClass::MutatesExternal, "mutates_external"),
554        ] {
555            let m = ToolValueModel {
556                side_effect_class: class,
557                ..Default::default()
558            };
559            let s = toml::to_string_pretty(&m).unwrap();
560            assert!(
561                s.contains(&format!("side_effect_class = \"{expected}\"")),
562                "expected `{expected}`, got: {s}"
563            );
564            // Round-trip must preserve the class.
565            let back: ToolValueModel = toml::from_str(&s).unwrap();
566            assert_eq!(back.side_effect_class, class);
567        }
568    }
569
570    #[test]
571    fn default_indeterminate_skipped_on_serialise() {
572        let m = ToolValueModel::default();
573        let s = toml::to_string_pretty(&m).unwrap();
574        assert!(
575            !s.contains("side_effect_class"),
576            "Indeterminate is the default and must be skip_serializing_if'd, got: {s}"
577        );
578        assert!(!s.contains("rate_limit_host"));
579        assert!(!s.contains("speculate"));
580    }
581
582    #[test]
583    fn followup_link_projection_arg_round_trips() {
584        let l = FollowUpLink {
585            tool: "Read".into(),
586            probability: 0.8,
587            projection: Some("path".into()),
588            projection_arg: Some("file_path".into()),
589        };
590        let s = toml::to_string_pretty(&l).unwrap();
591        let back: FollowUpLink = toml::from_str(&s).unwrap();
592        assert_eq!(back.projection_arg.as_deref(), Some("file_path"));
593    }
594
595    #[test]
596    fn empty_optional_fields_are_skipped_on_serialise() {
597        let m = ToolValueModel::default();
598        let s = toml::to_string_pretty(&m).unwrap();
599        // No `field_groups`, `follow_up`, `invalidates`, `fail_fast_after_n` —
600        // they were `Default` and should be skip_serializing_if'd.
601        assert!(!s.contains("field_groups"));
602        assert!(!s.contains("follow_up"));
603        assert!(!s.contains("invalidates"));
604        assert!(!s.contains("fail_fast_after_n"));
605        assert!(!s.contains("max_kb"));
606    }
607
608    #[test]
609    fn value_class_serialises_snake_case() {
610        let m = ToolValueModel {
611            value_class: ValueClass::AuditOnly,
612            ..Default::default()
613        };
614        let s = toml::to_string_pretty(&m).unwrap();
615        assert!(s.contains("audit_only"), "expected snake_case, got: {s}");
616    }
617
618    #[test]
619    fn field_group_default_estimated_value_is_half() {
620        let g = FieldGroup::default();
621        assert!((g.estimated_value - 0.5).abs() < 1e-6);
622        assert!(g.default_include);
623    }
624
625    #[test]
626    fn followup_link_round_trips_without_projection() {
627        let l = FollowUpLink {
628            tool: "Bash".into(),
629            probability: 0.8,
630            ..FollowUpLink::default()
631        };
632        let s = toml::to_string_pretty(&l).unwrap();
633        assert!(
634            !s.contains("projection"),
635            "None should be skipped, got: {s}"
636        );
637        let back: FollowUpLink = toml::from_str(&s).unwrap();
638        assert_eq!(back.tool, "Bash");
639        assert!((back.probability - 0.8).abs() < 1e-6);
640        assert!(back.projection.is_none());
641        assert!(back.projection_arg.is_none());
642    }
643}