Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

src/twinscan.rs

//! **The twin scan** — the ONE caller of [`crate::mimir_subclone`], and the
//! gate in front of the dependency sweep.
//!
//! ```text
//!   OWN CODE                              DEPS
//!   ────────                              ────
//!   generation moves (a source file       Cargo.lock / the resolved closure moves
//!   changed)                              (deepscan::deps_generation)
//!        │                                     │
//!        ▼                                     ▼
//!   lens fold: scan_own()                 deps_precondition()  ← GPU, BEFORE any work
//!   CPU / gatling, 6 s per repo                │  Err ⇒ REFUSE, do nothing
//!        │                                     ▼
//!        ▼                                run_deps()
//!   lens_subclone table                   knn_neighbors table
//!        │                                     │
//!        └──────────────► the pane ◄───────────┘
//!                    reads a table, NEVER scans
//! ```
//!
//! # Why there is no Scan button
//!
//! Seven of them were deleted from the GUI on 2026-08-26 (`1aa8f0f`). The owner's
//! doctrine: *after populate, jera jobs bring things up to date on their own.*
//! A button is the UI admitting the automation is not there. So the two scopes
//! are **triggered by their own fingerprints**, and the CLI verb
//! (`nornir twinscan …`) is a SECOND door for a human who wants to force a run —
//! it calls exactly the functions below, never a parallel implementation.
//!
//! # Why the two scopes are different jobs
//!
//! They differ in cost by five orders of magnitude and in trigger by everything:
//!
//! | | own code | deps |
//! |---|---|---|
//! | corpus | 398 files / 9.3 MiB for nornir; 3 350 / 70 MiB for eleven repos | 5 702 262 chunks, 2 912 crates, 1.41 GB |
//! | cost | **6.0 s** one repo, **38.6 s** eleven — MEASURED 2026-08-26 | hours on CPU |
//! | fingerprint | the warehouse generation | [`crate::deepscan::deps_generation`] |
//! | trigger | every generation change | only when the resolved closure moves |
//! | device | **CPU is correct** — never gated on a GPU | **no GPU, no scan** |
//!
//! Re-sweeping 2 912 crates because a source file in nornir changed is the
//! failure this split exists to prevent. `~/.cargo/registry/src` only moves when
//! the resolved dependency closure moves, and `deps_generation` is exactly that
//! hash — it is already the trigger `deepscan` uses, and reusing it means the
//! two deps-scale sweeps cannot disagree about whether the deps moved (LAW 5).
//!
//! # Anti-silent-emptiness
//!
//! [`TwinRead`] has **three** states, not two. `read_ledger_jobs` returning
//! `Ok(vec![])` for both "table absent" and "table empty" is what let
//! `jera.rs:118` fall through to `fixture_zoned_jobs()` and paint invented jobs
//! for a deleted workspace on 2026-08-26. Absent is NOT empty: absent means the
//! producer never ran, and the only honest render is "not computed yet".

use std::path::{Path, PathBuf};
use std::time::Instant;

use crate::mimir_subclone::{self, SubcloneOptions, SubcloneReport};

// ---------------------------------------------------------------------------
// Scope
// ---------------------------------------------------------------------------

/// Which corpus a twin scan runs over. The two are separate jobs with separate
/// triggers and separate device rules — see the module docs.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scope {
    /// The constellation's own repos. CPU, cheap, runs on every generation.
    Own,
    /// Everything under `~/.cargo/registry/src`. GPU-gated, runs only when the
    /// resolved dependency closure moves.
    Deps,
}

impl Scope {
    pub fn as_str(self) -> &'static str {
        match self {
            Scope::Own => "own",
            Scope::Deps => "deps",
        }
    }
    /// Parse a CLI/job argument. Returns `None` for anything else, so a typo is
    /// a refusal rather than a silent default to the cheap scope.
    pub fn parse(s: &str) -> Option<Scope> {
        match s {
            "own" => Some(Scope::Own),
            "deps" => Some(Scope::Deps),
            _ => None,
        }
    }
}

// ---------------------------------------------------------------------------
// Three states, because two of them were conflated
// ---------------------------------------------------------------------------

/// What a reader found in a twin-scan table. **Three** states.
///
/// `Absent` and `Empty` are DIFFERENT facts with different renders, and
/// collapsing them is what painted invented jobs into a deleted workspace's
/// pane on 2026-08-26. Absent ⇒ "not computed yet"; Empty ⇒ "computed, clean";
/// Rows ⇒ the answer. A pane that cannot tell the first two apart will
/// eventually render a fixture as if it were data.
#[derive(Debug, Clone, PartialEq)]
pub enum TwinRead<T> {
    /// The producer has never run for this scope. NOT a clean result.
    Absent,
    /// The producer ran and found nothing. A real, reportable answer.
    Empty,
    /// The producer ran and found something.
    Rows(T),
}

