Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/distribird/agent/enrich.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,12 +98,17 @@ def enrich_parameter(
search_hints=raw.get("search_hints", []),
application_context=raw.get("application_context", ""),
context_keywords=raw.get("context_keywords", []),
is_recognized_parameter=raw.get("is_recognized_parameter"),
recognition_confidence=raw.get("recognition_confidence", "none"),
empirically_measured=raw.get("empirically_measured"),
)
logger.info(
"[LLM:enrich_param] param=%r terms=%r hints=%r",
"[LLM:enrich_param] param=%r terms=%r hints=%r recognized=%s empirical=%s",
parameter.name,
ctx.common_terminology,
ctx.search_hints,
ctx.is_recognized_parameter,
ctx.empirically_measured,
)
return ctx

Expand Down
27 changes: 22 additions & 5 deletions src/distribird/agent/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@
refine_search_node,
relevance_judge_node,
route_after_deliberation,
route_after_enrich,
route_after_quality_gate,
search_node,
synthesize_node,
validity_check_node,
)
from distribird.agent.state import IterationBudget, PipelineState, QualityMetrics
from distribird.config import Settings, get_settings
from distribird.models import ParameterInput, PipelineResult
from distribird.models import ParameterInput, ParameterValidity, PipelineResult

ProgressCallback = Callable[[str, dict[str, Any]], None] | None

Expand All @@ -37,7 +39,8 @@
"fetch_fulltext": ("Fetching full-text papers", 0.10),
"extract": ("Extracting numerical values", 0.20),
"quality_gate": ("Evaluating extraction quality", 0.05),
"synthesize": ("Fitting prior distribution", 0.15),
"synthesize": ("Fitting prior distribution", 0.13),
"validity_check": ("Validating parameter", 0.02),
# Loop nodes — zero weight so progress never goes backwards
"refine_search": ("Refining search strategy", 0.0),
"refine_extraction": ("Refining value extraction", 0.0),
Expand All @@ -57,6 +60,7 @@ def build_pipeline_graph() -> StateGraph: # type: ignore[type-arg]
graph.add_node("extract", extract_node)
graph.add_node("quality_gate", quality_gate_node)
graph.add_node("synthesize", synthesize_node)
graph.add_node("validity_check", validity_check_node)

# Feedback loop nodes
graph.add_node("refine_search", refine_search_node)
Expand All @@ -65,7 +69,15 @@ def build_pipeline_graph() -> StateGraph: # type: ignore[type-arg]

# Main flow edges
graph.add_edge(START, "enrich")
graph.add_edge("enrich", "query_gen")

# After enrich: short-circuit to validity_check if the LLM did not
# recognize the parameter — saves all downstream search/extract/synthesize
# work for clearly-invalid requests.
graph.add_conditional_edges(
"enrich",
route_after_enrich,
{"query_gen": "query_gen", "validity_check": "validity_check"},
)
graph.add_edge("query_gen", "search")

# After search → relevance judge → conditional: cross-enrich or fetch fulltext
Expand Down Expand Up @@ -102,8 +114,9 @@ def build_pipeline_graph() -> StateGraph: # type: ignore[type-arg]
# Loop C: refine_extraction → quality_gate
graph.add_edge("refine_extraction", "quality_gate")

# Synthesize → END
graph.add_edge("synthesize", END)
# Synthesize → validity_check → END
graph.add_edge("synthesize", "validity_check")
graph.add_edge("validity_check", END)

return graph

Expand Down Expand Up @@ -179,4 +192,8 @@ async def run_parameter_graph(
warnings=final_state.get("warnings", []),
enrichment=final_state.get("enrichment"),
deliberation=final_state.get("deliberation"),
parameter_validity=final_state.get("parameter_validity", ParameterValidity.UNKNOWN),
validity_reason=final_state.get("validity_reason", ""),
validity_signals=final_state.get("validity_signals", {}),
is_empirical=final_state.get("is_empirical"),
)
122 changes: 122 additions & 0 deletions src/distribird/agent/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,101 @@ async def synthesize_node(state: PipelineState) -> dict[str, object]:
}


async def validity_check_node(state: PipelineState) -> dict[str, object]:
"""Classify whether the requested parameter is a real, empirically-measured quantity.

Runs after synthesize. Uses passive heuristics first; optionally runs a single
LLM probe for ambiguous (SUSPICIOUS) cases when budget allows.
"""
t0 = time.time()
settings = _settings_from_state(state)
warnings = list(state.get("warnings", []))
traces = list(state.get("trace_events", []))

from distribird.agent.validity import (
apply_probe_verdict,
classify_validity_passive,
validity_probe_llm,
)
from distribird.models import ParameterValidity

if not settings.enable_validity_check:
traces.append(_trace("validity_check", t0, {"skipped": True}))
return {
"parameter_validity": ParameterValidity.UNKNOWN,
"validity_reason": "",
"validity_signals": {},
"is_empirical": None,
"warnings": warnings,
"trace_events": traces,
}

