27 AI Foundations: State, Actions, and the Ledger

Every other chapter in this book describes analysis you run yourself: you call RunDEtest(), you look at the plot, you decide. This chapter describes a different mode. You describe a goal, and something else runs the analysis — an AI agent inside the package, or an agent you talk to from outside R, or your own script written against a contract.

All three modes run on the same three components, and all three are only as trustworthy as those components. This chapter explains them, because a plan you cannot read is a plan you cannot audit.

27.1 The problem: a script is a decision you cannot re-examine

The usual script is a straight line:

# Not executed: `sce` is a placeholder and the point is the SHAPE of the script
# -- four calls with no record of why. The same four calls run for real in the
# [core workflow](#core-workflow) chapter, with the state they leave behind.
sce <- NormalizeData(sce)
sce <- FindVariableFeatures(sce)
sce <- FindClusters(sce)
sce <- RunDEtest(sce)

Shown, not executed: this is the shape of the problem, not a runnable example. sce is a placeholder, and the same four calls appear as real, executing code in the core workflow.

That is fine while you are the one running it. But notice what it does not record: why these thresholds, whether step 3 was even correct for this object, and what any of it implies. If a reviewer asks “how do you know your clusters were not an artifact of the batch effect?”, the script cannot answer, because the script never knew.

sclet’s AI layer is built on the opposite premise. The object already carries its own history — the state contract from state-aware analysis. The AI layer’s job is to make that history something that can be planned against, validated, and audited instead of merely read.

27.2 The AIAction contract

An action is the unit of AI work in sclet. It is not a function you call directly; it is a descriptor that declares what would happen if you did, tightly enough that the engine can refuse a plan that will not work.

AIAction() takes:

argument what it declares
name the stable key a plan must match exactly
handler the function that runs, receiving (object, params)
description one line, shown to a planner
prerequisites a function returning TRUE, FALSE, or a string explaining why not
input_schema per-parameter types, or enumerations
returns "sce" (returns the object) or "value"
requires_confirmation whether a human must approve before execution
output_schema what a later step may reference
mutates_object, allowed_state_types what state the handler may write
estimated_cost, idempotent cost label, and whether repeating is safe
planner_visible whether a model may propose it, or only a person writing the plan
replaces_object whether the handler returns a different object (an aggregation, say)

Four of those do the real work:

prerequisites returns a reason, not just a boolean. An action whose object has no batch column is not “unavailable”; it says the parameter is missing, which the caller can act on. This is what lets a planner propose a fix instead of guessing why it failed.

allowed_state_types bounds what a handler may write. An action that only reads cannot add a reduction; an integration action cannot quietly rewrite the clustering. A plan that violates this is refused by the engine, not merely deprecated in a style guide.

estimated_cost is honest. Most of the registry is cheap; the expensive end is a Python model fit that runs for minutes to hours — SCENIC’s regulon inference, CellOracle’s in-silico perturbation, RegVelo, cell2location deconvolution. Those also ask for explicit confirmation before they start, because a multi-hour run is a decision, not a default.

planner_visible separates “registered” from “plannable”. Some analyses need an input only the user has: a gene set, a bulk phenotype matrix, a reference SingleCellExperiment, a GRN prior, a pySCENIC database. An action over such an input is still registered, still validated, still executed — but a model cannot invent the input it requires, so the action is marked planner_visible = FALSE and left out of the catalog a planner is shown. It remains available to you, writing the plan by hand, which is the subject of the next chapter.

27.3 The registry only exposes what this object can actually run

AIDefaultExecutionRegistry() returns the actions available on the object you hand it. Prerequisites are evaluated, not advertised: an action the object cannot satisfy is listed together with the reason, so a refusal is something you can read and act on.

One distinction matters from here on. The registry is the executable catalog; the catalog a model is shown is a subset of it, minus the actions marked planner_visible = FALSE. So include = "all" means “every action this package offers”, not “every action a model may propose” — the difference is the handful whose input only you can supply.

library(sclet)

sce <- SingleCellExperiment::SingleCellExperiment(
    list(counts = matrix(1, nrow = 10, ncol = 8))
)

reg <- AIDefaultExecutionRegistry(sce, include = "read")
length(reg)
## [1] 3
names(reg)
## [1] "inspect_status" "inspect_ledger" "check_qc"
# The full executable catalog, and how much of it a model may propose.
all_actions <- AIDefaultExecutionRegistry(sce, include = "all")
length(all_actions)
## [1] 46
sum(vapply(all_actions, function(a) isTRUE(a$planner_visible), logical(1L)))
## [1] 37

The include argument selects groups. "read" is the deliberately small default — the inspection group, none of which modify the object. Requesting "all" exposes the analysis groups, each carrying the same confirmation requirements it had before you asked for it, and every one of them appears in the second count unless planner_visible says otherwise.

The numbers are worth reading as a pair: the executable catalog is larger than the planner’s, and the gap is exactly the actions whose input you must supply yourself.

27.4 The ledger is the only authority

GetAnalysisLedger() projects the object’s state into a bounded, model-facing summary: the active view, recorded analyses, lineage, the actions available right now, the fingerprint, and — crucially — analysis_story, the plain-language account of what this object has been through.

ledger <- GetAnalysisLedger(sce)
names(ledger)
##  [1] "schema_version"     "sclet_version"     
##  [3] "sclet_installed_at" "dataset"           
##  [5] "active_view"        "active_states"     
##  [7] "features"           "health"            
##  [9] "analyses"           "state_records"     
## [11] "workflows"          "lineage"           
## [13] "analysis_story"     "capabilities"      
## [15] "blocked_actions"    "quality_checks"    
## [17] "available_actions"  "warnings"          
## [19] "fingerprint"
ledger$fingerprint
## [1] "sclet-ai-747aba50"

Nothing about the AI layer is stored separately. Evidence, claims, and results are state records like any other, so they survive saveRDS(), are visible in Status(), and cannot drift out of sync with the object they describe.

That property is what makes the next two chapters possible: a plan is validated against the ledger, and a result is only ever as strong as the evidence the ledger can show for it.