Skip to main content

harn_vm/
verification.rs

1//! Verification profile store and stale-diagnostic contract primitives.
2//!
3//! Provides the `verification_profiles_get`, `verification_profiles_set`,
4//! `verification_profile_resolve`, `verification_profile_matches`,
5//! `verification_profile_record_run`, and `verification_diagnostic_classify`
6//! builtins.
7//!
8//! A profile record set is a versioned (`schemaVersion: 1`) list of check
9//! rows keyed by scope selectors (repo -> dir glob -> language -> task
10//! kind), resolved most-specific-wins. Rows are opaque JSON objects: the
11//! store reads and updates only the fields it owns (`id`, `scope`,
12//! `timings`, `lastRun`) and round-trips every other field untouched, so
13//! newer writers can extend rows without breaking older readers. Languages
14//! and toolchains are data in the rows — nothing in this module names a
15//! language, toolchain, or build system.
16//!
17//! Persistence rides the hierarchical project metadata store
18//! (`metadata.rs`) under the `verification-profiles` namespace, giving the
19//! record set the same durability, sharding, and host-fallback behavior as
20//! the other cross-run learning namespaces.
21//!
22//! The stale-diagnostic contract half is pure: a diagnostic envelope
23//! `{rung, rowId, at, snapshot}` is classified against the current
24//! file->hash map as `bound_fresh` (may feed no-progress detectors,
25//! escalation streaks, verifier signatures, and completion gates),
26//! `bound_stale` (an edit invalidated the snapshot; suppressed to advisory
27//! and marked stale), or `unbound` (advisory-only; must never feed gates).
28//! Loop consumers gate on the returned `feedsGates` bit; wiring the
29//! detectors onto that bit lives with them, not here.
30
31use std::collections::BTreeMap;
32
33use crate::metadata::{chrono_now_iso, json_to_vm, with_state};
34use crate::stdlib::macros::{harn_builtin, VmBuiltinDef};
35use crate::value::vm_to_storage_json as vm_to_json;
36use crate::value::{VmError, VmValue};
37
38/// Highest record-set major version this runtime can safely mutate.
39/// Reads of newer sets still round-trip; writes are rejected so an older
40/// runtime cannot silently strip fields a newer schema made load-bearing.
41const PROFILE_SCHEMA_VERSION: i64 = 1;
42
43/// Metadata namespace the record set persists under (sibling to the other
44/// cross-run learning namespaces).
45const PROFILE_NAMESPACE: &str = "verification-profiles";
46
47/// Recent-duration window per timing kind. Percentiles are recomputed over
48/// this window on every observation, so a toolchain regime shift (cache
49/// warmed, daemon started) is reflected within one window of runs instead
50/// of being averaged away forever.
51const TIMING_WINDOW_CAP: usize = 64;
52
53pub(crate) const MODULE_BUILTINS: &[&VmBuiltinDef] = &[
54    &VERIFICATION_PROFILES_GET_IMPL_DEF,
55    &VERIFICATION_PROFILES_SET_IMPL_DEF,
56    &VERIFICATION_PROFILE_RESOLVE_IMPL_DEF,
57    &VERIFICATION_PROFILE_MATCHES_IMPL_DEF,
58    &VERIFICATION_PROFILE_RECORD_RUN_IMPL_DEF,
59    &VERIFICATION_DIAGNOSTIC_CLASSIFY_IMPL_DEF,
60];
61
62// -----------------------------------------------------------------------
63// Record-set model (pure serde_json layer).
64// -----------------------------------------------------------------------
65
66/// Validate a caller-supplied record set and normalize the fields the
67/// store owns. Unknown top-level fields pass through untouched.
68fn validate_record_set(value: &VmValue) -> Result<BTreeMap<String, serde_json::Value>, VmError> {
69    let VmValue::Dict(dict) = value else {
70        return Err(VmError::Runtime(
71            "verification_profiles_set: record_set must be a dict".to_string(),
72        ));
73    };
74    let mut fields = BTreeMap::new();
75    for (key, entry) in dict.iter() {
76        fields.insert(key.to_string(), vm_to_json(entry));
77    }
78    match fields.get("schemaVersion") {
79        None => {
80            fields.insert(
81                "schemaVersion".to_string(),
82                serde_json::json!(PROFILE_SCHEMA_VERSION),
83            );
84        }
85        Some(version) => {
86            let Some(version) = version.as_i64() else {
87                return Err(VmError::Runtime(
88                    "verification_profiles_set: schemaVersion must be an int".to_string(),
89                ));
90            };
91            if version < 1 || version > PROFILE_SCHEMA_VERSION {
92                return Err(VmError::Runtime(format!(
93                    "verification_profiles_set: unsupported schemaVersion {version} \
94                     (this runtime writes schemaVersion <= {PROFILE_SCHEMA_VERSION})"
95                )));
96            }
97        }
98    }
99    match fields.get("rows") {
100        None => {
101            fields.insert("rows".to_string(), serde_json::json!([]));
102        }
103        Some(rows) => {
104            let Some(rows) = rows.as_array() else {
105                return Err(VmError::Runtime(
106                    "verification_profiles_set: rows must be a list".to_string(),
107                ));
108            };
109            if rows.iter().any(|row| !row.is_object()) {
110                return Err(VmError::Runtime(
111                    "verification_profiles_set: every row must be a dict".to_string(),
112                ));
113            }
114        }
115    }
116    fields.insert(
117        "updatedAt".to_string(),
118        serde_json::Value::String(chrono_now_iso()),
119    );
120    Ok(fields)
121}
122
123fn record_set_fields_to_vm(fields: &BTreeMap<String, serde_json::Value>) -> VmValue {
124    let mut map = BTreeMap::new();
125    for (key, value) in fields {
126        map.insert(key.clone(), json_to_vm(value));
127    }
128    VmValue::dict(map)
129}
130
131// -----------------------------------------------------------------------
132// Scope-selector resolution.
133// -----------------------------------------------------------------------
134
135#[derive(Default)]
136struct ScopeQuery {
137    repo: Option<String>,
138    path: Option<String>,
139    language: Option<String>,
140    task: Option<String>,
141}
142
143fn optional_query_string(dict: &crate::value::DictMap, key: &str) -> Option<String> {
144    match dict.get(key) {
145        Some(VmValue::Nil) | None => None,
146        Some(value) => {
147            let text = value.display();
148            if text.trim().is_empty() {
149                None
150            } else {
151                Some(text)
152            }
153        }
154    }
155}
156
157fn scope_query_from_vm(value: Option<&VmValue>) -> ScopeQuery {
158    let Some(VmValue::Dict(dict)) = value else {
159        return ScopeQuery::default();
160    };
161    ScopeQuery {
162        repo: optional_query_string(dict, "repo"),
163        path: optional_query_string(dict, "path"),
164        language: optional_query_string(dict, "language"),
165        task: optional_query_string(dict, "task"),
166    }
167}
168
169fn scope_selector<'a>(row: &'a serde_json::Value, key: &str) -> Option<&'a str> {
170    row.get("scope")
171        .and_then(|scope| scope.get(key))
172        .and_then(|value| value.as_str())
173        .map(str::trim)
174        .filter(|text| !text.is_empty())
175}
176
177/// Match one row against a query. `None` means the row does not apply;
178/// `Some(specificity)` orders applicable rows.
179///
180/// Specificity is lexicographic over (task, language, dir, repo): a row
181/// constraining a narrower axis always beats a row constraining only
182/// broader axes, mirroring the selector hierarchy repo -> dir glob ->
183/// language -> task kind. A selector the row specifies must be satisfied
184/// by the query — a row scoped to a language never matches a query that
185/// does not name that language. Rows with no scope match everything at
186/// specificity 0.
187fn row_specificity(row: &serde_json::Value, query: &ScopeQuery) -> Option<u32> {
188    let mut specificity = 0u32;
189    if let Some(repo) = scope_selector(row, "repo") {
190        match &query.repo {
191            Some(candidate) if candidate == repo => specificity |= 1,
192            _ => return None,
193        }
194    }
195    if let Some(dir_glob) = scope_selector(row, "dir") {
196        match &query.path {
197            Some(path) if harn_glob::match_name(dir_glob, path) => specificity |= 2,
198            _ => return None,
199        }
200    }
201    if let Some(language) = scope_selector(row, "language") {
202        match &query.language {
203            Some(candidate) if candidate.eq_ignore_ascii_case(language) => specificity |= 4,
204            _ => return None,
205        }
206    }
207    if let Some(task) = scope_selector(row, "task") {
208        match &query.task {
209            Some(candidate) if candidate == task => specificity |= 8,
210            _ => return None,
211        }
212    }
213    Some(specificity)
214}
215
216/// Most-specific matching row; ties keep the earliest row so a record set
217/// stays deterministic under append-only edits.
218fn matching_rows<'a>(
219    rows: &'a [serde_json::Value],
220    query: &ScopeQuery,
221) -> Vec<(u32, usize, &'a serde_json::Value)> {
222    let mut out = Vec::new();
223    for (index, row) in rows.iter().enumerate() {
224        let Some(specificity) = row_specificity(row, query) else {
225            continue;
226        };
227        out.push((specificity, index, row));
228    }
229    out.sort_by(|left, right| right.0.cmp(&left.0).then(left.1.cmp(&right.1)));
230    out
231}
232
233fn resolve_row<'a>(
234    rows: &'a [serde_json::Value],
235    query: &ScopeQuery,
236) -> Option<&'a serde_json::Value> {
237    matching_rows(rows, query)
238        .into_iter()
239        .next()
240        .map(|(_, _, row)| row)
241}
242
243// -----------------------------------------------------------------------
244// Timing + lastRun updates.
245// -----------------------------------------------------------------------
246
247/// Nearest-rank percentile over a sorted window.
248fn percentile_ms(sorted: &[i64], percentile: usize) -> i64 {
249    if sorted.is_empty() {
250        return 0;
251    }
252    let rank = (percentile * sorted.len()).div_ceil(100).max(1);
253    sorted[rank - 1]
254}
255
256/// Fold one observed duration into the row's `timings`: append to the
257/// bounded recent window for the warm/cold kind, recompute p50/p95/p99
258/// from the window, and bump the total sample count. Fields inside
259/// `timings` that this store does not own are preserved.
260fn update_timings(
261    row: &mut serde_json::Map<String, serde_json::Value>,
262    duration_ms: i64,
263    warm: bool,
264) {
265    let timings = row
266        .entry("timings")
267        .or_insert_with(|| serde_json::json!({}));
268    if !timings.is_object() {
269        *timings = serde_json::json!({});
270    }
271    let timings = timings.as_object_mut().expect("timings coerced to object");
272
273    let (stats_key, window_key) = if warm {
274        ("warmMs", "recentWarmMs")
275    } else {
276        ("coldMs", "recentColdMs")
277    };
278    let mut window: Vec<i64> = timings
279        .get(window_key)
280        .and_then(|value| value.as_array())
281        .map(|values| values.iter().filter_map(|value| value.as_i64()).collect())
282        .unwrap_or_default();
283    window.push(duration_ms);
284    if window.len() > TIMING_WINDOW_CAP {
285        window.drain(0..window.len() - TIMING_WINDOW_CAP);
286    }
287    let mut sorted = window.clone();
288    sorted.sort_unstable();
289
290    let stats = timings
291        .entry(stats_key)
292        .or_insert_with(|| serde_json::json!({}));
293    if !stats.is_object() {
294        *stats = serde_json::json!({});
295    }
296    let stats = stats.as_object_mut().expect("stats coerced to object");
297    stats.insert(
298        "p50".to_string(),
299        serde_json::json!(percentile_ms(&sorted, 50)),
300    );
301    stats.insert(
302        "p95".to_string(),
303        serde_json::json!(percentile_ms(&sorted, 95)),
304    );
305    stats.insert(
306        "p99".to_string(),
307        serde_json::json!(percentile_ms(&sorted, 99)),
308    );
309
310    timings.insert(window_key.to_string(), serde_json::json!(window));
311    let samples = timings
312        .get("samples")
313        .and_then(|value| value.as_i64())
314        .unwrap_or(0);
315    timings.insert("samples".to_string(), serde_json::json!(samples + 1));
316}
317
318/// Replace the row's `lastRun` with this run's outcome. `lastRun`
319/// describes exactly one execution, so it is overwritten wholesale — a
320/// merge would let a stale failure signature outlive the run that
321/// produced it.
322fn update_last_run(
323    row: &mut serde_json::Map<String, serde_json::Value>,
324    observation: &serde_json::Map<String, serde_json::Value>,
325) {
326    let mut last_run = serde_json::Map::new();
327    let at = observation
328        .get("at")
329        .and_then(|value| value.as_str())
330        .map(ToString::to_string)
331        .unwrap_or_else(chrono_now_iso);
332    last_run.insert("at".to_string(), serde_json::Value::String(at));
333    for key in ["exit", "failureSignature", "snapshot"] {
334        if let Some(value) = observation.get(key) {
335            if !value.is_null() {
336                last_run.insert(key.to_string(), value.clone());
337            }
338        }
339    }
340    row.insert("lastRun".to_string(), serde_json::Value::Object(last_run));
341}
342
343/// Apply one observation to one row. Timing-only observations leave
344/// `lastRun` untouched; outcome-bearing observations (exit, failure
345/// signature, or snapshot) replace it.
346fn apply_observation(
347    row: &mut serde_json::Map<String, serde_json::Value>,
348    observation: &serde_json::Map<String, serde_json::Value>,
349) {
350    if let Some(duration_ms) = observation
351        .get("durationMs")
352        .and_then(|value| value.as_i64())
353    {
354        let warm = observation
355            .get("warm")
356            .and_then(|value| value.as_bool())
357            .unwrap_or(true);
358        update_timings(row, duration_ms, warm);
359    }
360    let has_outcome = ["exit", "failureSignature", "snapshot"].iter().any(|key| {
361        observation
362            .get(*key)
363            .map(|value| !value.is_null())
364            .unwrap_or(false)
365    });
366    if has_outcome {
367        update_last_run(row, observation);
368    }
369}
370
371// -----------------------------------------------------------------------
372// Stale-diagnostic classification (pure).
373// -----------------------------------------------------------------------
374
375/// Classify a diagnostic envelope against the current file->hash map.
376///
377/// The contract: no diagnostic is authoritative unless it names the
378/// snapshot it applies to. A binding needs a rung, a timestamp, and a
379/// non-empty file->hash snapshot (`rowId` stays nullable until a profile
380/// row exists). Anything less is `unbound` and advisory-only. A bound
381/// diagnostic whose snapshot no longer matches the current hashes — a
382/// hash changed, or a snapshot file has no current hash at all — is
383/// `bound_stale`: suppressed to advisory, never re-presented as a current
384/// fact. Only `bound_fresh` diagnostics may feed no-progress detectors,
385/// escalation streaks, verifier signatures, or completion gates.
386fn classify_diagnostic(
387    envelope: &serde_json::Value,
388    current_hashes: &serde_json::Value,
389) -> serde_json::Value {
390    let mut result = serde_json::Map::new();
391    let envelope_obj = envelope.as_object();
392
393    let rung = envelope_obj
394        .and_then(|obj| obj.get("rung"))
395        .and_then(|value| value.as_str())
396        .map(str::trim)
397        .filter(|text| !text.is_empty());
398    let has_timestamp = envelope_obj
399        .and_then(|obj| obj.get("at"))
400        .map(|value| value.is_string() || value.is_number())
401        .unwrap_or(false);
402    let snapshot = envelope_obj
403        .and_then(|obj| obj.get("snapshot"))
404        .and_then(|value| value.as_object())
405        .filter(|snapshot| !snapshot.is_empty());
406
407    if let Some(rung) = rung {
408        result.insert("rung".to_string(), serde_json::json!(rung));
409    }
410    if let Some(row_id) = envelope_obj
411        .and_then(|obj| obj.get("rowId"))
412        .filter(|value| !value.is_null())
413    {
414        result.insert("rowId".to_string(), row_id.clone());
415    }
416
417    let (status, stale_files, reason) = match (rung, has_timestamp, snapshot) {
418        (Some(_), true, Some(snapshot)) => {
419            let current = current_hashes.as_object();
420            let mut stale_files: Vec<String> = snapshot
421                .iter()
422                .filter(|(file, recorded_hash)| {
423                    current.and_then(|map| map.get(*file)) != Some(recorded_hash)
424                })
425                .map(|(file, _)| file.clone())
426                .collect();
427            stale_files.sort();
428            if stale_files.is_empty() {
429                (
430                    "bound_fresh",
431                    stale_files,
432                    "snapshot matches current file hashes".to_string(),
433                )
434            } else {
435                let reason = format!(
436                    "snapshot superseded for {} file(s); re-check before trusting",
437                    stale_files.len()
438                );
439                ("bound_stale", stale_files, reason)
440            }
441        }
442        _ => (
443            "unbound",
444            Vec::new(),
445            "diagnostic carries no rung+timestamp+snapshot binding; advisory only".to_string(),
446        ),
447    };
448
449    result.insert("status".to_string(), serde_json::json!(status));
450    result.insert("staleFiles".to_string(), serde_json::json!(stale_files));
451    let feeds_gates = status == "bound_fresh";
452    result.insert("feedsGates".to_string(), serde_json::json!(feeds_gates));
453    result.insert("advisory".to_string(), serde_json::json!(!feeds_gates));
454    result.insert("reason".to_string(), serde_json::json!(reason));
455    serde_json::Value::Object(result)
456}
457
458// -----------------------------------------------------------------------
459// Builtins.
460// -----------------------------------------------------------------------
461
462fn optional_dir_arg(args: &[VmValue], index: usize) -> String {
463    match args.get(index) {
464        Some(VmValue::Nil) | None => ".".to_string(),
465        Some(value) => {
466            let text = value.display();
467            if text.trim().is_empty() {
468                ".".to_string()
469            } else {
470                text
471            }
472        }
473    }
474}
475
476/// Reads the record set with hierarchical directory resolution, so a
477/// record set stored at the repo root is visible from any subdirectory.
478#[harn_builtin(
479    sig = "verification_profiles_get(dir?: string|nil) -> dict|nil",
480    category = "verification"
481)]
482fn verification_profiles_get_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
483    let dir = optional_dir_arg(args, 0);
484    with_state("verification_profiles_get", |st| {
485        match st.get_namespace(&dir, PROFILE_NAMESPACE) {
486            Some(fields) => Ok(record_set_fields_to_vm(&fields)),
487            None => Ok(VmValue::Nil),
488        }
489    })
490}
491
492/// Replaces the stored record set at `dir` (default repo root) and saves.
493/// Unknown top-level and row fields are persisted untouched; only
494/// `schemaVersion` (defaulted/validated), `rows` (must be a list of
495/// dicts), and `updatedAt` (stamped by the store) are normalized.
496#[harn_builtin(
497    sig = "verification_profiles_set(record_set: dict, dir?: string|nil) -> nil",
498    category = "verification"
499)]
500fn verification_profiles_set_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
501    let record_set = args.first().cloned().unwrap_or(VmValue::Nil);
502    let dir = optional_dir_arg(args, 1);
503    let fields = validate_record_set(&record_set)?;
504    with_state("verification_profiles_set", |st| {
505        st.replace_namespace(&dir, PROFILE_NAMESPACE, fields);
506        st.save().map_err(VmError::Runtime)?;
507        Ok(VmValue::Nil)
508    })
509}
510
511/// Resolve the most-specific row for a scope query
512/// `{repo?, path?, language?, task?}`. Returns the full row (unknown
513/// fields included) or nil when no row applies.
514#[harn_builtin(
515    sig = "verification_profile_resolve(query: dict, dir?: string|nil) -> dict|nil",
516    category = "verification"
517)]
518fn verification_profile_resolve_impl(
519    args: &[VmValue],
520    _out: &mut String,
521) -> Result<VmValue, VmError> {
522    let query = scope_query_from_vm(args.first());
523    let dir = optional_dir_arg(args, 1);
524    with_state("verification_profile_resolve", |st| {
525        let Some(fields) = st.get_namespace(&dir, PROFILE_NAMESPACE) else {
526            return Ok(VmValue::Nil);
527        };
528        let Some(rows) = fields.get("rows").and_then(|value| value.as_array()) else {
529            return Ok(VmValue::Nil);
530        };
531        match resolve_row(rows, &query) {
532            Some(row) => Ok(json_to_vm(row)),
533            None => Ok(VmValue::Nil),
534        }
535    })
536}
537
538/// Return every row that applies to a scope query, ordered by
539/// most-specific-wins selector semantics and stable original row order for
540/// ties. Each item is `{row, specificity, index}` so higher-level Harn
541/// scheduler policy can rank candidates without duplicating the selector
542/// engine in stdlib code.
543#[harn_builtin(
544    sig = "verification_profile_matches(query: dict, dir?: string|nil) -> list",
545    category = "verification"
546)]
547fn verification_profile_matches_impl(
548    args: &[VmValue],
549    _out: &mut String,
550) -> Result<VmValue, VmError> {
551    let query = scope_query_from_vm(args.first());
552    let dir = optional_dir_arg(args, 1);
553    with_state("verification_profile_matches", |st| {
554        let Some(fields) = st.get_namespace(&dir, PROFILE_NAMESPACE) else {
555            return Ok(VmValue::List(std::sync::Arc::new(Vec::new())));
556        };
557        let Some(rows) = fields.get("rows").and_then(|value| value.as_array()) else {
558            return Ok(VmValue::List(std::sync::Arc::new(Vec::new())));
559        };
560        let matches = matching_rows(rows, &query)
561            .into_iter()
562            .map(|(specificity, index, row)| {
563                json_to_vm(&serde_json::json!({
564                    "row": row,
565                    "specificity": specificity,
566                    "index": index,
567                }))
568            })
569            .collect();
570        Ok(VmValue::List(std::sync::Arc::new(matches)))
571    })
572}
573
574/// Fold one run observation
575/// `{durationMs?, warm?, at?, exit?, failureSignature?, snapshot?}` into
576/// the row with the given id, persist, and return the updated row (nil
577/// when no stored row carries that id). The write lands on the directory
578/// the record set actually lives at, so updating from a subdirectory
579/// never forks a shadowing copy.
580#[harn_builtin(
581    sig = "verification_profile_record_run(row_id: string, observation: dict, dir?: string|nil) -> dict|nil",
582    category = "verification"
583)]
584fn verification_profile_record_run_impl(
585    args: &[VmValue],
586    _out: &mut String,
587) -> Result<VmValue, VmError> {
588    let row_id = args
589        .first()
590        .map(|value| value.display())
591        .unwrap_or_default();
592    if row_id.trim().is_empty() {
593        return Err(VmError::Runtime(
594            "verification_profile_record_run: row_id must not be empty".to_string(),
595        ));
596    }
597    let observation = match args.get(1) {
598        Some(VmValue::Dict(_)) => vm_to_json(args.get(1).expect("checked")),
599        _ => {
600            return Err(VmError::Runtime(
601                "verification_profile_record_run: observation must be a dict".to_string(),
602            ))
603        }
604    };
605    let observation = observation.as_object().cloned().unwrap_or_default();
606    let dir = optional_dir_arg(args, 2);
607    with_state("verification_profile_record_run", |st| {
608        let origin = st
609            .origin_directory(&dir, PROFILE_NAMESPACE, Some("rows"))
610            .unwrap_or_else(|| dir.clone());
611        let Some(mut fields) = st
612            .local_directory(&origin)
613            .namespaces
614            .get(PROFILE_NAMESPACE)
615            .cloned()
616        else {
617            return Ok(VmValue::Nil);
618        };
619        let Some(rows) = fields
620            .get_mut("rows")
621            .and_then(|value| value.as_array_mut())
622        else {
623            return Ok(VmValue::Nil);
624        };
625        let mut updated_row = None;
626        for row in rows.iter_mut() {
627            let Some(row_obj) = row.as_object_mut() else {
628                continue;
629            };
630            let matches = row_obj
631                .get("id")
632                .and_then(|value| value.as_str())
633                .map(|id| id == row_id)
634                .unwrap_or(false);
635            if matches {
636                apply_observation(row_obj, &observation);
637                updated_row = Some(row.clone());
638                break;
639            }
640        }
641        let Some(updated_row) = updated_row else {
642            return Ok(VmValue::Nil);
643        };
644        fields.insert(
645            "updatedAt".to_string(),
646            serde_json::Value::String(chrono_now_iso()),
647        );
648        st.replace_namespace(&origin, PROFILE_NAMESPACE, fields);
649        st.save().map_err(VmError::Runtime)?;
650        Ok(json_to_vm(&updated_row))
651    })
652}
653
654/// Classify a diagnostic envelope `{rung, rowId?, at, snapshot}` against
655/// the current file->hash map. Returns
656/// `{status, staleFiles, feedsGates, advisory, reason, rung?, rowId?}`
657/// with status one of `bound_fresh` / `bound_stale` / `unbound`.
658#[harn_builtin(
659    sig = "verification_diagnostic_classify(envelope: dict|nil, current_hashes: dict) -> dict",
660    category = "verification"
661)]
662fn verification_diagnostic_classify_impl(
663    args: &[VmValue],
664    _out: &mut String,
665) -> Result<VmValue, VmError> {
666    let envelope = args
667        .first()
668        .map(vm_to_json)
669        .unwrap_or(serde_json::Value::Null);
670    let current_hashes = args
671        .get(1)
672        .map(vm_to_json)
673        .unwrap_or_else(|| serde_json::json!({}));
674    Ok(json_to_vm(&classify_diagnostic(&envelope, &current_hashes)))
675}
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680    use crate::metadata::MetadataState;
681    use std::path::PathBuf;
682    use std::sync::atomic::{AtomicU64, Ordering};
683
684    static TEMP_PATH_COUNTER: AtomicU64 = AtomicU64::new(0);
685
686    fn temp_path(name: &str) -> PathBuf {
687        let unique = TEMP_PATH_COUNTER.fetch_add(1, Ordering::Relaxed);
688        let pid = std::process::id();
689        std::env::temp_dir().join(format!("harn-verification-{name}-{pid}-{unique}"))
690    }
691
692    fn query(path: &str, language: &str, task: &str) -> ScopeQuery {
693        let optional = |text: &str| {
694            if text.is_empty() {
695                None
696            } else {
697                Some(text.to_string())
698            }
699        };
700        ScopeQuery {
701            repo: None,
702            path: optional(path),
703            language: optional(language),
704            task: optional(task),
705        }
706    }
707
708    // Selector names are synthetic on purpose: the store must resolve a
709    // never-before-seen toolchain purely from data rows.
710    fn sample_rows() -> Vec<serde_json::Value> {
711        vec![
712            serde_json::json!({"id": "any", "scope": {}}),
713            serde_json::json!({"id": "by-dir", "scope": {"dir": "src/**"}}),
714            serde_json::json!({"id": "by-lang", "scope": {"language": "toolchainx"}}),
715            serde_json::json!({
716                "id": "by-dir-lang",
717                "scope": {"dir": "src/**", "language": "toolchainx"}
718            }),
719            serde_json::json!({
720                "id": "by-task",
721                "scope": {"language": "toolchainx", "task": "post_edit"}
722            }),
723        ]
724    }
725
726    fn resolved_id(rows: &[serde_json::Value], q: &ScopeQuery) -> Option<String> {
727        resolve_row(rows, q)
728            .and_then(|row| row.get("id"))
729            .and_then(|id| id.as_str())
730            .map(ToString::to_string)
731    }
732
733    #[test]
734    fn resolve_prefers_most_specific_row() {
735        let rows = sample_rows();
736        // Task kind outranks dir glob + language.
737        assert_eq!(
738            resolved_id(&rows, &query("src/a.tx", "toolchainx", "post_edit")),
739            Some("by-task".to_string())
740        );
741        // Without a task, dir+language outranks either alone.
742        assert_eq!(
743            resolved_id(&rows, &query("src/a.tx", "toolchainx", "")),
744            Some("by-dir-lang".to_string())
745        );
746        // Language alone when the path misses the glob.
747        assert_eq!(
748            resolved_id(&rows, &query("docs/a.tx", "toolchainx", "")),
749            Some("by-lang".to_string())
750        );
751        // Unscoped fallback when nothing narrower applies.
752        assert_eq!(
753            resolved_id(&rows, &query("docs/readme.md", "otherlang", "")),
754            Some("any".to_string())
755        );
756        // A row's selector must be satisfiable: no language in the query
757        // means language-scoped rows are out.
758        assert_eq!(
759            resolved_id(&rows, &query("src/a.tx", "", "")),
760            Some("by-dir".to_string())
761        );
762        // Case-insensitive language match; exact task match.
763        assert_eq!(
764            resolved_id(&rows, &query("", "TOOLCHAINX", "")),
765            Some("by-lang".to_string())
766        );
767        assert_eq!(
768            resolved_id(&rows, &query("", "toolchainx", "other_task")),
769            Some("by-lang".to_string())
770        );
771    }
772
773    #[test]
774    fn resolve_ties_keep_the_earliest_row() {
775        let rows = vec![
776            serde_json::json!({"id": "first", "scope": {"language": "toolchainx"}}),
777            serde_json::json!({"id": "second", "scope": {"language": "toolchainx"}}),
778        ];
779        assert_eq!(
780            resolved_id(&rows, &query("", "toolchainx", "")),
781            Some("first".to_string())
782        );
783    }
784
785    #[test]
786    fn matching_rows_return_all_matches_by_specificity_then_order() {
787        let mut rows = sample_rows();
788        rows.insert(
789            3,
790            serde_json::json!({
791                "id": "by-lang-later",
792                "scope": {"language": "toolchainx"}
793            }),
794        );
795        let ids: Vec<String> = matching_rows(&rows, &query("src/a.tx", "toolchainx", ""))
796            .into_iter()
797            .map(|(_, _, row)| {
798                row.get("id")
799                    .and_then(|id| id.as_str())
800                    .expect("row id")
801                    .to_string()
802            })
803            .collect();
804        assert_eq!(
805            ids,
806            vec![
807                "by-dir-lang".to_string(),
808                "by-lang".to_string(),
809                "by-lang-later".to_string(),
810                "by-dir".to_string(),
811                "any".to_string()
812            ]
813        );
814    }
815
816    #[test]
817    fn record_set_round_trips_unknown_fields_through_disk() {
818        let base = temp_path("roundtrip");
819        let record_set = VmValue::dict(BTreeMap::from([
820            ("schemaVersion".to_string(), VmValue::Int(1)),
821            (
822                "vendorExt".to_string(),
823                VmValue::dict(BTreeMap::from([("keep".to_string(), VmValue::Bool(true))])),
824            ),
825            (
826                "rows".to_string(),
827                VmValue::List(std::sync::Arc::new(vec![VmValue::dict(BTreeMap::from([
828                    (
829                        "id".to_string(),
830                        VmValue::String(arcstr::ArcStr::from("r1")),
831                    ),
832                    (
833                        "futureField".to_string(),
834                        VmValue::String(arcstr::ArcStr::from("kept")),
835                    ),
836                ]))])),
837            ),
838        ]));
839        let fields = validate_record_set(&record_set).expect("valid");
840        assert_eq!(fields.get("schemaVersion"), Some(&serde_json::json!(1)));
841        assert!(fields.contains_key("updatedAt"));
842
843        let mut state = MetadataState::new(&base);
844        state.replace_namespace(".", PROFILE_NAMESPACE, fields);
845        state.save().expect("save");
846
847        let mut reloaded = MetadataState::new(&base);
848        let fields = reloaded
849            .get_namespace(".", PROFILE_NAMESPACE)
850            .expect("stored");
851        assert_eq!(
852            fields.get("vendorExt"),
853            Some(&serde_json::json!({"keep": true}))
854        );
855        let rows = fields
856            .get("rows")
857            .and_then(|value| value.as_array())
858            .unwrap();
859        assert_eq!(rows[0].get("futureField"), Some(&serde_json::json!("kept")));
860        let _ = std::fs::remove_dir_all(base);
861    }
862
863    #[test]
864    fn replace_namespace_drops_keys_absent_from_the_new_set() {
865        let base = temp_path("replace");
866        let mut state = MetadataState::new(&base);
867        state.replace_namespace(
868            ".",
869            PROFILE_NAMESPACE,
870            BTreeMap::from([("stray".into(), serde_json::json!(true))]),
871        );
872        state.replace_namespace(
873            ".",
874            PROFILE_NAMESPACE,
875            BTreeMap::from([("rows".into(), serde_json::json!([]))]),
876        );
877        let fields = state.get_namespace(".", PROFILE_NAMESPACE).expect("stored");
878        assert!(!fields.contains_key("stray"));
879        assert!(fields.contains_key("rows"));
880    }
881
882    #[test]
883    fn profiles_set_rejects_newer_schema_versions() {
884        let record_set = VmValue::dict(BTreeMap::from([(
885            "schemaVersion".to_string(),
886            VmValue::Int(PROFILE_SCHEMA_VERSION + 1),
887        )]));
888        let error = validate_record_set(&record_set).expect_err("must reject");
889        let VmError::Runtime(message) = error else {
890            panic!("unexpected error kind");
891        };
892        assert!(message.contains("unsupported schemaVersion"));
893    }
894
895    #[test]
896    fn timing_percentiles_track_the_recent_window() {
897        let mut row = serde_json::json!({
898            "id": "r1",
899            "timings": {"customNote": "kept"}
900        });
901        let row_obj = row.as_object_mut().unwrap();
902        for duration in 1..=100 {
903            update_timings(row_obj, duration, true);
904        }
905        let timings = row_obj
906            .get("timings")
907            .and_then(|value| value.as_object())
908            .unwrap();
909        // Window keeps the newest 64 samples: 37..=100.
910        let window = timings
911            .get("recentWarmMs")
912            .and_then(|value| value.as_array())
913            .unwrap();
914        assert_eq!(window.len(), TIMING_WINDOW_CAP);
915        assert_eq!(window.first().and_then(|value| value.as_i64()), Some(37));
916        assert_eq!(window.last().and_then(|value| value.as_i64()), Some(100));
917        let warm = timings
918            .get("warmMs")
919            .and_then(|value| value.as_object())
920            .unwrap();
921        assert_eq!(warm.get("p50"), Some(&serde_json::json!(68)));
922        assert_eq!(warm.get("p95"), Some(&serde_json::json!(97)));
923        assert_eq!(warm.get("p99"), Some(&serde_json::json!(100)));
924        assert_eq!(timings.get("samples"), Some(&serde_json::json!(100)));
925        // Cold observations land in their own window + stats.
926        update_timings(row_obj, 5000, false);
927        let timings = row_obj
928            .get("timings")
929            .and_then(|value| value.as_object())
930            .unwrap();
931        let cold = timings
932            .get("coldMs")
933            .and_then(|value| value.as_object())
934            .unwrap();
935        assert_eq!(cold.get("p50"), Some(&serde_json::json!(5000)));
936        assert_eq!(timings.get("samples"), Some(&serde_json::json!(101)));
937        // Fields the store does not own survive every update.
938        assert_eq!(timings.get("customNote"), Some(&serde_json::json!("kept")));
939    }
940
941    #[test]
942    fn observations_update_last_run_only_when_outcome_bearing() {
943        let mut row = serde_json::json!({"id": "r1"});
944        let row_obj = row.as_object_mut().unwrap();
945
946        let timing_only = serde_json::json!({"durationMs": 120})
947            .as_object()
948            .cloned()
949            .unwrap();
950        apply_observation(row_obj, &timing_only);
951        assert!(row_obj.get("lastRun").is_none());
952
953        let outcome = serde_json::json!({
954            "durationMs": 90,
955            "exit": 1,
956            "failureSignature": "expected ';' after statement",
957            "snapshot": {"src/a.tx": "h1"}
958        })
959        .as_object()
960        .cloned()
961        .unwrap();
962        apply_observation(row_obj, &outcome);
963        let last_run = row_obj
964            .get("lastRun")
965            .and_then(|value| value.as_object())
966            .unwrap();
967        assert_eq!(last_run.get("exit"), Some(&serde_json::json!(1)));
968        assert_eq!(
969            last_run.get("failureSignature"),
970            Some(&serde_json::json!("expected ';' after statement"))
971        );
972        assert_eq!(
973            last_run.get("snapshot"),
974            Some(&serde_json::json!({"src/a.tx": "h1"}))
975        );
976        assert!(last_run
977            .get("at")
978            .and_then(|value| value.as_str())
979            .is_some());
980
981        // A later green replaces the failure wholesale — no stale
982        // signature may survive the run that fixed it.
983        let green = serde_json::json!({"exit": 0, "snapshot": {"src/a.tx": "h2"}})
984            .as_object()
985            .cloned()
986            .unwrap();
987        apply_observation(row_obj, &green);
988        let last_run = row_obj
989            .get("lastRun")
990            .and_then(|value| value.as_object())
991            .unwrap();
992        assert_eq!(last_run.get("exit"), Some(&serde_json::json!(0)));
993        assert!(last_run.get("failureSignature").is_none());
994    }
995
996    #[test]
997    fn classify_bound_fresh_feeds_gates() {
998        let envelope = serde_json::json!({
999            "rung": "R2",
1000            "rowId": "r1",
1001            "at": "2026-07-02T00:00:00Z",
1002            "snapshot": {"src/a.tx": "h1", "src/b.tx": "h2"}
1003        });
1004        let current = serde_json::json!({"src/a.tx": "h1", "src/b.tx": "h2"});
1005        let result = classify_diagnostic(&envelope, &current);
1006        assert_eq!(
1007            result.get("status"),
1008            Some(&serde_json::json!("bound_fresh"))
1009        );
1010        assert_eq!(result.get("feedsGates"), Some(&serde_json::json!(true)));
1011        assert_eq!(result.get("advisory"), Some(&serde_json::json!(false)));
1012        assert_eq!(result.get("staleFiles"), Some(&serde_json::json!([])));
1013        assert_eq!(result.get("rowId"), Some(&serde_json::json!("r1")));
1014    }
1015
1016    #[test]
1017    fn classify_bound_stale_on_hash_change_or_missing_hash() {
1018        let envelope = serde_json::json!({
1019            "rung": "R2",
1020            "at": "2026-07-02T00:00:00Z",
1021            "snapshot": {"src/a.tx": "h1", "src/b.tx": "h2"}
1022        });
1023        // Edited file: recorded hash superseded.
1024        let result = classify_diagnostic(
1025            &envelope,
1026            &serde_json::json!({"src/a.tx": "h9", "src/b.tx": "h2"}),
1027        );
1028        assert_eq!(
1029            result.get("status"),
1030            Some(&serde_json::json!("bound_stale"))
1031        );
1032        assert_eq!(result.get("feedsGates"), Some(&serde_json::json!(false)));
1033        assert_eq!(result.get("advisory"), Some(&serde_json::json!(true)));
1034        assert_eq!(
1035            result.get("staleFiles"),
1036            Some(&serde_json::json!(["src/a.tx"]))
1037        );
1038        // A snapshot file with no current hash cannot be confirmed fresh.
1039        let result = classify_diagnostic(&envelope, &serde_json::json!({"src/a.tx": "h1"}));
1040        assert_eq!(
1041            result.get("status"),
1042            Some(&serde_json::json!("bound_stale"))
1043        );
1044        assert_eq!(
1045            result.get("staleFiles"),
1046            Some(&serde_json::json!(["src/b.tx"]))
1047        );
1048    }
1049
1050    #[test]
1051    fn classify_unbound_diagnostics_never_feed_gates() {
1052        let current = serde_json::json!({"src/a.tx": "h1"});
1053        let unbound_cases = [
1054            serde_json::Value::Null,
1055            serde_json::json!({}),
1056            // Missing snapshot.
1057            serde_json::json!({"rung": "R2", "at": "2026-07-02T00:00:00Z"}),
1058            // Empty snapshot binds nothing.
1059            serde_json::json!({"rung": "R2", "at": "2026-07-02T00:00:00Z", "snapshot": {}}),
1060            // Missing rung.
1061            serde_json::json!({"at": "2026-07-02T00:00:00Z", "snapshot": {"src/a.tx": "h1"}}),
1062            // Missing timestamp.
1063            serde_json::json!({"rung": "R2", "snapshot": {"src/a.tx": "h1"}}),
1064        ];
1065        for envelope in unbound_cases {
1066            let result = classify_diagnostic(&envelope, &current);
1067            assert_eq!(
1068                result.get("status"),
1069                Some(&serde_json::json!("unbound")),
1070                "envelope: {envelope}"
1071            );
1072            assert_eq!(result.get("feedsGates"), Some(&serde_json::json!(false)));
1073            assert_eq!(result.get("advisory"), Some(&serde_json::json!(true)));
1074        }
1075    }
1076}