enrichment = state.get("enrichment")
prior = state.get("prior")
parameter = state["parameter"]
papers = state.get("all_papers", [])
queries = state.get("all_queries_tried", []) or state.get("search_queries", [])
quality = state.get("quality") or update_quality(state)

verdict, reason, signals, is_empirical = classify_validity_passive(
enrichment=enrichment,
prior=prior,
papers_found=len(papers),
values_extracted=quality.n_total_values,
queries_tried=len(queries),
)

probe_called = False
budget = state.get("budget", IterationBudget())

if (
verdict == ParameterValidity.SUSPICIOUS
and settings.enable_validity_probe
and budget.has_budget()
):
probe_result = validity_probe_llm(
parameter=parameter,
enrichment=enrichment,
signals=signals,
settings=settings,
)
probe_called = True
budget.consume_llm_call()
verdict, reason, is_empirical = apply_probe_verdict(
passive_verdict=verdict,
passive_reason=reason,
passive_is_empirical=is_empirical,
probe_result=probe_result,
)
signals["probe_result"] = probe_result

if verdict in (ParameterValidity.LIKELY_INVALID, ParameterValidity.SUSPICIOUS):
label = verdict.value.replace("_", " ").upper()
warnings.append(f"Parameter validity: {label} — {reason}")

traces.append(
_trace(
"validity_check",
t0,
{
"verdict": verdict.value,
"probe_called": probe_called,
"is_empirical": is_empirical,
},
)
)

return {
"parameter_validity": verdict,
"validity_reason": reason,
"validity_signals": signals,
"is_empirical": is_empirical,
"warnings": warnings,
"budget": budget,
"trace_events": traces,
}


# ---------------------------------------------------------------------------
# Feedback loop nodes
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -650,6 +745,33 @@ def route_after_deliberation(state: PipelineState) -> str:
return "fetch_fulltext"


def route_after_enrich(state: PipelineState) -> str:
"""Decide whether to run the full pipeline or short-circuit to validity_check.

The full search/extract/synthesize work is expensive (LLM calls, paper fetches,
PyMC fitting). When the enrichment LLM clearly does not recognize the
parameter as a real scientific quantity (is_recognized_parameter=False with
none/low confidence), there is no plausible path to a literature-backed
prior, so we route directly to the terminal validity_check node — which
will classify as LIKELY_INVALID without consulting empty paper/value sets.
"""
settings = _settings_from_state(state)
if not settings.enable_validity_check:
return "query_gen"

enrichment = state.get("enrichment")
if enrichment is None:
return "query_gen"

if enrichment.is_recognized_parameter is False and enrichment.recognition_confidence in {
"none",
"low",
}:
return "validity_check"

return "query_gen"


def route_after_quality_gate(state: PipelineState) -> str:
"""Route after quality gate: synthesize, refine search, or refine extraction."""
quality = state.get("quality", QualityMetrics())
Expand Down
3 changes: 3 additions & 0 deletions src/distribird/agent/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from distribird.models import (
BatchResult,
ParameterInput,
ParameterValidity,
PipelineResult,
)

Expand Down Expand Up @@ -132,6 +133,8 @@ def _on_node(node_name: str, state: dict[str, Any]) -> None:
parameter=param,
prior=fallback_prior,
warnings=[f"Pipeline error: {e}"],
parameter_validity=ParameterValidity.UNKNOWN,
validity_reason=f"Pipeline error prevented validity check: {e}",
)

