Solve: a problem at a place, planned first, checked at every step¶
aquascope ask answers a question. aquascope solve takes a problem at a
location ("design flow for a road crossing, 100-year return period", "what
flow can this ungauged stream give an irrigation scheme", "is the water table
under this well falling") and goes from there to a verified answer through a
plan you see before anything runs. The design contract is in
solve-design.md; this page is the user's guide.
The flow¶
intake ──► recon ──► plan ──► review ──► execute ──► report
(the text, (assess_ (a playbook (you see (aquascope (answer + what it
--intake) site) branch) the plan) run, a gate does not establish
per step, + study.yaml)
replan once)
- Intake. The problem text and the coordinates. A playbook's intake
fields (return period, what is being decided, ...) are read off the text
where they are stated, or given with
--intake key=value. - Recon.
assess_site(lat, lon)says which records exist within reach, for how long, at what resolution, what the catchment looks like and how many donor gauges there are, and grades every method in the registry (aquascope.methods) as defensible, marginal or not defensible here. - Plan. The playbook's decision tree picks a branch for the data that exists and fills a study (version 2): steps with arguments, a rationale each, the gates each must pass, and a fallback for when a gate fails. No model is needed for this.
- Review. The CLI prints the plan as a numbered checklist and asks
y/N; the API gives it to a callback that may edit it or decline it. - Execute.
run_studyruns the steps in order and evaluates the gates after each one. A failed gate runs the step's fallback once; if that fails too the step is recorded as not established and the run goes on (the steps that depend on it are skipped with the reason; only an explicitfallback: stopends the run). When a model is present, a Specialist may propose one more fallback step for each failed step; the recovery is bounded per step (max_replans=1). - Report. The answer, the plan and its rationale, every step with its
gate outcomes, "what this answer does not establish", the playbook's
caveats verbatim, Data and Methods assembled from the tool results, and
the executed study, which
aquascope run study.yamlreproduces with no model at all.
A CLI example¶
aquascope playbooks # the playbooks and their branches
aquascope playbooks show flood_risk # intake, branches, gates, declines, caveats
aquascope solve "Design flow for a road crossing, 100-year return period" \
--lat 51.415 --lon -0.308 --playbook flood_risk \
--out kingston.md --study kingston.yaml
The plan printed for the Thames at Kingston (39 years of daily discharge,
so the at_site branch):
Plan: playbook flood_risk, branch at_site, 3 step(s)
39.5 years of daily discharge at Kingston (uk_ea 3400TH, 0.4 km from the point): an at-site
frequency fit is defensible. ...
1. describe_catchment(lat=51.415, lon=-0.308)
2. analyze_station(source='uk_ea', station_id='3400TH')
gate min_years 20 on years
gate not_empty on trend
3. flood_frequency(source='uk_ea', station_id='3400TH', bootstrap_ci=True)
gate max_return_period_factor 3 on years
gate ci_finite on ffa.fits.gev_bootstrap.ci
gate spread_within 0.25 on ffa.fits.gev_lmoments.q, ffa.fits.lp3.q
fallback: similar_basins
Run this plan? [y/N]
--yes skips the question (scripts, notebooks). --intake return_period=200
sets an intake field. --quiet hides the timeline. The report lands in
--out, the executed study in --study; aquascope run kingston.yaml
re-runs it and prints the same gate outcomes.
Without --lat/--lon, aquascope solve is the older challenge agent over a
data file, unchanged.
The playbooks¶
A playbook is a YAML file under aquascope/playbooks/: data, not code. Each
has intake fields, branches over the reconnaissance (first match wins),
study-v2 steps with gates and fallbacks, the sentences it prints when it
declines, caveats printed verbatim in every report, and citations. The
thresholds (20 years, T at most three times the record, three donors, the
10,000 km2 ceiling for a lumped model) are the registry's, not the file's:
a step that names its method is checked against aquascope.methods when
the plan is filled, so a method the registry calls not defensible at this
site is refused before anything runs.
| Playbook | Branches | Declines |
|---|---|---|
flood_risk |
at_site (20+ years of discharge: Mann-Kendall pre-test, GEV by L-moments and Log-Pearson III with a bootstrap band, the spread quoted; a stationary estimate with a climate caveat, never a nonstationary fit), short_record (8 to 20 years: marginal at-site numbers next to donors and regionalised signatures), regional (no gauge: donors, signatures, a GloFAS cross-check) |
a return period beyond about three times the record with fewer than three donors; inundation extent (out of scope) |
ungauged_flow |
at_gauge (a gauge with 5+ years nearby: its flow-duration curve beside the regionalised signatures for the point), regional (donors, signatures with the band and the leave-one-out skill, GloFAS) |
fewer than three donor gauges |
groundwater_decline |
well (10+ years of levels: Sen's slope with Mann-Kendall, the Standardised Groundwater Index, water-table-fluctuation recharge with a stated specific yield), regional (no well: the ERA5 water balance for the cell, labelled regional) |
attributing the cause without pumping data |
drought_status |
gauge_indices (a rain gauge with 30+ years and ERA5 temperature reachable: SPI and SPEI at the intake timescales, default 1, 3 and 12 months, the divergence between them and the temperature trend behind it), gauge_indices_marginal (20 to 30 years, classes indicative), gauge_spi_only (no temperature: SPI with the caveat that SPEI is preferable under warming), reanalysis (no rain gauge: both indices from the ERA5 cell over 40 years); every branch adds, when the record exists, the low-flow context at the nearest discharge gauge and the SGI at the nearest well with the SPI-to-SGI propagation lag in months |
a flash-drought question (no sub-monthly index; this playbook sees monthly droughts) |
supply_reliability |
gauged (10+ years of discharge: Q95, Q50, Q10, baseflow index and 7Q10, then the screening rule on the daily record, Q95 kept in the river and at most the abstraction share taken, as the fraction of days, of years without a shortfall and of the volume the demand is met), gauged_short (5 to 10 years: the same, marginal, with the regional estimate beside it), regional (no gauge: donors, Q95, median and Q05 transferred with band and skill over the upstream area, the reliability read off them as a band) |
a scheme with a reservoir (a storage-yield analysis it does not do); a missing demand; fewer than three donors |
irrigation_feasibility |
with_gauge (ERA5 climate of the point, the crop's seasonal demand from FAO-56 single Kc on ERA5 ET0 averaged over the seasons in the window, then the supply screening at the gauge within reach on the peak-month demand over the season's months, the demand read from the previous step's result), demand_only (no gauge: the demand, with the note that supply was not checked) |
a day-by-day schedule (needs soil parameters and the season's weather) |
water_quality |
drinking (a station with sampled parameters within reach: the last five years of samples, the WHO 2022 screen, the CCME WQI 1.0 against the WHO guidelines and the NSF WQI when its nine parameters are present), irrigation (the samples, the CCME index against FAO 29 thresholds, and SAR, sodium percentage, RSC with the FAO degree of restriction), aquatic_life (the samples and the CCME index against the CCME freshwater guidelines) |
no sampled parameters within reach (the archive carries no water-quality variables until Phase 3, #188; samples are fetched from USGS and the Water Quality Portal in the US); a health verdict beyond the sampled parameters |
Writing a playbook¶
Copy one of the three and keep to the block-style YAML subset the browser
worker reads (nested mappings and lists, quoted or bare scalars, >- block
text; no anchors). The schema:
id: my_problem # the file name without .yaml
title: ...
problem: my_problem # the problem kind the registry knows
variable: discharge # picks the station for {{ station.* }}
intake: # types: int, float, str, bool, choice (with options), list
- {name: return_period, label: Return period (years), type: int, default: 100}
- {name: timescales, label: Timescales (months), type: list, default: [1, 3, 12]}
branches: # first match wins; conditions over the recon dict
- id: at_site
when: [{path: context.years_by_variable.discharge, op: ">=", value: 20}]
station_variable: discharge
rationale: >- # placeholders: {{ intake.x }}, {{ station.source }},
... # {{ station.station_id }}, {{ site.lat }}, {{ site.lon }}, {{ derived.x }}
steps:
- id: s1
tool: analyze_station # any Analyst tool, workbench analysis, or assess_site
method: at_site_flood_frequency # optional: checked against aquascope.methods at plan time
optional: false # true: dropped with a note when the registry says not defensible
arguments: {source: "{{ station.source }}", station_id: "{{ station.station_id }}"}
expects: # gates, see aquascope.gates.CHECKS
- {check: min_years, value: 20, path: years}
fallback: {step: {tool: similar_basins, arguments: {...}}} # or {branch: regional} or stop
depends_on: []
- id: s2
tool: drought_propagation
station_variable: groundwater_level # this step's {{ station.* }} is the nearest well, not the branch's gauge
optional: true
arguments: {source: "{{ station.source }}", station_id: "{{ station.station_id }}", lat: "{{ site.lat }}", lon: "{{ site.lon }}"}
- id: s3
tool: supply_reliability
depends_on: [s1]
arguments: {source: "{{ station.source }}", station_id: "{{ station.station_id }}",
demand_m3s: "{{ result.s1.demand.peak_month_m3s }}"} # a number an earlier step computed; the runner fills it
declines:
- {when: [...], say: "the sentence printed verbatim"}
caveats: ["always", {say: "only when", when: [...]}]
citations: ["..."]
Conditions take ==, !=, >=, <=, >, <, in, exists, and are
evaluated over the recon dict extended with intake, station, site and
derived (discharge_years, groundwater_years, precipitation_years,
has_temperature, donors, dams, return_period_cap,
return_period_beyond_cap, area_km2). A workbench step takes
from_step: s2 to run on the series a previous get_timeseries step
fetched; a step that needs a number an earlier step computed writes
{{ result.s2.demand.peak_month_m3s }} (a dotted path into that step's
payload), which the plan leaves in place and the runner fills, and lists the
step in depends_on. The site-level tools a playbook can call with what a
plan knows (drought_indices, drought_propagation, low_flow_context,
supply_reliability, crop_water_demand) live in aquascope/problems.py.
aquascope.playbooks.validate("my_problem") lists every authoring mistake;
tests/test_playbooks.py shows the fixtures (gauged long, gauged short,
rain gauge with and without temperature, well, ungauged) a new playbook
should be exercised on.
What keyless gives, what a key adds¶
With no key, no role calls a model: the Scout runs the reconnaissance, the tree fills the plan, the Reviewer evaluates the gates and the deterministic checks, and a template Narrator writes the answer from the results. That is a complete, reproducible run, and it is what the MCP tools and the browser do by default.
With --provider (or --model, --api-key, --base-url), three roles use
the model, each as a stateless subcall that sees only its own inputs, never a
transcript: the Coordinator writes a one-paragraph rationale for the plan
(and settles a problem the keyword rules cannot place), the Specialist
proposes one fallback step after a failed gate, and the Narrator writes the
prose under the analyst's rules (units, records named, no invented numbers).
The report's footer counts the calls and tokens per role. solve is keyless
unless you ask for a model, even when a key is in the environment.
The honesty rules¶
- Every number in the report comes from a tool result; the gates checked it
before it was quoted, and the deterministic checks of
aquascope ask(numbers present in the results, units named, records named, intervals with return levels, significance wording matching the test) run on the prose as well. What fails is listed under "what this answer does not establish", never hidden. - A playbook that declines prints its own sentence and stops. So does a plan whose method the registry calls not defensible at this site (the GR4J on a 100,000 km2 catchment of #273 is refused before it runs).
- The caveats are the playbook's, verbatim, in every report: design-flood guidance under climate change is immature (Wasko et al. 2024), a transferred number without its band and skill is not an estimate, a trend says whether a level is changing and never why.
- The executed study is the answer's receipt and its plan at once:
aquascope run study.yamlreproduces it with no model in the loop.
Measuring an agent on it¶
The playbooks double as a benchmark: AquaScope Bench generates
tasks (a problem at a real site, the tree's branch, gates or decline as the
key, unsolvable tasks included) and scores the plan-first team, the older
ask loop, or any agent against them (aquascope gym tasks | bench |
leaderboard).
Using the team from LangGraph or your own orchestrator¶
The roles are plain functions with no framework between them: the Scout is
aquascope.explore.assess_site, the Coordinator and the tree are
aquascope.ai_engine.team.solve(execute=False) (or aquascope.playbooks.plan
when you already hold the recon), the review is whatever you put there, and
the runner with its gates, the Reviewer and the Narrator are
aquascope.ai_engine.team.run_reviewed. The browser runs these same functions
in a worker with no agent framework at all, which is why the package does not
depend on one; it also means they drop into the orchestrator your stack
already has.
examples/langgraph_team.py is that mapping for LangGraph, one node per role:
| Team role | Graph node | Calls |
|---|---|---|
| Scout | scout |
assess_site(lat, lon, problem=...) |
| Coordinator (tree, keyword rules) | plan |
solve(..., recon=recon, execute=False) |
| You | review |
LangGraph's interrupt() with the plan; resume with the study (edited or not) or None to decline |
| Runner, Reviewer, Narrator | run |
run_reviewed(study, recon=recon) |
| Report | report |
the Markdown report, or the decline in the playbook's words |
A declined plan (a decline rule, no branch, a refused method) skips the review and the run and goes to the report. The graph needs a checkpointer for the interrupt to pause; the example uses the in-memory one. Run it keyless:
pip install langgraph langchain-core # not aquascope dependencies
python examples/langgraph_team.py --lat 51.415 --lon -0.308 --playbook flood_risk
The same mapping holds for CrewAI-style roles: a scout, a planner, a reviewer and a runner agent whose tools are these four functions, in that order, with the study as the artefact each hands to the next.
The other faces¶
Over MCP (aquascope mcp): list_playbooks(), describe_playbook(id),
solve_plan(problem, lat, lon, playbook, intake) returns the study to
review, solve_run(study) executes it. The Analyst (aquascope ask) has
the same four tools, so a question that turns out to be a problem at a place
can be handed to Solve. In Python:
from aquascope.ai_engine.team import solve
result = solve("Design flow for a road crossing, 100-year return period",
lat=51.415, lon=-0.308, playbook="flood_risk",
review=lambda study: study) # or edit it, or return None
print(result.answer)
print(result.to_markdown())
open("study.yaml", "w").write(result.study_yaml) # aquascope run study.yaml