impl<T> TwinRead<T> {
    /// The one line a pane prints. `Absent` never reads as clean.
    pub fn line(&self, scope: Scope, describe: impl FnOnce(&T) -> String) -> String {
        match self {
            TwinRead::Absent => format!(
                "twins/{}: NOT COMPUTED — the producer has never run for this scope; \
                 this is not a clean result",
                scope.as_str()
            ),
            TwinRead::Empty => format!(
                "twins/{}: computed, 0 findings — clean",
                scope.as_str()
            ),
            TwinRead::Rows(t) => format!("twins/{}: {}", scope.as_str(), describe(t)),
        }
    }
    pub fn is_absent(&self) -> bool {
        matches!(self, TwinRead::Absent)
    }
    pub fn rows(&self) -> Option<&T> {
        match self {
            TwinRead::Rows(t) => Some(t),
            _ => None,
        }
    }
}

// ---------------------------------------------------------------------------
// The deps precondition — `no GPU, no scan`
// ---------------------------------------------------------------------------

/// Why the deps sweep did not start.
///
/// This is returned **before** any file is opened. `GpuVerdict::line()` prefixes
/// a CPU verdict with `⚠` because a downgrade the operator cannot see is the
/// failure mode that module is written against; this type goes one step further
/// and makes the downgrade impossible rather than visible — there is no CPU deps
/// sweep to fall back to. `crate::vector::gpu::knn_loud` DOES fall back to CPU,
/// which is right for a 3 000-row question and catastrophic for a 5 702 262-row
/// one, so the deps job calls `gpu::open()` itself and never `knn_loud`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Refusal {
    /// No real discrete GPU. `rejected` names every adapter enumerated and
    /// turned down, so a `llvmpipe`-only box reads as **refused a software
    /// adapter**, never as "no adapter".
    NoGpu {
        /// The complete sentence from `GpuVerdict::Cpu::reason`.
        reason: String,
        /// `backend/type name` for every adapter seen and refused.
        rejected: Vec<String>,
    },
    /// A GPU is present, but there is nothing to score: the deps corpus is
    /// COUNTED, not embedded. Refusing here rather than scoring an empty index
    /// is the `Absent` vs `Empty` rule applied to the input side.
    NoCorpus {
        /// What is known about the corpus that does not exist yet.
        counted: &'static str,
    },
}

impl Refusal {
    /// The one line an operator sees. Always `⚠`, always names what was refused.
    pub fn line(&self) -> String {
        match self {
            Refusal::NoGpu { reason, rejected } => {
                let seen = if rejected.is_empty() {
                    "no adapters enumerated".to_string()
                } else {
                    format!("adapters seen and REFUSED: {}", rejected.join(", "))
                };
                format!(
                    "⚠ twins/deps REFUSED before starting — no GPU, no scan. \
                     {reason} ({seen}). 5 702 262 rows on CPU is hours; \
                     nothing was read and nothing was written."
                )
            }
            Refusal::NoCorpus { counted } => format!(
                "⚠ twins/deps REFUSED before starting — a GPU is present but the \
                 deps corpus is not embedded. {counted}"
            ),
        }
    }
    /// `true` when the refusal names at least one adapter it turned down.
    ///
    /// The guard `a_cpu_only_box_refuses_and_names_the_adapter` asserts this: a
    /// refusal that cannot say WHAT it refused is indistinguishable from a box
    /// with no graphics stack at all, and those need different fixes.
    pub fn names_an_adapter(&self) -> bool {
        matches!(self, Refusal::NoGpu { rejected, .. } if !rejected.is_empty())
    }
}

/// **The precondition.** Called before the deps sweep opens a single file.
///
/// `Ok(adapter)` — a real discrete GPU answered; the sweep may proceed.
/// `Err(Refusal)` — it may not, and the refusal says exactly what was seen.
///
/// # Without the `vector-gpu` feature
///
/// The build cannot even ASK for a GPU, so it certainly must not sweep 2 912
/// crates. That is a refusal with an empty `rejected` list and a reason that
/// names the missing feature — deliberately distinguishable from "adapters were
/// enumerated and all refused", which is a hardware fact rather than a build
/// one.
///
/// ⚠ `vector-gpu` is in NEITHER `default` NOR `full-server` (Cargo.toml:314,
/// :439), so in every shipped configuration today this returns the
/// build-configuration refusal and the hardware is never consulted.
pub fn deps_precondition() -> Result<String, Refusal> {
    #[cfg(feature = "vector-gpu")]
    {
        use crate::vector::gpu::{self, GpuVerdict};
        match gpu::open() {
            Ok(g) => match g.verdict() {
                GpuVerdict::Gpu { adapter, backend } => Ok(format!("{adapter} ({backend})")),
                // `open()` cannot return Ok with a Cpu verdict, but the type
                // permits it and a silent proceed here would be the exact
                // downgrade this function exists to prevent.
                GpuVerdict::Cpu { reason, rejected } => Err(Refusal::NoGpu {
                    reason: reason.clone(),
                    rejected: rejected.clone(),
                }),
            },
            Err(GpuVerdict::Cpu { reason, rejected }) => Err(Refusal::NoGpu { reason, rejected }),
            Err(GpuVerdict::Gpu { adapter, backend }) => {
                // Equally impossible, equally not worth trusting.
                Ok(format!("{adapter} ({backend})"))
            }
        }
    }
    #[cfg(not(feature = "vector-gpu"))]
    {
        Err(Refusal::NoGpu {
            reason: "this binary was built WITHOUT the `vector-gpu` feature, so it \
                     cannot open a compute device at all"
                .to_string(),
            rejected: Vec::new(),
        })
    }
}