results = await asyncio.gather(*[_run_one(p) for p in parameters])
Expand Down
62 changes: 60 additions & 2 deletions src/distribird/agent/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@
- "search_hints": [string, ...] (3-5 short keyword phrases optimized for literature search)
- "application_context": string (extract ALL specific context from domain context that narrows down which literature is most relevant — this could be geographic region, climate zone, species/cultivar, soil type, management practice, experimental conditions, etc. Combine into a concise phrase. Return empty string if domain context is purely generic.)
- "context_keywords": [string, ...] (3-6 keywords/phrases capturing the user's specific application context, useful for finding the most relevant papers. Include geographic terms, species names, condition descriptors, nearby regions with similar conditions, etc. Return empty array if domain context is purely generic.)
- "is_recognized_parameter": boolean (TRUE if you recognize this as an established scientific parameter that appears in the literature. FALSE if the name looks fabricated, contains non-standard suffixes like '_xyz' or 'mumblesnort', or you have never encountered it. Misspellings of real parameters should still be TRUE with low recognition_confidence.)
- "recognition_confidence": "high" | "medium" | "low" | "none" (your confidence in the recognition. Use "high" only for well-established named parameters; "none" for unrecognized strings.)
- "empirically_measured": boolean (TRUE if this parameter is empirically measured in laboratory or field experiments. FALSE if it is purely theoretical, model-internal, or a calibration weight that cannot be directly measured. When in doubt about a model-specific parameter, prefer FALSE over leaving null/uncertain.)

Red flags that strongly suggest empirically_measured=false:
- Version suffixes in the name (_v1, _v2, _v3, _2024, _r5)
- Software/model identifiers as a prefix (biome_bgcmuso_, dssat_, ctsm_, swat_)
- Words like "calibration", "weight", "tuning", "fitted", "latent", "state", "covariance", "scaling_factor"
- Internal configuration constants of a specific software version

IMPORTANT: Carefully analyze the domain context to extract ALL specifics that affect \
which literature is most relevant. Parameter values in scientific literature vary by:
Expand All @@ -56,15 +65,64 @@

Return ONLY the JSON object, no other text.

Example — Parameter: "allocation_ratio_root_leaf", Domain: "Biome-BGCMuSo maize crop modeling in Hungary"
Example 1 — Parameter: "allocation_ratio_root_leaf", Domain: "Biome-BGCMuSo maize crop modeling in Hungary"
{{
"parameter_meaning": "The fraction of assimilated carbon allocated to roots relative to leaves, controlling belowground vs. aboveground biomass partitioning.",
"common_terminology": ["root:shoot ratio", "carbon partitioning", "belowground allocation fraction", "root biomass allocation", "assimilate partitioning"],
"typical_range": "0.5-2.0 (dimensionless ratio) for maize, varying with growth stage; in Central European continental conditions typically 0.6-1.5",
"enriched_description": "Root to leaf carbon allocation ratio in maize, controlling the partitioning of photosynthetic assimilates between belowground (root) and aboveground (leaf) biomass",
"search_hints": ["maize root shoot ratio", "carbon partitioning maize roots", "biomass allocation cereal crops", "root fraction crop model", "maize Hungary Central Europe"],
"application_context": "Hungary, Central Europe, Pannonian continental climate, maize",
"context_keywords": ["Hungary", "Central Europe", "continental climate", "Pannonian", "maize", "Carpathian Basin"]
"context_keywords": ["Hungary", "Central Europe", "continental climate", "Pannonian", "maize", "Carpathian Basin"],
"is_recognized_parameter": true,
"recognition_confidence": "high",
"empirically_measured": true
}}

Example 2 — Parameter: "biome_bgcmuso_root_decomp_q10_v3", Domain: "Biome-BGCMuSo maize crop modeling"
{{
"parameter_meaning": "An internal calibration weight in Biome-BGCMuSo v3 controlling the temperature sensitivity (Q10) of root decomposition. This is a software-version-specific tuning parameter, not a measurable physical quantity.",
"common_terminology": ["Q10 temperature sensitivity", "root decomposition rate", "model calibration parameter"],
"typical_range": "Software-specific calibration; not a measurable quantity",
"enriched_description": "Q10 temperature sensitivity coefficient for root organic matter decomposition in soil",
"search_hints": ["Q10 root decomposition temperature sensitivity", "soil organic matter decomposition Q10"],
"application_context": "Biome-BGCMuSo crop modeling",
"context_keywords": ["Biome-BGCMuSo", "soil decomposition", "temperature sensitivity"],
"is_recognized_parameter": true,
"recognition_confidence": "medium",
"empirically_measured": false
}}
"""

PARAMETER_VALIDITY_PROBE = """\
You are a strict scientific parameter validator. Decide whether the following parameter request \
refers to a real, empirically-measured scientific quantity that should appear in peer-reviewed literature.

Classify into exactly one of three verdicts:
- "valid": A real, empirically-measured scientific parameter (e.g., specific_leaf_area, hubble_constant, soil_porosity).
- "suspicious": Either (a) plausible-sounding but not standard terminology, or (b) a real concept that is theoretical / model-internal only and never empirically measured (e.g., calibration weights, latent variables in a specific model version).
- "likely_invalid": Nonsense, fabricated, or pure jargon with no scientific reality (e.g., "mumblesnort_factor", "fake_quantum_correction_xyz").

Parameter name: {name}
Description: {description}
Domain context: {domain_context}

Enrichment LLM reported:
- recognized as known parameter: {is_recognized}
- recognition confidence: {recognition_confidence}
- empirically measured: {empirically_measured}
- common terminology found: {terminology}

Pipeline observations:
- queries tried: {n_queries}
- papers found: {papers_found}
- numerical values extracted: {values_extracted}

Return ONLY a JSON object:
{{
"verdict": "valid" | "suspicious" | "likely_invalid",
"is_empirical": true | false,
"reason": "<one concise sentence explaining the verdict>"
}}
"""

Expand Down
10 changes: 10 additions & 0 deletions src/distribird/agent/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
FittedPrior,
LiteratureEvidence,
ParameterInput,
ParameterValidity,
)

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -89,6 +90,9 @@ def can_refine_extraction(self) -> bool:
def has_budget(self) -> bool:
return self.total_llm_calls_used < self.total_llm_calls_max

def consume_llm_call(self, count: int = 1) -> None:
self.total_llm_calls_used += count


class TraceEvent(BaseModel):
node: str
Expand Down Expand Up @@ -130,6 +134,12 @@ class PipelineState(TypedDict, total=False):
# Observability
trace_events: list[TraceEvent]

# Validity verdict (set by validity_check node)
parameter_validity: ParameterValidity
validity_reason: str
validity_signals: dict[str, object]
is_empirical: bool | None


# ---------------------------------------------------------------------------
# Helper functions for manipulating state
Expand Down
Loading
Loading