Does nitrogen rate affect maize yield?
agridat::hernandez.nitrogen holds 136 plots from five
Minnesota sites, four replicates each, at nitrogen rates from 0 to 268
kg/ha. Two methods answer the question independently: the slope of a
linear regression of yield on rate, and Kendall’s rank correlation. A
plan puts a power-aware gate on the rank lens, reconciles the two
lenses, and turns the consensus into a recommendation.
A tool is a function of (inputs, data, node) that
returns an orchestraManifest::orchestra_manifest.
lens_to_manifest() lifts a yes-or-no verdict into one;
decision_to_manifest() does the same for a recommendation.
The tools are registered by name so the plan can be written to a
file.
ols_lens <- function(inputs, data, node) {
fit <- lm(yield ~ nitro, data = data)
co <- summary(fit)$coefficients["nitro", ]
p <- unname(co["Pr(>|t|)"])
lens_to_manifest(
headline = if (p < 0.05) "effect" else "no_effect",
effect_present = p < 0.05, emitter = "ols", method = "ols:slope_t",
metrics = list(p_value = p, slope = unname(co["Estimate"]),
effect_mean = unname(co["Estimate"]) * diff(range(data$nitro)),
noise_sd = sigma(fit), n = nrow(data)))
}
rank_lens <- function(inputs, data, node) {
ct <- suppressWarnings(cor.test(data$nitro, data$yield, method = "kendall"))
lens_to_manifest(
headline = if (ct$p.value < 0.05) "effect" else "no_effect",
effect_present = ct$p.value < 0.05, emitter = "rank", method = "rank:kendall",
metrics = list(p_value = ct$p.value, tau = unname(ct$estimate),
n = nrow(data)))
}
recommend <- function(inputs, data, node) {
consensus <- inputs$consensus@summary$headline
action <- switch(consensus,
causal_effect_corroborated = "apply_nitrogen",
no_effect_corroborated = "withhold_nitrogen",
"insufficient_evidence")
decision_to_manifest(action, emitter = "agronomy", method = "agronomy:rule",
metrics = list(consensus = consensus),
consumed = list(inputs$consensus))
}
register_tool("ols_lens", ols_lens)
register_tool("rank_lens", rank_lens)
register_tool("recommend", recommend)Five nodes. Each edge names the inferential target it expects from its source; the gate reads the rank lens and abstains when a null verdict comes from fewer than 25 plots or from a design that could not have resolved the implied effect.
score <- oap_plan(
id = "nitrogen_response",
meta = list(question = "Does nitrogen rate affect maize yield?"),
nodes = list(
node("lens_ols", "ols_lens", emits = "treatment_effects", role = "regression"),
node("lens_rank", "rank_lens", emits = "treatment_effects", role = "rank"),
node("gate", operator = "abstain_gate", emits = "decisions",
consumes = list(edge("lens_rank", "treatment_effects")),
params = list(n_min = 25L, z_floor = 2, mode = "flag")),
node("consensus", operator = "triangulate", emits = "decisions",
consumes = list(edge("lens_ols", "treatment_effects"),
edge("lens_rank", "treatment_effects"),
edge("gate", "decisions"))),
node("decision", "recommend", emits = "decisions", role = "agronomy",
consumes = list(edge("consensus", "decisions")))
))
plan_order(score)
#> [1] "lens_ols" "lens_rank" "gate" "consensus" "decision"run_a <- conduct(score, data = d)
#> [compute ] lens_ols -> treatment_effects
#> [compute ] lens_rank -> treatment_effects
#> [abstain_gate] gate lens_rank -> decisions
#> [triangulate ] consensus lens_ols+lens_rank+gate -> decisions
#> [compute ] decision consensus -> decisions
run_a$terminal@summary$headline
#> [1] "apply_nitrogen"
run_a$bus$consensus@summary$metrics[c("agree", "n_lenses")]
#> $agree
#> [1] TRUE
#>
#> $n_lenses
#> [1] 2Both lenses find the effect, the gate passes, and the consensus is corroborated.
small <- d[d$site == "S1" & d$rep == "R1" & d$nitro <= 67.2, ]
nrow(small)
#> [1] 3
run_b <- conduct(score, data = small)
#> [compute ] lens_ols -> treatment_effects
#> [compute ] lens_rank -> treatment_effects
#> [abstain_gate] gate lens_rank -> decisions [ABSTAIN]
#> [triangulate ] consensus lens_ols+lens_rank+gate -> decisions [ABSTAIN]
#> [compute ] decision consensus -> decisions [ABSTAIN]
run_b$bus$gate@summary$abstain_reason
#> [1] "small-sample no_effect: n = 3 < 25, so the design cannot rule out the model-implied effect; abstaining"
run_b$terminal@summary$headline
#> [1] "insufficient_evidence"With three plots the rank test cannot reject, and the gate refuses to
let that null through. In flag mode the consensus abstains
and the recommendation is insufficient_evidence. Rerouting
instead drops the gated lens and reconciles what survives.
gate <- plan_node(score, "gate")
gate@params$mode <- "reroute"
rerouted <- score
rerouted@nodes[[3]] <- gate
run_b2 <- conduct(rerouted, data = small, verbose = FALSE)
run_b2$bus$consensus@summary$metrics[c("rerouted", "n_lenses")]
#> $rerouted
#> [1] TRUE
#>
#> $n_lenses
#> [1] 1
run_b2$terminal@summary$headline
#> [1] "insufficient_evidence"One lens on three plots is not a corroborated effect, so the recommendation is still to wait for evidence.
Because every compute node names its tool, the plan writes to YAML and reads back to the same run.
f <- tempfile(fileext = ".oap.yaml")
plan_to_yaml(score, f)
cat(readLines(f, n = 12), sep = "\n")
#> id: "nitrogen_response"
#> meta:
#> question: "Does nitrogen rate affect maize yield?"
#> nodes:
#> - id: "lens_ols"
#> role: "regression"
#> operator: "compute"
#> emits: "treatment_effects"
#> consumes: []
#> params: []
#> tool: "ols_lens"
#> - id: "lens_rank"
again <- plan_from_yaml(f)
run_c <- conduct(again, data = d, verbose = FALSE)
identical(run_c$terminal@run_id, run_a$terminal@run_id)
#> [1] TRUEA plan can also be written directly as a file, by a person or by an AI model. Version 0.2 of the plan language adds four optional fields that let a plan act on its own results:
| Field | What it does |
|---|---|
when |
runs the node only if a condition on upstream results is true |
foreach |
runs the node once per item, from a list or from a column of an upstream result |
on_refusal |
says what an abstaining result does: continue,
halt-branch or stop |
operator: select |
keeps the rows of an upstream table that meet a rule |
The plan below asks which interactions in a two-way model of yield
hold up. The first tool fits yield ~ nitro * site and
returns one row per model term. A select node keeps the
interaction terms with p < 0.05, and a check runs once for each kept
term, but only if there is at least one.
term_table <- function(inputs, data, node) {
a <- anova(lm(yield ~ nitro * site, data = data))
terms <- rownames(a)[rownames(a) != "Residuals"]
tab <- data.frame(term = terms,
order = lengths(strsplit(terms, ":", fixed = TRUE)),
p_value = a[terms, "Pr(>F)"])
summ <- orchestraManifest::manifest_summary("terms", metrics = list(n = nrow(data)))
orchestraManifest::orchestra_manifest(
emitter_package = "example", emitter_version = "0.1.0",
inferential_target = "parameters", run_id = "terms-1",
method = "lm:anova", params = tab, summary = summ, timestamp = Sys.time(),
data_hash = orchestraManifest::manifest_data_hash(tab, NULL, NULL, NULL,
NA_integer_, summ))
}The check is a stand-in for an external validation: it refits the
model on replicates R1 and R2, then on R3 and R4, and asks whether the
term is significant in both halves. When a half has too few plots it
abstains with insufficient_evidence, which
on_refusal: continue records and passes on.
check_term <- function(inputs, data, node) {
term <- node@params$item
p <- vapply(list(c("R1", "R2"), c("R3", "R4")), function(half) {
a <- anova(lm(yield ~ nitro * site, data = data[data$rep %in% half, ]))
a[term, "Pr(>F)"]
}, numeric(1))
verdict <- if (anyNA(p)) "insufficient_evidence"
else if (all(p < 0.05)) "replicated" else "not_replicated"
decision_to_manifest(verdict, emitter = "split_half",
method = paste0("split_half:", term),
metrics = list(p_first = p[1], p_second = p[2]))
}
register_tool("term_table", term_table)
register_tool("check_term", check_term)The plan as YAML. Every string is in quotes: YAML 1.1 reads an
unquoted yes, no, on or
off as a logical, so conductoR refuses those words unquoted
rather than guess.
plan_text <- '
id: "interactions"
plan_version: "0.2"
meta:
question: "Which nitrogen-by-site interactions hold up in both halves of the trial?"
nodes:
- id: "terms"
tool: "term_table"
emits: "parameters"
- id: "flagged"
operator: "select"
emits: "parameters"
consumes:
- from: "terms"
contract: "parameters"
params:
rule: "order > 1 & p_value < 0.05"
- id: "check"
tool: "check_term"
emits: "decisions"
consumes:
- from: "flagged"
contract: "parameters"
when: "flagged.n_rows > 0"
foreach:
from: "flagged"
column: "term"
foreach_max: 10
on_refusal: "continue"
'
interactions <- plan_from_yaml(plan_text)
run_i <- conduct(interactions, data = d)
#> [compute ] terms -> parameters
#> [select ] flagged terms -> parameters
#> [compute ] check flagged -> decisions
run_i$conditions
#> node condition value
#> 1 check flagged.n_rows > 0 TRUE
run_i$items[, c("item", "status")]
#> item status
#> 1 nitro:site ran
run_i$bus$check@outputs[["check[nitro:site]"]]@summary$headline
#> [1] "not_replicated"One interaction, nitro:site, meets the rule on all 136
plots, and the check finds it significant in the first half but not in
the second. The run record lists the condition, its value and every item
with its status, so a blinded run and the unblinded run that follows it
can be compared item by item.
Conditions are read by conductoR, never run as R code. They may refer
to <node>.headline,
<node>.abstained, <node>.n_rows
and <node>.metrics.<name>, and use plain
numbers, quoted strings, comparisons, %in% with
c(...), &, |, !
and brackets. Anything else is refused when the plan is read:
res <- try(plan_from_yaml(sub("flagged.n_rows > 0", "system(cmd) == 0",
plan_text, fixed = TRUE)), silent = TRUE)
cat(conditionMessage(attr(res, "condition")))
#> The plan has 1 problem:
#> * node 'check', when: `system` is not allowed in a condition. Fix: a condition may use <node>.headline, <node>.abstained, <node>.n_rows, <node>.metrics.<name>, literals and comparisons (== != < <= > >=), %in% with c(...), & | ! and brackets.plan_schema() returns a JSON Schema (draft 2020-12) of
the language. An editor can check a plan against it as it is typed, and
an AI model can use it to write a plan as structured output. The loop
is: write the plan as JSON against the schema, check it with
plan_validate(), and fix every problem the table lists.
plan_validate() reports all the problems at once, each with
the field, the node, the rule broken and the fix.
schema <- jsonlite::fromJSON(plan_schema(), simplifyVector = FALSE)
names(schema$`$defs`$node$properties)
#> [1] "id" "role" "tool" "operator" "emits"
#> [6] "consumes" "params" "when" "foreach" "foreach_max"
#> [11] "on_refusal"
draft <- '{
"id": "interactions",
"plan_version": 0.2,
"nodes": [
{"id": "terms", "tool": "term_table", "emits": "parameters"},
{"id": "flagged", "operator": "select", "emits": "parameters",
"consumes": [{"from": "terms", "contract": "parameters"}],
"params": {"rule": "order > 1 & p_value < 5e-2"}},
{"id": "check", "tool": "check_term",
"consumes": [{"from": "flagged", "contract": "parameters"}],
"foreach": {"from": "flagged", "column": "term"},
"on_refuse": "continue"}
]
}'
plan_validate(draft)[, c("node", "field", "fix")]
#> node field
#> 1 plan_version
#> 2 flagged params.rule
#> 3 check on_refuse
#> 4 check emits
#> fix
#> 1 write plan_version: "0.2" in quotes
#> 2 write numbers in plain decimal form: 0.05, 1000
#> 3 node fields are id, role, tool, operator, emits, consumes, params, when, foreach, foreach_max, on_refusal
#> 4 set emits to the inferential target its tool returnsEach row says what to change. With the version quoted, the number
written plainly, emits declared and the field name
corrected, the plan is valid, and plan_to_yaml() writes the
YAML a person reads.
fixed <- draft
fixed <- sub('"plan_version": 0.2', '"plan_version": "0.2"', fixed, fixed = TRUE)
fixed <- sub("5e-2", "0.05", fixed, fixed = TRUE)
fixed <- sub('"tool": "check_term",', '"tool": "check_term", "emits": "decisions",',
fixed, fixed = TRUE)
fixed <- sub('"on_refuse"', '"on_refusal"', fixed, fixed = TRUE)
nrow(plan_validate(fixed))
#> [1] 0
cat(plan_to_yaml(plan_from_json(fixed)))
#> id: "interactions"
#> plan_version: "0.2"
#> meta: []
#> nodes:
#> - id: "terms"
#> role: "compute"
#> operator: "compute"
#> emits: "parameters"
#> consumes: []
#> params: []
#> tool: "term_table"
#> - id: "flagged"
#> role: "compute"
#> operator: "select"
#> emits: "parameters"
#> consumes:
#> - from: "terms"
#> contract: "parameters"
#> params:
#> rule: "order > 1 & p_value < 0.05"
#> - id: "check"
#> role: "compute"
#> operator: "compute"
#> emits: "decisions"
#> consumes:
#> - from: "flagged"
#> contract: "parameters"
#> params: []
#> tool: "check_term"
#> foreach:
#> from: "flagged"
#> column: "term"An edge that declares the wrong contract is refused before the
downstream node runs. Here the decision node is told to expect
parameters from the consensus, which emits
decisions.
bad <- oap_plan(id = "mistyped", nodes = list(
node("lens_ols", "ols_lens", emits = "treatment_effects"),
node("decision", "recommend", emits = "decisions",
consumes = list(edge("lens_ols", "parameters")))
))
res <- try(conduct(bad, data = d, verbose = FALSE), silent = TRUE)
cat(conditionMessage(attr(res, "condition")))
#> TYPED-EDGE VIOLATION -- node 'decision' consumes 'lens_ols' as 'parameters', but 'lens_ols' emits inferential_target 'treatment_effects'. The step is not run.Giving conduct() an out_dir writes
run.rds, run_card.md and
provenance.md, the last carrying the chain of payload
hashes in execution order. run_provenance() returns the
same table in the session.
run_provenance(run_a)[, c("node", "operator", "emits", "abstained")]
#> node operator emits abstained
#> 1 lens_ols compute treatment_effects FALSE
#> 2 lens_rank compute treatment_effects FALSE
#> 3 gate abstain_gate decisions FALSE
#> 4 consensus triangulate decisions FALSE
#> 5 decision compute decisions FALSEThe same plan compiles to a _targets.R in which each
node is one target calling conduct_step(), so a
long-running plan inherits caching from the ‘targets’ package.