/// **The second gate**, reached only after [`deps_precondition`] passes.
///
/// The deps corpus is COUNTED, not embedded: 5 702 262 chunks over 88 407 `.rs`
/// files were counted on 2026-08-26 by running the real
/// `vector::chunk::chunk_file`, and **no model has ever been run over them**.
/// There is no `VectorIndex` to score.
///
/// Scoring a corpus that does not exist would return 0 twins, and 0-because-
/// nothing-was-read is the `Absent` state wearing `Empty`'s clothes — the exact
/// defect [`TwinRead`] exists to prevent, applied to the input side instead of
/// the output side. So the sweep refuses here too, and names the gap.
///
/// This gate disappears the moment something embeds the deps corpus. That is a
/// **decision for the owner**, not a bug to fix quietly: 4.1 GiB of q8 vectors is
/// a real commitment of GPU memory and of embedding time.
pub fn deps_corpus_gate() -> Refusal {
    Refusal::NoCorpus {
        counted: concat!(
            "the rows are COUNTED, NOT embedded — 5 702 262 chunks over 88 407 .rs ",
            "files (1.41 GB) in 2 912 crate sources, measured 2026-08-26 in 217 s; ",
            "no model was run and no vectors exist, so there is no VectorIndex to ",
            "score. At dim 768 they would be 4.1 GiB as q8 (16.3 GiB as f32, which ",
            "does not leave a 24 GB card room to work). Embedding them is a ",
            "DECISION, and until it is made the honest answer is `not computed`, ",
            "never `0 twins`."
        ),
    }
}

// ---------------------------------------------------------------------------
// Reporting scope — pre-registered, before the corpus is seen
// ---------------------------------------------------------------------------

/// **How many findings a person is actually shown.**
///
/// Pre-registered here, before any corpus count, because choosing a reporting
/// cut after seeing the number is how a measurement becomes a story — the same
/// discipline [`SubcloneOptions`] records for the detector's own gates.
///
/// The ledger's evidence: *10 152 clusters at cosine ≥0.80 — far too many to act
/// on*. The subclone lane's: **65 findings is actionable, 347 across three repos
/// is not.** So the actionable band tops out somewhere between 65 and 347, and
/// 20 is a screen — the unit a person reads in one sitting and can rank by eye.
///
/// The TOTAL is always printed beside the shown slice. Truncating without
/// saying so would be the `buckets_skipped` defect at the reporting layer.
pub const REPORT_TOP_FINDINGS: usize = 20;

/// Above this many findings, a scope's output is declared UNACTIONABLE and the
/// headline says so rather than pretending a longer list is a longer to-do list.
///
/// 347 is the measured not-actionable point; 200 sits below it with margin and
/// above the 65 that was verified as actionable.
pub const ACTIONABLE_CEILING: usize = 200;

/// **The own-code reporting scope, DECIDED ON THE MEASUREMENT.**
///
/// Run 2026-08-26 over eleven constellation repos — 3 350 files, 70 MiB, 38.6 s
/// on CPU/gatling. Constellation-wide the answer is **918 findings** (817 once
/// vendored trees are dropped), which is over four times the not-actionable
/// point and is not a to-do list by any reading.
///
/// Split by repo, the same 817 are:
///
/// ```text
///   facett 162   dwarves 161   edda 148   nornir 97   Skidbladnir 70
///   korp 55      znippy-zoomies 45   znippy 29   knut 25   skade 16   draupnir 9
/// ```
///
/// **Every one of them is at or under the ceiling**, and seven of eleven are
/// inside the 9–100 band the subclone lane verified as workable. So the scope a
/// person is shown is **one repo**, and the constellation number is a
/// corpus-level signal that is reported as such and never as a backlog. This is
/// not a threshold chosen to make a number look better — the constellation
/// figure is still printed in full, with the ⚠ that says what it is.
pub const OWN_REPORT_SCOPE: &str = "\
ONE REPO per report. MEASURED 2026-08-26 over eleven repos: 918 findings \
constellation-wide (817 excluding vendored trees) is four times the \
not-actionable point, but split by repo it is facett 162 / dwarves 161 / \
edda 148 / nornir 97 / Skidbladnir 70 / korp 55 / znippy-zoomies 45 / znippy 29 \
/ knut 25 / skade 16 / draupnir 9 — every one at or under the ceiling. The \
workspace-wide number is still printed, flagged as a corpus signal, never as a \
backlog.";

/// **The deps reporting scope, decided before the run.**
///
/// A twin scan over 2 912 crates has three possible outputs, and only one of
/// them is a thing a person can act on:
///
/// 1. **dep ↔ dep** — `serde_json` 1.0.140 and 1.0.141 are near-identical, and
///    `windows-sys` ships the same binding twenty times. This is TRUE, VAST, and
///    **unactionable**: nobody in this constellation can edit those crates. At
///    5 702 262 rows it is also the overwhelming majority of every pair found.
///    **Not reported.**
/// 2. **own ↔ own** — already covered by [`Scope::Own`], at 0.2 s, on CPU.
///    Reporting it again from the deps sweep would be two writers for one fact.
///    **Not reported.**
/// 3. **own ↔ dep** — *we have re-implemented something a crate we already
///    depend on provides.* Every hit is a deletion someone can make this week,
///    and the LAW's first instruction is to reuse rather than rewrite.
///    **This is the whole deps report.**
///
/// So the deps sweep queries with OUR rows and searches the DEPS index. That is
/// not a filter applied afterwards — it changes the query set from 5.7 M to
/// ~5 300, which is also what makes the sweep a batch rather than an all-pairs
/// problem.
pub const DEPS_REPORT_SCOPE: &str = "\
own↔dep ONLY: queries are the constellation's own symbols, the index is the deps \
corpus. dep↔dep pairs are true, vast and unactionable (nobody here can edit \
serde_json) and are NOT reported; own↔own is Scope::Own's job and is not \
restated here. Every reported hit is code we wrote that a crate we already \
depend on already provides.";

// ---------------------------------------------------------------------------
// Own code — the lens fold body
// ---------------------------------------------------------------------------

/// One own-code twin scan, with the cost of producing it.
#[derive(Debug, Clone)]
pub struct OwnScan {
    pub report: SubcloneReport,
    /// Wall seconds for lex + winnow + pair + cluster.
    pub secs: f64,
    /// Source bytes actually read.
    pub bytes: usize,
    /// Roots the corpus came from, so a count is never rootless.
    pub roots: Vec<String>,
    /// Files dropped by [`is_vendored`] — counted, never silent. A filter that
    /// hides its work is the same defect as a count without its gate.
    pub vendored_skipped: usize,
}

/// **Vendored upstream source is not own code.**
///
/// MEASURED 2026-08-26 over eleven constellation repos: of 918 findings, **101
/// touched a `vendor/` tree** — `skade/vendor/iceberg-arrow59`,
/// `skade/bench/vendor/iceberg-datafusion` — and they took six of the top ten
/// slots by damage. They are TRUE duplications and they are **unactionable for
/// exactly the reason dep↔dep pairs are**: nobody in this constellation edits a
/// vendored copy of `iceberg-datafusion`, so every one of those rows is a
/// finding a person reads, understands, and can do nothing about.
///
/// Dropping them is therefore the same decision as [`DEPS_REPORT_SCOPE`]'s, and
/// it is made on the same ground — not to make the number smaller, but because
/// the rows removed are ones no one can act on. The count of what was dropped is
/// carried in [`OwnScan::vendored_skipped`] and printed, so the filter is
/// auditable rather than invisible.
pub fn is_vendored(path: &Path) -> bool {
    path.components()
        .any(|c| c.as_os_str() == "vendor" || c.as_os_str() == "vendored")
}

impl OwnScan {
    /// Findings per second is the wrong rate; **corpus per second** is the one
    /// that says whether this can run on every generation change.
    pub fn mib_per_s(&self) -> f64 {
        if self.secs <= 0.0 {
            return 0.0;
        }
        (self.bytes as f64 / (1024.0 * 1024.0)) / self.secs
    }
    /// `true` when the finding count is inside the band a person can work
    /// through — see [`ACTIONABLE_CEILING`].
    pub fn actionable(&self) -> bool {
        self.report.findings.len() <= ACTIONABLE_CEILING
    }
    /// **`true` when the sweep did not see the whole corpus.**
    ///
    /// `buckets_skipped > 0` means fingerprint buckets were dropped for
    /// exceeding `max_bucket`, so findings are a **LOWER bound**. MEASURED at
    /// constellation scope: 360 buckets skipped over eleven repos — the headline
    /// said "918 findings" and read as complete, which is the
    /// zero-because-unread defect wearing a non-zero number.
    pub fn incomplete(&self) -> bool {
        self.report.buckets_skipped > 0
    }
    /// The headline. Total first, shown slice second, never the other way round.
    pub fn line(&self) -> String {
        let mut s = format!(
            "twins/own: {} finding(s) from {} group(s) over {} file(s) ({} unparsed, \
             {} vendored skipped), {:.2} MiB in {:.2} s = {:.1} MiB/s.",
            self.report.findings.len(),
            self.report.groups.len(),
            self.report.files_scanned,
            self.report.files_unparsed,
            self.vendored_skipped,
            self.bytes as f64 / (1024.0 * 1024.0),
            self.secs,
            self.mib_per_s(),
        );
        // An incomplete sweep says so BEFORE anything that reads as a verdict.
        if self.incomplete() {
            s.push_str(&format!(
                " ⚠ INCOMPLETE — {} fingerprint bucket(s) skipped for exceeding \
                 max_bucket, so this count is a LOWER BOUND, not a total.",
                self.report.buckets_skipped
            ));
        }
        if !self.actionable() {
            s.push_str(&format!(
                " ⚠ ABOVE the {ACTIONABLE_CEILING}-finding actionable ceiling; \
                 this is a corpus-level signal, not a to-do list — narrow the \
                 scope to one repo to get a list a person can work through."
            ));
        }
        s.push_str(&format!(
            " Showing top {}.",
            REPORT_TOP_FINDINGS.min(self.report.findings.len())
        ));
        s
    }
}

/// Every scannable `.rs` file under `roots`, as `(display path, source)`.
///
/// Reuses [`crate::knowledge::symbols::discover_files`] — the SAME set the
/// symbol scan parses — rather than walking again with its own skip list. Two
/// walkers with two skip lists is two corpora, and a twin finding in a file the
/// symbol scan never saw could not be joined to anything.
pub fn collect_sources(roots: &[PathBuf]) -> (Vec<(String, String)>, usize, usize) {
    let mut out = Vec::new();
    let mut bytes = 0usize;
    let mut vendored = 0usize;
    for root in roots {
        for f in crate::knowledge::symbols::discover_files(root) {
            if is_vendored(&f.path) {
                vendored += 1;
                continue;
            }
            let Ok(src) = std::fs::read_to_string(&f.path) else {
                continue;
            };
            bytes += src.len();
            out.push((display_path(root, &f.path), src));
        }
    }
    (out, bytes, vendored)
}

/// `<root-basename>/<path-relative-to-root>` — enough to locate the file across
/// repos without embedding the operator's home directory in a warehouse row.
fn display_path(root: &Path, file: &Path) -> String {
    let base = root
        .file_name()
        .map(|s| s.to_string_lossy().into_owned())
        .unwrap_or_default();
    match file.strip_prefix(root) {
        Ok(rel) => format!("{base}/{}", rel.display()),
        Err(_) => file.display().to_string(),
    }
}

/// **The own-code fold.** CPU, gatling, no GPU gate — 5 323 functions in 0.2 s
/// is not a workload that needs a device, and gating it on one would make the
/// cheap scope fail on exactly the headless boxes it is cheapest on.
///
/// This is the function the lens calls. It takes the sources rather than the
/// roots so a lens fold stays pure: `Sources → Report`, no filesystem, testable
/// with no warehouse (`iceberg_schema::knn_neighbors` states the same rule).
pub fn scan_own(files: Vec<(String, String)>, opts: &SubcloneOptions) -> (SubcloneReport, f64) {
    let t = Instant::now();
    // Span-less since Mímir v2 (§7.3b): the report counts `files_without_spans`.
    let files = files.into_iter().map(|(p, t)| (p, t, None)).collect();
    let report = mimir_subclone::detect(files, opts);
    (report, t.elapsed().as_secs_f64())
}

/// [`scan_own`] plus the filesystem half — the CLI door and the job door.
pub fn scan_own_roots(roots: &[PathBuf], opts: &SubcloneOptions) -> OwnScan {
    let (files, bytes, vendored_skipped) = collect_sources(roots);
    let (report, secs) = scan_own(files, opts);
    OwnScan {
        report,
        secs,
        bytes,
        roots: roots.iter().map(|r| r.display().to_string()).collect(),
        vendored_skipped,
    }
}

/// The findings a person is actually shown, longest-damage first.
///
/// Returns `(shown, total)` — the total is not optional, and a caller that drops
/// it is truncating in silence.
pub fn top_findings(report: &SubcloneReport) -> (&[crate::mimir_subclone::SubcloneFinding], usize) {
    let total = report.findings.len();
    let n = REPORT_TOP_FINDINGS.min(total);
    (&report.findings[..n], total)
}

// ---------------------------------------------------------------------------
// The own-code LENS — spec for the lens-params lane
// ---------------------------------------------------------------------------

/// **Spec handed to the lens-params lane.** `src/warehouse/lens_*` is theirs;
/// this is the contract, not the implementation.
///
/// The own-code twin scan belongs in the lens chain beside the eleven queued at
/// boot (`nornir-lens-chain: nordisk: 11 lens job(s) queued at boot`).
///
/// ⚠ **The 0.2 s figure is `src/`-only and does not survive a repo-scope run.**
/// MEASURED 2026-08-26 through the shipping path: nornir at REPO scope (398
/// files, 9.33 MiB — `tests/`, `examples/` and `crates/` included, which is what
/// a lens over a repo means) takes **6.0 s**, thirty times the quoted number. It
/// is still cheap enough to run on a generation change, but it is not free, and
/// a chain that folds eleven of these serially is a 40-second tail. Fold ONE
/// repo per job, as the other eleven lenses already do.
///
/// ```text
/// name         subclone
/// scope        workspace (repo = "") by default; per-repo on demand, like the
///              other eleven — the fold takes a repo filter and honours it
/// trigger      lens_chain::generation() — the standard snapshot hash. No new
///              trigger, no new fingerprint.
/// entry point  twinscan::scan_own(files, &opts) -> (SubcloneReport, secs)
///              PURE: Sources -> Report. No filesystem, no warehouse, no device.
///              `scan_own_roots` is the filesystem half and is the CLI's, not
///              the fold's — a fold that touched the disk would be untestable
///              on a forge runner, which is the same rule
///              `iceberg_schema::knn_neighbors` states for the GPU.
/// options      mimir_subclone::SubcloneOptions. Defaults are PRE-REGISTERED
///              (k=25, w=36, overlap=0.70) and must not be tuned to move a
///              count. The four env keys in mimir_subclone::ENV_KEYS are the
///              operator door; options_from_env() returns EnvOptions whose
///              `.rejected` names every malformed value — render that line, do
///              not swallow it.
///              PARAMS for lens_chain::is_default_params: (k, w, min_run_lines,
///              min_run_tokens, overlap_floor, normalize_identifiers). A
///              non-default fold must not be readable as a default one.
/// output       one row per SubcloneFinding (NOT per SubcloneGroup):
///                key         string   lead group's 16-hex shape id, stable
///                lines       int      lead group's median run height
///                sites       int      total sites across member groups
///                files       list     distinct files touched, sorted
///                damage      double   lead group's damage — NOT a sum; the
///                                     members restate ONE region and adding
///                                     them counts the same lines N times
///              plus, per fold, the counts that make an empty answer honest:
///                groups, groups_before_subsumption, groups_non_callable,
///                files_scanned, files_unparsed, buckets_skipped, runs_rejected
///              `buckets_skipped > 0` means the sweep is INCOMPLETE and the
///              finding count is a LOWER bound — it must reach `evidence_note`.
/// ranking      damage descending. Show REPORT_TOP_FINDINGS (20); always print
///              the total beside the slice.
/// three states the pane MUST distinguish (LedgerRead::{Absent,Empty,Rows}
///              shape — see TwinRead): a lens that has never folded is NOT
///              "0 findings". 195 groups collapse to 65 findings on nornir/src;
///              a pane that renders groups is restating one region 38 times.
/// ```
pub const LENS_SPEC: &str = "\
lens `subclone`: workspace scope by default, folded on the request, \
entry point twinscan::scan_own(files, &SubcloneOptions) -> (SubcloneReport, secs) \
which is a PURE Sources->Report fold; one row per SubcloneFinding (never per \
group — 195 groups are 65 regions); rank by damage descending; show 20 and \
always print the total; carry buckets_skipped into evidence_note because a \
non-zero value makes the count a LOWER bound; and keep Absent distinct from \
Empty in the pane.";

// ---------------------------------------------------------------------------
// Deps — the fingerprint
// ---------------------------------------------------------------------------

/// **The deps trigger.** The dependency set moves when the resolved closure
/// moves, and nothing else.
///
/// Reuses [`crate::deepscan::deps_generation`], which already hashes the
/// resolved `(name-version, path)` closure for the deep scan. One hash for both
/// deps-scale sweeps means they cannot disagree about whether the deps moved
/// (LAW 5) — and it is a genuine content hash, not an mtime, so a `cargo build`
/// that rewrites nothing does not retrigger 2 912 crates.
pub fn deps_fingerprint(member_dirs: &[PathBuf]) -> anyhow::Result<String> {
    let closure = crate::deepscan::resolve_dep_closure(member_dirs)?;
    Ok(crate::deepscan::deps_generation(&closure))
}

/// Whether the deps sweep should run at all, given what it last ran on.
///
/// `last` is the fingerprint recorded by the previous successful sweep
/// (`None` ⇒ never run ⇒ [`TwinRead::Absent`] ⇒ run). Unchanged fingerprint ⇒
/// **no read, no thread, no RPC**, exactly as `dwarves-gui/src/generation.rs`
/// does it for the panes.
pub fn deps_due(last: Option<&str>, now: &str) -> bool {
    last != Some(now)
}

// ---------------------------------------------------------------------------
// Deps — the job
// ---------------------------------------------------------------------------

/// What one run of the deps job did. Every arm is a complete, printable fact —
/// there is no arm that means "something happened, look in the log".
#[derive(Debug, Clone, PartialEq)]
pub enum DepsOutcome {
    /// The device said no. **No file was opened, no closure was resolved.**
    Refused(Refusal),
    /// The device said yes and the resolved closure has not moved since the last
    /// successful sweep. No read, no thread, no RPC — the
    /// `dwarves-gui/src/generation.rs` rule.
    Unchanged {
        /// The fingerprint that matched.
        fingerprint: String,
    },
    /// The device said yes, the closure moved, and the sweep ran.
    Swept {
        fingerprint: String,
        adapter: String,
        /// Rows written to `knn_neighbors`.
        rows: usize,
    },
}

impl DepsOutcome {
    pub fn line(&self) -> String {
        match self {
            DepsOutcome::Refused(r) => r.line(),
            DepsOutcome::Unchanged { fingerprint } => format!(
                "twins/deps: UNCHANGED at {} — the resolved dependency closure has \
                 not moved, so 2 912 crates were not re-swept.",
                &fingerprint[..16.min(fingerprint.len())]
            ),
            DepsOutcome::Swept {
                fingerprint,
                adapter,
                rows,
            } => format!(
                "twins/deps: swept on {adapter} at {} — {rows} neighbour row(s).",
                &fingerprint[..16.min(fingerprint.len())]
            ),
        }
    }
    /// `true` when the job did no work — the common case, and the one that must
    /// be cheap.
    pub fn did_nothing(&self) -> bool {
        !matches!(self, DepsOutcome::Swept { .. })
    }
}

/// **The deps job.** The whole entry point, in the order the gates must fire.
///
/// 1. **The device.** [`deps_precondition`] — `no GPU, no scan`. This is first
///    because it is the cheapest and the most absolute: on a box that cannot
///    sweep, nothing else is worth computing, and *"a sweep that starts on CPU
///    and takes hours"* is the failure the whole gate exists against. It reads
///    no file and resolves no closure before answering.
/// 2. **The trigger.** [`deps_fingerprint`] over the members' `Cargo.lock`s
///    (pure Rust, no `cargo` invocation). Unchanged ⇒ stop.
/// 3. **The corpus.** [`deps_corpus_gate`] — the rows are counted, not embedded.
///
/// `last` is the fingerprint of the previous successful sweep; `None` means the
/// job has never run, which is [`TwinRead::Absent`] and is always due.
pub fn run_deps_job(member_dirs: &[PathBuf], last: Option<&str>) -> anyhow::Result<DepsOutcome> {
    // ── 1. the device, before anything else ──────────────────────────────────
    let adapter = match deps_precondition() {
        Ok(a) => a,
        Err(r) => return Ok(DepsOutcome::Refused(r)),
    };

    // ── 2. the trigger ───────────────────────────────────────────────────────
    let fingerprint = deps_fingerprint(member_dirs)?;
    if !deps_due(last, &fingerprint) {
        return Ok(DepsOutcome::Unchanged { fingerprint });
    }

    // ── 3. the corpus ────────────────────────────────────────────────────────
    // Nothing has embedded the deps corpus. Returning `Swept { rows: 0 }` here
    // would be the Absent-as-Empty defect at deps scale, so the job refuses
    // instead and says exactly what is missing.
    let _ = adapter;
    Ok(DepsOutcome::Refused(deps_corpus_gate()))
}

#[cfg(test)]
mod tests {
    use super::*;

    /// **THE RED PROOF for job 2.** A box with no discrete GPU must REFUSE the
    /// deps sweep, and the refusal must name what it turned down.
    ///
    /// This is the honest red: it is not a mocked verdict, it is
    /// [`deps_precondition`] on whatever hardware the test runs on. On a box
    /// with a 4090 it takes the `Ok` arm and asserts the adapter is named.
    ///
    /// MEASURED on this box 2026-08-26, through the shipping CLI path
    /// (`nornir twinscan deps`, built `--features vector-gpu`), exit code 3:
    ///
    /// ```text
    /// ⚠ twins/deps REFUSED before starting — no GPU, no scan. no DISCRETE,
    /// non-software Vulkan adapter is present (adapters seen and REFUSED:
    /// Vulkan/IntegratedGpu AMD Radeon 890M Graphics (RADV STRIX1),
    /// Vulkan/Cpu llvmpipe (LLVM 21.1.8, 256 bits)). 5 702 262 rows on CPU is
    /// hours; nothing was read and nothing was written.
    /// ```
    ///
    /// Both adapters are named and both read as **REFUSED**. `rejected` must
    /// stay non-empty, because "an integrated GPU and llvmpipe were turned
    /// down" and "there is no graphics stack" need different fixes and must
    /// never print the same line.
    #[test]
    fn a_cpu_only_box_refuses_the_deps_sweep_and_names_the_adapter() {
        match deps_precondition() {
            Ok(adapter) => {
                assert!(
                    !adapter.is_empty(),
                    "a GPU verdict that cannot name its adapter is not a verdict"
                );
            }
            Err(r) => {
                let line = r.line();
                assert!(
                    line.starts_with('⚠'),
                    "a refusal the operator can miss is the failure mode: {line}"
                );
                assert!(
                    line.contains("no GPU, no scan"),
                    "the refusal must state the rule it is enforcing: {line}"
                );
                #[cfg(feature = "vector-gpu")]
                assert!(
                    r.names_an_adapter(),
                    "a hardware refusal MUST name the adapters it turned down — \
                     'refused a software adapter' and 'no adapter' are different \
                     facts: {line}"
                );
                #[cfg(not(feature = "vector-gpu"))]
                assert!(
                    line.contains("vector-gpu"),
                    "a build-configuration refusal must name the missing feature \
                     rather than blame the hardware: {line}"
                );
            }
        }
    }

    /// The refusal happens BEFORE work. There is no code path from a refused
    /// precondition into a sweep, and this pins the shape that guarantees it:
    /// `deps_precondition` returns a `Result` whose `Err` carries no scan.
    #[test]
    fn a_refusal_carries_no_partial_result() {
        // `Refusal` has no field that could hold rows — if someone adds one,
        // this stops compiling, which is the point.
        let r = Refusal::NoGpu {
            reason: "no discrete GPU".into(),
            rejected: vec!["Vulkan/IntegratedGpu AMD Radeon Graphics".into()],
        };
        assert!(r.names_an_adapter());
        assert!(r.line().contains("nothing was read and nothing was written"));
    }

    /// `llvmpipe` must read as REFUSED, never as ABSENT.
    #[test]
    fn a_software_only_box_reads_as_refused_not_as_absent() {
        let refused = Refusal::NoGpu {
            reason: "no discrete GPU was found".into(),
            rejected: vec!["Vulkan/Cpu llvmpipe (LLVM 19.1.7, 256 bits)".into()],
        };
        let absent = Refusal::NoGpu {
            reason: "no discrete GPU was found".into(),
            rejected: Vec::new(),
        };
        assert!(refused.line().contains("REFUSED: Vulkan/Cpu llvmpipe"));
        assert!(absent.line().contains("no adapters enumerated"));
        assert_ne!(
            refused.line(),
            absent.line(),
            "a software-only box and a graphics-less box must not print the same line"
        );
    }

    /// Absent is not Empty. This is the `jera.rs:118` defect as an assertion.
    #[test]
    fn absent_never_reads_as_clean() {
        let absent: TwinRead<usize> = TwinRead::Absent;
        let empty: TwinRead<usize> = TwinRead::Empty;
        let a = absent.line(Scope::Own, |n| n.to_string());
        let e = empty.line(Scope::Own, |n| n.to_string());
        assert!(a.contains("NOT COMPUTED") && a.contains("not a clean result"));
        assert!(e.contains("clean"));
        assert!(
            !a.contains("0 findings"),
            "an absent table must never render a count: {a}"
        );
        assert_ne!(a, e);
    }

    /// An unchanged closure must not retrigger 2 912 crates.
    #[test]
    fn the_deps_sweep_reruns_only_when_the_closure_moves() {
        assert!(deps_due(None, "gen-a"), "never run ⇒ due");
        assert!(!deps_due(Some("gen-a"), "gen-a"), "unchanged ⇒ NOT due");
        assert!(deps_due(Some("gen-a"), "gen-b"), "moved ⇒ due");
    }

    /// A typo is a refusal, not a silent default to the cheap scope.
    #[test]
    fn an_unknown_scope_is_refused_rather_than_defaulted() {
        assert_eq!(Scope::parse("own"), Some(Scope::Own));
        assert_eq!(Scope::parse("deps"), Some(Scope::Deps));
        assert_eq!(Scope::parse("dep"), None);
        assert_eq!(Scope::parse(""), None);
    }

    /// The headline states the total, and says so when the total is past the
    /// band a person can work through.
    #[test]
    fn an_unactionable_count_says_so_in_the_headline() {
        let mut s = OwnScan {
            report: SubcloneReport::default(),
            secs: 0.2,
            bytes: 1024 * 1024,
            roots: vec!["nornir".into()],
            vendored_skipped: 0,
        };
        assert!(s.actionable());
        assert!(!s.line().contains("ABOVE the"));
        s.report.findings = (0..ACTIONABLE_CEILING + 1)
            .map(|i| crate::mimir_subclone::SubcloneFinding {
                key: format!("{i:016x}"),
                groups: vec![],
                files: vec![],
                sites: 2,
                lines: 10,
                damage: 10.0,
            })
            .collect();
        assert!(!s.actionable());
        let line = s.line();
        assert!(line.contains("ABOVE the"), "{line}");
        assert!(
            line.contains(&format!("{} finding(s)", ACTIONABLE_CEILING + 1)),
            "the TOTAL must be in the headline, not just the shown slice: {line}"
        );
    }

    /// **An incomplete sweep must say so, in the headline, before the count.**
    ///
    /// RED before this existed: the constellation sweep skipped 360 fingerprint
    /// buckets and still printed `918 finding(s)` with nothing to say the corpus
    /// had not been fully read. A non-zero number that is silently a lower bound
    /// is the `zero-because-unread` defect with a different number on it.
    #[test]
    fn an_incomplete_sweep_declares_itself_a_lower_bound() {
        let mut s = OwnScan {
            report: SubcloneReport::default(),
            secs: 1.0,
            bytes: 1024,
            roots: vec!["nornir".into()],
            vendored_skipped: 0,
        };
        assert!(!s.incomplete());
        assert!(!s.line().contains("INCOMPLETE"));
        s.report.buckets_skipped = 360;
        assert!(s.incomplete());
        let line = s.line();
        assert!(line.contains("INCOMPLETE"), "{line}");
        assert!(line.contains("LOWER BOUND"), "{line}");
        assert!(line.contains("360"), "the count of what was skipped: {line}");
    }

    /// Vendored upstream source is dropped, and the drop is COUNTED — a filter
    /// that hides its work is the defect, not the filter.
    #[test]
    fn vendored_source_is_excluded_and_the_exclusion_is_counted() {
        assert!(is_vendored(Path::new("skade/vendor/iceberg-arrow59/src/x.rs")));
        assert!(is_vendored(Path::new(
            "skade/bench/vendor/iceberg-datafusion/src/y.rs"
        )));
        assert!(!is_vendored(Path::new("nornir/src/twinscan.rs")));
        // `vendor` must be a PATH COMPONENT, not a substring: a real module
        // called `vendoring.rs` is our code and must survive.
        assert!(!is_vendored(Path::new("korp/src/vendoring.rs")));
        assert!(!is_vendored(Path::new("korp/src/vendor_api/mod.rs")));

        let s = OwnScan {
            report: SubcloneReport::default(),
            secs: 1.0,
            bytes: 1024,
            roots: vec!["skade".into()],
            vendored_skipped: 742,
        };
        assert!(
            s.line().contains("742 vendored skipped"),
            "the exclusion must be visible: {}",
            s.line()
        );
    }

    /// The shown slice never silently exceeds the screen, and the total always
    /// comes back with it.
    #[test]
    fn top_findings_returns_the_total_beside_the_slice() {
        let mut r = SubcloneReport::default();
        r.findings = (0..57)
            .map(|i| crate::mimir_subclone::SubcloneFinding {
                key: format!("{i:016x}"),
                groups: vec![],
                files: vec![],
                sites: 2,
                lines: 10,
                damage: 10.0,
            })
            .collect();
        let (shown, total) = top_findings(&r);
        assert_eq!(total, 57);
        assert_eq!(shown.len(), REPORT_TOP_FINDINGS);
    }
}