From b00a0e212702a529cd987bc08603a09a796a94d3 Mon Sep 17 00:00:00 2001 From: gautam8387 Date: Mon, 31 Aug 2026 22:39:29 +0200 Subject: [PATCH 01/21] analysis fix; context fix --- scarf/agent/biological_interpretation.py | 282 +++++++++++++--- scarf/agent/config/__init__.py | 4 +- scarf/agent/config/agent_exec.py | 18 +- scarf/agent/data_enrichment.py | 244 +++++++++++--- scarf/agent/experimental_context.py | 304 +++++++++++++++--- scarf/agent/orchestrator/context.py | 59 +++- scarf/agent/orchestrator/journal.py | 19 ++ scarf/agent/parameter_tuning.py | 104 ++++-- tests/test_agent_biological_interpretation.py | 89 ++++- tests/test_agent_data_enrichment.py | 97 ++++++ tests/test_agent_experimental_context.py | 59 +++- tests/test_agent_orchestrator_lifecycle.py | 114 +++++++ tests/test_agent_orchestrator_stages.py | 135 ++++++++ tests/test_agent_parameter_tuning.py | 52 +++ 14 files changed, 1393 insertions(+), 187 deletions(-) diff --git a/scarf/agent/biological_interpretation.py b/scarf/agent/biological_interpretation.py index d0b731a3..98cd0fc3 100644 --- a/scarf/agent/biological_interpretation.py +++ b/scarf/agent/biological_interpretation.py @@ -26,7 +26,13 @@ from ..utils.logging import logger try: - from pydantic_ai import ModelRetry, RunContext, Tool + from pydantic_ai import ( + ModelRetry, + RunContext, + Tool, + UnexpectedModelBehavior, + UsageLimitExceeded, + ) from pydantic_ai.tools import ToolDefinition except ImportError as exc: from .config._deps import AGENT_INSTALL_HINT @@ -383,6 +389,19 @@ class BiologicalInterpretationDependencies(AgentDataModel): evidenceIds: set[str] = Field(default_factory=set, exclude=True) clusterValues: dict[str, Any] = Field(default_factory=dict, exclude=True) markerEvidenceIds: dict[str, str] = Field(default_factory=dict, exclude=True) + markerEvidence: dict[str, ClusterMarkerEvidence] = Field( + default_factory=dict, + exclude=True, + ) + compositionEvidence: ClusterCompositionEvidence | None = Field( + default=None, + exclude=True, + ) + markerBatch: ClusterMarkerBatchEvidence | None = Field( + default=None, + exclude=True, + ) + markerBatchClusterIds: list[str] = Field(default_factory=list, exclude=True) toolCalls: list[str] = Field(default_factory=list, exclude=True) conditionEvidence: dict[str, ConditionClusterSummary] = Field( default_factory=dict, @@ -433,13 +452,16 @@ def _prepare_biological_interpretation_tool( Tool calls execute Scarf operations, so wait for their results before drawing conclusions. Do not split marker inspection across calls. - Treat cell identities as hypotheses unless the caller supplied a trusted - label. Do not invent genes, cell types, statistics, artifact identifiers, - or evidence identifiers. Cite only evidenceIds returned by tools. For each - cluster interpretation, copy the exact non-empty marker evidenceId returned - for that cluster into its evidenceIds. Do not interpret a cluster whose - marker evidenceId is empty. Cluster abundance summaries are descriptive, - not tests of significance or causal effects. Treatment observations must + This API does not provide trusted per-cluster identities. Treat every cell + identity as a hypothesis and always set identityIsHypothesis=true. Caller + cell-type references are context, not assignments to clusters. Prefer + proposedIdentity="unresolved" when the returned markers do not ground a + specific hypothesis. Do not invent genes, cell types, statistics, artifact + identifiers, or evidence identifiers. Cite only evidenceIds returned by + tools. For each cluster interpretation, copy the exact non-empty marker + evidenceId returned for that cluster into its evidenceIds. Do not interpret + a cluster whose marker evidenceId is empty. Cluster abundance summaries are + descriptive, not tests of significance or causal effects. Treatment observations must compare two returned independent-unit condition summaries for the same cluster. Independent units may occur in more than one condition in paired or repeated-measure designs. Return treatmentObservations empty unless the @@ -448,10 +470,12 @@ def _prepare_biological_interpretation_tool( independent units in each cited condition. Marker p-values describe cluster-versus-rest marker specificity, not condition effects. Keep treatment content out of cluster identity - interpretations. Recommend a named follow-up operation when replication, a - covariate, or an exact artifact is missing. Do not write exploratory code, - use a shell, access files, or call arbitrary Scarf methods. Return only - fields defined by the structured output schema. + interpretations. Never return status=failed for uncertainty or weak + evidence. Return needsInput with one concrete question instead. Recommend a + named follow-up operation when replication, a covariate, or an exact + artifact is missing. Do not write exploratory code, use a shell, access + files, or call arbitrary Scarf methods. Return only fields defined by the + structured output schema. """ ).strip() @@ -480,6 +504,9 @@ async def inspect_cluster_composition( ) -> ClusterCompositionEvidence: """Inspect bounded cluster and condition composition without identifiers.""" deps = ctx.deps + if deps.compositionEvidence is not None: + logger.info("Reused completed cluster composition inspection") + return deps.compositionEvidence logger.info( f"Inspecting cluster composition from artifact " f"{getattr(deps.cluster, 'artifact_id', '')!r}; " @@ -638,6 +665,7 @@ async def inspect_cluster_composition( evidenceIds=sorted(deps.evidenceIds), warnings=warnings, ) + deps.compositionEvidence = evidence logger.info( f"Completed cluster composition inspection: cells={evidence.totalCells}, " f"clusters={len(evidence.clusterCounts)}, " @@ -740,6 +768,10 @@ async def inspect_cluster_markers( raise ModelRetry("Call inspect_cluster_composition before inspecting markers.") if cluster_id not in deps.clusterValues: raise ModelRetry(f"cluster_id must be one of {sorted(deps.clusterValues)}") + cached = deps.markerEvidence.get(cluster_id) + if cached is not None: + logger.debug(f"Reused cached markers for cluster {cluster_id!r}") + return cached if deps.marker is None: if not deps.allowMarkerSearch: logger.warning( @@ -829,6 +861,7 @@ async def inspect_cluster_markers( evidenceId=evidence_id if markers else "", warnings=[] if markers else ["No markers passed the requested thresholds."], ) + deps.markerEvidence[cluster_id] = evidence logger.debug( f"Completed marker inspection for cluster {cluster_id!r}: " f"markers={len(markers)}" @@ -851,6 +884,14 @@ async def inspect_cluster_markers_batch( ) if len(set(cluster_ids)) != len(cluster_ids): raise ModelRetry("cluster_ids must not contain duplicates") + if ctx.deps.markerBatch is not None: + if cluster_ids != ctx.deps.markerBatchClusterIds: + raise ModelRetry( + "Marker inspection already completed. Use the returned evidence " + "and do not request a different cluster batch." + ) + logger.info("Reused completed cluster marker batch") + return ctx.deps.markerBatch logger.info(f"Inspecting markers for {len(cluster_ids)} cluster(s) in one batch") clusters = [ @@ -869,6 +910,8 @@ async def inspect_cluster_markers_batch( evidenceIds=evidence_ids, warnings=warnings, ) + ctx.deps.markerBatch = evidence + ctx.deps.markerBatchClusterIds = list(cluster_ids) logger.info( f"Completed marker batch inspection: clusters={len(clusters)}, " f"clusters_with_markers={sum(bool(cluster.markers) for cluster in clusters)}, " @@ -915,9 +958,9 @@ def _canonicalize_cluster_interpretations( interpretation.model_copy( update={ "evidenceIds": [marker_id], + "identityIsHypothesis": True, **( { - "identityIsHypothesis": True, "confidence": "low", } if deps.markerAssayType == "ATAC" @@ -1049,6 +1092,17 @@ def validate_biological_interpretation_report( """Reject invented evidence, clusters, or completed marker-free reviews.""" if not deps.clusterValues: raise ModelRetry("Call inspect_cluster_composition before returning a report.") + if report.status == "failed": + raise ModelRetry( + "Do not return failed for biological uncertainty; return needsInput " + "with one concrete question instead." + ) + if report.status == "needsInput" and ( + report.needsInput is None or not report.needsInput.question.strip() + ): + raise ModelRetry("A needsInput report requires one concrete input question.") + if report.status != "needsInput" and report.needsInput is not None: + raise ModelRetry("Only a needsInput report may include an input question.") expected_cluster_artifact = artifact_reference(deps.cluster) if ( report.clusterArtifact is not None @@ -1148,6 +1202,91 @@ def validate_biological_interpretation_report( return validated +def fallback_biological_interpretation_report( + deps: BiologicalInterpretationDependencies, + *, + error: UnexpectedModelBehavior | UsageLimitExceeded, + model_name: str, +) -> BiologicalInterpretationReport: + """Return exact unresolved identities when structured interpretation fails.""" + if not deps.clusterValues: + raise error + error_detail = str(error).replace("\n", " ").strip()[:500] + interpretations = [ + ClusterInterpretation( + clusterId=cluster_id, + proposedIdentity="unresolved", + identityIsHypothesis=True, + confidence="low", + rationale=( + "Exact marker evidence was available, but structured biological " + "interpretation was unavailable." + ), + evidenceIds=[evidence_id], + ) + for cluster_id, evidence_id in sorted(deps.markerEvidenceIds.items()) + ] + if interpretations: + report = BiologicalInterpretationReport( + status="done", + clusterInterpretations=interpretations, + evidenceIds=[ + evidence_id + for interpretation in interpretations + for evidence_id in interpretation.evidenceIds + ], + limitations=[ + "Cluster identities remain unresolved because structured model " + "interpretation exhausted its bounded correction budget.", + "No treatment observations were generated by the fallback.", + error_detail, + ], + stopReason=( + "Exact marker-bearing clusters were retained as unresolved " + "low-confidence hypotheses." + ), + runInfo=AgentRunInfo( + agentName="biological_interpretation_fallback", + modelName=model_name, + ), + ) + else: + composition_evidence = sorted( + evidence_id + for evidence_id in deps.evidenceIds + if evidence_id.startswith("composition:") + ) + report = BiologicalInterpretationReport( + status="needsInput", + evidenceIds=composition_evidence, + limitations=[ + "No non-empty marker evidence was available for a grounded cluster " + "interpretation.", + error_detail, + ], + stopReason="Biological interpretation requires marker evidence.", + needsInput=BiologicalInterpretationNeedsInput( + question=( + "Provide an exact marker artifact with non-empty cluster markers " + "or revise the authorized marker thresholds." + ), + requiredInputs=["markerArtifactOrThresholds"], + evidenceIds=composition_evidence, + ), + runInfo=AgentRunInfo( + agentName="biological_interpretation_fallback", + modelName=model_name, + ), + ) + validated = validate_biological_interpretation_report(report, deps) + logger.warning( + "Biological Interpretation used its conservative fallback: " + f"status={validated.status}, clusters=" + f"{len(validated.clusterInterpretations)}, reason={error_detail}" + ) + return validated + + def _prepare_biological_interpretation_dependencies( store: Any, *, @@ -1312,6 +1451,25 @@ def _prepare_biological_interpretation_dependencies( assay=None, table_path="cellData", ).astype(np.int64, copy=False) + if isinstance(marker, ArtifactRef): + marker_status = store.inspect_artifact(marker) + if not getattr(marker_status, "exists", True): + raise ValueError("marker artifact does not exist") + if not getattr(marker_status, "complete", False): + raise ValueError("marker artifact is incomplete") + marker_inputs = getattr(marker_status, "inputs", None) or {} + stored_clusters = marker_inputs.get("clusters") + expected_cluster = artifact_reference(cluster) + if ( + not isinstance(stored_clusters, Mapping) + or stored_clusters.get("artifact_id") != expected_cluster.artifactId + or stored_clusters.get("kind") != expected_cluster.kind + or stored_clusters.get("scope") != expected_cluster.scope + or stored_clusters.get("assay") != expected_cluster.assay + ): + raise ValueError( + "marker artifact is not linked to the exact cluster artifact" + ) return BiologicalInterpretationDependencies( store=store, cluster=cluster, @@ -1401,6 +1559,14 @@ def run( context = biological_context or BiologicalContext() cluster_artifact = artifact_reference(deps.cluster) marker_state = "provided" if deps.marker is not None else "not provided" + treatment_eligible = bool( + experimental_handoff is not None + and experimental_handoff.conditionColumn + and experimental_handoff.independentUnit + and experimental_handoff.coefficientScope == "betweenUnit" + and experimental_handoff.estimability.get("status") == "ok" + and experimental_handoff.estimability.get("coefficientEstimable") is True + ) user_prompt = ( dedent( """ @@ -1409,8 +1575,9 @@ def run( {graph_assay}; markers are resolved from {marker_assay}. The exact marker artifact is {marker_state}; creating a marker artifact is authorized={allow_marker_search}. Review no more - than {max_clusters} clusters and return no more than {max_markers} - markers per tool call. + than {max_clusters} clusters. The tool returns no more than + {max_markers} markers per cluster. Treatment observations are + eligible from the supplied design={treatment_eligible}. Caller biological context: {biological_context} @@ -1418,11 +1585,19 @@ def run( Experimental design context: {experimental_context} - Call inspect_cluster_composition once. Then send every cluster you - intend to interpret in one inspect_cluster_markers_batch call. If - markers cannot be inspected, return needsInput and state the exact - missing input. Each tool is removed after it succeeds, so request - every required cluster in that one marker batch. + Call inspect_cluster_composition once. Copy every returned cluster + ID exactly and send the complete unique list in one + inspect_cluster_markers_batch call. Interpret only clusters with a + non-empty returned marker evidenceId. Use proposedIdentity="unresolved" + when markers do not ground a specific hypothesis, and always set + identityIsHypothesis=true. Caller cell-type references are not + cluster labels. If marker evidence is empty, return needsInput with + one populated question. If treatment eligibility is false, return + treatmentObservations=[]. Never return status=failed for biological + uncertainty; use needsInput with a concrete question. Leave artifact + fields null or copy only exact tool-returned values. Each tool is + removed after it succeeds, so request every cluster in that one + marker batch. """ ) .strip() @@ -1435,6 +1610,7 @@ def run( allow_marker_search=allow_marker_search, max_clusters=max_clusters, max_markers=max_markers, + treatment_eligible=str(treatment_eligible).lower(), biological_context=context.model_dump_json(), experimental_context=( experimental_handoff.model_dump_json() @@ -1447,35 +1623,49 @@ def run( f"Requesting biological interpretation for at most " f"{deps.maxClusters} clusters" ) - execution = run_agent_sync( - model=self.model, - output_type=BiologicalInterpretationReport, - system_prompt=_SYSTEM_PROMPT, - user_prompt=user_prompt, - tools=( - Tool( - inspect_cluster_composition, - prepare=_prepare_biological_interpretation_tool, - sequential=self.config.sequentialTools, - timeout=self.config.timeoutSeconds, + try: + execution = run_agent_sync( + model=self.model, + output_type=BiologicalInterpretationReport, + system_prompt=_SYSTEM_PROMPT, + user_prompt=user_prompt, + tools=( + Tool( + inspect_cluster_composition, + prepare=_prepare_biological_interpretation_tool, + sequential=self.config.sequentialTools, + timeout=self.config.timeoutSeconds, + ), + Tool( + inspect_cluster_markers_batch, + max_retries=1, + prepare=_prepare_biological_interpretation_tool, + sequential=self.config.sequentialTools, + timeout=self.config.timeoutSeconds, + ), ), - Tool( - inspect_cluster_markers_batch, - prepare=_prepare_biological_interpretation_tool, - sequential=self.config.sequentialTools, - timeout=self.config.timeoutSeconds, + deps_type=BiologicalInterpretationDependencies, + deps=deps, + config=self.config, + name="biological_interpretation", + output_validator=lambda report: ( + validate_biological_interpretation_report( + report, + deps, + ) ), - ), - deps_type=BiologicalInterpretationDependencies, - deps=deps, - config=self.config, - name="biological_interpretation", - output_validator=lambda report: validate_biological_interpretation_report( - report, + ) + except (UnexpectedModelBehavior, UsageLimitExceeded) as exc: + if not deps.clusterValues: + raise + model_name = getattr(self.model, "model_name", type(self.model).__name__) + return fallback_biological_interpretation_report( deps, - ), - ) - report = validate_biological_interpretation_report(execution.output, deps) + error=exc, + model_name=str(model_name), + ) + report = BiologicalInterpretationReport.model_validate(execution.output) + report = validate_biological_interpretation_report(report, deps) report.runInfo = execution.runInfo logger.info( f"Completed biological interpretation: status={report.status}, " diff --git a/scarf/agent/config/__init__.py b/scarf/agent/config/__init__.py index 1b7b9f9e..d998700d 100644 --- a/scarf/agent/config/__init__.py +++ b/scarf/agent/config/__init__.py @@ -71,8 +71,8 @@ class Config: class AgentRunConfig(AgentDataModel): """Bound one agent run without selecting a scientific workflow.""" - requestLimit: int = 9 - toolCallLimit: int = 5 + requestLimit: int = 10 + toolCallLimit: int = 10 inputTokenLimit: int | None = None outputTokenLimit: int | None = 32768 # Per provider response. totalTokenLimit: int | None = None diff --git a/scarf/agent/config/agent_exec.py b/scarf/agent/config/agent_exec.py index 0467dfde..d8c520e5 100644 --- a/scarf/agent/config/agent_exec.py +++ b/scarf/agent/config/agent_exec.py @@ -273,9 +273,16 @@ async def execute() -> AgentExecutionResult: usage_limits=usage_limits, ) except Exception as exc: + error_detail = str(exc).replace("\n", " ").strip()[:500] + cause = exc.__cause__ + if cause is not None and cause is not exc: + cause_detail = str(cause).replace("\n", " ").strip()[:500] + error_detail = ( + f"{error_detail}; caused by {type(cause).__name__}: {cause_detail}" + ) logger.error( f"Agent {agent_name} failed after {time.monotonic() - started:.2f}s: " - f"{type(exc).__name__}" + f"{type(exc).__name__}: {error_detail}" ) raise return _execution_result( @@ -341,9 +348,16 @@ async def run_agent( usage_limits=usage_limits, ) except Exception as exc: + error_detail = str(exc).replace("\n", " ").strip()[:500] + cause = exc.__cause__ + if cause is not None and cause is not exc: + cause_detail = str(cause).replace("\n", " ").strip()[:500] + error_detail = ( + f"{error_detail}; caused by {type(cause).__name__}: {cause_detail}" + ) logger.error( f"Agent {agent_name} failed after {time.monotonic() - started:.2f}s: " - f"{type(exc).__name__}" + f"{type(exc).__name__}: {error_detail}" ) raise return _execution_result( diff --git a/scarf/agent/data_enrichment.py b/scarf/agent/data_enrichment.py index 2149fc6e..75f6c219 100644 --- a/scarf/agent/data_enrichment.py +++ b/scarf/agent/data_enrichment.py @@ -17,7 +17,13 @@ try: from pydantic import ConfigDict, Field, model_validator - from pydantic_ai import ModelRetry, RunContext, Tool + from pydantic_ai import ( + ModelRetry, + RunContext, + Tool, + UnexpectedModelBehavior, + UsageLimitExceeded, + ) from pydantic_ai.tools import ToolDefinition except ImportError as exc: raise ImportError(AGENT_INSTALL_HINT) from exc @@ -62,12 +68,21 @@ Call inspect_assay_features_batch once for all requested assays. Never invent a feature. If individual features are needed, collect all proposed names across assays and call find_present_features_batch once before - placing them in a policy. Persisted assay types determine modality routes; - never infer a route from an assay label. Copy exact ADT controls, HTO tags, - and ATAC-coordinate status from inspection evidence. Treat Ensembl release - misses as unresolved, not artificial. Mitochondrial, ribosomal, and histone - families may be exclusion candidates. Sex-linked and cell-cycle families - are protected by default in this initial implementation. + placing them in a policy. Do not call feature lookup when no individual + feature decision is needed. Absent or ambiguous lookup results must never + enter a policy. If inspection resolves a supported species, copy that exact + species key. Use caller organism context only when inspection leaves the + species unknown. Exclude only observed families with defaultExclude=true, + and never exclude a family with defaultExclude=false. + + Persisted assay types determine modality routes; never infer a route from + an assay label. The validator fills assay type, modality eligibility, ADT + controls, HTO tags, ATAC-coordinate status, inspections, tool calls, and + report-level evidence. Leave those derived fields at their defaults instead + of copying them into the output. Treat Ensembl release misses as unresolved, + not artificial. Mitochondrial, ribosomal, and histone families may be + exclusion candidates. Sex-linked and cell-cycle families are protected by + default in this initial implementation. Structure studyContextSummary using only verbatim spans from the supplied study paragraph or exact caller references. Do not paraphrase, infer, or @@ -609,6 +624,8 @@ class DataEnrichmentDependencies(AgentDataModel): evidenceIds: set[str] = Field(default_factory=set) inspections: dict[str, AssayFeatureInspection] = Field(default_factory=dict) confirmedFeatures: dict[str, set[str]] = Field(default_factory=dict) + lookupBatch: FeatureLookupBatch | None = Field(default=None, exclude=True) + lookupQueries: dict[str, list[str]] = Field(default_factory=dict, exclude=True) toolCalls: list[DataEnrichmentToolCall] = Field(default_factory=list) @classmethod @@ -866,6 +883,12 @@ async def inspect_assay_features( raise ModelRetry( f"assay_name must be one of the requested assays: {deps.assays}" ) + cached = deps.inspections.get(assay_name) + if cached is not None: + logger.debug( + f"Data Enrichment reused cached inspection for assay {assay_name!r}" + ) + return cached characterization = characterize_features( deps.store, @@ -968,14 +991,34 @@ async def inspect_assay_features_batch( deps = ctx.deps if not deps.assays: raise ModelRetry("No assays were requested") + if any( + call.name == "inspect_assay_features_batch" for call in deps.toolCalls + ) and all(assay_name in deps.inspections for assay_name in deps.assays): + inspections = [deps.inspections[assay_name] for assay_name in deps.assays] + evidence_ids = list( + dict.fromkeys( + evidence_id + for inspection in inspections + for evidence_id in inspection.evidenceIds + ) + ) + logger.info("Data Enrichment reused the completed feature inspection batch") + return AssayFeatureInspectionBatch( + inspections=inspections, + evidenceIds=evidence_ids, + ) logger.info( f"Data Enrichment feature inspection started for {len(deps.assays)} assays" ) start = len(deps.toolCalls) - inspections = [ - await inspect_assay_features(ctx, assay_name=assay_name) - for assay_name in deps.assays - ] + try: + inspections = [ + await inspect_assay_features(ctx, assay_name=assay_name) + for assay_name in deps.assays + ] + except Exception: + del deps.toolCalls[start:] + raise del deps.toolCalls[start:] evidence_ids = list( dict.fromkeys( @@ -1120,6 +1163,14 @@ async def find_present_features_batch( "The batch may contain at most " f"{CONFIG._MAX_FEATURE_QUERIES} feature queries in total" ) + if deps.lookupBatch is not None: + if clean_queries_by_assay != deps.lookupQueries: + raise ModelRetry( + "Feature lookup already completed. Use only the returned lookup " + "evidence and do not request a different batch." + ) + logger.info("Data Enrichment reused the completed feature lookup batch") + return deps.lookupBatch logger.info( "Data Enrichment feature lookup started: " @@ -1159,7 +1210,10 @@ async def find_present_features_batch( f"ambiguous={result_counts['ambiguous']}, " f"absent={result_counts['absent']}, evidence={len(evidence_ids)}" ) - return FeatureLookupBatch(lookups=lookups, evidenceIds=evidence_ids) + batch = FeatureLookupBatch(lookups=lookups, evidenceIds=evidence_ids) + deps.lookupBatch = batch + deps.lookupQueries = clean_queries_by_assay + return batch def _ground_study_context_summary( @@ -1376,6 +1430,8 @@ def validate_data_enrichment_report( requested = set(deps.assays) reported = {policy.assay for policy in report.policies} + if len(reported) != len(report.policies): + raise ValueError("reports may contain only one policy for each assay") if not reported.issubset(requested): raise ValueError( f"policies cite assays outside the requested set: {sorted(reported - requested)}" @@ -1417,6 +1473,94 @@ def validate_data_enrichment_report( return report +def fallback_data_enrichment_report( + deps: DataEnrichmentDependencies, + *, + error: UnexpectedModelBehavior | UsageLimitExceeded, + model_name: str, +) -> DataEnrichmentReport: + """Build a conservative policy from completed deterministic inspections.""" + if set(deps.inspections) != set(deps.assays): + raise error + policies: list[FeatureSelectionPolicy] = [] + for assay_name in deps.assays: + inspection = deps.inspections[assay_name] + species = "unknown" + species_confidence: Literal["high", "medium", "low", "unknown"] = "unknown" + species_rationale = ( + inspection.speciesReason + or "Deterministic feature inspection did not resolve a species." + ) + policy_evidence = [f"assay:{assay_name}:species"] + if inspection.species in _SUPPORTED_SPECIES: + species = inspection.species + species_confidence = ( + "high" if inspection.speciesMethod == "ensemblPrefix" else "medium" + ) + else: + organism_hint = deps.context.organismHint.strip().casefold() + for key, specification in _SUPPORTED_SPECIES.items(): + if organism_hint in {key.casefold(), specification.label.casefold()}: + species = key + species_confidence = "medium" + species_rationale = ( + "Exact caller organism hint resolved an otherwise unknown " + "feature-based species." + ) + policy_evidence.append("context:organism") + break + excluded_families = [ + family + for family in inspection.families + if family.defaultExclude is True and family.count > 0 + ] + protected_families = [ + family for family in inspection.families if family.defaultExclude is False + ] + policy_evidence.extend( + family.evidenceId for family in [*excluded_families, *protected_families] + ) + policies.append( + FeatureSelectionPolicy( + assay=assay_name, + species=species, + speciesConfidence=species_confidence, + speciesRationale=species_rationale, + excludeFamilies=[family.family for family in excluded_families], + protectFamilies=[family.family for family in protected_families], + rationale=( + "Retained only deterministic family defaults after structured " + "model output was unavailable." + ), + evidenceIds=list(dict.fromkeys(policy_evidence)), + ) + ) + error_detail = str(error).replace("\n", " ").strip()[:500] + report = DataEnrichmentReport( + status="done", + policies=policies, + studyContextSummary=StudyContextSummary.get_blank(), + limitations=[ + "Structured enrichment output was unavailable; the fallback omitted " + "all model-selected individual and artificial features.", + "Free-text context extraction may be incomplete because only exact " + "caller fields and deterministic organism mentions were retained.", + error_detail, + ], + runInfo=AgentRunInfo( + agentName="data_enrichment_fallback", + modelName=model_name, + ), + ) + validated = validate_data_enrichment_report(deps, report) + logger.warning( + "Data Enrichment used its conservative fallback: " + f"assays={len(validated.policies)}, evidence={len(validated.evidenceIds)}, " + f"reason={error_detail}" + ) + return validated + + class DataEnrichmentAgent: """A small read-only tool agent for feature and organism enrichment.""" @@ -1502,13 +1646,21 @@ def run( find_present_features_batch exactly once. Do not call a singular assay tool or split lookups across calls. A batched tool is removed after it succeeds, so use each call to request all required data. + If no policy needs an individual feature, do not call feature lookup + and keep excludeFeatures, protectFeatures, and artificialFeatures + empty. Return exactly one policy for every requested assay. Copy a + resolved inspection species exactly; otherwise use unknown unless + exact caller context supports a species. Exclude only observed + defaultExclude=true families and protect every observed + defaultExclude=false family. Populate studyContextSummary only with exact verbatim spans from the paragraph or caller references. Empty optional hint fields do not erase references present in the paragraph. Before returning, verify that every explicit organism, tissue, cell population, experiment, hypothesis, and analysis intent has been placed in its - corresponding summary list. Copy modality evidence from the - inspection without changing feature IDs or names. + corresponding summary list. Leave inspections, modality-derived + fields, exact controls and tags, toolCalls, and report evidence at + their defaults because validation fills them from exact tool state. """ ) .strip() @@ -1524,34 +1676,46 @@ def run( or "not provided", ) ) - execution = run_agent_sync( - model=self.model, - output_type=DataEnrichmentReport, - system_prompt=_SYSTEM_PROMPT, - user_prompt=user_prompt, - tools=[ - Tool( - inspect_assay_features_batch, - prepare=_prepare_data_enrichment_tool, - sequential=self.config.sequentialTools, - timeout=self.config.timeoutSeconds, + try: + execution = run_agent_sync( + model=self.model, + output_type=DataEnrichmentReport, + system_prompt=_SYSTEM_PROMPT, + user_prompt=user_prompt, + tools=[ + Tool( + inspect_assay_features_batch, + max_retries=1, + prepare=_prepare_data_enrichment_tool, + sequential=self.config.sequentialTools, + timeout=self.config.timeoutSeconds, + ), + Tool( + find_present_features_batch, + max_retries=1, + prepare=_prepare_data_enrichment_tool, + sequential=self.config.sequentialTools, + timeout=self.config.timeoutSeconds, + ), + ], + deps_type=DataEnrichmentDependencies, + deps=deps, + config=self.config, + name="data_enrichment", + output_validator=lambda report: validate_data_enrichment_report( + deps, + report, ), - Tool( - find_present_features_batch, - prepare=_prepare_data_enrichment_tool, - sequential=self.config.sequentialTools, - timeout=self.config.timeoutSeconds, - ), - ], - deps_type=DataEnrichmentDependencies, - deps=deps, - config=self.config, - name="data_enrichment", - output_validator=lambda report: validate_data_enrichment_report( + ) + except (UnexpectedModelBehavior, UsageLimitExceeded) as exc: + if set(deps.inspections) != set(deps.assays): + raise + model_name = getattr(self.model, "model_name", type(self.model).__name__) + return fallback_data_enrichment_report( deps, - report, - ), - ) + error=exc, + model_name=str(model_name), + ) report = DataEnrichmentReport.model_validate(execution.output) report = validate_data_enrichment_report(deps, report) report.runInfo = execution.runInfo diff --git a/scarf/agent/experimental_context.py b/scarf/agent/experimental_context.py index 6494fe99..774c75eb 100644 --- a/scarf/agent/experimental_context.py +++ b/scarf/agent/experimental_context.py @@ -45,7 +45,7 @@ try: from pydantic import ConfigDict, Field, model_validator - from pydantic_ai import ModelRetry, RunContext, Tool + from pydantic_ai import ModelRetry, RunContext, Tool, UnexpectedModelBehavior from pydantic_ai.tools import ToolDefinition except ImportError as exc: raise ImportError(AGENT_INSTALL_HINT) from exc @@ -1240,6 +1240,57 @@ async def analyze_experimental_design( directed_units.update(dict(directions.get("unitsOfInference") or {})) directions["unitsOfInference"] = directed_units + proposed_batch_columns = ( + [batch_columns] if isinstance(batch_columns, str) else list(batch_columns or []) + ) + canonical_batch_columns = sorted(set(proposed_batch_columns)) + if len(canonical_batch_columns) != len(proposed_batch_columns): + logger.warning( + "Experimental Context rejected duplicate proposed batch columns: " + f"{proposed_batch_columns[:20]}" + ) + raise ModelRetry("Proposed batch columns must be unique") + inspected_records = { + record.get("name"): record + for record in ( + ctx.deps.characterization.columns + if ctx.deps.characterization is not None + else [] + ) + if isinstance(record.get("name"), str) + } + if ctx.deps.characterization is not None: + for batch_column in canonical_batch_columns: + inspected = inspected_records.get(batch_column) + if inspected is None: + logger.warning( + "Experimental Context rejected unknown proposed batch column " + f"before design recomputation: {batch_column!r}" + ) + raise ModelRetry(f"Unknown batch column {batch_column!r}") + proposed_domain = directed_domains.get( + batch_column, + inspected.get("domain"), + ) + if proposed_domain != "technical": + logger.warning( + "Experimental Context rejected proposed batch column before " + f"design recomputation: {batch_column!r}, " + f"domain={proposed_domain!r}, required='technical'" + ) + raise ModelRetry( + f"Batch column {batch_column!r} must be classified as technical" + ) + if inspected.get("kind") != "categorical": + logger.warning( + "Experimental Context rejected proposed batch column before " + f"design recomputation: {batch_column!r}, " + f"kind={inspected.get('kind')!r}, required='categorical'" + ) + raise ModelRetry( + f"Batch column {batch_column!r} must be categorical for Harmony" + ) + characterization = characterize_covariates( ctx.deps.store, cellSelection=ctx.deps.cellSelection, @@ -1249,15 +1300,33 @@ async def analyze_experimental_design( groupingArtifacts=_hto_artifact_map(ctx.deps), ) if characterization.status == "failed": - logger.warning("Experimental Context design characterization failed") + rejection = "; ".join(characterization.notes).strip() + logger.warning( + "Experimental Context design characterization rejected the proposed " + f"directions: {rejection[:1000]}; " + f"domainColumns={sorted(column_domains)[:50]}, " + f"coefficients={coefficients_of_interest[:50]}, " + f"inferenceUnits={sorted(units_of_inference)[:50]}" + ) raise ModelRetry("; ".join(characterization.notes)) - proposed_batch_columns = ( - [batch_columns] if isinstance(batch_columns, str) else list(batch_columns or []) + # Retain the validated deterministic work even when the proposed Harmony + # columns below are rejected. A bounded fallback can then continue without + # rescanning the metadata or accepting an unsafe model choice. + ctx.deps.characterization = characterization + if not ctx.deps.htoIdentityColumns: + ctx.deps.htoIdentityColumns = _hto_identity_columns(ctx.deps) + qc_profiles = _offered_qc_profiles(ctx.deps, characterization) + evidence_ids = characterization_evidence(characterization) + evidence_ids.update(profile.evidenceId for profile in qc_profiles) + evidence_ids.update( + f"htoIdentity:{column}" for column in ctx.deps.htoIdentityColumns ) - canonical_batch_columns = sorted(set(proposed_batch_columns)) - if len(canonical_batch_columns) != len(proposed_batch_columns): - raise ModelRetry("Proposed batch columns must be unique") + evidence_ids.update( + _artifact_evidence_id(source) for source in ctx.deps.htoIdentityArtifacts + ) + ctx.deps.evidenceIds.update(evidence_ids) + column_records = { record.get("name"): record for record in characterization.columns @@ -1266,12 +1335,26 @@ async def analyze_experimental_design( for batch_column in canonical_batch_columns: record = column_records.get(batch_column) if record is None: + logger.warning( + "Experimental Context rejected unknown proposed batch column: " + f"{batch_column!r}" + ) raise ModelRetry(f"Unknown batch column {batch_column!r}") if record.get("domain") != "technical": + logger.warning( + "Experimental Context rejected proposed batch column " + f"{batch_column!r}: domain={record.get('domain')!r}, " + "required='technical'" + ) raise ModelRetry( f"Batch column {batch_column!r} must be classified as technical" ) if record.get("kind") != "categorical": + logger.warning( + "Experimental Context rejected proposed batch column " + f"{batch_column!r}: kind={record.get('kind')!r}, " + "required='categorical'" + ) raise ModelRetry( f"Batch column {batch_column!r} must be categorical for Harmony" ) @@ -1379,19 +1462,7 @@ async def analyze_experimental_design( batch_safety.append(safety) ctx.deps.batchSafety[safety.evidenceId] = safety - ctx.deps.characterization = characterization - if not ctx.deps.htoIdentityColumns: - ctx.deps.htoIdentityColumns = _hto_identity_columns(ctx.deps) - qc_profiles = _offered_qc_profiles(ctx.deps, characterization) - evidence_ids = characterization_evidence(characterization) evidence_ids.update(item.evidenceId for item in batch_safety) - evidence_ids.update(profile.evidenceId for profile in qc_profiles) - evidence_ids.update( - f"htoIdentity:{column}" for column in ctx.deps.htoIdentityColumns - ) - evidence_ids.update( - _artifact_evidence_id(source) for source in ctx.deps.htoIdentityArtifacts - ) ctx.deps.evidenceIds.update(evidence_ids) ctx.deps.toolCalls.append("analyze_experimental_design") safety_counts = { @@ -2017,6 +2088,123 @@ def validate_experimental_context( return validated +def fallback_experimental_context_result( + deps: ExperimentalContextDependencies, + *, + error: UnexpectedModelBehavior, + model_name: str, +) -> ExperimentalContextResult: + """Continue conservatively when the model exhausts its correction budget.""" + characterization = deps.characterization + if characterization is None: + characterization = characterize_covariates( + deps.store, + cellSelection=deps.cellSelection, + studyContext=deps.studyContext, + model=None, + directions=deps.directions, + groupingArtifacts=_hto_artifact_map(deps), + ) + deps.characterization = characterization + if not deps.htoIdentityColumns: + deps.htoIdentityColumns = _hto_identity_columns(deps) + qc_profiles = list(deps.qcProfiles.values()) + if not qc_profiles: + qc_profiles = _offered_qc_profiles(deps, characterization) + cell_qc = _canonical_cell_qc_plan( + CellQcPlan.get_blank(), + deps, + characterization, + ) + evidence_ids = characterization_evidence(characterization) + evidence_ids.update(profile.evidenceId for profile in qc_profiles) + evidence_ids.update(f"htoIdentity:{column}" for column in deps.htoIdentityColumns) + evidence_ids.update( + _artifact_evidence_id(source) for source in deps.htoIdentityArtifacts + ) + deps.evidenceIds.update(evidence_ids) + column_domains = { + str(record["name"]): record["domain"] + for record in characterization.columns + if isinstance(record.get("name"), str) + and record.get("domain") + in {"biological", "technical", "design", "ignore", "unknown"} + } + coefficient_records = { + str(record["name"]): record + for record in characterization.coefficients + if isinstance(record.get("name"), str) + } + coefficients = list(coefficient_records) + units = { + coefficient: InferenceUnit( + observationUnit=record.get("observationUnit"), + independentUnit=record.get("independentUnit"), + ) + for coefficient, record in coefficient_records.items() + } + batch_evidence = sorted( + f"column:{name}" + for name, domain in column_domains.items() + if domain == "technical" + ) + if not batch_evidence: + batch_evidence = sorted( + evidence_id + for evidence_id in evidence_ids + if evidence_id.startswith("column:") + )[:1] + limitation = ( + "The model exhausted its bounded correction budget while proposing the " + "experimental design. Harmony was skipped because no model proposal was " + "accepted as a categorical technical batch design." + ) + decision = ExperimentalContextDecision( + columnDomains=column_domains, + coefficientsOfInterest=coefficients, + unitsOfInference=units, + batchCorrection=BatchCorrectionPlan( + action="skip", + rationale=( + "Use the native representation because bounded validation did not " + "authorize a safe Harmony batch column." + ), + evidenceIds=batch_evidence, + ), + cellQc=cell_qc, + rationale=( + "Retained deterministic metadata characterization and the exact bounded " + "cell-QC profile, while declining an unvalidated batch-correction choice." + ), + evidenceIds=sorted(evidence_ids), + ) + error_detail = str(error).replace("\n", " ").strip()[:500] + status: StageStatus = "failed" if characterization.status == "failed" else "done" + logger.warning( + "Experimental Context used its conservative fallback: " + f"status={status}, cellQc={cell_qc.action}, coefficients={len(coefficients)}, " + f"reason={error_detail}" + ) + return ExperimentalContextResult( + status=status, + decision=decision, + characterization=characterization, + cellSelection=artifact_reference(deps.cellSelection), + cellQc=cell_qc, + qcProfiles=qc_profiles, + qualityMetricArtifacts=deps.qualityMetricArtifacts, + htoIdentityColumns=deps.htoIdentityColumns, + htoIdentityArtifacts=deps.htoIdentityArtifacts, + batchSafety=list(deps.batchSafety.values()), + currentRepresentation=deps.currentRepresentation, + notes=[*characterization.notes, limitation, error_detail], + runInfo=AgentRunInfo( + agentName="experimental_context_fallback", + modelName=model_name, + ), + ) + + class ExperimentalContextAgent: """A narrow agent for study design and batch-correction planning.""" @@ -2063,7 +2251,11 @@ def __init__( sample, observation-unit, independent-unit, biological, cluster, or embedding columns as Harmony batch columns. A biological coefficient that is not estimable with the exact proposed batch columns makes - correction unsafe. + correction unsafe. A sample or library identifier is not automatically + technical. When no exact observed column is both categorical and + technical, pass batch_columns=[] and recommend skipping Harmony. Every + observation and independent unit must be an exact observed column name + or null. LISI evaluates a representation; it does not identify which metadata column is a batch. Recommend evaluateHarmony, not application, because Parameter Tuning must compare exact uncorrected and corrected artifacts. @@ -2227,42 +2419,50 @@ def run( directions=json.dumps(direction_map, sort_keys=True, default=str), ) ) - execution = run_agent_sync( - model=self.model, - output_type=ExperimentalContextDecision, - system_prompt=self.system_prompt, - user_prompt=user_prompt, - tools=( - Tool( - inspect_cell_covariates, - prepare=_prepare_experimental_context_tool, - sequential=self.config.sequentialTools, - timeout=self.config.timeoutSeconds, - ), - Tool( - analyze_experimental_design, - prepare=_prepare_experimental_context_tool, - sequential=self.config.sequentialTools, - timeout=self.config.timeoutSeconds, + try: + execution = run_agent_sync( + model=self.model, + output_type=ExperimentalContextDecision, + system_prompt=self.system_prompt, + user_prompt=user_prompt, + tools=( + Tool( + inspect_cell_covariates, + prepare=_prepare_experimental_context_tool, + sequential=self.config.sequentialTools, + timeout=self.config.timeoutSeconds, + ), + Tool( + analyze_experimental_design, + max_retries=1, + prepare=_prepare_experimental_context_tool, + sequential=self.config.sequentialTools, + timeout=self.config.timeoutSeconds, + ), + Tool( + score_current_representation, + prepare=_prepare_experimental_context_tool, + sequential=self.config.sequentialTools, + timeout=self.config.timeoutSeconds, + ), ), - Tool( - score_current_representation, - prepare=_prepare_experimental_context_tool, - sequential=self.config.sequentialTools, - timeout=self.config.timeoutSeconds, + deps_type=ExperimentalContextDependencies, + deps=deps, + config=self.config, + name="experimental_context", + output_validator=lambda decision: validate_experimental_context( + decision, + deps, ), - ), - deps_type=ExperimentalContextDependencies, - deps=deps, - config=self.config, - name="experimental_context", - output_validator=lambda decision: validate_experimental_context( - decision, + ) + except UnexpectedModelBehavior as exc: + model_name = getattr(self.model, "model_name", type(self.model).__name__) + return fallback_experimental_context_result( deps, - ), - ) + error=exc, + model_name=str(model_name), + ) decision = ExperimentalContextDecision.model_validate(execution.output) - decision = validate_experimental_context(decision, deps) characterization = deps.characterization if characterization is None: characterization = characterize_covariates( diff --git a/scarf/agent/orchestrator/context.py b/scarf/agent/orchestrator/context.py index 74c96a39..12492e06 100644 --- a/scarf/agent/orchestrator/context.py +++ b/scarf/agent/orchestrator/context.py @@ -697,6 +697,15 @@ def experimental_context_stage( and paused.outputs.get("unsafeBatchCorrection") is True else None ) + no_inference_resolution = ( + paused is not None + and paused.outputs.get("unsafeBatchCorrection") is not True + and isinstance(supplied_directions, Mapping) + and supplied_directions.get("coefficientsOfInterest") == [] + and supplied_directions.get("unitsOfInference") == {} + and isinstance(supplied_directions.get("batchCorrection"), Mapping) + and supplied_directions["batchCorrection"].get("action") == "skip" + ) actions: list[str] = [] recovered = journal._recover_persisted_stage_report( store, @@ -715,11 +724,11 @@ def experimental_context_stage( actions.append("recover_persisted_experimental_context_report") else: parent_reports = [journal._report_link(enrichment_reference)] - if unsafe_resolution == "skip": + if unsafe_resolution == "skip" or no_inference_resolution: assert paused is not None if not paused.reportReferences: raise ValueError( - "Unsafe Experimental Context pause has no persisted report" + "Experimental Context pause has no persisted report" ) prior_report = cast( ExperimentalContextResult, @@ -752,20 +761,48 @@ def experimental_context_stage( "Paused Experimental Context invocation artifacts are stale" ) prior_plan = prior_report.decision.batchCorrection + if no_inference_resolution: + plan_updates: dict[str, Any] = { + "preserveColumns": [], + "rationale": ( + "The caller explicitly continued without " + "coefficient-level inference and skipped Harmony." + ), + } + decision_updates: dict[str, Any] = { + "coefficientsOfInterest": [], + "unitsOfInference": {}, + } + resolution_note = ( + "Caller explicitly continued without coefficient-level " + "inference and skipped Harmony." + ) + resolution_action = ( + "resolve_experimental_context:no_inference_skip_harmony" + ) + else: + plan_updates = { + "rationale": ( + "The caller explicitly skipped Harmony after reviewing " + "the persisted unsafe batch-correction evidence." + ) + } + decision_updates = {} + resolution_note = ( + "Caller explicitly skipped Harmony after an unsafe result." + ) + resolution_action = "resolve_unsafe_batch_correction:skip" skip_plan = prior_plan.model_copy( update={ "action": "skip", "batchColumns": [], "metricsRequired": [], - "rationale": ( - "The caller explicitly skipped Harmony after " - "reviewing the persisted unsafe batch-correction " - "evidence." - ), + **plan_updates, } ) decision = prior_report.decision.model_copy( update={ + **decision_updates, "batchCorrection": skip_plan, "needsInput": [], } @@ -774,18 +811,14 @@ def experimental_context_stage( update={ "status": "done", "decision": decision, - "notes": [ - *prior_report.notes, - "Caller explicitly skipped Harmony after an unsafe " - "result.", - ], + "notes": [*prior_report.notes, resolution_note], } ) + actions.append(resolution_action) parent_reports.append( journal._report_link(paused.reportReferences[0]) ) run_config = request_record.config.agentRunConfig - actions.append("resolve_unsafe_batch_correction:skip") else: logger.info( f"Workflow {workflow.workflowRunId}: invoking Experimental " diff --git a/scarf/agent/orchestrator/journal.py b/scarf/agent/orchestrator/journal.py index 2ff3ca16..5afbf54d 100644 --- a/scarf/agent/orchestrator/journal.py +++ b/scarf/agent/orchestrator/journal.py @@ -894,6 +894,14 @@ def finish_exception( outputs: Mapping[str, Any] | None = None, notes: Sequence[str] = (), ) -> WorkflowStageAttempt: + if is_retryable_model_error(exc): + status_code = getattr(exc, "status_code", "unknown") + logger.warning( + f"Workflow {workflow.workflowRunId}: stage={started.stage!r} was " + f"interrupted by retryable model HTTP status {status_code}; leaving " + "the workflow running for recovery" + ) + raise exc error = f"{type(exc).__name__}: {exc}" logger.error( f"Workflow {workflow.workflowRunId}: stage={started.stage!r} raised " @@ -921,6 +929,17 @@ def finish_exception( return outcome +def is_retryable_model_error(exc: BaseException) -> bool: + """Return whether a provider HTTP failure should leave the workflow resumable.""" + try: + from pydantic_ai import ModelHTTPError + except ImportError: + return False + return isinstance(exc, ModelHTTPError) and ( + exc.status_code == 429 or 500 <= exc.status_code <= 599 + ) + + def finalize_failed( store: DataStore, workflow: AgentWorkflowRun, diff --git a/scarf/agent/parameter_tuning.py b/scarf/agent/parameter_tuning.py index eb5eb3a7..d655329e 100644 --- a/scarf/agent/parameter_tuning.py +++ b/scarf/agent/parameter_tuning.py @@ -29,7 +29,7 @@ raise ImportError(AGENT_INSTALL_HINT) from exc try: - from pydantic_ai import RunContext, UnexpectedModelBehavior + from pydantic_ai import RunContext, UnexpectedModelBehavior, UsageLimitExceeded except ImportError as exc: raise ImportError(AGENT_INSTALL_HINT) from exc @@ -767,7 +767,9 @@ def parameter_search_system_prompt() -> str: Never return status=complete with candidates. A Harmony candidate always uses the exact authorized batch columns supplied in the prompt. You may choose between no correction and that approved Harmony configuration, but - you must not propose or modify batch columns. + you must not propose or modify batch columns. When proposing any Harmony + refinement, base it on one matched corrected and uncorrected initial pair + with otherwise identical parameters. Cite only evidenceIds from the initial evaluations. Identify the successful initial candidates that motivate refinement, state focused objectives, and @@ -777,6 +779,26 @@ def parameter_search_system_prompt() -> str: ).strip() +def parameter_evaluation_payload( + evaluation: ParameterCandidateEvaluation, +) -> dict[str, Any]: + """Return only candidate evidence needed for planning and selection.""" + return { + "candidateId": evaluation.candidateId, + "phase": evaluation.phase, + "harmonyBatchColumns": evaluation.harmonyBatchColumns, + "status": evaluation.status, + "eligible": evaluation.eligible, + "parameters": evaluation.parameters.model_dump(mode="json"), + "effectiveDimensions": evaluation.effectiveDimensions, + "metrics": evaluation.metrics.model_dump(mode="json"), + "evidenceIds": evaluation.evidenceIds, + "eligibilityReasons": evaluation.eligibilityReasons, + "warnings": [warning[:500] for warning in evaluation.warnings[:10]], + "error": evaluation.error[:500] if evaluation.error is not None else None, + } + + def parameter_search_prompt( *, from_assay: str, @@ -789,7 +811,9 @@ def parameter_search_prompt( ) -> str: """Build the planning prompt from deterministic initial evaluations.""" - evaluation_payload = [evaluation.model_dump() for evaluation in evaluations] + evaluation_payload = [ + parameter_evaluation_payload(evaluation) for evaluation in evaluations + ] correction_modes = ["none", "harmony"] if harmony_authorized else ["none"] return ( dedent( @@ -848,7 +872,10 @@ def parameter_tuning_system_prompt(min_cluster_cells: int) -> str: evidence for parameter quality. When multiple candidates complete, return one comparison for every non-selected successful candidate. Each comparison must cite evidence from both the selected candidate and that - comparator. Return a concise structured report. + comparator. Return only model-owned selection fields. Leave evaluations, + selectedArtifacts, searchPlan, assayReports, integration fields, final + graph fields, and runInfo at their defaults because validation fills them + from executor state. Return a concise structured report. """ ) .strip() @@ -867,7 +894,9 @@ def parameter_tuning_prompt( ) -> str: """Build the final selection prompt from completed evaluations.""" - evaluation_payload = [evaluation.model_dump() for evaluation in evaluations] + evaluation_payload = [ + parameter_evaluation_payload(evaluation) for evaluation in evaluations + ] return ( dedent( """ @@ -918,7 +947,7 @@ def parameter_batch_search_prompt( payload = { assay: { "evaluations": [ - deps.evaluations[candidate_id].model_dump() + parameter_evaluation_payload(deps.evaluations[candidate_id]) for candidate_id in deps.executionOrder ], "authorizedHarmony": deps.harmonyAuthorized, @@ -973,7 +1002,12 @@ def parameter_batch_selection_system_prompt() -> str: exactly one grounded single-assay report in assayReports per assay. Apply eligibility, evidence, and comparison requirements independently. Do not invent joint scores, artifacts, candidates, or evidence. UMAP - appearance is not evidence. Leave integration fields empty. + appearance is not evidence. Inside each assay report, return only + model-owned selection, rationale, comparison, trade-off, limitation, + evidence, and stop fields. Leave evaluations, selectedArtifacts, + searchPlan, nested assayReports, integration fields, final graph fields, + and runInfo at their defaults because validation fills them from + executor state. """ ) .strip() @@ -992,7 +1026,7 @@ def parameter_batch_selection_prompt( payload = { assay: { "evaluations": [ - deps.evaluations[candidate_id].model_dump() + parameter_evaluation_payload(deps.evaluations[candidate_id]) for candidate_id in deps.executionOrder ], "searchPlan": search_plans[assay].model_dump(exclude={"runInfo"}), @@ -1743,7 +1777,9 @@ def validate_parameter_search_plan( raise ValueError( f"Refinement evidence must cite every parent candidate: {parent_id!r}" ) - if deps.harmonyAuthorized: + if deps.harmonyAuthorized and any( + candidate.useHarmony for candidate in plan.candidates + ): parent_candidates = [ deps.candidates[candidate_id] for candidate_id in plan.basedOnCandidateIds ] @@ -2148,6 +2184,11 @@ def fallback_parameter_tuning_report( cannot_recommend = ( not eligible or (comparison_required and len(successful) < 2) + or ( + comparison_required + and "baseline" in deps.candidates + and not any(item.candidateId == "baseline" for item in successful) + ) or any(evidence_by_candidate[item.candidateId] is None for item in successful) ) if cannot_recommend: @@ -2217,8 +2258,8 @@ def fallback_parameter_tuning_report( recommendedCandidateId=selected.candidateId, confidence="low", rationale=( - "Structured model selection was unavailable after bounded retries; " - "the first eligible authorized branch was retained conservatively." + "The bounded structured model selection was unavailable; the first " + "eligible authorized branch was retained conservatively." ), evidenceIds=[selected_evidence], comparisons=comparisons, @@ -2601,10 +2642,11 @@ def select_final_parameter_graph( marker_assay=marker_assay, ), ) - except UnexpectedModelBehavior: + except (UnexpectedModelBehavior, UsageLimitExceeded) as exc: option_ids = sorted(options) logger.warning( - "Final graph selection exhausted structured-output retries; " + "Final graph selection model run failed within its bounds " + f"({type(exc).__name__}); " f"requesting input for {len(option_ids)} eligible options" ) selection = validate_final_graph_selection( @@ -2613,8 +2655,7 @@ def select_final_parameter_graph( markerAssay=marker_assay, confidence="low", rationale=( - "Structured final-graph selection was unavailable after " - "bounded retries." + "The bounded structured final-graph selection was unavailable." ), limitations=[ "No ranking was invented across multiple eligible graphs." @@ -3198,18 +3239,19 @@ def tune_parameters_batch( ) ), ) - except UnexpectedModelBehavior: + except (UnexpectedModelBehavior, UsageLimitExceeded) as exc: logger.warning( - "Batched parameter refinement planning exhausted " - "structured-output retries; skipping optional refinement" + "Batched parameter refinement model run failed within its bounds " + f"({type(exc).__name__}); " + "skipping optional refinement" ) batch_plan = ParameterTuningBatchSearchPlan( assayPlans={ assay: ParameterSearchPlan( status="complete", rationale=( - "Structured refinement planning was unavailable after " - "bounded retries; optional refinement was skipped." + "The bounded structured refinement plan was unavailable; " + "optional refinement was skipped." ), stoppingCriteria=[ "Use the completed deterministic initial screen." @@ -3292,9 +3334,10 @@ def tune_parameters_batch( primary_assay=resolved_primary, ), ) - except UnexpectedModelBehavior: + except (UnexpectedModelBehavior, UsageLimitExceeded) as exc: logger.warning( - "Batched parameter selection exhausted structured-output retries; " + "Batched parameter selection model run failed within its bounds " + f"({type(exc).__name__}); " "using the conservative executor-evidence fallback" ) return fallback_parameter_tuning_batch_report( @@ -3414,16 +3457,17 @@ def tune_parameters( ) ), ) - except UnexpectedModelBehavior: + except (UnexpectedModelBehavior, UsageLimitExceeded) as exc: logger.warning( f"Parameter refinement planning for assay {from_assay!r} " - "exhausted structured-output retries; skipping optional refinement" + f"failed within its model-run bounds ({type(exc).__name__}); " + "skipping optional refinement" ) plan = ParameterSearchPlan( status="complete", rationale=( - "Structured refinement planning was unavailable after bounded " - "retries; optional refinement was skipped." + "The bounded structured refinement plan was unavailable; " + "optional refinement was skipped." ), stoppingCriteria=["Use the completed deterministic initial screen."], runInfo=AgentRunInfo(agentName="parameter_search_planning_fallback"), @@ -3479,11 +3523,11 @@ def tune_parameters( search_plan=plan, ), ) - except UnexpectedModelBehavior: + except (UnexpectedModelBehavior, UsageLimitExceeded) as exc: logger.warning( - f"Parameter selection for assay {from_assay!r} exhausted " - "structured-output retries; using the conservative executor-evidence " - "fallback" + f"Parameter selection for assay {from_assay!r} failed within its " + f"model-run bounds ({type(exc).__name__}); using the conservative " + "executor-evidence fallback" ) return fallback_parameter_tuning_report( deps, diff --git a/tests/test_agent_biological_interpretation.py b/tests/test_agent_biological_interpretation.py index 50bca054..75b4ec00 100644 --- a/tests/test_agent_biological_interpretation.py +++ b/tests/test_agent_biological_interpretation.py @@ -7,7 +7,7 @@ import pandas as pd import pytest import zarr -from pydantic_ai import ModelRetry, RunContext +from pydantic_ai import ModelRetry, RunContext, UnexpectedModelBehavior from pydantic_ai.messages import ( ModelMessage, ModelResponse, @@ -536,6 +536,29 @@ def test_validator_rejects_unobserved_evidence() -> None: validate_biological_interpretation_report(report, run_context.deps) +def test_validator_rejects_empty_input_questions_and_model_authored_failure() -> None: + store = FakeStore() + run_context = context(store, marker=store.marker) + asyncio.run(inspect_cluster_composition(run_context)) + + with pytest.raises(ModelRetry, match="concrete input question"): + validate_biological_interpretation_report( + BiologicalInterpretationReport( + status="needsInput", + needsInput=BiologicalInterpretationNeedsInput(), + ), + run_context.deps, + ) + with pytest.raises(ModelRetry, match="Do not return failed"): + validate_biological_interpretation_report( + BiologicalInterpretationReport( + status="failed", + limitations=["The model was uncertain."], + ), + run_context.deps, + ) + + def test_treatment_observation_requires_evidence_for_its_exact_cluster() -> None: store = FakeStore() run_context = context(store, marker=store.marker) @@ -873,6 +896,70 @@ async def reply(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: } +def test_biological_interpretation_falls_back_to_unresolved_hypotheses( + monkeypatch: pytest.MonkeyPatch, +) -> None: + store = FakeStore() + marker_retries: list[int] = [] + + def unavailable_structured_output(**kwargs: object) -> None: + deps = kwargs["deps"] + assert isinstance(deps, BiologicalInterpretationDependencies) + marker_tool = next( + tool + for tool in kwargs["tools"] + if tool.name == "inspect_cluster_markers_batch" + ) + marker_retries.append(marker_tool.max_retries) + run_context = SimpleNamespace(deps=deps) + asyncio.run(inspect_cluster_composition(run_context)) + asyncio.run( + inspect_cluster_markers_batch( + run_context, + cluster_ids=list(deps.clusterValues), + ) + ) + raise UnexpectedModelBehavior("structured output unavailable") + + monkeypatch.setattr( + biological_module, + "run_agent_sync", + unavailable_structured_output, + ) + result = BiologicalInterpretationAgent(object()).run( + store, + cluster=store.cluster, + marker=store.marker, + ) + + assert result.status == "done" + assert result.runInfo.agentName == "biological_interpretation_fallback" + assert {item.clusterId for item in result.clusterInterpretations} == {"0", "1"} + assert all( + item.proposedIdentity == "unresolved" + and item.identityIsHypothesis + and item.confidence == "low" + for item in result.clusterInterpretations + ) + assert result.treatmentObservations == [] + assert marker_retries == [1] + + +def test_marker_batch_cache_rejects_different_clusters() -> None: + store = FakeStore() + run_context = context(store, marker=store.marker) + asyncio.run(inspect_cluster_composition(run_context)) + + first = asyncio.run(inspect_cluster_markers_batch(run_context, cluster_ids=["0"])) + repeated = asyncio.run( + inspect_cluster_markers_batch(run_context, cluster_ids=["0"]) + ) + + assert repeated is first + with pytest.raises(ModelRetry, match="already completed"): + asyncio.run(inspect_cluster_markers_batch(run_context, cluster_ids=["1"])) + + def test_exact_cluster_artifact_conflict_is_rejected_before_model_execution() -> None: store = FakeStore() tuning_handoff = TuningBiologyHandoff( diff --git a/tests/test_agent_data_enrichment.py b/tests/test_agent_data_enrichment.py index 09b2eab4..a45814fb 100644 --- a/tests/test_agent_data_enrichment.py +++ b/tests/test_agent_data_enrichment.py @@ -1,9 +1,11 @@ """Tests for the read-only data enrichment agent.""" +import asyncio from types import SimpleNamespace import pytest from pydantic import BaseModel +from pydantic_ai import ModelRetry, UnexpectedModelBehavior from pydantic_ai.messages import ModelMessage, ModelResponse, ToolCallPart from pydantic_ai.models.function import AgentInfo, FunctionModel @@ -28,6 +30,7 @@ FeatureSelectionPolicy, HtoTagEvidence, StudyContextSummary, + find_present_features_batch, validate_data_enrichment_report, ) @@ -483,6 +486,76 @@ async def reply( assert state["request"] == 3 +def test_data_enrichment_falls_back_from_completed_inspection( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from scarf.agent import data_enrichment as module + + store = ReadOnlyStore() + monkeypatch.setattr( + module, + "characterize_features", + lambda *_args, **_kwargs: characterization(), + ) + tool_retries: dict[str, int] = {} + + def unavailable_structured_output(**kwargs: object) -> None: + deps = kwargs["deps"] + assert isinstance(deps, DataEnrichmentDependencies) + for tool in kwargs["tools"]: + tool_retries[tool.name] = tool.max_retries + asyncio.run(module.inspect_assay_features_batch(SimpleNamespace(deps=deps))) + raise UnexpectedModelBehavior("structured output unavailable") + + monkeypatch.setattr(module, "run_agent_sync", unavailable_structured_output) + result = DataEnrichmentAgent(object()).run( + store, + context=DataEnrichmentContext(organismHint="human"), + ) + + assert result.status == "done" + assert result.runInfo.agentName == "data_enrichment_fallback" + assert result.policies[0].species == "homo_sapiens" + assert result.policies[0].speciesConfidence == "medium" + assert result.policies[0].excludeFamilies == ["mitochondrial"] + assert result.policies[0].protectFamilies == ["sex"] + assert result.policies[0].artificialFeatures == [] + assert tool_retries == { + "inspect_assay_features_batch": 1, + "find_present_features_batch": 1, + } + + +def test_feature_lookup_cache_rejects_different_arguments() -> None: + deps = DataEnrichmentDependencies( + store=ReadOnlyStore(), + assays=["RNA"], + ) + run_context = SimpleNamespace(deps=deps) + + first = asyncio.run( + find_present_features_batch( + run_context, + queries_by_assay={"RNA": ["MT-CYB"]}, + ) + ) + repeated = asyncio.run( + find_present_features_batch( + run_context, + queries_by_assay={"RNA": ["MT-CYB"]}, + ) + ) + + assert repeated is first + with pytest.raises(ModelRetry, match="already completed"): + asyncio.run( + find_present_features_batch( + run_context, + queries_by_assay={"RNA": ["GAPDH"]}, + ) + ) + + def test_data_enrichment_validates_policy_and_assay() -> None: with pytest.raises(ValueError, match="both excluded and protected"): FeatureSelectionPolicy( @@ -536,6 +609,30 @@ def test_enrichment_copies_caller_context_instead_of_model_paraphrases() -> None ] +def test_enrichment_rejects_duplicate_assay_policies() -> None: + inspection = AssayFeatureInspection( + assay="RNA", + species="unknown", + evidenceIds=["assay:RNA:species"], + ) + deps = DataEnrichmentDependencies( + store=ReadOnlyStore(), + assays=["RNA"], + inspections={"RNA": inspection}, + evidenceIds={"assay:RNA:species"}, + ) + policy = FeatureSelectionPolicy( + assay="RNA", + evidenceIds=["assay:RNA:species"], + ) + + with pytest.raises(ValueError, match="one policy for each assay"): + validate_data_enrichment_report( + deps, + DataEnrichmentReport(status="done", policies=[policy, policy.model_copy()]), + ) + + def test_enrichment_rejects_protected_family_exclusion() -> None: family = FeatureFamilyEvidence( family="cellCycle", diff --git a/tests/test_agent_experimental_context.py b/tests/test_agent_experimental_context.py index 3c2db1bc..a1a72475 100644 --- a/tests/test_agent_experimental_context.py +++ b/tests/test_agent_experimental_context.py @@ -8,7 +8,7 @@ import pytest import zarr from pydantic import ValidationError -from pydantic_ai import ModelRetry, RunContext +from pydantic_ai import ModelRetry, RunContext, UnexpectedModelBehavior from pydantic_ai.messages import ModelMessage, ModelResponse, ToolCallPart from pydantic_ai.models.function import AgentInfo, FunctionModel from pydantic_ai.models.test import TestModel @@ -511,6 +511,63 @@ async def reply( assert sorted(store.zw.group_keys()) == ["artifacts", "cellData"] +def test_agent_uses_conservative_fallback_after_tool_retry_exhaustion( + monkeypatch: pytest.MonkeyPatch, +) -> None: + store = _Store() + analyze_retries: list[int | None] = [] + + def unavailable_design(**kwargs: Any) -> None: + deps = kwargs["deps"] + analyze_tool = next( + tool + for tool in kwargs["tools"] + if tool.name == "analyze_experimental_design" + ) + analyze_retries.append(analyze_tool.max_retries) + asyncio.run( + inspect_cell_covariates( + RunContext( + deps=deps, + model=TestModel(), + usage=RunUsage(), + ) + ) + ) + raise UnexpectedModelBehavior( + "Tool 'analyze_experimental_design' exceeded max retries count of 1" + ) + + monkeypatch.setattr( + experimental_context_module, + "run_agent_sync", + unavailable_design, + ) + result = ExperimentalContextAgent(object()).run( + store, + study_context="Case-control study with samples nested in donors.", + cell_selection=store.cell_selection, + ) + + assert analyze_retries == [1] + assert result.status == "done" + assert result.decision.batchCorrection.action == "skip" + assert result.decision.batchCorrection.batchColumns == [] + assert result.cellSelection is not None + assert result.cellSelection.artifactId == store.cell_selection.artifact_id + assert result.cellQc.profileId in { + profile.profileId for profile in result.qcProfiles + } + assert result.cellQc.evidenceIds == [ + profile.evidenceId + for profile in result.qcProfiles + if profile.profileId == result.cellQc.profileId + ] + assert result.runInfo.agentName == "experimental_context_fallback" + assert result.to_parameter_tuning_handoff().batchAction == "skip" + assert any("Harmony was skipped" in note for note in result.notes) + + def test_handoff_builders_reject_incomplete_or_ambiguous_results() -> None: incomplete = ExperimentalContextResult.get_blank() with pytest.raises(ValueError, match="must be done"): diff --git a/tests/test_agent_orchestrator_lifecycle.py b/tests/test_agent_orchestrator_lifecycle.py index eaa66862..3b51f3d8 100644 --- a/tests/test_agent_orchestrator_lifecycle.py +++ b/tests/test_agent_orchestrator_lifecycle.py @@ -10,6 +10,7 @@ import pytest import zarr +from pydantic_ai import ModelHTTPError import scarf.agent.orchestrator.context as context_module import scarf.agent.orchestrator.journal as journal_module @@ -740,6 +741,119 @@ def test_failed_stage_preserves_completed_operation_journal(tmp_path: Path) -> N assert load_agent_workflow(store, workflow.workflowRunId).status == "failed" +@pytest.mark.parametrize("status_code", [429, 500, 503, 599]) +def test_retryable_model_http_error_leaves_stage_interrupted( + tmp_path: Path, + status_code: int, +) -> None: + path = create_store(tmp_path / f"retryable-model-{status_code}.zarr") + store = DataStore( + str(path), + default_assay="RNA", + min_features_per_cell=-1, + mito_pattern="", + ribo_pattern="", + zarr_mode="r+", + ) + workflow = create_agent_workflow( + store, + workflow_run_id=f"retryable-model-{status_code}", + ) + request_record = OrchestrationRequestRecord( + workflowRunId=workflow.workflowRunId, + request=AutomatedWorkflowRequest( + sourcePath=str(path), + zarrPath=str(path), + studyContext="A retryable model failure test.", + allowAssumptions=True, + ), + config=AutomatedWorkflowConfig(), + ) + prefix = journal_module._ensure_orchestration_store(store) + started = journal_module._start_attempt( + store.zw, + prefix, + workflow.workflowRunId, + "experimental_context", + request_record, + [], + ) + error = ModelHTTPError(status_code, "test-model", {"error": "transient"}) + + with pytest.raises(ModelHTTPError) as raised: + journal_module.finish_exception( + store, + prefix, + workflow, + started, + error, + ) + + assert raised.value is error + assert ( + journal_module._stage_outcomes( + store.zw, + prefix, + workflow.workflowRunId, + "experimental_context", + ) + == [] + ) + assert load_agent_workflow(store, workflow.workflowRunId).status == "running" + + +@pytest.mark.parametrize("status_code", [400, 401, 403, 404]) +def test_nonretryable_model_http_error_remains_terminal( + tmp_path: Path, + status_code: int, +) -> None: + path = create_store(tmp_path / f"terminal-model-{status_code}.zarr") + store = DataStore( + str(path), + default_assay="RNA", + min_features_per_cell=-1, + mito_pattern="", + ribo_pattern="", + zarr_mode="r+", + ) + workflow = create_agent_workflow( + store, + workflow_run_id=f"terminal-model-{status_code}", + ) + request_record = OrchestrationRequestRecord( + workflowRunId=workflow.workflowRunId, + request=AutomatedWorkflowRequest( + sourcePath=str(path), + zarrPath=str(path), + studyContext="A terminal model failure test.", + allowAssumptions=True, + ), + config=AutomatedWorkflowConfig(), + ) + prefix = journal_module._ensure_orchestration_store(store) + started = journal_module._start_attempt( + store.zw, + prefix, + workflow.workflowRunId, + "experimental_context", + request_record, + [], + ) + + outcome = journal_module.finish_exception( + store, + prefix, + workflow, + started, + ModelHTTPError(status_code, "test-model", {"error": "terminal"}), + ) + + assert outcome.status == "failed" + assert outcome.error is not None + assert outcome.error.startswith("ModelHTTPError:") + assert load_agent_workflow(store, workflow.workflowRunId).status == "failed" + + def test_failed_stage_links_report_committed_before_exception(tmp_path: Path) -> None: path = create_store(tmp_path / "failure-report-link.zarr") store = DataStore( diff --git a/tests/test_agent_orchestrator_stages.py b/tests/test_agent_orchestrator_stages.py index b0657397..4da233ae 100644 --- a/tests/test_agent_orchestrator_stages.py +++ b/tests/test_agent_orchestrator_stages.py @@ -457,6 +457,141 @@ def run(self, *_args: Any, **_kwargs: Any) -> ExperimentalContextResult: assert UnsafeAgent.calls == 1 +def test_explicit_no_inference_skip_resolves_context_without_provider_rerun( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + path = create_store(tmp_path / "no-inference-context.zarr") + store = DataStore(str(path), default_assay="RNA", min_features_per_cell=0) + workflow = create_agent_workflow(store, workflow_run_id="no-inference-context") + cell_selection = ArtifactReferenceModel.from_artifact_ref( + store.snapshot_cell_selection("I") + ) + enrichment = DataEnrichmentReport.get_example().model_copy( + update={ + "runInfo": AgentRunInfo( + agentName="data_enrichment", + runId=uuid.uuid4().hex, + ) + } + ) + enrichment_reference = save_agent_report( + store, + workflow.workflowRunId, + enrichment, + invocation=AgentInvocation( + agentName="data_enrichment", + inputs={"studyContext": "A study with unresolved replication."}, + ), + ) + request_record = OrchestrationRequestRecord( + workflowRunId=workflow.workflowRunId, + request=AutomatedWorkflowRequest( + sourcePath=str(path), + zarrPath=str(path), + studyContext="A study with unresolved replication.", + allowAssumptions=True, + ), + ) + example = ExperimentalContextResult.get_example() + needs_input_plan = example.decision.batchCorrection.model_copy( + update={ + "action": "needsInput", + "batchColumns": [], + "preserveColumns": [], + "metricsRequired": [], + } + ) + needs_input_report = example.model_copy( + update={ + "status": "needsInput", + "cellSelection": cell_selection, + "cellQc": CellQcPlan(), + "qcProfiles": [], + "qualityMetricArtifacts": [], + "htoIdentityColumns": [], + "htoIdentityArtifacts": [], + "decision": example.decision.model_copy( + update={ + "batchCorrection": needs_input_plan, + "cellQc": CellQcPlan(), + "needsInput": [ + "Provide replicated observation units or skip inference." + ], + } + ), + "runInfo": AgentRunInfo( + agentName="experimental_context", + runId=uuid.uuid4().hex, + ), + } + ) + + class NeedsInputAgent: + calls = 0 + + def __init__(self, *_args: Any, **_kwargs: Any) -> None: + self.config = AgentRunConfig() + + def run(self, *_args: Any, **_kwargs: Any) -> ExperimentalContextResult: + type(self).calls += 1 + return needs_input_report + + monkeypatch.setattr(context_module, "ExperimentalContextAgent", NeedsInputAgent) + orchestrator = AgentOrchestrator(object()) + paused_outcome, _ = orchestrator.experimental_context_stage( + store, + workflow, + request_record, + [], + cell_selection, + enrichment_reference, + [], + [], + {}, + ) + + assert paused_outcome.status == "needsInput" + resolved_outcome, resolved_report = orchestrator.experimental_context_stage( + store, + workflow, + request_record, + [], + cell_selection, + enrichment_reference, + [], + [], + { + "experimentalDirections": { + "coefficientsOfInterest": [], + "unitsOfInference": {}, + "batchCorrection": {"action": "skip"}, + } + }, + ) + + assert resolved_outcome.status == "done" + assert resolved_outcome.artifacts == paused_outcome.artifacts + assert resolved_outcome.actions == [ + "resolve_experimental_context:no_inference_skip_harmony" + ] + assert resolved_report.status == "done" + assert resolved_report.decision.coefficientsOfInterest == [] + assert resolved_report.decision.unitsOfInference == {} + assert resolved_report.decision.batchCorrection.action == "skip" + assert resolved_report.decision.batchCorrection.batchColumns == [] + assert resolved_report.decision.needsInput == [] + assert NeedsInputAgent.calls == 1 + resolved_record = load_agent_record( + store, + resolved_outcome.reportReferences[0], + ) + assert resolved_record.invocation.artifacts == resolved_outcome.artifacts + assert resolved_record.invocation.parentReports[-1].agentRunId == ( + paused_outcome.reportReferences[0].agentRunId + ) + + def test_preprocessing_plan_routes_supported_modalities_and_skips_others() -> None: assays = { "peaks": ( diff --git a/tests/test_agent_parameter_tuning.py b/tests/test_agent_parameter_tuning.py index ed2cfa1b..22aa309f 100644 --- a/tests/test_agent_parameter_tuning.py +++ b/tests/test_agent_parameter_tuning.py @@ -32,6 +32,7 @@ build_initial_parameter_candidates, evaluate_parameter_candidate, execute_parameter_candidate, + fallback_parameter_tuning_report, FinalGraphComparison, FinalGraphNeedsInput, FinalGraphSelection, @@ -40,6 +41,7 @@ IntegrationCandidateEvaluation, IntegrationMetrics, parameter_batch_selection_prompt, + parameter_evaluation_payload, parameter_search_prompt, parameter_search_system_prompt, parameter_tuning_prompt, @@ -1865,6 +1867,56 @@ def unavailable_structured_output(**_kwargs: Any) -> None: assert result.runInfo.agentName == "parameter_tuning_fallback" +def test_parameter_fallback_does_not_select_without_successful_baseline() -> None: + candidates = [ + ParameterCandidate.get_example(), + ParameterCandidate(candidateId="pca_15", dimensions=15), + ParameterCandidate(candidateId="pca_30", dimensions=30), + ] + deps = _dependencies( + _FakeStore(), + candidates=candidates, + max_candidates=3, + ) + for candidate in candidates: + execute_parameter_candidate(deps, candidate.candidateId) + deps.evaluations["baseline"] = deps.evaluations["baseline"].model_copy( + update={ + "status": "failed", + "eligible": False, + "error": "baseline failed", + } + ) + + result = fallback_parameter_tuning_report( + deps, + search_plan=ParameterSearchPlan(status="complete"), + agent_name="parameter_tuning_fallback", + ) + + assert result.status == "needsInput" + assert result.recommendedCandidateId is None + assert result.needsInput is not None + assert result.needsInput.options == ["pca_15", "pca_30"] + + +def test_parameter_prompt_payload_is_bounded_and_excludes_artifacts() -> None: + evaluation = ParameterCandidateEvaluation.get_example().model_copy( + update={ + "warnings": ["w" * 700 for _index in range(12)], + "error": "e" * 700, + } + ) + + payload = parameter_evaluation_payload(evaluation) + + assert "artifacts" not in payload + assert "cellSelection" not in payload + assert len(payload["warnings"]) == 10 + assert all(len(warning) == 500 for warning in payload["warnings"]) + assert len(payload["error"]) == 500 + + def test_single_eligible_final_graph_skips_provider_selection() -> None: evaluation = ParameterCandidateEvaluation.get_example() evaluation.artifacts["clusters"] = ArtifactRecord( From c0e6ca7870a51bce0fb356b8cc2117b7e0798915 Mon Sep 17 00:00:00 2001 From: gautam8387 Date: Tue, 1 Sep 2026 00:45:30 +0200 Subject: [PATCH 02/21] local report --- .gitignore | 4 +- scarf/agent/__init__.py | 2 + scarf/agent/experimental_context.py | 34 +- scarf/agent/orchestrator/context.py | 10 +- scarf/agent/orchestrator/main.py | 41 +- scarf/agent/parameter_tuning.py | 36 +- scarf/agent/persistence.py | 2 + scarf/agent/report.py | 1532 ++++++++++++++++++++++ tests/test_agent_experimental_context.py | 9 + tests/test_agent_orchestrator.py | 10 + tests/test_agent_orchestrator_stages.py | 11 + tests/test_agent_parameter_tuning.py | 8 + tests/test_agent_report.py | 428 ++++++ 13 files changed, 2115 insertions(+), 12 deletions(-) create mode 100644 scarf/agent/report.py create mode 100644 tests/test_agent_report.py diff --git a/.gitignore b/.gitignore index f308f225..17f2c444 100644 --- a/.gitignore +++ b/.gitignore @@ -71,4 +71,6 @@ scarf/_version.py # Local working notes for countsT strip-shard experiments (tmp Modal hacks). /performance_reconfig.md .env -temp/ \ No newline at end of file +temp/ +# Dev testing scard reports +modal_scarf/ \ No newline at end of file diff --git a/scarf/agent/__init__.py b/scarf/agent/__init__.py index b3aec4b9..be5a2adb 100644 --- a/scarf/agent/__init__.py +++ b/scarf/agent/__init__.py @@ -79,6 +79,7 @@ load_agent_workflow, save_agent_report, ) +from .report import generate_agent_report from .runtime import check_runtime, load_env from .types import ( BatchSafetyEvidence, @@ -158,6 +159,7 @@ "decide", "detect_format", "get_default_parameter_candidates", + "generate_agent_report", "ingest", "load_env", "finalize_agent_workflow", diff --git a/scarf/agent/experimental_context.py b/scarf/agent/experimental_context.py index 774c75eb..2025edef 100644 --- a/scarf/agent/experimental_context.py +++ b/scarf/agent/experimental_context.py @@ -1969,6 +1969,34 @@ def validate_experimental_context( deps: ExperimentalContextDependencies, ) -> ExperimentalContextDecision: """Recompute and validate every model-authored design choice.""" + narrative_fields = { + "rationale": decision.rationale, + "batchCorrection.rationale": decision.batchCorrection.rationale, + "cellQc.rationale": decision.cellQc.rationale, + **{ + f"needsInput[{index}]": question + for index, question in enumerate(decision.needsInput) + }, + } + serialized_field_markers = ( + '"evidenceIds":', + '"needsInput":', + '"runInfo":', + '"batchCorrection":', + '"cellQc":', + ) + invalid_narratives = [ + name + for name, value in narrative_fields.items() + if any( + marker in value.replace('\\"', '"') for marker in serialized_field_markers + ) + ] + if invalid_narratives: + raise ModelRetry( + "Narrative fields must contain plain prose without serialized sibling " + f"fields: {invalid_narratives}" + ) directions = dict(deps.directions) column_domains = dict(decision.columnDomains) column_domains.update(dict(directions.get("columnDomains") or {})) @@ -2262,8 +2290,10 @@ def __init__( Cite only evidenceIds returned by tools. Ask for input when study design cannot be resolved. Never propose Python, shell commands, - direct Zarr access, or any datastore mutation. Return only fields - defined by the structured output schema. + direct Zarr access, or any datastore mutation. Every rationale and + question must be plain prose. Never place serialized JSON, schema + field names, or sibling output fields inside a narrative string. + Return only fields defined by the structured output schema. """ ) .strip() diff --git a/scarf/agent/orchestrator/context.py b/scarf/agent/orchestrator/context.py index 12492e06..5a9b77fb 100644 --- a/scarf/agent/orchestrator/context.py +++ b/scarf/agent/orchestrator/context.py @@ -24,7 +24,7 @@ AgentReportReference, AgentWorkflowRun, ) -from ..types import ArtifactReferenceModel +from ..types import AgentRunInfo, ArtifactReferenceModel from . import journal from .models import ( OrchestrationRequestRecord, @@ -812,13 +812,16 @@ def experimental_context_stage( "status": "done", "decision": decision, "notes": [*prior_report.notes, resolution_note], + "runInfo": AgentRunInfo( + agentName="experimental_context_resolution" + ), } ) actions.append(resolution_action) parent_reports.append( journal._report_link(paused.reportReferences[0]) ) - run_config = request_record.config.agentRunConfig + run_config = paused_record.invocation.runConfig else: logger.info( f"Workflow {workflow.workflowRunId}: invoking Experimental " @@ -857,6 +860,9 @@ def experimental_context_stage( for source in hto_identity_artifacts ], "unsafeResolution": unsafe_resolution, + "deterministicResolution": ( + actions[-1] if actions else None + ), }, artifacts=context_artifacts, runConfig=run_config, diff --git a/scarf/agent/orchestrator/main.py b/scarf/agent/orchestrator/main.py index 65a153c0..05e654ea 100644 --- a/scarf/agent/orchestrator/main.py +++ b/scarf/agent/orchestrator/main.py @@ -1,5 +1,6 @@ """Public controller for resumable automated Scarf agent workflows.""" +import os import time import uuid from collections.abc import Mapping @@ -9,6 +10,7 @@ import zarr from ...datastore.datastore import DataStore +from ...storage.stores import zarr_root_path from ...utils.logging import logger from .. import record_io from ..ingest import IngestResult, detect_format, ingest @@ -43,6 +45,32 @@ from .tuning import TuningStagesMixin +def _generate_completed_report( + store: DataStore, + workflow: AgentWorkflowRun, +) -> None: + """Generate a local report without changing the completed workflow result.""" + if workflow.status != "completed": + return + try: + if zarr_root_path(store.z) is None: + return + from ..report import generate_agent_report + + report_path = generate_agent_report(store, workflow.workflowRunId) + relative_path = os.path.relpath(report_path, start=Path.cwd()) + logger.info( + f"Workflow {workflow.workflowRunId}: local HTML report saved to " + f"{relative_path}" + ) + print(f"Agent workflow report: {relative_path}") + except Exception as exc: + logger.warning( + f"Workflow {workflow.workflowRunId}: local HTML report generation " + f"failed ({type(exc).__name__}: {exc})" + ) + + class AgentOrchestrator( ContextStagesMixin, PreprocessingStagesMixin, @@ -682,7 +710,9 @@ def validated_chain( result = result.model_copy( update={"contentSha256": journal._record_checksum(result)} ) - return journal._persist_terminal_result(store, prefix, workflow, result) + persisted = journal._persist_terminal_result(store, prefix, workflow, result) + _generate_completed_report(store, workflow) + return persisted def _continue( self, @@ -912,4 +942,11 @@ def _continue( f"Automated workflow {workflow.workflowRunId} completed with " f"{len(terminal.reports)} report(s)" ) - return journal._persist_terminal_result(store, prefix, terminal, completed) + persisted = journal._persist_terminal_result( + store, + prefix, + terminal, + completed, + ) + _generate_completed_report(store, terminal) + return persisted diff --git a/scarf/agent/parameter_tuning.py b/scarf/agent/parameter_tuning.py index d655329e..e3d6e2ac 100644 --- a/scarf/agent/parameter_tuning.py +++ b/scarf/agent/parameter_tuning.py @@ -774,7 +774,11 @@ def parameter_search_system_prompt() -> str: Cite only evidenceIds from the initial evaluations. Identify the successful initial candidates that motivate refinement, state focused objectives, and provide concrete stopping criteria. Do not invent metrics, artifacts, or - candidate ids. + candidate ids. Treat pcaSilhouette, macroF1, and weightedF1 only as PCA + cluster-separability metrics. Biological preservation evidence exists only + in a non-empty biologicalPreservation map. Check every exact value before + stating a ranking or trend, and keep narrative fields as plain prose + without serialized JSON. """ ).strip() @@ -869,7 +873,13 @@ def parameter_tuning_system_prompt(min_cluster_cells: int) -> str: Balance cluster separation, cluster sizes, batch mixing, and biological preservation. High batch mixing alone can indicate overcorrection, so do not collapse the metrics into an invented score. UMAP appearance is not - evidence for parameter quality. When multiple candidates complete, + evidence for parameter quality. Treat pcaSilhouette, macroF1, and + weightedF1 only as PCA cluster-separability metrics. Biological + preservation evidence exists only in a non-empty biologicalPreservation + map. Do not call any metric highest, lowest, improved, degraded, or + monotonic without checking its exact value across every relevant + candidate. Narrative fields contain plain prose only and must not contain + serialized JSON keys or objects. When multiple candidates complete, return one comparison for every non-selected successful candidate. Each comparison must cite evidence from both the selected candidate and that comparator. Return only model-owned selection fields. Leave evaluations, @@ -1002,7 +1012,12 @@ def parameter_batch_selection_system_prompt() -> str: exactly one grounded single-assay report in assayReports per assay. Apply eligibility, evidence, and comparison requirements independently. Do not invent joint scores, artifacts, candidates, or evidence. UMAP - appearance is not evidence. Inside each assay report, return only + appearance is not evidence. Treat pcaSilhouette, macroF1, and + weightedF1 only as PCA cluster-separability metrics; biological + preservation exists only when biologicalPreservation is non-empty. + Check all exact values before making ranking or trend claims, and keep + narrative fields as plain prose without serialized JSON. Inside each + assay report, return only model-owned selection, rationale, comparison, trade-off, limitation, evidence, and stop fields. Leave evaluations, selectedArtifacts, searchPlan, nested assayReports, integration fields, final graph fields, @@ -3271,12 +3286,23 @@ def tune_parameters_batch( planning_execution.output, ParameterTuningBatchSearchPlan ): raise TypeError("Batched parameter planner returned an unexpected type") - batch_plan = validate_parameter_batch_search_plan( + validated_batch_plan = validate_parameter_batch_search_plan( planning_execution.output, dependencies, initial_candidate_ids=initial_ids, max_refined_by_assay=max_refined_by_assay, - ).model_copy(update={"runInfo": planning_execution.runInfo}) + ) + batch_plan = validated_batch_plan.model_copy( + update={ + "assayPlans": { + assay: plan.model_copy( + update={"runInfo": planning_execution.runInfo} + ) + for assay, plan in validated_batch_plan.assayPlans.items() + }, + "runInfo": planning_execution.runInfo, + } + ) logger.info( "Completed batched parameter refinement plan: " + ", ".join( diff --git a/scarf/agent/persistence.py b/scarf/agent/persistence.py index 8cfcd463..1ccb29e4 100644 --- a/scarf/agent/persistence.py +++ b/scarf/agent/persistence.py @@ -857,6 +857,8 @@ def _report_references( relative = key.removeprefix(f"{run_prefix}/") if relative in {"workflow.json", "finalization.json"}: continue + if relative == "report" or relative.startswith("report/"): + continue parts = relative.split("/") if len(parts) != 3 or parts[2] != "report.json": raise ValueError(f"Unexpected agent workflow record {key!r}") diff --git a/scarf/agent/report.py b/scarf/agent/report.py new file mode 100644 index 00000000..cc141319 --- /dev/null +++ b/scarf/agent/report.py @@ -0,0 +1,1532 @@ +"""Local HTML reports for completed automated Scarf agent workflows. + +Reports are derived presentation files. They are written beside the immutable +agent records, but they are not Zarr components and never participate in +workflow checksums or artifact lineage. +""" + +import html +import json +import os +import re +import uuid +from collections import Counter +from collections.abc import Mapping, Sequence +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, cast + +from .. import __version__ +from ..datastore.datastore import DataStore +from ..storage.stores import zarr_root_path +from ..utils.logging import logger +from . import record_io +from .orchestrator import journal +from .orchestrator.models import ( + _STAGE_ORDER, + AutomatedWorkflowResult, + OrchestrationRequestRecord, + WorkflowStageAttempt, + artifact_model_to_ref, +) +from .persistence import ( + AgentWorkflowRun, + load_agent_report, + load_agent_workflow, +) + +MAX_MARKER_DOTPLOT_FEATURES = 24 +CLUSTER_COUNT_BLOCK_SIZE = 100_000 +MAX_EMBEDDING_PLOT_CELLS = 250_000 +MAX_DOTPLOT_CELLS = 75_000 +MAX_CONNECTIVITY_PLOT_CELLS = 100_000 +MAX_COMPOSITION_PLOT_CELLS = 1_000_000 + + +def _local_root(target: str | Path | DataStore) -> Path: + """Resolve a local filesystem root without accepting remote stores.""" + if isinstance(target, DataStore): + location = zarr_root_path(target.z) + if location is None: + raise ValueError("Agent HTML reports require a local filesystem store") + path = Path(location) + elif isinstance(target, Path): + path = target + elif isinstance(target, str) and target.startswith("file://"): + path = Path(target.removeprefix("file://")) + elif isinstance(target, str): + if "://" in target: + raise ValueError("Agent HTML reports require a local filesystem store") + path = Path(target) + else: + raise TypeError("Agent HTML reports require a local filesystem store") + path = path.expanduser().resolve() + if not path.is_dir(): + raise FileNotFoundError(path) + return path + + +def _open_datastore( + target: str | Path | DataStore, + root: Path, + workflow: AgentWorkflowRun, +) -> DataStore: + if isinstance(target, DataStore): + if target.workspace != workflow.workspace: + raise ValueError("Workflow workspace does not match the DataStore") + return target + default_assay = next(iter(workflow.datasetFingerprints)) + return DataStore( + str(root), + default_assay=default_assay, + min_features_per_cell=-1, + mito_pattern="", + ribo_pattern="", + zarr_mode="r", + workspace=workflow.workspace, + ) + + +def _load_request( + store: DataStore, + prefix: str, + workflow_run_id: str, +) -> OrchestrationRequestRecord: + record = cast( + OrchestrationRequestRecord, + journal._read_model( + store.zw, + journal._request_key(prefix, workflow_run_id), + OrchestrationRequestRecord, + ), + ) + if record.workflowRunId != workflow_run_id: + raise ValueError("Stored orchestration request belongs to another workflow") + if record.requestSha256 != journal._sha256_model(record.request): + raise ValueError("Stored orchestration request checksum is invalid") + if record.configSha256 != journal._sha256_model(record.config): + raise ValueError("Stored orchestration configuration checksum is invalid") + if record.contentSha256 != journal._record_checksum(record): + raise ValueError("Stored orchestration request envelope is invalid") + return record + + +def _load_completed_result( + store: DataStore, + workflow: AgentWorkflowRun, +) -> tuple[str, AutomatedWorkflowResult, OrchestrationRequestRecord]: + if workflow.status != "completed": + raise RuntimeError( + "Agent HTML reports can only be generated for completed workflows" + ) + prefix = journal._ensure_orchestration_store(store) + result = journal._load_terminal_result(store, prefix, workflow) + if result is None: + raise FileNotFoundError( + f"Completed workflow {workflow.workflowRunId!r} has no terminal result" + ) + if result.status != "completed" or result.finalAnalysis is None: + raise ValueError("Completed workflow result lacks its final analysis handoff") + request = _load_request(store, prefix, workflow.workflowRunId) + if request.request.workspace != workflow.workspace: + raise ValueError("Stored request workspace does not match the workflow") + return prefix, result, request + + +def _collect_reports( + store: DataStore, + result: AutomatedWorkflowResult, +) -> dict[str, list[dict[str, Any]]]: + reports: dict[str, list[dict[str, Any]]] = {} + for reference in result.reportReferences: + report = load_agent_report(store, reference) + reports.setdefault(reference.agentName, []).append( + report.model_dump(mode="json") + ) + return reports + + +def _stage_summary(attempt: WorkflowStageAttempt) -> dict[str, Any]: + duration = ( + (attempt.completedAtNs - attempt.startedAtNs) / 1_000_000_000 + if attempt.completedAtNs + else None + ) + error_type = None + if attempt.error: + candidate = attempt.error.partition(":")[0].strip() + error_type = ( + candidate + if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_.]{0,127}", candidate) + else "WorkflowStageError" + ) + return { + "stage": attempt.stage, + "attemptId": attempt.attemptId, + "status": attempt.status, + "durationSeconds": duration, + "actions": list(attempt.actions), + "reportCount": len(attempt.reportReferences), + "artifactCount": len(attempt.artifacts), + "artifacts": { + name: artifact.model_dump(mode="json") + for name, artifact in attempt.artifacts.items() + }, + "parentAttempts": [ + f"{parent.stage}:{parent.attemptId}" for parent in attempt.parentAttempts + ], + "questionIds": ( + [question.questionId for question in attempt.needsInput.questions] + if attempt.needsInput is not None + else [] + ), + "noteCount": len(attempt.notes), + "notes": list(attempt.notes), + "errorType": error_type, + } + + +def _collect_history( + store: DataStore, + prefix: str, + workflow: AgentWorkflowRun, + request: OrchestrationRequestRecord, +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + attempts: dict[tuple[str, str], WorkflowStageAttempt] = {} + summaries: list[tuple[int, dict[str, Any]]] = [] + for stage in _STAGE_ORDER: + starts = { + item.attemptId: item + for item in journal._stage_starts( + store.zw, prefix, workflow.workflowRunId, stage + ) + } + outcomes = { + item.attemptId: item + for item in journal._stage_outcomes( + store.zw, prefix, workflow.workflowRunId, stage + ) + } + if not set(outcomes).issubset(starts): + raise ValueError("Workflow history contains an outcome without a start") + for attempt_id, started in starts.items(): + attempt = outcomes.get(attempt_id, started) + identity = (stage, attempt_id) + if identity in attempts: + raise ValueError("Workflow history contains duplicate stage attempts") + attempts[identity] = attempt + summaries.append((attempt.startedAtNs, _stage_summary(attempt))) + + for attempt in attempts.values(): + for parent in attempt.parentAttempts: + observed = attempts.get((parent.stage, parent.attemptId)) + if ( + observed is None + or observed.status != "done" + or observed.contentSha256 != parent.contentSha256 + ): + raise ValueError("Workflow parent-stage lineage does not resolve") + + biological_reports = [ + reference + for reference in workflow.reports + if reference.agentName == "biological_interpretation" + ] + if not biological_reports: + raise ValueError("Completed workflow lacks a Biological Interpretation report") + terminal_report = biological_reports[-1] + terminal_candidates = [ + attempt + for attempt in attempts.values() + if attempt.stage == "biological_interpretation" + and attempt.status == "done" + and terminal_report in attempt.reportReferences + ] + if len(terminal_candidates) != 1: + raise ValueError("Completed workflow lacks one exact terminal stage attempt") + current = terminal_candidates[0] + terminal_chain: set[tuple[str, str]] = set() + while True: + identity = (current.stage, current.attemptId) + if identity in terminal_chain: + raise ValueError("Workflow stage lineage contains a cycle") + terminal_chain.add(identity) + if not journal._stage_outcome_resolves( + store, + prefix, + workflow.workflowRunId, + request, + current, + ): + raise ValueError("Terminal workflow stage artifacts do not resolve") + stage_index = _STAGE_ORDER.index(current.stage) + if stage_index == 0: + if current.parentAttempts: + raise ValueError("The ingest stage cannot have a parent") + break + if len(current.parentAttempts) != 1: + raise ValueError("Every terminal-chain stage must have one parent") + parent = current.parentAttempts[0] + if parent.stage != _STAGE_ORDER[stage_index - 1]: + raise ValueError("Terminal workflow lineage skips a stage") + current = attempts[(parent.stage, parent.attemptId)] + + resumes: list[dict[str, Any]] = [] + resume_prefix = record_io.join_key(prefix, workflow.workflowRunId, "resumes") + for key in record_io.list_keys(store.zw, resume_prefix): + if not key.endswith(".json"): + continue + resume_id = key.rsplit("/", 1)[-1].removesuffix(".json") + resume = journal._validated_resume_record( + store, prefix, workflow.workflowRunId, resume_id + ) + resumes.append( + { + "resumeId": resume.resumeId, + "createdAtNs": resume.createdAtNs, + "answeredStage": ( + resume.answeredAttempt.stage + if resume.answeredAttempt is not None + else None + ), + "answeredAttemptId": ( + resume.answeredAttempt.attemptId + if resume.answeredAttempt is not None + else None + ), + "questionIds": list(resume.questionIds), + } + ) + resumes.sort(key=lambda value: (value["createdAtNs"], value["resumeId"])) + ordered = sorted( + summaries, + key=lambda item: ( + item[0], + str(item[1]["stage"]), + str(item[1]["attemptId"]), + ), + ) + return [value for _, value in ordered], resumes + + +def _save_plot(plot: Any, path: Path) -> None: + """Atomically save one plot and its provenance, always closing its figure.""" + token = uuid.uuid4().hex + temporary = path.with_name(f".{path.stem}.{token}{path.suffix}") + sidecar = path.with_suffix(path.suffix + ".json") + temporary_sidecar = sidecar.with_name(f".{sidecar.stem}.{token}{sidecar.suffix}") + try: + plot.save(temporary, dpi=150) + plot.save_provenance(temporary_sidecar, figure_path=path, dpi=150) + os.replace(temporary, path) + os.replace(temporary_sidecar, sidecar) + finally: + temporary.unlink(missing_ok=True) + temporary_sidecar.unlink(missing_ok=True) + plot.close() + + +def _safe_assay_name(value: str, fallback: str) -> str: + label = "_".join(part.lower() for part in re.findall(r"[A-Za-z0-9]+", value)) + return label[:64].rstrip("_") or fallback + + +def _collect_final_artifacts( + store: DataStore, + result: AutomatedWorkflowResult, + plot_dir: Path, +) -> tuple[ + dict[str, int], + list[dict[str, Any]], + dict[str, str], + list[str], +]: + """Validate the final handoff and derive bounded tables and plots.""" + import numpy as np + + final = result.finalAnalysis + assert final is not None + if final.cellSelection is None or final.clusters is None or final.umap is None: + raise ValueError("Final handoff lacks its selection, clusters, or UMAP") + + artifact_models = [ + final.cellSelection, + final.graph, + final.clusters, + final.embeddingInitialization, + final.umap, + final.markerFeatures, + final.markers, + ] + for native in final.nativeAnalyses: + artifact_models.extend( + [ + native.featureSelection, + native.markerFeatures, + native.normalized, + native.reduction, + native.batchCorrection, + native.annIndex, + native.embeddingInitialization, + native.neighbors, + native.graph, + native.clusters, + native.umap, + ] + ) + for artifact in artifact_models: + if artifact is not None: + store.load_artifact(artifact_model_to_ref(artifact)) + + cluster_ref = artifact_model_to_ref(final.clusters) + umap_ref = artifact_model_to_ref(final.umap) + cluster_artifact: Any = store.load_artifact(cluster_ref) + values = cluster_artifact["values"] + counts: Counter[str] = Counter() + for start in range(0, int(values.shape[0]), CLUSTER_COUNT_BLOCK_SIZE): + block = np.asarray(values[start : start + CLUSTER_COUNT_BLOCK_SIZE]).astype(str) + block_labels, frequencies = np.unique(block, return_counts=True) + counts.update( + { + str(label): int(frequency) + for label, frequency in zip(block_labels, frequencies, strict=True) + } + ) + cluster_counts = dict(sorted(counts.items())) + cluster_labels = list(cluster_counts) + n_cells = sum(cluster_counts.values()) + plots: dict[str, str] = {} + notes: list[str] = [] + plot_dir.mkdir(parents=True, exist_ok=True) + + def render_plot(name: str, filename: str, create: Any) -> None: + try: + path = plot_dir / filename + _save_plot(create(), path) + plots[name] = f"plots/{filename}" + except Exception as exc: + notes.append(f"{name}: {type(exc).__name__}: {exc}") + + if n_cells <= MAX_EMBEDDING_PLOT_CELLS: + render_plot( + "umapClusters", + "final_umap.png", + lambda: store.plots.embedding( + layout=umap_ref, + color_by=cluster_ref, + show=False, + ), + ) + else: + notes.append( + "umapClusters: skipped because the final selection has " + f"{n_cells:,} cells, above the memory-safe report limit of " + f"{MAX_EMBEDDING_PLOT_CELLS:,}" + ) + + observed_native_names: set[str] = set() + for index, native in enumerate(final.nativeAnalyses): + if native.umap is None or native.clusters is None: + continue + native_umap = artifact_model_to_ref(native.umap) + native_clusters = artifact_model_to_ref(native.clusters) + if native_umap == umap_ref and native_clusters == cluster_ref: + continue + suffix = _safe_assay_name(native.assay, f"assay_{index + 1}") + base_name = "nativeUmap" + "".join( + part.capitalize() for part in suffix.split("_") + ) + plot_name = base_name + serial = 1 + while plot_name in observed_native_names: + serial += 1 + plot_name = f"{base_name}{serial}" + observed_native_names.add(plot_name) + file_suffix = suffix if serial == 1 else f"{suffix}_{serial}" + if n_cells <= MAX_EMBEDDING_PLOT_CELLS: + render_plot( + plot_name, + f"native_umap_{file_suffix}.png", + lambda layout=native_umap, color=native_clusters: store.plots.embedding( + layout=layout, color_by=color, show=False + ), + ) + else: + notes.append( + f"{plot_name}: skipped because {n_cells:,} cells exceed the " + f"memory-safe report limit of {MAX_EMBEDDING_PLOT_CELLS:,}" + ) + + if n_cells <= MAX_COMPOSITION_PLOT_CELLS: + render_plot( + "clusterComposition", + "cluster_composition.png", + lambda: store.plots.composition( + categories=cluster_ref, + show_percent_labels=len(cluster_labels) <= 12, + show=False, + ), + ) + else: + notes.append( + "clusterComposition: skipped because the final selection has " + f"{n_cells:,} cells, above the memory-safe report limit of " + f"{MAX_COMPOSITION_PLOT_CELLS:,}" + ) + + top_markers: list[dict[str, Any]] = [] + if final.markers is not None: + marker_ref = artifact_model_to_ref(final.markers) + marker_parameters = store.inspect_artifact(marker_ref).parameters or {} + raw_normalization = marker_parameters.get("normalization", {}) + marker_normalization = ( + dict(raw_normalization) if isinstance(raw_normalization, Mapping) else {} + ) + marker_log_transform = marker_normalization.get("log_transform", False) is True + if marker_normalization.get("renormalize_subset", False) is True: + notes.append( + "marker visualizations: the persisted marker search renormalized " + "its feature subset; current plotting APIs preserve its log " + "transform but visualize assay-wide normalized values" + ) + for label in cluster_labels: + try: + table = store.get_markers( + marker_ref, + group_id=label, + min_score=-1, + min_frac_exp=-1, + ) + if not table.empty: + if "score" in table: + table = table.sort_values( + "score", ascending=False, kind="stable" + ) + top_markers.extend( + json.loads(table.head(5).to_json(orient="records")) + ) + except Exception as exc: + notes.append( + f"marker export for cluster {label}: {type(exc).__name__}: {exc}" + ) + + render_plot( + "markerHeatmap", + "marker_heatmap.png", + lambda: store.plots.marker_heatmap( + marker=marker_ref, + log_transform=marker_log_transform, + show=False, + ), + ) + + try: + from ..plotting import FeatureRef, NormalizationSpec + + by_cluster: dict[str, list[tuple[tuple[str, str], Any]]] = { + label: [] for label in cluster_labels + } + for marker in top_markers: + group_id = str(marker.get("group_id", "")) + if group_id not in by_cluster: + continue + feature_name = marker.get("feature_name") + feature_id = marker.get("feature_id") + feature_index = marker.get("feature_index") + label = str(feature_name or feature_id or feature_index or "") + if isinstance(feature_index, (int, float)): + identity = ("index", str(int(feature_index))) + feature = FeatureRef( + value=int(feature_index), + assay=final.markerAssay, + by="index", + label=label, + ) + elif isinstance(feature_id, str) and feature_id: + identity = ("id", feature_id) + feature = FeatureRef( + value=feature_id, + assay=final.markerAssay, + by="id", + label=label, + ) + else: + continue + if all(observed != identity for observed, _ in by_cluster[group_id]): + by_cluster[group_id].append((identity, feature)) + + marker_groups: dict[str, list[Any]] = {} + selected: set[tuple[str, str]] = set() + max_rank = max(map(len, by_cluster.values()), default=0) + rank = 0 + while rank < max_rank and len(selected) < MAX_MARKER_DOTPLOT_FEATURES: + for cluster in cluster_labels: + features = by_cluster[cluster] + if rank >= len(features): + continue + identity, feature = features[rank] + if identity in selected: + continue + marker_groups.setdefault(f"Cluster {cluster}", []).append(feature) + selected.add(identity) + if len(selected) == MAX_MARKER_DOTPLOT_FEATURES: + break + rank += 1 + if marker_groups and n_cells <= MAX_DOTPLOT_CELLS: + render_plot( + "markerDotplot", + "marker_dotplot.png", + lambda: store.plots.dotplot( + features=marker_groups, + groups=cluster_ref, + from_assay=final.markerAssay, + normalization=NormalizationSpec( + source="assay", + transform=("log1p" if marker_log_transform else "none"), + ), + standardize="feature", + show=False, + ), + ) + elif marker_groups: + notes.append( + "markerDotplot: skipped because the final selection has " + f"{n_cells:,} cells, above the memory-safe report limit of " + f"{MAX_DOTPLOT_CELLS:,}" + ) + except Exception as exc: + notes.append(f"markerDotplot: {type(exc).__name__}: {exc}") + + if final.graph is not None: + graph_ref = artifact_model_to_ref(final.graph) + if n_cells <= MAX_CONNECTIVITY_PLOT_CELLS: + render_plot( + "clusterConnectivity", + "cluster_connectivity.png", + lambda: store.plots.cluster_connectivity( + groups=cluster_ref, + layout=umap_ref, + graph=graph_ref, + show=False, + ), + ) + else: + notes.append( + "clusterConnectivity: skipped because the final selection has " + f"{n_cells:,} cells, above the memory-safe report limit of " + f"{MAX_CONNECTIVITY_PLOT_CELLS:,}" + ) + return cluster_counts, top_markers, plots, notes + + +REPORT_STYLES = """ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400&display=swap'); + +:root { + --blue: #0077fc; + --black: #000000; + --gray: #b4b4b4; + --white: #ffffff; +} + +* { box-sizing: border-box; } +html { background: var(--white); color: var(--black); font-family: Inter, sans-serif; } +body { + margin: 0; + background: var(--white); + color: var(--black); + font-family: Inter, sans-serif; + font-weight: 300; + letter-spacing: -0.04em; + line-height: 1.2; +} +a { color: var(--blue); } +header, main, footer { + width: min(100%, 1240px); + margin: 0 auto; + padding-left: clamp(1.25rem, 5vw, 4.5rem); + padding-right: clamp(1.25rem, 5vw, 4.5rem); +} +header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + border-bottom: 1px solid var(--black); + padding-top: 1.75rem; + padding-bottom: 1.75rem; +} +.brand { + color: var(--black); + font-size: 1rem; + font-weight: 400; + text-decoration: none; +} +main { padding-top: clamp(3rem, 8vw, 7rem); padding-bottom: 6rem; } +footer { + border-top: 1px solid var(--black); + padding-top: 2rem; + padding-bottom: 2rem; +} +h1, h2, h3, p { margin-top: 0; } +h1 { + max-width: 15ch; + margin-bottom: 1.5rem; + font-size: clamp(2.75rem, 7vw, 5rem); + font-weight: 400; + letter-spacing: 0; + line-height: 1.2; +} +h2 { + margin-bottom: 1.5rem; + font-size: clamp(1.65rem, 3vw, 2.25rem); + font-weight: 400; + letter-spacing: -0.04em; + line-height: 1.2; +} +h3 { + margin-bottom: .8rem; + font-size: 1rem; + font-weight: 300; + letter-spacing: -0.04em; + line-height: 1.2; +} +p, li, td, th, summary, code, pre, a { + font-family: Inter, sans-serif; + letter-spacing: -0.04em; + line-height: 1.2; +} +strong { font-weight: 400; } +.eyebrow { + margin-bottom: 1rem; + color: var(--gray); + font-size: .75rem; + font-weight: 400; + text-transform: uppercase; +} +.lead { + max-width: 48ch; + font-size: clamp(1.2rem, 2vw, 1.7rem); + font-weight: 300; +} +.pill-row, .chip-row, .metric-grid { + display: flex; + flex-wrap: wrap; + gap: .65rem; +} +.pill-row { margin-top: 1.75rem; } +.pill, .chip { + display: inline-flex; + align-items: center; + border-radius: 999px; + font-size: .82rem; + font-weight: 400; + line-height: 1.2; +} +.pill { + border: 1px solid var(--blue); + padding: .68rem 1.1rem; + background: var(--blue); + color: var(--white); + text-decoration: none; +} +.pill-outline { + background: var(--white); + box-shadow: inset 0 0 0 1px var(--blue); + color: var(--blue); +} +.chip { + box-shadow: inset 0 0 0 1px var(--blue); + padding: .42rem .75rem; + color: var(--black); +} +.metric-grid { margin-top: 2rem; } +.metric { + display: flex; + min-width: 9rem; + flex-direction: column; + gap: .2rem; + border-radius: 999px; + box-shadow: inset 0 0 0 1px var(--blue); + padding: .8rem 1.2rem; +} +.metric-label { + color: var(--gray); + font-size: .68rem; + font-weight: 400; + text-transform: uppercase; +} +.metric-value { font-size: .95rem; font-weight: 400; overflow-wrap: anywhere; } +.section { margin-top: 4rem; border-top: 1px solid var(--black); padding-top: 1.5rem; } +.section:target { scroll-margin-top: 1rem; } +.section-heading { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 1rem; + align-items: start; +} +.subsection { margin-top: 2rem; } +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr)); + gap: 1rem; +} +.card, .callout { + border: 1px solid var(--black); + padding: 1.25rem; + background: var(--white); +} +.callout { border-color: var(--blue); } +.product-callout { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 1rem; + align-items: center; + margin-top: 2.5rem; + border-radius: 999px; + box-shadow: inset 0 0 0 1px var(--blue); + padding: 1.4rem; +} +.product-callout p { margin-bottom: 0; max-width: 55rem; } +.empty { color: var(--gray); font-style: italic; } +.table-wrap { width: 100%; overflow-x: auto; } +table { width: 100%; border-collapse: collapse; font-size: .86rem; } +th, td { + border-bottom: 1px solid var(--black); + padding: .8rem .7rem; + text-align: left; + vertical-align: top; +} +th { color: var(--gray); font-weight: 400; text-transform: uppercase; } +td { font-weight: 300; overflow-wrap: anywhere; } +tr.selected { box-shadow: inset 4px 0 0 var(--blue); } +dl { margin: 0; } +.details > div { + display: grid; + grid-template-columns: minmax(8rem, 14rem) minmax(0, 1fr); + gap: 1rem; + border-bottom: 1px solid var(--gray); + padding: .55rem 0; +} +dt { color: var(--gray); font-size: .78rem; font-weight: 400; text-transform: uppercase; } +dd { margin: 0; overflow-wrap: anywhere; } +.plot-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 26rem), 1fr)); + gap: 2rem; +} +figure { margin: 0; } +figure.primary { grid-column: 1 / -1; } +figure img { display: block; width: 100%; height: auto; border: 1px solid var(--black); } +figcaption { margin-top: .7rem; color: var(--black); font-size: .85rem; } +.cluster-row { + display: grid; + grid-template-columns: minmax(5rem, auto) minmax(8rem, 1fr) auto; + gap: .7rem; + align-items: center; + margin: .5rem 0; +} +.cluster-track { height: .7rem; border-radius: 999px; background: var(--gray); overflow: hidden; } +.cluster-fill { height: 100%; border-radius: 999px; background: var(--blue); } +.text-list { padding-left: 1.2rem; } +.text-list li { margin: .45rem 0; } +details { margin-top: 1rem; border-top: 1px solid var(--gray); padding-top: .8rem; } +summary { cursor: pointer; font-weight: 400; } +pre { + max-height: 36rem; + overflow: auto; + background: var(--white); + box-shadow: inset 0 0 0 1px var(--blue); + padding: 1rem; + font-size: .76rem; + white-space: pre-wrap; + word-break: break-word; +} +@media (max-width: 680px) { + .section-heading, .product-callout { grid-template-columns: 1fr; } + .details > div { grid-template-columns: 1fr; gap: .25rem; } +} +""" + + +def _present(value: Any) -> bool: + return value is not None and value != "" and value != [] and value != {} + + +def _label(value: Any) -> str: + text = str(value).replace("_", " ").strip() + words: list[str] = [] + for index, character in enumerate(text): + if ( + index + and character.isupper() + and not text[index - 1].isupper() + and text[index - 1] != " " + ): + words.append(" ") + words.append(character) + text = "".join(words) + return text[:1].upper() + text[1:] + + +def _scalar(value: Any) -> str: + if value is None or value == "": + return "Not provided" + if isinstance(value, bool): + return "Yes" if value else "No" + if isinstance(value, int): + return f"{value:,}" + if isinstance(value, float): + if value == 0: + return "0" + if abs(value) < 0.001 or abs(value) >= 10_000: + return f"{value:.3g}" + return f"{value:.3f}".rstrip("0").rstrip(".") + return str(value) + + +def _mapping(value: Any) -> dict[str, Any]: + return dict(value) if isinstance(value, Mapping) else {} + + +def _mappings(value: Any) -> list[dict[str, Any]]: + if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)): + return [] + return [dict(item) for item in value if isinstance(item, Mapping)] + + +def _chips(value: Any, empty: str = "Not provided") -> str: + if not _present(value): + return f'{html.escape(empty)}' + if isinstance(value, Mapping): + items = [f"{_label(key)}: {_scalar(item)}" for key, item in value.items()] + elif isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + items = list(value) + else: + items = [value] + return '{}'.format( + "".join( + f'{html.escape(_scalar(item))}' for item in items + ) + ) + + +def _value(value: Any) -> str: + if not _present(value): + return 'Not provided' + if isinstance(value, Mapping): + rows = "".join( + f"
{html.escape(_label(key))}
{_value(item)}
" + for key, item in value.items() + if _present(item) + ) + return f'
{rows}
' + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + if all(not isinstance(item, Mapping) for item in value): + return _chips(value) + return '
{}
'.format( + "".join(f'
{_value(item)}
' for item in value) + ) + return html.escape(_scalar(value)) + + +def _table( + rows: Sequence[Mapping[str, Any]], + *, + columns: Sequence[str] | None = None, + empty: str = "No records available.", +) -> str: + normalized = [dict(row) for row in rows] + if not normalized: + return f'

{html.escape(empty)}

' + visible = list(columns or ()) or list( + dict.fromkeys( + key for row in normalized for key in row if not str(key).startswith("_") + ) + ) + headings = "".join(f"{html.escape(_label(key))}" for key in visible) + body = "".join( + ('' if row.get("_selected") else "") + + "".join(f"{_value(row.get(key))}" for key in visible) + + "" + for row in normalized + ) + return ( + '
' + f"{headings}{body}
" + ) + + +def _latest(reports: Mapping[str, Any], agent_name: str) -> dict[str, Any]: + values = reports.get(agent_name) + if isinstance(values, Mapping): + return dict(values) + if isinstance(values, Sequence) and not isinstance(values, (str, bytes, bytearray)): + for value in reversed(values): + if isinstance(value, Mapping): + return dict(value) + return {} + + +def _render_plots(plots: Mapping[str, str], notes: Sequence[str]) -> str: + titles = { + "umapClusters": ( + "Final UMAP by cluster", + "The selected final representation, colored by final cluster.", + ), + "markerHeatmap": ( + "Marker heatmap", + "Marker-feature patterns across the final clusters.", + ), + "markerDotplot": ( + "Marker dot plot", + "A bounded expression summary for exact exported marker features.", + ), + "clusterComposition": ( + "Cluster composition", + "The relative size of each cluster in the final cell selection.", + ), + "clusterConnectivity": ( + "Cluster connectivity", + "Connectivity between final clusters in the selected graph.", + ), + } + order = [ + "umapClusters", + *(name for name in plots if name.startswith("nativeUmap")), + "markerHeatmap", + "markerDotplot", + "clusterComposition", + "clusterConnectivity", + *plots, + ] + figures: list[str] = [] + for name in dict.fromkeys(order): + source = plots.get(name) + if source is None: + continue + if name.startswith("nativeUmap"): + assay = name.removeprefix("nativeUmap") or "assay" + title = f"{assay} native UMAP" + caption = f"The finalized native {assay} representation and clusters." + else: + title, caption = titles.get( + name, (_label(name), "A finalized Scarf analysis plot.") + ) + escaped_source = html.escape(source, quote=True) + provenance = html.escape(source + ".json", quote=True) + plot_class = ' class="primary"' if name == "umapClusters" else "" + figures.append( + f"" + f'' + f"
{html.escape(title)}
" + f"{html.escape(caption)} " + f'Plot provenance
' + ) + if not figures: + plot_markup = ( + '

No plots could be rendered. The structured ' + "analysis remains available below. Install Scarf with the " + "extra dependency group to enable plotting.

" + ) + else: + plot_markup = f'
{"".join(figures)}
' + note_markup = "" + if notes: + note_markup = ( + "
Plot availability notes" + '
    ' + + "".join(f"
  • {html.escape(note)}
  • " for note in notes) + + "
" + ) + return plot_markup + note_markup + + +def _render_clusters(cluster_counts: Mapping[str, int]) -> str: + if not cluster_counts: + return '

No final cluster counts were available.

' + maximum = max(cluster_counts.values(), default=1) or 1 + return "".join( + '
' + f"Cluster {html.escape(str(label))}" + '' + f'' + "" + f"{count:,}
" + for label, count in cluster_counts.items() + ) + + +def _parameter_rows(parameter: Mapping[str, Any]) -> list[dict[str, Any]]: + assay_reports = _mapping(parameter.get("assayReports")) + if not assay_reports and _present(parameter.get("evaluations")): + assay_reports = {str(parameter.get("fromAssay") or "Primary"): dict(parameter)} + recommended = _mapping(parameter.get("recommendedByAssay")) + rows: list[dict[str, Any]] = [] + for assay, raw_report in assay_reports.items(): + report = _mapping(raw_report) + selected = recommended.get(assay) or report.get("recommendedCandidateId") + for evaluation in _mappings(report.get("evaluations")): + parameters = _mapping(evaluation.get("parameters")) + rows.append( + { + "_selected": evaluation.get("candidateId") == selected, + "assay": assay, + "candidate": evaluation.get("candidateId"), + "phase": evaluation.get("phase"), + "status": evaluation.get("status"), + "eligible": evaluation.get("eligible"), + "selection confidence": report.get("confidence"), + "reduction": parameters.get("reductionMethod"), + "dimensions": parameters.get("dimensions"), + "neighbors K": parameters.get("neighborsK"), + "resolution": parameters.get("leidenResolution"), + "Harmony": parameters.get("useHarmony"), + "metrics": evaluation.get("metrics"), + } + ) + return rows + + +def _render_parameter_tuning(parameter: Mapping[str, Any]) -> str: + if not parameter: + return '

No Parameter Tuning report was persisted.

' + candidate_rows = _parameter_rows(parameter) + integration_rows = _mappings(parameter.get("integrationEvaluations")) + plans: list[dict[str, Any]] = [] + comparisons: list[dict[str, Any]] = [] + root_plan = _mapping(parameter.get("searchPlan")) + if root_plan: + plans.append({"assay": parameter.get("fromAssay"), **root_plan}) + for comparison in _mappings(parameter.get("comparisons")): + comparisons.append( + {"scope": parameter.get("fromAssay") or "primary assay", **comparison} + ) + for assay, report in _mapping(parameter.get("assayReports")).items(): + assay_report = _mapping(report) + plan = _mapping(assay_report.get("searchPlan")) + if plan and plan not in plans: + plans.append({"assay": assay, **plan}) + for comparison in _mappings(assay_report.get("comparisons")): + comparisons.append({"scope": assay, **comparison}) + final_selection = _mapping(parameter.get("finalSelection")) + for comparison in _mappings(final_selection.get("comparisons")): + comparisons.append({"scope": "final graph", **comparison}) + narrative = { + "status": parameter.get("status"), + "totalCandidates": parameter.get("totalCandidates"), + "recommendedByAssay": parameter.get("recommendedByAssay"), + "recommendedIntegrationId": parameter.get("recommendedIntegrationId"), + "confidence": parameter.get("confidence"), + "rationale": parameter.get("rationale"), + "tradeoffs": parameter.get("tradeoffs"), + "stopReason": parameter.get("stopReason"), + "finalSelection": final_selection, + } + return ( + '

Final graph selection

' + f"{_value(narrative)}
" + '

Native and Harmony candidates

' + f"{_table(candidate_rows, empty='No native candidates were recorded.')}
" + '

SNN and WNN integration candidates

' + f"{_table(integration_rows, empty='No integration candidates were eligible.')}
" + '

Model-authored comparisons

' + f"{_table(comparisons, empty='No candidate comparisons were required.')}
" + '

Bounded search plans

' + f"{_value(plans) if plans else '

No refinement plan was requested.

'}" + "
" + ) + + +def _execution_rows(reports: Mapping[str, Any]) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + seen: set[tuple[str, str, str]] = set() + + def visit(value: Any, stage: str, path: tuple[str, ...]) -> None: + if isinstance(value, Mapping): + usage = value.get("usage") + agent_name = value.get("agentName") + if ( + isinstance(usage, Mapping) + and isinstance(agent_name, str) + and agent_name.strip() + ): + run_id = str(value.get("runId") or "") + identity = (agent_name, run_id, str(value.get("modelName") or "")) + if identity not in seen: + seen.add(identity) + rows.append( + { + "agent stage": _label(stage), + "execution": _label(path[-1]) if path else agent_name, + "agent": agent_name, + "run ID": run_id or "deterministic", + "model": value.get("modelName") or "not applicable", + "duration seconds": value.get("durationSeconds"), + "requests": usage.get("requests", 0), + "tool calls": usage.get("toolCalls", 0), + "input tokens": usage.get("inputTokens", 0), + "output tokens": usage.get("outputTokens", 0), + "total tokens": usage.get("totalTokens", 0), + } + ) + for key, item in value.items(): + visit(item, stage, (*path, str(key))) + elif isinstance(value, Sequence) and not isinstance( + value, (str, bytes, bytearray) + ): + for index, item in enumerate(value): + visit(item, stage, (*path, str(index + 1))) + + for stage, records in reports.items(): + visit(records, str(stage), ()) + return rows + + +def _render_executions(reports: Mapping[str, Any]) -> str: + rows = _execution_rows(reports) + if not rows: + return '

No provider execution metadata was recorded.

' + totals = { + "recorded executions": len(rows), + "provider executions": sum( + int( + bool(row["model"] != "not applicable") + or int(row["requests"] or 0) > 0 + or int(row["input tokens"] or 0) > 0 + or int(row["output tokens"] or 0) > 0 + ) + for row in rows + ), + "requests": sum(int(row["requests"] or 0) for row in rows), + "tool calls": sum(int(row["tool calls"] or 0) for row in rows), + "input tokens": sum(int(row["input tokens"] or 0) for row in rows), + "output tokens": sum(int(row["output tokens"] or 0) for row in rows), + "total tokens": sum(int(row["total tokens"] or 0) for row in rows), + } + return ( + '

Recorded totals

' + f'{_chips(totals)}
{_table(rows)}
' + ) + + +def _render_timeline( + attempts: Sequence[Mapping[str, Any]], + resumes: Sequence[Mapping[str, Any]], +) -> str: + artifacts: list[dict[str, Any]] = [] + for attempt in attempts: + for name, reference in _mapping(attempt.get("artifacts")).items(): + artifact = _mapping(reference) + artifacts.append( + { + "stage": attempt.get("stage"), + "attempt": attempt.get("attemptId"), + "name": name, + "scope": artifact.get("scope"), + "assay": artifact.get("assay"), + "kind": artifact.get("kind"), + "artifact ID": artifact.get("artifactId"), + } + ) + return ( + "

Stage attempts

" + + _table( + attempts, + columns=( + "stage", + "status", + "durationSeconds", + "actions", + "reportCount", + "artifactCount", + "parentAttempts", + "questionIds", + "noteCount", + "errorType", + ), + ) + + '

Stage artifact inventory

' + + _table(artifacts, empty="No stage artifacts were recorded.") + + "
" + + '

Resume lineage

' + + _table( + resumes, + columns=( + "resumeId", + "answeredStage", + "answeredAttemptId", + "questionIds", + ), + empty="No resume was required.", + ) + + "
" + ) + + +def _render_document(payload: Mapping[str, Any]) -> str: + reports = _mapping(payload.get("reports")) + workflow_result = _mapping(payload.get("workflowResult")) + request = _mapping(payload.get("request")) + final = _mapping(workflow_result.get("finalAnalysis")) + plan = _mapping(workflow_result.get("preprocessingPlan")) + enrichment = _latest(reports, "data_enrichment") + experimental = _latest(reports, "experimental_context") + parameter = _latest(reports, "parameter_tuning") + biology = _latest(reports, "biological_interpretation") + cluster_counts = { + str(key): int(value) + for key, value in _mapping(payload.get("clusterCounts")).items() + } + top_markers = _mappings(payload.get("topMarkers")) + plots = { + str(key): str(value) + for key, value in _mapping(payload.get("plotFiles")).items() + } + plot_notes = [str(item) for item in payload.get("plotNotes", [])] + attempts = _mappings(payload.get("stageAttempts")) + resumes = _mappings(payload.get("workflowResumes")) + workflow = _mapping(workflow_result.get("workflowRun")) + workflow_id = str(workflow.get("workflowRunId") or "unavailable") + total_cells = sum(cluster_counts.values()) + assay_plans = _mappings(plan.get("assays")) + assays = [str(item.get("assay")) for item in assay_plans if item.get("assay")] + metrics = [ + ("Final cells", total_cells or None), + ("Final clusters", len(cluster_counts) or None), + ("Assays", ", ".join(assays) or None), + ("Candidates", parameter.get("totalCandidates")), + ("Selected graph", final.get("graphMethod")), + ("Marker assay", final.get("markerAssay")), + ] + metric_markup = "".join( + '' + f'{html.escape(label)}' + f'{html.escape(_scalar(value))}' + for label, value in metrics + if _present(value) + ) + interpretation = { + "status": biology.get("status"), + "clusterInterpretations": biology.get("clusterInterpretations"), + "evidenceIds": biology.get("evidenceIds"), + "stopReason": biology.get("stopReason"), + } + study = enrichment.get("studyContextSummary") or { + "originalContext": request.get("studyContext") + } + enrichment_summary = { + "status": enrichment.get("status"), + "policies": enrichment.get("policies"), + "inspections": enrichment.get("inspections"), + "evidenceIds": enrichment.get("evidenceIds"), + "unresolvedQuestions": enrichment.get("unresolvedQuestions"), + } + experimental_summary = { + "status": experimental.get("status"), + "decision": experimental.get("decision"), + "cellQc": experimental.get("cellQc"), + "qcProfiles": experimental.get("qcProfiles"), + "batchSafety": experimental.get("batchSafety"), + "characterization": experimental.get("characterization"), + } + preprocessing_summary = { + "primaryAssay": plan.get("primaryAssay"), + "markerAssay": plan.get("markerAssay"), + "pairedAssays": plan.get("pairedAssays"), + "cellQc": plan.get("cellQc"), + "assays": plan.get("assays"), + "planChecksum": plan.get("planChecksum"), + } + limitations = { + "Data Enrichment": enrichment.get("limitations"), + "Experimental Context": experimental.get("notes"), + "Parameter Tuning": parameter.get("limitations"), + "Biological Interpretation": biology.get("limitations"), + "Final analysis": final.get("limitations"), + "Workflow": workflow_result.get("notes"), + "Plots": plot_notes, + } + limitations = {key: value for key, value in limitations.items() if _present(value)} + marker_columns = [ + key + for key in ( + "group_id", + "feature_name", + "feature_id", + "score", + "frac_exp", + "fold_change", + "p_value", + ) + if any(key in row for row in top_markers) + ] + provenance: list[dict[str, Any]] = [ + {"field": "Workflow run ID", "value": workflow_id}, + {"field": "Scarf version", "value": __version__}, + {"field": "Workspace", "value": workflow.get("workspace")}, + {"field": "Analysis store", "value": workflow.get("analysisStore")}, + {"field": "Dataset fingerprints", "value": workflow.get("datasetFingerprints")}, + {"field": "Generated at", "value": payload.get("generatedAt")}, + {"field": "Source path", "value": request.get("sourcePath")}, + ] + raw_json = json.dumps( + payload, indent=2, sort_keys=True, ensure_ascii=False, default=str + ) + title = f"Scarf agent report {workflow_id}" + return f""" + + + + + {html.escape(title)} + + + +
+ Nygen Analytics + Scarf agent workflow +
+
+

Completed analysis

+

Evidence from an automated analysis.

+

The workflow completed and its selected artifacts, decisions, and biological interpretation are summarized here.

+
+ Completed + {html.escape(_label(workflow_result.get("currentStage") or "completed"))} +
+
{metric_markup}
+ + + + +
+

Visual results

Persisted artifacts
+ {_render_plots(plots, plot_notes)} +
+ +
+

Biological interpretation

+ {_value(interpretation)} +

Final cluster sizes

{_render_clusters(cluster_counts)}
+

Top marker evidence

{_table(top_markers, columns=marker_columns, empty="No marker table was available.")}
+
+ +

Treatment observations

{_value(biology.get("treatmentObservations"))}
+

Follow-up recommendations

{_value(biology.get("followUps"))}
+ +

Study context

{_value(study)}
+

Data enrichment

{_value(enrichment_summary)}
+

Experimental design

{_value(experimental_summary)}
+

Preprocessing plan

{_value(preprocessing_summary)}
+ +

Parameter tuning and graph selection

{_render_parameter_tuning(parameter)}
+

Workflow execution

{_render_timeline(attempts, resumes)}
+

Agent execution

{_render_executions(reports)}
+

Limitations and workflow notes

{_value(limitations) if limitations else '

No limitations were recorded.

'}
+ +
+

Technical provenance

+ {_table(provenance)} +
Final immutable artifact references{_value(final)}
+
Structured report data
{html.escape(raw_json)}
+
+
+ + + +""" + + +def generate_agent_report( + target: str | Path | DataStore, + workflow_run_id: str, + *, + workspace: str | None = None, +) -> Path: + """Generate a local HTML report for one completed automated workflow. + + The returned path points to ``index.html`` beneath the workflow's report + directory. Existing derived report files may be replaced; immutable agent + and orchestration records are only read. + """ + root = _local_root(target) + resolved_workspace = ( + target.workspace if isinstance(target, DataStore) else workspace + ) + if ( + isinstance(target, DataStore) + and workspace is not None + and workspace != target.workspace + ): + raise ValueError("workspace does not match the DataStore workspace") + workflow = load_agent_workflow( + target, + workflow_run_id, + workspace=resolved_workspace, + ) + store = _open_datastore(target, root, workflow) + prefix, result, request = _load_completed_result(store, workflow) + reports = _collect_reports(store, result) + stage_attempts, resumes = _collect_history(store, prefix, workflow, request) + + active_root = ( + root if workflow.workspace is None else (root / workflow.workspace).resolve() + ) + if not active_root.is_relative_to(root): + raise ValueError("Workflow workspace resolves outside the analysis store") + report_dir = ( + active_root / "agents" / "runs" / workflow_run_id / "report" + ).resolve() + if not report_dir.is_relative_to(active_root): + raise ValueError("Agent report path resolves outside the analysis store") + plot_dir = report_dir / "plots" + report_dir.mkdir(parents=True, exist_ok=True) + cluster_counts, top_markers, plot_files, plot_notes = _collect_final_artifacts( + store, + result, + plot_dir, + ) + payload: dict[str, Any] = { + "status": result.status, + "currentStage": result.currentStage, + "workflowRunId": workflow_run_id, + "generatedAt": datetime.now(UTC).isoformat(), + "request": request.request.model_dump(mode="json"), + "effectiveConfig": request.config.model_dump(mode="json"), + "workflowResult": result.model_dump(mode="json"), + "reports": reports, + "stageAttempts": stage_attempts, + "workflowResumes": resumes, + "clusterCounts": cluster_counts, + "topMarkers": top_markers, + "plotFiles": plot_files, + "plotNotes": plot_notes, + } + document = _render_document(payload) + destination = report_dir / "index.html" + temporary = report_dir / f".index.{uuid.uuid4().hex}.tmp" + try: + temporary.write_text(document, encoding="utf-8") + os.replace(temporary, destination) + finally: + temporary.unlink(missing_ok=True) + logger.info( + f"Generated HTML report for agent workflow {workflow_run_id}: {destination}" + ) + return destination + + +__all__ = ["generate_agent_report"] diff --git a/tests/test_agent_experimental_context.py b/tests/test_agent_experimental_context.py index a1a72475..af56e53c 100644 --- a/tests/test_agent_experimental_context.py +++ b/tests/test_agent_experimental_context.py @@ -429,6 +429,15 @@ def test_system_prompt_does_not_embed_fictional_output_values() -> None: assert "estimability:treatment" not in prompt +def test_validator_rejects_serialized_fields_inside_narrative() -> None: + decision = ExperimentalContextDecision( + rationale='Study design is unresolved.", "evidenceIds": ["column:batch"]', + ) + + with pytest.raises(ModelRetry, match="plain prose"): + validate_experimental_context(decision, _context(_Store()).deps) + + def test_agent_runs_only_read_only_tools_and_returns_a_grounded_report() -> None: store = _Store() tool_names: set[str] = set() diff --git a/tests/test_agent_orchestrator.py b/tests/test_agent_orchestrator.py index ac355a71..1fc29134 100644 --- a/tests/test_agent_orchestrator.py +++ b/tests/test_agent_orchestrator.py @@ -481,6 +481,16 @@ def test_rna_h5ad_completes_public_automated_workflow(tmp_path: Path) -> None: assert result.currentStage == "biological_interpretation" assert result.workflowRun is not None assert result.workflowRun.status == "completed" + report_path = ( + target + / "agents" + / "runs" + / result.workflowRun.workflowRunId + / "report" + / "index.html" + ) + assert report_path.is_file() + assert "Nygen Analytics" in report_path.read_text(encoding="utf-8") assert state["requests"] == 9 assert [reference.agentName for reference in result.reportReferences] == [ "data_enrichment", diff --git a/tests/test_agent_orchestrator_stages.py b/tests/test_agent_orchestrator_stages.py index 4da233ae..2f67b825 100644 --- a/tests/test_agent_orchestrator_stages.py +++ b/tests/test_agent_orchestrator_stages.py @@ -581,12 +581,23 @@ def run(self, *_args: Any, **_kwargs: Any) -> ExperimentalContextResult: assert resolved_report.decision.batchCorrection.action == "skip" assert resolved_report.decision.batchCorrection.batchColumns == [] assert resolved_report.decision.needsInput == [] + assert resolved_report.runInfo.agentName == "experimental_context_resolution" + assert resolved_report.runInfo.runId == "" + assert resolved_report.runInfo.usage.requests == 0 assert NeedsInputAgent.calls == 1 + paused_record = load_agent_record( + store, + paused_outcome.reportReferences[0], + ) resolved_record = load_agent_record( store, resolved_outcome.reportReferences[0], ) assert resolved_record.invocation.artifacts == resolved_outcome.artifacts + assert resolved_record.invocation.runConfig == paused_record.invocation.runConfig + assert resolved_record.invocation.inputs["deterministicResolution"] == ( + "resolve_experimental_context:no_inference_skip_harmony" + ) assert resolved_record.invocation.parentReports[-1].agentRunId == ( paused_outcome.reportReferences[0].agentRunId ) diff --git a/tests/test_agent_parameter_tuning.py b/tests/test_agent_parameter_tuning.py index 22aa309f..63b56bd0 100644 --- a/tests/test_agent_parameter_tuning.py +++ b/tests/test_agent_parameter_tuning.py @@ -1799,6 +1799,14 @@ async def reply( assert model_calls == 3 assert result.status == "done" assert result.recommendedByAssay == {"RNA": "baseline"} + assert result.searchPlan is not None + assert result.searchPlan.runInfo.agentName == "parameter_batch_search_planning" + assert result.searchPlan.runInfo.usage.requests == 2 + assay_plan = result.assayReports["RNA"].searchPlan + assert assay_plan is not None + assert assay_plan.runInfo == result.searchPlan.runInfo + assert result.runInfo.agentName == "parameter_tuning_batch" + assert result.runInfo.usage.requests == 1 def test_batched_tuning_falls_back_after_structured_output_exhaustion( diff --git a/tests/test_agent_report.py b/tests/test_agent_report.py new file mode 100644 index 00000000..59196f5f --- /dev/null +++ b/tests/test_agent_report.py @@ -0,0 +1,428 @@ +"""Supported local HTML report contracts for completed agent workflows.""" + +from pathlib import Path +from types import SimpleNamespace +from typing import Any, Literal + +import pytest +import zarr + +import scarf.agent as agent_api +import scarf.agent.report as report_module +import scarf.agent.orchestrator.main as orchestrator_main +from scarf.agent import ( + AgentWorkflowRun, + AutomatedWorkflowConfig, + AutomatedWorkflowRequest, + AutomatedWorkflowResult, + FinalAnalysisHandoff, + create_agent_workflow, + generate_agent_report, + list_agent_workflows, + load_agent_workflow, +) + + +def _workflow( + *, + workspace: str | None = None, + status: Literal["completed", "running"] = "completed", +) -> AgentWorkflowRun: + return AgentWorkflowRun( + workflowRunId="report-workflow", + workspace=workspace, + createdAtNs=1, + finalizedAtNs=2 if status != "running" else 0, + status=status, + finalizationMessage="analysis completed" if status != "running" else "", + analysisStore="data.zarr", + datasetFingerprints={"RNA": "dataset-rna"}, + ) + + +def _reports(study_context: str) -> dict[str, list[dict[str, Any]]]: + run_info = { + "agentName": "data_enrichment", + "runId": "provider-run", + "modelName": "test-model", + "durationSeconds": 1.5, + "usage": { + "requests": 2, + "toolCalls": 1, + "inputTokens": 20, + "outputTokens": 5, + "totalTokens": 25, + }, + } + candidate = { + "candidateId": "refined", + "phase": "refined", + "status": "done", + "eligible": True, + "parameters": { + "reductionMethod": "pca", + "dimensions": 21, + "neighborsK": 11, + "leidenResolution": 0.75, + "useHarmony": False, + }, + "metrics": { + "nClusters": 7, + "minClusterCells": 42, + "graphSilhouetteMedian": 0.343, + }, + } + return { + "data_enrichment": [ + { + "status": "done", + "studyContextSummary": { + "studyContext": study_context, + "organismReferences": ["human"], + "tissueReferences": ["blood"], + }, + "policies": [{"assay": "RNA", "policyId": "rna-default"}], + "runInfo": run_info, + } + ], + "experimental_context": [ + { + "status": "done", + "decision": {"batchCorrection": {"action": "skip"}}, + "cellQc": {"action": "globalGaussian", "driverAssay": "RNA"}, + } + ], + "parameter_tuning": [ + { + "status": "done", + "fromAssay": "RNA", + "totalCandidates": 2, + "recommendedByAssay": {"RNA": "refined"}, + "rationale": "The refined candidate balanced cluster viability.", + "stopReason": "The bounded refinement completed.", + "assayReports": { + "RNA": { + "recommendedCandidateId": "refined", + "confidence": "medium", + "evaluations": [candidate], + "comparisons": [ + { + "candidateId": "baseline", + "summary": ( + "The refined candidate retained larger " + "minimum clusters." + ), + "evidenceIds": ["candidate:refined:clusters"], + } + ], + "searchPlan": { + "status": "refine", + "objectives": ["Test an intermediate resolution."], + }, + } + }, + } + ], + "biological_interpretation": [ + { + "status": "done", + "clusterInterpretations": [ + { + "clusterId": "0", + "proposedIdentity": "T cell", + "identityIsHypothesis": True, + } + ], + "treatmentObservations": [], + "followUps": ["Validate the proposed identities."], + } + ], + } + + +def _patch_completed_workflow( + monkeypatch: pytest.MonkeyPatch, + root: Path, + *, + workspace: str | None = None, + study_context: str = "A human blood study.", + plots: bool = True, +) -> Path: + group = zarr.open_group(str(root), mode="w", zarr_format=3) + if workspace is not None: + group.create_group(workspace) + workflow = _workflow(workspace=workspace) + final = FinalAnalysisHandoff.get_example().model_copy( + update={"workflowRunId": workflow.workflowRunId} + ) + result = AutomatedWorkflowResult( + status="completed", + currentStage="biological_interpretation", + zarrPath=str(root), + workflowRun=workflow, + finalAnalysis=final, + ) + request = AutomatedWorkflowRequest( + sourcePath="input.h5ad", + zarrPath=str(root), + studyContext=study_context, + workspace=workspace, + ) + request_record = SimpleNamespace( + request=request, + config=AutomatedWorkflowConfig(), + ) + + monkeypatch.setattr( + report_module, + "load_agent_workflow", + lambda *_a, **_k: workflow, + ) + monkeypatch.setattr(report_module, "_open_datastore", lambda *_a, **_k: object()) + monkeypatch.setattr( + report_module, + "_load_completed_result", + lambda *_a, **_k: ("agents/orchestrations", result, request_record), + ) + monkeypatch.setattr( + report_module, + "_collect_reports", + lambda *_a, **_k: _reports(study_context), + ) + monkeypatch.setattr( + report_module, + "_collect_history", + lambda *_a, **_k: ( + [ + { + "stage": "parameter_tuning", + "status": "done", + "durationSeconds": 3.5, + "actions": ["evaluate_refined_candidate"], + "reportCount": 1, + "artifactCount": 4, + "artifacts": { + "selectedGraph": { + "scope": "assay", + "assay": "RNA", + "kind": "connectivity_map", + "artifactId": "a" * 64, + } + }, + "parentAttempts": ["preprocessing:attempt-1"], + "questionIds": [], + "noteCount": 0, + "errorType": None, + } + ], + [], + ), + ) + + def collect_artifacts( + _store: object, + _result: AutomatedWorkflowResult, + plot_dir: Path, + ) -> tuple[dict[str, int], list[dict[str, Any]], dict[str, str], list[str]]: + if not plots: + return ( + {"0": 3, "1": 2}, + [], + {}, + ["umapClusters: ImportError: plotting dependencies are unavailable"], + ) + plot_dir.mkdir(parents=True, exist_ok=True) + (plot_dir / "final_umap.png").write_bytes(b"png") + (plot_dir / "final_umap.png.json").write_text( + '{"artifact":"umap"}\n', encoding="utf-8" + ) + return ( + {"0": 3, "1": 2}, + [{"group_id": "0", "feature_name": "CD3D", "score": 8.5}], + {"umapClusters": "plots/final_umap.png"}, + [], + ) + + monkeypatch.setattr(report_module, "_collect_final_artifacts", collect_artifacts) + return root + + +def test_public_report_generates_branded_readable_html_and_relative_plots( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + root = _patch_completed_workflow( + monkeypatch, + tmp_path / "data.zarr", + study_context='Human blood & treatment.', + ) + immutable_record = root / "agents/runs/report-workflow/workflow.json" + immutable_record.parent.mkdir(parents=True) + immutable_record.write_bytes(b'{"immutable":true}\n') + + report_path = generate_agent_report(root, "report-workflow") + markup = report_path.read_text(encoding="utf-8") + + assert agent_api.generate_agent_report is generate_agent_report + assert report_path == root / "agents/runs/report-workflow/report/index.html" + assert immutable_record.read_bytes() == b'{"immutable":true}\n' + assert 'href="https://www.nygen.io/"' in markup + assert ">Nygen Analytics" in markup + assert 'href="https://www.nygen.io/products/scarfweb"' in markup + assert ( + "Distributed, secure infrastructure for intuitive secondary analysis, " + "browser-native." + ) in markup + assert "Human blood <script>alert" in markup + assert '' not in markup + assert "Parameter tuning and graph selection" in markup + assert "refined" in markup + assert "0.343" in markup + assert "The refined candidate retained larger minimum clusters." in markup + assert "Stage artifact inventory" in markup + assert "connectivity_map" in markup + assert "Recorded totals" in markup + assert "evaluate_refined_candidate" in markup + assert 'src="plots/final_umap.png"' in markup + assert 'href="plots/final_umap.png.json"' in markup + assert (report_path.parent / "plots/final_umap.png").read_bytes() == b"png" + + +def test_report_uses_workspace_path_and_can_be_regenerated( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + root = _patch_completed_workflow( + monkeypatch, + tmp_path / "data.zarr", + workspace="analysis", + study_context="First context", + ) + + first = generate_agent_report(root, "report-workflow", workspace="analysis") + assert first == (root / "analysis/agents/runs/report-workflow/report/index.html") + first_markup = first.read_text(encoding="utf-8") + assert "First context" in first_markup + + monkeypatch.setattr( + report_module, + "_collect_reports", + lambda *_a, **_k: _reports("Regenerated context"), + ) + second = generate_agent_report(root, "report-workflow", workspace="analysis") + + assert second == first + second_markup = second.read_text(encoding="utf-8") + assert "Regenerated context" in second_markup + assert second_markup != first_markup + + +def test_report_remains_available_when_optional_plots_fail( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + root = _patch_completed_workflow( + monkeypatch, + tmp_path / "data.zarr", + plots=False, + ) + + report_path = generate_agent_report(root, "report-workflow") + markup = report_path.read_text(encoding="utf-8") + + assert report_path.is_file() + assert "No plots could be rendered" in markup + assert "plotting dependencies are unavailable" in markup + assert "Final cluster sizes" in markup + + +def test_report_rejects_remote_and_non_completed_workflows( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + with pytest.raises(ValueError, match="local filesystem"): + generate_agent_report("s3://bucket/data.zarr", "report-workflow") + + root = tmp_path / "data.zarr" + zarr.open_group(str(root), mode="w", zarr_format=3) + running = _workflow(status="running") + monkeypatch.setattr( + report_module, + "load_agent_workflow", + lambda *_a, **_k: running, + ) + monkeypatch.setattr(report_module, "_open_datastore", lambda *_a, **_k: object()) + + with pytest.raises(RuntimeError, match="completed workflows"): + generate_agent_report(root, running.workflowRunId) + + +def test_orchestrator_generates_only_completed_local_reports_non_fatally( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + generated: list[tuple[object, str]] = [] + local_store = SimpleNamespace(z=object()) + completed = _workflow() + + monkeypatch.setattr( + orchestrator_main, + "zarr_root_path", + lambda _store: tmp_path / "data.zarr", + ) + monkeypatch.setattr( + report_module, + "generate_agent_report", + lambda target, workflow_run_id: ( + generated.append((target, workflow_run_id)) + or tmp_path / "data.zarr/agents/runs/report-workflow/report/index.html" + ), + ) + + orchestrator_main._generate_completed_report(local_store, completed) + + assert generated == [(local_store, completed.workflowRunId)] + assert "Agent workflow report:" in capsys.readouterr().out + + orchestrator_main._generate_completed_report( + local_store, + _workflow(status="running"), + ) + monkeypatch.setattr(orchestrator_main, "zarr_root_path", lambda _store: None) + orchestrator_main._generate_completed_report(local_store, completed) + assert len(generated) == 1 + + monkeypatch.setattr( + orchestrator_main, + "zarr_root_path", + lambda _store: tmp_path / "data.zarr", + ) + monkeypatch.setattr( + report_module, + "generate_agent_report", + lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("plot failed")), + ) + orchestrator_main._generate_completed_report(local_store, completed) + + +def test_derived_report_files_do_not_change_workflow_record_discovery( + tmp_path: Path, +) -> None: + root = zarr.open_group(str(tmp_path / "data.zarr"), mode="w", zarr_format=3) + root.create_group("cellData") + assay = root.create_group("RNA") + assay.attrs["is_assay"] = True + assay.attrs["dataset_fingerprint"] = "dataset-rna" + workflow = create_agent_workflow(root, workflow_run_id="report-workflow") + report_dir = ( + tmp_path / "data.zarr" / "agents" / "runs" / workflow.workflowRunId / "report" + ) + plot_dir = report_dir / "plots" + plot_dir.mkdir(parents=True) + (report_dir / "index.html").write_text("", encoding="utf-8") + (plot_dir / "final_umap.png").write_bytes(b"png") + (plot_dir / "final_umap.png.json").write_text("{}\n", encoding="utf-8") + + assert load_agent_workflow(root, workflow.workflowRunId) == workflow + assert list_agent_workflows(root, include_incomplete=True) == [workflow] From 09f7ee65be8ac97a698c70a2e02ea4e898d12ec3 Mon Sep 17 00:00:00 2001 From: gautam8387 Date: Tue, 1 Sep 2026 10:46:11 +0200 Subject: [PATCH 03/21] coverage --- tests/test_agent_report.py | 608 +++++++++++++++++++++++++++++++++++++ 1 file changed, 608 insertions(+) diff --git a/tests/test_agent_report.py b/tests/test_agent_report.py index 59196f5f..05481214 100644 --- a/tests/test_agent_report.py +++ b/tests/test_agent_report.py @@ -1,14 +1,24 @@ """Supported local HTML report contracts for completed agent workflows.""" +import asyncio +import sys from pathlib import Path from types import SimpleNamespace from typing import Any, Literal +import numpy as np +import pandas as pd import pytest import zarr +from pydantic_ai import ModelRetry, UnexpectedModelBehavior import scarf.agent as agent_api +import scarf.agent.biological_interpretation as biological_module +import scarf.agent.config.agent_exec as agent_exec_module +import scarf.agent.data_enrichment as enrichment_module +import scarf.agent.experimental_context as experimental_module import scarf.agent.report as report_module +import scarf.agent.orchestrator.journal as journal_module import scarf.agent.orchestrator.main as orchestrator_main from scarf.agent import ( AgentWorkflowRun, @@ -21,6 +31,30 @@ list_agent_workflows, load_agent_workflow, ) +from scarf.agent.biological_interpretation import ( + BiologicalInterpretationDependencies, + BiologicalInterpretationNeedsInput, + BiologicalInterpretationReport, + ClusterCompositionEvidence, + ClusterMarkerEvidence, +) +from scarf.agent.characterize_covariates import CovariateCharacterization +from scarf.agent.data_enrichment import ( + AssayFeatureInspection, + DataEnrichmentAgent, + DataEnrichmentDependencies, + DataEnrichmentToolCall, +) +from scarf.agent.experimental_context import ( + CellQcProfileEvidence, + ExperimentalContextDependencies, +) +from scarf.agent.orchestrator.models import ( + NativeAnalysisHandoff, + OrchestrationRequestRecord, + WorkflowStageAttempt, +) +from scarf.agent.types import ArtifactReferenceModel def _workflow( @@ -426,3 +460,577 @@ def test_derived_report_files_do_not_change_workflow_record_discovery( assert load_agent_workflow(root, workflow.workflowRunId) == workflow assert list_agent_workflows(root, include_incomplete=True) == [workflow] + + +def test_report_store_request_and_result_validation_edges( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + local_root = tmp_path / "data.zarr" + local_root.mkdir() + + class LocalDataStore: + def __init__(self, *args: object, **kwargs: object) -> None: + self.args = args + self.kwargs = kwargs + self.workspace = kwargs.get("workspace") + self.z = object() + + monkeypatch.setattr(report_module, "DataStore", LocalDataStore) + monkeypatch.setattr(report_module, "zarr_root_path", lambda _store: None) + with pytest.raises(ValueError, match="local filesystem"): + report_module._local_root(LocalDataStore()) + + monkeypatch.setattr( + report_module, + "zarr_root_path", + lambda _store: local_root, + ) + assert report_module._local_root(f"file://{local_root}") == local_root.resolve() + assert report_module._local_root(str(local_root)) == local_root.resolve() + with pytest.raises(TypeError, match="local filesystem"): + report_module._local_root(object()) + with pytest.raises(FileNotFoundError): + report_module._local_root(tmp_path / "missing.zarr") + + workflow = _workflow() + with pytest.raises(ValueError, match="workspace"): + report_module._open_datastore( + LocalDataStore(workspace="other"), + local_root, + workflow, + ) + opened = report_module._open_datastore(local_root, local_root, workflow) + assert opened.args == (str(local_root),) + assert opened.kwargs["default_assay"] == "RNA" + assert opened.kwargs["zarr_mode"] == "r" + + request = AutomatedWorkflowRequest.get_example() + config = AutomatedWorkflowConfig.get_example() + valid_record = OrchestrationRequestRecord( + workflowRunId="workflow-1", + request=request, + config=config, + requestSha256=journal_module._sha256_model(request), + configSha256=journal_module._sha256_model(config), + ) + valid_record.contentSha256 = journal_module._record_checksum(valid_record) + current_record = valid_record + monkeypatch.setattr( + journal_module, + "_read_model", + lambda *_args, **_kwargs: current_record, + ) + store = SimpleNamespace(z=object(), zw=object()) + assert report_module._load_request(store, "agents", "workflow-1") == valid_record + + invalid_records = ( + ( + valid_record.model_copy(update={"workflowRunId": "another-workflow"}), + "another workflow", + ), + ( + valid_record.model_copy(update={"requestSha256": "f" * 64}), + "request checksum", + ), + ( + valid_record.model_copy(update={"configSha256": "f" * 64}), + "configuration checksum", + ), + ( + valid_record.model_copy(update={"contentSha256": "f" * 64}), + "request envelope", + ), + ) + for current_record, message in invalid_records: + with pytest.raises(ValueError, match=message): + report_module._load_request(store, "agents", "workflow-1") + + monkeypatch.setattr( + journal_module, + "_ensure_orchestration_store", + lambda _store: "agents/orchestrations", + ) + terminal_result: object | None = None + monkeypatch.setattr( + journal_module, + "_load_terminal_result", + lambda *_args, **_kwargs: terminal_result, + ) + with pytest.raises(FileNotFoundError, match="no terminal result"): + report_module._load_completed_result(store, workflow) + + terminal_result = SimpleNamespace(status="failed", finalAnalysis=object()) + with pytest.raises(ValueError, match="final analysis"): + report_module._load_completed_result(store, workflow) + + terminal_result = SimpleNamespace(status="completed", finalAnalysis=object()) + monkeypatch.setattr( + report_module, + "_load_request", + lambda *_args, **_kwargs: SimpleNamespace( + request=SimpleNamespace(workspace="other") + ), + ) + with pytest.raises(ValueError, match="request workspace"): + report_module._load_completed_result(store, workflow) + + invalid_attempt = WorkflowStageAttempt( + status="failed", + startedAtNs=1, + completedAtNs=2, + error="not a valid error type!?: details", + ) + assert report_module._stage_summary(invalid_attempt)["errorType"] == ( + "WorkflowStageError" + ) + + data_store = LocalDataStore(workspace="analysis") + with pytest.raises(ValueError, match="workspace does not match"): + generate_agent_report( + data_store, + "report-workflow", + workspace="other", + ) + + +def test_report_renderer_edge_branches() -> None: + assert report_module._safe_assay_name("RNA / strange assay", "fallback") == ( + "rna_strange_assay" + ) + assert report_module._safe_assay_name("***", "fallback") == "fallback" + assert report_module._scalar(None) == "Not provided" + assert "Nothing" in report_module._chips(None, empty="Nothing") + assert "value" in report_module._chips("value") + assert report_module._latest({"agent": {"status": "done"}}, "agent") == { + "status": "done" + } + assert report_module._latest({}, "agent") == {} + + native_plot = report_module._render_plots( + {"nativeUmapRna": "plots/native.png"}, + [], + ) + assert "Rna native UMAP" in native_plot + assert "finalized native Rna" in native_plot + assert "No final cluster counts" in report_module._render_clusters({}) + + legacy_parameter = { + "fromAssay": "RNA", + "evaluations": [{"candidateId": "native"}], + } + assert report_module._parameter_rows(legacy_parameter)[0]["assay"] == "RNA" + assert "No Parameter Tuning report" in report_module._render_parameter_tuning({}) + rendered_parameter = report_module._render_parameter_tuning( + { + "fromAssay": "RNA", + "searchPlan": {"status": "refine"}, + "comparisons": [{"candidateId": "native"}], + "finalSelection": {"comparisons": [{"candidateId": "integrated"}]}, + } + ) + assert "RNA" in rendered_parameter + assert "final graph" in rendered_parameter + assert "No provider execution metadata" in report_module._render_executions({}) + + +def test_report_collects_bounded_artifact_branches( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + def artifact( + kind: str, + digit: str, + *, + scope: Literal["assay", "datastore"] = "assay", + ) -> ArtifactReferenceModel: + return ArtifactReferenceModel( + scope=scope, + assay=None if scope == "datastore" else "RNA", + kind=kind, + artifactId=digit * 64, + ) + + final_clusters = artifact("cluster_labels", "3") + final_umap = artifact("embedding", "4") + final = FinalAnalysisHandoff( + workflowRunId="report-workflow", + primaryAssay="RNA", + markerAssay="RNA", + cellSelection=artifact("cell_selection", "c", scope="datastore"), + graph=artifact("connectivity_map", "2"), + clusters=final_clusters, + umap=final_umap, + markers=artifact("marker_table", "5"), + nativeAnalyses=[ + NativeAnalysisHandoff.get_blank(), + NativeAnalysisHandoff( + assay="RNA / strange assay", + reductionMethod="pca", + clusters=artifact("cluster_labels", "6"), + umap=artifact("embedding", "7"), + ), + NativeAnalysisHandoff( + assay="RNA / strange assay", + reductionMethod="pca", + clusters=artifact("cluster_labels", "8"), + umap=artifact("embedding", "9"), + ), + ], + ) + result = AutomatedWorkflowResult( + status="completed", + currentStage="biological_interpretation", + zarrPath=str(tmp_path / "data.zarr"), + workflowRun=_workflow(), + finalAnalysis=final, + ) + + class PlotMethods: + @staticmethod + def marker_heatmap(**_kwargs: object) -> object: + raise RuntimeError("heatmap unavailable") + + class ArtifactStore: + plots = PlotMethods() + + @staticmethod + def load_artifact(reference: object) -> dict[str, np.ndarray]: + if getattr(reference, "artifact_id", None) == "3" * 64: + return {"values": np.asarray(["0", "0", "1"])} + return {} + + @staticmethod + def inspect_artifact(_reference: object) -> SimpleNamespace: + return SimpleNamespace( + parameters={ + "normalization": { + "log_transform": True, + "renormalize_subset": True, + } + } + ) + + @staticmethod + def get_markers( + _marker: object, + *, + group_id: str, + min_score: float, + min_frac_exp: float, + ) -> pd.DataFrame: + assert min_score == -1 + assert min_frac_exp == -1 + if group_id == "1": + raise RuntimeError("marker table unavailable") + return pd.DataFrame( + { + "group_id": ["unknown", "0", "0"], + "feature_name": ["ignored", "CD3D", "unresolved"], + "feature_id": ["ignored-id", "ENSG00000167286", None], + "feature_index": [None, None, None], + "score": [3.0, 2.0, 1.0], + } + ) + + monkeypatch.setattr(report_module, "MAX_EMBEDDING_PLOT_CELLS", 0) + monkeypatch.setattr(report_module, "MAX_COMPOSITION_PLOT_CELLS", 0) + monkeypatch.setattr(report_module, "MAX_DOTPLOT_CELLS", 0) + monkeypatch.setattr(report_module, "MAX_CONNECTIVITY_PLOT_CELLS", 0) + + store = ArtifactStore() + counts, markers, plots, notes = report_module._collect_final_artifacts( + store, + result, + tmp_path / "plots", + ) + assert counts == {"0": 2, "1": 1} + assert len(markers) == 3 + assert plots == {} + assert any("nativeUmapRnaStrangeAssay2" in note for note in notes) + assert any("marker export for cluster 1" in note for note in notes) + assert any("markerDotplot: skipped" in note for note in notes) + assert any("clusterConnectivity: skipped" in note for note in notes) + + monkeypatch.setattr(report_module, "MAX_MARKER_DOTPLOT_FEATURES", 1) + _counts, _markers, _plots, one_marker_notes = ( + report_module._collect_final_artifacts(store, result, tmp_path / "plots-one") + ) + assert any("markerDotplot: skipped" in note for note in one_marker_notes) + + monkeypatch.setattr(report_module, "MAX_MARKER_DOTPLOT_FEATURES", object()) + _counts, _markers, _plots, invalid_limit_notes = ( + report_module._collect_final_artifacts( + store, result, tmp_path / "plots-invalid" + ) + ) + assert any("markerDotplot: TypeError" in note for note in invalid_limit_notes) + + incomplete = result.model_copy( + update={"finalAnalysis": FinalAnalysisHandoff.get_blank()} + ) + with pytest.raises(ValueError, match="lacks its selection"): + report_module._collect_final_artifacts( + store, + incomplete, + tmp_path / "plots-incomplete", + ) + + +def test_data_enrichment_cache_rollback_and_fallback_branches( + monkeypatch: pytest.MonkeyPatch, +) -> None: + inspection = AssayFeatureInspection.get_example() + completed = DataEnrichmentDependencies( + store=object(), + assays=["RNA"], + inspections={"RNA": inspection}, + toolCalls=[ + DataEnrichmentToolCall( + name="inspect_assay_features_batch", + assay="all", + ) + ], + ) + completed_context = SimpleNamespace(deps=completed) + + assert ( + asyncio.run( + enrichment_module.inspect_assay_features( + completed_context, + assay_name="RNA", + ) + ) + == inspection + ) + cached_batch = asyncio.run( + enrichment_module.inspect_assay_features_batch(completed_context) + ) + assert cached_batch.inspections == [inspection] + assert cached_batch.evidenceIds == inspection.evidenceIds + + incomplete = DataEnrichmentDependencies( + assays=["RNA"], + toolCalls=[DataEnrichmentToolCall(name="sentinel", assay="RNA")], + ) + with pytest.raises(ModelRetry, match="datastore"): + asyncio.run( + enrichment_module.inspect_assay_features_batch( + SimpleNamespace(deps=incomplete) + ) + ) + assert [call.name for call in incomplete.toolCalls] == ["sentinel"] + + provider_error = UnexpectedModelBehavior("provider output failed") + with pytest.raises(UnexpectedModelBehavior, match="provider output failed"): + enrichment_module.fallback_data_enrichment_report( + DataEnrichmentDependencies(assays=["RNA"]), + error=provider_error, + model_name="test-model", + ) + fallback = enrichment_module.fallback_data_enrichment_report( + DataEnrichmentDependencies( + assays=["RNA"], + inspections={"RNA": inspection}, + evidenceIds=set(inspection.evidenceIds), + ), + error=provider_error, + model_name="test-model", + ) + assert fallback.policies[0].species == "homo_sapiens" + assert fallback.policies[0].speciesConfidence == "high" + + def fail_before_inspection(**_kwargs: object) -> object: + raise UnexpectedModelBehavior("no inspection completed") + + monkeypatch.setattr(enrichment_module, "run_agent_sync", fail_before_inspection) + store = SimpleNamespace(assay_names=["RNA"]) + with pytest.raises(UnexpectedModelBehavior, match="no inspection completed"): + DataEnrichmentAgent(object()).run(store) + + +def test_biological_interpretation_cache_and_fallback_branches() -> None: + composition = ClusterCompositionEvidence.get_example() + composition_deps = BiologicalInterpretationDependencies( + compositionEvidence=composition + ) + assert ( + asyncio.run( + biological_module.inspect_cluster_composition( + SimpleNamespace(deps=composition_deps) + ) + ) + == composition + ) + + marker = ClusterMarkerEvidence.get_example() + marker_deps = BiologicalInterpretationDependencies( + clusterValues={marker.clusterId: 0}, + markerEvidence={marker.clusterId: marker}, + ) + assert ( + asyncio.run( + biological_module.inspect_cluster_markers( + SimpleNamespace(deps=marker_deps), + cluster_id=marker.clusterId, + ) + ) + == marker + ) + + invalid_report = BiologicalInterpretationReport( + status="done", + needsInput=BiologicalInterpretationNeedsInput(question="More context?"), + ) + with pytest.raises(ModelRetry, match="Only a needsInput"): + biological_module.validate_biological_interpretation_report( + invalid_report, + BiologicalInterpretationDependencies(clusterValues={"0": 0}), + ) + + provider_error = UnexpectedModelBehavior("structured output failed") + with pytest.raises(UnexpectedModelBehavior, match="structured output failed"): + biological_module.fallback_biological_interpretation_report( + BiologicalInterpretationDependencies(), + error=provider_error, + model_name="test-model", + ) + needs_markers = biological_module.fallback_biological_interpretation_report( + BiologicalInterpretationDependencies( + clusterValues={"0": 0}, + evidenceIds={"composition:clusters"}, + ), + error=provider_error, + model_name="test-model", + ) + assert needs_markers.status == "needsInput" + assert needs_markers.needsInput is not None + assert needs_markers.evidenceIds == ["composition:clusters"] + + +def test_experimental_context_rejects_invalid_batches_and_builds_fallback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + invalid_batches = ( + ( + [{"name": "batch", "domain": "technical", "kind": "categorical"}], + "missing", + "Unknown batch column", + ), + ( + [{"name": "condition", "domain": "biological", "kind": "categorical"}], + "condition", + "must be classified as technical", + ), + ( + [{"name": "depth", "domain": "technical", "kind": "continuous"}], + "depth", + "must be categorical", + ), + ) + for columns, batch_column, message in invalid_batches: + deps = ExperimentalContextDependencies( + characterization=CovariateCharacterization( + status="done", + columns=columns, + ) + ) + with pytest.raises(ModelRetry, match=message): + asyncio.run( + experimental_module.analyze_experimental_design( + SimpleNamespace(deps=deps), + column_domains={}, + coefficients_of_interest=[], + units_of_inference={}, + batch_columns=[batch_column], + ) + ) + + characterization = CovariateCharacterization( + status="done", + columns=[{"name": "condition", "domain": "biological", "kind": "categorical"}], + ) + monkeypatch.setattr( + experimental_module, + "characterize_covariates", + lambda *_args, **_kwargs: characterization, + ) + + def offer_profile( + deps: ExperimentalContextDependencies, + _characterization: CovariateCharacterization, + ) -> list[CellQcProfileEvidence]: + profile = CellQcProfileEvidence.get_example() + deps.qcProfiles[profile.profileId] = profile + return [profile] + + monkeypatch.setattr(experimental_module, "_offered_qc_profiles", offer_profile) + fallback_deps = ExperimentalContextDependencies( + cellSelection=ArtifactReferenceModel( + scope="datastore", + kind="cell_selection", + artifactId="c" * 64, + ), + htoIdentityColumns=["hto_identity"], + ) + fallback = experimental_module.fallback_experimental_context_result( + fallback_deps, + error=UnexpectedModelBehavior("design output failed"), + model_name="test-model", + ) + assert fallback.status == "done" + assert fallback_deps.characterization is characterization + assert fallback.cellQc.profileId == CellQcProfileEvidence.get_example().profileId + + +def test_agent_execution_logs_nested_failures_for_sync_and_async_runners( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class FailingAgent: + async def __aenter__(self) -> "FailingAgent": + return self + + async def __aexit__(self, *_args: object) -> bool: + return False + + async def run(self, *_args: object, **_kwargs: object) -> object: + try: + raise ValueError("inner failure") + except ValueError as cause: + raise RuntimeError("outer failure") from cause + + monkeypatch.setattr( + agent_exec_module, + "_build_agent", + lambda **_kwargs: FailingAgent(), + ) + messages: list[str] = [] + monkeypatch.setattr(agent_exec_module.logger, "error", messages.append) + with pytest.raises(RuntimeError, match="outer failure"): + agent_exec_module.run_agent_sync( + model=object(), + output_type=dict, + system_prompt="system", + user_prompt="user", + name="sync-failure", + ) + with pytest.raises(RuntimeError, match="outer failure"): + asyncio.run( + agent_exec_module.run_agent( + model=object(), + output_type=dict, + system_prompt="system", + user_prompt="user", + name="async-failure", + ) + ) + assert all("caused by ValueError: inner failure" in message for message in messages) + assert "sync-failure" in messages[0] + assert "async-failure" in messages[1] + + +def test_journal_retryable_error_handles_missing_optional_dependency( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setitem(sys.modules, "pydantic_ai", None) + assert journal_module.is_retryable_model_error(RuntimeError("unavailable")) is False From 6bfcf2057065fdb56f728042bfdc3dabaa0a7842 Mon Sep 17 00:00:00 2001 From: gautam8387 Date: Tue, 1 Sep 2026 11:24:11 +0200 Subject: [PATCH 04/21] add docs --- .../base.ipynb | 532 ---------- .../base.ipynb | 944 ++++++++++++++++++ docs/.jupyter_cache/global.db | Bin 36864 -> 36864 bytes docs/source/analysis_with_agents.md | 55 +- docs/source/index.md | 2 +- docs/source/llms.txt | 2 +- docs/source/toctree.yml | 2 +- docs/source/tutorials/agent_workflow.md | 910 +++++++++++------ 8 files changed, 1595 insertions(+), 852 deletions(-) delete mode 100644 docs/.jupyter_cache/executed/30e76fa5af053e197ea52e41c1409264/base.ipynb create mode 100644 docs/.jupyter_cache/executed/d46bae099f63faffe6d8c4de5a9bc1d1/base.ipynb diff --git a/docs/.jupyter_cache/executed/30e76fa5af053e197ea52e41c1409264/base.ipynb b/docs/.jupyter_cache/executed/30e76fa5af053e197ea52e41c1409264/base.ipynb deleted file mode 100644 index 80c7fc30..00000000 --- a/docs/.jupyter_cache/executed/30e76fa5af053e197ea52e41c1409264/base.ipynb +++ /dev/null @@ -1,532 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 1, - "id": "769b139d", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'active_cells': 3940, 'total_cells': 5025, 'assays': ['RNA']}" - ] - }, - "execution_count": 1, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "import scarf\n", - "from scarf.agent import (\n", - " BiologicalContext,\n", - " BiologicalInterpretationAgent,\n", - " DataEnrichmentAgent,\n", - " DataEnrichmentContext,\n", - " ExperimentalContextAgent,\n", - " ParameterCandidate,\n", - " ParameterTuningAgent,\n", - ")\n", - "\n", - "scarf.configure_output(level=\"WARNING\", progress=False)\n", - "\n", - "dataset = scarf.cytebase.connect(\"scarf_docs\").download_dataset(\n", - " \"tenx_5K_pbmc_rnaseq\",\n", - " destination=\"scarf_datasets\",\n", - " zarr=True,\n", - ")\n", - "ds = scarf.DataStore(\n", - " f\"{dataset}/data.zarr\",\n", - " default_assay=\"RNA\",\n", - " nthreads=2,\n", - ")\n", - "\n", - "{\n", - " \"active_cells\": int(ds.cells.fetch_all(\"I\").sum()),\n", - " \"total_cells\": ds.cells.N,\n", - " \"assays\": ds.assay_names,\n", - "}" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "6716ba44", - "metadata": { - "tags": [ - "remove-cell" - ] - }, - "outputs": [], - "source": [ - "from pydantic_ai.messages import (\n", - " ModelMessage,\n", - " ModelResponse,\n", - " ToolCallPart,\n", - " ToolReturnPart,\n", - ")\n", - "from pydantic_ai.models.function import AgentInfo, FunctionModel\n", - "\n", - "from scarf.agent.biological_interpretation import (\n", - " ClusterCompositionEvidence,\n", - " ClusterMarkerBatchEvidence,\n", - ")\n", - "from scarf.agent.data_enrichment import AssayFeatureInspectionBatch\n", - "from scarf.agent.experimental_context import CovariateEvidence\n", - "\n", - "\n", - "def _tool_returns(messages: list[ModelMessage]) -> list[ToolReturnPart]:\n", - " return [\n", - " part\n", - " for message in messages\n", - " for part in message.parts\n", - " if isinstance(part, ToolReturnPart)\n", - " ]\n", - "\n", - "\n", - "def _tool_call(name: str, args: dict | None = None) -> ModelResponse:\n", - " return ModelResponse(parts=[ToolCallPart(tool_name=name, args=args or {})])\n", - "\n", - "\n", - "def _structured_output(info: AgentInfo, payload: dict) -> ModelResponse:\n", - " return _tool_call(info.output_tools[0].name, payload)\n", - "\n", - "\n", - "async def _enrichment_reply(\n", - " messages: list[ModelMessage],\n", - " info: AgentInfo,\n", - ") -> ModelResponse:\n", - " returns = _tool_returns(messages)\n", - " if not returns:\n", - " return _tool_call(\"inspect_assay_features_batch\")\n", - "\n", - " batch = AssayFeatureInspectionBatch.model_validate(returns[-1].content)\n", - " inspection = batch.inspections[0]\n", - " species_observed = inspection.species != \"unknown\"\n", - " species = inspection.species if species_observed else \"homo_sapiens\"\n", - " evidence_ids = list(inspection.evidenceIds)\n", - " if not species_observed:\n", - " evidence_ids.append(\"context:organism\")\n", - " policy = {\n", - " \"assay\": inspection.assay,\n", - " \"species\": species,\n", - " \"speciesConfidence\": \"high\" if species_observed else \"medium\",\n", - " \"speciesRationale\": (\n", - " inspection.speciesReason\n", - " or \"The inspected features and caller context support this species.\"\n", - " ),\n", - " \"excludeFamilies\": [\n", - " family.family\n", - " for family in inspection.families\n", - " if family.count > 0 and family.defaultExclude is True\n", - " ],\n", - " \"protectFamilies\": [\n", - " family.family\n", - " for family in inspection.families\n", - " if family.count > 0 and family.defaultExclude is False\n", - " ],\n", - " \"rationale\": \"Exclude observed technical families and preserve protected ones.\",\n", - " \"evidenceIds\": evidence_ids,\n", - " }\n", - " return _structured_output(info, {\"status\": \"done\", \"policies\": [policy]})\n", - "\n", - "\n", - "async def _experimental_reply(\n", - " messages: list[ModelMessage],\n", - " info: AgentInfo,\n", - ") -> ModelResponse:\n", - " returns = _tool_returns(messages)\n", - " if not returns:\n", - " return _tool_call(\"inspect_cell_covariates\")\n", - " if len(returns) == 1:\n", - " return _tool_call(\n", - " \"analyze_experimental_design\",\n", - " {\n", - " \"column_domains\": {},\n", - " \"coefficients_of_interest\": [],\n", - " \"units_of_inference\": {},\n", - " \"batch_columns\": [],\n", - " },\n", - " )\n", - "\n", - " design = CovariateEvidence.model_validate(returns[-1].content)\n", - " evidence_id = design.evidenceIds[0]\n", - " return _structured_output(\n", - " info,\n", - " {\n", - " \"batchCorrection\": {\n", - " \"action\": \"skip\",\n", - " \"rationale\": (\n", - " \"No explicit technical batch or biological contrast was supplied.\"\n", - " ),\n", - " \"evidenceIds\": [evidence_id],\n", - " },\n", - " \"rationale\": \"Continue with an uncorrected baseline.\",\n", - " \"evidenceIds\": [evidence_id],\n", - " },\n", - " )\n", - "\n", - "\n", - "async def _tuning_reply(\n", - " _messages: list[ModelMessage],\n", - " info: AgentInfo,\n", - ") -> ModelResponse:\n", - " return _structured_output(\n", - " info,\n", - " {\n", - " \"status\": \"done\",\n", - " \"recommendedCandidateId\": \"baseline\",\n", - " \"confidence\": \"medium\",\n", - " \"rationale\": \"The single authorized baseline completed successfully.\",\n", - " \"evidenceIds\": [\"candidate:baseline:clusters\"],\n", - " \"stopReason\": \"The authorized candidate was evaluated.\",\n", - " },\n", - " )\n", - "\n", - "\n", - "async def _biology_reply(\n", - " messages: list[ModelMessage],\n", - " info: AgentInfo,\n", - ") -> ModelResponse:\n", - " returns = _tool_returns(messages)\n", - " if not returns:\n", - " return _tool_call(\"inspect_cluster_composition\")\n", - " if len(returns) == 1:\n", - " composition = ClusterCompositionEvidence.model_validate(returns[-1].content)\n", - " cluster_id = sorted(\n", - " composition.clusterCounts,\n", - " key=lambda value: (-composition.clusterCounts[value], value),\n", - " )[0]\n", - " return _tool_call(\n", - " \"inspect_cluster_markers_batch\",\n", - " {\"cluster_ids\": [cluster_id]},\n", - " )\n", - "\n", - " marker_batch = ClusterMarkerBatchEvidence.model_validate(returns[-1].content)\n", - " marker = marker_batch.clusters[0]\n", - " if not marker.evidenceId:\n", - " return _structured_output(\n", - " info,\n", - " {\n", - " \"status\": \"needsInput\",\n", - " \"needsInput\": {\n", - " \"question\": \"No markers passed the bounded search thresholds.\",\n", - " \"requiredInputs\": [\"markerArtifact\"],\n", - " },\n", - " \"limitations\": marker.warnings,\n", - " \"stopReason\": \"Marker evidence was unavailable.\",\n", - " },\n", - " )\n", - " names = [item.featureName or item.featureId for item in marker.markers[:3]]\n", - " return _structured_output(\n", - " info,\n", - " {\n", - " \"status\": \"done\",\n", - " \"clusterInterpretations\": [\n", - " {\n", - " \"clusterId\": marker.clusterId,\n", - " \"proposedIdentity\": \"unresolved marker-defined cluster\",\n", - " \"identityIsHypothesis\": True,\n", - " \"confidence\": \"low\",\n", - " \"rationale\": f\"Top returned marker features: {', '.join(names)}.\",\n", - " \"evidenceIds\": [marker.evidenceId],\n", - " }\n", - " ],\n", - " \"evidenceIds\": [marker.evidenceId],\n", - " \"limitations\": [\n", - " \"The scripted documentation model does not assign cell identities.\"\n", - " ],\n", - " \"stopReason\": \"One bounded cluster was reviewed.\",\n", - " },\n", - " )" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "3743c9de", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'status': 'done',\n", - " 'species': 'homo_sapiens',\n", - " 'exclude_families': ['ribosomal', 'histone'],\n", - " 'protect_families': ['cellCycle'],\n", - " 'tool_calls': ['inspect_assay_features_batch']}" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "enrichment = DataEnrichmentAgent(FunctionModel(_enrichment_reply)).run(\n", - " ds,\n", - " context=DataEnrichmentContext(\n", - " studyContext=(\n", - " \"10x 5K PBMC RNA-seq from peripheral blood of a healthy human donor.\"\n", - " ),\n", - " organismHint=\"human\",\n", - " tissueReferences=[\"peripheral blood\"],\n", - " cellTypeReferences=[\"T cell\", \"B cell\", \"NK cell\", \"monocyte\"],\n", - " experimentalDetails=[\"10x 3 prime RNA-seq\", \"single donor\"],\n", - " ),\n", - " assays=[\"RNA\"],\n", - ")\n", - "\n", - "policy = enrichment.policies[0]\n", - "{\n", - " \"status\": enrichment.status,\n", - " \"species\": policy.species,\n", - " \"exclude_families\": policy.excludeFamilies,\n", - " \"protect_families\": policy.protectFamilies,\n", - " \"tool_calls\": [call.name for call in enrichment.toolCalls],\n", - "}" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "1f9de55d", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'run_id': 'a502c6ba45de187eb8aa73bca96bbd09fa2c5722976aaf534d2dce9207ffafab',\n", - " 'active_cells': 3940,\n", - " 'feature_selection': 'adeede36cdca822fde8cf2e62bb430843f28056a4e34414f753607c97a69240d',\n", - " 'normalized': '24cfc31e5c045f8066a38de7ce90d4ca1c6fe7d9c140e6b6686c77e731fd75d0'}" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "run = ds.pipeline.open(label=\"docs_default\")\n", - "normalized = run[\"normalized\"]\n", - "hvg_ref = run[\"highly_variable_features\"]\n", - "\n", - "{\n", - " \"run_id\": run.run_id,\n", - " \"active_cells\": int(run.cells.fetch_all(\"I\").sum()),\n", - " \"feature_selection\": hvg_ref.artifact_id,\n", - " \"normalized\": normalized.artifact_id,\n", - "}" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "4cbf4143", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'status': 'done',\n", - " 'batch_action': 'skip',\n", - " 'batch_columns': [],\n", - " 'coefficients': []}" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "experimental = ExperimentalContextAgent(FunctionModel(_experimental_reply)).run(\n", - " ds,\n", - " study_context=(\n", - " \"Healthy-donor 5K PBMC. No treatment or batch labels are available. \"\n", - " \"Do not invent a technical batch or biological contrast.\"\n", - " ),\n", - " run=run,\n", - ")\n", - "\n", - "{\n", - " \"status\": experimental.status,\n", - " \"batch_action\": experimental.decision.batchCorrection.action,\n", - " \"batch_columns\": experimental.decision.batchCorrection.batchColumns,\n", - " \"coefficients\": experimental.decision.coefficientsOfInterest,\n", - "}" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "8057dafa", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'status': 'done',\n", - " 'recommended_candidate': 'baseline',\n", - " 'eligible': True,\n", - " 'clusters': 12,\n", - " 'smallest_cluster': 12,\n", - " 'cluster_artifact': 'c9ec9a51d0f0ea779459e20c1d030262aa50c6d766ad0e9b7dd02c09b423a195',\n", - " 'cell_selection': 'a4137c5ada6d3933e211d551dd636a097d6f0ed8103afde75fb3b2b05d67c16b'}" - ] - }, - "execution_count": 6, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "if experimental.status != \"done\":\n", - " raise RuntimeError(f\"Experimental Context stopped with {experimental.status!r}\")\n", - "\n", - "tuning_handoff = experimental.to_parameter_tuning_handoff()\n", - "candidate = ParameterCandidate(\n", - " candidateId=\"baseline\",\n", - " dimensions=15,\n", - " leidenResolution=0.5,\n", - " neighborsK=11,\n", - " useHarmony=False,\n", - ")\n", - "tuning = ParameterTuningAgent(FunctionModel(_tuning_reply)).run(\n", - " ds,\n", - " normalized=normalized,\n", - " candidates=[candidate],\n", - " experimental_handoff=tuning_handoff,\n", - " max_candidates=1,\n", - " max_refined_candidates=0,\n", - " min_cluster_cells=10,\n", - ")\n", - "\n", - "evaluation = tuning.evaluations[0]\n", - "{\n", - " \"status\": tuning.status,\n", - " \"recommended_candidate\": tuning.recommendedCandidateId,\n", - " \"eligible\": evaluation.eligible,\n", - " \"clusters\": evaluation.metrics.nClusters,\n", - " \"smallest_cluster\": evaluation.metrics.minClusterCells,\n", - " \"cluster_artifact\": evaluation.artifacts[\"clusters\"].artifactId,\n", - " \"cell_selection\": evaluation.cellSelection.artifactId,\n", - "}" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "54dd0924", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'status': 'done',\n", - " 'interpretations': [{'cluster': '4',\n", - " 'identity': 'unresolved marker-defined cluster',\n", - " 'rationale': 'Top returned marker features: SOCS3, TCF7, TRAC.',\n", - " 'evidence': ['markers:6d1bcc868ec6fbdfc8b40108b234ea7a2301c8807188fdad57ceb2b7e99758ed:clusters:c9ec9a51d0f0ea779459e20c1d030262aa50c6d766ad0e9b7dd02c09b423a195:cluster:4']}],\n", - " 'tool_calls': ['inspect_cluster_composition',\n", - " 'inspect_cluster_markers_batch'],\n", - " 'treatment_observations': 0,\n", - " 'limitations': ['The scripted documentation model does not assign cell identities.']}" - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "if tuning.status != \"done\":\n", - " raise RuntimeError(f\"Parameter Tuning stopped with {tuning.status!r}\")\n", - "\n", - "biology_handoff = tuning.to_biological_handoff()\n", - "biology = BiologicalInterpretationAgent(FunctionModel(_biology_reply)).run(\n", - " ds,\n", - " tuning_handoff=biology_handoff,\n", - " biological_context=BiologicalContext(\n", - " organism=\"Homo sapiens\",\n", - " tissue=\"peripheral blood\",\n", - " cellTypeReferences=[\"T cell\", \"B cell\", \"NK cell\", \"monocyte\"],\n", - " experimentalDetails=[\"healthy donor PBMC\", \"no treatment contrast\"],\n", - " ),\n", - " allow_marker_search=True,\n", - " marker_features=hvg_ref,\n", - " max_clusters=1,\n", - " max_markers=5,\n", - " marker_min_score=0.01,\n", - " marker_min_fraction=0.0,\n", - ")\n", - "\n", - "{\n", - " \"status\": biology.status,\n", - " \"interpretations\": [\n", - " {\n", - " \"cluster\": item.clusterId,\n", - " \"identity\": item.proposedIdentity,\n", - " \"rationale\": item.rationale,\n", - " \"evidence\": item.evidenceIds,\n", - " }\n", - " for item in biology.clusterInterpretations\n", - " ],\n", - " \"tool_calls\": [call.toolName for call in biology.runInfo.toolCalls],\n", - " \"treatment_observations\": len(biology.treatmentObservations),\n", - " \"limitations\": biology.limitations,\n", - "}" - ] - } - ], - "metadata": { - "description": "Run Scarf's four grounded analysis agents from a rebuilt 5K PBMC baseline.", - "jupytext": { - "cell_metadata_filter": "tags", - "text_representation": { - "extension": ".md", - "format_name": "myst", - "format_version": 0.13, - "jupytext_version": "1.14.1" - } - }, - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.14.4" - }, - "source_map": [ - 14, - 37, - 67, - 75, - 259, - 267, - 290, - 302, - 313, - 322, - 338, - 350, - 382, - 390, - 427 - ] - }, - "nbformat": 4, - "nbformat_minor": 5 -} \ No newline at end of file diff --git a/docs/.jupyter_cache/executed/d46bae099f63faffe6d8c4de5a9bc1d1/base.ipynb b/docs/.jupyter_cache/executed/d46bae099f63faffe6d8c4de5a9bc1d1/base.ipynb new file mode 100644 index 00000000..760dfbab --- /dev/null +++ b/docs/.jupyter_cache/executed/d46bae099f63faffe6d8c4de5a9bc1d1/base.ipynb @@ -0,0 +1,944 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "b9a6f390", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
Downloading bucket files18098007 / 18098007 complete18098007 / 18098007 complete
" + ], + "text/plain": [ + "Downloading bucket files: 18098007 / 18098007 complete" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
Downloading bytes18098007 / 18098007 complete18098007 / 18098007 complete
" + ], + "text/plain": [ + "Downloading bytes: 18098007 / 18098007 complete" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "{'source': 'data.h5', 'destination': 'agent_workflow.zarr'}" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from contextlib import redirect_stdout\n", + "from io import StringIO\n", + "from pathlib import Path\n", + "\n", + "import scarf\n", + "from scarf.agent import (\n", + " AgentOrchestrator,\n", + " AgentRunConfig,\n", + " AutomatedWorkflowConfig,\n", + " AutomatedWorkflowRequest,\n", + " AutomatedWorkflowResumeRequest,\n", + " generate_agent_report,\n", + " load_agent_report,\n", + ")\n", + "from scarf.agent.orchestrator import artifact_model_to_ref\n", + "\n", + "scarf.configure_output(level=\"WARNING\", progress=False)\n", + "\n", + "source_path = scarf.cytebase.connect(\"scarf_docs\").download(\n", + " \"tenx_5K_pbmc_rnaseq/data.h5\",\n", + " destination=\"scarf_datasets\",\n", + ")[0]\n", + "zarr_path = source_path.with_name(\"agent_workflow.zarr\")\n", + "\n", + "study_context = (\n", + " \"This is a human 10x Genomics 5K PBMC 3-prime gene-expression dataset \"\n", + " \"from peripheral blood collected from one healthy donor. The goal is \"\n", + " \"unsupervised identification and characterization of the major immune-cell \"\n", + " \"populations. No treatment comparison, technical batch covariate, paired \"\n", + " \"modality, or independent replication metadata is available. Do not invent \"\n", + " \"absent design variables or report treatment effects.\"\n", + ")\n", + "\n", + "{\"source\": source_path.name, \"destination\": zarr_path.name}" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "e3421ca7", + "metadata": { + "tags": [ + "remove-cell" + ] + }, + "outputs": [], + "source": [ + "import re\n", + "from typing import Any\n", + "\n", + "from pydantic_ai.messages import (\n", + " ModelMessage,\n", + " ModelResponse,\n", + " ToolCallPart,\n", + " ToolReturnPart,\n", + ")\n", + "from pydantic_ai.models.function import AgentInfo, FunctionModel\n", + "\n", + "from scarf.agent.biological_interpretation import (\n", + " BiologicalInterpretationReport,\n", + " ClusterCompositionEvidence,\n", + " ClusterInterpretation,\n", + " ClusterMarkerBatchEvidence,\n", + ")\n", + "from scarf.agent.data_enrichment import (\n", + " AssayFeatureInspectionBatch,\n", + " DataEnrichmentReport,\n", + " FeatureSelectionPolicy,\n", + " StudyContextSummary,\n", + ")\n", + "from scarf.agent.experimental_context import (\n", + " BatchCorrectionPlan,\n", + " CellQcPlan,\n", + " CovariateEvidence,\n", + " ExperimentalContextDecision,\n", + ")\n", + "from scarf.agent.parameter_tuning import (\n", + " FinalGraphSelection,\n", + " ParameterTuningReport,\n", + ")\n", + "\n", + "\n", + "def _prompt_text(messages: list[ModelMessage]) -> str:\n", + " return \"\\n\".join(\n", + " part.content\n", + " for message in messages\n", + " for part in message.parts\n", + " if isinstance(getattr(part, \"content\", None), str)\n", + " )\n", + "\n", + "\n", + "def _tool_result(\n", + " messages: list[ModelMessage],\n", + " tool_name: str,\n", + " model_type: Any,\n", + ") -> Any:\n", + " for message in reversed(messages):\n", + " for part in reversed(message.parts):\n", + " if isinstance(part, ToolReturnPart) and part.tool_name == tool_name:\n", + " if isinstance(part.content, model_type):\n", + " return part.content\n", + " if isinstance(part.content, str):\n", + " return model_type.model_validate_json(part.content)\n", + " return model_type.model_validate(part.content)\n", + " raise AssertionError(f\"Missing tool return {tool_name!r}\")\n", + "\n", + "\n", + "def _tool_call(name: str, args: dict[str, Any] | None = None) -> ModelResponse:\n", + " return ModelResponse(parts=[ToolCallPart(tool_name=name, args=args or {})])\n", + "\n", + "\n", + "def _structured_output(info: AgentInfo, value: Any) -> ModelResponse:\n", + " payload = value.model_dump() if hasattr(value, \"model_dump\") else value\n", + " return _tool_call(info.output_tools[0].name, payload)\n", + "\n", + "\n", + "def _scripted_workflow_model() -> tuple[FunctionModel, dict[str, int]]:\n", + " state = {\n", + " \"enrichment\": 0,\n", + " \"context\": 0,\n", + " \"parameter\": 0,\n", + " \"biology\": 0,\n", + " \"requests\": 0,\n", + " }\n", + "\n", + " async def reply(\n", + " messages: list[ModelMessage],\n", + " info: AgentInfo,\n", + " ) -> ModelResponse:\n", + " state[\"requests\"] += 1\n", + " tools = {tool.name for tool in info.function_tools}\n", + "\n", + " if \"inspect_assay_features_batch\" in tools or state[\"enrichment\"] == 1:\n", + " if state[\"enrichment\"] == 0:\n", + " state[\"enrichment\"] = 1\n", + " return _tool_call(\"inspect_assay_features_batch\")\n", + "\n", + " batch = _tool_result(\n", + " messages,\n", + " \"inspect_assay_features_batch\",\n", + " AssayFeatureInspectionBatch,\n", + " )\n", + " policies = []\n", + " for inspection in batch.inspections:\n", + " species_observed = inspection.species != \"unknown\"\n", + " policy_evidence = list(inspection.evidenceIds)\n", + " if not species_observed:\n", + " policy_evidence.append(\"context:study\")\n", + " policies.append(\n", + " FeatureSelectionPolicy(\n", + " assay=inspection.assay,\n", + " species=(\n", + " inspection.species\n", + " if species_observed\n", + " else \"homo_sapiens\"\n", + " ),\n", + " speciesConfidence=\"high\" if species_observed else \"medium\",\n", + " speciesRationale=(\n", + " inspection.speciesReason\n", + " or \"The exact study paragraph identifies a human sample.\"\n", + " ),\n", + " excludeFamilies=[\n", + " family.family\n", + " for family in inspection.families\n", + " if family.count > 0 and family.defaultExclude is True\n", + " ],\n", + " protectFamilies=[\n", + " family.family\n", + " for family in inspection.families\n", + " if family.count > 0 and family.defaultExclude is False\n", + " ],\n", + " rationale=(\n", + " \"Exclude observed technical families and preserve \"\n", + " \"observed protected families.\"\n", + " ),\n", + " evidenceIds=list(dict.fromkeys(policy_evidence)),\n", + " )\n", + " )\n", + " state[\"enrichment\"] = 2\n", + " return _structured_output(\n", + " info,\n", + " DataEnrichmentReport(\n", + " status=\"done\",\n", + " studyContextSummary=StudyContextSummary(\n", + " organismReferences=[\"human\"],\n", + " tissueReferences=[\"peripheral blood\"],\n", + " experimentalReferences=[\n", + " \"10x Genomics 5K PBMC 3-prime gene-expression dataset\"\n", + " ],\n", + " analysisIntentReferences=[\n", + " \"unsupervised identification and characterization of \"\n", + " \"the major immune-cell populations\"\n", + " ],\n", + " ),\n", + " policies=policies,\n", + " ),\n", + " )\n", + "\n", + " if tools.intersection(\n", + " {\n", + " \"inspect_cell_covariates\",\n", + " \"analyze_experimental_design\",\n", + " \"score_current_representation\",\n", + " }\n", + " ) or state[\"context\"] in {1, 2}:\n", + " if state[\"context\"] == 0:\n", + " state[\"context\"] = 1\n", + " return _tool_call(\"inspect_cell_covariates\")\n", + " if state[\"context\"] == 1:\n", + " state[\"context\"] = 2\n", + " return _tool_call(\n", + " \"analyze_experimental_design\",\n", + " {\n", + " \"column_domains\": {},\n", + " \"coefficients_of_interest\": [],\n", + " \"units_of_inference\": {},\n", + " \"batch_columns\": [],\n", + " },\n", + " )\n", + "\n", + " design = _tool_result(\n", + " messages,\n", + " \"analyze_experimental_design\",\n", + " CovariateEvidence,\n", + " )\n", + " profile = next(\n", + " value\n", + " for value in design.qcProfiles\n", + " if value.action == \"globalGaussian\"\n", + " )\n", + " evidence_id = profile.evidenceId\n", + " state[\"context\"] = 3\n", + " return _structured_output(\n", + " info,\n", + " ExperimentalContextDecision(\n", + " batchCorrection=BatchCorrectionPlan(\n", + " action=\"skip\",\n", + " rationale=\"No trusted technical batch column was supplied.\",\n", + " evidenceIds=[evidence_id],\n", + " ),\n", + " cellQc=CellQcPlan(\n", + " action=profile.action,\n", + " profileId=profile.profileId,\n", + " driverAssay=profile.driverAssay,\n", + " driverAssayType=profile.driverAssayType,\n", + " attributes=profile.attributes,\n", + " artifactMetrics=profile.artifactMetrics,\n", + " rationale=\"Apply the bounded global RNA QC profile.\",\n", + " evidenceIds=[evidence_id],\n", + " ),\n", + " rationale=\"No experimental covariates were supplied.\",\n", + " evidenceIds=[evidence_id],\n", + " ),\n", + " )\n", + "\n", + " if tools.intersection(\n", + " {\"inspect_cluster_composition\", \"inspect_cluster_markers_batch\"}\n", + " ) or state[\"biology\"]:\n", + " if state[\"biology\"] == 0:\n", + " state[\"biology\"] = 1\n", + " return _tool_call(\"inspect_cluster_composition\")\n", + " if state[\"biology\"] == 1:\n", + " composition = _tool_result(\n", + " messages,\n", + " \"inspect_cluster_composition\",\n", + " ClusterCompositionEvidence,\n", + " )\n", + " state[\"biology\"] = 2\n", + " return _tool_call(\n", + " \"inspect_cluster_markers_batch\",\n", + " {\"cluster_ids\": list(composition.clusterCounts)},\n", + " )\n", + "\n", + " marker_batch = _tool_result(\n", + " messages,\n", + " \"inspect_cluster_markers_batch\",\n", + " ClusterMarkerBatchEvidence,\n", + " )\n", + " interpretations = []\n", + " for cluster in marker_batch.clusters:\n", + " if cluster.evidenceId and cluster.markers:\n", + " marker = cluster.markers[0]\n", + " marker_name = marker.featureName or marker.featureId\n", + " interpretations.append(\n", + " ClusterInterpretation(\n", + " clusterId=cluster.clusterId,\n", + " proposedIdentity=f\"{marker_name}-high RNA state\",\n", + " identityIsHypothesis=True,\n", + " confidence=\"low\",\n", + " rationale=(\n", + " \"The returned marker panel is led by \"\n", + " f\"{marker_name}.\"\n", + " ),\n", + " evidenceIds=[cluster.evidenceId],\n", + " )\n", + " )\n", + " state[\"biology\"] = 3\n", + " return _structured_output(\n", + " info,\n", + " BiologicalInterpretationReport(\n", + " status=\"done\",\n", + " clusterInterpretations=interpretations,\n", + " evidenceIds=[item.evidenceIds[0] for item in interpretations],\n", + " limitations=[\n", + " \"The scripted documentation model returns marker-linked \"\n", + " \"hypotheses, not validated cell identities.\"\n", + " ],\n", + " stopReason=(\n", + " \"Every cluster with returned marker evidence was reviewed.\"\n", + " ),\n", + " ),\n", + " )\n", + "\n", + " prompt = _prompt_text(messages)\n", + " if state[\"parameter\"] == 0:\n", + " match = re.search(\n", + " r'\"candidateId\"\\s*:\\s*\"([A-Za-z0-9_]+)\"',\n", + " prompt,\n", + " )\n", + " if match is None:\n", + " raise AssertionError(\"The parameter prompt lacks a candidate ID\")\n", + " candidate_id = match.group(1)\n", + " evidence_id = f\"candidate:{candidate_id}:clusters\"\n", + " assay_report = ParameterTuningReport(\n", + " status=\"done\",\n", + " recommendedCandidateId=candidate_id,\n", + " confidence=\"high\",\n", + " rationale=\"The only authorized native branch is eligible.\",\n", + " evidenceIds=[evidence_id],\n", + " stopReason=\"The bounded one-candidate screen completed.\",\n", + " )\n", + " state[\"parameter\"] = 1\n", + " return _structured_output(\n", + " info,\n", + " ParameterTuningReport(\n", + " status=\"done\",\n", + " assayReports={\"RNA\": assay_report},\n", + " rationale=\"The RNA native screen completed.\",\n", + " evidenceIds=[evidence_id],\n", + " stopReason=\"Native selection completed.\",\n", + " ),\n", + " )\n", + "\n", + " match = re.search(\n", + " r'\"optionId\"\\s*:\\s*\"(native:RNA:([A-Za-z0-9_]+))\"',\n", + " prompt,\n", + " )\n", + " if match is None:\n", + " raise AssertionError(\"The final-selection prompt lacks a native option\")\n", + " option_id, candidate_id = match.groups()\n", + " evidence_id = f\"native:RNA:candidate:{candidate_id}:clusters\"\n", + " state[\"parameter\"] = 2\n", + " return _structured_output(\n", + " info,\n", + " FinalGraphSelection(\n", + " status=\"done\",\n", + " selectedOptionId=option_id,\n", + " graphMethod=\"native\",\n", + " nativeAssay=\"RNA\",\n", + " nativeCandidateId=candidate_id,\n", + " markerAssay=\"RNA\",\n", + " confidence=\"high\",\n", + " rationale=\"The sole eligible native graph is selected.\",\n", + " evidenceIds=[evidence_id],\n", + " ),\n", + " )\n", + "\n", + " return FunctionModel(reply), state" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "e43fec76", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'initial_candidates': 1,\n", + " 'refinement_candidates': 0,\n", + " 'harmony_candidates': 0,\n", + " 'allow_assumptions': False}" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "model, model_state = _scripted_workflow_model()\n", + "config = AutomatedWorkflowConfig(\n", + " primaryInitialCandidates=1,\n", + " secondaryInitialCandidates=1,\n", + " maxRefinedCandidatesPerAssay=0,\n", + " maxHarmonyCandidatesPerAssay=0,\n", + " integrationResolutionCandidates=1,\n", + " maxCandidateBranches=1,\n", + " minClusterCells=2,\n", + " agentRunConfig=AgentRunConfig(\n", + " requestLimit=5,\n", + " toolCallLimit=5,\n", + " ),\n", + ")\n", + "orchestrator = AgentOrchestrator(model, config=config)\n", + "request = AutomatedWorkflowRequest(\n", + " sourcePath=str(source_path),\n", + " zarrPath=str(zarr_path),\n", + " studyContext=study_context,\n", + " allowAssumptions=False,\n", + " primaryAssay=\"RNA\",\n", + " markerAssay=\"RNA\",\n", + " analysisAssays=[\"RNA\"],\n", + " ingestDirections={\"overwrite\": True, \"defaultAssay\": \"RNA\"},\n", + ")\n", + "\n", + "{\n", + " \"initial_candidates\": config.primaryInitialCandidates,\n", + " \"refinement_candidates\": config.maxRefinedCandidatesPerAssay,\n", + " \"harmony_candidates\": config.maxHarmonyCandidatesPerAssay,\n", + " \"allow_assumptions\": request.allowAssumptions,\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "51a1e20a", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'status': 'needsInput',\n", + " 'stage': 'preprocessing_plan',\n", + " 'question_id': 'approvePlanChecksum',\n", + " 'primary_assay': 'RNA',\n", + " 'marker_assay': 'RNA',\n", + " 'cell_qc': 'globalGaussian',\n", + " 'routes': [{'assay': 'RNA', 'features': 'hvg', 'reduction': 'pca'}]}" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "with redirect_stdout(StringIO()):\n", + " result = orchestrator.run(request)\n", + "\n", + "if (\n", + " result.status != \"needsInput\"\n", + " or result.currentStage != \"preprocessing_plan\"\n", + " or result.preprocessingPlan is None\n", + " or result.workflowRun is None\n", + " or result.zarrPath is None\n", + "):\n", + " raise RuntimeError(f\"Unexpected workflow result: {result.status}, {result.notes}\")\n", + "\n", + "question = result.needsInput.questions[0]\n", + "plan = result.preprocessingPlan\n", + "{\n", + " \"status\": result.status,\n", + " \"stage\": result.currentStage,\n", + " \"question_id\": question.questionId,\n", + " \"primary_assay\": plan.primaryAssay,\n", + " \"marker_assay\": plan.markerAssay,\n", + " \"cell_qc\": plan.cellQc.action,\n", + " \"routes\": [\n", + " {\n", + " \"assay\": assay.assay,\n", + " \"features\": assay.featureMethod,\n", + " \"reduction\": assay.reductionMethod,\n", + " }\n", + " for assay in plan.assays\n", + " ],\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "d68d6c4f", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'status': 'completed',\n", + " 'stage': 'biological_interpretation',\n", + " 'agent_reports': ['data_enrichment',\n", + " 'experimental_context',\n", + " 'parameter_tuning',\n", + " 'biological_interpretation'],\n", + " 'model_requests': 9,\n", + " 'graph_method': 'native',\n", + " 'marker_assay': 'RNA'}" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "with redirect_stdout(StringIO()):\n", + " result = orchestrator.resume(\n", + " AutomatedWorkflowResumeRequest(\n", + " zarrPath=result.zarrPath,\n", + " workflowRunId=result.workflowRun.workflowRunId,\n", + " workspace=result.workflowRun.workspace,\n", + " answers={\"approvePlanChecksum\": plan.planChecksum},\n", + " )\n", + " )\n", + "\n", + "if result.status != \"completed\" or result.finalAnalysis is None:\n", + " raise RuntimeError(f\"Workflow stopped at {result.currentStage}: {result.notes}\")\n", + "\n", + "{\n", + " \"status\": result.status,\n", + " \"stage\": result.currentStage,\n", + " \"agent_reports\": [ref.agentName for ref in result.reportReferences],\n", + " \"model_requests\": model_state[\"requests\"],\n", + " \"graph_method\": result.finalAnalysis.graphMethod,\n", + " \"marker_assay\": result.finalAnalysis.markerAssay,\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "ad25f11e", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'candidates': [{'assay': 'RNA',\n", + " 'candidate': 1,\n", + " 'dimensions': 21,\n", + " 'resolution': 1.0,\n", + " 'neighbors': 11,\n", + " 'eligible': True,\n", + " 'clusters': 17,\n", + " 'smallest_cluster': 29,\n", + " 'graph_silhouette': 0.21152588062616312}],\n", + " 'stop_reason': 'Native selection completed.',\n", + " 'report_statuses': {'data_enrichment': 'done',\n", + " 'experimental_context': 'done',\n", + " 'parameter_tuning': 'done',\n", + " 'biological_interpretation': 'done'}}" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "reports = {\n", + " reference.agentName: load_agent_report(result.zarrPath, reference)\n", + " for reference in result.reportReferences\n", + "}\n", + "parameter_report = reports[\"parameter_tuning\"]\n", + "\n", + "candidate_metrics = []\n", + "for assay, assay_report in parameter_report.assayReports.items():\n", + " for index, evaluation in enumerate(assay_report.evaluations, start=1):\n", + " candidate_metrics.append(\n", + " {\n", + " \"assay\": assay,\n", + " \"candidate\": index,\n", + " \"dimensions\": evaluation.parameters.dimensions,\n", + " \"resolution\": evaluation.parameters.leidenResolution,\n", + " \"neighbors\": evaluation.parameters.neighborsK,\n", + " \"eligible\": evaluation.eligible,\n", + " \"clusters\": evaluation.metrics.nClusters,\n", + " \"smallest_cluster\": evaluation.metrics.minClusterCells,\n", + " \"graph_silhouette\": evaluation.metrics.graphSilhouetteMedian,\n", + " }\n", + " )\n", + "\n", + "{\n", + " \"candidates\": candidate_metrics,\n", + " \"stop_reason\": parameter_report.stopReason,\n", + " \"report_statuses\": {\n", + " name: report.status for name, report in reports.items()\n", + " },\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "238e9842", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "final = result.finalAnalysis\n", + "if (\n", + " final.cellSelection is None\n", + " or final.clusters is None\n", + " or final.umap is None\n", + " or final.markers is None\n", + "):\n", + " raise RuntimeError(\"The completed final handoff is missing required artifacts\")\n", + "\n", + "final_store = scarf.DataStore(\n", + " result.zarrPath,\n", + " default_assay=final.primaryAssay,\n", + " min_features_per_cell=-1,\n", + " mito_pattern=\"\",\n", + " ribo_pattern=\"\",\n", + " zarr_mode=\"r\",\n", + " workspace=result.workflowRun.workspace,\n", + " nthreads=2,\n", + ")\n", + "cell_selection_ref = artifact_model_to_ref(final.cellSelection)\n", + "cluster_ref = artifact_model_to_ref(final.clusters)\n", + "umap_ref = artifact_model_to_ref(final.umap)\n", + "marker_ref = artifact_model_to_ref(final.markers)\n", + "\n", + "final_store.plots.embedding(\n", + " layout=umap_ref,\n", + " color_by=cluster_ref,\n", + " legend_loc=\"on_data\",\n", + " frame=\"none\",\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "276879c2", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
group_idfeature_namescorefrac_exp
01S100A120.741380.98516
11QPCT0.734600.53116
1390810AL022069.10.138790.04211
1390910AC073320.10.137700.04211
2781611ADTRP0.233320.23611
2781711FHIT0.205660.58333
4172412ANKRD550.252000.16746
4172512ADTRP0.249740.24242
5563213TCL1A0.853080.96578
5563313IGHD0.768871.00000
6954014TNFRSF40.399100.37500
6954114TTC39C-AS10.300780.16189
\n", + "
" + ], + "text/plain": [ + " group_id feature_name score frac_exp\n", + "0 1 S100A12 0.74138 0.98516\n", + "1 1 QPCT 0.73460 0.53116\n", + "13908 10 AL022069.1 0.13879 0.04211\n", + "13909 10 AC073320.1 0.13770 0.04211\n", + "27816 11 ADTRP 0.23332 0.23611\n", + "27817 11 FHIT 0.20566 0.58333\n", + "41724 12 ANKRD55 0.25200 0.16746\n", + "41725 12 ADTRP 0.24974 0.24242\n", + "55632 13 TCL1A 0.85308 0.96578\n", + "55633 13 IGHD 0.76887 1.00000\n", + "69540 14 TNFRSF4 0.39910 0.37500\n", + "69541 14 TTC39C-AS1 0.30078 0.16189" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "marker_table = final_store.get_markers(\n", + " marker=marker_ref,\n", + " group_id=None,\n", + " min_score=-1,\n", + " min_frac_exp=-1,\n", + ")\n", + "marker_table.sort_values(\n", + " [\"group_id\", \"score\"],\n", + " ascending=[True, False],\n", + ").groupby(\"group_id\", sort=True).head(2)[\n", + " [\"group_id\", \"feature_name\", \"score\", \"frac_exp\"]\n", + "].head(12)" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "7453753b", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'report': 'agent_workflow.zarr/agents/runs//report/index.html',\n", + " 'exists': True,\n", + " 'final_artifact_kinds': {'selection': 'cell_selection',\n", + " 'clusters': 'cluster_labels',\n", + " 'umap': 'embedding',\n", + " 'markers': 'marker_table'}}" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "report_path = generate_agent_report(\n", + " result.zarrPath,\n", + " result.workflowRun.workflowRunId,\n", + " workspace=result.workflowRun.workspace,\n", + ")\n", + "display_path = str(report_path.relative_to(Path(result.zarrPath).parent)).replace(\n", + " result.workflowRun.workflowRunId,\n", + " \"\",\n", + ")\n", + "\n", + "{\n", + " \"report\": display_path,\n", + " \"exists\": report_path.is_file(),\n", + " \"final_artifact_kinds\": {\n", + " \"selection\": cell_selection_ref.kind,\n", + " \"clusters\": cluster_ref.kind,\n", + " \"umap\": umap_ref.kind,\n", + " \"markers\": marker_ref.kind,\n", + " },\n", + "}" + ] + } + ], + "metadata": { + "description": "Run Scarf's resumable automated agent orchestrator on a 5K PBMC dataset.", + "jupytext": { + "cell_metadata_filter": "tags", + "text_representation": { + "extension": ".md", + "format_name": "myst", + "format_version": 0.13, + "jupytext_version": "1.14.1" + } + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.0" + }, + "source_map": [ + 14, + 58, + 93, + 99, + 424, + 433, + 466, + 475, + 506, + 516, + 538, + 551, + 582, + 596, + 627, + 632, + 645, + 657, + 678 + ] + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/.jupyter_cache/global.db b/docs/.jupyter_cache/global.db index 98bf856b77b9f38c5bbc06d866c11f30127a5268..5d521e9fac1e0bd88733e5bafe3f38390c8a1c3e 100644 GIT binary patch delta 3130 zcmeH}%Wq9l6vnTss;zr_CuqHINk}9;*{`+tUIwBAiHU@&Rv%QKL|Y^tRg_*563?(q zI_tn#JO(roDG7@~}gFzQ2AZV_2B4r{#dlfW^ ziB@#t(Dk%LN<O|GEV?#u2P}|xIkenVd6E79!|S>&;CJ4 zOr)a5_K8HpAo7MgWJa6UmBT zS&oSm^!doul*Bq~T;Nt9iitOLt}}U&47BtPLUqN&D=KzPrd+^zql1!|7)f8dlD{!T zpb?A;xs(ag)$Z|>i$RMvD2a&^)YNk^Edk@%rs91;WSH=^eC)ZDmI(AR*fGOI85%j7 zK0Y7ay5Qx<#5-DhY+NpWk`O86UIryGL0f9f(#hUaohg;k+6QY>Owg3JFRRJyKbSw- z(lTglf8UTm9JYG6Z+Ljf4*CHzI0SvzKOhGs{Wyku97gE)uDwuN2q7m!os!<74E9jc z@7^u6@p!Sm@IALazp4II;c@O+;q{Bsx>c|GM!Kr#Y;UnUyKVXY+P{^P&tx(Unb}OC zqyBAuLw;$f&gX+z8mhrZvNTkehU$MhRQLYtP~~x}#LKCRb%|iIkp;!sP&uK?gvU2M zy_1YT2E1N{5_5qd?Yos9VK#+dkO~#*%IARflk}^S>zIO!|2N_EY=urAE z5G?K7z(LlL?`>(J&7(ycyWf`T2Col@f$hmgW#Z`9{p2phz<9^&cEvI@z|512n+CApigX delta 3134 zcmeH}yG~S56o!WZ5pafSl$%jUjWL>VvhNoP6FV(P5D^d%oR~qwMG6%}K^aUm5z*+f zv7@jyUJ5l7XrYOhNAMZ+0c>1*n1ZvMg8T?$bdq*r5mXru7HTD^hATboC&m>%kRpIU_5+$I$ zf8IMVqP4(o0xpgy*yvyOS|LJm89XpY6o;9C8@>q@+K`}$BhWHMR$v|BdqG12fqUT~XcfBXhOGIU&;QLVg8YAFJ>%h&g z5CeU}JRRNW4~V1XaOb#o_nZzI5&_ zbezv;G9B4H%@esNnHRYquS)GZ-V`Q>>JI&@`t-rt?nYRj$*XKCm1<4BOXZ5q>&-3M z>RjE72v(h|)w!A&EY-RCAI?=TY5p=--NeeKGKE~q#K6y$cm&EwTE#@h+$hcYta(9y$0nZ-B*A4{I2n^*!x^EKP S(T_cP>J=uKA>q>~6a5F+1-Lc< diff --git a/docs/source/analysis_with_agents.md b/docs/source/analysis_with_agents.md index 8fbbbd0c..46be3880 100644 --- a/docs/source/analysis_with_agents.md +++ b/docs/source/analysis_with_agents.md @@ -8,7 +8,8 @@ description: Use Scarf safely in an autonomous or AI-assisted single-cell analys This page is a routing and reasoning guide for an AI agent that uses Scarf to analyse data. It does not replace the workflow tutorials or define one correct analysis. The study question, experimental design, and user instructions remain authoritative. -For an executable example of the four bounded Scarf agents and their validated handoffs, see {doc}`tutorials/agent_workflow`. +For an executable ingest-to-interpretation example with persisted checkpoints and resume, see +{doc}`tutorials/agent_workflow`. ## Scope and authority @@ -96,6 +97,50 @@ Before the first mutating operation, make a short execution record containing: Update this record before changing the cohort, inputs, or decision criteria. This prospective boundary makes unintended writes and retrospective justifications visible. +`AgentOrchestrator` persists the immutable request, effective configuration, stage attempts, +agent-report handoffs, and artifact references. The caller still owns the scientific question and +unit of inference. + +### When to use the automated agent workflow + +Use `AgentOrchestrator` when the input is a supported dataset path and the caller can supply one +study-context paragraph. The orchestrator owns a fixed stage order: ingest, Data Enrichment, +optional HTO demultiplexing, Experimental Context, preprocessing-plan approval, preprocessing, +Parameter Tuning, analysis finalization, and Biological Interpretation. The model does not write +exploratory code or choose arbitrary `DataStore` calls. It selects only validated policies and +candidate identifiers from bounded evidence; executor-owned public operations create and pass exact +immutable artifact references. + +```python +from scarf.agent import ( + AgentOrchestrator, + AutomatedWorkflowRequest, + AutomatedWorkflowResumeRequest, +) + +orchestrator = AgentOrchestrator(model) +result = orchestrator.run( + AutomatedWorkflowRequest( + sourcePath="study.h5ad", + zarrPath="study.zarr", + studyContext="One paragraph describing the study and analysis intent.", + ) +) +``` + +The parameter screen uses granular public operations for each authorized branch rather than +invoking `ds.pipeline.run()` for every candidate. This keeps normalization, reduction, neighbours, +graph, clustering, metrics, promotion, UMAP, and marker artifacts explicit and enforces their order +through lineage. `ds.pipeline.run()` remains the fixed baseline recipe described below. + +With `allowAssumptions=False`, `run()` can persist a complete preprocessing plan and return +`needsInput` for approval. Resume only that running workflow with +`AutomatedWorkflowResumeRequest` and the persisted question identifiers. +`allowAssumptions=True` automatically approves the evidence-bounded preprocessing plan, but it does +not authorize invented metadata or unsafe batch correction. Any agent can still pause for genuine +ambiguity. A completed local workflow persists its terminal result and then creates a replaceable +HTML report. `generate_agent_report()` can regenerate that derived view without training new +analysis artifacts. ### When to use the pipeline @@ -219,7 +264,10 @@ Classify the problem before retrying: - **Scientific ambiguity:** preserve branches, seek another independent form of evidence, narrow the claim, or report that the available design does not resolve the alternatives. In a granular workflow, retry the lowest failed stage. A failed pipeline run is not resumable; -start a new run, which can reuse matching complete artifacts from the earlier attempt. +start a new run, which can reuse matching complete artifacts from the earlier attempt. An automated +agent workflow resumes only while it is running after `needsInput`. Failed and abandoned +orchestrations are terminal, while completed stages in a valid running workflow are checked and +reused on resume. ## Progress and deterministic comparisons @@ -248,4 +296,7 @@ A useful handoff reports: Artifact provenance records how Scarf produced a result. It does not replace this study-level reasoning record. +For an automated run, `AutomatedWorkflowResult.finalAnalysis` provides the exact final artifact +handoff and `reportReferences` identifies the persisted agent reports. The generated local HTML +report is a replaceable presentation of those durable records, not an additional source of truth. See {doc}`index` for the implemented methods and current boundaries. diff --git a/docs/source/index.md b/docs/source/index.md index b4b6ef5a..8deb7210 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -27,7 +27,7 @@ inputs and parameters produced each result. - {ref}`Install Scarf ` - Follow the {ref}`Quick start ` - Use {doc}`analysis_with_agents` to route an autonomous or AI-assisted analysis -- Run the executable {doc}`tutorials/agent_workflow` for the four grounded agent stages +- Run the executable {doc}`tutorials/agent_workflow` for a resumable automated agent workflow - Read {doc}`scanpy_and_seurat` if you already use Scanpy or Seurat - See {doc}`concepts/benchmarks` for measured end-to-end scale and stage timings diff --git a/docs/source/llms.txt b/docs/source/llms.txt index 9ac27cb8..9272d999 100644 --- a/docs/source/llms.txt +++ b/docs/source/llms.txt @@ -6,7 +6,7 @@ - [Home](index.html): design, implemented methods, validation, and current boundaries - [Analysis with AI agents](analysis_with_agents.html): scientific decision loop, run and artifact inspection, task routing, troubleshooting, and handoff -- [Grounded agent workflow](tutorials/agent_workflow.html): executable enrichment, design, tuning, and biological-interpretation handoffs +- [Automated agent workflow](tutorials/agent_workflow.html): executable ingest, preprocessing, tuning, finalization, interpretation, resume, and local report - [Quick start](quickstart.html): minimal count-to-cluster workflow - [API reference](reference/api.html): exact public signatures and result contracts diff --git a/docs/source/toctree.yml b/docs/source/toctree.yml index e3fdc631..70ca5ad2 100644 --- a/docs/source/toctree.yml +++ b/docs/source/toctree.yml @@ -9,7 +9,7 @@ subtrees: - file: analysis_with_agents title: Analysis with AI agents - file: tutorials/agent_workflow - title: Grounded agent workflow + title: Automated agent workflow - file: scanpy_and_seurat title: Scanpy and Seurat users - caption: Assay workflows diff --git a/docs/source/tutorials/agent_workflow.md b/docs/source/tutorials/agent_workflow.md index 94bc963a..6e5937b2 100644 --- a/docs/source/tutorials/agent_workflow.md +++ b/docs/source/tutorials/agent_workflow.md @@ -1,5 +1,5 @@ --- -description: Run Scarf's four grounded analysis agents from a rebuilt 5K PBMC baseline. +description: Run Scarf's resumable automated agent orchestrator on a 5K PBMC dataset. jupytext: cell_metadata_filter: tags text_representation: @@ -15,66 +15,93 @@ kernelspec: (agent_workflow)= -# Run a grounded agent workflow - -This tutorial runs Data Enrichment, Experimental Context, Parameter Tuning, and Biological -Interpretation against a current 5K PBMC store. -Each stage can inspect only its bounded tools, and each recommendation must cite evidence returned by those tools. +# Run the automated agent workflow + +This tutorial sends a 10x H5 dataset and one study-context paragraph to +`AgentOrchestrator`. The orchestrator runs Scarf's four bounded agents, owns the exact operation +order, persists every handoff, and returns exact final artifact references. The agents select from +executor-authorized operations and parameters. They do not write exploratory code. + +```{mermaid} +flowchart LR + A[Input dataset and study context] --> B[Ingest] + B --> C[Data Enrichment] + C --> D[HTO demultiplexing when present] + D --> E[Experimental Context] + E --> F[Preprocessing plan] + F --> G[Modality preprocessing] + G --> H[Parameter Tuning] + H --> I[UMAP, clusters, and markers] + I --> J[Biological Interpretation] + J --> K[Persisted reports and local HTML] +``` -The committed build uses small scripted `FunctionModel` callbacks. -They exercise the real tool calls, validators, artifact creation, and handoffs without sending data to an external model or requiring an API key. -The scripted Biological Interpretation callback deliberately leaves the selected cluster unresolved: it demonstrates evidence grounding, not biological expertise. -A live-provider configuration is shown at the end. +The committed documentation build uses one scripted Pydantic AI `FunctionModel`. It exercises the +real tools, validators, preprocessing, candidate execution, finalization, persistence, and resume +path without an API key. Its biological labels remain low-confidence marker-linked hypotheses. A +live-provider configuration is shown at the end. -## 1. Open the rebuilt teaching store +## 1. Download the raw teaching dataset -Install the optional agent dependencies before running this workflow outside the documentation environment: +Install the optional agent dependencies before running this workflow outside the documentation +environment: ```console pip install "scarf[agent]" ``` +The documentation run converts a raw H5 file into a separate teaching store. The explicit +`overwrite` direction is safe here because `agent_workflow.zarr` is a disposable derived target +owned by this tutorial. Omit it in ordinary work unless replacing that exact destination is +intentional. + ```{code-cell} ipython3 +from contextlib import redirect_stdout +from io import StringIO +from pathlib import Path + import scarf from scarf.agent import ( - BiologicalContext, - BiologicalInterpretationAgent, - DataEnrichmentAgent, - DataEnrichmentContext, - ExperimentalContextAgent, - ParameterCandidate, - ParameterTuningAgent, + AgentOrchestrator, + AgentRunConfig, + AutomatedWorkflowConfig, + AutomatedWorkflowRequest, + AutomatedWorkflowResumeRequest, + generate_agent_report, + load_agent_report, ) +from scarf.agent.orchestrator import artifact_model_to_ref scarf.configure_output(level="WARNING", progress=False) -dataset = scarf.cytebase.connect("scarf_docs").download_dataset( - "tenx_5K_pbmc_rnaseq", +source_path = scarf.cytebase.connect("scarf_docs").download( + "tenx_5K_pbmc_rnaseq/data.h5", destination="scarf_datasets", - zarr=True, -) -ds = scarf.DataStore( - f"{dataset}/data.zarr", - default_assay="RNA", - nthreads=2, +)[0] +zarr_path = source_path.with_name("agent_workflow.zarr") + +study_context = ( + "This is a human 10x Genomics 5K PBMC 3-prime gene-expression dataset " + "from peripheral blood collected from one healthy donor. The goal is " + "unsupervised identification and characterization of the major immune-cell " + "populations. No treatment comparison, technical batch covariate, paired " + "modality, or independent replication metadata is available. Do not invent " + "absent design variables or report treatment effects." ) -{ - "active_cells": int(ds.cells.fetch_all("I").sum()), - "total_cells": ds.cells.N, - "assays": ds.assay_names, -} +{"source": source_path.name, "destination": zarr_path.name} ``` -The downloaded store was rebuilt with this version of Scarf. Its labelled pipeline run supplies -the frozen baseline below, while agent-created candidates remain separate immutable artifacts. - -The hidden setup below defines deterministic model responses for this documentation build. -Every response is assembled from the actual tool return, so fabricated cluster IDs, feature families, or evidence IDs still fail the production validators. +The hidden setup below routes each model request by its available tools. Every structured response +is assembled from the exact tool result, so a fabricated assay, feature family, candidate, cluster, +or evidence identifier still fails the production validator. ```{code-cell} ipython3 :tags: [remove-cell] +import re +from typing import Any + from pydantic_ai.messages import ( ModelMessage, ModelResponse, @@ -84,355 +111,599 @@ from pydantic_ai.messages import ( from pydantic_ai.models.function import AgentInfo, FunctionModel from scarf.agent.biological_interpretation import ( + BiologicalInterpretationReport, ClusterCompositionEvidence, + ClusterInterpretation, ClusterMarkerBatchEvidence, ) -from scarf.agent.data_enrichment import AssayFeatureInspectionBatch -from scarf.agent.experimental_context import CovariateEvidence +from scarf.agent.data_enrichment import ( + AssayFeatureInspectionBatch, + DataEnrichmentReport, + FeatureSelectionPolicy, + StudyContextSummary, +) +from scarf.agent.experimental_context import ( + BatchCorrectionPlan, + CellQcPlan, + CovariateEvidence, + ExperimentalContextDecision, +) +from scarf.agent.parameter_tuning import ( + FinalGraphSelection, + ParameterTuningReport, +) -def _tool_returns(messages: list[ModelMessage]) -> list[ToolReturnPart]: - return [ - part +def _prompt_text(messages: list[ModelMessage]) -> str: + return "\n".join( + part.content for message in messages for part in message.parts - if isinstance(part, ToolReturnPart) - ] + if isinstance(getattr(part, "content", None), str) + ) -def _tool_call(name: str, args: dict | None = None) -> ModelResponse: +def _tool_result( + messages: list[ModelMessage], + tool_name: str, + model_type: Any, +) -> Any: + for message in reversed(messages): + for part in reversed(message.parts): + if isinstance(part, ToolReturnPart) and part.tool_name == tool_name: + if isinstance(part.content, model_type): + return part.content + if isinstance(part.content, str): + return model_type.model_validate_json(part.content) + return model_type.model_validate(part.content) + raise AssertionError(f"Missing tool return {tool_name!r}") + + +def _tool_call(name: str, args: dict[str, Any] | None = None) -> ModelResponse: return ModelResponse(parts=[ToolCallPart(tool_name=name, args=args or {})]) -def _structured_output(info: AgentInfo, payload: dict) -> ModelResponse: +def _structured_output(info: AgentInfo, value: Any) -> ModelResponse: + payload = value.model_dump() if hasattr(value, "model_dump") else value return _tool_call(info.output_tools[0].name, payload) -async def _enrichment_reply( - messages: list[ModelMessage], - info: AgentInfo, -) -> ModelResponse: - returns = _tool_returns(messages) - if not returns: - return _tool_call("inspect_assay_features_batch") - - batch = AssayFeatureInspectionBatch.model_validate(returns[-1].content) - inspection = batch.inspections[0] - species_observed = inspection.species != "unknown" - species = inspection.species if species_observed else "homo_sapiens" - evidence_ids = list(inspection.evidenceIds) - if not species_observed: - evidence_ids.append("context:organism") - policy = { - "assay": inspection.assay, - "species": species, - "speciesConfidence": "high" if species_observed else "medium", - "speciesRationale": ( - inspection.speciesReason - or "The inspected features and caller context support this species." - ), - "excludeFamilies": [ - family.family - for family in inspection.families - if family.count > 0 and family.defaultExclude is True - ], - "protectFamilies": [ - family.family - for family in inspection.families - if family.count > 0 and family.defaultExclude is False - ], - "rationale": "Exclude observed technical families and preserve protected ones.", - "evidenceIds": evidence_ids, +def _scripted_workflow_model() -> tuple[FunctionModel, dict[str, int]]: + state = { + "enrichment": 0, + "context": 0, + "parameter": 0, + "biology": 0, + "requests": 0, } - return _structured_output(info, {"status": "done", "policies": [policy]}) + async def reply( + messages: list[ModelMessage], + info: AgentInfo, + ) -> ModelResponse: + state["requests"] += 1 + tools = {tool.name for tool in info.function_tools} + + if "inspect_assay_features_batch" in tools or state["enrichment"] == 1: + if state["enrichment"] == 0: + state["enrichment"] = 1 + return _tool_call("inspect_assay_features_batch") + + batch = _tool_result( + messages, + "inspect_assay_features_batch", + AssayFeatureInspectionBatch, + ) + policies = [] + for inspection in batch.inspections: + species_observed = inspection.species != "unknown" + policy_evidence = list(inspection.evidenceIds) + if not species_observed: + policy_evidence.append("context:study") + policies.append( + FeatureSelectionPolicy( + assay=inspection.assay, + species=( + inspection.species + if species_observed + else "homo_sapiens" + ), + speciesConfidence="high" if species_observed else "medium", + speciesRationale=( + inspection.speciesReason + or "The exact study paragraph identifies a human sample." + ), + excludeFamilies=[ + family.family + for family in inspection.families + if family.count > 0 and family.defaultExclude is True + ], + protectFamilies=[ + family.family + for family in inspection.families + if family.count > 0 and family.defaultExclude is False + ], + rationale=( + "Exclude observed technical families and preserve " + "observed protected families." + ), + evidenceIds=list(dict.fromkeys(policy_evidence)), + ) + ) + state["enrichment"] = 2 + return _structured_output( + info, + DataEnrichmentReport( + status="done", + studyContextSummary=StudyContextSummary( + organismReferences=["human"], + tissueReferences=["peripheral blood"], + experimentalReferences=[ + "10x Genomics 5K PBMC 3-prime gene-expression dataset" + ], + analysisIntentReferences=[ + "unsupervised identification and characterization of " + "the major immune-cell populations" + ], + ), + policies=policies, + ), + ) -async def _experimental_reply( - messages: list[ModelMessage], - info: AgentInfo, -) -> ModelResponse: - returns = _tool_returns(messages) - if not returns: - return _tool_call("inspect_cell_covariates") - if len(returns) == 1: - return _tool_call( - "analyze_experimental_design", + if tools.intersection( { - "column_domains": {}, - "coefficients_of_interest": [], - "units_of_inference": {}, - "batch_columns": [], - }, - ) - - design = CovariateEvidence.model_validate(returns[-1].content) - evidence_id = design.evidenceIds[0] - return _structured_output( - info, - { - "batchCorrection": { - "action": "skip", - "rationale": ( - "No explicit technical batch or biological contrast was supplied." + "inspect_cell_covariates", + "analyze_experimental_design", + "score_current_representation", + } + ) or state["context"] in {1, 2}: + if state["context"] == 0: + state["context"] = 1 + return _tool_call("inspect_cell_covariates") + if state["context"] == 1: + state["context"] = 2 + return _tool_call( + "analyze_experimental_design", + { + "column_domains": {}, + "coefficients_of_interest": [], + "units_of_inference": {}, + "batch_columns": [], + }, + ) + + design = _tool_result( + messages, + "analyze_experimental_design", + CovariateEvidence, + ) + profile = next( + value + for value in design.qcProfiles + if value.action == "globalGaussian" + ) + evidence_id = profile.evidenceId + state["context"] = 3 + return _structured_output( + info, + ExperimentalContextDecision( + batchCorrection=BatchCorrectionPlan( + action="skip", + rationale="No trusted technical batch column was supplied.", + evidenceIds=[evidence_id], + ), + cellQc=CellQcPlan( + action=profile.action, + profileId=profile.profileId, + driverAssay=profile.driverAssay, + driverAssayType=profile.driverAssayType, + attributes=profile.attributes, + artifactMetrics=profile.artifactMetrics, + rationale="Apply the bounded global RNA QC profile.", + evidenceIds=[evidence_id], + ), + rationale="No experimental covariates were supplied.", + evidenceIds=[evidence_id], ), - "evidenceIds": [evidence_id], - }, - "rationale": "Continue with an uncorrected baseline.", - "evidenceIds": [evidence_id], - }, - ) - - -async def _tuning_reply( - _messages: list[ModelMessage], - info: AgentInfo, -) -> ModelResponse: - return _structured_output( - info, - { - "status": "done", - "recommendedCandidateId": "baseline", - "confidence": "medium", - "rationale": "The single authorized baseline completed successfully.", - "evidenceIds": ["candidate:baseline:clusters"], - "stopReason": "The authorized candidate was evaluated.", - }, - ) - + ) + + if tools.intersection( + {"inspect_cluster_composition", "inspect_cluster_markers_batch"} + ) or state["biology"]: + if state["biology"] == 0: + state["biology"] = 1 + return _tool_call("inspect_cluster_composition") + if state["biology"] == 1: + composition = _tool_result( + messages, + "inspect_cluster_composition", + ClusterCompositionEvidence, + ) + state["biology"] = 2 + return _tool_call( + "inspect_cluster_markers_batch", + {"cluster_ids": list(composition.clusterCounts)}, + ) + + marker_batch = _tool_result( + messages, + "inspect_cluster_markers_batch", + ClusterMarkerBatchEvidence, + ) + interpretations = [] + for cluster in marker_batch.clusters: + if cluster.evidenceId and cluster.markers: + marker = cluster.markers[0] + marker_name = marker.featureName or marker.featureId + interpretations.append( + ClusterInterpretation( + clusterId=cluster.clusterId, + proposedIdentity=f"{marker_name}-high RNA state", + identityIsHypothesis=True, + confidence="low", + rationale=( + "The returned marker panel is led by " + f"{marker_name}." + ), + evidenceIds=[cluster.evidenceId], + ) + ) + state["biology"] = 3 + return _structured_output( + info, + BiologicalInterpretationReport( + status="done", + clusterInterpretations=interpretations, + evidenceIds=[item.evidenceIds[0] for item in interpretations], + limitations=[ + "The scripted documentation model returns marker-linked " + "hypotheses, not validated cell identities." + ], + stopReason=( + "Every cluster with returned marker evidence was reviewed." + ), + ), + ) + + prompt = _prompt_text(messages) + if state["parameter"] == 0: + match = re.search( + r'"candidateId"\s*:\s*"([A-Za-z0-9_]+)"', + prompt, + ) + if match is None: + raise AssertionError("The parameter prompt lacks a candidate ID") + candidate_id = match.group(1) + evidence_id = f"candidate:{candidate_id}:clusters" + assay_report = ParameterTuningReport( + status="done", + recommendedCandidateId=candidate_id, + confidence="high", + rationale="The only authorized native branch is eligible.", + evidenceIds=[evidence_id], + stopReason="The bounded one-candidate screen completed.", + ) + state["parameter"] = 1 + return _structured_output( + info, + ParameterTuningReport( + status="done", + assayReports={"RNA": assay_report}, + rationale="The RNA native screen completed.", + evidenceIds=[evidence_id], + stopReason="Native selection completed.", + ), + ) -async def _biology_reply( - messages: list[ModelMessage], - info: AgentInfo, -) -> ModelResponse: - returns = _tool_returns(messages) - if not returns: - return _tool_call("inspect_cluster_composition") - if len(returns) == 1: - composition = ClusterCompositionEvidence.model_validate(returns[-1].content) - cluster_id = sorted( - composition.clusterCounts, - key=lambda value: (-composition.clusterCounts[value], value), - )[0] - return _tool_call( - "inspect_cluster_markers_batch", - {"cluster_ids": [cluster_id]}, + match = re.search( + r'"optionId"\s*:\s*"(native:RNA:([A-Za-z0-9_]+))"', + prompt, ) - - marker_batch = ClusterMarkerBatchEvidence.model_validate(returns[-1].content) - marker = marker_batch.clusters[0] - if not marker.evidenceId: + if match is None: + raise AssertionError("The final-selection prompt lacks a native option") + option_id, candidate_id = match.groups() + evidence_id = f"native:RNA:candidate:{candidate_id}:clusters" + state["parameter"] = 2 return _structured_output( info, - { - "status": "needsInput", - "needsInput": { - "question": "No markers passed the bounded search thresholds.", - "requiredInputs": ["markerArtifact"], - }, - "limitations": marker.warnings, - "stopReason": "Marker evidence was unavailable.", - }, + FinalGraphSelection( + status="done", + selectedOptionId=option_id, + graphMethod="native", + nativeAssay="RNA", + nativeCandidateId=candidate_id, + markerAssay="RNA", + confidence="high", + rationale="The sole eligible native graph is selected.", + evidenceIds=[evidence_id], + ), ) - names = [item.featureName or item.featureId for item in marker.markers[:3]] - return _structured_output( - info, - { - "status": "done", - "clusterInterpretations": [ - { - "clusterId": marker.clusterId, - "proposedIdentity": "unresolved marker-defined cluster", - "identityIsHypothesis": True, - "confidence": "low", - "rationale": f"Top returned marker features: {', '.join(names)}.", - "evidenceIds": [marker.evidenceId], - } - ], - "evidenceIds": [marker.evidenceId], - "limitations": [ - "The scripted documentation model does not assign cell identities." - ], - "stopReason": "One bounded cluster was reviewed.", - }, - ) + + return FunctionModel(reply), state ``` -## 2. Inspect species and feature families +## 2. Configure one bounded teaching branch -Data Enrichment is read-only. -It inspects the requested assays and returns a policy rather than changing the datastore. -The context below is caller-supplied study evidence, not a label inferred from expression. +The production defaults screen five candidates for the primary assay and may request one +refinement. This documentation run uses one native RNA candidate, no refinement, and no Harmony. +The smaller search exercises the same executor and persistence path while keeping the build +bounded. Harmony would be eligible only if Experimental Context returned exact safe batch evidence. ```{code-cell} ipython3 -enrichment = DataEnrichmentAgent(FunctionModel(_enrichment_reply)).run( - ds, - context=DataEnrichmentContext( - studyContext=( - "10x 5K PBMC RNA-seq from peripheral blood of a healthy human donor." - ), - organismHint="human", - tissueReferences=["peripheral blood"], - cellTypeReferences=["T cell", "B cell", "NK cell", "monocyte"], - experimentalDetails=["10x 3 prime RNA-seq", "single donor"], +model, model_state = _scripted_workflow_model() +config = AutomatedWorkflowConfig( + primaryInitialCandidates=1, + secondaryInitialCandidates=1, + maxRefinedCandidatesPerAssay=0, + maxHarmonyCandidatesPerAssay=0, + integrationResolutionCandidates=1, + maxCandidateBranches=1, + minClusterCells=2, + agentRunConfig=AgentRunConfig( + requestLimit=5, + toolCallLimit=5, ), - assays=["RNA"], ) +orchestrator = AgentOrchestrator(model, config=config) +request = AutomatedWorkflowRequest( + sourcePath=str(source_path), + zarrPath=str(zarr_path), + studyContext=study_context, + allowAssumptions=False, + primaryAssay="RNA", + markerAssay="RNA", + analysisAssays=["RNA"], + ingestDirections={"overwrite": True, "defaultAssay": "RNA"}, +) + +{ + "initial_candidates": config.primaryInitialCandidates, + "refinement_candidates": config.maxRefinedCandidatesPerAssay, + "harmony_candidates": config.maxHarmonyCandidatesPerAssay, + "allow_assumptions": request.allowAssumptions, +} +``` + +## 3. Run to the persisted approval checkpoint -policy = enrichment.policies[0] +With `allowAssumptions=False`, the orchestrator persists the exact proposed plan before asking the +caller to approve it. Data Enrichment and Experimental Context have already completed at this +point. The documentation captures the normal report-path printout so its output does not contain a +random workflow identifier. + +```{code-cell} ipython3 +with redirect_stdout(StringIO()): + result = orchestrator.run(request) + +if ( + result.status != "needsInput" + or result.currentStage != "preprocessing_plan" + or result.preprocessingPlan is None + or result.workflowRun is None + or result.zarrPath is None +): + raise RuntimeError(f"Unexpected workflow result: {result.status}, {result.notes}") + +question = result.needsInput.questions[0] +plan = result.preprocessingPlan { - "status": enrichment.status, - "species": policy.species, - "exclude_families": policy.excludeFamilies, - "protect_families": policy.protectFamilies, - "tool_calls": [call.name for call in enrichment.toolCalls], + "status": result.status, + "stage": result.currentStage, + "question_id": question.questionId, + "primary_assay": plan.primaryAssay, + "marker_assay": plan.markerAssay, + "cell_qc": plan.cellQc.action, + "routes": [ + { + "assay": assay.assay, + "features": assay.featureMethod, + "reduction": assay.reductionMethod, + } + for assay in plan.assays + ], } ``` -The family policy is advisory. -The feature-selection API accepts an exact selection or regular-expression blacklist, so do not silently translate family names into guessed feature names. +The plan checksum binds the approval to this exact plan. A different value is rejected rather than +approving whichever plan happens to be current. -## 3. Prepare a frozen baseline +## 4. Resume the same workflow -Open the durable pipeline run built with this dataset. It supplies frozen metadata, -normalization, and a current graph to Experimental Context. Parameter Tuning consumes the exact -normalized artifact and creates its own PCA, neighbour, graph, and clustering candidate without -changing the run. +Only a running persisted workflow can resume. The answer uses the question identifier and checksum +returned above. Completed stages and artifacts are validated and reused rather than executed again. ```{code-cell} ipython3 -run = ds.pipeline.open(label="docs_default") -normalized = run["normalized"] -hvg_ref = run["highly_variable_features"] +with redirect_stdout(StringIO()): + result = orchestrator.resume( + AutomatedWorkflowResumeRequest( + zarrPath=result.zarrPath, + workflowRunId=result.workflowRun.workflowRunId, + workspace=result.workflowRun.workspace, + answers={"approvePlanChecksum": plan.planChecksum}, + ) + ) + +if result.status != "completed" or result.finalAnalysis is None: + raise RuntimeError(f"Workflow stopped at {result.currentStage}: {result.notes}") { - "run_id": run.run_id, - "active_cells": int(run.cells.fetch_all("I").sum()), - "feature_selection": hvg_ref.artifact_id, - "normalized": normalized.artifact_id, + "status": result.status, + "stage": result.currentStage, + "agent_reports": [ref.agentName for ref in result.reportReferences], + "model_requests": model_state["requests"], + "graph_method": result.finalAnalysis.graphMethod, + "marker_assay": result.finalAnalysis.markerAssay, } ``` -## 4. Check the experimental context +The single scripted provider is called by all four agents. Deterministic operations, such as HTO +routing, preprocessing, candidate execution, promotion, UMAP, clustering, marker search, and +persistence, do not require separate model requests. -Experimental Context classifies metadata and authorizes an exact Harmony batch-column set only when the design supports it. -Passing the completed run binds metadata and integration metrics to its frozen cell selection and -graph artifact. -This single-donor store has no treatment or batch labels, so the grounded action is to keep an uncorrected baseline. +## 5. Review parameter evidence and agent reports + +The parameter agent receives executor-produced metrics for candidates that have already run. It +does not generate Scarf code. Each candidate follows the explicit reduction, optional Harmony, +ANN, neighbours, connectivity, Leiden, and metric chain. The final selected branch is replayed with +state updates and checked against the evaluated immutable references. ```{code-cell} ipython3 -experimental = ExperimentalContextAgent(FunctionModel(_experimental_reply)).run( - ds, - study_context=( - "Healthy-donor 5K PBMC. No treatment or batch labels are available. " - "Do not invent a technical batch or biological contrast." - ), - run=run, -) +reports = { + reference.agentName: load_agent_report(result.zarrPath, reference) + for reference in result.reportReferences +} +parameter_report = reports["parameter_tuning"] + +candidate_metrics = [] +for assay, assay_report in parameter_report.assayReports.items(): + for index, evaluation in enumerate(assay_report.evaluations, start=1): + candidate_metrics.append( + { + "assay": assay, + "candidate": index, + "dimensions": evaluation.parameters.dimensions, + "resolution": evaluation.parameters.leidenResolution, + "neighbors": evaluation.parameters.neighborsK, + "eligible": evaluation.eligible, + "clusters": evaluation.metrics.nClusters, + "smallest_cluster": evaluation.metrics.minClusterCells, + "graph_silhouette": evaluation.metrics.graphSilhouetteMedian, + } + ) { - "status": experimental.status, - "batch_action": experimental.decision.batchCorrection.action, - "batch_columns": experimental.decision.batchCorrection.batchColumns, - "coefficients": experimental.decision.coefficientsOfInterest, + "candidates": candidate_metrics, + "stop_reason": parameter_report.stopReason, + "report_statuses": { + name: report.status for name, report in reports.items() + }, } ``` -The validated result becomes a narrow handoff. -Downstream tuning receives the exact batch action and columns, rather than reparsing prose. +This one-candidate teaching run demonstrates execution and selection, not a broad parameter search. +The default configuration evaluates more initial candidates and may execute one evidence-driven +refinement. Harmony is added only when the exact Experimental Context handoff authorizes a matched +comparison. -## 5. Evaluate one authorized parameter branch +## 6. Plot the exact final UMAP and inspect markers -The original notebook screens five defaults. -For a bounded documentation run, authorize one explicit candidate and disable refinement. -The candidate executes PCA, neighbours, graph construction, Leiden clustering, and its available -diagnostics against the baseline run's exact normalized artifact. +`FinalAnalysisHandoff` separates graph ownership from marker-assay ownership and contains the exact +selection, graph, clusters, UMAP, and marker references used by Biological Interpretation. The +plotting call consumes those references directly; no coordinates or labels are copied into live +metadata columns. ```{code-cell} ipython3 -if experimental.status != "done": - raise RuntimeError(f"Experimental Context stopped with {experimental.status!r}") - -tuning_handoff = experimental.to_parameter_tuning_handoff() -candidate = ParameterCandidate( - candidateId="baseline", - dimensions=15, - leidenResolution=0.5, - neighborsK=11, - useHarmony=False, +final = result.finalAnalysis +if ( + final.cellSelection is None + or final.clusters is None + or final.umap is None + or final.markers is None +): + raise RuntimeError("The completed final handoff is missing required artifacts") + +final_store = scarf.DataStore( + result.zarrPath, + default_assay=final.primaryAssay, + min_features_per_cell=-1, + mito_pattern="", + ribo_pattern="", + zarr_mode="r", + workspace=result.workflowRun.workspace, + nthreads=2, ) -tuning = ParameterTuningAgent(FunctionModel(_tuning_reply)).run( - ds, - normalized=normalized, - candidates=[candidate], - experimental_handoff=tuning_handoff, - max_candidates=1, - max_refined_candidates=0, - min_cluster_cells=10, +cell_selection_ref = artifact_model_to_ref(final.cellSelection) +cluster_ref = artifact_model_to_ref(final.clusters) +umap_ref = artifact_model_to_ref(final.umap) +marker_ref = artifact_model_to_ref(final.markers) + +final_store.plots.embedding( + layout=umap_ref, + color_by=cluster_ref, + legend_loc="on_data", + frame="none", ) +``` -evaluation = tuning.evaluations[0] -{ - "status": tuning.status, - "recommended_candidate": tuning.recommendedCandidateId, - "eligible": evaluation.eligible, - "clusters": evaluation.metrics.nClusters, - "smallest_cluster": evaluation.metrics.minClusterCells, - "cluster_artifact": evaluation.artifacts["clusters"].artifactId, - "cell_selection": evaluation.cellSelection.artifactId, -} +UMAP is a presentation artifact. The tuning agent compares graph and metadata metrics, not visual +appearance, and the orchestrator does not train several UMAPs to choose the most attractive one. + +```{code-cell} ipython3 +marker_table = final_store.get_markers( + marker=marker_ref, + group_id=None, + min_score=-1, + min_frac_exp=-1, +) +marker_table.sort_values( + ["group_id", "score"], + ascending=[True, False], +).groupby("group_id", sort=True).head(2)[ + ["group_id", "feature_name", "score", "frac_exp"] +].head(12) ``` -## 6. Inspect one cluster with marker evidence +Marker scores are cell-level descriptive evidence. They are not replicate-aware differential +expression, and the scripted identities remain hypotheses. + +## 7. Open or regenerate the local HTML report -Biological Interpretation consumes the exact selected cluster artifact. -Marker search is explicitly authorized because Parameter Tuning does not create a marker table. -The build reviews only the largest cluster and uses relaxed retrieval thresholds so the example remains bounded; the returned identity remains an unresolved hypothesis. +A completed local workflow first persists its terminal result and then writes a replaceable HTML +view under `agents/runs//report/index.html`. Calling +`generate_agent_report()` regenerates that view from the persisted workflow and existing analysis +artifacts. It does not train another UMAP. ```{code-cell} ipython3 -if tuning.status != "done": - raise RuntimeError(f"Parameter Tuning stopped with {tuning.status!r}") - -biology_handoff = tuning.to_biological_handoff() -biology = BiologicalInterpretationAgent(FunctionModel(_biology_reply)).run( - ds, - tuning_handoff=biology_handoff, - biological_context=BiologicalContext( - organism="Homo sapiens", - tissue="peripheral blood", - cellTypeReferences=["T cell", "B cell", "NK cell", "monocyte"], - experimentalDetails=["healthy donor PBMC", "no treatment contrast"], - ), - allow_marker_search=True, - marker_features=hvg_ref, - max_clusters=1, - max_markers=5, - marker_min_score=0.01, - marker_min_fraction=0.0, +report_path = generate_agent_report( + result.zarrPath, + result.workflowRun.workflowRunId, + workspace=result.workflowRun.workspace, +) +display_path = str(report_path.relative_to(Path(result.zarrPath).parent)).replace( + result.workflowRun.workflowRunId, + "", ) { - "status": biology.status, - "interpretations": [ - { - "cluster": item.clusterId, - "identity": item.proposedIdentity, - "rationale": item.rationale, - "evidence": item.evidenceIds, - } - for item in biology.clusterInterpretations - ], - "tool_calls": [call.toolName for call in biology.runInfo.toolCalls], - "treatment_observations": len(biology.treatmentObservations), - "limitations": biology.limitations, + "report": display_path, + "exists": report_path.is_file(), + "final_artifact_kinds": { + "selection": cell_selection_ref.kind, + "clusters": cluster_ref.kind, + "umap": umap_ref.kind, + "markers": marker_ref.kind, + }, } ``` -There are no replicated condition labels, so treatment observations remain empty. -Artifact provenance supports the computational chain, but a real biological identity still requires an appropriate model, study context, and independent validation. +## Pauses, failures, and other input formats + +`needsInput` keeps the workflow running. Inspect every returned question and supply only grounded +answers. `failed` and `abandoned` are terminal. An ingest question can occur before a persisted +workflow exists; update `ingestDirections` and call `run()` again in that case. A running workflow +can also be finalized as abandoned with `orchestrator.cancel()`. + +For another new local H5 or H5AD input, provide a destination that does not yet exist: + +```python +request = AutomatedWorkflowRequest( + sourcePath="study.h5ad", + zarrPath="study.zarr", + studyContext="One paragraph describing the study, design, and analysis intent.", + allowAssumptions=False, +) +result = AgentOrchestrator(model).run(request) +``` + +For an existing Zarr input, omit `zarrPath` or set it to the same location. Its current `I` +selection is preserved and snapshotted. A workspace may be supplied only for an existing Zarr +input. ## Use a live model -Replace the four scripted models with one supported Pydantic AI model in an interactive analysis. -For an OpenAI-compatible endpoint, keep credentials in environment variables and never place them in a notebook or datastore: +Replace the scripted model with one supported Pydantic AI model. Keep credentials in environment +variables and never place them in a notebook or datastore: ```python import os @@ -448,11 +719,20 @@ model = OpenAIChatModel( ), ) -enrichment = DataEnrichmentAgent(model).run( - ds, - context=DataEnrichmentContext(organismHint="human"), +orchestrator = AgentOrchestrator(model) +result = orchestrator.run( + AutomatedWorkflowRequest( + sourcePath="study.h5ad", + zarrPath="study.zarr", + studyContext=( + "Human single-cell study with three biological replicates per " + "condition; donor is the unit of inference and library is technical." + ), + allowAssumptions=False, + ) ) ``` -Use the same `model` for the other stages, retain the explicit handoffs, and set execution limits appropriate to the provider. -Model output remains provisional: Scarf's validators reject unknown evidence, but they cannot establish that a biologically plausible interpretation is true. +Provider output remains provisional. Scarf validates evidence identifiers, operations, artifact +lineage, and resume state, but it cannot establish that a biologically plausible interpretation is +true. From f5d0022b7d724376ee30534247d4da0a6dddb430 Mon Sep 17 00:00:00 2001 From: gautam8387 Date: Fri, 4 Sep 2026 16:35:03 +0200 Subject: [PATCH 05/21] deterministic agent plan --- scarf/agent/__init__.py | 42 +- scarf/agent/data_enrichment.py | 111 +- scarf/agent/decision_kernel.py | 1052 +++++++ scarf/agent/decision_persistence.py | 770 ++++++ scarf/agent/experimental_context.py | 613 +++- scarf/agent/hvg_diagnostics.py | 732 +++++ scarf/agent/ingest/__init__.py | 10 + scarf/agent/ingest/manifest.py | 985 +++++++ scarf/agent/orchestrator/context.py | 82 +- scarf/agent/orchestrator/decisions.py | 812 ++++++ scarf/agent/orchestrator/finalization.py | 247 +- scarf/agent/orchestrator/journal.py | 121 +- scarf/agent/orchestrator/main.py | 334 ++- scarf/agent/orchestrator/models.py | 162 +- scarf/agent/orchestrator/preprocessing.py | 1238 ++++++++- scarf/agent/orchestrator/tuning.py | 1985 ++++++++++++- scarf/agent/parameter_tuning.py | 212 +- scarf/agent/persistence.py | 20 +- scarf/agent/qc_execution.py | 370 +++ scarf/agent/qc_profiles.py | 581 ++++ scarf/agent/report.py | 3078 ++++++++++++++++++++- scarf/agent/rna_decisions.py | 1490 ++++++++++ scarf/agent/sequential_tuning.py | 713 +++++ scarf/agent/study_contract.py | 198 ++ scarf/agent/tuning_diagnostics.py | 1104 ++++++++ scarf/agent/types.py | 2 +- 26 files changed, 16306 insertions(+), 758 deletions(-) create mode 100644 scarf/agent/decision_kernel.py create mode 100644 scarf/agent/decision_persistence.py create mode 100644 scarf/agent/hvg_diagnostics.py create mode 100644 scarf/agent/ingest/manifest.py create mode 100644 scarf/agent/orchestrator/decisions.py create mode 100644 scarf/agent/qc_execution.py create mode 100644 scarf/agent/qc_profiles.py create mode 100644 scarf/agent/rna_decisions.py create mode 100644 scarf/agent/sequential_tuning.py create mode 100644 scarf/agent/study_contract.py create mode 100644 scarf/agent/tuning_diagnostics.py diff --git a/scarf/agent/__init__.py b/scarf/agent/__init__.py index be5a2adb..b562b268 100644 --- a/scarf/agent/__init__.py +++ b/scarf/agent/__init__.py @@ -23,13 +23,35 @@ StudyContextSummary, ) from .decide import DecisionValidationError, decide +from .decision_kernel import ( + DecisionEvidence, + DecisionOption, + DecisionRecord, + DecisionSelection, + DecisionSpec, + DecisionWorkflowRun, + DeterministicDecisionAuditor, + EvidenceBundle, + PendingDecision, + ProtectedVariableEffect, + RevisionRequest, + VerificationCheck, + VerificationRecord, +) from .experimental_context import ( CellQcPlan, ExperimentalContextAgent, ExperimentalContextResult, NamedArtifactSource, ) -from .ingest import IngestResult, detect_format, ingest +from .ingest import ( + DatasetManifest, + DatasetManifestDecision, + IngestResult, + detect_format, + ingest, + inspect_h5ad_manifest, +) from .orchestrator import ( AgentOrchestrator, AssayPreprocessingPlan, @@ -81,6 +103,7 @@ ) from .report import generate_agent_report from .runtime import check_runtime, load_env +from .study_contract import StudyContract from .types import ( BatchSafetyEvidence, Decision, @@ -123,8 +146,18 @@ "DataEnrichmentContext", "DataEnrichmentReport", "Decision", + "DecisionEvidence", + "DecisionOption", + "DecisionRecord", + "DecisionSelection", + "DecisionSpec", + "DecisionWorkflowRun", "DecisionValidationError", + "DeterministicDecisionAuditor", + "DatasetManifest", + "DatasetManifestDecision", "EvidenceItem", + "EvidenceBundle", "ExperimentalBiologyHandoff", "ExperimentalContextAgent", "ExperimentalContextResult", @@ -138,20 +171,26 @@ "NativeAnalysisHandoff", "NamedArtifactSource", "NeedsInput", + "PendingDecision", "ParameterCandidate", "ParameterSearchPlan", "ParameterTuningAssayInput", "ParameterTuningAgent", "ParameterTuningReport", "PreprocessedAssayHandoff", + "ProtectedVariableEffect", + "RevisionRequest", "StageResult", "StageStatus", "StudyContextSummary", + "StudyContract", "TuningBiologyHandoff", "WorkflowNeedsInput", "WorkflowQuestion", "WorkflowStageAttempt", "WorkflowStageLink", + "VerificationCheck", + "VerificationRecord", "characterize_covariates", "characterize_features", "check_runtime", @@ -161,6 +200,7 @@ "get_default_parameter_candidates", "generate_agent_report", "ingest", + "inspect_h5ad_manifest", "load_env", "finalize_agent_workflow", "list_agent_reports", diff --git a/scarf/agent/data_enrichment.py b/scarf/agent/data_enrichment.py index 75f6c219..a47c889c 100644 --- a/scarf/agent/data_enrichment.py +++ b/scarf/agent/data_enrichment.py @@ -72,8 +72,10 @@ feature decision is needed. Absent or ambiguous lookup results must never enter a policy. If inspection resolves a supported species, copy that exact species key. Use caller organism context only when inspection leaves the - species unknown. Exclude only observed families with defaultExclude=true, - and never exclude a family with defaultExclude=false. + species unknown. Use excludeFamilies only to nominate one conditional + representation-sensitivity bundle from observed families with + defaultExclude=true. It is not an instruction to remove those families. + Never nominate a family with defaultExclude=false. Persisted assay types determine modality routes; never infer a route from an assay label. The validator fills assay type, modality eligibility, ADT @@ -81,11 +83,12 @@ report-level evidence. Leave those derived fields at their defaults instead of copying them into the output. Treat Ensembl release misses as unresolved, not artificial. Mitochondrial, ribosomal, and histone families may be - exclusion candidates. Sex-linked and cell-cycle families are protected by - default in this initial implementation. + sensitivity candidates. Sex-linked and cell-cycle families are protected + by default. Marker testing retains conditional biological families. Structure studyContextSummary using only verbatim spans from the supplied - study paragraph or exact caller references. Do not paraphrase, infer, or + study paragraph, study objective, or exact caller references. Do not + paraphrase, infer, or invent an organism, tissue, cell type, experiment, hypothesis, or analysis intent. Empty optional hint lists do not mean that the paragraph lacks those references. When a category is explicitly present in the paragraph, @@ -104,6 +107,7 @@ class DataEnrichmentContext(AgentDataModel): """Study evidence that may help resolve organism and feature policy.""" studyContext: str = "" + studyObjective: str = "" organismHint: str = "" tissueReferences: list[str] = Field(default_factory=list) cellTypeReferences: list[str] = Field(default_factory=list) @@ -117,6 +121,9 @@ def get_blank(cls) -> "DataEnrichmentContext": def get_example(cls) -> "DataEnrichmentContext": return cls( studyContext="Single-cell profiling of treated lung tissue", + studyObjective=( + "Discover stable populations while preserving treatment effects." + ), organismHint="human", tissueReferences=["lung"], cellTypeReferences=["alveolar macrophage", "T cell"], @@ -128,6 +135,7 @@ class StudyContextSummary(AgentDataModel): """Verbatim, evidence-backed references extracted from the study context.""" studyContext: str = "" + studyObjective: str = "" organismReferences: list[str] = Field(default_factory=list) tissueReferences: list[str] = Field(default_factory=list) cellTypeReferences: list[str] = Field(default_factory=list) @@ -147,6 +155,9 @@ def get_example(cls) -> "StudyContextSummary": "Single-cell profiling of treated human lung tests whether " "treatment changes alveolar macrophage states." ), + studyObjective=( + "Discover populations while preserving the treatment comparison." + ), organismReferences=["human"], tissueReferences=["lung"], cellTypeReferences=["alveolar macrophage"], @@ -1222,11 +1233,13 @@ def _ground_study_context_summary( ) -> StudyContextSummary: """Bind structured context references to exact caller text.""" original_context = context.studyContext + original_objective = context.studyObjective + grounded_text = f"{original_context}\n{original_objective}" organism_references = [context.organismHint] if context.organismHint else [] for species in _SUPPORTED_SPECIES.values(): match = re.search( rf"\b{re.escape(species.label)}\b", - original_context, + grounded_text, flags=re.IGNORECASE, ) if match is not None: @@ -1258,7 +1271,7 @@ def _ground_study_context_summary( invalid = [ value for value in combined - if value not in supplied and value not in original_context + if value not in supplied and value not in grounded_text ] if invalid: raise ValueError( @@ -1272,6 +1285,8 @@ def _ground_study_context_summary( evidence_ids: list[str] = [] if original_context: evidence_ids.append("context:study") + if original_objective: + evidence_ids.append("context:objective") if context.organismHint: evidence_ids.append("context:organism") evidence_ids.extend( @@ -1288,6 +1303,7 @@ def _ground_study_context_summary( ) return StudyContextSummary( studyContext=original_context, + studyObjective=original_objective, **grounded, evidenceIds=evidence_ids, ) @@ -1473,89 +1489,36 @@ def validate_data_enrichment_report( return report -def fallback_data_enrichment_report( +def pending_data_enrichment_report( deps: DataEnrichmentDependencies, *, error: UnexpectedModelBehavior | UsageLimitExceeded, model_name: str, ) -> DataEnrichmentReport: - """Build a conservative policy from completed deterministic inspections.""" + """Pause after deterministic inspection when no valid policy was selected.""" if set(deps.inspections) != set(deps.assays): raise error - policies: list[FeatureSelectionPolicy] = [] - for assay_name in deps.assays: - inspection = deps.inspections[assay_name] - species = "unknown" - species_confidence: Literal["high", "medium", "low", "unknown"] = "unknown" - species_rationale = ( - inspection.speciesReason - or "Deterministic feature inspection did not resolve a species." - ) - policy_evidence = [f"assay:{assay_name}:species"] - if inspection.species in _SUPPORTED_SPECIES: - species = inspection.species - species_confidence = ( - "high" if inspection.speciesMethod == "ensemblPrefix" else "medium" - ) - else: - organism_hint = deps.context.organismHint.strip().casefold() - for key, specification in _SUPPORTED_SPECIES.items(): - if organism_hint in {key.casefold(), specification.label.casefold()}: - species = key - species_confidence = "medium" - species_rationale = ( - "Exact caller organism hint resolved an otherwise unknown " - "feature-based species." - ) - policy_evidence.append("context:organism") - break - excluded_families = [ - family - for family in inspection.families - if family.defaultExclude is True and family.count > 0 - ] - protected_families = [ - family for family in inspection.families if family.defaultExclude is False - ] - policy_evidence.extend( - family.evidenceId for family in [*excluded_families, *protected_families] - ) - policies.append( - FeatureSelectionPolicy( - assay=assay_name, - species=species, - speciesConfidence=species_confidence, - speciesRationale=species_rationale, - excludeFamilies=[family.family for family in excluded_families], - protectFamilies=[family.family for family in protected_families], - rationale=( - "Retained only deterministic family defaults after structured " - "model output was unavailable." - ), - evidenceIds=list(dict.fromkeys(policy_evidence)), - ) - ) error_detail = str(error).replace("\n", " ").strip()[:500] report = DataEnrichmentReport( - status="done", - policies=policies, + status="needsInput", studyContextSummary=StudyContextSummary.get_blank(), + unresolvedQuestions=[ + "The Data Enrichment agent did not produce a validated feature policy. " + "Provide explicit organism and representation-feature intent." + ], limitations=[ - "Structured enrichment output was unavailable; the fallback omitted " - "all model-selected individual and artificial features.", - "Free-text context extraction may be incomplete because only exact " - "caller fields and deterministic organism mentions were retained.", + "No scientific feature policy was selected after model failure.", error_detail, ], runInfo=AgentRunInfo( - agentName="data_enrichment_fallback", + agentName="data_enrichment_needs_input", modelName=model_name, ), ) validated = validate_data_enrichment_report(deps, report) logger.warning( - "Data Enrichment used its conservative fallback: " - f"assays={len(validated.policies)}, evidence={len(validated.evidenceIds)}, " + "Data Enrichment paused without a scientific selection: " + f"assays={len(validated.inspections)}, evidence={len(validated.evidenceIds)}, " f"reason={error_detail}" ) return validated @@ -1607,6 +1570,8 @@ def run( evidence_ids: set[str] = set() if enrichment_context.studyContext: evidence_ids.add("context:study") + if enrichment_context.studyObjective: + evidence_ids.add("context:objective") if enrichment_context.organismHint: evidence_ids.add("context:organism") evidence_ids.update( @@ -1635,6 +1600,7 @@ def run( """ Enrich the feature policy for assays: {assays}. Study context: {study_context} + Study objective: {study_objective} Organism hint: {organism_hint} Tissue references: {tissue_references} Cell-type references: {cell_type_references} @@ -1667,6 +1633,7 @@ def run( .format( assays=", ".join(selected_assays), study_context=enrichment_context.studyContext or "not provided", + study_objective=enrichment_context.studyObjective or "not provided", organism_hint=enrichment_context.organismHint or "not provided", tissue_references=", ".join(enrichment_context.tissueReferences) or "not provided", @@ -1711,7 +1678,7 @@ def run( if set(deps.inspections) != set(deps.assays): raise model_name = getattr(self.model, "model_name", type(self.model).__name__) - return fallback_data_enrichment_report( + return pending_data_enrichment_report( deps, error=exc, model_name=str(model_name), diff --git a/scarf/agent/decision_kernel.py b/scarf/agent/decision_kernel.py new file mode 100644 index 00000000..ac7876c1 --- /dev/null +++ b/scarf/agent/decision_kernel.py @@ -0,0 +1,1052 @@ +"""Typed decision contracts and deterministic validation for agent workflows. + +The models in this module deliberately separate deterministic option construction +from agent output. An agent may select one offered option and cite observed +evidence, but it cannot add operations or numeric execution parameters. +""" + +import hashlib +import re +from collections.abc import Iterable +from typing import Literal + +from pydantic import ConfigDict, Field, field_validator, model_validator + +from . import record_io +from .types import AgentDataModel, ArtifactReferenceModel + +type DecisionStatus = Literal["apply", "skip", "defer", "abstain"] +type DecisionSource = Literal["rule", "agent", "human"] +type DecisionConfidence = Literal["low", "medium", "high", "notApplicable"] +type EvidenceClass = Literal[ + "geometric", + "markerCoherence", + "resamplingStability", + "crossUnitSupport", + "protectedVariablePreservation", + "qualityControl", + "technical", + "design", + "provenance", + "batchRemoval", + "biologicalConservation", + "other", +] +type VerificationStatus = Literal["passed", "failed", "inconclusive"] +type DecisionWorkflowStatus = Literal[ + "running", + "completed", + "needsInput", + "abstained", + "failed", +] +type ProtectedVariableEffectStatus = Literal[ + "preserved", + "degraded", + "improved", + "notEvaluated", +] + +_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:/-]{0,255}$") +_SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") +_NON_GEOMETRIC_OVERRIDE_CLASSES: frozenset[EvidenceClass] = frozenset( + { + "markerCoherence", + "resamplingStability", + "crossUnitSupport", + "protectedVariablePreservation", + } +) + + +def _validate_identifier(value: str, field_name: str) -> str: + if _ID_PATTERN.fullmatch(value) is None: + raise ValueError( + f"{field_name} must be a non-empty stable identifier containing only " + "letters, digits, '.', '_', ':', '/', or '-'" + ) + return value + + +def _validate_unique(values: list[str], field_name: str) -> list[str]: + if len(values) != len(set(values)): + raise ValueError(f"{field_name} must not contain duplicates") + return values + + +def _default_decision_sources() -> list[DecisionSource]: + return ["rule", "agent", "human"] + + +class DecisionKernelModel(AgentDataModel): + """Base for immutable, closed decision-kernel contracts.""" + + model_config = ConfigDict(extra="forbid", frozen=True, validate_default=True) + + +class DecisionEvidence(DecisionKernelModel): + """One bounded observed fact available to a decision.""" + + evidenceId: str + evidenceClass: EvidenceClass + summary: str = Field(min_length=1, max_length=2000) + artifactReferences: list[ArtifactReferenceModel] = Field(default_factory=list) + + @field_validator("evidenceId") + @classmethod + def validate_evidence_id(cls, value: str) -> str: + return _validate_identifier(value, "evidenceId") + + @field_validator("summary") + @classmethod + def validate_summary(cls, value: str) -> str: + if value != value.strip(): + raise ValueError("summary must not contain surrounding whitespace") + return value + + @model_validator(mode="after") + def validate_artifact_references(self) -> "DecisionEvidence": + identities: list[tuple[str, str | None, str, str]] = [] + for reference in self.artifactReferences: + if not reference.kind or not reference.artifactId: + raise ValueError("artifactReferences require kind and artifactId") + identities.append( + ( + reference.scope, + reference.assay, + reference.kind, + reference.artifactId, + ) + ) + if len(identities) != len(set(identities)): + raise ValueError("artifactReferences must not contain duplicates") + return self + + +class EvidenceBundle(DecisionKernelModel): + """The complete immutable evidence inventory offered for one decision.""" + + formatVersion: Literal[1] = 1 + bundleId: str + decisionId: str + evidence: list[DecisionEvidence] = Field(default_factory=list) + contentSha256: str | None = None + + @field_validator("bundleId", "decisionId") + @classmethod + def validate_ids(cls, value: str, info: object) -> str: + field_name = getattr(info, "field_name", "identifier") + return _validate_identifier(value, field_name) + + @field_validator("contentSha256") + @classmethod + def validate_content_sha256(cls, value: str | None) -> str | None: + if value is not None and _SHA256_PATTERN.fullmatch(value) is None: + raise ValueError("contentSha256 must be a lowercase SHA-256 digest") + return value + + @model_validator(mode="after") + def validate_evidence_ids(self) -> "EvidenceBundle": + _validate_unique( + [item.evidenceId for item in self.evidence], + "EvidenceBundle.evidence IDs", + ) + if self.contentSha256 is not None: + payload = self.model_dump(mode="json", exclude={"contentSha256"}) + expected = hashlib.sha256( + record_io.canonical_json_bytes(payload) + ).hexdigest() + if self.contentSha256 != expected: + raise ValueError("contentSha256 does not match the evidence bundle") + return self + + def evidence_by_id(self) -> dict[str, DecisionEvidence]: + """Return evidence indexed by its stable identifier.""" + return {item.evidenceId: item for item in self.evidence} + + def with_content_sha256(self) -> "EvidenceBundle": + """Return this bundle with its canonical content identity.""" + payload = self.model_dump(mode="json", exclude={"contentSha256"}) + digest = hashlib.sha256(record_io.canonical_json_bytes(payload)).hexdigest() + return EvidenceBundle.model_validate({**payload, "contentSha256": digest}) + + +class DecisionOption(DecisionKernelModel): + """One pre-registered option whose execution details live outside the model.""" + + optionId: str + status: DecisionStatus + label: str = Field(min_length=1, max_length=200) + description: str = Field(min_length=1, max_length=2000) + requiredEvidenceClasses: list[EvidenceClass] = Field(default_factory=list) + requiredEvidenceIds: list[str] = Field(default_factory=list) + + @field_validator("optionId") + @classmethod + def validate_option_id(cls, value: str) -> str: + return _validate_identifier(value, "optionId") + + @field_validator("label", "description") + @classmethod + def validate_text(cls, value: str, info: object) -> str: + if value != value.strip(): + field_name = getattr(info, "field_name", "text") + raise ValueError(f"{field_name} must not contain surrounding whitespace") + return value + + @field_validator("requiredEvidenceClasses") + @classmethod + def validate_required_classes( + cls, value: list[EvidenceClass] + ) -> list[EvidenceClass]: + if len(value) != len(set(value)): + raise ValueError("requiredEvidenceClasses must not contain duplicates") + return value + + @field_validator("requiredEvidenceIds") + @classmethod + def validate_required_evidence_ids(cls, value: list[str]) -> list[str]: + for evidence_id in value: + _validate_identifier(evidence_id, "requiredEvidenceIds item") + return _validate_unique(value, "requiredEvidenceIds") + + +class DecisionSpec(DecisionKernelModel): + """Authoritative closed option set for one atomic decision.""" + + decisionId: str + definitionVersion: int = Field(ge=1, strict=True) + checkpoint: str + question: str = Field(min_length=1, max_length=2000) + evidenceBundleId: str + options: list[DecisionOption] = Field(min_length=1) + baselineOptionId: str | None = None + metricPreferredOptionId: str | None = None + requireIndependentOverrideEvidence: bool = Field(default=False, strict=True) + allowedSources: list[DecisionSource] = Field( + default_factory=_default_decision_sources + ) + + @field_validator("decisionId", "checkpoint", "evidenceBundleId") + @classmethod + def validate_ids(cls, value: str, info: object) -> str: + field_name = getattr(info, "field_name", "identifier") + return _validate_identifier(value, field_name) + + @field_validator("question") + @classmethod + def validate_question(cls, value: str) -> str: + if value != value.strip(): + raise ValueError("question must not contain surrounding whitespace") + return value + + @field_validator("allowedSources") + @classmethod + def validate_allowed_sources( + cls, value: list[DecisionSource] + ) -> list[DecisionSource]: + if not value: + raise ValueError("allowedSources must not be empty") + if len(value) != len(set(value)): + raise ValueError("allowedSources must not contain duplicates") + return value + + @model_validator(mode="after") + def validate_option_set(self) -> "DecisionSpec": + option_ids = [option.optionId for option in self.options] + _validate_unique(option_ids, "DecisionSpec option IDs") + if ( + self.baselineOptionId is not None + and self.baselineOptionId not in option_ids + ): + raise ValueError("baselineOptionId must reference an offered option") + if ( + self.metricPreferredOptionId is not None + and self.metricPreferredOptionId not in option_ids + ): + raise ValueError("metricPreferredOptionId must reference an offered option") + if ( + self.requireIndependentOverrideEvidence + and self.metricPreferredOptionId is None + ): + raise ValueError( + "requireIndependentOverrideEvidence requires metricPreferredOptionId" + ) + return self + + def option_by_id(self) -> dict[str, DecisionOption]: + """Return offered options indexed by their stable identifier.""" + return {option.optionId: option for option in self.options} + + +class ProtectedVariableEffect(DecisionKernelModel): + """Observed effect of a choice on one objective-protected variable.""" + + variable: str + status: ProtectedVariableEffectStatus + evidenceIds: list[str] = Field(default_factory=list) + summary: str = Field(min_length=1, max_length=1000) + + @field_validator("variable") + @classmethod + def validate_variable(cls, value: str) -> str: + return _validate_identifier(value, "variable") + + @field_validator("evidenceIds") + @classmethod + def validate_evidence_ids(cls, value: list[str]) -> list[str]: + for evidence_id in value: + _validate_identifier(evidence_id, "evidenceIds item") + return _validate_unique(value, "ProtectedVariableEffect.evidenceIds") + + @field_validator("summary") + @classmethod + def validate_summary(cls, value: str) -> str: + if value != value.strip(): + raise ValueError("summary must not contain surrounding whitespace") + return value + + +class DecisionSelection(DecisionKernelModel): + """The bounded choice an agent or human may return.""" + + selectedOptionId: str + evidenceIds: list[str] = Field(default_factory=list) + rationale: str = Field(min_length=1, max_length=4000) + confidence: DecisionConfidence = "notApplicable" + protectedVariableEffects: list[ProtectedVariableEffect] = Field( + default_factory=list + ) + overrideOfOptionId: str | None = None + overrideEvidenceIds: list[str] = Field(default_factory=list) + + @field_validator("selectedOptionId", "overrideOfOptionId") + @classmethod + def validate_ids(cls, value: str | None, info: object) -> str | None: + if value is None: + return None + field_name = getattr(info, "field_name", "identifier") + return _validate_identifier(value, field_name) + + @field_validator("evidenceIds", "overrideEvidenceIds") + @classmethod + def validate_id_lists(cls, value: list[str], info: object) -> list[str]: + field_name = getattr(info, "field_name", "identifiers") + for item in value: + _validate_identifier(item, f"{field_name} item") + return _validate_unique(value, field_name) + + @field_validator("rationale") + @classmethod + def validate_rationale(cls, value: str) -> str: + if value != value.strip(): + raise ValueError("rationale must not contain surrounding whitespace") + return value + + @model_validator(mode="after") + def validate_override(self) -> "DecisionSelection": + if not set(self.overrideEvidenceIds).issubset(self.evidenceIds): + raise ValueError("overrideEvidenceIds must be included in evidenceIds") + if self.overrideOfOptionId is None and self.overrideEvidenceIds: + raise ValueError("overrideEvidenceIds require overrideOfOptionId") + if self.overrideOfOptionId == self.selectedOptionId: + raise ValueError("overrideOfOptionId must differ from selectedOptionId") + return self + + +class PendingDecision(DecisionKernelModel): + """One unresolved checkpoint persisted without fabricating a selection.""" + + questionId: str + decisionId: str + definitionVersion: int = Field(ge=1, strict=True) + evidenceBundleId: str + evidenceBundleSha256: str + offeredOptionIds: list[str] = Field(min_length=1) + availableEvidenceIds: list[str] = Field(default_factory=list) + reason: str = Field(min_length=1, max_length=2000) + createdAtNs: int = Field(default=0, ge=0, strict=True) + + @field_validator("questionId", "decisionId", "evidenceBundleId") + @classmethod + def validate_ids(cls, value: str, info: object) -> str: + field_name = getattr(info, "field_name", "identifier") + return _validate_identifier(value, field_name) + + @field_validator("offeredOptionIds", "availableEvidenceIds") + @classmethod + def validate_id_lists(cls, value: list[str], info: object) -> list[str]: + field_name = getattr(info, "field_name", "identifiers") + for item in value: + _validate_identifier(item, f"{field_name} item") + return _validate_unique(value, field_name) + + @field_validator("reason") + @classmethod + def validate_reason(cls, value: str) -> str: + if value != value.strip(): + raise ValueError("reason must not contain surrounding whitespace") + return value + + @field_validator("evidenceBundleSha256") + @classmethod + def validate_evidence_bundle_sha256(cls, value: str) -> str: + if _SHA256_PATTERN.fullmatch(value) is None: + raise ValueError("evidenceBundleSha256 must be a lowercase SHA-256 digest") + return value + + +class DecisionRecord(DecisionKernelModel): + """One durable rule, agent, or human selection from an exact option set.""" + + recordId: str + decisionId: str + definitionVersion: int = Field(ge=1, strict=True) + evidenceBundleId: str + evidenceBundleSha256: str + offeredOptionIds: list[str] = Field(min_length=1) + availableEvidenceIds: list[str] = Field(default_factory=list) + selectedOptionId: str + status: DecisionStatus + source: DecisionSource + evidenceIds: list[str] = Field(default_factory=list) + rationale: str = Field(min_length=1, max_length=4000) + confidence: DecisionConfidence = "notApplicable" + protectedVariableEffects: list[ProtectedVariableEffect] = Field( + default_factory=list + ) + overrideOfOptionId: str | None = None + overrideEvidenceIds: list[str] = Field(default_factory=list) + promptSha256: str | None = None + modelName: str | None = None + softwareSha256: str | None = None + verificationId: str | None = None + supersedes: str | None = None + createdAtNs: int = Field(default=0, ge=0, strict=True) + + @field_validator( + "recordId", + "decisionId", + "evidenceBundleId", + "selectedOptionId", + "verificationId", + "supersedes", + "overrideOfOptionId", + ) + @classmethod + def validate_ids(cls, value: str | None, info: object) -> str | None: + if value is None: + return None + field_name = getattr(info, "field_name", "identifier") + return _validate_identifier(value, field_name) + + @field_validator( + "offeredOptionIds", + "availableEvidenceIds", + "evidenceIds", + "overrideEvidenceIds", + ) + @classmethod + def validate_id_lists(cls, value: list[str], info: object) -> list[str]: + field_name = getattr(info, "field_name", "identifiers") + for item in value: + _validate_identifier(item, f"{field_name} item") + return _validate_unique(value, field_name) + + @field_validator("rationale") + @classmethod + def validate_rationale(cls, value: str) -> str: + if value != value.strip(): + raise ValueError("rationale must not contain surrounding whitespace") + return value + + @field_validator( + "evidenceBundleSha256", + "promptSha256", + "softwareSha256", + ) + @classmethod + def validate_sha256(cls, value: str | None, info: object) -> str | None: + if value is not None and _SHA256_PATTERN.fullmatch(value) is None: + field_name = getattr(info, "field_name", "digest") + raise ValueError(f"{field_name} must be a lowercase SHA-256 digest") + return value + + @field_validator("modelName") + @classmethod + def validate_model_name(cls, value: str | None) -> str | None: + if value is not None and (not value.strip() or value != value.strip()): + raise ValueError( + "modelName must be non-empty without surrounding whitespace" + ) + return value + + @model_validator(mode="after") + def validate_references(self) -> "DecisionRecord": + offered = set(self.offeredOptionIds) + available = set(self.availableEvidenceIds) + used = set(self.evidenceIds) + override = set(self.overrideEvidenceIds) + if self.selectedOptionId not in offered: + raise ValueError("selectedOptionId must reference an offered option") + if not used.issubset(available): + raise ValueError("evidenceIds must reference only available evidence") + if not override.issubset(used): + raise ValueError("overrideEvidenceIds must be included in evidenceIds") + protected_ids = { + evidence_id + for effect in self.protectedVariableEffects + for evidence_id in effect.evidenceIds + } + if not protected_ids.issubset(available): + raise ValueError( + "protectedVariableEffects must reference only available evidence" + ) + if self.overrideOfOptionId is None and self.overrideEvidenceIds: + raise ValueError("overrideEvidenceIds require overrideOfOptionId") + if self.overrideOfOptionId is not None: + if self.overrideOfOptionId not in offered: + raise ValueError("overrideOfOptionId must reference an offered option") + if self.overrideOfOptionId == self.selectedOptionId: + raise ValueError("overrideOfOptionId must differ from selectedOptionId") + if self.supersedes == self.recordId: + raise ValueError("A DecisionRecord cannot supersede itself") + return self + + +class VerificationCheck(DecisionKernelModel): + """One deterministic invariant evaluated by the auditor.""" + + checkId: str + status: VerificationStatus + summary: str = Field(min_length=1, max_length=2000) + evidenceIds: list[str] = Field(default_factory=list) + + @field_validator("checkId") + @classmethod + def validate_check_id(cls, value: str) -> str: + return _validate_identifier(value, "checkId") + + @field_validator("evidenceIds") + @classmethod + def validate_evidence_ids(cls, value: list[str]) -> list[str]: + for evidence_id in value: + _validate_identifier(evidence_id, "evidenceIds item") + return _validate_unique(value, "VerificationCheck.evidenceIds") + + @field_validator("summary") + @classmethod + def validate_summary(cls, value: str) -> str: + if value != value.strip(): + raise ValueError("summary must not contain surrounding whitespace") + return value + + +class VerificationRecord(DecisionKernelModel): + """Deterministic verification result for exactly one decision record.""" + + verificationId: str + decisionRecordId: str + status: VerificationStatus + checks: list[VerificationCheck] = Field(min_length=1) + createdAtNs: int = Field(default=0, ge=0, strict=True) + + @field_validator("verificationId", "decisionRecordId") + @classmethod + def validate_ids(cls, value: str, info: object) -> str: + field_name = getattr(info, "field_name", "identifier") + return _validate_identifier(value, field_name) + + @model_validator(mode="after") + def validate_aggregate_status(self) -> "VerificationRecord": + check_ids = [check.checkId for check in self.checks] + _validate_unique(check_ids, "VerificationRecord check IDs") + statuses = {check.status for check in self.checks} + if self.status == "passed" and statuses != {"passed"}: + raise ValueError("passed verification requires every check to pass") + if self.status == "failed" and "failed" not in statuses: + raise ValueError("failed verification requires a failed check") + if self.status == "inconclusive" and ( + "failed" in statuses or "inconclusive" not in statuses + ): + raise ValueError( + "inconclusive verification requires an inconclusive check and no failures" + ) + return self + + +class RevisionRequest(DecisionKernelModel): + """A bounded request to supersede one decision using exact audit evidence.""" + + revisionId: str + targetDecisionRecordId: str + verificationId: str + replacementOptionId: str + reason: str = Field(min_length=1, max_length=2000) + evidenceBundleId: str | None = None + evidenceBundleSha256: str | None = None + availableEvidenceIds: list[str] = Field(default_factory=list) + evidenceIds: list[str] = Field(default_factory=list) + invalidatesDecisionRecordIds: list[str] = Field(default_factory=list) + createdAtNs: int = Field(default=0, ge=0, strict=True) + + @field_validator( + "revisionId", + "targetDecisionRecordId", + "verificationId", + "replacementOptionId", + "evidenceBundleId", + ) + @classmethod + def validate_ids(cls, value: str | None, info: object) -> str | None: + if value is None: + return None + field_name = getattr(info, "field_name", "identifier") + return _validate_identifier(value, field_name) + + @field_validator( + "availableEvidenceIds", + "evidenceIds", + "invalidatesDecisionRecordIds", + ) + @classmethod + def validate_id_lists(cls, value: list[str], info: object) -> list[str]: + field_name = getattr(info, "field_name", "identifiers") + for item in value: + _validate_identifier(item, f"{field_name} item") + return _validate_unique(value, field_name) + + @field_validator("evidenceBundleSha256") + @classmethod + def validate_evidence_bundle_sha256(cls, value: str | None) -> str | None: + if value is not None and _SHA256_PATTERN.fullmatch(value) is None: + raise ValueError("evidenceBundleSha256 must be a lowercase SHA-256 digest") + return value + + @field_validator("reason") + @classmethod + def validate_reason(cls, value: str) -> str: + if value != value.strip(): + raise ValueError("reason must not contain surrounding whitespace") + return value + + @model_validator(mode="after") + def validate_evidence_bundle(self) -> "RevisionRequest": + if (self.evidenceBundleId is None) != (self.evidenceBundleSha256 is None): + raise ValueError( + "Revision evidence bundle ID and checksum must be provided together" + ) + if self.evidenceBundleId is None and ( + self.availableEvidenceIds or self.evidenceIds + ): + raise ValueError( + "Revision evidence inventory requires an exact evidence bundle" + ) + if not set(self.evidenceIds).issubset(self.availableEvidenceIds): + raise ValueError( + "Revision evidence must reference its exact available inventory" + ) + return self + + +class DecisionWorkflowRun(DecisionKernelModel): + """Versioned, acyclic ledger for one bounded decision workflow.""" + + recordType: Literal["decisionWorkflowRun"] = "decisionWorkflowRun" + formatVersion: Literal[2] = 2 + workflowRunId: str + status: DecisionWorkflowStatus = "running" + decisionRecords: list[DecisionRecord] = Field(default_factory=list) + verificationRecords: list[VerificationRecord] = Field(default_factory=list) + revisionRequests: list[RevisionRequest] = Field(default_factory=list) + maxRevisions: int = Field(default=2, ge=0, le=2, strict=True) + pendingDecision: PendingDecision | None = None + finalHandoffId: str | None = None + limitations: list[str] = Field(default_factory=list) + unresolvedClaims: list[str] = Field(default_factory=list) + + @field_validator("workflowRunId", "finalHandoffId") + @classmethod + def validate_ids(cls, value: str | None, info: object) -> str | None: + if value is None: + return None + field_name = getattr(info, "field_name", "identifier") + return _validate_identifier(value, field_name) + + @model_validator(mode="after") + def validate_ledger(self) -> "DecisionWorkflowRun": + if len(self.revisionRequests) > self.maxRevisions: + raise ValueError("Decision workflow exceeds its configured revision limit") + + records: dict[str, DecisionRecord] = {} + record_positions: dict[str, int] = {} + active_by_decision: dict[str, str] = {} + for position, record in enumerate(self.decisionRecords): + if record.recordId in records: + raise ValueError("decisionRecords must have unique recordId values") + if record.decisionId in active_by_decision: + expected_parent = active_by_decision[record.decisionId] + if record.supersedes != expected_parent: + raise ValueError( + "Repeated decisions must supersede the current active record" + ) + elif record.supersedes is not None: + raise ValueError( + "supersedes must reference an earlier matching decision" + ) + if record.supersedes is not None: + parent = records.get(record.supersedes) + if parent is None or parent.decisionId != record.decisionId: + raise ValueError( + "supersedes must reference an earlier record for the same decision" + ) + records[record.recordId] = record + record_positions[record.recordId] = position + active_by_decision[record.decisionId] = record.recordId + + verifications: dict[str, VerificationRecord] = {} + verified_records: set[str] = set() + for verification in self.verificationRecords: + if verification.verificationId in verifications: + raise ValueError( + "verificationRecords must have unique verificationId values" + ) + if verification.decisionRecordId not in records: + raise ValueError("Verification must reference an exact decision record") + if verification.decisionRecordId in verified_records: + raise ValueError("A decision record may have only one verification") + linked_record = records[verification.decisionRecordId] + if linked_record.verificationId != verification.verificationId: + raise ValueError( + "Decision and verification references must agree exactly" + ) + verifications[verification.verificationId] = verification + verified_records.add(verification.decisionRecordId) + + revision_ids: set[str] = set() + revised_targets: set[str] = set() + revisions_by_target: dict[str, RevisionRequest] = {} + for revision in self.revisionRequests: + if revision.revisionId in revision_ids: + raise ValueError("revisionRequests must have unique revisionId values") + if revision.targetDecisionRecordId in revised_targets: + raise ValueError("A decision record may be revised only once") + target = records.get(revision.targetDecisionRecordId) + if target is None: + raise ValueError("Revision must reference an exact decision record") + revision_verification = verifications.get(revision.verificationId) + if ( + revision_verification is None + or revision_verification.decisionRecordId + != revision.targetDecisionRecordId + ): + raise ValueError( + "Revision must reference the target decision's verification" + ) + if revision_verification.status == "passed" and ( + revision.evidenceBundleId is None or not revision.evidenceIds + ): + raise ValueError( + "Revising a passed decision requires exact downstream evidence" + ) + if revision.replacementOptionId == target.selectedOptionId: + raise ValueError("Revision replacement must change the selected option") + if revision.evidenceBundleId == target.evidenceBundleId and ( + revision.evidenceBundleSha256 != target.evidenceBundleSha256 + or revision.availableEvidenceIds != target.availableEvidenceIds + ): + raise ValueError( + "Revision evidence must match the target bundle exactly" + ) + for invalidated_id in revision.invalidatesDecisionRecordIds: + if invalidated_id not in records: + raise ValueError( + "Revision invalidation must reference an exact decision record" + ) + if ( + record_positions[invalidated_id] + <= record_positions[target.recordId] + ): + raise ValueError( + "Revision invalidation may reference only downstream decisions" + ) + revision_ids.add(revision.revisionId) + revised_targets.add(revision.targetDecisionRecordId) + revisions_by_target[revision.targetDecisionRecordId] = revision + + invalidated_record_ids = { + record_id + for revision in self.revisionRequests + for record_id in revision.invalidatesDecisionRecordIds + } + for record in self.decisionRecords: + if record.supersedes is None: + continue + matching_revision = revisions_by_target.get(record.supersedes) + if ( + matching_revision is None + and record.supersedes not in invalidated_record_ids + ): + raise ValueError("A superseding decision requires a revision request") + if ( + matching_revision is not None + and matching_revision.replacementOptionId != record.selectedOptionId + ): + raise ValueError( + "A superseding decision must select the requested replacement option" + ) + + active_record_ids = set(active_by_decision.values()).difference( + invalidated_record_ids + ) + active_records = [records[record_id] for record_id in active_record_ids] + if self.status == "completed": + if self.finalHandoffId is None: + raise ValueError("completed workflows require finalHandoffId") + if self.pendingDecision is not None: + raise ValueError( + "completed workflows cannot contain a pending decision" + ) + for record in active_records: + verification_id = record.verificationId + active_verification = ( + verifications.get(verification_id) + if verification_id is not None + else None + ) + if record.status in {"defer", "abstain"} or ( + active_verification is None + or active_verification.status != "passed" + ): + raise ValueError( + "completed workflows require every active decision to pass" + ) + elif self.finalHandoffId is not None: + raise ValueError("Only completed workflows may reference a final handoff") + + if self.status == "needsInput" and ( + self.pendingDecision is None + and not any(record.status == "defer" for record in active_records) + ): + raise ValueError( + "needsInput workflows require a pending or active defer decision" + ) + if self.status != "needsInput" and self.pendingDecision is not None: + raise ValueError("Only needsInput workflows may contain a pending decision") + if self.status == "abstained" and not any( + record.status == "abstain" for record in active_records + ): + raise ValueError("abstained workflows require an active abstain decision") + return self + + def invalidated_decision_record_ids(self) -> set[str]: + """Return records invalidated by accepted revision requests.""" + return { + record_id + for revision in self.revisionRequests + for record_id in revision.invalidatesDecisionRecordIds + } + + def active_decision_records(self) -> list[DecisionRecord]: + """Return active records in their original transition order.""" + superseded = { + record.supersedes + for record in self.decisionRecords + if record.supersedes is not None + } + invalidated = self.invalidated_decision_record_ids() + inactive = superseded | invalidated + return [ + record for record in self.decisionRecords if record.recordId not in inactive + ] + + +class DeterministicDecisionAuditor: + """Cross-check a decision against its authoritative spec and evidence bundle.""" + + @classmethod + def audit( + cls, + spec: DecisionSpec, + evidence: EvidenceBundle, + record: DecisionRecord, + *, + created_at_ns: int = 0, + ) -> VerificationRecord: + """Return a deterministic verification without repairing invalid output.""" + checks: list[VerificationCheck] = [] + evidence_sha256 = ( + evidence.contentSha256 + if evidence.contentSha256 is not None + else evidence.with_content_sha256().contentSha256 + ) + + def add_check( + check_id: str, + passed: bool, + pass_summary: str, + fail_summary: str, + evidence_ids: Iterable[str] = (), + ) -> None: + checks.append( + VerificationCheck( + checkId=check_id, + status="passed" if passed else "failed", + summary=pass_summary if passed else fail_summary, + evidenceIds=list(evidence_ids), + ) + ) + + add_check( + "decisionIdentity", + record.decisionId == spec.decisionId + and record.definitionVersion == spec.definitionVersion + and evidence.decisionId == spec.decisionId + and record.evidenceBundleId == spec.evidenceBundleId + and evidence.bundleId == spec.evidenceBundleId + and record.evidenceBundleSha256 == evidence_sha256, + "Decision, definition, and evidence bundle identities agree.", + "Decision, definition, or evidence bundle identity does not agree.", + ) + + offered_option_ids = [option.optionId for option in spec.options] + add_check( + "exactOptionInventory", + record.offeredOptionIds == offered_option_ids, + "The durable record contains the exact offered option inventory.", + "The durable record does not contain the exact offered option inventory.", + ) + + available_evidence_ids = [item.evidenceId for item in evidence.evidence] + add_check( + "exactEvidenceInventory", + record.availableEvidenceIds == available_evidence_ids, + "The durable record contains the exact evidence inventory.", + "The durable record does not contain the exact evidence inventory.", + ) + + option = spec.option_by_id().get(record.selectedOptionId) + add_check( + "selectedOption", + option is not None and option.status == record.status, + "The selected option and decision status agree.", + "The selected option is unavailable or its status does not agree.", + ) + + add_check( + "decisionSource", + record.source in spec.allowedSources, + "The decision source is allowed by the definition.", + "The decision source is not allowed by the definition.", + ) + + evidence_by_id = evidence.evidence_by_id() + cited_ids = set(record.evidenceIds) + required_classes = set(option.requiredEvidenceClasses) if option else set() + required_ids = set(option.requiredEvidenceIds) if option else set() + cited_classes = { + evidence_by_id[evidence_id].evidenceClass + for evidence_id in cited_ids + if evidence_id in evidence_by_id + } + required_evidence_ok = ( + cited_ids.issubset(evidence_by_id) + and required_ids.issubset(cited_ids) + and required_classes.issubset(cited_classes) + ) + add_check( + "requiredEvidence", + required_evidence_ok, + "All cited and required evidence is present.", + "Cited evidence is unavailable or a required evidence class is missing.", + record.evidenceIds, + ) + + protected_evidence_ids = { + evidence_id + for effect in record.protectedVariableEffects + for evidence_id in effect.evidenceIds + } + protected_ok = not any( + effect.status == "degraded" for effect in record.protectedVariableEffects + ) and protected_evidence_ids.issubset(cited_ids) + add_check( + "protectedVariablePreservation", + protected_ok, + "No cited protected variable is degraded.", + "A protected variable degraded or its evidence was not cited.", + sorted(protected_evidence_ids), + ) + + metric_override = ( + spec.requireIndependentOverrideEvidence + and spec.metricPreferredOptionId is not None + and record.status in {"apply", "skip"} + and record.selectedOptionId != spec.metricPreferredOptionId + ) + if metric_override: + override_classes = { + evidence_by_id[evidence_id].evidenceClass + for evidence_id in record.overrideEvidenceIds + if evidence_id in evidence_by_id + } + qualifying_classes = override_classes & _NON_GEOMETRIC_OVERRIDE_CLASSES + override_ok = ( + record.overrideOfOptionId == spec.metricPreferredOptionId + and set(record.overrideEvidenceIds).issubset(record.evidenceIds) + and ( + not required_ids + or set(record.overrideEvidenceIds).issubset(required_ids) + ) + and len(qualifying_classes) >= 2 + ) + add_check( + "independentOverrideEvidence", + override_ok, + "The override cites at least two independent non-geometric evidence classes.", + "The override requires two independent non-geometric evidence classes.", + record.overrideEvidenceIds, + ) + else: + add_check( + "independentOverrideEvidence", + record.overrideOfOptionId is None and not record.overrideEvidenceIds, + "No metric override evidence is required.", + "Override fields were supplied without an eligible metric override.", + record.overrideEvidenceIds, + ) + + verification_status: VerificationStatus = ( + "failed" if any(check.status == "failed" for check in checks) else "passed" + ) + return VerificationRecord( + verificationId=f"verification:{record.recordId}", + decisionRecordId=record.recordId, + status=verification_status, + checks=checks, + createdAtNs=created_at_ns, + ) + + +__all__ = [ + "DecisionConfidence", + "DecisionEvidence", + "DecisionOption", + "DecisionRecord", + "DecisionSelection", + "DecisionSource", + "DecisionSpec", + "DecisionStatus", + "DecisionWorkflowRun", + "DecisionWorkflowStatus", + "DeterministicDecisionAuditor", + "EvidenceBundle", + "EvidenceClass", + "PendingDecision", + "ProtectedVariableEffect", + "ProtectedVariableEffectStatus", + "RevisionRequest", + "VerificationCheck", + "VerificationRecord", + "VerificationStatus", +] diff --git a/scarf/agent/decision_persistence.py b/scarf/agent/decision_persistence.py new file mode 100644 index 00000000..2d5a7c41 --- /dev/null +++ b/scarf/agent/decision_persistence.py @@ -0,0 +1,770 @@ +"""Append-only persistence for decision-driven orchestration ledgers.""" + +import hashlib +import json +import re +import time +from typing import Literal, cast + +import zarr +from pydantic import ConfigDict, Field, field_validator, model_validator +from zarr.core.buffer import default_buffer_prototype +from zarr.core.sync import sync + +from . import record_io +from .decision_kernel import ( + DecisionRecord, + DecisionWorkflowRun, + PendingDecision, + RevisionRequest, +) +from .orchestrator.models import ( + _ORCHESTRATION_FORMAT, + _ORCHESTRATION_VERSION, + OrchestrationRequestRecord, +) +from .persistence import AgentPersistenceTarget, _resolve_target +from .rna_decisions import ( + CompiledRnaDecision, + RNA_DECISION_TRANSITION_GRAPH, + RnaDecisionCheckpoint, +) +from .types import AgentDataModel + +_RUN_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,127}$") +_SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") +_RERUN_MESSAGE = ( + "Start a new orchestration run; decision snapshots are not migrated or resumed " + "across persistence formats. Existing artifacts are left unchanged." +) + + +class DecisionPersistenceFormatError(ValueError): + """Raised for old or unknown persistence without mutating stored data.""" + + +class DecisionSnapshotModel(AgentDataModel): + """Base for immutable decision-persistence records.""" + + model_config = ConfigDict(extra="forbid", frozen=True, validate_default=True) + + +class OrchestrationRunIdentity(DecisionSnapshotModel): + """Exact immutable orchestration request linked by a decision snapshot.""" + + workflowRunId: str + workspace: str | None = None + requestSha256: str + configSha256: str + requestContentSha256: str + + @field_validator("workflowRunId") + @classmethod + def validate_workflow_run_id(cls, value: str) -> str: + if _RUN_ID_PATTERN.fullmatch(value) is None: + raise ValueError("workflowRunId must be a lowercase run identifier") + return value + + @field_validator("requestSha256", "configSha256", "requestContentSha256") + @classmethod + def validate_sha256(cls, value: str, info: object) -> str: + if _SHA256_PATTERN.fullmatch(value) is None: + field_name = getattr(info, "field_name", "digest") + raise ValueError(f"{field_name} must be a lowercase SHA-256 digest") + return value + + +class DecisionWorkflowSnapshot(DecisionSnapshotModel): + """One content-addressed snapshot in an immutable decision ledger chain.""" + + recordType: Literal["decisionWorkflowSnapshot"] = "decisionWorkflowSnapshot" + formatVersion: Literal[2] = 2 + sequence: int = Field(ge=0, strict=True) + createdAtNs: int = Field(ge=1, strict=True) + parentContentSha256: str | None = None + orchestrationRun: OrchestrationRunIdentity + workflow: DecisionWorkflowRun + contentSha256: str + + @field_validator("parentContentSha256", "contentSha256") + @classmethod + def validate_sha256(cls, value: str | None, info: object) -> str | None: + if value is not None and _SHA256_PATTERN.fullmatch(value) is None: + field_name = getattr(info, "field_name", "digest") + raise ValueError(f"{field_name} must be a lowercase SHA-256 digest") + return value + + @model_validator(mode="after") + def validate_identity(self) -> "DecisionWorkflowSnapshot": + if self.sequence == 0 and self.parentContentSha256 is not None: + raise ValueError("The first snapshot cannot have a parent") + if self.sequence > 0 and self.parentContentSha256 is None: + raise ValueError("A later snapshot requires its exact parent checksum") + if self.workflow.workflowRunId != self.orchestrationRun.workflowRunId: + raise ValueError( + "Decision workflow identity must match the orchestration run" + ) + return self + + +def _validate_run_id(value: str) -> str: + if _RUN_ID_PATTERN.fullmatch(value) is None: + raise ValueError("workflow_run_id must be a lowercase run identifier") + return value + + +def _validate_sha256(value: str, label: str) -> str: + if _SHA256_PATTERN.fullmatch(value) is None: + raise ValueError(f"{label} must be a lowercase SHA-256 digest") + return value + + +def _model_checksum(value: AgentDataModel) -> str: + return hashlib.sha256( + record_io.canonical_json_bytes(value.model_dump(mode="json")) + ).hexdigest() + + +def _record_checksum(value: AgentDataModel) -> str: + return hashlib.sha256( + record_io.canonical_json_bytes( + value.model_dump(mode="json", exclude={"contentSha256"}) + ) + ).hexdigest() + + +def decision_record_checksum(record: DecisionRecord) -> str: + """Return the canonical SHA-256 identity of one immutable decision record.""" + return hashlib.sha256( + record_io.canonical_json_bytes(record.model_dump(mode="json")) + ).hexdigest() + + +def _snapshot_checksum(snapshot: DecisionWorkflowSnapshot) -> str: + return _record_checksum(snapshot) + + +def _write_key_once(group: zarr.Group, key: str, payload: bytes) -> None: + store = group.store + if bool(getattr(store, "read_only", False)) or not bool( + getattr(store, "supports_writes", True) + ): + raise PermissionError("Decision persistence target is read-only") + if record_io.read_key(group, key) is not None: + raise FileExistsError(f"Immutable decision snapshot {key!r} already exists") + buffer = default_buffer_prototype().buffer.from_bytes(payload) + sync(store.set_if_not_exists(key, buffer)) + stored = record_io.read_key(group, key) + if stored is None: + raise RuntimeError(f"Decision snapshot {key!r} was not stored") + if stored != payload: + raise FileExistsError( + f"Immutable decision snapshot {key!r} was written by another writer" + ) + + +def _list_keys(group: zarr.Group, prefix: str) -> list[str]: + if not group.store.supports_listing: + raise NotImplementedError("Decision persistence requires a listable Zarr store") + return record_io.list_keys(group, prefix) + + +def _orchestration_prefix(group: zarr.Group) -> str: + return record_io.join_key( + str(getattr(group, "path", "")).strip("/"), + "agents", + "orchestrations", + ) + + +def _request_key(prefix: str, workflow_run_id: str) -> str: + return record_io.join_key(prefix, workflow_run_id, "request.json") + + +def _snapshot_prefix(prefix: str, workflow_run_id: str) -> str: + return record_io.join_key( + prefix, + workflow_run_id, + "decisions", + "snapshots", + ) + + +def _snapshot_key(prefix: str, workflow_run_id: str, content_sha256: str) -> str: + return record_io.join_key( + _snapshot_prefix(prefix, workflow_run_id), + f"{content_sha256}.json", + ) + + +def _format_error(detail: str) -> DecisionPersistenceFormatError: + return DecisionPersistenceFormatError(f"{detail} {_RERUN_MESSAGE}") + + +def _resolve_orchestration_group( + target: AgentPersistenceTarget, + *, + write: bool, + workspace: str | None, +) -> tuple[zarr.Group, str | None, str]: + group, _datastore, resolved_workspace, _analysis_store = _resolve_target( + target, + write=write, + workspace=workspace, + ) + if "agents" not in group: + raise FileNotFoundError( + "No agent namespace exists for this data group. " + _RERUN_MESSAGE + ) + agents = group["agents"] + if not isinstance(agents, zarr.Group): + raise _format_error("The agents namespace is not a Zarr group.") + if "orchestrations" not in agents: + raise FileNotFoundError( + "No orchestration journal exists for this data group. " + _RERUN_MESSAGE + ) + orchestrations = agents["orchestrations"] + if not isinstance(orchestrations, zarr.Group): + raise _format_error("The orchestrations namespace is not a Zarr group.") + observed_format = orchestrations.attrs.get("format") + observed_version = orchestrations.attrs.get("format_version") + if ( + observed_format != _ORCHESTRATION_FORMAT + or observed_version != _ORCHESTRATION_VERSION + ): + raise _format_error( + "Unsupported orchestration persistence format " + f"{observed_format!r} version {observed_version!r}." + ) + return group, resolved_workspace, _orchestration_prefix(group) + + +def _decode_json(raw: bytes, key: str) -> object: + try: + return json.loads(raw) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValueError(f"Decision JSON record {key!r} is malformed") from exc + + +def _load_orchestration_identity( + group: zarr.Group, + prefix: str, + workflow_run_id: str, + workspace: str | None, +) -> OrchestrationRunIdentity: + key = _request_key(prefix, workflow_run_id) + raw = record_io.read_key(group, key) + if raw is None: + raise KeyError( + f"Unknown orchestration run {workflow_run_id!r}; {_RERUN_MESSAGE}" + ) + decoded = _decode_json(raw, key) + if not isinstance(decoded, dict): + raise ValueError(f"Orchestration request {key!r} is not a JSON object") + if decoded.get("formatVersion") != 2: + raise _format_error( + f"Unsupported orchestration request version " + f"{decoded.get('formatVersion')!r}." + ) + if decoded.get("recordType") != "automatedWorkflowRequest": + raise _format_error( + f"Unsupported orchestration request type {decoded.get('recordType')!r}." + ) + try: + request = OrchestrationRequestRecord.model_validate(decoded) + except ValueError as exc: + raise ValueError( + f"Orchestration request {key!r} does not match its schema" + ) from exc + if request.workflowRunId != workflow_run_id: + raise ValueError("Orchestration request identity does not match its path") + if request.request.workspace != workspace: + raise ValueError("Orchestration request workspace does not match its path") + if request.requestSha256 != _model_checksum(request.request): + raise ValueError("Orchestration request payload checksum is invalid") + if request.configSha256 != _model_checksum(request.config): + raise ValueError("Orchestration configuration checksum is invalid") + if request.contentSha256 != _record_checksum(request): + raise ValueError("Orchestration request envelope checksum is invalid") + return OrchestrationRunIdentity( + workflowRunId=workflow_run_id, + workspace=workspace, + requestSha256=request.requestSha256, + configSha256=request.configSha256, + requestContentSha256=request.contentSha256, + ) + + +def _load_snapshot_at( + group: zarr.Group, + prefix: str, + workflow_run_id: str, + content_sha256: str, + expected_identity: OrchestrationRunIdentity, +) -> DecisionWorkflowSnapshot: + content_sha256 = _validate_sha256(content_sha256, "content_sha256") + key = _snapshot_key(prefix, workflow_run_id, content_sha256) + raw = record_io.read_key(group, key) + if raw is None: + raise KeyError( + f"Unknown decision snapshot {content_sha256!r} for " + f"workflow {workflow_run_id!r}" + ) + decoded = _decode_json(raw, key) + if not isinstance(decoded, dict): + raise ValueError(f"Decision snapshot {key!r} is not a JSON object") + if decoded.get("formatVersion") != 2: + raise _format_error( + f"Unsupported decision snapshot version {decoded.get('formatVersion')!r}." + ) + if decoded.get("recordType") != "decisionWorkflowSnapshot": + raise _format_error( + f"Unsupported decision snapshot type {decoded.get('recordType')!r}." + ) + try: + snapshot = DecisionWorkflowSnapshot.model_validate(decoded) + except ValueError as exc: + raise ValueError( + f"Decision snapshot {key!r} does not match its schema" + ) from exc + if snapshot.contentSha256 != content_sha256: + raise ValueError("Decision snapshot checksum does not match its path") + if snapshot.contentSha256 != _snapshot_checksum(snapshot): + raise ValueError("Decision snapshot checksum does not match its content") + if snapshot.orchestrationRun != expected_identity: + raise ValueError("Decision snapshot orchestration identity is stale") + if snapshot.workflow.workflowRunId != workflow_run_id: + raise ValueError("Decision snapshot workflow identity does not match its path") + return snapshot + + +def _validate_snapshot_evolution( + previous: DecisionWorkflowSnapshot, + current: DecisionWorkflowSnapshot, +) -> None: + if previous.orchestrationRun != current.orchestrationRun: + raise ValueError("Decision snapshot orchestration identity changed") + if previous.workflow.workflowRunId != current.workflow.workflowRunId: + raise ValueError("Decision snapshot workflow identity changed") + if current.createdAtNs < previous.createdAtNs: + raise ValueError("Decision snapshot creation times must not move backwards") + for field_name in ( + "decisionRecords", + "verificationRecords", + "revisionRequests", + ): + old_values = getattr(previous.workflow, field_name) + new_values = getattr(current.workflow, field_name) + if new_values[: len(old_values)] != old_values: + raise ValueError( + f"Decision snapshot {field_name} must preserve its immutable prefix" + ) + if previous.workflow.status in {"completed", "abstained", "failed"}: + raise ValueError("Terminal decision snapshots cannot have descendants") + + +def _load_snapshot_chain( + group: zarr.Group, + prefix: str, + workflow_run_id: str, + identity: OrchestrationRunIdentity, +) -> list[DecisionWorkflowSnapshot]: + snapshot_prefix = _snapshot_prefix(prefix, workflow_run_id) + snapshots: list[DecisionWorkflowSnapshot] = [] + for key in _list_keys(group, snapshot_prefix): + if not key.endswith(".json"): + continue + filename = key.rsplit("/", 1)[-1] + content_sha256 = filename.removesuffix(".json") + if _SHA256_PATTERN.fullmatch(content_sha256) is None: + raise ValueError("Decision snapshot path is not content-addressed") + snapshots.append( + _load_snapshot_at( + group, + prefix, + workflow_run_id, + content_sha256, + identity, + ) + ) + snapshots.sort(key=lambda value: (value.sequence, value.contentSha256)) + for expected_sequence, snapshot in enumerate(snapshots): + if snapshot.sequence != expected_sequence: + raise ValueError("Decision snapshot chain has a gap or fork") + expected_parent = ( + snapshots[expected_sequence - 1].contentSha256 + if expected_sequence > 0 + else None + ) + if snapshot.parentContentSha256 != expected_parent: + raise ValueError("Decision snapshot parent does not match the exact chain") + if expected_sequence > 0: + _validate_snapshot_evolution( + snapshots[expected_sequence - 1], + snapshot, + ) + return snapshots + + +def save_decision_workflow_snapshot( + target: AgentPersistenceTarget, + workflow: DecisionWorkflowRun, + *, + workspace: str | None = None, + created_at_ns: int | None = None, +) -> DecisionWorkflowSnapshot: + """Append one content-addressed snapshot without overwriting earlier state.""" + workflow_run_id = _validate_run_id(workflow.workflowRunId) + group, resolved_workspace, prefix = _resolve_orchestration_group( + target, + write=True, + workspace=workspace, + ) + identity = _load_orchestration_identity( + group, + prefix, + workflow_run_id, + resolved_workspace, + ) + snapshots = _load_snapshot_chain( + group, + prefix, + workflow_run_id, + identity, + ) + if snapshots and snapshots[-1].workflow == workflow: + return snapshots[-1] + if snapshots and snapshots[-1].workflow.status in { + "completed", + "abstained", + "failed", + }: + raise RuntimeError("Cannot append after a terminal decision snapshot") + timestamp = time.time_ns() if created_at_ns is None else created_at_ns + if timestamp < 1: + raise ValueError("created_at_ns must be positive") + snapshot_values = { + "sequence": len(snapshots), + "createdAtNs": timestamp, + "parentContentSha256": snapshots[-1].contentSha256 if snapshots else None, + "orchestrationRun": identity, + "workflow": workflow, + "contentSha256": "0" * 64, + } + unhashed = DecisionWorkflowSnapshot.model_validate(snapshot_values) + snapshot_values["contentSha256"] = _snapshot_checksum(unhashed) + snapshot = DecisionWorkflowSnapshot.model_validate(snapshot_values) + if snapshots: + _validate_snapshot_evolution(snapshots[-1], snapshot) + key = _snapshot_key( + prefix, + workflow_run_id, + snapshot.contentSha256, + ) + _write_key_once( + group, + key, + record_io.display_json_bytes(snapshot.model_dump(mode="json")), + ) + stored = _load_snapshot_at( + group, + prefix, + workflow_run_id, + snapshot.contentSha256, + identity, + ) + chain = _load_snapshot_chain(group, prefix, workflow_run_id, identity) + if chain[-1].contentSha256 != stored.contentSha256: + raise RuntimeError("Decision snapshot did not become the exact chain head") + return stored + + +def load_decision_workflow_snapshot( + target: AgentPersistenceTarget, + workflow_run_id: str, + content_sha256: str, + *, + workspace: str | None = None, +) -> DecisionWorkflowSnapshot: + """Load one exact content-addressed snapshot and validate its live link.""" + workflow_run_id = _validate_run_id(workflow_run_id) + group, resolved_workspace, prefix = _resolve_orchestration_group( + target, + write=False, + workspace=workspace, + ) + identity = _load_orchestration_identity( + group, + prefix, + workflow_run_id, + resolved_workspace, + ) + return _load_snapshot_at( + group, + prefix, + workflow_run_id, + content_sha256, + identity, + ) + + +def list_decision_workflow_snapshots( + target: AgentPersistenceTarget, + workflow_run_id: str, + *, + workspace: str | None = None, +) -> list[DecisionWorkflowSnapshot]: + """Return the complete validated append-only snapshot chain.""" + workflow_run_id = _validate_run_id(workflow_run_id) + group, resolved_workspace, prefix = _resolve_orchestration_group( + target, + write=False, + workspace=workspace, + ) + identity = _load_orchestration_identity( + group, + prefix, + workflow_run_id, + resolved_workspace, + ) + return _load_snapshot_chain(group, prefix, workflow_run_id, identity) + + +def load_latest_decision_workflow_snapshot( + target: AgentPersistenceTarget, + workflow_run_id: str, + *, + workspace: str | None = None, +) -> DecisionWorkflowSnapshot: + """Return the head of the fully validated immutable snapshot chain.""" + snapshots = list_decision_workflow_snapshots( + target, + workflow_run_id, + workspace=workspace, + ) + if not snapshots: + raise KeyError(f"No decision snapshots for workflow {workflow_run_id!r}") + return snapshots[-1] + + +def load_decision_workflow_for_replay( + target: AgentPersistenceTarget, + workflow_run_id: str, + content_sha256: str, + *, + expected_handoff_id: str | None = None, + workspace: str | None = None, +) -> DecisionWorkflowRun: + """Load an exact completed ledger after validating its complete chain.""" + snapshots = list_decision_workflow_snapshots( + target, + workflow_run_id, + workspace=workspace, + ) + matches = [ + snapshot for snapshot in snapshots if snapshot.contentSha256 == content_sha256 + ] + if len(matches) != 1: + raise KeyError( + f"Snapshot {content_sha256!r} is not in the exact workflow chain" + ) + workflow = matches[0].workflow + if workflow.status != "completed" or workflow.finalHandoffId is None: + raise RuntimeError("Replay requires an exact completed decision workflow") + if ( + expected_handoff_id is not None + and workflow.finalHandoffId != expected_handoff_id + ): + raise ValueError("Replay final handoff identity does not match the snapshot") + return workflow + + +def pause_decision_workflow( + workflow: DecisionWorkflowRun, + pending: PendingDecision, +) -> DecisionWorkflowRun: + """Persist one unresolved checkpoint without inventing a selection.""" + if workflow.status != "running": + raise ValueError("Only a running decision workflow can pause") + values = workflow.model_dump(mode="json") + values["status"] = "needsInput" + values["pendingDecision"] = pending.model_dump(mode="json") + return DecisionWorkflowRun.model_validate(values) + + +def attach_audited_rna_decision( + workflow: DecisionWorkflowRun, + record: DecisionRecord, + compiled: CompiledRnaDecision, + *, + revision: RevisionRequest | None = None, +) -> DecisionWorkflowRun: + """Append one audited RNA decision in exact transition order.""" + if workflow.status == "needsInput" and workflow.pendingDecision is not None: + pending = workflow.pendingDecision + mismatches = [ + field_name + for field_name, pending_value, record_value in ( + ("decisionId", pending.decisionId, record.decisionId), + ( + "definitionVersion", + pending.definitionVersion, + record.definitionVersion, + ), + ("evidenceBundleId", pending.evidenceBundleId, record.evidenceBundleId), + ( + "evidenceBundleSha256", + pending.evidenceBundleSha256, + record.evidenceBundleSha256, + ), + ("offeredOptionIds", pending.offeredOptionIds, record.offeredOptionIds), + ( + "availableEvidenceIds", + pending.availableEvidenceIds, + record.availableEvidenceIds, + ), + ) + if pending_value != record_value + ] + if mismatches: + raise ValueError( + "Decision does not resolve the exact pending checkpoint; " + f"mismatched fields: {mismatches}" + ) + elif workflow.status != "running": + raise ValueError("Only a running decision workflow can accept a decision") + if ( + compiled.decisionRecordId != record.recordId + or compiled.decisionId != record.decisionId + or compiled.selectedOptionId != record.selectedOptionId + or compiled.status != record.status + or compiled.verification.decisionRecordId != record.recordId + or compiled.verification.verificationId != record.verificationId + or compiled.verification.status != "passed" + ): + raise ValueError("Compiled RNA decision does not exactly match its record") + try: + checkpoint = cast(RnaDecisionCheckpoint, record.decisionId) + RNA_DECISION_TRANSITION_GRAPH.resolve(checkpoint, record.status) + except KeyError as exc: + raise ValueError("Decision is not a registered RNA checkpoint/status") from exc + + if record.supersedes is None: + if revision is not None: + raise ValueError("A non-superseding decision cannot attach a revision") + if workflow.decisionRecords: + previous = workflow.decisionRecords[-1] + previous_checkpoint = cast(RnaDecisionCheckpoint, previous.decisionId) + expected_checkpoint, terminal = RNA_DECISION_TRANSITION_GRAPH.resolve( + previous_checkpoint, + previous.status, + ) + if terminal is not None or expected_checkpoint != checkpoint: + raise ValueError( + "Decision does not follow the exact RNA transition order" + ) + elif checkpoint != "qcGrouping": + raise ValueError("The first RNA decision must be qcGrouping") + else: + if revision is not None: + if revision.targetDecisionRecordId != record.supersedes: + raise ValueError( + "A superseding decision requires its exact revision request" + ) + else: + if record.supersedes not in workflow.invalidated_decision_record_ids(): + raise ValueError( + "A superseding decision requires a revision or invalidation" + ) + active_records = workflow.active_decision_records() + if not active_records: + raise ValueError( + "An invalidated decision rerun requires an active predecessor" + ) + previous = active_records[-1] + expected_checkpoint, terminal = RNA_DECISION_TRANSITION_GRAPH.resolve( + cast(RnaDecisionCheckpoint, previous.decisionId), + previous.status, + ) + if terminal is not None or expected_checkpoint != checkpoint: + raise ValueError( + "Invalidated decision rerun does not follow transition order" + ) + + _next_checkpoint, terminal_status = RNA_DECISION_TRANSITION_GRAPH.resolve( + checkpoint, + record.status, + ) + status = ( + "needsInput" + if terminal_status == "needsInput" + else "abstained" + if terminal_status == "abstained" + else "running" + ) + values = workflow.model_dump(mode="json") + values["status"] = status + values["pendingDecision"] = None + values["decisionRecords"] = [*workflow.decisionRecords, record] + values["verificationRecords"] = [ + *workflow.verificationRecords, + compiled.verification, + ] + if revision is not None: + values["revisionRequests"] = [*workflow.revisionRequests, revision] + return DecisionWorkflowRun.model_validate(values) + + +def complete_decision_workflow( + workflow: DecisionWorkflowRun, + final_handoff_id: str, +) -> DecisionWorkflowRun: + """Finalize a fully adjudicated RNA ledger with its exact handoff ID.""" + if workflow.status != "running" or not workflow.decisionRecords: + raise ValueError("Only a running adjudicated workflow can complete") + active_records = { + record.decisionId: record for record in workflow.active_decision_records() + } + expected: str = "qcGrouping" + visited: set[str] = set() + while expected != "finalize": + record = active_records.get(expected) + if record is None: + raise ValueError(f"RNA decision path is missing {expected!r}") + visited.add(expected) + try: + destination, terminal = RNA_DECISION_TRANSITION_GRAPH.resolve( + cast(RnaDecisionCheckpoint, expected), + record.status, + ) + except KeyError as exc: + raise ValueError( + f"Active decision {expected!r} has no registered transition" + ) from exc + if terminal is not None or destination is None: + raise ValueError("RNA decisions have not reached the finalize transition") + expected = destination + if visited != set(active_records): + raise ValueError( + "Decision workflow contains active records outside its RNA path" + ) + values = workflow.model_dump(mode="json") + values["status"] = "completed" + values["finalHandoffId"] = final_handoff_id + return DecisionWorkflowRun.model_validate(values) + + +__all__ = [ + "DecisionPersistenceFormatError", + "DecisionWorkflowSnapshot", + "OrchestrationRunIdentity", + "attach_audited_rna_decision", + "complete_decision_workflow", + "decision_record_checksum", + "list_decision_workflow_snapshots", + "load_decision_workflow_for_replay", + "load_decision_workflow_snapshot", + "load_latest_decision_workflow_snapshot", + "pause_decision_workflow", + "save_decision_workflow_snapshot", +] diff --git a/scarf/agent/experimental_context.py b/scarf/agent/experimental_context.py index 2025edef..548ac7e6 100644 --- a/scarf/agent/experimental_context.py +++ b/scarf/agent/experimental_context.py @@ -27,6 +27,12 @@ from .config import AgentRunConfig from .config._deps import AGENT_INSTALL_HINT from .config.agent_exec import run_agent_sync +from .qc_profiles import ( + RegisteredCellQcProfile, + RegisteredQcProjection, + offered_registered_qc_profiles, + registered_qc_metric_role, +) from .tools import artifact_reference, core_artifact_reference from .types import ( AgentDataModel, @@ -63,6 +69,7 @@ "InferenceUnit", "NamedArtifactSource", "RepresentationEvaluation", + "RegisteredCellQcProfile", "analyze_experimental_design", "inspect_cell_covariates", "score_current_representation", @@ -76,7 +83,13 @@ "graphConnectivity", "proportionalBatchMixing", ] -type CellQcAction = Literal["skip", "globalGaussian", "sampleMad"] +type CellQcAction = Literal[ + "skip", + "globalGaussian", + "sampleMad", + "registeredMad", +] +type LegacyCellQcAction = Literal["skip", "globalGaussian", "sampleMad"] type CellQcDriverType = Literal["RNA", "ATAC"] _CONTEXT_LIMIT = 1200 @@ -173,6 +186,7 @@ def _validate_qc_sources( artifact_metrics: list[NamedArtifactSource], sample_column: str | None, sample_artifact: NamedArtifactSource | None, + registered_profile: RegisteredCellQcProfile | None = None, ) -> None: if len(attributes) != len(set(attributes)): raise ValueError("Cell-QC metadata attributes must be unique") @@ -211,6 +225,36 @@ def _validate_qc_sources( ) if sample_artifact is not None and sample_artifact.name in artifact_names: raise ValueError("Cell-QC sample and metric artifact names must be distinct") + if registered_profile is not None: + if registered_profile == "retainWithFlags": + if action != "skip": + raise ValueError( + "retainWithFlags must use the non-filtering skip action" + ) + if sample_column is not None or sample_artifact is not None: + raise ValueError("retainWithFlags cannot include a capture source") + return + if action != "registeredMad": + raise ValueError(f"{registered_profile} must use the registeredMad action") + capture_profile = registered_profile in { + "captureMad5", + "captureMad3Sensitivity", + "pooledReferenceMad5", + } + has_one_capture_source = (sample_column is None) != (sample_artifact is None) + if capture_profile and not has_one_capture_source: + raise ValueError( + f"{registered_profile} requires exactly one proven capture source" + ) + if not capture_profile and ( + sample_column is not None or sample_artifact is not None + ): + raise ValueError(f"{registered_profile} cannot include a capture source") + if not attributes and not artifact_metrics: + raise ValueError("Registered MAD filtering requires at least one metric") + return + if action == "registeredMad": + raise ValueError("registeredMad requires a registeredProfile") if action == "skip" and (attributes or artifact_metrics): raise ValueError("skip cannot include Cell-QC metrics") if action != "skip" and not attributes and not artifact_metrics: @@ -226,10 +270,11 @@ def _validate_qc_sources( class CellQcProfileEvidence(AgentDataModel): - """Projected retention for one executor-supported cell-QC profile.""" + """Projected retention for one registered or legacy cell-QC profile.""" profileId: str = "" action: CellQcAction = "skip" + registeredProfile: RegisteredCellQcProfile | None = None driverAssay: str | None = None driverAssayType: CellQcDriverType | None = None sampleColumn: str | None = None @@ -241,6 +286,10 @@ class CellQcProfileEvidence(AgentDataModel): retainedCells: int = 0 retainedFraction: float = 0.0 sampleRetainedCells: dict[str, int] = Field(default_factory=dict) + retainedCellsByColumn: dict[str, dict[str, int]] = Field(default_factory=dict) + unsafeRetentionGroups: list[str] = Field(default_factory=list) + flaggedCells: dict[str, int] = Field(default_factory=dict) + failedCaptureCandidates: list[str] = Field(default_factory=list) notes: list[str] = Field(default_factory=list) evidenceId: str = "" @@ -252,6 +301,7 @@ def validate_sources(self) -> "CellQcProfileEvidence": artifact_metrics=self.artifactMetrics, sample_column=self.sampleColumn, sample_artifact=self.sampleArtifact, + registered_profile=self.registeredProfile, ) return self @@ -262,17 +312,18 @@ def get_blank(cls) -> "CellQcProfileEvidence": @classmethod def get_example(cls) -> "CellQcProfileEvidence": return cls( - profileId="cellQc:RNA:RNA:globalGaussian:0.01:0.99", - action="globalGaussian", + profileId="cellQc:RNA:globalMad5", + action="registeredMad", + registeredProfile="globalMad5", driverAssay="RNA", driverAssayType="RNA", attributes=["RNA_nCounts", "RNA_nFeatures"], artifactMetrics=[NamedArtifactSource.get_example()], - parameters={"minP": 0.01, "maxP": 0.99}, + parameters={"nMads": 5.0}, activeCells=100, retainedCells=96, retainedFraction=0.96, - evidenceId=("qcProfile:cellQc:RNA:RNA:globalGaussian:0.01:0.99"), + evidenceId="qcProfile:cellQc:RNA:globalMad5", ) @@ -280,6 +331,7 @@ class CellQcPlan(AgentDataModel): """A validated selection from the bounded cell-QC profiles.""" action: CellQcAction = "skip" + registeredProfile: RegisteredCellQcProfile | None = None profileId: str = "" driverAssay: str | None = None driverAssayType: CellQcDriverType | None = None @@ -298,6 +350,7 @@ def validate_sources(self) -> "CellQcPlan": artifact_metrics=self.artifactMetrics, sample_column=self.sampleColumn, sample_artifact=self.sampleArtifact, + registered_profile=self.registeredProfile, ) return self @@ -310,6 +363,7 @@ def get_example(cls) -> "CellQcPlan": evidence = CellQcProfileEvidence.get_example() return cls( action=evidence.action, + registeredProfile=evidence.registeredProfile, profileId=evidence.profileId, driverAssay=evidence.driverAssay, driverAssayType=evidence.driverAssayType, @@ -352,7 +406,6 @@ def get_example(cls) -> "ExperimentalContextDecision": coefficientsOfInterest=["treatment"], unitsOfInference={"treatment": InferenceUnit.get_example()}, batchCorrection=BatchCorrectionPlan.get_example(), - cellQc=CellQcPlan.get_example(), rationale="Treatment is the primary between-sample contrast.", evidenceIds=[ "column:batch", @@ -485,7 +538,6 @@ def get_example(cls) -> "ExperimentalContextResult": notes=["Example deterministic design characterization"], ), cellSelection=representation.cellSelection, - cellQc=CellQcPlan.get_example(), qcProfiles=[CellQcProfileEvidence.get_example()], qualityMetricArtifacts=[NamedArtifactSource.get_example()], htoIdentityColumns=["sample_id"], @@ -625,6 +677,7 @@ class ExperimentalContextDependencies(AgentDataModel): connectivityMap: Any = Field(default=None, exclude=True) cellSelection: Any = Field(default=None, exclude=True) studyContext: str = "" + studyObjective: str = "" directions: dict[str, Any] = Field(default_factory=dict) evidenceIds: set[str] = Field(default_factory=set) characterization: CovariateCharacterization | None = None @@ -646,6 +699,9 @@ def get_blank(cls) -> "ExperimentalContextDependencies": def get_example(cls) -> "ExperimentalContextDependencies": return cls( studyContext="Case-control study with samples nested in donors.", + studyObjective=( + "Discover populations while preserving the case-control contrast." + ), directions={"columnDomains": {"batch": "technical"}}, ) @@ -851,7 +907,7 @@ def _qc_sample_columns( def _qc_profile_id( - action: CellQcAction, + action: LegacyCellQcAction, *, driver: tuple[str, CellQcDriverType] | None, sample_column: str | None = None, @@ -873,6 +929,263 @@ def _qc_profile_id( return f"cellQc:{assay_type}:{assay_name}:{suffix}" +def _registered_qc_profile_id( + profile: RegisteredCellQcProfile, + *, + driver: tuple[str, CellQcDriverType], + sample_column: str | None, + sample_artifact: NamedArtifactSource | None, +) -> str: + if sample_column is not None: + source = f"metadata:{sample_column}" + elif sample_artifact is not None: + source = ( + f"artifact:{sample_artifact.name}:{sample_artifact.artifact.artifactId}" + ) + else: + source = "global" + return f"cellQc:{driver[1]}:{driver[0]}:registered:{profile}:{source}" + + +def _directed_capture_source( + deps: ExperimentalContextDependencies, +) -> tuple[str | None, NamedArtifactSource | None, np.ndarray] | None: + directed_qc = deps.directions.get("cellQc") + qc_directions = dict(directed_qc) if isinstance(directed_qc, Mapping) else {} + candidates = [ + deps.directions.get("physicalCaptureColumn"), + qc_directions.get("physicalCaptureColumn"), + qc_directions.get("captureColumn"), + ] + specified = [value for value in candidates if value is not None] + if not specified: + return None + if any(not isinstance(value, str) or not value.strip() for value in specified): + raise ValueError("physicalCaptureColumn must be a non-empty string") + names = list(dict.fromkeys(str(value) for value in specified)) + if len(names) != 1: + raise ValueError("Conflicting physical capture columns were supplied") + name = names[0] + matching_artifacts = [ + source for source in deps.htoIdentityArtifacts if source.name == name + ] + if len(matching_artifacts) > 1: + raise ValueError(f"Physical capture artifact {name!r} is not unique") + if matching_artifacts: + source = matching_artifacts[0] + labels = _resolved_artifact_values( + deps, + source, + expected_kind="hto_identity", + ) + return None, source, np.asarray(labels) + if name not in deps.cells.columns: + raise ValueError( + f"physicalCaptureColumn {name!r} is not observed metadata or an " + "exact HTO identity artifact" + ) + return name, None, np.asarray(deps.cells.fetch(name)) + + +def _directed_pooled_reference_captures( + deps: ExperimentalContextDependencies, +) -> tuple[str, ...] | None: + directed_qc = deps.directions.get("cellQc") + qc_directions = dict(directed_qc) if isinstance(directed_qc, Mapping) else {} + raw = qc_directions.get( + "pooledReferenceCaptures", + deps.directions.get("pooledReferenceCaptures"), + ) + if raw is None: + return None + if not isinstance(raw, list | tuple) or any( + not isinstance(value, str) or not value.strip() for value in raw + ): + raise ValueError("pooledReferenceCaptures must contain non-empty strings") + references = tuple(str(value) for value in raw) + if len(references) < 2 or len(references) != len(set(references)): + raise ValueError( + "pooledReferenceCaptures must contain at least two unique captures" + ) + return references + + +def _registered_profile_evidence( + projection: RegisteredQcProjection, + *, + deps: ExperimentalContextDependencies, + characterization: CovariateCharacterization | None, + driver: tuple[str, CellQcDriverType], + active: np.ndarray, + values_by_attr: dict[str, np.ndarray], + metadata_attributes: list[str], + artifact_metrics: list[NamedArtifactSource], + sample_column: str | None, + sample_artifact: NamedArtifactSource | None, + pooled_reference_captures: tuple[str, ...] | None, + active_cells: int, + comparison_source: str | None, +) -> CellQcProfileEvidence: + supported_names = { + name + for name in values_by_attr + if registered_qc_metric_role(name) != "diagnostic" + } + attributes = [name for name in metadata_attributes if name in supported_names] + metric_artifacts = [ + source for source in artifact_metrics if source.name in supported_names + ] + profile_id = _registered_qc_profile_id( + projection.profile, + driver=driver, + sample_column=sample_column, + sample_artifact=sample_artifact, + ) + n_mads = 3.0 if projection.profile == "captureMad3Sensitivity" else 5.0 + action: CellQcAction = ( + "skip" if projection.profile == "retainWithFlags" else "registeredMad" + ) + parameters: dict[str, Any] = { + "policyVersion": 1, + "profile": projection.profile, + "nMads": n_mads, + "boundPolicy": { + "count": {"remove": "lower", "flag": "upper"}, + "feature": {"remove": "lower", "flag": "upper"}, + "mitochondrial": {"remove": "upper", "fixedCutoff": None}, + "diagnostic": {"remove": "none"}, + }, + "resolvedBounds": [threshold.to_dict() for threshold in projection.thresholds], + "captureSizes": projection.captureSizes, + "captureComparisons": [ + comparison.to_dict() for comparison in projection.captureComparisons + ], + "captureComparisonSource": comparison_source, + "pooledReferenceCaptures": list(pooled_reference_captures or ()), + } + cells = deps.cells if deps.cells is not None else deps.store.cells + retention_columns: list[str] = [] + if characterization is not None: + for coefficient in characterization.coefficients: + for value in ( + coefficient.get("name"), + coefficient.get("observationUnit"), + coefficient.get("independentUnit"), + ): + if isinstance(value, str) and value in cells.columns: + retention_columns.append(value) + retained_by_column: dict[str, dict[str, int]] = {} + unsafe_groups: list[str] = [] + retained = np.asarray(projection.keep, dtype=bool) & np.asarray(active, dtype=bool) + for column in dict.fromkeys(retention_columns): + labels = np.asarray(cells.fetch(column)) + if labels.shape != retained.shape: + raise ValueError( + f"QC retention column {column!r} does not align with cellSelection" + ) + counts: dict[str, int] = {} + for raw_label in np.unique(labels[np.asarray(active, dtype=bool)]): + label = raw_label.item() if isinstance(raw_label, np.generic) else raw_label + key = label.decode("utf-8") if isinstance(label, bytes) else str(label) + count = int((retained & (labels == raw_label)).sum()) + counts[key] = count + if count == 0: + unsafe_groups.append(f"{column}={key}") + retained_by_column[column] = counts + return CellQcProfileEvidence( + profileId=profile_id, + action=action, + registeredProfile=projection.profile, + driverAssay=driver[0], + driverAssayType=driver[1], + sampleColumn=sample_column, + sampleArtifact=sample_artifact, + attributes=attributes, + artifactMetrics=metric_artifacts, + parameters=parameters, + activeCells=active_cells, + retainedCells=projection.retainedCells, + retainedFraction=( + projection.retainedCells / active_cells if active_cells else 0.0 + ), + sampleRetainedCells=projection.retainedByCapture, + retainedCellsByColumn=retained_by_column, + unsafeRetentionGroups=sorted(unsafe_groups), + flaggedCells=projection.flagCounts, + failedCaptureCandidates=list(projection.failedCaptureCandidates), + notes=list(projection.warnings), + evidenceId=f"qcProfile:{profile_id}", + ) + + +def _registered_qc_profiles( + deps: ExperimentalContextDependencies, + *, + characterization: CovariateCharacterization | None, + driver: tuple[str, CellQcDriverType], + active: np.ndarray, + values_by_attr: dict[str, np.ndarray], + metadata_attributes: list[str], + artifact_metrics: list[NamedArtifactSource], +) -> list[CellQcProfileEvidence]: + capture = _directed_capture_source(deps) + sample_column: str | None = None + sample_artifact: NamedArtifactSource | None = None + capture_labels: np.ndarray | None = None + if capture is not None: + sample_column, sample_artifact, capture_labels = capture + if sample_column is not None: + comparison_source = f"metadata:{sample_column}" + elif sample_artifact is not None: + comparison_source = ( + f"artifact:{sample_artifact.name}:{sample_artifact.artifact.artifactId}" + ) + else: + comparison_source = None + pooled_references = _directed_pooled_reference_captures(deps) + if pooled_references is not None and capture is None: + raise ValueError( + "pooledReferenceCaptures requires an explicit physicalCaptureColumn" + ) + projections = offered_registered_qc_profiles( + values_by_metric=values_by_attr, + active=active, + capture_labels=capture_labels, + grouping_proven=capture is not None, + min_cells_per_capture=20, + pooled_reference_captures=pooled_references, + ) + profiles: list[CellQcProfileEvidence] = [] + for projection in projections: + uses_capture = projection.profile in { + "captureMad5", + "captureMad3Sensitivity", + "pooledReferenceMad5", + } + profiles.append( + _registered_profile_evidence( + projection, + deps=deps, + characterization=characterization, + driver=driver, + active=active, + values_by_attr=values_by_attr, + metadata_attributes=metadata_attributes, + artifact_metrics=artifact_metrics, + sample_column=sample_column if uses_capture else None, + sample_artifact=sample_artifact if uses_capture else None, + pooled_reference_captures=( + pooled_references + if projection.profile == "pooledReferenceMad5" + else None + ), + active_cells=int(active.sum()), + comparison_source=comparison_source, + ) + ) + return profiles + + def _global_qc_profile( deps: ExperimentalContextDependencies, driver: tuple[str, CellQcDriverType], @@ -1051,20 +1364,39 @@ def _offered_qc_profiles( if driver is not None else ["No RNA or ATAC assay is eligible to drive automatic cell QC"] ) - profiles = [ - CellQcProfileEvidence( - profileId=skip_id, - action="skip", - driverAssay=driver_assay, - driverAssayType=driver_type, - activeCells=active_cells, - retainedCells=active_cells, - retainedFraction=1.0 if active_cells else 0.0, - notes=skip_notes, - evidenceId=f"qcProfile:{skip_id}", - ) - ] + registered_only = deps.directions.get("registeredQcOnly") is True + profiles = ( + [] + if registered_only + else [ + CellQcProfileEvidence( + profileId=skip_id, + action="skip", + driverAssay=driver_assay, + driverAssayType=driver_type, + activeCells=active_cells, + retainedCells=active_cells, + retainedFraction=1.0 if active_cells else 0.0, + notes=skip_notes, + evidenceId=f"qcProfile:{skip_id}", + ) + ] + ) if driver is None or active_cells == 0: + if registered_only: + profiles.append( + CellQcProfileEvidence( + profileId=skip_id, + action="skip", + driverAssay=driver_assay, + driverAssayType=driver_type, + activeCells=active_cells, + retainedCells=active_cells, + retainedFraction=1.0 if active_cells else 0.0, + notes=skip_notes, + evidenceId=f"qcProfile:{skip_id}", + ) + ) deps.qcProfiles = {profile.profileId: profile for profile in profiles} return profiles @@ -1117,28 +1449,40 @@ def _offered_qc_profiles( values_by_attr[source.name] = values artifact_metrics.append(source) - global_profile = _global_qc_profile( - deps, - driver, - active, - active_cells, - values_by_attr, - valid_metadata_attributes, - artifact_metrics, - attribute_notes, - ) - if global_profile is not None: - profiles.append(global_profile) - profiles.extend( - _sample_qc_profiles( + if not registered_only: + global_profile = _global_qc_profile( deps, - characterization, driver, active, active_cells, values_by_attr, valid_metadata_attributes, artifact_metrics, + attribute_notes, + ) + if global_profile is not None: + profiles.append(global_profile) + profiles.extend( + _sample_qc_profiles( + deps, + characterization, + driver, + active, + active_cells, + values_by_attr, + valid_metadata_attributes, + artifact_metrics, + ) + ) + profiles.extend( + _registered_qc_profiles( + deps, + characterization=characterization, + driver=driver, + active=active, + values_by_attr=values_by_attr, + metadata_attributes=valid_metadata_attributes, + artifact_metrics=artifact_metrics, ) ) @@ -1158,7 +1502,9 @@ async def inspect_cell_covariates( characterization = characterize_covariates( ctx.deps.store, cellSelection=ctx.deps.cellSelection, - studyContext=ctx.deps.studyContext, + studyContext=( + f"{ctx.deps.studyContext}\nStudy objective: {ctx.deps.studyObjective}" + ), model=None, directions=ctx.deps.directions, groupingArtifacts=_hto_artifact_map(ctx.deps), @@ -1294,7 +1640,9 @@ async def analyze_experimental_design( characterization = characterize_covariates( ctx.deps.store, cellSelection=ctx.deps.cellSelection, - studyContext=ctx.deps.studyContext, + studyContext=( + f"{ctx.deps.studyContext}\nStudy objective: {ctx.deps.studyObjective}" + ), model=None, directions=directions, groupingArtifacts=_hto_artifact_map(ctx.deps), @@ -1311,8 +1659,8 @@ async def analyze_experimental_design( raise ModelRetry("; ".join(characterization.notes)) # Retain the validated deterministic work even when the proposed Harmony - # columns below are rejected. A bounded fallback can then continue without - # rescanning the metadata or accepting an unsafe model choice. + # columns below are rejected. A bounded retry or resumed decision can reuse + # the evidence without rescanning metadata or accepting an unsafe choice. ctx.deps.characterization = characterization if not ctx.deps.htoIdentityColumns: ctx.deps.htoIdentityColumns = _hto_identity_columns(ctx.deps) @@ -1662,13 +2010,20 @@ def _canonical_cell_qc_plan( has_directed_selector = any( key in direction_map - for key in ("profileId", "action", "sampleColumn", "sampleArtifactName") + for key in ( + "profileId", + "registeredProfile", + "action", + "sampleColumn", + "sampleArtifactName", + ) ) selected_id = directed_profile_id or ( "" if has_directed_selector else plan.profileId ) if not selected_id: requested_action = direction_map.get("action") + requested_registered_profile = direction_map.get("registeredProfile") requested_sample = direction_map.get("sampleColumn") requested_sample_artifact = direction_map.get("sampleArtifactName") if requested_sample is not None and requested_sample_artifact is not None: @@ -1684,12 +2039,35 @@ def _canonical_cell_qc_plan( "skip", "globalGaussian", "sampleMad", + "registeredMad", }: raise ModelRetry(f"Unsupported cellQc.action {requested_action!r}") + if requested_registered_profile is not None and not isinstance( + requested_registered_profile, str + ): + raise ModelRetry("cellQc.registeredProfile must be a string") + if ( + requested_registered_profile is not None + and requested_registered_profile + not in { + "retainWithFlags", + "globalMad5", + "captureMad5", + "captureMad3Sensitivity", + "pooledReferenceMad5", + } + ): + raise ModelRetry( + f"Unsupported cellQc.registeredProfile {requested_registered_profile!r}" + ) matches = [ profile for profile in deps.qcProfiles.values() if (requested_action is None or profile.action == requested_action) + and ( + requested_registered_profile is None + or profile.registeredProfile == requested_registered_profile + ) and (requested_sample is None or profile.sampleColumn == requested_sample) and ( requested_sample_artifact is None @@ -1699,7 +2077,7 @@ def _canonical_cell_qc_plan( ) ) ] - if requested_action is not None: + if requested_action is not None or requested_registered_profile is not None: if len(matches) != 1: raise ModelRetry( "cellQc directions must identify exactly one offered profile" @@ -1729,6 +2107,7 @@ def _canonical_cell_qc_plan( if model_selected: expected_fields = { "action": profile.action, + "registeredProfile": profile.registeredProfile, "driverAssay": profile.driverAssay, "driverAssayType": profile.driverAssayType, "sampleColumn": profile.sampleColumn, @@ -1760,6 +2139,7 @@ def _canonical_cell_qc_plan( cited_evidence = plan.evidenceIds if model_selected else [] return CellQcPlan( action=profile.action, + registeredProfile=profile.registeredProfile, profileId=profile.profileId, driverAssay=profile.driverAssay, driverAssayType=profile.driverAssayType, @@ -1778,7 +2158,6 @@ def _validate_batch_correction_plan( characterization: CovariateCharacterization, requested_coefficients: set[str], units_of_inference: dict[str, dict[str, Any]], - cell_qc_plan: CellQcPlan, records: dict[str, dict[str, Any]], coefficient_records: dict[str, dict[str, Any]], ) -> None: @@ -1943,7 +2322,6 @@ def _validate_batch_correction_plan( cited_ids = [ *decision.evidenceIds, *plan.evidenceIds, - *cell_qc_plan.evidenceIds, ] unknown_evidence = sorted(set(cited_ids) - deps.evidenceIds) if unknown_evidence: @@ -1972,7 +2350,6 @@ def validate_experimental_context( narrative_fields = { "rationale": decision.rationale, "batchCorrection.rationale": decision.batchCorrection.rationale, - "cellQc.rationale": decision.cellQc.rationale, **{ f"needsInput[{index}]": question for index, question in enumerate(decision.needsInput) @@ -2019,7 +2396,7 @@ def validate_experimental_context( characterization = characterize_covariates( deps.store, cellSelection=deps.cellSelection, - studyContext=deps.studyContext, + studyContext=f"{deps.studyContext}\nStudy objective: {deps.studyObjective}", model=None, directions=directions, groupingArtifacts=_hto_artifact_map(deps), @@ -2034,11 +2411,13 @@ def validate_experimental_context( if "analyze_experimental_design" not in deps.toolCalls: raise ModelRetry("Call analyze_experimental_design before returning a decision") - cell_qc_plan = _canonical_cell_qc_plan( - decision.cellQc, - deps, - characterization, - ) + if decision.cellQc != CellQcPlan.get_blank(): + raise ModelRetry( + "Experimental Context must leave cellQc blank; the audited filtering " + "checkpoint selects from qcProfiles" + ) + if not deps.qcProfiles: + _offered_qc_profiles(deps, characterization) deps.evidenceIds.update(profile.evidenceId for profile in deps.qcProfiles.values()) requested_coefficients = set(directions["coefficientsOfInterest"]) @@ -2072,7 +2451,6 @@ def validate_experimental_context( characterization, requested_coefficients, units_of_inference, - cell_qc_plan, records, coefficient_records, ) @@ -2102,33 +2480,35 @@ def validate_experimental_context( "columnDomains": canonical_domains, "coefficientsOfInterest": list(directions["coefficientsOfInterest"]), "unitsOfInference": canonical_units, - "cellQc": cell_qc_plan, + "cellQc": CellQcPlan.get_blank(), } ) logger.debug( "Experimental Context decision validated: " f"domains={len(validated.columnDomains)}, " f"coefficients={len(validated.coefficientsOfInterest)}, " - f"cellQc={validated.cellQc.action}, " + f"qcProfiles={len(deps.qcProfiles)}, " f"batchCorrection={validated.batchCorrection.action}, " f"needsInput={len(validated.needsInput)}" ) return validated -def fallback_experimental_context_result( +def pending_experimental_context_result( deps: ExperimentalContextDependencies, *, error: UnexpectedModelBehavior, model_name: str, ) -> ExperimentalContextResult: - """Continue conservatively when the model exhausts its correction budget.""" + """Pause when the model exhausts its bounded decision budget.""" characterization = deps.characterization if characterization is None: characterization = characterize_covariates( deps.store, cellSelection=deps.cellSelection, - studyContext=deps.studyContext, + studyContext=( + f"{deps.studyContext}\nStudy objective: {deps.studyObjective}" + ), model=None, directions=deps.directions, groupingArtifacts=_hto_artifact_map(deps), @@ -2139,11 +2519,6 @@ def fallback_experimental_context_result( qc_profiles = list(deps.qcProfiles.values()) if not qc_profiles: qc_profiles = _offered_qc_profiles(deps, characterization) - cell_qc = _canonical_cell_qc_plan( - CellQcPlan.get_blank(), - deps, - characterization, - ) evidence_ids = characterization_evidence(characterization) evidence_ids.update(profile.evidenceId for profile in qc_profiles) evidence_ids.update(f"htoIdentity:{column}" for column in deps.htoIdentityColumns) @@ -2151,83 +2526,38 @@ def fallback_experimental_context_result( _artifact_evidence_id(source) for source in deps.htoIdentityArtifacts ) deps.evidenceIds.update(evidence_ids) - column_domains = { - str(record["name"]): record["domain"] - for record in characterization.columns - if isinstance(record.get("name"), str) - and record.get("domain") - in {"biological", "technical", "design", "ignore", "unknown"} - } - coefficient_records = { - str(record["name"]): record - for record in characterization.coefficients - if isinstance(record.get("name"), str) - } - coefficients = list(coefficient_records) - units = { - coefficient: InferenceUnit( - observationUnit=record.get("observationUnit"), - independentUnit=record.get("independentUnit"), - ) - for coefficient, record in coefficient_records.items() - } - batch_evidence = sorted( - f"column:{name}" - for name, domain in column_domains.items() - if domain == "technical" - ) - if not batch_evidence: - batch_evidence = sorted( - evidence_id - for evidence_id in evidence_ids - if evidence_id.startswith("column:") - )[:1] - limitation = ( - "The model exhausted its bounded correction budget while proposing the " - "experimental design. Harmony was skipped because no model proposal was " - "accepted as a categorical technical batch design." + question = ( + "The Experimental Context agent could not produce a validated scientific " + "decision. Provide explicit metadata roles, units of inference, cell-QC " + "profile, and batch-correction intent before continuing." ) decision = ExperimentalContextDecision( - columnDomains=column_domains, - coefficientsOfInterest=coefficients, - unitsOfInference=units, - batchCorrection=BatchCorrectionPlan( - action="skip", - rationale=( - "Use the native representation because bounded validation did not " - "authorize a safe Harmony batch column." - ), - evidenceIds=batch_evidence, - ), - cellQc=cell_qc, - rationale=( - "Retained deterministic metadata characterization and the exact bounded " - "cell-QC profile, while declining an unvalidated batch-correction choice." - ), + batchCorrection=BatchCorrectionPlan(action="needsInput"), + cellQc=CellQcPlan.get_blank(), + rationale="No scientific decision was selected.", evidenceIds=sorted(evidence_ids), + needsInput=[question], ) error_detail = str(error).replace("\n", " ").strip()[:500] - status: StageStatus = "failed" if characterization.status == "failed" else "done" logger.warning( - "Experimental Context used its conservative fallback: " - f"status={status}, cellQc={cell_qc.action}, coefficients={len(coefficients)}, " + "Experimental Context paused without a scientific decision: " f"reason={error_detail}" ) return ExperimentalContextResult( - status=status, + status=("failed" if characterization.status == "failed" else "needsInput"), decision=decision, characterization=characterization, cellSelection=artifact_reference(deps.cellSelection), - cellQc=cell_qc, + cellQc=CellQcPlan.get_blank(), qcProfiles=qc_profiles, qualityMetricArtifacts=deps.qualityMetricArtifacts, htoIdentityColumns=deps.htoIdentityColumns, htoIdentityArtifacts=deps.htoIdentityArtifacts, batchSafety=list(deps.batchSafety.values()), currentRepresentation=deps.currentRepresentation, - notes=[*characterization.notes, limitation, error_detail], + notes=[*characterization.notes, question, error_detail], runInfo=AgentRunInfo( - agentName="experimental_context_fallback", + agentName="experimental_context_needs_input", modelName=model_name, ), ) @@ -2267,13 +2597,12 @@ def __init__( its single call. The tools return bounded cell-QC profiles projected against the exact - shared cell selection. Select one returned profileId, copy its action, - driver assay name and type, metadata attributes, artifact metrics, and - sample source exactly, and cite its evidenceId. RNA is the preferred - QC driver and ATAC is the fallback. ADT and HTO never drive automatic - cell filtering. An exact HTO identity artifact may be used as sample - or grouping evidence. It is not a live metadata column and does not - make HTO a QC driver. + shared cell selection. Do not choose a profile and leave cellQc blank. + A later audited checkpoint selects one registered profile. Never author + or alter numeric quality bounds. RNA is the preferred QC driver and + ATAC is the fallback. ADT and HTO never drive automatic cell filtering. + An exact HTO identity artifact may be used as grouping evidence. It is + not a live metadata column and does not make HTO a QC driver. A batch column must be categorical and technical. Never use donor, sample, observation-unit, independent-unit, biological, cluster, or @@ -2289,7 +2618,10 @@ def __init__( Parameter Tuning must compare exact uncorrected and corrected artifacts. Cite only evidenceIds returned by tools. Ask for input when study - design cannot be resolved. Never propose Python, shell commands, + design cannot be resolved. The study objective is authoritative: use + it to identify protected biological variables and the intended unit + of inference, but do not broaden it or claim to test a hypothesis. + Never propose Python, shell commands, direct Zarr access, or any datastore mutation. Every rationale and question must be plain prose. Never place serialized JSON, schema field names, or sibling output fields inside a narrative string. @@ -2305,6 +2637,7 @@ def run( store: Any, *, study_context: str | None = None, + study_objective: str | None = None, cell_selection: ArtifactRef | None = None, directions: Mapping[str, Any] | None = None, run: "PipelineRun | None" = None, @@ -2315,8 +2648,11 @@ def run( ) -> ExperimentalContextResult: """Inspect one datastore and return a validated experimental-context report.""" study_context = (study_context or "").strip() + study_objective = (study_objective or "").strip() if len(study_context) > _CONTEXT_LIMIT: study_context = study_context[: _CONTEXT_LIMIT - 3] + "..." + if len(study_objective) > _CONTEXT_LIMIT: + study_objective = study_objective[: _CONTEXT_LIMIT - 3] + "..." direction_map = dict(directions or {}) if run is not None: if ( @@ -2397,7 +2733,8 @@ def run( f"directions={len(direction_map)}, " f"qualityMetrics={len(quality_sources)}, " f"htoIdentities={len(hto_sources)}, " - f"studyContextProvided={bool(study_context)}" + f"studyContextProvided={bool(study_context)}, " + f"studyObjectiveProvided={bool(study_objective)}" ) deps = ExperimentalContextDependencies( store=store, @@ -2417,6 +2754,7 @@ def run( connectivityMap=connectivity_map, cellSelection=cell_selection, studyContext=study_context, + studyObjective=study_objective, directions=direction_map, qualityMetricArtifacts=quality_sources, htoIdentityArtifacts=hto_sources, @@ -2424,10 +2762,12 @@ def run( user_prompt = ( dedent( """ - Characterize this experiment's metadata, select one offered cell-QC - profile, and decide whether Harmony should be evaluated. + Characterize this experiment's metadata and decide whether Harmony + should be evaluated. Return cell-QC candidates as tool evidence; + leave cellQc blank for the later audited filtering checkpoint. Study context: {study_context} + Study objective: {study_objective} Exact cell-selection artifact: {cell_selection} Exact quality-metric artifacts: {quality_metrics} Exact HTO identity artifacts: {hto_identities} @@ -2437,6 +2777,7 @@ def run( .strip() .format( study_context=study_context or "not provided", + study_objective=study_objective or "not provided", cell_selection=cell_selection.artifact_id, quality_metrics=json.dumps( [source.model_dump(mode="json") for source in quality_sources], @@ -2487,7 +2828,7 @@ def run( ) except UnexpectedModelBehavior as exc: model_name = getattr(self.model, "model_name", type(self.model).__name__) - return fallback_experimental_context_result( + return pending_experimental_context_result( deps, error=exc, model_name=str(model_name), @@ -2498,7 +2839,7 @@ def run( characterization = characterize_covariates( store, cellSelection=cell_selection, - studyContext=study_context, + studyContext=(f"{study_context}\nStudy objective: {study_objective}"), model=None, directions=direction_map, groupingArtifacts=_hto_artifact_map(deps), @@ -2511,7 +2852,7 @@ def run( status = "done" logger.info( "Experimental Context Agent completed: " - f"status={status}, cellQc={decision.cellQc.action}, " + f"status={status}, qcProfiles={len(deps.qcProfiles)}, " f"batchCorrection={decision.batchCorrection.action}, " f"coefficients={len(decision.coefficientsOfInterest)}, " f"toolCalls={len(deps.toolCalls)}, evidence={len(deps.evidenceIds)}" @@ -2521,7 +2862,7 @@ def run( decision=decision, characterization=characterization, cellSelection=artifact_reference(cell_selection), - cellQc=decision.cellQc, + cellQc=CellQcPlan.get_blank(), qcProfiles=list(deps.qcProfiles.values()), qualityMetricArtifacts=deps.qualityMetricArtifacts, htoIdentityColumns=deps.htoIdentityColumns, diff --git a/scarf/agent/hvg_diagnostics.py b/scarf/agent/hvg_diagnostics.py new file mode 100644 index 00000000..03fc0c42 --- /dev/null +++ b/scarf/agent/hvg_diagnostics.py @@ -0,0 +1,732 @@ +from collections.abc import Callable, Iterable, Mapping, Sequence +from dataclasses import dataclass +from typing import Any, Literal + +import numpy as np +import zarr + +from ..assay import RNAassay +from ..features.variability import fit_lowess +from ..storage.arrays import create_zarr_dataset +from ..storage.artifact_writer import ( + ArrayRequirement, + AttributeRequirement, + finish_artifact, + plan_artifact, + start_artifact, +) +from ..storage.artifacts import ( + ArtifactRef, + artifact_group, + fingerprint_array, + fingerprint_stored_arrays, +) +from ..storage.feature_selection import ( + _feature_selection_plan, + _feature_selection_values, + _ordered_feature_ids_fingerprint, + _write_feature_selection, + read_feature_selection_indices, +) +from ..storage.selections import ( + read_stored_selection_indices, + snapshot_run_metadata, + validate_run_metadata_snapshot, +) +from ..storage.types import as_zarr_array + +HVG_CANDIDATE_TARGETS = (1000, 2000, 4000) +_HVG_DIAGNOSTIC_ARRAYS = ( + "eligible", + "global_corrected_variance", + "recurrence", + "mean_within_group_rank", + "ranking", +) +_HVG_DIAGNOSTIC_VERSION = 1 + + +@dataclass(frozen=True, slots=True) +class HvgGroupVariability: + """One technical group's feature variability, streamed into aggregation.""" + + group_id: str + cell_count: int + corrected_variance: np.ndarray + detected_features: np.ndarray + + +@dataclass(frozen=True, slots=True) +class HvgRanking: + """Bounded feature-axis output of global or batch-aware HVG ranking.""" + + ranking_mode: Literal["global", "batchAware"] + eligible: np.ndarray + global_corrected_variance: np.ndarray + recurrence: np.ndarray + mean_within_group_rank: np.ndarray + ranking: np.ndarray + valid_group_count: int + candidate_counts: tuple[int, ...] + + @property + def eligible_feature_count(self) -> int: + return int(self.eligible.sum()) + + def candidate_mask(self, top_n: int) -> np.ndarray: + """Return one registered nested candidate, never an arbitrary count.""" + if top_n not in self.candidate_counts: + raise ValueError( + f"top_n must be one of the registered counts {self.candidate_counts}" + ) + values = np.zeros(self.eligible.shape, dtype=bool) + values[self.ranking[:top_n]] = True + return values + + +@dataclass(frozen=True, slots=True) +class HvgCandidateArtifact: + """One persisted candidate with its effective capped feature count.""" + + top_n: int + features: ArtifactRef + + +@dataclass(frozen=True, slots=True) +class HvgDiagnosticArtifacts: + """Persisted HVG diagnostic and its registered feature selections.""" + + diagnostic: ArtifactRef + ranking_mode: Literal["global", "batchAware"] + technical_group_column: str | None + valid_groups: tuple[str, ...] + excluded_groups: tuple[str, ...] + eligible_feature_count: int + candidates: tuple[HvgCandidateArtifact, ...] + + +def effective_hvg_candidate_counts( + eligible_feature_count: int, + targets: Sequence[int] = HVG_CANDIDATE_TARGETS, +) -> tuple[int, ...]: + """Cap registered HVG counts by eligibility and remove capped duplicates.""" + if isinstance(eligible_feature_count, bool) or not isinstance( + eligible_feature_count, int + ): + raise TypeError("eligible_feature_count must be an integer") + if eligible_feature_count < 1: + raise ValueError("eligible_feature_count must be greater than 0") + if isinstance(targets, str | bytes): + raise TypeError("targets must be a sequence of positive integers") + resolved: list[int] = [] + for target in targets: + if isinstance(target, bool) or not isinstance(target, int): + raise TypeError("HVG candidate targets must be integers") + if target < 1: + raise ValueError("HVG candidate targets must be greater than 0") + effective = min(target, eligible_feature_count) + if effective not in resolved: + resolved.append(effective) + if not resolved: + raise ValueError("At least one HVG candidate target is required") + return tuple(resolved) + + +def corrected_variance_from_summary( + summary: Mapping[str, np.ndarray], + *, + n_selected: int, + n_bins: int, + lowess_frac: float, +) -> np.ndarray: + """Derive LOWESS-corrected variability from feature-axis sufficient stats.""" + if isinstance(n_selected, bool) or not isinstance(n_selected, int): + raise TypeError("n_selected must be an integer") + if n_selected < 1: + raise ValueError("n_selected must be greater than 0") + required = ("normed_tot", "normed_n", "sigmas") + try: + normed_tot, normed_n, sigmas = ( + np.asarray(summary[name], dtype=np.float64) for name in required + ) + except KeyError as exc: + raise ValueError(f"RNA feature summary is missing {exc.args[0]!r}") from exc + shape = normed_tot.shape + if normed_tot.ndim != 1 or normed_n.shape != shape or sigmas.shape != shape: + raise ValueError("RNA feature-summary arrays must be aligned vectors") + if not all(np.isfinite(values).all() for values in (normed_tot, normed_n, sigmas)): + raise ValueError("RNA feature-summary arrays must contain only finite values") + + average = normed_tot / n_selected + corrected = np.zeros(shape, dtype=np.float64) + positive = (average > 0) & (sigmas > 0) + if positive.any(): + corrected[positive] = fit_lowess( + average[positive], + sigmas[positive], + n_bins, + lowess_frac, + bin_strategy="adaptive", + ) + if not np.isfinite(corrected).all() or (corrected < 0).any(): + raise ValueError("Corrected feature variability is invalid") + return corrected + + +def aggregate_hvg_rankings( + global_corrected_variance: np.ndarray, + eligible_features: np.ndarray, + group_variability: Iterable[HvgGroupVariability], + *, + valid_group_count: int, + candidate_targets: Sequence[int] = HVG_CANDIDATE_TARGETS, +) -> HvgRanking: + """Aggregate global and optional batch-aware feature rankings.""" + corrected = np.asarray(global_corrected_variance, dtype=np.float64) + eligible = np.asarray(eligible_features, dtype=bool) + if corrected.ndim != 1 or eligible.shape != corrected.shape: + raise ValueError("Global variability and eligibility must be aligned vectors") + if not np.isfinite(corrected).all() or (corrected < 0).any(): + raise ValueError("Global corrected variability must be finite and non-negative") + if isinstance(valid_group_count, bool) or not isinstance(valid_group_count, int): + raise TypeError("valid_group_count must be an integer") + if valid_group_count < 0: + raise ValueError("valid_group_count must be non-negative") + eligible_count = int(eligible.sum()) + counts = effective_hvg_candidate_counts(eligible_count, candidate_targets) + indices = np.flatnonzero(eligible) + global_order = indices[np.lexsort((indices, -corrected[indices]))].astype( + np.int64, copy=False + ) + recurrence = np.zeros(corrected.shape, dtype=np.int32) + mean_rank = np.full(corrected.shape, np.inf, dtype=np.float64) + + if valid_group_count < 2: + denominator = max(1, len(global_order)) + mean_rank[global_order] = ( + np.arange(1, len(global_order) + 1, dtype=np.float64) / denominator + ) + return HvgRanking( + ranking_mode="global", + eligible=eligible.copy(), + global_corrected_variance=corrected.copy(), + recurrence=recurrence, + mean_within_group_rank=mean_rank, + ranking=global_order.copy(), + valid_group_count=valid_group_count, + candidate_counts=counts, + ) + + rank_sum = np.zeros(corrected.shape, dtype=np.float64) + broad_count = max(counts) + received = 0 + for group in group_variability: + received += 1 + if received > valid_group_count: + raise ValueError("More group summaries were supplied than declared") + if not isinstance(group.group_id, str) or not group.group_id: + raise ValueError("Every valid technical group needs a non-empty ID") + if isinstance(group.cell_count, bool) or not isinstance(group.cell_count, int): + raise TypeError("Technical-group cell counts must be integers") + if group.cell_count < 1: + raise ValueError("Technical-group cell counts must be greater than 0") + group_corrected = np.asarray(group.corrected_variance, dtype=np.float64) + detected = np.asarray(group.detected_features, dtype=bool) + if ( + group_corrected.shape != corrected.shape + or detected.shape != corrected.shape + ): + raise ValueError("Technical-group feature arrays must align globally") + if not np.isfinite(group_corrected).all() or (group_corrected < 0).any(): + raise ValueError( + "Technical-group variability must be finite and non-negative" + ) + group_candidates = np.flatnonzero(eligible & detected) + if group_candidates.size == 0: + raise ValueError( + f"Valid technical group {group.group_id!r} has no rankable features" + ) + order = group_candidates[ + np.lexsort((group_candidates, -group_corrected[group_candidates])) + ] + selected = order[: min(broad_count, len(order))] + recurrence[selected] += 1 + rank_sum[selected] += np.arange(1, len(selected) + 1, dtype=np.float64) / len( + order + ) + if received != valid_group_count: + raise ValueError( + f"Expected {valid_group_count} group summaries but received {received}" + ) + observed = recurrence > 0 + mean_rank[observed] = rank_sum[observed] / recurrence[observed] + ranking = indices[ + np.lexsort( + ( + indices, + -corrected[indices], + mean_rank[indices], + -recurrence[indices], + ) + ) + ].astype(np.int64, copy=False) + return HvgRanking( + ranking_mode="batchAware", + eligible=eligible.copy(), + global_corrected_variance=corrected.copy(), + recurrence=recurrence, + mean_within_group_rank=mean_rank, + ranking=ranking.copy(), + valid_group_count=valid_group_count, + candidate_counts=counts, + ) + + +def _group_id(value: Any) -> str: + native = value.item() if isinstance(value, np.generic) else value + if isinstance(native, bool): + return f"bool:{str(native).lower()}" + if isinstance(native, int): + return f"int:{native}" + if isinstance(native, float): + if not np.isfinite(native): + raise ValueError("Non-finite technical-group values must be marked missing") + return f"float:{native.hex()}" + if isinstance(native, str): + return f"str:{native}" + raise TypeError( + "Technical-group values must be strings, booleans, integers, or floats" + ) + + +def _technical_groups( + root: zarr.Group, + snapshot: ArtifactRef, + column: str, + cell_indices: np.ndarray, + *, + min_group_cells: int, +) -> tuple[tuple[tuple[str, np.ndarray], ...], tuple[str, ...]]: + group = validate_run_metadata_snapshot( + root, + snapshot, + axis="cell", + assay=None, + table_path="cellData", + ordered_columns=(column,), + ) + values_array = as_zarr_array(group[column], name=column) + values = np.asarray(values_array[cell_indices]) + missing_name = values_array.attrs.get("missing_mask") + missing = ( + np.asarray( + as_zarr_array(group[missing_name], name=missing_name)[cell_indices], + dtype=bool, + ) + if isinstance(missing_name, str) + else np.zeros(len(cell_indices), dtype=bool) + ) + if values.dtype.kind == "f": + missing |= ~np.isfinite(values) + grouped: dict[str, list[int]] = {} + for cell_index, value, is_missing in zip( + cell_indices, + values, + missing, + strict=True, + ): + if is_missing: + continue + grouped.setdefault(_group_id(value), []).append(int(cell_index)) + valid: list[tuple[str, np.ndarray]] = [] + excluded: list[str] = [] + for group_id in sorted(grouped): + indices = grouped[group_id] + if len(indices) >= min_group_cells: + valid.append((group_id, np.asarray(indices, dtype=np.int64))) + else: + excluded.append(group_id) + return tuple(valid), tuple(excluded) + + +def _diagnostic_reuse_validator( + *, + n_features: int, + eligible_count: int, + ordered_feature_ids_fingerprint: str, +) -> Any: + def validate(_ref: ArtifactRef, group: zarr.Group) -> bool: + try: + if set(group.array_keys()) != set(_HVG_DIAGNOSTIC_ARRAYS): + return False + expected = { + "eligible": ((n_features,), np.dtype(bool)), + "global_corrected_variance": ((n_features,), np.dtype(np.float64)), + "recurrence": ((n_features,), np.dtype(np.int32)), + "mean_within_group_rank": ((n_features,), np.dtype(np.float64)), + "ranking": ((eligible_count,), np.dtype(np.int64)), + } + for name, (shape, dtype) in expected.items(): + array = as_zarr_array(group[name], name=name) + if array.shape != shape or np.dtype(array.dtype) != dtype: + return False + return group.attrs.get( + "ordered_feature_ids_fingerprint" + ) == ordered_feature_ids_fingerprint and group.attrs.get( + "payload_fingerprint" + ) == fingerprint_stored_arrays(group, _HVG_DIAGNOSTIC_ARRAYS) + except (KeyError, TypeError, ValueError): + return False + + return validate + + +def _ranking_mode_predicate( + mode: Literal["global", "batchAware"], +) -> Callable[[Any], bool]: + return lambda value: value == mode + + +def _write_hvg_diagnostic( + root: zarr.Group, + planned: Any, + ranking: HvgRanking, + *, + ordered_feature_ids_fingerprint: str, + valid_groups: tuple[str, ...], + excluded_groups: tuple[str, ...], +) -> None: + group = start_artifact(root, planned) + payload = { + "eligible": np.asarray(ranking.eligible, dtype=bool), + "global_corrected_variance": np.asarray( + ranking.global_corrected_variance, dtype=np.float64 + ), + "recurrence": np.asarray(ranking.recurrence, dtype=np.int32), + "mean_within_group_rank": np.asarray( + ranking.mean_within_group_rank, dtype=np.float64 + ), + "ranking": np.asarray(ranking.ranking, dtype=np.int64), + } + for name in _HVG_DIAGNOSTIC_ARRAYS: + values = payload[name] + chunks = (min(max(len(values), 1), 100_000),) + output = create_zarr_dataset(group, name, chunks, values.dtype, values.shape) + output[:] = values + group.attrs["ordered_feature_ids_fingerprint"] = ordered_feature_ids_fingerprint + group.attrs["payload_fingerprint"] = fingerprint_stored_arrays( + group, _HVG_DIAGNOSTIC_ARRAYS + ) + group.attrs["ranking_mode"] = ranking.ranking_mode + group.attrs["valid_groups"] = list(valid_groups) + group.attrs["excluded_groups"] = list(excluded_groups) + finish_artifact(group, planned) + + +def run_hvg_diagnostic_artifacts( + root: zarr.Group, + assay: RNAassay, + *, + cell_selection: ArtifactRef, + eligible_features: ArtifactRef, + all_features: ArtifactRef, + technical_group_column: str | None, + min_group_cells: int, + min_cells: int, + n_bins: int, + lowess_frac: float, + invalidate_cache: bool, + candidate_targets: Sequence[int] = HVG_CANDIDATE_TARGETS, +) -> tuple[HvgDiagnosticArtifacts, ...]: + """Run and persist global and eligible technical-group HVG rankings.""" + cell_indices = read_stored_selection_indices( + root, + cell_selection, + kind="cell_selection", + scope="datastore", + assay=None, + table_path="cellData", + ).astype(np.int64, copy=False) + if cell_indices.size == 0: + raise ValueError("cell_selection must select at least one cell") + n_features = int(assay.feats.N) + selected_feature_indices = read_feature_selection_indices( + root, + assay.name, + eligible_features, + ) + eligible_input = np.zeros(n_features, dtype=bool) + eligible_input[selected_feature_indices] = True + if not eligible_input.any(): + raise ValueError("eligible_features must select at least one feature") + if ( + len( + read_feature_selection_indices( + root, + assay.name, + all_features, + ) + ) + != n_features + ): + raise ValueError("all_features must select the complete feature universe") + + from ..assay.feature_summary import ensure_feature_summary, feature_summary_values + + global_summary_ref = ensure_feature_summary( + root, + assay, + cell_selection, + invalidate_cache=invalidate_cache, + ) + global_summary = feature_summary_values( + root, + global_summary_ref, + n_selected=len(cell_indices), + ) + global_corrected = corrected_variance_from_summary( + global_summary, + n_selected=len(cell_indices), + n_bins=n_bins, + lowess_frac=lowess_frac, + ) + detected_global = np.asarray(global_summary["normed_n"], dtype=np.float64) + eligible = eligible_input & (detected_global >= min_cells) + eligible_count = int(eligible.sum()) + candidate_counts = effective_hvg_candidate_counts( + eligible_count, + candidate_targets, + ) + + technical_snapshot: ArtifactRef | None = None + valid_group_rows: tuple[tuple[str, np.ndarray], ...] = () + excluded_groups: tuple[str, ...] = () + if technical_group_column is not None: + technical_snapshot = snapshot_run_metadata( + root, + table_path="cellData", + id_column="ids", + columns=(technical_group_column,), + axis="cell", + invalidate_cache=invalidate_cache, + ) + valid_group_rows, excluded_groups = _technical_groups( + root, + technical_snapshot, + technical_group_column, + cell_indices, + min_group_cells=min_group_cells, + ) + valid_groups = tuple(group_id for group_id, _indices in valid_group_rows) + ordered_feature_ids_fingerprint = _ordered_feature_ids_fingerprint(assay) + diagnostic_inputs: dict[str, Any] = { + "cell_selection": cell_selection, + "eligible_features": eligible_features, + "global_feature_summary": global_summary_ref, + } + if technical_snapshot is not None: + diagnostic_inputs["technical_group_snapshot"] = technical_snapshot + feature_indices = np.arange(n_features, dtype=np.int64) + group_variability: list[HvgGroupVariability] = [] + for group_id, group_cells in valid_group_rows: + summary = assay._compute_feature_summary(group_cells, feature_indices) + corrected = corrected_variance_from_summary( + summary, + n_selected=len(group_cells), + n_bins=n_bins, + lowess_frac=lowess_frac, + ) + group_variability.append( + HvgGroupVariability( + group_id=group_id, + cell_count=len(group_cells), + corrected_variance=corrected, + detected_features=( + np.asarray(summary["normed_n"], dtype=np.float64) >= min_cells + ), + ) + ) + sensitivity_ranking = aggregate_hvg_rankings( + global_corrected, + eligible, + group_variability, + valid_group_count=len(valid_group_rows), + candidate_targets=candidate_targets, + ) + eligible_indices = np.flatnonzero(eligible) + global_order = eligible_indices[ + np.lexsort((eligible_indices, -global_corrected[eligible_indices])) + ].astype(np.int64, copy=False) + global_ranking = HvgRanking( + ranking_mode="global", + eligible=sensitivity_ranking.eligible, + global_corrected_variance=sensitivity_ranking.global_corrected_variance, + recurrence=sensitivity_ranking.recurrence, + mean_within_group_rank=sensitivity_ranking.mean_within_group_rank, + ranking=global_order, + valid_group_count=sensitivity_ranking.valid_group_count, + candidate_counts=sensitivity_ranking.candidate_counts, + ) + rankings = [global_ranking] + if sensitivity_ranking.ranking_mode == "batchAware": + rankings.append(sensitivity_ranking) + + results: list[HvgDiagnosticArtifacts] = [] + for ranking in rankings: + parameters = { + "algorithm_version": _HVG_DIAGNOSTIC_VERSION, + "candidate_counts": list(candidate_counts), + "min_cells": min_cells, + "min_group_cells": min_group_cells, + "n_bins": n_bins, + "lowess_frac": lowess_frac, + "ranking_mode": ranking.ranking_mode, + "technical_group_column": technical_group_column, + } + planned = plan_artifact( + root, + scope="assay", + assay=assay.name, + kind="feature_summary", + operation="diagnose_hvg_candidates", + parameters=parameters, + inputs=diagnostic_inputs, + execution_options={"nthreads": assay.nthreads}, + invalidate_cache=invalidate_cache, + required_arrays=( + ArrayRequirement("eligible", shape=(n_features,), dtype=bool), + ArrayRequirement( + "global_corrected_variance", + shape=(n_features,), + dtype=np.float64, + ), + ArrayRequirement("recurrence", shape=(n_features,), dtype=np.int32), + ArrayRequirement( + "mean_within_group_rank", + shape=(n_features,), + dtype=np.float64, + ), + ArrayRequirement("ranking", shape=(eligible_count,), dtype=np.int64), + ), + required_attributes=( + AttributeRequirement( + "ordered_feature_ids_fingerprint", expected_types=(str,) + ), + AttributeRequirement("payload_fingerprint", expected_types=(str,)), + AttributeRequirement( + "ranking_mode", + expected_types=(str,), + predicate=_ranking_mode_predicate(ranking.ranking_mode), + ), + AttributeRequirement("valid_groups", expected_types=(list,)), + AttributeRequirement("excluded_groups", expected_types=(list,)), + ), + reuse_validator=_diagnostic_reuse_validator( + n_features=n_features, + eligible_count=eligible_count, + ordered_feature_ids_fingerprint=ordered_feature_ids_fingerprint, + ), + ) + if not planned.reused: + _write_hvg_diagnostic( + root, + planned, + ranking, + ordered_feature_ids_fingerprint=ordered_feature_ids_fingerprint, + valid_groups=valid_groups, + excluded_groups=excluded_groups, + ) + else: + diagnostic_group = artifact_group(root, planned.ref) + ranking = HvgRanking( + ranking_mode=ranking.ranking_mode, + eligible=np.asarray( + as_zarr_array(diagnostic_group["eligible"], name="eligible")[:], + dtype=bool, + ), + global_corrected_variance=np.asarray( + as_zarr_array( + diagnostic_group["global_corrected_variance"], + name="global_corrected_variance", + )[:], + dtype=np.float64, + ), + recurrence=np.asarray( + as_zarr_array( + diagnostic_group["recurrence"], + name="recurrence", + )[:], + dtype=np.int32, + ), + mean_within_group_rank=np.asarray( + as_zarr_array( + diagnostic_group["mean_within_group_rank"], + name="mean_within_group_rank", + )[:], + dtype=np.float64, + ), + ranking=np.asarray( + as_zarr_array(diagnostic_group["ranking"], name="ranking")[:], + dtype=np.int64, + ), + valid_group_count=len(valid_groups), + candidate_counts=candidate_counts, + ) + + candidates: list[HvgCandidateArtifact] = [] + for top_n in candidate_counts: + values = ranking.candidate_mask(top_n) + values_fingerprint = fingerprint_array(values) + selection_plan = _feature_selection_plan( + root, + assay=assay.name, + n_features=n_features, + ordered_feature_ids_fingerprint=ordered_feature_ids_fingerprint, + operation="set_feature_selection", + parameters={"values_fingerprint": values_fingerprint}, + inputs={ + "all_features": all_features, + }, + execution_options={"invalidate_cache": invalidate_cache}, + expected_payload_fingerprint=values_fingerprint, + invalidate_cache=invalidate_cache, + ) + if selection_plan.reused: + stored = np.asarray( + _feature_selection_values(root, selection_plan.ref), dtype=bool + ) + if not np.array_equal(stored, values): + selection_plan = selection_plan.invalidated(root) + _write_feature_selection( + root, + selection_plan, + ordered_feature_ids_fingerprint=ordered_feature_ids_fingerprint, + payload={"values": values}, + ) + candidates.append(HvgCandidateArtifact(top_n, selection_plan.ref)) + + results.append( + HvgDiagnosticArtifacts( + diagnostic=planned.ref, + ranking_mode=ranking.ranking_mode, + technical_group_column=technical_group_column, + valid_groups=valid_groups, + excluded_groups=excluded_groups, + eligible_feature_count=eligible_count, + candidates=tuple(candidates), + ) + ) + return tuple(results) + + +__all__ = [ + "HVG_CANDIDATE_TARGETS", + "HvgCandidateArtifact", + "HvgDiagnosticArtifacts", + "HvgGroupVariability", + "HvgRanking", + "aggregate_hvg_rankings", + "corrected_variance_from_summary", + "effective_hvg_candidate_counts", + "run_hvg_diagnostic_artifacts", +] diff --git a/scarf/agent/ingest/__init__.py b/scarf/agent/ingest/__init__.py index 31c90b48..34a52f86 100644 --- a/scarf/agent/ingest/__init__.py +++ b/scarf/agent/ingest/__init__.py @@ -9,6 +9,12 @@ from .detect import detect_format from .h5ad import ingest_h5ad from .loom import ingest_loom +from .manifest import ( + DatasetManifest, + DatasetManifestDecision, + inspect_h5ad_manifest, + is_author_label_column, +) from .mtx import ingest_mtx from .result import IngestResult, needs_input from .seurat import ingest_seurat @@ -16,8 +22,12 @@ __all__ = [ "IngestResult", + "DatasetManifest", + "DatasetManifestDecision", "detect_format", "ingest", + "inspect_h5ad_manifest", + "is_author_label_column", ] diff --git a/scarf/agent/ingest/manifest.py b/scarf/agent/ingest/manifest.py new file mode 100644 index 00000000..d6a00490 --- /dev/null +++ b/scarf/agent/ingest/manifest.py @@ -0,0 +1,985 @@ +"""Read-only H5AD inventory for decision-driven RNA workflows.""" + +from collections.abc import Iterator +from hashlib import sha256 +from pathlib import Path +from typing import Any, Literal, cast + +import h5py +import numpy as np + +from ...readers._h5ad_inspect import ( + _MatrixCandidate, + _as_text, + _column_names, + _matrix_candidates, + _node_length, + _select_matrix, + inspect_h5ad, +) +from ..config._deps import AGENT_INSTALL_HINT +from ..types import AgentDataModel + +try: + from pydantic import Field +except ImportError as exc: + raise ImportError(AGENT_INSTALL_HINT) from exc + + +type AuthorLabelPolicy = Literal["holdout", "preservation"] +type ManifestStatus = Literal["supported", "needsInput", "abstained"] +type MatrixCountSemantics = Literal["integerLikeCounts", "nonIntegerValues"] + +_AUTHOR_LABEL_EXACT = frozenset( + { + "annotation", + "annotations", + "author_annotation", + "author_cell_type", + "author_cell_type_ontology_term_id", + "cell_type", + "cell_type_ontology_term_id", + "cluster", + "clusters", + "clustering", + "leiden", + "louvain", + } +) +_AUTHOR_LABEL_FRAGMENTS = ( + "annotation", + "cell_label", + "cell_type", + "celltype", + "cluster", + "leiden", + "louvain", +) +_OBS_SCHEMA_FIELDS = frozenset( + { + "assay", + "assay_ontology_term_id", + "development_stage", + "development_stage_ontology_term_id", + "disease", + "disease_ontology_term_id", + "donor_id", + "is_primary_data", + "organism", + "organism_ontology_term_id", + "self_reported_ethnicity", + "self_reported_ethnicity_ontology_term_id", + "sex", + "suspension_type", + "tissue", + "tissue_ontology_term_id", + } +) +_VAR_SCHEMA_FIELDS = frozenset( + { + "feature_biotype", + "feature_is_filtered", + "feature_length", + "feature_name", + "feature_reference", + "feature_type", + "feature_types", + "gene_id", + "gene_ids", + "gene_name", + "gene_symbol", + } +) +_ANALYSIS_KEY_FRAGMENTS = ( + "cluster", + "leiden", + "louvain", + "marker", + "neighbors", + "pca", + "rank_gene", + "tsne", + "umap", +) +_DEFAULT_CHUNK_VALUES = 8_192 +_DEFAULT_MAX_COLUMNS = 256 +_DEFAULT_MAX_DOMAIN_VALUES = 16 +_DEFAULT_MAX_INVENTORY_ITEMS = 256 +_DIGEST_CHUNK_BYTES = 8 * 1024 * 1024 + + +class MatrixCandidateManifest(AgentDataModel): + """One dimension-compatible matrix observed in the source file.""" + + key: str + encoding: Literal["csr", "csc", "dense"] + nCells: int + nFeatures: int + integerLike: bool + countSemantics: MatrixCountSemantics + dimensionCompatible: bool + featureMetadataKey: str | None = None + selected: bool = False + + +class MetadataColumnSummary(AgentDataModel): + """Bounded summary of one H5AD dataframe column.""" + + name: str + dtype: str + storageKind: Literal["categorical", "boolean", "numeric", "string", "other"] + valueCount: int + missingCount: int + missingFraction: float + domainSize: int | None = None + domainValues: list[str] = Field(default_factory=list) + domainTruncated: bool = False + valueCounts: dict[str, int] = Field(default_factory=dict) + minimum: float | None = None + maximum: float | None = None + schemaField: bool = False + + +class MetadataTableSummary(AgentDataModel): + """Column inventory for an H5AD dataframe-like node.""" + + key: str + rowCount: int + columns: list[MetadataColumnSummary] = Field(default_factory=list) + schemaFields: list[str] = Field(default_factory=list) + identifierColumns: list[str] = Field(default_factory=list) + omittedColumnCount: int = 0 + heldOutAuthorColumnCount: int = 0 + + +class H5adInventory(AgentDataModel): + """Bounded top-level inventory of reusable and author-produced assets.""" + + layers: list[str] = Field(default_factory=list) + obsm: list[str] = Field(default_factory=list) + uns: list[str] = Field(default_factory=list) + priorEmbeddings: list[str] = Field(default_factory=list) + authorAnalysisArtifacts: list[str] = Field(default_factory=list) + omittedLayerCount: int = 0 + omittedObsmCount: int = 0 + omittedUnsCount: int = 0 + heldOutUnsItemCount: int = 0 + + +class PriorFilteringFacts(AgentDataModel): + """Facts and limitations imposed by the published H5AD cell universe.""" + + cellXGeneSchemaDetected: bool + cellXGeneSchemaVersion: str | None = None + cellXGeneSchemaReference: str | None = None + cellSetStatus: Literal["publishedCellsOnly", "unknown"] + rawCountsAvailable: bool + originalDropletsAvailable: bool = False + originalCellCallingSupported: bool = False + ambientCorrectionSupported: bool = False + featureFilteringFlagAvailable: bool = False + filteredFeatureCount: int | None = None + primaryDataFlagAvailable: bool = False + nonPrimaryCellCount: int | None = None + limitations: list[str] = Field(default_factory=list) + + +class DatasetManifestDecision(AgentDataModel): + """Eligibility result for the count-dependent RNA decision workflow.""" + + status: ManifestStatus + reasonCode: Literal[ + "rawRnaCountsAvailable", + "ambiguousCountMatrices", + "normalizedOnly", + "rnaModalityUnavailable", + ] + summary: str + selectedMatrixKey: str | None = None + options: list[str] = Field(default_factory=list) + evidenceIds: list[str] = Field(default_factory=list) + + +class DatasetManifest(AgentDataModel): + """Read-only evidence collected before materializing an H5AD source.""" + + formatVersion: Literal[1] = 1 + sourcePath: str + sourceUri: str | None = None + sourceSha256: str + sourceSizeBytes: int + authorLabelPolicy: AuthorLabelPolicy + labelBenchmarkEligible: bool + nCells: int + nFeatures: int + selectedFeatureMetadataKey: str | None = None + matrixCandidates: list[MatrixCandidateManifest] = Field(default_factory=list) + obs: MetadataTableSummary + var: MetadataTableSummary + rawVar: MetadataTableSummary | None = None + assayMetadata: MetadataColumnSummary | None = None + suspensionMetadata: MetadataColumnSummary | None = None + organismMetadata: MetadataColumnSummary | None = None + inventory: H5adInventory + priorFiltering: PriorFilteringFacts + decision: DatasetManifestDecision + + +def _source_sha256(path: Path) -> str: + digest = sha256() + with path.open("rb") as source: + while chunk := source.read(_DIGEST_CHUNK_BYTES): + digest.update(chunk) + return digest.hexdigest() + + +def _is_author_label(name: str) -> bool: + normalized = name.strip().lower() + return normalized in _AUTHOR_LABEL_EXACT or any( + fragment in normalized for fragment in _AUTHOR_LABEL_FRAGMENTS + ) + + +def is_author_label_column(name: str) -> bool: + """Return whether a metadata column is quarantined as an author label.""" + return _is_author_label(name) + + +def _is_missing(values: np.ndarray) -> np.ndarray: + if values.dtype.kind in {"f", "c"}: + return cast(np.ndarray, ~np.isfinite(values)) + if values.dtype.kind in {"S", "U"}: + return np.asarray([not _as_text(value) for value in values], dtype=bool) + if values.dtype.kind != "O": + return np.zeros(values.shape, dtype=bool) + return np.asarray( + [ + value is None + or (isinstance(value, float | np.floating) and not np.isfinite(value)) + or ( + isinstance(value, str | bytes | np.str_ | np.bytes_) + and not _as_text(value) + ) + for value in values + ], + dtype=bool, + ) + + +def _value_text(value: Any) -> str: + if isinstance(value, bool | np.bool_): + return "true" if bool(value) else "false" + if isinstance(value, bytes | np.bytes_): + return value.decode("utf-8", errors="replace") + if isinstance(value, np.integer): + return str(int(value)) + if isinstance(value, np.floating): + return repr(float(value)) + return str(value) + + +def _storage_kind( + dtype: np.dtype[Any], *, categorical: bool = False +) -> Literal["categorical", "boolean", "numeric", "string", "other"]: + if categorical: + return "categorical" + if np.issubdtype(dtype, np.bool_): + return "boolean" + if np.issubdtype(dtype, np.number): + return "numeric" + if dtype.kind in {"O", "S", "U"}: + return "string" + return "other" + + +def _dataset_value_chunks( + dataset: h5py.Dataset, + *, + field: str | None, + row_count: int, + chunk_values: int, +) -> Iterator[np.ndarray]: + for start in range(0, row_count, chunk_values): + stop = min(row_count, start + chunk_values) + raw = np.asarray(dataset[start:stop]) + yield np.asarray(raw[field] if field is not None else raw).reshape(-1) + + +def _column_source( + table: h5py.Group | h5py.Dataset, + name: str, + *, + row_count: int, + chunk_values: int, +) -> tuple[Iterator[np.ndarray], np.dtype[Any], h5py.Dataset | None]: + if isinstance(table, h5py.Dataset): + if table.dtype.names is None or name not in table.dtype.names: + raise ValueError(f"Column {name!r} is unavailable in {table.name}") + dtype = table.dtype.fields[name][0] + return ( + _dataset_value_chunks( + table, + field=name, + row_count=row_count, + chunk_values=chunk_values, + ), + dtype, + None, + ) + + node = table.get(name) + if isinstance(node, h5py.Dataset): + categories: h5py.Dataset | None = None + for category_group_name in ("__categories", "categories"): + category_group = table.get(category_group_name) + if isinstance(category_group, h5py.Group): + category_node = category_group.get(name) + if isinstance(category_node, h5py.Dataset): + categories = category_node + break + return ( + _dataset_value_chunks( + node, + field=None, + row_count=row_count, + chunk_values=chunk_values, + ), + node.dtype, + categories, + ) + if isinstance(node, h5py.Group): + codes = node.get("codes") + categories = node.get("categories") + if isinstance(codes, h5py.Dataset) and isinstance(categories, h5py.Dataset): + return ( + _dataset_value_chunks( + codes, + field=None, + row_count=row_count, + chunk_values=chunk_values, + ), + codes.dtype, + categories, + ) + raise ValueError(f"Unsupported H5AD column encoding for {table.name}/{name}") + + +def _categorical_summary( + *, + name: str, + chunks: Iterator[np.ndarray], + dtype: np.dtype[Any], + categories: h5py.Dataset, + row_count: int, + max_domain_values: int, + schema_field: bool, +) -> MetadataColumnSummary: + category_count = int(categories.shape[0]) + shown_count = min(category_count, max_domain_values) + shown = [_value_text(value) for value in np.asarray(categories[:shown_count])] + counts = np.zeros(shown_count, dtype=np.int64) + missing_count = 0 + for values in chunks: + codes = np.asarray(values, dtype=np.int64) + valid = (codes >= 0) & (codes < category_count) + missing_count += int((~valid).sum()) + shown_codes = codes[valid & (codes < shown_count)] + if shown_codes.size: + counts += np.bincount(shown_codes, minlength=shown_count) + value_counts = ( + {value: int(count) for value, count in zip(shown, counts, strict=True)} + if category_count <= max_domain_values + else {} + ) + return MetadataColumnSummary( + name=name, + dtype=str(dtype), + storageKind="categorical", + valueCount=row_count, + missingCount=missing_count, + missingFraction=missing_count / row_count if row_count else 0.0, + domainSize=category_count, + domainValues=shown, + domainTruncated=category_count > max_domain_values, + valueCounts=value_counts, + schemaField=schema_field, + ) + + +def _plain_summary( + *, + name: str, + chunks: Iterator[np.ndarray], + dtype: np.dtype[Any], + row_count: int, + max_domain_values: int, + schema_field: bool, +) -> MetadataColumnSummary: + missing_count = 0 + observed_counts: dict[str, int] = {} + domain_truncated = False + minimum: float | None = None + maximum: float | None = None + numeric = np.issubdtype(dtype, np.number) and not np.issubdtype(dtype, np.bool_) + for values in chunks: + missing = _is_missing(values) + missing_count += int(missing.sum()) + observed = values[~missing] + if numeric and observed.size: + observed_float = np.asarray(observed, dtype=np.float64) + block_minimum = float(observed_float.min()) + block_maximum = float(observed_float.max()) + minimum = block_minimum if minimum is None else min(minimum, block_minimum) + maximum = block_maximum if maximum is None else max(maximum, block_maximum) + if domain_truncated: + continue + for value in observed: + normalized = _value_text(value) + observed_counts[normalized] = observed_counts.get(normalized, 0) + 1 + if len(observed_counts) > max_domain_values: + observed_counts.clear() + domain_truncated = True + break + domain_values = sorted(observed_counts) + return MetadataColumnSummary( + name=name, + dtype=str(dtype), + storageKind=_storage_kind(dtype), + valueCount=row_count, + missingCount=missing_count, + missingFraction=missing_count / row_count if row_count else 0.0, + domainSize=None if domain_truncated else len(domain_values), + domainValues=domain_values, + domainTruncated=domain_truncated, + valueCounts={} if domain_truncated else observed_counts, + minimum=minimum, + maximum=maximum, + schemaField=schema_field, + ) + + +def _summarize_column( + table: h5py.Group | h5py.Dataset, + name: str, + *, + row_count: int, + chunk_values: int, + max_domain_values: int, + schema_fields: frozenset[str], +) -> MetadataColumnSummary: + chunks, dtype, categories = _column_source( + table, + name, + row_count=row_count, + chunk_values=chunk_values, + ) + if categories is not None: + return _categorical_summary( + name=name, + chunks=chunks, + dtype=dtype, + categories=categories, + row_count=row_count, + max_domain_values=max_domain_values, + schema_field=name.lower() in schema_fields, + ) + return _plain_summary( + name=name, + chunks=chunks, + dtype=dtype, + row_count=row_count, + max_domain_values=max_domain_values, + schema_field=name.lower() in schema_fields, + ) + + +def _index_columns(table: h5py.Group | h5py.Dataset) -> set[str]: + if isinstance(table, h5py.Dataset): + return {"_index", "index"} & set(table.dtype.names or ()) + names = {"_index", "index"} & set(table.keys()) + index_attribute = table.attrs.get("_index") + if index_attribute is not None: + names.add(_as_text(index_attribute)) + return names + + +def _summarize_table( + h5: h5py.File, + key: str, + *, + author_label_policy: AuthorLabelPolicy, + schema_fields: frozenset[str], + chunk_values: int, + max_columns: int, + max_domain_values: int, +) -> MetadataTableSummary: + node = h5.get(key) + if not isinstance(node, h5py.Group | h5py.Dataset): + return MetadataTableSummary(key=key, rowCount=0) + row_count = _node_length(node) or 0 + names = sorted(_column_names(node)) + identifiers = _index_columns(node) + held_out = { + name + for name in names + if author_label_policy == "holdout" and _is_author_label(name) + } + inspectable = [ + name for name in names if name not in identifiers and name not in held_out + ] + inspectable.sort(key=lambda name: (name.lower() not in schema_fields, name)) + selected = inspectable[:max_columns] + columns = [ + _summarize_column( + node, + name, + row_count=row_count, + chunk_values=chunk_values, + max_domain_values=max_domain_values, + schema_fields=schema_fields, + ) + for name in selected + ] + return MetadataTableSummary( + key=key, + rowCount=row_count, + columns=columns, + schemaFields=sorted(summary.name for summary in columns if summary.schemaField), + identifierColumns=sorted(identifiers), + omittedColumnCount=max(0, len(inspectable) - len(selected)), + heldOutAuthorColumnCount=len(held_out), + ) + + +def _inventory_keys( + h5: h5py.File, + key: str, + *, + max_items: int, + hide_author_labels: bool = False, +) -> tuple[list[str], int, int]: + node = h5.get(key) + if not isinstance(node, h5py.Group): + return [], 0, 0 + names = sorted(str(name) for name in node.keys()) + held_out = [name for name in names if hide_author_labels and _is_author_label(name)] + visible = [name for name in names if name not in held_out] + return visible[:max_items], max(0, len(visible) - max_items), len(held_out) + + +def _read_text_scalar( + h5: h5py.File, + paths: tuple[str, ...], + *, + max_length: int = 500, +) -> str | None: + for path in paths: + node = h5.get(path) + if not isinstance(node, h5py.Dataset) or node.shape not in {(), (1,)}: + continue + value = node[()] if node.shape == () else node[0] + return _as_text(value)[:max_length] + return None + + +def _column_by_name( + *tables: MetadataTableSummary | None, + names: tuple[str, ...], +) -> MetadataColumnSummary | None: + wanted = {name.lower() for name in names} + for table in tables: + if table is None: + continue + for column in table.columns: + if column.name.lower() in wanted: + return column + return None + + +def _count_value( + column: MetadataColumnSummary | None, + value: str, +) -> int | None: + if column is None or not column.valueCounts: + return None + return column.valueCounts.get(value, 0) + + +def _matrix_manifests( + h5: h5py.File, + *, + selected_key: str | None, +) -> tuple[list[MatrixCandidateManifest], list[_MatrixCandidate]]: + manifests: list[MatrixCandidateManifest] = [] + compatible: list[_MatrixCandidate] = [] + for candidate in _matrix_candidates(h5): + if candidate.encoding not in {"csr", "csc", "dense"}: + raise ValueError( + f"Unsupported matrix encoding for {candidate.key}: {candidate.encoding}" + ) + encoding = cast(Literal["csr", "csc", "dense"], candidate.encoding) + feature_key: str | None = None + dimension_compatible = False + try: + _, feature_key = _select_matrix(h5, [candidate]) + dimension_compatible = True + compatible.append(candidate) + except ValueError: + pass + manifests.append( + MatrixCandidateManifest( + key=candidate.key, + encoding=encoding, + nCells=candidate.shape[0], + nFeatures=candidate.shape[1], + integerLike=candidate.integerLike, + countSemantics=( + "integerLikeCounts" if candidate.integerLike else "nonIntegerValues" + ), + dimensionCompatible=dimension_compatible, + featureMetadataKey=feature_key, + selected=candidate.key == selected_key, + ) + ) + return manifests, compatible + + +def _matrix_decision( + compatible: list[_MatrixCandidate], + *, + matrix_key: str | None, +) -> tuple[DatasetManifestDecision, str | None]: + by_key = {candidate.key: candidate for candidate in compatible} + if matrix_key is not None: + candidate = by_key.get(matrix_key) + if candidate is None: + available = ", ".join(sorted(by_key)) + raise ValueError( + f"matrix_key {matrix_key!r} is not dimension-compatible. " + f"Available: {available}" + ) + if not candidate.integerLike: + return ( + DatasetManifestDecision( + status="abstained", + reasonCode="normalizedOnly", + summary=( + f"Selected matrix {matrix_key} is not integer-like and " + "cannot authorize count-dependent RNA analysis" + ), + options=[matrix_key], + evidenceIds=[f"matrix:{matrix_key}:nonIntegerValues"], + ), + None, + ) + return ( + DatasetManifestDecision( + status="supported", + reasonCode="rawRnaCountsAvailable", + summary=f"Selected integer-like count candidate {matrix_key}", + selectedMatrixKey=matrix_key, + options=[matrix_key], + evidenceIds=[f"matrix:{matrix_key}:integerLikeCounts"], + ), + matrix_key, + ) + + integer_keys = sorted( + candidate.key for candidate in compatible if candidate.integerLike + ) + if not integer_keys: + non_integer_keys = sorted(candidate.key for candidate in compatible) + return ( + DatasetManifestDecision( + status="abstained", + reasonCode="normalizedOnly", + summary=( + "No dimension-compatible integer-like count matrix is " + "available in this H5AD" + ), + options=non_integer_keys, + evidenceIds=[ + f"matrix:{key}:nonIntegerValues" for key in non_integer_keys + ], + ), + None, + ) + if "raw/X" in integer_keys: + conflicting_keys = [key for key in integer_keys if key not in {"raw/X", "X"}] + if not conflicting_keys: + return ( + DatasetManifestDecision( + status="supported", + reasonCode="rawRnaCountsAvailable", + summary=( + "Selected authoritative integer-like raw/X count matrix " + "over the visualization matrix X" + ), + selectedMatrixKey="raw/X", + options=["raw/X"], + evidenceIds=["matrix:raw/X:integerLikeCounts"], + ), + "raw/X", + ) + if len(integer_keys) > 1: + return ( + DatasetManifestDecision( + status="needsInput", + reasonCode="ambiguousCountMatrices", + summary=( + "Multiple dimension-compatible integer-like matrices are " + "available; select the intended raw count matrix" + ), + options=integer_keys, + evidenceIds=[f"matrix:{key}:integerLikeCounts" for key in integer_keys], + ), + None, + ) + selected_key = integer_keys[0] + return ( + DatasetManifestDecision( + status="supported", + reasonCode="rawRnaCountsAvailable", + summary=f"One integer-like count candidate is available: {selected_key}", + selectedMatrixKey=selected_key, + options=[selected_key], + evidenceIds=[f"matrix:{selected_key}:integerLikeCounts"], + ), + selected_key, + ) + + +def _inventory( + h5: h5py.File, + *, + author_label_policy: AuthorLabelPolicy, + max_items: int, +) -> H5adInventory: + layers, omitted_layers, _ = _inventory_keys(h5, "layers", max_items=max_items) + obsm, omitted_obsm, _ = _inventory_keys(h5, "obsm", max_items=max_items) + uns, omitted_uns, held_out_uns = _inventory_keys( + h5, + "uns", + max_items=max_items, + hide_author_labels=author_label_policy == "holdout", + ) + prior_embeddings = [ + f"obsm/{name}" + for name in obsm + if name.lower().startswith("x_") + or any(fragment in name.lower() for fragment in ("pca", "tsne", "umap")) + ] + analysis_artifacts = [ + f"{group}/{name}" + for group, names in (("obsm", obsm), ("uns", uns)) + for name in names + if any(fragment in name.lower() for fragment in _ANALYSIS_KEY_FRAGMENTS) + ] + if held_out_uns: + analysis_artifacts.append(f"uns/heldOutAuthorItems:{held_out_uns}") + return H5adInventory( + layers=layers, + obsm=obsm, + uns=uns, + priorEmbeddings=prior_embeddings, + authorAnalysisArtifacts=analysis_artifacts, + omittedLayerCount=omitted_layers, + omittedObsmCount=omitted_obsm, + omittedUnsCount=omitted_uns, + heldOutUnsItemCount=held_out_uns, + ) + + +def inspect_h5ad_manifest( + path: str | Path, + *, + source_uri: str | None = None, + author_label_policy: AuthorLabelPolicy = "holdout", + matrix_key: str | None = None, + chunk_values: int = _DEFAULT_CHUNK_VALUES, + max_columns: int = _DEFAULT_MAX_COLUMNS, + max_domain_values: int = _DEFAULT_MAX_DOMAIN_VALUES, + max_inventory_items: int = _DEFAULT_MAX_INVENTORY_ITEMS, +) -> DatasetManifest: + """Inspect an H5AD without converting or modifying it. + + Matrix values are sampled only through :func:`inspect_h5ad`; metadata is + summarized in bounded chunks. Author cell-type and clustering columns are + not read when ``author_label_policy`` is ``"holdout"``. + """ + source = Path(path) + if not source.is_file(): + raise ValueError(f"H5AD source is not a file: {source}") + if author_label_policy not in {"holdout", "preservation"}: + raise ValueError( + "author_label_policy must be either 'holdout' or 'preservation'" + ) + if chunk_values < 1: + raise ValueError("chunk_values must be at least 1") + if max_columns < 1: + raise ValueError("max_columns must be at least 1") + if max_domain_values < 1: + raise ValueError("max_domain_values must be at least 1") + if max_inventory_items < 1: + raise ValueError("max_inventory_items must be at least 1") + + with h5py.File(source, mode="r") as h5: + matrix_candidates, compatible = _matrix_manifests(h5, selected_key=None) + if not compatible: + raise ValueError("No matrix candidate matches the obs and var dimensions") + decision, selected_key = _matrix_decision(compatible, matrix_key=matrix_key) + if selected_key is not None: + inspection = inspect_h5ad(str(source), matrix_key=selected_key) + else: + inspection = inspect_h5ad(str(source)) + matrix_candidates, _ = _matrix_manifests(h5, selected_key=selected_key) + + obs = _summarize_table( + h5, + "obs", + author_label_policy=author_label_policy, + schema_fields=_OBS_SCHEMA_FIELDS, + chunk_values=chunk_values, + max_columns=max_columns, + max_domain_values=max_domain_values, + ) + var = _summarize_table( + h5, + "var", + author_label_policy="preservation", + schema_fields=_VAR_SCHEMA_FIELDS, + chunk_values=chunk_values, + max_columns=max_columns, + max_domain_values=max_domain_values, + ) + raw_var = ( + _summarize_table( + h5, + "raw/var", + author_label_policy="preservation", + schema_fields=_VAR_SCHEMA_FIELDS, + chunk_values=chunk_values, + max_columns=max_columns, + max_domain_values=max_domain_values, + ) + if isinstance(h5.get("raw/var"), h5py.Group | h5py.Dataset) + else None + ) + inventory = _inventory( + h5, + author_label_policy=author_label_policy, + max_items=max_inventory_items, + ) + schema_version = _read_text_scalar( + h5, + ("uns/schema_version", "uns/cellxgene_schema_version"), + ) + schema_reference = _read_text_scalar( + h5, + ("uns/schema_reference", "uns/cellxgene_schema_reference"), + ) + + selected_table = raw_var if inspection.featureAttrsKey == "raw/var" else var + assay = _column_by_name( + obs, + names=("assay_ontology_term_id", "assay"), + ) + suspension = _column_by_name(obs, names=("suspension_type",)) + organism = _column_by_name( + obs, + names=("organism_ontology_term_id", "organism"), + ) + feature_filtered = _column_by_name( + selected_table, + var, + raw_var, + names=("feature_is_filtered",), + ) + is_primary = _column_by_name(obs, names=("is_primary_data",)) + cxg_detected = schema_version is not None or ( + assay is not None + and suspension is not None + and organism is not None + and feature_filtered is not None + ) + limitations = [ + "The H5AD observation table contains only the published cell universe; " + "discarded barcodes and empty droplets are unavailable.", + "Original cell calling and empty-droplet ambient correction cannot be " + "reconstructed from this file.", + ] + if decision.status == "abstained" and decision.reasonCode == "normalizedOnly": + limitations.append( + "Count-dependent quality control and feature selection require an " + "unambiguous integer-like source matrix." + ) + prior_filtering = PriorFilteringFacts( + cellXGeneSchemaDetected=cxg_detected, + cellXGeneSchemaVersion=schema_version, + cellXGeneSchemaReference=schema_reference, + cellSetStatus="publishedCellsOnly" if cxg_detected else "unknown", + rawCountsAvailable=any( + candidate.integerLike and candidate.dimensionCompatible + for candidate in matrix_candidates + ), + featureFilteringFlagAvailable=feature_filtered is not None, + filteredFeatureCount=_count_value(feature_filtered, "true"), + primaryDataFlagAvailable=is_primary is not None, + nonPrimaryCellCount=_count_value(is_primary, "false"), + limitations=limitations, + ) + + if decision.status == "supported" and inspection.suggestedAssays: + if "RNA" not in inspection.suggestedAssays: + decision = DatasetManifestDecision( + status="abstained", + reasonCode="rnaModalityUnavailable", + summary="The selected count matrix contains no RNA feature span", + options=sorted(inspection.suggestedAssays), + evidenceIds=[ + f"assay:{name}:{count}" + for name, count in sorted(inspection.suggestedAssays.items()) + ], + ) + selected_key = None + for candidate in matrix_candidates: + candidate.selected = False + + return DatasetManifest( + sourcePath=str(source.resolve()), + sourceUri=source_uri, + sourceSha256=_source_sha256(source), + sourceSizeBytes=source.stat().st_size, + authorLabelPolicy=author_label_policy, + labelBenchmarkEligible=author_label_policy == "holdout", + nCells=inspection.nCells, + nFeatures=inspection.nFeatures, + selectedFeatureMetadataKey=( + inspection.featureAttrsKey if selected_key is not None else None + ), + matrixCandidates=matrix_candidates, + obs=obs, + var=var, + rawVar=raw_var, + assayMetadata=assay, + suspensionMetadata=suspension, + organismMetadata=organism, + inventory=inventory, + priorFiltering=prior_filtering, + decision=decision, + ) + + +__all__ = [ + "AuthorLabelPolicy", + "DatasetManifest", + "DatasetManifestDecision", + "H5adInventory", + "ManifestStatus", + "MatrixCandidateManifest", + "MetadataColumnSummary", + "MetadataTableSummary", + "PriorFilteringFacts", + "inspect_h5ad_manifest", + "is_author_label_column", +] diff --git a/scarf/agent/orchestrator/context.py b/scarf/agent/orchestrator/context.py index 5a9b77fb..dc659f94 100644 --- a/scarf/agent/orchestrator/context.py +++ b/scarf/agent/orchestrator/context.py @@ -19,11 +19,13 @@ NamedArtifactSource, ) from ..ingest import IngestResult +from ..ingest.manifest import DatasetManifest, is_author_label_column from ..persistence import ( AgentInvocation, AgentReportReference, AgentWorkflowRun, ) +from ..study_contract import build_study_contract from ..types import AgentRunInfo, ArtifactReferenceModel from . import journal from .models import ( @@ -114,6 +116,7 @@ def record_ingest_stage( workflow: AgentWorkflowRun, request_record: OrchestrationRequestRecord, ingest_result: IngestResult, + dataset_manifest: DatasetManifest | None = None, ) -> WorkflowStageAttempt: existing = journal._validated_done_outcome( store, @@ -159,6 +162,11 @@ def record_ingest_stage( else None ), "summary": ingest_result.summary, + "datasetManifest": ( + dataset_manifest.model_dump(mode="json") + if dataset_manifest is not None + else None + ), "operations": [ { "operation": "snapshot_cell_selection", @@ -230,6 +238,7 @@ def data_enrichment_stage( parents, inputs={ "studyContext": request.studyContext, + "studyObjective": request.studyObjective, "assays": selected_assays, "cellSelection": cell_selection.model_dump(mode="json"), "allowDownload": request_record.config.allowDownloads, @@ -240,7 +249,10 @@ def data_enrichment_stage( actions: list[str] = [] operations: list[dict[str, Any]] = [] try: - context_payload: dict[str, Any] = {"studyContext": request.studyContext} + context_payload: dict[str, Any] = { + "studyContext": request.studyContext, + "studyObjective": request.studyObjective, + } supplied_context = answers.get("dataEnrichmentContext") if isinstance(supplied_context, Mapping): context_payload.update(dict(supplied_context)) @@ -659,11 +671,57 @@ def experimental_context_stage( required_status="needsInput", ) directions = dict(request_record.request.experimentalDirections) + directions["registeredQcOnly"] = True supplied_directions = answers.get("experimentalDirections") if isinstance(supplied_directions, Mapping): directions.update(dict(supplied_directions)) elif isinstance(supplied_directions, str) and supplied_directions.strip(): directions["callerAnswer"] = supplied_directions.strip() + if request_record.request.authorLabelPolicy == "holdout": + held_out_columns = sorted( + column + for column in store.cells.columns + if is_author_label_column(column) + ) + existing_exclusions = directions.get("excludeColumns") + if existing_exclusions is None: + existing_exclusion_list: list[str] = [] + elif isinstance(existing_exclusions, list) and all( + isinstance(value, str) for value in existing_exclusions + ): + existing_exclusion_list = existing_exclusions + else: + raise ValueError("experimentalDirections.excludeColumns must be a list") + + referenced_held_out: set[str] = set() + + def find_held_out_references(value: Any) -> None: + if isinstance(value, str): + if value in held_out_columns: + referenced_held_out.add(value) + return + if isinstance(value, Mapping): + for nested in value.values(): + find_held_out_references(nested) + return + if isinstance(value, list | tuple | set): + for nested in value: + find_held_out_references(nested) + + for key, value in directions.items(): + if key != "excludeColumns": + find_held_out_references(value) + if referenced_held_out: + raise ValueError( + "authorLabelPolicy='holdout' forbids runtime use of author " + "annotation columns: " + ", ".join(sorted(referenced_held_out)) + ) + directions["excludeColumns"] = sorted( + { + *held_out_columns, + *existing_exclusion_list, + } + ) started = journal._start_attempt( store.zw, prefix, @@ -673,6 +731,7 @@ def experimental_context_stage( parents, inputs={ "studyContext": request_record.request.studyContext, + "studyObjective": request_record.request.studyObjective, "cellSelection": cell_selection.model_dump(mode="json"), "directions": directions, "qualityMetricArtifacts": [ @@ -834,6 +893,7 @@ def experimental_context_stage( report = agent.run( store, study_context=request_record.request.studyContext, + study_objective=request_record.request.studyObjective, cell_selection=cell_selection_ref, directions=directions, quality_metric_artifacts=quality_metric_artifacts, @@ -849,6 +909,7 @@ def experimental_context_stage( parentReports=parent_reports, inputs={ "studyContext": request_record.request.studyContext, + "studyObjective": request_record.request.studyObjective, "cellSelection": cell_selection.model_dump(mode="json"), "directions": directions, "qualityMetricArtifacts": [ @@ -942,6 +1003,24 @@ def experimental_context_stage( notes=report.notes, ) else: + physical_capture = directions.get("physicalCaptureColumn") + if not isinstance(physical_capture, str) or not physical_capture: + physical_capture = None + elif physical_capture not in { + *store.cells.columns, + *report.htoIdentityColumns, + }: + raise ValueError( + "physicalCaptureColumn must identify observed metadata or " + "an exact HTO identity" + ) + study_contract = build_study_contract( + study_context=request_record.request.studyContext, + study_objective=request_record.request.studyObjective, + experimental_result=report, + author_label_policy=(request_record.request.authorLabelPolicy), + physical_capture_column=physical_capture, + ) outcome = journal._complete_attempt( started, status="done", @@ -962,6 +1041,7 @@ def experimental_context_stage( for source in quality_metric_artifacts ], "metadataColumns": report.htoIdentityColumns, + "studyContract": study_contract.model_dump(mode="json"), }, actions=actions, notes=report.notes, diff --git a/scarf/agent/orchestrator/decisions.py b/scarf/agent/orchestrator/decisions.py new file mode 100644 index 00000000..a49c8319 --- /dev/null +++ b/scarf/agent/orchestrator/decisions.py @@ -0,0 +1,812 @@ +"""Shared resolution and persistence for RNA workflow decisions.""" + +import hashlib +import json +import time +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any + +from pydantic_ai.exceptions import AgentRunError + +from .. import record_io +from ..config.agent_exec import run_agent_sync +from ..decision_kernel import ( + DecisionRecord, + DecisionSelection, + DecisionSource, + DecisionWorkflowRun, + EvidenceBundle, + PendingDecision, + RevisionRequest, +) +from ..decision_persistence import ( + attach_audited_rna_decision, + load_latest_decision_workflow_snapshot, + pause_decision_workflow, + save_decision_workflow_snapshot, +) +from ..rna_decisions import ( + CompiledRnaDecision, + RnaDecisionDefinition, + compile_rna_decision, +) +from .models import OrchestrationRequestRecord, WorkflowQuestion + + +@dataclass(frozen=True, slots=True) +class DecisionResolution: + """Runtime result of resolving or pausing one exact checkpoint.""" + + workflow: DecisionWorkflowRun + record: DecisionRecord | None + compiled: CompiledRnaDecision | None + snapshotSha256: str + + @property + def pending(self) -> PendingDecision | None: + return self.workflow.pendingDecision + + +@dataclass(frozen=True, slots=True) +class DecisionReconsideration: + """Result of one downstream-evidence review of an active decision.""" + + selection: DecisionSelection | None + resolution: DecisionResolution | None + revised: bool + snapshotSha256: str + question: WorkflowQuestion | None = None + + +def _sha256(value: object) -> str: + return hashlib.sha256(record_io.canonical_json_bytes(value)).hexdigest() + + +def _software_sha256(definition: RnaDecisionDefinition) -> str: + return _sha256( + { + "controller": "rnaDecisionResolver", + "definition": definition.model_dump(mode="json"), + } + ) + + +def _selection_evidence_for_human( + definition: RnaDecisionDefinition, + evidence: EvidenceBundle, + option_id: str, +) -> list[str]: + option = definition.spec.option_by_id()[option_id] + required = set(option.requiredEvidenceClasses) + evidence_ids = [ + item.evidenceId + for item in evidence.evidence + if not required or item.evidenceClass in required + ] + return list(dict.fromkeys([*option.requiredEvidenceIds, *evidence_ids])) + + +def _validate_selection( + definition: RnaDecisionDefinition, + evidence: EvidenceBundle, + selection: DecisionSelection, +) -> DecisionSelection: + options = definition.spec.option_by_id() + if selection.selectedOptionId not in options: + raise ValueError("selectedOptionId is not an offered decision option") + available = evidence.evidence_by_id() + if not set(selection.evidenceIds).issubset(available): + raise ValueError("Decision selection cites unavailable evidence") + selected = options[selection.selectedOptionId] + cited_classes = { + available[evidence_id].evidenceClass for evidence_id in selection.evidenceIds + } + if not set(selected.requiredEvidenceClasses).issubset(cited_classes): + raise ValueError("Decision selection omits a required evidence class") + if not set(selected.requiredEvidenceIds).issubset(selection.evidenceIds): + raise ValueError("Decision selection omits option-specific evidence") + if selection.overrideOfOptionId is not None and ( + selection.overrideOfOptionId not in options + ): + raise ValueError("overrideOfOptionId is not an offered decision option") + if ( + definition.spec.requireIndependentOverrideEvidence + and definition.spec.metricPreferredOptionId is not None + and selection.selectedOptionId != definition.spec.metricPreferredOptionId + and selected.status in {"apply", "skip"} + ): + evidence_classes = { + available[evidence_id].evidenceClass + for evidence_id in selection.overrideEvidenceIds + if evidence_id in available + } + independent_classes = evidence_classes.intersection( + { + "markerCoherence", + "resamplingStability", + "crossUnitSupport", + "protectedVariablePreservation", + } + ) + if ( + selection.overrideOfOptionId != definition.spec.metricPreferredOptionId + or not set(selection.overrideEvidenceIds).issubset(selection.evidenceIds) + or ( + selected.requiredEvidenceIds + and not set(selection.overrideEvidenceIds).issubset( + selected.requiredEvidenceIds + ) + ) + or len(independent_classes) < 2 + ): + raise ValueError( + "A metric override requires two independent non-geometric " + "evidence classes" + ) + return selection + + +def _record_from_selection( + *, + workflow_run_id: str, + definition: RnaDecisionDefinition, + evidence: EvidenceBundle, + selection: DecisionSelection, + source: DecisionSource, + model_name: str | None, + prompt_sha256: str | None, + supersedes: str | None, + created_at_ns: int, +) -> DecisionRecord: + if evidence.contentSha256 is None: + raise ValueError("Decision evidence bundle requires a content checksum") + option = definition.spec.option_by_id()[selection.selectedOptionId] + identity = { + "workflowRunId": workflow_run_id, + "decisionId": definition.spec.decisionId, + "definitionVersion": definition.spec.definitionVersion, + "evidenceBundleId": evidence.bundleId, + "evidenceBundleSha256": evidence.contentSha256, + "selection": selection.model_dump(mode="json"), + "source": source, + "supersedes": supersedes, + } + record_id = f"decision:{definition.spec.decisionId}:{_sha256(identity)[:24]}" + return DecisionRecord( + recordId=record_id, + decisionId=definition.spec.decisionId, + definitionVersion=definition.spec.definitionVersion, + evidenceBundleId=evidence.bundleId, + evidenceBundleSha256=evidence.contentSha256, + offeredOptionIds=[offered.optionId for offered in definition.spec.options], + availableEvidenceIds=[item.evidenceId for item in evidence.evidence], + selectedOptionId=selection.selectedOptionId, + status=option.status, + source=source, + evidenceIds=list(selection.evidenceIds), + rationale=selection.rationale, + confidence=selection.confidence, + protectedVariableEffects=list(selection.protectedVariableEffects), + overrideOfOptionId=selection.overrideOfOptionId, + overrideEvidenceIds=list(selection.overrideEvidenceIds), + promptSha256=prompt_sha256, + modelName=model_name, + softwareSha256=_software_sha256(definition), + verificationId=f"verification:{record_id}", + supersedes=supersedes, + createdAtNs=created_at_ns, + ) + + +def _active_record( + workflow: DecisionWorkflowRun, + decision_id: str, +) -> DecisionRecord | None: + matches = [ + record + for record in workflow.active_decision_records() + if record.decisionId == decision_id + ] + if len(matches) > 1: + raise ValueError( + f"Decision workflow has multiple active {decision_id!r} records" + ) + return matches[0] if matches else None + + +class DecisionStagesMixin: + """Resolve every rule, agent, and human choice through one ledger path.""" + + model: Any + + def _load_or_create_decision_workflow( + self, + store: Any, + request_record: OrchestrationRequestRecord, + ) -> tuple[DecisionWorkflowRun, str]: + try: + snapshot = load_latest_decision_workflow_snapshot( + store, + request_record.workflowRunId, + workspace=request_record.request.workspace, + ) + except KeyError: + workflow = DecisionWorkflowRun( + workflowRunId=request_record.workflowRunId, + maxRevisions=request_record.config.maxRevisions, + ) + snapshot = save_decision_workflow_snapshot( + store, + workflow, + workspace=request_record.request.workspace, + ) + if snapshot.workflow.maxRevisions != request_record.config.maxRevisions: + raise ValueError( + "Persisted decision revision limit differs from the request" + ) + return snapshot.workflow, snapshot.contentSha256 + + def _reconsider_rna_decision( + self, + store: Any, + request_record: OrchestrationRequestRecord, + definition: RnaDecisionDefinition, + evidence: EvidenceBundle, + answers: Mapping[str, Any], + ) -> DecisionReconsideration: + """Select from new evidence, then create a revision only when it changes.""" + evidence = ( + evidence + if evidence.contentSha256 is not None + else evidence.with_content_sha256() + ) + if evidence.contentSha256 is None: + raise RuntimeError("Reconsideration evidence checksum was not created") + workflow, snapshot_sha256 = self._load_or_create_decision_workflow( + store, + request_record, + ) + target = _active_record(workflow, definition.spec.decisionId) + if target is None: + raise ValueError("Reconsideration requires an active target decision") + question_id = f"decision:{definition.spec.decisionId}Review" + raw_answer = answers.get(question_id) + source: DecisionSource + model_name: str | None = None + if raw_answer is not None: + if not isinstance(raw_answer, Mapping): + raise ValueError("Human reconsideration answer must be a mapping") + if set(raw_answer) != {"decisionId", "optionId", "rationale"}: + raise ValueError( + "Human reconsideration answer requires decisionId, optionId, " + "and rationale" + ) + if raw_answer.get("decisionId") != definition.spec.decisionId: + raise ValueError("Human reconsideration answer has a stale decisionId") + option_id = raw_answer.get("optionId") + rationale = raw_answer.get("rationale") + if not isinstance(option_id, str) or not isinstance(rationale, str): + raise ValueError("Human reconsideration answer has invalid values") + selection = _validate_selection( + definition, + evidence, + DecisionSelection( + selectedOptionId=option_id, + evidenceIds=_selection_evidence_for_human( + definition, + evidence, + option_id, + ), + rationale=rationale.strip(), + confidence="notApplicable", + ), + ) + source = "human" + else: + payload = { + "decisionId": definition.spec.decisionId, + "studyObjective": request_record.request.studyObjective, + "question": definition.spec.question, + "baselineOptionId": definition.spec.baselineOptionId, + "options": [ + option.model_dump(mode="json") for option in definition.spec.options + ], + "evidence": [ + item.model_dump(mode="json") for item in evidence.evidence + ], + } + user_prompt = json.dumps(payload, indent=2, sort_keys=True) + try: + execution = run_agent_sync( + model=self.model, + output_type=DecisionSelection, + system_prompt=( + "Select whether the active feature policy should remain " + "unchanged or use the one newly licensed exclusion bundle. " + "Cite every required evidence ID and class. The exclusion " + "affects representation only, never marker testing." + ), + user_prompt=user_prompt, + config=request_record.config.agentRunConfig, + name=f"rna_{definition.spec.decisionId}_review", + output_validator=lambda value: _validate_selection( + definition, + evidence, + value, + ), + ) + except AgentRunError: + return DecisionReconsideration( + selection=None, + resolution=None, + revised=False, + snapshotSha256=snapshot_sha256, + question=WorkflowQuestion( + questionId=question_id, + decisionId=definition.spec.decisionId, + question=definition.spec.question, + options=[option.optionId for option in definition.spec.options], + evidenceIds=[item.evidenceId for item in evidence.evidence], + ), + ) + if not isinstance(execution.output, DecisionSelection): + raise TypeError( + "RNA reconsideration model returned an unexpected output type" + ) + selection = _validate_selection(definition, evidence, execution.output) + source = "agent" + model_name = execution.runInfo.modelName + + selected_status = definition.spec.option_by_id()[ + selection.selectedOptionId + ].status + if selected_status == "defer": + return DecisionReconsideration( + selection=selection, + resolution=None, + revised=False, + snapshotSha256=snapshot_sha256, + question=WorkflowQuestion( + questionId=question_id, + decisionId=definition.spec.decisionId, + question=definition.spec.question, + options=[option.optionId for option in definition.spec.options], + evidenceIds=[item.evidenceId for item in evidence.evidence], + ), + ) + if selection.selectedOptionId == target.selectedOptionId: + return DecisionReconsideration( + selection=selection, + resolution=None, + revised=False, + snapshotSha256=snapshot_sha256, + ) + + target_position = workflow.decisionRecords.index(target) + active_ids = {record.recordId for record in workflow.active_decision_records()} + invalidated = [ + record.recordId + for record in workflow.decisionRecords[target_position + 1 :] + if record.recordId in active_ids + ] + revision_id = ( + "revision:" + f"{_sha256({'target': target.recordId, 'bundle': evidence.bundleId, 'option': selection.selectedOptionId})[:24]}" + ) + if target.verificationId is None: + raise ValueError("Reconsideration target lacks deterministic verification") + revision = RevisionRequest( + revisionId=revision_id, + targetDecisionRecordId=target.recordId, + verificationId=target.verificationId, + replacementOptionId=selection.selectedOptionId, + reason=selection.rationale, + evidenceBundleId=evidence.bundleId, + evidenceBundleSha256=evidence.contentSha256, + availableEvidenceIds=[item.evidenceId for item in evidence.evidence], + evidenceIds=list(selection.evidenceIds), + invalidatesDecisionRecordIds=invalidated, + createdAtNs=time.time_ns(), + ) + if source == "agent": + resolution = self._resolve_rna_decision( + store, + request_record, + definition, + evidence, + {}, + agent_selection=selection, + agent_model_name=model_name, + revision=revision, + ) + else: + resolution = self._resolve_rna_decision( + store, + request_record, + definition, + evidence, + { + f"decision:{definition.spec.decisionId}": { + "decisionId": definition.spec.decisionId, + "optionId": selection.selectedOptionId, + "rationale": selection.rationale, + } + }, + revision=revision, + ) + return DecisionReconsideration( + selection=selection, + resolution=resolution, + revised=True, + snapshotSha256=resolution.snapshotSha256, + ) + + def _resolve_rna_decision( + self, + store: Any, + request_record: OrchestrationRequestRecord, + definition: RnaDecisionDefinition, + evidence: EvidenceBundle, + answers: Mapping[str, Any], + *, + rule_selection: DecisionSelection | None = None, + agent_selection: DecisionSelection | None = None, + agent_model_name: str | None = None, + revision: RevisionRequest | None = None, + ) -> DecisionResolution: + evidence = ( + evidence + if evidence.contentSha256 is not None + else evidence.with_content_sha256() + ) + evidence_sha256 = evidence.contentSha256 + if evidence_sha256 is None: + raise RuntimeError("Decision evidence checksum was not created") + if evidence.decisionId != definition.spec.decisionId: + raise ValueError("Evidence does not match the decision definition") + if evidence.bundleId != definition.spec.evidenceBundleId: + raise ValueError("Evidence bundle identity does not match the definition") + if revision is not None and ( + revision.evidenceBundleId != evidence.bundleId + or revision.evidenceBundleSha256 != evidence_sha256 + or revision.availableEvidenceIds + != [item.evidenceId for item in evidence.evidence] + ): + raise ValueError( + "Revision does not reference the exact replacement evidence bundle" + ) + + workflow, snapshot_sha256 = self._load_or_create_decision_workflow( + store, + request_record, + ) + supersedes = revision.targetDecisionRecordId if revision is not None else None + existing = _active_record(workflow, definition.spec.decisionId) + if existing is not None and revision is None: + if ( + existing.definitionVersion != definition.spec.definitionVersion + or existing.evidenceBundleId != evidence.bundleId + or existing.offeredOptionIds + != [option.optionId for option in definition.spec.options] + or existing.availableEvidenceIds + != [item.evidenceId for item in evidence.evidence] + ): + raise ValueError( + "Persisted decision does not match the current definition and evidence" + ) + persisted_verifications = [ + verification + for verification in workflow.verificationRecords + if verification.decisionRecordId == existing.recordId + ] + if len(persisted_verifications) != 1: + raise ValueError( + "Persisted decision lacks one exact verification record" + ) + persisted_verification = persisted_verifications[0] + compiled = compile_rna_decision( + definition, + evidence, + existing, + created_at_ns=persisted_verification.createdAtNs, + ) + if compiled.verification != persisted_verification: + raise ValueError( + "Persisted verification does not match deterministic replay" + ) + return DecisionResolution( + workflow=workflow, + record=existing, + compiled=compiled, + snapshotSha256=snapshot_sha256, + ) + if existing is None and revision is None: + latest_matching = next( + ( + record + for record in reversed(workflow.decisionRecords) + if record.decisionId == definition.spec.decisionId + ), + None, + ) + if ( + latest_matching is not None + and latest_matching.recordId + in workflow.invalidated_decision_record_ids() + ): + supersedes = latest_matching.recordId + + question_id = f"decision:{definition.spec.decisionId}" + raw_answer = answers.get(question_id) + source: DecisionSource + prompt_sha256: str | None = None + model_name: str | None = None + selection: DecisionSelection + + if rule_selection is not None and agent_selection is not None: + raise ValueError("A decision cannot have both rule and agent selections") + if rule_selection is not None: + if raw_answer is not None: + raise ValueError("A rule-owned decision cannot accept a human answer") + source = "rule" + selection = _validate_selection(definition, evidence, rule_selection) + elif agent_selection is not None: + if raw_answer is not None: + raise ValueError( + "A preselected agent decision cannot accept a human answer" + ) + source = "agent" + selection = _validate_selection(definition, evidence, agent_selection) + model_name = agent_model_name + elif raw_answer is not None: + if not isinstance(raw_answer, Mapping): + raise ValueError("Human decision answer must be a mapping") + if set(raw_answer) != {"decisionId", "optionId", "rationale"}: + raise ValueError( + "Human decision answer requires decisionId, optionId, and rationale" + ) + if raw_answer.get("decisionId") != definition.spec.decisionId: + raise ValueError("Human decision answer has a stale decisionId") + option_id = raw_answer.get("optionId") + rationale = raw_answer.get("rationale") + if not isinstance(option_id, str) or not isinstance(rationale, str): + raise ValueError("Human decision answer has invalid values") + evidence_ids = _selection_evidence_for_human( + definition, evidence, option_id + ) + override_of: str | None = None + override_evidence_ids: list[str] = [] + if ( + definition.spec.requireIndependentOverrideEvidence + and definition.spec.metricPreferredOptionId is not None + and option_id != definition.spec.metricPreferredOptionId + and definition.spec.option_by_id()[option_id].status + in {"apply", "skip"} + ): + override_of = definition.spec.metricPreferredOptionId + selected_option = definition.spec.option_by_id()[option_id] + option_evidence_ids = set(selected_option.requiredEvidenceIds) + override_evidence_ids = [ + item.evidenceId + for item in evidence.evidence + if item.evidenceClass + in { + "markerCoherence", + "resamplingStability", + "crossUnitSupport", + "protectedVariablePreservation", + } + and ( + not option_evidence_ids + or item.evidenceId in option_evidence_ids + ) + ] + evidence_ids = list( + dict.fromkeys([*evidence_ids, *override_evidence_ids]) + ) + selection = _validate_selection( + definition, + evidence, + DecisionSelection( + selectedOptionId=option_id, + evidenceIds=evidence_ids, + rationale=rationale.strip(), + confidence="notApplicable", + overrideOfOptionId=override_of, + overrideEvidenceIds=override_evidence_ids, + ), + ) + source = "human" + elif workflow.status == "needsInput" and workflow.pendingDecision is not None: + if workflow.pendingDecision.decisionId != definition.spec.decisionId: + raise ValueError( + "Decision workflow is paused at another exact checkpoint" + ) + return DecisionResolution( + workflow=workflow, + record=None, + compiled=None, + snapshotSha256=snapshot_sha256, + ) + else: + payload = { + "decisionId": definition.spec.decisionId, + "studyObjective": request_record.request.studyObjective, + "question": definition.spec.question, + "baselineOptionId": definition.spec.baselineOptionId, + "metricPreferredOptionId": definition.spec.metricPreferredOptionId, + "requireIndependentOverrideEvidence": ( + definition.spec.requireIndependentOverrideEvidence + ), + "options": [ + option.model_dump(mode="json") for option in definition.spec.options + ], + "evidence": [ + item.model_dump(mode="json") for item in evidence.evidence + ], + } + user_prompt = json.dumps(payload, indent=2, sort_keys=True) + prompt_sha256 = hashlib.sha256(user_prompt.encode()).hexdigest() + try: + execution = run_agent_sync( + model=self.model, + output_type=DecisionSelection, + system_prompt=( + "The task is to select one offered option from the supplied " + "evidence. A valid selection includes the option ID, every " + "option-specific evidence ID, the required evidence classes, " + "and a concise rationale. Numeric parameters and operations " + "are fixed by the executor. When independent override " + "evidence is required, a non-preferred option also identifies " + "the preferred option it overrides and cites two independent " + "non-geometric evidence classes." + ), + user_prompt=user_prompt, + config=request_record.config.agentRunConfig, + name=f"rna_{definition.spec.decisionId}_decision", + output_validator=lambda value: _validate_selection( + definition, evidence, value + ), + ) + except AgentRunError as exc: + pending = PendingDecision( + questionId=question_id, + decisionId=definition.spec.decisionId, + definitionVersion=definition.spec.definitionVersion, + evidenceBundleId=evidence.bundleId, + evidenceBundleSha256=evidence_sha256, + offeredOptionIds=[ + option.optionId for option in definition.spec.options + ], + availableEvidenceIds=[ + item.evidenceId for item in evidence.evidence + ], + reason=( + "The bounded model run did not return a valid registered " + f"selection ({type(exc).__name__})." + ), + createdAtNs=time.time_ns(), + ) + paused = pause_decision_workflow(workflow, pending) + snapshot = save_decision_workflow_snapshot( + store, + paused, + workspace=request_record.request.workspace, + ) + return DecisionResolution( + workflow=snapshot.workflow, + record=None, + compiled=None, + snapshotSha256=snapshot.contentSha256, + ) + if not isinstance(execution.output, DecisionSelection): + raise TypeError("RNA decision model returned an unexpected output type") + selection = _validate_selection(definition, evidence, execution.output) + source = "agent" + model_name = execution.runInfo.modelName + + selected_option = definition.spec.option_by_id()[selection.selectedOptionId] + if selected_option.status == "defer": + if workflow.status == "needsInput": + active_pending = workflow.pendingDecision + if active_pending is None or ( + active_pending.decisionId != definition.spec.decisionId + or active_pending.definitionVersion + != definition.spec.definitionVersion + or active_pending.evidenceBundleId != evidence.bundleId + or active_pending.evidenceBundleSha256 != evidence_sha256 + or active_pending.offeredOptionIds + != [option.optionId for option in definition.spec.options] + or active_pending.availableEvidenceIds + != [item.evidenceId for item in evidence.evidence] + ): + raise ValueError( + "Deferred answer does not match the exact pending checkpoint" + ) + return DecisionResolution( + workflow=workflow, + record=None, + compiled=None, + snapshotSha256=snapshot_sha256, + ) + pending = PendingDecision( + questionId=question_id, + decisionId=definition.spec.decisionId, + definitionVersion=definition.spec.definitionVersion, + evidenceBundleId=evidence.bundleId, + evidenceBundleSha256=evidence_sha256, + offeredOptionIds=[ + option.optionId for option in definition.spec.options + ], + availableEvidenceIds=[item.evidenceId for item in evidence.evidence], + reason=selection.rationale, + createdAtNs=time.time_ns(), + ) + paused = pause_decision_workflow(workflow, pending) + snapshot = save_decision_workflow_snapshot( + store, + paused, + workspace=request_record.request.workspace, + ) + return DecisionResolution( + workflow=snapshot.workflow, + record=None, + compiled=None, + snapshotSha256=snapshot.contentSha256, + ) + + created_at_ns = time.time_ns() + record = _record_from_selection( + workflow_run_id=request_record.workflowRunId, + definition=definition, + evidence=evidence, + selection=selection, + source=source, + model_name=model_name, + prompt_sha256=prompt_sha256, + supersedes=supersedes, + created_at_ns=created_at_ns, + ) + compiled = compile_rna_decision( + definition, + evidence, + record, + created_at_ns=created_at_ns, + ) + updated = attach_audited_rna_decision( + workflow, + record, + compiled, + revision=revision, + ) + snapshot = save_decision_workflow_snapshot( + store, + updated, + workspace=request_record.request.workspace, + ) + return DecisionResolution( + workflow=snapshot.workflow, + record=record, + compiled=compiled, + snapshotSha256=snapshot.contentSha256, + ) + + @staticmethod + def _pending_decision_question( + resolution: DecisionResolution, + definition: RnaDecisionDefinition, + ) -> WorkflowQuestion: + pending = resolution.pending + if pending is None: + raise ValueError("Decision resolution has no pending checkpoint") + return WorkflowQuestion( + questionId=pending.questionId, + decisionId=pending.decisionId, + question=definition.spec.question, + options=list(pending.offeredOptionIds), + evidenceIds=list(pending.availableEvidenceIds), + ) + + +__all__ = ["DecisionResolution", "DecisionStagesMixin"] diff --git a/scarf/agent/orchestrator/finalization.py b/scarf/agent/orchestrator/finalization.py index 22ee5f4d..a80071d1 100644 --- a/scarf/agent/orchestrator/finalization.py +++ b/scarf/agent/orchestrator/finalization.py @@ -5,7 +5,6 @@ from typing import Any, Literal, cast from ...datastore.datastore import DataStore -from ...storage.refs import ArtifactRef from ...utils.logging import logger from ..biological_interpretation import ( BiologicalContext, @@ -13,13 +12,22 @@ BiologicalInterpretationReport, ) from ..data_enrichment import DataEnrichmentReport +from ..decision_persistence import ( + complete_decision_workflow, + load_latest_decision_workflow_snapshot, + save_decision_workflow_snapshot, +) from ..experimental_context import ExperimentalContextResult -from ..parameter_tuning import ParameterTuningAgent, ParameterTuningReport +from ..parameter_tuning import ( + ParameterTuningAgent, + ParameterTuningReport, +) from ..persistence import ( AgentInvocation, AgentReportReference, AgentWorkflowRun, ) +from ..study_contract import StudyContract from ..types import ArtifactReferenceModel, ExperimentalBiologyHandoff from . import journal from .models import ( @@ -53,6 +61,7 @@ def analysis_finalization_stage( preprocessed: Sequence[PreprocessedAssayHandoff], tuning_report: ParameterTuningReport, tuning_reference: AgentReportReference, + study_contract: StudyContract, *, resume_record: OrchestrationResumeRecord | None = None, ) -> tuple[WorkflowStageAttempt, FinalAnalysisHandoff]: @@ -69,9 +78,18 @@ def analysis_finalization_stage( logger.info( f"Workflow {workflow.workflowRunId}: reusing finalized analysis" ) - return existing, FinalAnalysisHandoff.model_validate( + handoff = FinalAnalysisHandoff.model_validate( existing.outputs["finalAnalysis"] ) + persisted = journal.load_final_analysis_handoff( + store, + prefix, + workflow.workflowRunId, + handoff.handoffId, + ) + if persisted != handoff: + raise ValueError("Finalization outcome and handoff journal differ") + return existing, handoff if tuning_report.cellSelection is None: raise ValueError("Parameter Tuning lacks an exact cell selection") cell_selection = tuning_report.cellSelection @@ -114,118 +132,195 @@ def analysis_finalization_stage( or tuning_report.finalClusterArtifact is None ): raise ValueError("Parameter Tuning has no finalized cluster branch") - preprocessed_by_assay = {value.assay: value for value in preprocessed} - plan_by_assay = {value.assay: value for value in plan.assays} - agent = ParameterTuningAgent( + if plan.primaryAssay != plan.markerAssay or len(preprocessed) != 1: + raise ValueError( + "Decision-driven v1 finalization requires exactly one RNA assay" + ) + if tuning_report.recommendedIntegrationId is not None: + raise ValueError( + "Decision-driven v1 cannot finalize an integrated SNN or WNN graph" + ) + preprocessed_assay = preprocessed[0] + if preprocessed_assay.assay != plan.primaryAssay: + raise ValueError("The final RNA assay does not match preprocessing") + if ( + preprocessed_assay.normalized is None + or preprocessed_assay.markerFeatures is None + ): + raise ValueError( + "Finalization requires exact normalized and marker features" + ) + tuning_agent = ParameterTuningAgent( self.model, config=request_record.config.agentRunConfig, ) - native_handoffs, native_umaps = self.finalize_native_analyses( + native_analyses, native_umaps = self.finalize_native_analyses( store, - agent, + tuning_agent, request_record, tuning_report, - preprocessed_by_assay, + {preprocessed_assay.assay: preprocessed_assay}, artifacts, actions, operations, ) - ( - graph_method, - final_graph, - final_initialization, - final_umap, - ) = self.finalize_selected_graph( - store, - plan, + if len(native_analyses) != 1: + raise ValueError("Decision-driven v1 requires one native analysis") + graph_method, final_graph, final_initialization, final_umap = ( + self.finalize_selected_graph( + store, + plan, + tuning_report, + native_analyses, + native_umaps, + actions, + operations, + ) + ) + native = native_analyses[0] + if native.clusters is None: + raise ValueError("Selected native analysis lacks clusters") + final_clusters = native.clusters + if artifact_model_to_ref(final_clusters) != artifact_model_to_ref( + tuning_report.finalClusterArtifact + ): + raise ValueError( + "Finalization changed the selected cluster artifact identity" + ) + + assay_report = tuning_report.assayReports.get( + plan.primaryAssay, tuning_report, - native_handoffs, - native_umaps, - actions, - operations, ) - final_clusters = ArtifactReferenceModel.model_validate( - tuning_report.finalClusterArtifact.model_dump() - ) - marker_handoff = preprocessed_by_assay[plan.markerAssay] - if marker_handoff.markerFeatures is None: - raise ValueError("Marker assay lacks an exact feature panel") - marker_plan = plan_by_assay[plan.markerAssay] - marker_ref = store.run_marker_search( - artifact_model_to_ref(final_clusters), - from_assay=plan.markerAssay, - features=artifact_model_to_ref(marker_handoff.markerFeatures), - invalidate_cache=False, - log_transform=bool( - marker_plan.normalizationParameters.get("logTransform", False) - ), - renormalize_subset=bool( - marker_plan.normalizationParameters.get("renormalizeSubset", False) + selected = next( + ( + evaluation + for evaluation in assay_report.evaluations + if evaluation.candidateId == assay_report.recommendedCandidateId ), + None, ) - if not isinstance(marker_ref, ArtifactRef): - raise TypeError("Saved marker search did not return an artifact") - marker_model = ArtifactReferenceModel.from_artifact_ref(marker_ref) - artifacts.update( - { - "final_graph": final_graph, - "final_clusters": final_clusters, - "final_embedding_initialization": final_initialization, - "final_umap": final_umap, - "marker_features": marker_handoff.markerFeatures, - "markers": marker_model, - } - ) + if selected is None or selected.status != "done" or not selected.eligible: + raise ValueError("Final tuning selected an ineligible RNA candidate") + marker_record = selected.artifacts.get("markerTable") + if marker_record is None: + marker_ref = store.run_marker_search( + artifact_model_to_ref(final_clusters), + from_assay=plan.markerAssay, + features=artifact_model_to_ref(preprocessed_assay.markerFeatures), + invalidate_cache=False, + ) + marker_model = ArtifactReferenceModel.from_artifact_ref(marker_ref) + actions.append("run_final_marker_search") + operations.append( + { + "operation": "run_marker_search", + "clusters": final_clusters.model_dump(mode="json"), + "features": preprocessed_assay.markerFeatures.model_dump( + mode="json" + ), + "artifact": marker_model.model_dump(mode="json"), + } + ) + else: + marker_model = ArtifactReferenceModel.model_validate( + marker_record.model_dump() + ) + store.load_artifact(artifact_model_to_ref(marker_model)) + actions.append("reuse_selected_marker_table") + artifacts["markers"] = marker_model + limitations = list( dict.fromkeys([*plan.limitations, *tuning_report.limitations]) ) - if marker_plan.assayType == "ATAC": - limitations.append( - "ATAC peak markers are descriptive and cannot establish " - "confident cell identities alone" + doublet_scores = [ + ArtifactReferenceModel.model_validate(artifact.model_dump()) + for name, artifact in sorted(selected.artifacts.items()) + if name.startswith("doubletScore:") + ] + if not doublet_scores: + raise ValueError( + "Selected cluster evidence lacks advisory doublet scores" ) + for index, doublet_model in enumerate(doublet_scores): + store.load_artifact(artifact_model_to_ref(doublet_model)) + artifacts[f"doubletScore{index}"] = doublet_model + limitations.extend( + warning + for warning in selected.warnings + if "doublet" in warning.lower() + or "physical capture identity" in warning.lower() + ) + actions.append("reuse_advisory_doublet_scores") + operations.append( + { + "operation": "reuse_advisory_doublet_scores", + "artifacts": [ + value.model_dump(mode="json") for value in doublet_scores + ], + } + ) + final_analysis = FinalAnalysisHandoff( workflowRunId=workflow.workflowRunId, primaryAssay=plan.primaryAssay, markerAssay=plan.markerAssay, cellSelection=cell_selection, - nativeAnalyses=native_handoffs, + nativeAnalyses=native_analyses, graph=final_graph, graphMethod=graph_method, clusters=final_clusters, embeddingInitialization=final_initialization, umap=final_umap, - markerFeatures=marker_handoff.markerFeatures, + markerFeatures=preprocessed_assay.markerFeatures, markers=marker_model, + doubletScores=doublet_scores, parameterReport=tuning_reference, limitations=list(dict.fromkeys(limitations)), - ) - actions.append(f"run_markers:{plan.markerAssay}") + ).with_handoff_id() + journal.save_final_analysis_handoff(store, prefix, final_analysis) + actions.append("persist_final_analysis_handoff") operations.append( { - "operation": "run_marker_search", - "assay": plan.markerAssay, - "clusters": final_clusters.model_dump(mode="json"), - "cellSelection": cell_selection.model_dump(mode="json"), - "features": marker_handoff.markerFeatures.model_dump(mode="json"), - "invalidateCache": False, - "logTransform": bool( - marker_plan.normalizationParameters.get("logTransform", False) - ), - "renormalizeSubset": bool( - marker_plan.normalizationParameters.get( - "renormalizeSubset", False - ) - ), - "artifact": marker_model.model_dump(mode="json"), + "operation": "persist_final_analysis_handoff", + "handoffId": final_analysis.handoffId, + "artifacts": { + name: value.model_dump(mode="json") + for name, value in artifacts.items() + }, } ) + + decision_snapshot = load_latest_decision_workflow_snapshot( + store, + workflow.workflowRunId, + workspace=request_record.request.workspace, + ) + decision_workflow = decision_snapshot.workflow + if decision_workflow.status == "completed": + if decision_workflow.finalHandoffId != final_analysis.handoffId: + raise ValueError( + "Completed decision ledger references another final handoff" + ) + completed_snapshot = decision_snapshot + else: + completed_workflow = complete_decision_workflow( + decision_workflow, + final_analysis.handoffId, + ) + completed_snapshot = save_decision_workflow_snapshot( + store, + completed_workflow, + workspace=request_record.request.workspace, + ) outcome = journal._complete_attempt( started, status="done", artifacts=artifacts, outputs={ "finalAnalysis": final_analysis.model_dump(mode="json"), + "handoffId": final_analysis.handoffId, + "decisionSnapshotSha256": (completed_snapshot.contentSha256), "operations": operations, }, actions=actions, @@ -234,8 +329,8 @@ def analysis_finalization_stage( journal._save_outcome(store.zw, prefix, outcome) logger.info( f"Workflow {workflow.workflowRunId}: finalized " - f"graphMethod={graph_method!r}, nativeLayouts=" - f"{len(native_handoffs)}, markerAssay={plan.markerAssay!r}" + f"handoff={final_analysis.handoffId!r}, " + f"markerAssay={plan.markerAssay!r}" ) return outcome, final_analysis except Exception as exc: diff --git a/scarf/agent/orchestrator/journal.py b/scarf/agent/orchestrator/journal.py index 5afbf54d..04f0627c 100644 --- a/scarf/agent/orchestrator/journal.py +++ b/scarf/agent/orchestrator/journal.py @@ -14,6 +14,7 @@ from ...datastore.datastore import DataStore from ...utils.logging import logger from .. import record_io +from ..ingest.manifest import DatasetManifest from ..persistence import ( AgentInvocation, AgentName, @@ -29,6 +30,7 @@ save_agent_report, ) from ..types import AgentDataModel, ArtifactReferenceModel +from ..study_contract import StudyContract from .models import ( _ORCHESTRATION_FORMAT, _ORCHESTRATION_VERSION, @@ -151,6 +153,64 @@ def _result_key(prefix: str, workflow_run_id: str) -> str: return record_io.join_key(prefix, workflow_run_id, "result.json") +def _handoff_key( + prefix: str, + workflow_run_id: str, + handoff_id: str, +) -> str: + marker, separator, digest = handoff_id.partition(":") + if ( + marker != "handoff" + or separator != ":" + or len(digest) != 64 + or any(value not in "0123456789abcdef" for value in digest) + ): + raise ValueError("handoff_id must contain a lowercase SHA-256 digest") + return record_io.join_key( + prefix, + workflow_run_id, + "handoffs", + f"{digest}.json", + ) + + +def save_final_analysis_handoff( + store: DataStore, + prefix: str, + handoff: FinalAnalysisHandoff, +) -> FinalAnalysisHandoff: + handoff = FinalAnalysisHandoff.model_validate(handoff.model_dump(mode="json")) + if not handoff.handoffId: + raise ValueError("Final analysis handoff requires its content identity") + key = _handoff_key(prefix, handoff.workflowRunId, handoff.handoffId) + payload = record_io.display_json_bytes(handoff.model_dump(mode="json")) + stored = record_io.read_key(store.zw, key) + if stored is None: + try: + _write_key_once(store.zw, key, payload) + except FileExistsError: + stored = record_io.read_key(store.zw, key) + if stored != payload: + raise + elif stored != payload: + raise FileExistsError("Final handoff identity has conflicting content") + return handoff + + +def load_final_analysis_handoff( + store: DataStore, + prefix: str, + workflow_run_id: str, + handoff_id: str, +) -> FinalAnalysisHandoff: + key = _handoff_key(prefix, workflow_run_id, handoff_id) + value = _read_model(store.zw, key, FinalAnalysisHandoff) + handoff = cast(FinalAnalysisHandoff, value) + if handoff.workflowRunId != workflow_run_id or handoff.handoffId != handoff_id: + raise ValueError("Final handoff identity does not match its journal key") + return handoff + + def _read_model( group: zarr.Group, key: str, @@ -180,7 +240,7 @@ def _stage_checksum(attempt: WorkflowStageAttempt) -> str: def _complete_attempt( started: WorkflowStageAttempt, *, - status: Literal["done", "needsInput", "failed"], + status: Literal["done", "needsInput", "abstained", "failed"], report_references: Sequence[AgentReportReference] = (), artifacts: Mapping[str, ArtifactReferenceModel] | None = None, outputs: Mapping[str, Any] | None = None, @@ -297,6 +357,11 @@ def _save_outcome( f"for {question_count} input question(s) ({details}; " f"{elapsed_seconds:.1f}s)" ) + elif outcome.status == "abstained": + logger.info( + f"Workflow {outcome.workflowRunId}: stage={outcome.stage!r} " + f"abstained ({details}; {elapsed_seconds:.1f}s)" + ) else: logger.info( f"Workflow {outcome.workflowRunId}: completed stage={outcome.stage!r} " @@ -423,6 +488,36 @@ def _resume_answer_errors( for question_id in sorted(expected_ids & supplied_ids): question = questions[question_id] answer = answers[question_id] + if question.decisionId is not None: + if not isinstance(answer, Mapping): + errors.append( + f"Resume answer for {question_id!r} must contain decisionId, " + "optionId, and rationale" + ) + continue + if set(answer) != {"decisionId", "optionId", "rationale"}: + errors.append( + f"Resume answer for {question_id!r} must contain exactly " + "decisionId, optionId, and rationale" + ) + continue + if answer.get("decisionId") != question.decisionId: + errors.append( + f"Resume answer for {question_id!r} does not match decision " + f"{question.decisionId!r}" + ) + option_id = answer.get("optionId") + if not isinstance(option_id, str) or option_id not in question.options: + errors.append( + f"Resume answer for {question_id!r} must select one persisted " + f"option {question.options!r}" + ) + rationale = answer.get("rationale") + if not isinstance(rationale, str) or not rationale.strip(): + errors.append( + f"Resume answer for {question_id!r} requires a non-empty rationale" + ) + continue if unsafe_context and question_id == "experimentalDirections": if _unsafe_context_resolution(answer) is None: errors.append( @@ -981,13 +1076,30 @@ def paused_or_failed_result( request_record: OrchestrationRequestRecord, outcome: WorkflowStageAttempt, *, + dataset_manifest: DatasetManifest | None = None, preprocessing_plan: AutomatedPreprocessingPlan | None = None, + study_contract: StudyContract | None = None, final_analysis: FinalAnalysisHandoff | None = None, ) -> AutomatedWorkflowResult: prefix = _ensure_orchestration_store(store) current = load_agent_workflow(store, workflow.workflowRunId) + if outcome.status == "abstained" and current.status == "running": + current = finalize_agent_workflow( + store, + workflow.workflowRunId, + status="abstained", + message=( + outcome.notes[0] + if outcome.notes + else "The available data do not support a defensible result" + ), + ) status: AutomatedWorkflowStatus = ( - "needsInput" if outcome.status == "needsInput" else "failed" + "needsInput" + if outcome.status == "needsInput" + else "abstained" + if outcome.status == "abstained" + else "failed" ) result = AutomatedWorkflowResult( status=status, @@ -995,13 +1107,16 @@ def paused_or_failed_result( zarrPath=str(store.zarr_loc), workflowRun=current, reportReferences=list(current.reports), + datasetManifest=dataset_manifest, preprocessingPlan=preprocessing_plan, + studyContract=study_contract, finalAnalysis=final_analysis, + decisionRunId=request_record.workflowRunId, needsInput=outcome.needsInput, notes=[*outcome.notes, *([outcome.error] if outcome.error else [])], ) result = result.model_copy(update={"contentSha256": _record_checksum(result)}) - if status == "failed": + if status in {"failed", "abstained"}: return _persist_terminal_result(store, prefix, current, result) logger.info( f"Workflow {workflow.workflowRunId}: returning needsInput at " diff --git a/scarf/agent/orchestrator/main.py b/scarf/agent/orchestrator/main.py index 05e654ea..b06fb965 100644 --- a/scarf/agent/orchestrator/main.py +++ b/scarf/agent/orchestrator/main.py @@ -13,7 +13,9 @@ from ...storage.stores import zarr_root_path from ...utils.logging import logger from .. import record_io +from ..decision_persistence import load_latest_decision_workflow_snapshot from ..ingest import IngestResult, detect_format, ingest +from ..ingest.manifest import DatasetManifest, inspect_h5ad_manifest from ..persistence import ( AgentWorkflowRun, create_agent_workflow, @@ -21,6 +23,7 @@ load_agent_report, load_agent_workflow, ) +from ..study_contract import StudyContract from . import journal from .context import ContextStagesMixin from .finalization import FinalizationStagesMixin @@ -91,6 +94,7 @@ def __init__( def run(self, request: AutomatedWorkflowRequest) -> AutomatedWorkflowResult: """Ingest the request and continue until completion or a persisted pause.""" format_name = detect_format(request.sourcePath) + dataset_manifest: DatasetManifest | None = None logger.info( f"Starting automated agent workflow from {format_name!r} input " f"(workspace={request.workspace is not None})" @@ -119,6 +123,68 @@ def run(self, request: AutomatedWorkflowRequest) -> AutomatedWorkflowResult: currentStage="ingest", notes=["An existing Zarr input cannot be copied implicitly"], ) + if format_name == "h5ad": + matrix_key = request.ingestDirections.get("matrixKey") + try: + dataset_manifest = inspect_h5ad_manifest( + request.sourcePath, + source_uri=( + str(request.ingestDirections["sourceUri"]) + if request.ingestDirections.get("sourceUri") is not None + else request.sourcePath + ), + author_label_policy=request.authorLabelPolicy, + matrix_key=str(matrix_key) if matrix_key is not None else None, + ) + except (OSError, RuntimeError, TypeError, ValueError) as exc: + return AutomatedWorkflowResult( + status="failed", + currentStage="ingest", + notes=[f"CELLxGENE manifest inspection failed: {exc}"], + ) + manifest_decision = dataset_manifest.decision + if manifest_decision.status == "needsInput": + return AutomatedWorkflowResult( + status="needsInput", + currentStage="ingest", + datasetManifest=dataset_manifest, + needsInput=WorkflowNeedsInput( + questions=[ + WorkflowQuestion( + questionId="datasetMatrixKey", + question=( + manifest_decision.summary + + ". Rerun with ingestDirections.matrixKey set " + "to the selected option." + ), + options=list(manifest_decision.options), + evidenceIds=list(manifest_decision.evidenceIds), + ) + ] + ), + limitations=list(dataset_manifest.priorFiltering.limitations), + ) + if manifest_decision.status == "abstained": + return AutomatedWorkflowResult( + status="abstained", + currentStage="ingest", + datasetManifest=dataset_manifest, + limitations=list(dataset_manifest.priorFiltering.limitations), + unresolvedClaims=[manifest_decision.summary], + notes=[ + "The count-dependent RNA workflow did not run because its " + "input contract is not satisfied." + ], + ) + selected_matrix = manifest_decision.selectedMatrixKey + if selected_matrix is None: + raise RuntimeError("Supported manifest lacks a selected matrix") + ingest_directions = { + **request.ingestDirections, + "matrixKey": selected_matrix, + } + request = request.model_copy(update={"ingestDirections": ingest_directions}) + if format_name == "zarr" and request.workspace is not None: zarr_path = str(Path(request.sourcePath).resolve()) effective_request = request.model_copy(update={"zarrPath": zarr_path}) @@ -196,6 +262,7 @@ def run(self, request: AutomatedWorkflowRequest) -> AutomatedWorkflowResult: workflow, request_record, ingest_result, + dataset_manifest, ) return self._continue( store, @@ -647,8 +714,7 @@ def validated_chain( terminal_candidates = [ outcome for outcome in observed - if outcome.stage == "biological_interpretation" - and outcome.status == "done" + if outcome.stage == "analysis_finalization" and outcome.status == "done" ] elif workflow.status == "failed": terminal_candidates = [ @@ -683,6 +749,28 @@ def validated_chain( preprocessing_plan = AutomatedPreprocessingPlan.model_validate( plan_outcome.outputs["preprocessingPlan"] ) + feature_preprocessing = validated_done.get("feature_policy_preprocessing") + if ( + feature_preprocessing is not None + and "resolvedPreprocessingPlan" in feature_preprocessing.outputs + ): + preprocessing_plan = AutomatedPreprocessingPlan.model_validate( + feature_preprocessing.outputs["resolvedPreprocessingPlan"] + ) + + dataset_manifest: DatasetManifest | None = None + ingest_stage = validated_done.get("ingest") + if ingest_stage is not None and ingest_stage.outputs.get("datasetManifest"): + dataset_manifest = DatasetManifest.model_validate( + ingest_stage.outputs["datasetManifest"] + ) + + study_contract: StudyContract | None = None + context_outcome = validated_done.get("experimental_context") + if context_outcome is not None and "studyContract" in context_outcome.outputs: + study_contract = StudyContract.model_validate( + context_outcome.outputs["studyContract"] + ) final_analysis: FinalAnalysisHandoff | None = None finalization_outcome = validated_done.get("analysis_finalization") @@ -693,18 +781,62 @@ def validated_chain( final_analysis = FinalAnalysisHandoff.model_validate( finalization_outcome.outputs["finalAnalysis"] ) + persisted_handoff = journal.load_final_analysis_handoff( + store, + prefix, + workflow.workflowRunId, + final_analysis.handoffId, + ) + if persisted_handoff != final_analysis: + raise ValueError("Final handoff journal content differs from outcome") for reference in workflow.reports: load_agent_report(store, reference) notes = [workflow.finalizationMessage] if workflow.finalizationMessage else [] + verification_summary: list[str] = [] + decision_run_id: str | None = None + if workflow.status == "completed": + decision_snapshot = load_latest_decision_workflow_snapshot( + store, + workflow.workflowRunId, + workspace=request_record.request.workspace, + ) + if ( + final_analysis is None + or decision_snapshot.workflow.status != "completed" + or decision_snapshot.workflow.finalHandoffId != final_analysis.handoffId + ): + raise ValueError( + "Terminal orchestration and decision ledger do not resolve" + ) + decision_run_id = workflow.workflowRunId + verification_by_record = { + value.decisionRecordId: value + for value in decision_snapshot.workflow.verificationRecords + } + verification_summary = [ + ( + f"{record.decisionId}: " + f"{len(verification_by_record[record.recordId].checks)} " + f"deterministic checks passed ({record.source})." + ) + for record in decision_snapshot.workflow.active_decision_records() + ] result = AutomatedWorkflowResult( status=cast(AutomatedWorkflowStatus, workflow.status), currentStage=terminal_outcome.stage, zarrPath=str(store.zarr_loc), workflowRun=workflow, reportReferences=list(workflow.reports), + datasetManifest=dataset_manifest, preprocessingPlan=preprocessing_plan, + studyContract=study_contract, finalAnalysis=final_analysis, + finalHandoffId=( + final_analysis.handoffId if final_analysis is not None else None + ), + decisionRunId=decision_run_id, + verificationSummary=verification_summary, notes=notes, ) result = result.model_copy( @@ -726,6 +858,7 @@ def _continue( """Continue the stage machine from the latest validated checkpoint.""" logger.info(f"Running stage sequence for workflow {workflow.workflowRunId}") prefix = journal._ensure_orchestration_store(store) + self._load_or_create_decision_workflow(store, request_record) ingest_outcome = journal._validated_done_outcome( store, prefix, @@ -736,6 +869,11 @@ def _continue( ) if ingest_outcome is None: raise RuntimeError("The persisted ingest stage is missing") + dataset_manifest = ( + DatasetManifest.model_validate(ingest_outcome.outputs["datasetManifest"]) + if ingest_outcome.outputs.get("datasetManifest") is not None + else None + ) cell_selection = ingest_outcome.artifacts.get("cellSelection") if cell_selection is None or cell_selection.kind != "cell_selection": raise RuntimeError( @@ -758,6 +896,7 @@ def _continue( workflow, request_record, enrichment_outcome, + dataset_manifest=dataset_manifest, ) parents = [journal._parent_link(enrichment_outcome)] @@ -776,6 +915,7 @@ def _continue( workflow, request_record, hto_outcome, + dataset_manifest=dataset_manifest, ) quality_metric_artifacts = self._named_stage_artifacts( hto_outcome, @@ -807,7 +947,11 @@ def _continue( workflow, request_record, context_outcome, + dataset_manifest=dataset_manifest, ) + study_contract = StudyContract.model_validate( + context_outcome.outputs["studyContract"] + ) parents = [journal._parent_link(context_outcome)] plan_outcome, preprocessing_plan = self.preprocessing_plan_stage( @@ -818,6 +962,7 @@ def _continue( enrichment, experimental, ingest_outcome, + study_contract, answers, resume_record=resume_record, ) @@ -827,17 +972,25 @@ def _continue( workflow, request_record, plan_outcome, + dataset_manifest=dataset_manifest, preprocessing_plan=preprocessing_plan, + study_contract=study_contract, ) parents = [journal._parent_link(plan_outcome)] - preprocessing_outcome, preprocessed = self.preprocessing_stage( + ( + preprocessing_outcome, + preprocessed, + preprocessing_plan, + ) = self.preprocessing_stage( store, workflow, request_record, parents, preprocessing_plan, experimental, + study_contract, + answers, resume_record=resume_record, ) if preprocessing_outcome.status != "done": @@ -846,7 +999,9 @@ def _continue( workflow, request_record, preprocessing_outcome, + dataset_manifest=dataset_manifest, preprocessing_plan=preprocessing_plan, + study_contract=study_contract, ) parents = [journal._parent_link(preprocessing_outcome)] @@ -861,6 +1016,7 @@ def _continue( enrichment_outcome.reportReferences[0], context_outcome.reportReferences[0], answers, + study_contract=study_contract, resume_record=resume_record, ) if tuning_outcome.status != "done": @@ -869,71 +1025,199 @@ def _continue( workflow, request_record, tuning_outcome, + dataset_manifest=dataset_manifest, preprocessing_plan=preprocessing_plan, + study_contract=study_contract, ) - parents = [journal._parent_link(tuning_outcome)] + baseline_preprocessing_outcome = preprocessing_outcome + baseline_preprocessed = list(preprocessed) + baseline_tuning_outcome = tuning_outcome + baseline_tuning_report = tuning_report + parents = [journal._parent_link(baseline_tuning_outcome)] - finalization_outcome, final_analysis = self.analysis_finalization_stage( + ( + feature_review_outcome, + preprocessing_plan, + feature_policy_revised, + ) = self.feature_policy_review_stage( store, workflow, request_record, parents, preprocessing_plan, - preprocessed, - tuning_report, - tuning_outcome.reportReferences[0], + baseline_tuning_report, + answers, resume_record=resume_record, ) - if finalization_outcome.status != "done": + if feature_review_outcome.status != "done": return journal.paused_or_failed_result( store, workflow, request_record, - finalization_outcome, + feature_review_outcome, + dataset_manifest=dataset_manifest, preprocessing_plan=preprocessing_plan, + study_contract=study_contract, ) - parents = [journal._parent_link(finalization_outcome)] + parents = [journal._parent_link(feature_review_outcome)] - biology_outcome = self.biological_interpretation_stage( + if feature_policy_revised: + ( + feature_preprocessing_outcome, + preprocessed, + preprocessing_plan, + ) = self.preprocessing_stage( + store, + workflow, + request_record, + parents, + preprocessing_plan, + experimental, + study_contract, + answers, + resume_record=resume_record, + stage_name="feature_policy_preprocessing", + ) + else: + ( + feature_preprocessing_outcome, + preprocessed, + preprocessing_plan, + ) = self.reuse_feature_policy_preprocessing_stage( + store, + workflow, + request_record, + parents, + preprocessing_plan, + baseline_preprocessing_outcome, + baseline_preprocessed, + resume_record=resume_record, + ) + if feature_preprocessing_outcome.status != "done": + return journal.paused_or_failed_result( + store, + workflow, + request_record, + feature_preprocessing_outcome, + dataset_manifest=dataset_manifest, + preprocessing_plan=preprocessing_plan, + study_contract=study_contract, + ) + parents = [journal._parent_link(feature_preprocessing_outcome)] + + if feature_policy_revised: + tuning_outcome, tuning_report = self.parameter_tuning_stage( + store, + workflow, + request_record, + parents, + preprocessing_plan, + preprocessed, + experimental, + enrichment_outcome.reportReferences[0], + context_outcome.reportReferences[0], + answers, + study_contract=study_contract, + resume_record=resume_record, + stage_name="feature_policy_tuning", + ) + tuning_reference = ( + tuning_outcome.reportReferences[0] + if tuning_outcome.reportReferences + else baseline_tuning_outcome.reportReferences[0] + ) + else: + tuning_outcome, tuning_report = self.reuse_feature_policy_tuning_stage( + store, + workflow, + request_record, + parents, + baseline_tuning_outcome, + baseline_tuning_report, + resume_record=resume_record, + ) + tuning_reference = baseline_tuning_outcome.reportReferences[0] + if tuning_outcome.status != "done": + return journal.paused_or_failed_result( + store, + workflow, + request_record, + tuning_outcome, + dataset_manifest=dataset_manifest, + preprocessing_plan=preprocessing_plan, + study_contract=study_contract, + ) + parents = [journal._parent_link(tuning_outcome)] + + finalization_outcome, final_analysis = self.analysis_finalization_stage( store, workflow, request_record, parents, - enrichment, - experimental, + preprocessing_plan, + preprocessed, tuning_report, - final_analysis, - enrichment_outcome.reportReferences[0], - context_outcome.reportReferences[0], - tuning_outcome.reportReferences[0], - answers, + tuning_reference, + study_contract, resume_record=resume_record, ) - if biology_outcome.status != "done": + if finalization_outcome.status != "done": return journal.paused_or_failed_result( store, workflow, request_record, - biology_outcome, + finalization_outcome, + dataset_manifest=dataset_manifest, preprocessing_plan=preprocessing_plan, - final_analysis=final_analysis, + study_contract=study_contract, ) terminal = finalize_agent_workflow( store, workflow.workflowRunId, status="completed", - message="Automated Scarf agent workflow completed", + message="Decision-driven Scarf analysis completed", ) + decision_snapshot = load_latest_decision_workflow_snapshot( + store, + workflow.workflowRunId, + workspace=request_record.request.workspace, + ) + if ( + decision_snapshot.workflow.status != "completed" + or decision_snapshot.workflow.finalHandoffId != final_analysis.handoffId + ): + raise ValueError( + "Completed orchestration and decision handoff identities differ" + ) + verification_by_record = { + value.decisionRecordId: value + for value in decision_snapshot.workflow.verificationRecords + } + verification_summary = [ + ( + f"{record.decisionId}: " + f"{len(verification_by_record[record.recordId].checks)} " + f"deterministic checks passed ({record.source})." + ) + for record in decision_snapshot.workflow.active_decision_records() + ] completed = AutomatedWorkflowResult( status="completed", - currentStage="biological_interpretation", + currentStage="analysis_finalization", zarrPath=str(store.zarr_loc), workflowRun=terminal, reportReferences=list(terminal.reports), + datasetManifest=dataset_manifest, preprocessingPlan=preprocessing_plan, + studyContract=study_contract, finalAnalysis=final_analysis, - notes=["Automated analysis completed"], + finalHandoffId=final_analysis.handoffId, + decisionRunId=workflow.workflowRunId, + verificationSummary=verification_summary, + limitations=list(study_contract.limitations), + unresolvedClaims=list(study_contract.unsupportedClaims), + notes=["Decision-driven RNA analysis completed"], ) completed = completed.model_copy( update={"contentSha256": journal._record_checksum(completed)} diff --git a/scarf/agent/orchestrator/models.py b/scarf/agent/orchestrator/models.py index 812b66c3..50aa6c9d 100644 --- a/scarf/agent/orchestrator/models.py +++ b/scarf/agent/orchestrator/models.py @@ -1,18 +1,31 @@ """Public data models for resumable automated agent workflows.""" +import hashlib import re from typing import Any, Literal from pydantic import Field, field_validator, model_validator from ...storage.refs import ArtifactRef +from .. import record_io from ..config import AgentRunConfig from ..experimental_context import CellQcPlan +from ..ingest.manifest import DatasetManifest from ..persistence import AgentReportReference, AgentWorkflowRun +from ..rna_decisions import CellQualityExecutorPayload +from ..study_contract import AuthorLabelPolicy, StudyContract from ..types import AgentDataModel, ArtifactReferenceModel -type AutomatedWorkflowStatus = Literal["completed", "needsInput", "failed", "abandoned"] -type WorkflowStageStatus = Literal["started", "done", "needsInput", "failed"] +type AutomatedWorkflowStatus = Literal[ + "completed", + "needsInput", + "abstained", + "failed", + "abandoned", +] +type WorkflowStageStatus = Literal[ + "started", "done", "needsInput", "abstained", "failed" +] type WorkflowStageName = Literal[ "ingest", "data_enrichment", @@ -21,6 +34,9 @@ "preprocessing_plan", "preprocessing", "parameter_tuning", + "feature_policy_review", + "feature_policy_preprocessing", + "feature_policy_tuning", "analysis_finalization", "biological_interpretation", ] @@ -30,7 +46,7 @@ _RUN_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,127}$") _SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") _ORCHESTRATION_FORMAT = "scarf_agent_orchestrations" -_ORCHESTRATION_VERSION = 1 +_ORCHESTRATION_VERSION = 2 _STAGE_ORDER: tuple[WorkflowStageName, ...] = ( "ingest", "data_enrichment", @@ -39,8 +55,10 @@ "preprocessing_plan", "preprocessing", "parameter_tuning", + "feature_policy_review", + "feature_policy_preprocessing", + "feature_policy_tuning", "analysis_finalization", - "biological_interpretation", ) @@ -48,6 +66,7 @@ class WorkflowQuestion(AgentDataModel): """One stable question that can be answered by a resume request.""" questionId: str = "" + decisionId: str | None = None question: str = "" options: list[str] = Field(default_factory=list) evidenceIds: list[str] = Field(default_factory=list) @@ -205,11 +224,23 @@ class AutomatedPreprocessingPlan(AgentDataModel): markerAssay: str = "" cellSelection: ArtifactReferenceModel | None = None cellQc: CellQcPlan = Field(default_factory=CellQcPlan.get_blank) + cellQualityPayload: CellQualityExecutorPayload | None = None assays: list[AssayPreprocessingPlan] = Field(default_factory=list) pairedAssays: list[str] = Field(default_factory=list) planChecksum: str = "" limitations: list[str] = Field(default_factory=list) + @model_validator(mode="after") + def validate_cell_quality_payload(self) -> "AutomatedPreprocessingPlan": + if ( + self.cellQualityPayload is not None + and self.cellQc.registeredProfile != self.cellQualityPayload.profile + ): + raise ValueError( + "cellQualityPayload must match the selected registered QC profile" + ) + return self + @classmethod def get_blank(cls) -> "AutomatedPreprocessingPlan": return cls() @@ -296,6 +327,7 @@ def get_example(cls) -> "NativeAnalysisHandoff": class FinalAnalysisHandoff(AgentDataModel): """Replayable final analysis used by Biological Interpretation.""" + handoffId: str = "" workflowRunId: str = "" primaryAssay: str = "" markerAssay: str = "" @@ -308,9 +340,32 @@ class FinalAnalysisHandoff(AgentDataModel): umap: ArtifactReferenceModel | None = None markerFeatures: ArtifactReferenceModel | None = None markers: ArtifactReferenceModel | None = None + doubletScores: list[ArtifactReferenceModel] = Field(default_factory=list) parameterReport: AgentReportReference | None = None limitations: list[str] = Field(default_factory=list) + @model_validator(mode="after") + def validate_handoff_id(self) -> "FinalAnalysisHandoff": + if not self.handoffId: + return self + expected = self._content_handoff_id() + if self.handoffId != expected: + raise ValueError("handoffId does not match the final artifact handoff") + return self + + def _content_handoff_id(self) -> str: + digest = hashlib.sha256( + record_io.canonical_json_bytes( + self.model_dump(mode="json", exclude={"handoffId"}) + ) + ).hexdigest() + return f"handoff:{digest}" + + def with_handoff_id(self) -> "FinalAnalysisHandoff": + values = self.model_dump(mode="json") + values["handoffId"] = self._content_handoff_id() + return FinalAnalysisHandoff.model_validate(values) + @classmethod def get_blank(cls) -> "FinalAnalysisHandoff": return cls() @@ -338,25 +393,67 @@ def get_example(cls) -> "FinalAnalysisHandoff": clusters=ArtifactReferenceModel( assay="RNA", kind="cluster_labels", artifactId="3" * 64 ), - ) + ).with_handoff_id() class AutomatedWorkflowConfig(AgentDataModel): """Bounded execution policy for automated workflows.""" - primaryInitialCandidates: int = Field(default=5, ge=1) + primaryInitialCandidates: int = Field(default=11, ge=1) secondaryInitialCandidates: int = Field(default=3, ge=1) - maxRefinedCandidatesPerAssay: int = Field(default=1, ge=0, le=1) + maxRefinedCandidatesPerAssay: int = Field(default=0, ge=0, le=0) maxHarmonyCandidatesPerAssay: int = Field(default=1, ge=0, le=1) integrationResolutionCandidates: int = Field(default=3, ge=1) maxCandidateBranches: int = Field(default=24, ge=1) minClusterCells: int = Field(default=20, ge=1) maxIdentityFeatures: int = Field(default=64, ge=2) maxGraphAssays: int = Field(default=3, ge=1) + hvgCandidateCounts: tuple[int, ...] = (1000, 2000, 4000) + pcaCandidateDimensions: tuple[int, ...] = (10, 20, 30, 50) + graphNeighborCandidates: tuple[int, ...] = (11, 21, 41) + leidenResolutionCandidates: tuple[float, ...] = ( + 0.25, + 0.5, + 0.75, + 1.0, + 1.25, + 1.5, + ) + leidenSeeds: tuple[int, ...] = (0, 1, 2) + clusterSubsamples: int = Field(default=2, ge=0, le=5) + clusterSubsampleFraction: float = Field(default=0.8, gt=0.0, lt=1.0) + maxRevisions: int = Field(default=2, ge=0, le=2) allowDownloads: bool = False cacheDir: str | None = None agentRunConfig: AgentRunConfig = Field(default_factory=AgentRunConfig) + @model_validator(mode="after") + def validate_candidate_registry(self) -> "AutomatedWorkflowConfig": + integer_fields = ( + "hvgCandidateCounts", + "pcaCandidateDimensions", + "graphNeighborCandidates", + ) + for field_name in integer_fields: + values = getattr(self, field_name) + if not values or any(value < 1 for value in values): + raise ValueError(f"{field_name} must contain positive integers") + if len(values) != len(set(values)) or tuple(sorted(values)) != values: + raise ValueError(f"{field_name} must be sorted and unique") + resolutions = self.leidenResolutionCandidates + if ( + not resolutions + or any(value <= 0 for value in resolutions) + or len(resolutions) != len(set(resolutions)) + or tuple(sorted(resolutions)) != resolutions + ): + raise ValueError( + "leidenResolutionCandidates must be positive, sorted, and unique" + ) + if not self.leidenSeeds or len(self.leidenSeeds) != len(set(self.leidenSeeds)): + raise ValueError("leidenSeeds must be non-empty and unique") + return self + @classmethod def get_blank(cls) -> "AutomatedWorkflowConfig": return cls() @@ -372,8 +469,9 @@ class AutomatedWorkflowRequest(AgentDataModel): sourcePath: str = "" zarrPath: str | None = None studyContext: str = "" + studyObjective: str = "" + authorLabelPolicy: AuthorLabelPolicy = "holdout" workspace: str | None = None - allowAssumptions: bool = False primaryAssay: str | None = None markerAssay: str | None = None analysisAssays: list[str] = Field(default_factory=list) @@ -387,6 +485,8 @@ def validate_request(self) -> "AutomatedWorkflowRequest": raise ValueError("sourcePath must be non-empty") if not self.studyContext.strip(): raise ValueError("studyContext must be non-empty") + if not self.studyObjective.strip(): + raise ValueError("studyObjective must be non-empty") if len(set(self.analysisAssays)) != len(self.analysisAssays): raise ValueError("analysisAssays must be unique") if len(set(self.pairedAssays)) != len(self.pairedAssays): @@ -397,7 +497,11 @@ def validate_request(self) -> "AutomatedWorkflowRequest": @classmethod def get_blank(cls) -> "AutomatedWorkflowRequest": - return cls(sourcePath="dataset.h5", studyContext="Study context") + return cls( + sourcePath="dataset.h5", + studyContext="Study context", + studyObjective="Discover stable population structure.", + ) @classmethod def get_example(cls) -> "AutomatedWorkflowRequest": @@ -405,6 +509,9 @@ def get_example(cls) -> "AutomatedWorkflowRequest": sourcePath="dataset.h5ad", zarrPath="dataset.zarr", studyContext="Single-cell profiling of treated human blood.", + studyObjective=( + "Discover stable populations while preserving treatment structure." + ), ) @@ -445,24 +552,53 @@ class AutomatedWorkflowResult(AgentDataModel): zarrPath: str | None = None workflowRun: AgentWorkflowRun | None = None reportReferences: list[AgentReportReference] = Field(default_factory=list) + datasetManifest: DatasetManifest | None = None preprocessingPlan: AutomatedPreprocessingPlan | None = None + studyContract: StudyContract | None = None finalAnalysis: FinalAnalysisHandoff | None = None + finalHandoffId: str | None = None + decisionRunId: str | None = None + verificationSummary: list[str] = Field(default_factory=list) + limitations: list[str] = Field(default_factory=list) + unresolvedClaims: list[str] = Field(default_factory=list) needsInput: WorkflowNeedsInput | None = None notes: list[str] = Field(default_factory=list) contentSha256: str = "" + @model_validator(mode="after") + def validate_terminal_handoff(self) -> "AutomatedWorkflowResult": + if self.status != "completed": + return self + if self.finalAnalysis is None or not self.finalAnalysis.handoffId: + raise ValueError("Completed workflow results require a final handoff") + if self.finalHandoffId != self.finalAnalysis.handoffId: + raise ValueError("Result and final analysis handoff IDs must agree") + if ( + self.workflowRun is None + or self.decisionRunId != self.workflowRun.workflowRunId + ): + raise ValueError( + "Completed results require the matching decision workflow ID" + ) + return self + @classmethod def get_blank(cls) -> "AutomatedWorkflowResult": return cls() @classmethod def get_example(cls) -> "AutomatedWorkflowResult": + workflow = AgentWorkflowRun.get_example() + final_analysis = FinalAnalysisHandoff.get_example() return cls( status="completed", - currentStage="biological_interpretation", + currentStage="analysis_finalization", zarrPath="dataset.zarr", - workflowRun=AgentWorkflowRun.get_example(), - finalAnalysis=FinalAnalysisHandoff.get_example(), + workflowRun=workflow, + studyContract=StudyContract.get_example(), + finalAnalysis=final_analysis, + finalHandoffId=final_analysis.handoffId, + decisionRunId=workflow.workflowRunId, ) @@ -470,7 +606,7 @@ class OrchestrationRequestRecord(AgentDataModel): """Stored immutable request and effective configuration.""" recordType: Literal["automatedWorkflowRequest"] = "automatedWorkflowRequest" - formatVersion: Literal[1] = 1 + formatVersion: Literal[2] = 2 workflowRunId: str = "" createdAtNs: int = Field(default=0, ge=0) request: AutomatedWorkflowRequest = Field( diff --git a/scarf/agent/orchestrator/preprocessing.py b/scarf/agent/orchestrator/preprocessing.py index de82328b..c78b045f 100644 --- a/scarf/agent/orchestrator/preprocessing.py +++ b/scarf/agent/orchestrator/preprocessing.py @@ -7,11 +7,13 @@ import numpy as np +from ...assay import RNAassay from ...datastore.datastore import DataStore from ...datastore.summary import AssaySummary from ...metadata.selection import NamedCellArtifact from ...storage.refs import ArtifactRef from ...storage.selections import read_stored_selection_mask +from ...storage.types import as_zarr_array from ...utils.logging import logger from .. import record_io from ..data_enrichment import ( @@ -19,10 +21,33 @@ DataEnrichmentReport, FeatureSelectionPolicy, ) -from ..experimental_context import CellQcPlan, ExperimentalContextResult +from ..decision_kernel import DecisionEvidence, DecisionSelection, EvidenceBundle +from ..experimental_context import ( + CellQcPlan, + CellQcProfileEvidence, + ExperimentalContextResult, +) +from ..hvg_diagnostics import run_hvg_diagnostic_artifacts from ..persistence import AgentWorkflowRun +from ..qc_execution import execute_registered_cell_qc +from ..rna_decisions import ( + CellQualityExecutorPayload, + CellQualityProfile, + FeaturePolicyExecutorPayload, + HvgExecutorPayload, + HvgRankingExecutorPayload, + QcGroupingExecutorPayload, + build_cell_quality_decision, + build_feature_policy_decision, + build_hvg_count_decision, + build_hvg_ranking_decision, + build_qc_grouping_decision, + require_option_evidence, +) +from ..study_contract import StudyContract from ..types import ArtifactReferenceModel from . import journal +from .decisions import DecisionStagesMixin from .models import ( AssayPreprocessingPlan, AutomatedPreprocessingPlan, @@ -34,11 +59,46 @@ WorkflowQuestion, WorkflowStageAttempt, WorkflowStageLink, + WorkflowStageName, artifact_model_to_ref, ) -class PreprocessingStagesMixin: +class _DecisionNeedsInput(RuntimeError): + def __init__( + self, + question: WorkflowQuestion, + snapshot_sha256: str, + ) -> None: + super().__init__("A registered RNA decision requires human input") + self.question = question + self.snapshotSha256 = snapshot_sha256 + + +def apply_feature_policy_to_plan( + plan: AutomatedPreprocessingPlan, + payload: FeaturePolicyExecutorPayload, +) -> AutomatedPreprocessingPlan: + assays: list[AssayPreprocessingPlan] = [] + for assay in plan.assays: + if assay.assay != plan.primaryAssay: + assays.append(assay) + continue + parameters = { + **assay.featureParameters, + "excludeFamilies": list(payload.excludedFamilies), + } + assays.append(assay.model_copy(update={"featureParameters": parameters})) + updated = plan.model_copy(update={"assays": assays, "planChecksum": ""}) + checksum = hashlib.sha256( + record_io.canonical_json_bytes( + updated.model_dump(mode="json", exclude={"planChecksum"}) + ) + ).hexdigest() + return updated.model_copy(update={"planChecksum": checksum}) + + +class PreprocessingStagesMixin(DecisionStagesMixin): """Stages that plan and execute modality-specific preprocessing.""" @staticmethod @@ -60,6 +120,394 @@ def _cell_qc_stage_artifacts( artifacts[key] = plan.sampleArtifact.artifact return artifacts + @classmethod + def _cell_qc_candidate_artifacts( + cls, + profiles: Sequence[CellQcProfileEvidence], + ) -> dict[str, ArtifactReferenceModel]: + artifacts: dict[str, ArtifactReferenceModel] = {} + for profile in profiles: + plan = CellQcPlan( + action=profile.action, + registeredProfile=profile.registeredProfile, + profileId=profile.profileId, + driverAssay=profile.driverAssay, + driverAssayType=profile.driverAssayType, + sampleColumn=profile.sampleColumn, + sampleArtifact=profile.sampleArtifact, + attributes=profile.attributes, + artifactMetrics=profile.artifactMetrics, + evidenceIds=[profile.evidenceId], + ) + for key, artifact in cls._cell_qc_stage_artifacts(plan).items(): + existing = artifacts.get(key) + if existing is not None and existing != artifact: + raise ValueError( + f"Cell-QC candidate artifact key {key!r} is ambiguous" + ) + artifacts[key] = artifact + return artifacts + + @staticmethod + def _decision_evidence_bundle( + decision_id: str, + evidence: list[DecisionEvidence], + ) -> EvidenceBundle: + digest = hashlib.sha256( + record_io.canonical_json_bytes( + [item.model_dump(mode="json") for item in evidence] + ) + ).hexdigest() + return EvidenceBundle( + bundleId=f"bundle:{decision_id}:{digest[:24]}", + decisionId=decision_id, + evidence=evidence, + ).with_content_sha256() + + @staticmethod + def _profile_is_safe(profile: CellQcProfileEvidence) -> bool: + if profile.unsafeRetentionGroups: + return False + if ( + profile.registeredProfile + in { + "captureMad5", + "captureMad3Sensitivity", + } + and profile.failedCaptureCandidates + ): + return False + if profile.registeredProfile == "pooledReferenceMad5" and set( + profile.failedCaptureCandidates + ).intersection(profile.parameters.get("pooledReferenceCaptures", [])): + return False + return True + + @staticmethod + def _profile_evidence(profile: CellQcProfileEvidence) -> DecisionEvidence: + return DecisionEvidence( + evidenceId=profile.evidenceId, + evidenceClass="qualityControl", + summary=( + f"{profile.registeredProfile} retains " + f"{profile.retainedCells}/{profile.activeCells} active cells; " + f"retention by capture={profile.sampleRetainedCells}; " + f"retention by design column={profile.retainedCellsByColumn}; " + f"failed capture candidates={profile.failedCaptureCandidates}; " + f"unsafe retention groups={profile.unsafeRetentionGroups}." + ), + artifactReferences=[ + *[source.artifact for source in profile.artifactMetrics], + *( + [profile.sampleArtifact.artifact] + if profile.sampleArtifact is not None + else [] + ), + ], + ) + + def _resolve_qc_grouping_decision( + self, + store: DataStore, + request_record: OrchestrationRequestRecord, + experimental: ExperimentalContextResult, + study_contract: StudyContract, + answers: Mapping[str, Any], + ) -> tuple[QcGroupingExecutorPayload, str]: + profiles = [ + profile + for profile in experimental.qcProfiles + if profile.registeredProfile is not None + ] + if not profiles: + raise ValueError("RNA decision workflow requires registered QC evidence") + safe_profiles = { + profile.registeredProfile: profile + for profile in profiles + if profile.registeredProfile is not None and self._profile_is_safe(profile) + } + capture_eligible = bool( + study_contract.physicalCaptureColumn is not None + and "captureMad5" in safe_profiles + ) + pooled_eligible = bool( + capture_eligible and "pooledReferenceMad5" in safe_profiles + ) + design_id = "evidence:qcGrouping:studyContract" + evidence = [ + DecisionEvidence( + evidenceId=design_id, + evidenceClass="design", + summary=( + "The validated physical capture is " + f"{study_contract.physicalCaptureColumn!r}; independent units=" + f"{study_contract.independentUnitColumns}; conditions=" + f"{study_contract.conditionColumns}." + ), + ) + ] + mode_profile: dict[str, CellQcProfileEvidence] = {} + global_profile = safe_profiles.get("globalMad5") or safe_profiles.get( + "retainWithFlags" + ) + if global_profile is None: + raise ValueError("No safe global or retain-only QC profile is available") + mode_profile["qcGrouping:global"] = global_profile + evidence.append(self._profile_evidence(global_profile)) + if capture_eligible: + capture_profile = safe_profiles["captureMad5"] + mode_profile["qcGrouping:physicalCapture"] = capture_profile + evidence.append(self._profile_evidence(capture_profile)) + if pooled_eligible: + pooled_profile = safe_profiles["pooledReferenceMad5"] + mode_profile["qcGrouping:pooledReference"] = pooled_profile + evidence.append(self._profile_evidence(pooled_profile)) + bundle = self._decision_evidence_bundle("qcGrouping", evidence) + definition = build_qc_grouping_decision( + evidence_bundle_id=bundle.bundleId, + physical_capture_eligible=capture_eligible, + pooled_reference_eligible=pooled_eligible, + ) + definition = require_option_evidence( + definition, + { + option_id: [design_id, profile.evidenceId] + for option_id, profile in mode_profile.items() + }, + ) + selectable_ids = [ + option.optionId + for option in definition.spec.options + if option.status != "defer" + ] + rule_selection = ( + DecisionSelection( + selectedOptionId=selectable_ids[0], + evidenceIds=list( + definition.spec.option_by_id()[ + selectable_ids[0] + ].requiredEvidenceIds + ), + rationale=( + "Use the only grouping mode licensed by the validated design " + "and retention evidence." + ), + ) + if len(selectable_ids) == 1 + else None + ) + resolution = self._resolve_rna_decision( + store, + request_record, + definition, + bundle, + answers, + rule_selection=rule_selection, + ) + if resolution.compiled is None: + raise _DecisionNeedsInput( + self._pending_decision_question(resolution, definition), + resolution.snapshotSha256, + ) + payload = resolution.compiled.executorPayload + if not isinstance(payload, QcGroupingExecutorPayload): + raise TypeError("QC-grouping decision compiled an unexpected payload") + return payload, resolution.snapshotSha256 + + def _resolve_cell_quality_decision( + self, + store: DataStore, + request_record: OrchestrationRequestRecord, + experimental: ExperimentalContextResult, + grouping: QcGroupingExecutorPayload, + answers: Mapping[str, Any], + ) -> tuple[CellQualityExecutorPayload, CellQcPlan, str]: + all_profiles = [ + profile + for profile in experimental.qcProfiles + if profile.registeredProfile is not None + ] + allowed_by_grouping: dict[str, set[CellQualityProfile]] = { + "global": {"retainWithFlags", "globalMad5"}, + "physicalCapture": {"retainWithFlags", "captureMad5"}, + "pooledReference": {"retainWithFlags", "pooledReferenceMad5"}, + } + allowed = allowed_by_grouping[grouping.groupingMode] + profiles = [ + profile + for profile in all_profiles + if profile.registeredProfile in allowed and self._profile_is_safe(profile) + ] + if not profiles: + raise ValueError("QC grouping has no safe registered profile") + evidence = [self._profile_evidence(profile) for profile in profiles] + if grouping.groupingMode == "physicalCapture": + sensitivity = next( + ( + profile + for profile in all_profiles + if profile.registeredProfile == "captureMad3Sensitivity" + ), + None, + ) + if sensitivity is not None: + evidence.append(self._profile_evidence(sensitivity)) + bundle = self._decision_evidence_bundle("cellQuality", evidence) + available_profiles = [ + profile.registeredProfile + for profile in profiles + if profile.registeredProfile is not None + ] + definition = build_cell_quality_decision( + evidence_bundle_id=bundle.bundleId, + available_profiles=available_profiles, + ) + definition = require_option_evidence( + definition, + { + f"cellQuality:{profile.registeredProfile}": [profile.evidenceId] + for profile in profiles + }, + ) + resolution = self._resolve_rna_decision( + store, + request_record, + definition, + bundle, + answers, + ) + if resolution.compiled is None: + raise _DecisionNeedsInput( + self._pending_decision_question(resolution, definition), + resolution.snapshotSha256, + ) + payload = resolution.compiled.executorPayload + if not isinstance(payload, CellQualityExecutorPayload): + raise TypeError("Cell-quality decision compiled an unexpected payload") + selected = next( + ( + profile + for profile in profiles + if profile.registeredProfile == payload.profile + ), + None, + ) + if selected is None or resolution.record is None: + raise ValueError("Audited cell-quality option lacks its exact profile") + plan = CellQcPlan( + action=selected.action, + registeredProfile=selected.registeredProfile, + profileId=selected.profileId, + driverAssay=selected.driverAssay, + driverAssayType=selected.driverAssayType, + sampleColumn=selected.sampleColumn, + sampleArtifact=selected.sampleArtifact, + attributes=selected.attributes, + artifactMetrics=selected.artifactMetrics, + rationale=resolution.record.rationale, + evidenceIds=list(resolution.record.evidenceIds), + ) + return payload, plan, resolution.snapshotSha256 + + def _resolve_feature_policy_decision( + self, + store: DataStore, + request_record: OrchestrationRequestRecord, + plan: AutomatedPreprocessingPlan, + enrichment: DataEnrichmentReport, + answers: Mapping[str, Any], + ) -> tuple[FeaturePolicyExecutorPayload, str]: + policy = next( + ( + value + for value in enrichment.policies + if value.assay == plan.primaryAssay + ), + None, + ) + nominations = list(policy.excludeFamilies) if policy is not None else [] + protected = list(policy.protectFamilies) if policy is not None else [] + evidence_id = f"evidence:featurePolicy:{plan.primaryAssay}" + bundle = self._decision_evidence_bundle( + "featurePolicy", + [ + DecisionEvidence( + evidenceId=evidence_id, + evidenceClass="technical", + summary=( + f"Data Enrichment nominated {sorted(nominations)} and " + f"protected {sorted(protected)}. No representation-dominance " + "evidence exists before the native PCA diagnostic." + ), + ) + ], + ) + definition = build_feature_policy_decision( + evidence_bundle_id=bundle.bundleId, + proposed_exclusion_families=[], + dominant_families=[], + protected_families=[], + ) + definition = require_option_evidence( + definition, + {"featurePolicy:keepAll": [evidence_id]}, + ) + resolution = self._resolve_rna_decision( + store, + request_record, + definition, + bundle, + answers, + rule_selection=DecisionSelection( + selectedOptionId="featurePolicy:keepAll", + evidenceIds=[evidence_id], + rationale=( + "Keep the conditional families until native representation " + "evidence demonstrates technical dominance." + ), + ), + ) + if resolution.compiled is None: + raise RuntimeError("A rule-owned feature decision cannot be pending") + payload = resolution.compiled.executorPayload + if not isinstance(payload, FeaturePolicyExecutorPayload): + raise TypeError("Feature-policy decision compiled an unexpected payload") + return payload, resolution.snapshotSha256 + + @staticmethod + def _apply_feature_policy_to_plan( + plan: AutomatedPreprocessingPlan, + payload: FeaturePolicyExecutorPayload, + ) -> AutomatedPreprocessingPlan: + return apply_feature_policy_to_plan(plan, payload) + + @staticmethod + def _plan_with_selected_hvg_counts( + plan: AutomatedPreprocessingPlan, + handoffs: Sequence[PreprocessedAssayHandoff], + ) -> AutomatedPreprocessingPlan: + selected_counts = {handoff.assay: handoff.nFeatures for handoff in handoffs} + assays = [ + assay.model_copy( + update={ + "featureParameters": { + **assay.featureParameters, + "topN": selected_counts[assay.assay], + } + } + ) + if assay.featureMethod == "hvg" and assay.assay in selected_counts + else assay + for assay in plan.assays + ] + updated = plan.model_copy(update={"assays": assays, "planChecksum": ""}) + checksum = hashlib.sha256( + record_io.canonical_json_bytes( + updated.model_dump(mode="json", exclude={"planChecksum"}) + ) + ).hexdigest() + return updated.model_copy(update={"planChecksum": checksum}) + def preprocessing_plan_stage( self, store: DataStore, @@ -69,6 +517,7 @@ def preprocessing_plan_stage( enrichment: DataEnrichmentReport, experimental: ExperimentalContextResult, ingest_outcome: WorkflowStageAttempt, + study_contract: StudyContract, answers: Mapping[str, Any], *, resume_record: OrchestrationResumeRecord | None = None, @@ -91,7 +540,7 @@ def preprocessing_plan_stage( ) if experimental.cellSelection is None: raise ValueError("Experimental Context lacks an exact cell selection") - cell_qc_artifacts = self._cell_qc_stage_artifacts(experimental.cellQc) + cell_qc_artifacts = self._cell_qc_candidate_artifacts(experimental.qcProfiles) started = journal._start_attempt( store.zw, prefix, @@ -100,20 +549,61 @@ def preprocessing_plan_stage( request_record, parents, inputs={ - "approvalAnswer": answers.get("approvePlanChecksum"), - "allowAssumptions": request_record.request.allowAssumptions, "cellSelection": experimental.cellSelection.model_dump(mode="json"), + "decisionPolicy": "evidenceBoundedAutomaticExecution", + "studyContract": study_contract.model_dump(mode="json"), }, resume_record=resume_record, ) try: + grouping_payload, grouping_decision_snapshot = ( + self._resolve_qc_grouping_decision( + store, + request_record, + experimental, + study_contract, + answers, + ) + ) + cell_payload, cell_qc, cell_decision_snapshot = ( + self._resolve_cell_quality_decision( + store, + request_record, + experimental, + grouping_payload, + answers, + ) + ) plan = self.build_preprocessing_plan( store, request_record, enrichment, experimental, ingest_outcome, + cell_qc, + ) + graph_plans = [value for value in plan.assays if value.graphEligible] + if ( + len(graph_plans) != 1 + or graph_plans[0].assayType != "RNA" + or plan.pairedAssays + or plan.primaryAssay != graph_plans[0].assay + or plan.markerAssay != graph_plans[0].assay + ): + raise ValueError( + "The automated decision workflow accepts one unpaired RNA assay" + ) + plan = plan.model_copy(update={"cellQualityPayload": cell_payload}) + feature_payload, feature_decision_snapshot = ( + self._resolve_feature_policy_decision( + store, + request_record, + plan, + enrichment, + answers, + ) ) + plan = self._apply_feature_policy_to_plan(plan, feature_payload) route_summary = ", ".join( f"{value.assay}:{value.featureMethod}/{value.reductionMethod}" for value in plan.assays @@ -123,6 +613,20 @@ def preprocessing_plan_stage( f"(primary={plan.primaryAssay!r}, marker={plan.markerAssay!r}, " f"routes=[{route_summary}])" ) + except _DecisionNeedsInput as pending: + outcome = journal._complete_attempt( + started, + status="needsInput", + artifacts={ + "cellSelection": experimental.cellSelection, + **cell_qc_artifacts, + }, + outputs={"decisionSnapshotSha256": pending.snapshotSha256}, + needs_input=WorkflowNeedsInput(questions=[pending.question]), + notes=["A registered filtering decision requires input."], + ) + journal._save_outcome(store.zw, prefix, outcome) + return outcome, AutomatedPreprocessingPlan.get_blank() except Exception as exc: outcome = journal.finish_exception( store, @@ -136,57 +640,30 @@ def preprocessing_plan_stage( }, ) return outcome, AutomatedPreprocessingPlan.get_blank() - supplied_approval = answers.get("approvePlanChecksum") - preapproved = bool( - request_record.request.experimentalDirections.get( - "approveAutomatedAnalysis", False - ) + logger.info( + f"Workflow {workflow.workflowRunId}: executing the evidence-bounded " + "preprocessing plan" ) - approved = ( - request_record.request.allowAssumptions - or preapproved - or supplied_approval == plan.planChecksum + outcome = journal._complete_attempt( + started, + status="done", + artifacts={ + "cellSelection": experimental.cellSelection, + **cell_qc_artifacts, + }, + outputs={ + "preprocessingPlan": plan.model_dump(mode="json"), + "qcGroupingDecisionSnapshot": grouping_decision_snapshot, + "cellQualityDecisionSnapshot": cell_decision_snapshot, + "featurePolicyDecisionSnapshot": feature_decision_snapshot, + }, + actions=[ + "audit_qc_grouping_decision", + "audit_cell_quality_decision", + "audit_feature_policy_decision", + "accept_evidence_bounded_preprocessing_plan", + ], ) - if not approved: - logger.info( - f"Workflow {workflow.workflowRunId}: preprocessing plan requires " - "caller approval" - ) - outcome = journal._complete_attempt( - started, - status="needsInput", - artifacts={ - "cellSelection": experimental.cellSelection, - **cell_qc_artifacts, - }, - outputs={"preprocessingPlan": plan.model_dump(mode="json")}, - needs_input=WorkflowNeedsInput( - questions=[ - WorkflowQuestion( - questionId="approvePlanChecksum", - question=( - "Approve the persisted preprocessing and bounded " - "parameter-tuning plan?" - ), - planChecksum=plan.planChecksum, - ) - ] - ), - ) - else: - logger.info( - f"Workflow {workflow.workflowRunId}: preprocessing plan approved" - ) - outcome = journal._complete_attempt( - started, - status="done", - artifacts={ - "cellSelection": experimental.cellSelection, - **cell_qc_artifacts, - }, - outputs={"preprocessingPlan": plan.model_dump(mode="json")}, - actions=["approve_preprocessing_plan"], - ) journal._save_outcome(store.zw, prefix, outcome) return outcome, plan @@ -197,6 +674,7 @@ def build_preprocessing_plan( enrichment: DataEnrichmentReport, experimental: ExperimentalContextResult, ingest_outcome: WorkflowStageAttempt, + cell_qc: CellQcPlan, ) -> AutomatedPreprocessingPlan: request = request_record.request store_summary = store.summary() @@ -211,7 +689,7 @@ def build_preprocessing_plan( ( value for value in experimental.qcProfiles - if value.profileId == experimental.cellQc.profileId + if value.profileId == cell_qc.profileId ), None, ) @@ -314,13 +792,14 @@ def build_preprocessing_plan( paired = [] if len(graph_assays) > 1: limitations.append( - "Multimodal integration skipped because pairing provenance was not supplied" + "Multimodal integration skipped because pairing provenance " + "was not supplied" ) final_plan = AutomatedPreprocessingPlan( primaryAssay=primary, markerAssay=marker_assay, cellSelection=experimental.cellSelection, - cellQc=experimental.cellQc, + cellQc=cell_qc, assays=assay_plans, pairedAssays=paired, limitations=list(dict.fromkeys(limitations)), @@ -365,6 +844,9 @@ def build_assay_preprocessing_plan( evidence_ids = list(policy.evidenceIds) if modality == "RNA": graph_eligible = summary.total_features >= 3 + proposed_families = ( + list(policy.excludeFamilies) if policy is not None else [] + ) return AssayPreprocessingPlan( assay=assay_name, assayType=summary.assay_type, @@ -374,18 +856,22 @@ def build_assay_preprocessing_plan( featureMethod="hvg" if graph_eligible else "none", reductionMethod="pca" if graph_eligible else "none", featureParameters={ - "topN": min(1000, summary.total_features), + "topN": min(2000, summary.total_features), "minCells": effective_min_cells, - "excludeFamilies": ( - list(policy.excludeFamilies) if policy is not None else [] + "excludeFamilies": [], + "proposedExcludeFamilies": proposed_families, + "protectFamilies": ( + list(policy.protectFamilies) if policy is not None else [] ), }, normalizationParameters={ "logTransform": True, "renormalizeSubset": True, }, - reductionParameters={"dimensions": 21}, - exactExcludedFeatures=excluded, + reductionParameters={"dimensions": min(50, summary.total_features - 1)}, + exactExcludedFeatures=( + list(policy.artificialFeatures) if policy is not None else [] + ), evidenceIds=evidence_ids, limitations=( [] @@ -519,15 +1005,22 @@ def preprocessing_stage( parents: Sequence[WorkflowStageLink], plan: AutomatedPreprocessingPlan, experimental: ExperimentalContextResult, + study_contract: StudyContract, + answers: Mapping[str, Any], *, resume_record: OrchestrationResumeRecord | None = None, - ) -> tuple[WorkflowStageAttempt, list[PreprocessedAssayHandoff]]: + stage_name: WorkflowStageName = "preprocessing", + ) -> tuple[ + WorkflowStageAttempt, + list[PreprocessedAssayHandoff], + AutomatedPreprocessingPlan, + ]: prefix = journal._ensure_orchestration_store(store) existing = journal._validated_done_outcome( store, prefix, workflow.workflowRunId, - "preprocessing", + stage_name, request_record, parents, ) @@ -535,26 +1028,28 @@ def preprocessing_stage( logger.info( f"Workflow {workflow.workflowRunId}: reusing preprocessing artifacts" ) - return existing, [ - PreprocessedAssayHandoff.model_validate(value) - for value in existing.outputs["assays"] - ] + return ( + existing, + [ + PreprocessedAssayHandoff.model_validate(value) + for value in existing.outputs["assays"] + ], + AutomatedPreprocessingPlan.model_validate( + existing.outputs["resolvedPreprocessingPlan"] + ), + ) if plan.cellSelection is None: raise ValueError("Preprocessing plan lacks an exact cell selection") if experimental.cellSelection != plan.cellSelection: raise ValueError( "Preprocessing plan and Experimental Context selections differ" ) - if experimental.cellQc != plan.cellQc: - raise ValueError( - "Preprocessing plan and Experimental Context cell-QC plans differ" - ) input_cell_selection = artifact_model_to_ref(plan.cellSelection) started = journal._start_attempt( store.zw, prefix, workflow.workflowRunId, - "preprocessing", + stage_name, request_record, parents, inputs={ @@ -570,17 +1065,30 @@ def preprocessing_stage( **self._cell_qc_stage_artifacts(plan.cellQc), } try: + if plan.cellQualityPayload is None: + raise ValueError( + "Decision-driven preprocessing requires an audited " + "cell-quality payload" + ) cell_selection = self.apply_cell_qc( store, experimental, input_cell_selection, actions, operations, + selected_plan=plan.cellQc, + decision_payload=plan.cellQualityPayload, ) cell_selection_model = ArtifactReferenceModel.from_artifact_ref( cell_selection ) artifacts["cellSelection"] = cell_selection_model + if operations: + diagnostic_flags = operations[-1].get("diagnosticFlags") + if diagnostic_flags is not None: + artifacts["cellQcDiagnosticFlags"] = ( + ArtifactReferenceModel.model_validate(diagnostic_flags) + ) active_cells = int( read_stored_selection_mask( store.zw, @@ -595,7 +1103,7 @@ def preprocessing_stage( ( profile for profile in experimental.qcProfiles - if profile.profileId == experimental.cellQc.profileId + if profile.profileId == plan.cellQc.profileId ), None, ) @@ -627,11 +1135,18 @@ def preprocessing_stage( cell_selection=cell_selection, cell_selection_model=cell_selection_model, active_cells=active_cells, + request_record=request_record, + study_contract=study_contract, + answers=answers, actions=actions, operations=operations, artifacts=artifacts, ) ) + resolved_plan = self._plan_with_selected_hvg_counts( + plan, + handoffs, + ) outcome = journal._complete_attempt( started, status="done", @@ -643,6 +1158,7 @@ def preprocessing_stage( outputs={ "assays": [value.model_dump(mode="json") for value in handoffs], "cellSelection": cell_selection_model.model_dump(mode="json"), + "resolvedPreprocessingPlan": resolved_plan.model_dump(mode="json"), "operations": operations, }, actions=actions, @@ -652,7 +1168,26 @@ def preprocessing_stage( f"Workflow {workflow.workflowRunId}: preprocessing produced " f"{len(handoffs)} graph-ready assay handoff(s)" ) - return outcome, handoffs + return outcome, handoffs, resolved_plan + except _DecisionNeedsInput as pending: + outcome = journal._complete_attempt( + started, + status="needsInput", + artifacts={ + name: value + for name, value in artifacts.items() + if value is not None + }, + outputs={ + "operations": operations, + "decisionSnapshotSha256": pending.snapshotSha256, + }, + needs_input=WorkflowNeedsInput(questions=[pending.question]), + actions=actions, + notes=["A registered RNA preprocessing decision requires input."], + ) + journal._save_outcome(store.zw, prefix, outcome) + return outcome, [], plan except Exception as exc: outcome = journal.finish_exception( store, @@ -664,7 +1199,86 @@ def preprocessing_stage( actions=actions, outputs={"operations": operations}, ) - return outcome, [] + return outcome, [], plan + + def reuse_feature_policy_preprocessing_stage( + self, + store: DataStore, + workflow: AgentWorkflowRun, + request_record: OrchestrationRequestRecord, + parents: Sequence[WorkflowStageLink], + plan: AutomatedPreprocessingPlan, + baseline_outcome: WorkflowStageAttempt, + baseline_handoffs: Sequence[PreprocessedAssayHandoff], + *, + resume_record: OrchestrationResumeRecord | None = None, + ) -> tuple[ + WorkflowStageAttempt, + list[PreprocessedAssayHandoff], + AutomatedPreprocessingPlan, + ]: + """Record deterministic reuse when feature review keeps the baseline.""" + prefix = journal._ensure_orchestration_store(store) + existing = journal._validated_done_outcome( + store, + prefix, + workflow.workflowRunId, + "feature_policy_preprocessing", + request_record, + parents, + ) + if existing is not None: + return ( + existing, + [ + PreprocessedAssayHandoff.model_validate(value) + for value in existing.outputs["assays"] + ], + AutomatedPreprocessingPlan.model_validate( + existing.outputs["resolvedPreprocessingPlan"] + ), + ) + baseline_plan = AutomatedPreprocessingPlan.model_validate( + baseline_outcome.outputs["resolvedPreprocessingPlan"] + ) + if baseline_plan != plan: + raise ValueError( + "A retained feature policy must reuse the exact baseline plan" + ) + started = journal._start_attempt( + store.zw, + prefix, + workflow.workflowRunId, + "feature_policy_preprocessing", + request_record, + parents, + inputs={ + "baselineAttemptId": baseline_outcome.attemptId, + "preprocessingPlan": plan.model_dump(mode="json"), + }, + resume_record=resume_record, + ) + outcome = journal._complete_attempt( + started, + status="done", + artifacts=dict(baseline_outcome.artifacts), + outputs={ + "assays": [ + value.model_dump(mode="json") for value in baseline_handoffs + ], + "cellSelection": baseline_outcome.outputs["cellSelection"], + "resolvedPreprocessingPlan": plan.model_dump(mode="json"), + "operations": [ + { + "operation": "reuse_baseline_preprocessing", + "attemptId": baseline_outcome.attemptId, + } + ], + }, + actions=["reuse_baseline_preprocessing"], + ) + journal._save_outcome(store.zw, prefix, outcome) + return outcome, list(baseline_handoffs), plan def preprocess_assay( self, @@ -674,6 +1288,9 @@ def preprocess_assay( cell_selection: ArtifactRef, cell_selection_model: ArtifactReferenceModel, active_cells: int, + request_record: OrchestrationRequestRecord | None = None, + study_contract: StudyContract | None = None, + answers: Mapping[str, Any] | None = None, actions: list[str], operations: list[dict[str, Any]], artifacts: dict[str, ArtifactReferenceModel], @@ -682,55 +1299,357 @@ def preprocess_assay( min_cells = int(assay_plan.featureParameters.get("minCells", 1)) marker_features: ArtifactRef if assay_plan.featureMethod == "hvg": - blacklist = self.rna_blacklist(assay_plan) - actual_top_n = min( - int(assay_plan.featureParameters["topN"]), - max(1, assay.feats.N - 1), - ) - hvg_features = store.select_hvgs( + if request_record is None or study_contract is None: + raise ValueError( + "RNA HVG preprocessing requires request and study contracts" + ) + if not isinstance(assay, RNAassay): + raise TypeError("The RNA HVG route requires an RNAassay") + detected = store.select_detected_features( cell_selection, from_assay=assay_plan.assay, min_cells=min_cells, - top_n=actual_top_n, - blacklist=blacklist, - show_plot=False, invalidate_cache=False, ) - graph_features = self.exclude_exact_features( + eligible_features = self.exclude_exact_features( store, assay_plan, - hvg_features, - ) - detected = store.select_detected_features( - cell_selection, - from_assay=assay_plan.assay, - min_cells=min_cells, - invalidate_cache=False, + detected, ) marker_features = self.exclude_exact_features( store, assay_plan, detected, + include_families=False, + ) + technical_columns = [ + value + for value in ( + study_contract.physicalCaptureColumn, + *study_contract.technicalBatchColumns, + ) + if value is not None and value in store.cells.columns + ] + technical_column = technical_columns[0] if technical_columns else None + diagnostics = run_hvg_diagnostic_artifacts( + store.zw, + assay, + cell_selection=cell_selection, + eligible_features=eligible_features, + all_features=store.select_all_features(from_assay=assay_plan.assay), + technical_group_column=technical_column, + min_group_cells=request_record.config.minClusterCells, + min_cells=min_cells, + n_bins=200, + lowess_frac=0.1, + invalidate_cache=False, + candidate_targets=request_record.config.hvgCandidateCounts, + ) + if not diagnostics: + raise ValueError("HVG diagnostics produced no registered ranking") + ranking_evidence: list[DecisionEvidence] = [] + ranking_evidence_ids: dict[str, str] = {} + for candidate_ranking in diagnostics: + mode = candidate_ranking.ranking_mode + diagnostic_model = ArtifactReferenceModel.from_artifact_ref( + candidate_ranking.diagnostic + ) + artifacts[f"{assay_plan.assay}_hvg_{mode}_diagnostic"] = ( + diagnostic_model + ) + for candidate in candidate_ranking.candidates: + artifacts[ + f"{assay_plan.assay}_hvg_{mode}_candidate_{candidate.top_n}" + ] = ArtifactReferenceModel.from_artifact_ref(candidate.features) + evidence_id = ( + f"evidence:hvgRanking:{candidate_ranking.diagnostic.artifact_id}" + ) + ranking_evidence_ids[mode] = evidence_id + ranking_group = store.load_artifact(candidate_ranking.diagnostic) + ranking_values = np.asarray( + as_zarr_array( + ranking_group["ranking"], + name="ranking", + )[:], + dtype=np.int64, + ) + recurrence_values = np.asarray( + as_zarr_array( + ranking_group["recurrence"], + name="recurrence", + )[:], + dtype=np.int32, + ) + within_group_ranks = np.asarray( + as_zarr_array( + ranking_group["mean_within_group_rank"], + name="mean_within_group_rank", + )[:], + dtype=np.float64, + ) + broad_count = max( + candidate.top_n for candidate in candidate_ranking.candidates + ) + broad_indices = ranking_values[:broad_count] + recurrence_summary = "" + if candidate_ranking.valid_groups: + selected_recurrence = recurrence_values[broad_indices] + selected_ranks = within_group_ranks[broad_indices] + finite_ranks = selected_ranks[np.isfinite(selected_ranks)] + median_rank = ( + f"{float(np.median(finite_ranks)):.3f}" + if finite_ranks.size + else "unavailable" + ) + recurrence_summary = ( + f" In the broad {broad_count}-gene candidate, mean technical-" + "group coverage is " + f"{float(selected_recurrence.mean()) / len(candidate_ranking.valid_groups):.1%}, " + f"{float((selected_recurrence >= 2).mean()):.1%} recur in at " + "least two groups, and the median normalized within-group " + f"rank is {median_rank}." + ) + if mode == "batchAware": + summary = ( + "The technical-group ranking uses recurrence and within-group " + f"rank across {len(candidate_ranking.valid_groups)} valid " + f"groups; excluded groups={candidate_ranking.excluded_groups}." + f"{recurrence_summary}" + ) + else: + summary = ( + "The global ranking orders every eligible gene by corrected " + "variability across the exact filtered cell selection." + f"{recurrence_summary}" + ) + ranking_evidence.append( + DecisionEvidence( + evidenceId=evidence_id, + evidenceClass="technical", + summary=summary, + artifactReferences=[diagnostic_model], + ) + ) + ranking_bundle = self._decision_evidence_bundle( + "hvgRanking", + ranking_evidence, + ) + ranking_definition = build_hvg_ranking_decision( + evidence_bundle_id=ranking_bundle.bundleId, + batch_aware_eligible=any( + value.ranking_mode == "batchAware" for value in diagnostics + ), + ) + ranking_definition = require_option_evidence( + ranking_definition, + { + option.optionId: [ranking_evidence_ids[option.payload.rankingMode]] + for option in ranking_definition.executorOptions + if isinstance(option.payload, HvgRankingExecutorPayload) + }, + ) + ranking_options = [ + option + for option in ranking_definition.executorOptions + if isinstance(option.payload, HvgRankingExecutorPayload) + ] + ranking_rule_selection = ( + DecisionSelection( + selectedOptionId=ranking_options[0].optionId, + evidenceIds=list( + ranking_definition.spec.option_by_id()[ + ranking_options[0].optionId + ].requiredEvidenceIds + ), + rationale=( + "Use the only variability ranking licensed by the available " + "technical groups." + ), + ) + if len(ranking_options) == 1 + else None + ) + ranking_resolution = self._resolve_rna_decision( + store, + request_record, + ranking_definition, + ranking_bundle, + answers or {}, + rule_selection=ranking_rule_selection, + ) + if ranking_resolution.compiled is None: + raise _DecisionNeedsInput( + self._pending_decision_question( + ranking_resolution, + ranking_definition, + ), + ranking_resolution.snapshotSha256, + ) + ranking_payload = ranking_resolution.compiled.executorPayload + if not isinstance(ranking_payload, HvgRankingExecutorPayload): + raise TypeError("HVG-ranking decision compiled an unexpected payload") + diagnostic = next( + ( + value + for value in diagnostics + if value.ranking_mode == ranking_payload.rankingMode + ), + None, + ) + if diagnostic is None: + raise ValueError("Selected HVG ranking has no exact diagnostic") + diagnostic_model = ArtifactReferenceModel.from_artifact_ref( + diagnostic.diagnostic ) actions.extend( [ - f"select_hvgs:{assay_plan.assay}", + f"diagnose_hvg_candidates:{assay_plan.assay}", + f"audit_hvg_ranking:{assay_plan.assay}", f"select_marker_features:{assay_plan.assay}", ] ) + artifacts[f"{assay_plan.assay}_hvg_diagnostic"] = diagnostic_model + for candidate in diagnostic.candidates: + artifacts[f"{assay_plan.assay}_hvg_candidate_{candidate.top_n}"] = ( + ArtifactReferenceModel.from_artifact_ref(candidate.features) + ) + diagnostic_group = store.load_artifact(diagnostic.diagnostic) + ranking = np.asarray( + as_zarr_array(diagnostic_group["ranking"], name="ranking")[:], + dtype=np.int64, + ) + corrected_variance = np.asarray( + as_zarr_array( + diagnostic_group["global_corrected_variance"], + name="global_corrected_variance", + )[:], + dtype=np.float64, + ) + eligible = np.asarray( + as_zarr_array(diagnostic_group["eligible"], name="eligible")[:], + dtype=bool, + ) + recurrence = np.asarray( + as_zarr_array( + diagnostic_group["recurrence"], + name="recurrence", + )[:], + dtype=np.int32, + ) + eligible_variance = float(corrected_variance[eligible].sum()) + candidate_evidence: list[DecisionEvidence] = [] + evidence_ids_by_count: dict[int, str] = {} + for candidate in diagnostic.candidates: + selected_indices = ranking[: candidate.top_n] + variance_fraction = ( + float(corrected_variance[selected_indices].sum()) + / eligible_variance + if eligible_variance > 0 + else 0.0 + ) + evidence_id = ( + f"evidence:hvg:{diagnostic.diagnostic.artifact_id}:" + f"top{candidate.top_n}" + ) + evidence_ids_by_count[candidate.top_n] = evidence_id + summary = ( + f"The {candidate.top_n}-gene {diagnostic.ranking_mode} candidate " + "captures " + f"{variance_fraction:.1%} of corrected variance across " + f"{diagnostic.eligible_feature_count} eligible genes." + ) + if diagnostic.valid_groups: + replicated = recurrence[selected_indices] >= max( + 2, + (len(diagnostic.valid_groups) + 1) // 2, + ) + summary += ( + f" {float(replicated.mean()):.1%} of selected genes recur " + "across the registered technical-group rankings." + ) + candidate_evidence.append( + DecisionEvidence( + evidenceId=evidence_id, + evidenceClass="technical", + summary=summary, + artifactReferences=[ + diagnostic_model, + ArtifactReferenceModel.from_artifact_ref( + candidate.features + ), + ], + ) + ) + bundle = self._decision_evidence_bundle( + "hvgCount", + candidate_evidence, + ) + definition = build_hvg_count_decision( + evidence_bundle_id=bundle.bundleId, + eligible_feature_count=diagnostic.eligible_feature_count, + ranking_mode=diagnostic.ranking_mode, + valid_technical_groups=len(diagnostic.valid_groups), + candidate_counts=[ + candidate.top_n for candidate in diagnostic.candidates + ], + ) + definition = require_option_evidence( + definition, + { + option.optionId: [evidence_ids_by_count[option.payload.topN]] + for option in definition.executorOptions + if isinstance(option.payload, HvgExecutorPayload) + }, + ) + resolution = self._resolve_rna_decision( + store, + request_record, + definition, + bundle, + answers or {}, + ) + if resolution.compiled is None: + raise _DecisionNeedsInput( + self._pending_decision_question(resolution, definition), + resolution.snapshotSha256, + ) + hvg_payload = resolution.compiled.executorPayload + if not isinstance(hvg_payload, HvgExecutorPayload): + raise TypeError("HVG decision compiled an unexpected payload") + selected_candidate = next( + ( + candidate + for candidate in diagnostic.candidates + if candidate.top_n == hvg_payload.topN + ), + None, + ) + if selected_candidate is None: + raise ValueError( + "Selected HVG count has no exact persisted candidate artifact" + ) + graph_features = selected_candidate.features + actions.append(f"audit_hvg_count:{assay_plan.assay}") operations.append( { - "operation": "select_hvgs", + "operation": "diagnose_hvg_candidates", "assay": assay_plan.assay, "cellSelection": cell_selection_model.model_dump(mode="json"), "minCells": min_cells, - "topN": actual_top_n, - "blacklist": blacklist, - "showPlot": False, + "technicalGroupColumn": technical_column, + "rankingMode": diagnostic.ranking_mode, + "validTechnicalGroups": list(diagnostic.valid_groups), + "excludedTechnicalGroups": list(diagnostic.excluded_groups), + "candidateCounts": [ + candidate.top_n for candidate in diagnostic.candidates + ], + "selectedTopN": hvg_payload.topN, + "rankingDecisionSnapshotSha256": ( + ranking_resolution.snapshotSha256 + ), + "countDecisionSnapshotSha256": resolution.snapshotSha256, "invalidateCache": False, - "artifact": ArtifactReferenceModel.from_artifact_ref( - hvg_features - ).model_dump(mode="json"), + "artifact": diagnostic_model.model_dump(mode="json"), } ) operations.extend( @@ -739,14 +1658,14 @@ def preprocess_assay( "operation": "set_feature_selection", "assay": assay_plan.assay, "source": ArtifactReferenceModel.from_artifact_ref( - hvg_features + detected ).model_dump(mode="json"), "exactExcludedFeatures": list(assay_plan.exactExcludedFeatures), "excludeFamilies": list( assay_plan.featureParameters.get("excludeFamilies", []) ), "artifact": ArtifactReferenceModel.from_artifact_ref( - graph_features + eligible_features ).model_dump(mode="json"), }, { @@ -765,9 +1684,7 @@ def preprocess_assay( detected ).model_dump(mode="json"), "exactExcludedFeatures": list(assay_plan.exactExcludedFeatures), - "excludeFamilies": list( - assay_plan.featureParameters.get("excludeFamilies", []) - ), + "excludeFamilies": [], "artifact": ArtifactReferenceModel.from_artifact_ref( marker_features ).model_dump(mode="json"), @@ -776,8 +1693,8 @@ def preprocess_assay( ) artifacts.update( { - f"{assay_plan.assay}_hvg_candidates": ( - ArtifactReferenceModel.from_artifact_ref(hvg_features) + f"{assay_plan.assay}_eligible_features": ( + ArtifactReferenceModel.from_artifact_ref(eligible_features) ), f"{assay_plan.assay}_detected_features": ( ArtifactReferenceModel.from_artifact_ref(detected) @@ -909,8 +1826,27 @@ def apply_cell_qc( cell_selection: ArtifactRef, actions: list[str], operations: list[dict[str, Any]], + *, + selected_plan: CellQcPlan | None = None, + decision_payload: CellQualityExecutorPayload | None = None, ) -> ArtifactRef: - plan = experimental.cellQc + plan = selected_plan or experimental.cellQc + if decision_payload is not None: + if plan.registeredProfile != decision_payload.profile: + raise ValueError( + "Cell-QC execution plan differs from its audited payload" + ) + expected_capture = decision_payload.profile in { + "captureMad5", + "captureMad3Sensitivity", + "pooledReferenceMad5", + } + if expected_capture != ( + plan.sampleColumn is not None or plan.sampleArtifact is not None + ): + raise ValueError( + "Cell-QC capture source differs from its audited payload" + ) input_model = ArtifactReferenceModel.from_artifact_ref(cell_selection) logger.info( f"Applying cell QC action={plan.action!r}, profile={plan.profileId!r}" @@ -927,6 +1863,7 @@ def apply_cell_qc( raise ValueError("Experimental Context selected an unknown QC profile") for field_name in ( "action", + "registeredProfile", "driverAssay", "driverAssayType", "sampleColumn", @@ -952,17 +1889,6 @@ def apply_cell_qc( raise ValueError( "Selected cell-QC sample artifact is absent from Experimental Context" ) - if plan.action == "skip": - actions.append("skip_cell_qc") - operations.append( - { - "operation": "skip_cell_qc", - "profileId": plan.profileId, - "cellSelection": input_model.model_dump(mode="json"), - "artifact": input_model.model_dump(mode="json"), - } - ) - return cell_selection artifact_metrics = [ NamedCellArtifact( name=source.name, @@ -978,6 +1904,70 @@ def apply_cell_qc( artifact=artifact_model_to_ref(plan.sampleArtifact.artifact), ) ) + if plan.registeredProfile is not None: + result, diagnostic_flags = execute_registered_cell_qc( + store, + plan.registeredProfile, + profile_parameters=profile.parameters, + expected_active_cells=profile.activeCells, + expected_retained_cells=profile.retainedCells, + expected_flag_counts=profile.flaggedCells, + attrs=plan.attributes, + artifact_metrics=artifact_metrics, + cell_selection=cell_selection, + sample_column=plan.sampleColumn, + sample_artifact=sample_artifact, + invalidate_cache=False, + ) + result_model = ArtifactReferenceModel.from_artifact_ref(result) + flags_model = ( + None + if diagnostic_flags is None + else ArtifactReferenceModel.from_artifact_ref(diagnostic_flags) + ) + actions.append(f"cell_qc_registered:{profile.registeredProfile}") + operations.append( + { + "operation": "run_registered_cell_qc", + "profileId": profile.profileId, + "registeredProfile": profile.registeredProfile, + "cellSelection": input_model.model_dump(mode="json"), + "attrs": list(plan.attributes), + "artifactMetrics": [ + source.model_dump(mode="json") + for source in plan.artifactMetrics + ], + "sampleColumn": plan.sampleColumn, + "sampleArtifact": ( + None + if plan.sampleArtifact is None + else plan.sampleArtifact.model_dump(mode="json") + ), + "profileParameters": profile.parameters, + "expectedActiveCells": profile.activeCells, + "expectedRetainedCells": profile.retainedCells, + "expectedFlagCounts": profile.flaggedCells, + "invalidateCache": False, + "diagnosticFlags": ( + None + if flags_model is None + else flags_model.model_dump(mode="json") + ), + "artifact": result_model.model_dump(mode="json"), + } + ) + return result + if plan.action == "skip": + actions.append("skip_cell_qc") + operations.append( + { + "operation": "skip_cell_qc", + "profileId": plan.profileId, + "cellSelection": input_model.model_dump(mode="json"), + "artifact": input_model.model_dump(mode="json"), + } + ) + return cell_selection if plan.action == "globalGaussian": if plan.sampleColumn is not None or sample_artifact is not None: raise ValueError("globalGaussian QC cannot include a sample source") @@ -1079,9 +2069,13 @@ def exclude_exact_features( store: DataStore, plan: AssayPreprocessingPlan, source: ArtifactRef, + *, + include_families: bool = True, ) -> ArtifactRef: - families = set( - cast(list[str], plan.featureParameters.get("excludeFamilies", [])) + families = ( + set(cast(list[str], plan.featureParameters.get("excludeFamilies", []))) + if include_families + else set() ) if not plan.exactExcludedFeatures and not families: return source diff --git a/scarf/agent/orchestrator/tuning.py b/scarf/agent/orchestrator/tuning.py index cca7544b..36512c8a 100644 --- a/scarf/agent/orchestrator/tuning.py +++ b/scarf/agent/orchestrator/tuning.py @@ -2,7 +2,7 @@ import hashlib import json -from collections.abc import Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence from typing import Any, Literal, cast import numpy as np @@ -10,6 +10,8 @@ from ...datastore.datastore import DataStore from ...utils.logging import logger +from .. import record_io +from ..decision_kernel import DecisionEvidence, DecisionSelection, EvidenceBundle from ..experimental_context import ExperimentalContextResult from ..parameter_tuning import ( ArtifactRecord, @@ -18,6 +20,7 @@ IntegrationCandidateEvaluation, IntegrationMetrics, ParameterCandidate, + ParameterCandidateEvaluation, ParameterTuningAgent, ParameterTuningAssayInput, ParameterTuningReport, @@ -35,8 +38,44 @@ load_agent_report, save_agent_report, ) +from ..sequential_tuning import ( + CorrectionNeedSelection, + ParameterPhaseEvidence, + ParameterPhasePlan, + ParameterPhaseSelection, + SequentialAssayTuningEvidence, + SequentialRnaTuningPlanner, + execute_parameter_phase, + sequential_evidence_to_report, + validate_parameter_phase_selection, +) +from ..rna_decisions import ( + ClusterExecutorPayload, + ConditionalGeneFamily, + CorrectionLicensePayload, + CorrectionNeedPayload, + CorrectionOutcomeExecutorPayload, + FeaturePolicyExecutorPayload, + GraphExecutorPayload, + PcaPrefixExecutorPayload, + build_cluster_partition_decision, + build_correction_license_decision, + build_correction_need_decision, + build_correction_outcome_decision, + build_feature_policy_decision, + build_graph_k_decision, + build_pca_prefix_decision, + require_option_evidence, +) +from ..study_contract import StudyContract +from ..tuning_diagnostics import ( + augment_cluster_evaluations, + augment_pca_evaluations, + score_advisory_doublets, +) from ..types import ArtifactReferenceModel, ExperimentalTuningHandoff from . import journal +from .decisions import DecisionResolution, DecisionStagesMixin from .models import ( AutomatedPreprocessingPlan, AutomatedWorkflowConfig, @@ -47,14 +86,1750 @@ WorkflowQuestion, WorkflowStageAttempt, WorkflowStageLink, + WorkflowStageName, artifact_model_to_ref, ) +from .preprocessing import apply_feature_policy_to_plan + + +def harmony_acceptance_gate( + native: ParameterCandidateEvaluation | None, + harmony: ParameterCandidateEvaluation | None, + *, + batch_columns: Sequence[str], + protected_columns: Sequence[str], + independent_unit_columns: Sequence[str], + tolerance: float = 0.05, +) -> tuple[bool, list[str]]: + """Require measured batch improvement without material biological loss.""" + reasons: list[str] = [] + if native is None or harmony is None: + return False, ["Matched native and Harmony candidates are unavailable."] + native_parameters = native.parameters.model_dump( + mode="json", + exclude={"candidateId", "useHarmony"}, + ) + harmony_parameters = harmony.parameters.model_dump( + mode="json", + exclude={"candidateId", "useHarmony"}, + ) + if native_parameters != harmony_parameters: + reasons.append("Native and Harmony candidate parameters are not matched.") + batch_deltas: dict[str, float] = {} + for column in batch_columns: + native_score = native.metrics.batchMixing.get(column) + harmony_score = harmony.metrics.batchMixing.get(column) + if native_score is None or harmony_score is None: + reasons.append(f"Batch comparison is missing for {column!r}.") + continue + batch_deltas[column] = harmony_score - native_score + if len(batch_deltas) != len(batch_columns): + reasons.append("Not every approved batch metric was compared.") + elif not any(delta > tolerance for delta in batch_deltas.values()): + reasons.append( + "Harmony did not improve an approved batch metric beyond tolerance." + ) + if any(delta < -tolerance for delta in batch_deltas.values()): + reasons.append("Harmony materially worsened an approved batch metric.") + + for column in protected_columns: + native_scores = native.metrics.biologicalPreservation.get(column) + harmony_scores = harmony.metrics.biologicalPreservation.get(column) + if not native_scores or not harmony_scores: + reasons.append(f"Protected comparison is missing for {column!r}.") + continue + missing_metrics = set(native_scores).difference(harmony_scores) + if missing_metrics: + reasons.append( + f"Harmony is missing protected metrics for {column!r}: " + f"{sorted(missing_metrics)}." + ) + continue + shared = set(native_scores).intersection(harmony_scores) + if not shared: + reasons.append(f"Protected metrics do not align for {column!r}.") + continue + if any( + harmony_scores[name] < native_scores[name] - tolerance for name in shared + ): + reasons.append( + f"Harmony materially degraded protected evidence for {column!r}." + ) + if independent_unit_columns: + if ( + native.metrics.crossUnitSupport is None + or harmony.metrics.crossUnitSupport is None + ): + reasons.append("Cross-unit support comparison is missing.") + elif ( + harmony.metrics.crossUnitSupport + < native.metrics.crossUnitSupport - tolerance + ): + reasons.append("Harmony materially degraded cross-unit support.") + if ( + native.metrics.markerCoherence is None + or harmony.metrics.markerCoherence is None + ): + reasons.append("Marker-coherence comparison is missing.") + elif harmony.metrics.markerCoherence < native.metrics.markerCoherence - tolerance: + reasons.append("Harmony materially degraded marker coherence.") + return not reasons, reasons + + +class TuningStagesMixin(DecisionStagesMixin): + """Execute parameter searches, integration comparisons, and graph selection.""" + + model: Any + + @staticmethod + def _tuning_evidence_bundle( + decision_id: str, + evidence: list[DecisionEvidence], + ) -> EvidenceBundle: + digest = hashlib.sha256( + record_io.canonical_json_bytes( + [item.model_dump(mode="json") for item in evidence] + ) + ).hexdigest() + return EvidenceBundle( + bundleId=f"bundle:{decision_id}:{digest[:24]}", + decisionId=decision_id, + evidence=evidence, + ).with_content_sha256() + + @staticmethod + def _evaluation_artifacts( + evaluation: Any, + ) -> list[ArtifactReferenceModel]: + references: list[ArtifactReferenceModel] = [] + identities: set[tuple[str, str | None, str, str]] = set() + for name in sorted(evaluation.artifacts): + reference = ArtifactReferenceModel.model_validate( + evaluation.artifacts[name].model_dump() + ) + identity = ( + reference.scope, + reference.assay, + reference.kind, + reference.artifactId, + ) + if identity not in identities: + identities.add(identity) + references.append(reference) + return references + + @staticmethod + def _phase_from_resolution( + plan: ParameterPhasePlan, + evaluations: Sequence[Any], + resolution: DecisionResolution, + *, + payload_field: str, + payload_value: Any, + ) -> ParameterPhaseEvidence: + if resolution.compiled is None or resolution.record is None: + pending = resolution.pending + selection = ParameterPhaseSelection( + phase=plan.phase, + status="needsInput", + rationale=( + pending.reason + if pending is not None + else "The registered decision is unresolved." + ), + ) + return validate_parameter_phase_selection(plan, evaluations, selection) + selected = next( + ( + evaluation + for evaluation in evaluations + if getattr(evaluation.parameters, payload_field) == payload_value + and evaluation.status == "done" + and evaluation.eligible + ), + None, + ) + if selected is None: + raise ValueError( + "Audited RNA decision has no eligible exact candidate execution" + ) + selection = ParameterPhaseSelection( + phase=plan.phase, + status="selected", + selectedCandidateId=selected.candidateId, + evidenceIds=list(resolution.record.evidenceIds), + rationale=resolution.record.rationale, + ) + return validate_parameter_phase_selection(plan, evaluations, selection) + + def _run_sequential_rna_tuning( + self, + store: DataStore, + workflow: AgentWorkflowRun, + request_record: OrchestrationRequestRecord, + plan: AutomatedPreprocessingPlan, + preprocessed: Sequence[PreprocessedAssayHandoff], + experimental_handoff: ExperimentalTuningHandoff, + study_contract: StudyContract, + answers: Mapping[str, Any], + prior: SequentialAssayTuningEvidence | None, + ) -> tuple[ParameterTuningReport, SequentialAssayTuningEvidence]: + if len(preprocessed) != 1 or plan.pairedAssays: + raise ValueError("Decision-driven v1 tuning accepts one RNA assay only") + handoff = preprocessed[0] + if ( + handoff.assayType != "RNA" + or handoff.normalized is None + or handoff.graphFeatures is None + or handoff.markerFeatures is None + ): + raise ValueError("Decision-driven v1 tuning requires normalized RNA") + if prior is not None and prior.assay != handoff.assay: + raise ValueError("Persisted sequential evidence belongs to another assay") + prior_phases = ( + {value.plan.phase: value for value in prior.phases} + if prior is not None + else {} + ) + + def phase_evaluations( + phase_plan: ParameterPhasePlan, + execute: Callable[[], Sequence[ParameterCandidateEvaluation]], + ) -> tuple[ParameterCandidateEvaluation, ...]: + persisted = prior_phases.get(phase_plan.phase) + if persisted is None: + return tuple(execute()) + if persisted.plan != phase_plan: + raise ValueError( + f"Persisted {phase_plan.phase!r} plan differs from the " + "current registered plan" + ) + logger.info( + f"Workflow {workflow.workflowRunId}: reusing persisted " + f"{phase_plan.phase} executor evidence" + ) + return tuple(persisted.evaluations) + + harmony_authorized = ( + study_contract.correctionLicense == "safe" + and experimental_handoff.batchAction == "evaluateHarmony" + and bool(experimental_handoff.batchColumns) + ) + planner = SequentialRnaTuningPlanner( + workflow_run_id=workflow.workflowRunId, + assay=handoff.assay, + n_cells=handoff.nCells, + n_features=handoff.nFeatures, + harmony_authorized=harmony_authorized, + dimension_candidates=request_record.config.pcaCandidateDimensions, + neighbor_candidates=request_record.config.graphNeighborCandidates, + resolution_candidates=request_record.config.leidenResolutionCandidates, + ) + phase_evidence: list[ParameterPhaseEvidence] = [] + decision_sources: dict[ + str, + Literal["rule", "agent", "human"], + ] = {} + correction_need_selection: CorrectionNeedSelection | None = None + + def build_state( + *, + pending_resolution: DecisionResolution | None = None, + correction_license: str = "notApplicable", + final_candidate_id: str | None = None, + ) -> SequentialAssayTuningEvidence: + pending = ( + pending_resolution.pending if pending_resolution is not None else None + ) + if pending_resolution is not None and pending is None: + raise ValueError( + "Pending tuning resolution lacks pending decision data" + ) + return SequentialAssayTuningEvidence.model_validate( + { + "assay": handoff.assay, + "phases": [ + value.model_dump(mode="json") for value in phase_evidence + ], + "correctionLicense": correction_license, + "correctionNeed": ( + correction_need_selection.model_dump(mode="json") + if correction_need_selection is not None + else None + ), + "decisionSources": decision_sources, + "pendingDecisionId": ( + pending.decisionId if pending is not None else None + ), + "pendingOptionIds": ( + pending.offeredOptionIds if pending is not None else [] + ), + "pendingEvidenceIds": ( + pending.availableEvidenceIds if pending is not None else [] + ), + "finalCandidateId": final_candidate_id, + } + ) + + def return_pending( + resolution: DecisionResolution, + *, + correction_license: str = "notApplicable", + ) -> tuple[ParameterTuningReport, SequentialAssayTuningEvidence]: + state = build_state( + pending_resolution=resolution, + correction_license=correction_license, + ) + return ( + sequential_evidence_to_report( + state, + marker_assay=plan.markerAssay, + ), + state, + ) + + normalized = artifact_model_to_ref(handoff.normalized) + assay_plan = next( + value for value in plan.assays if value.assay == handoff.assay + ) + nominated_families = cast( + list[str], + assay_plan.featureParameters.get("proposedExcludeFamilies", []), + ) + protected_families = cast( + list[str], + assay_plan.featureParameters.get("protectFamilies", []), + ) + pca_plan = planner.pca_prefix_phase() + raw_pca = phase_evaluations( + pca_plan, + lambda: execute_parameter_phase( + store, + normalized=normalized, + plan=pca_plan, + batch_columns=( + experimental_handoff.batchColumns if harmony_authorized else [] + ), + preservation_columns=experimental_handoff.preservationColumns, + experimental_handoff=experimental_handoff, + min_cluster_cells=request_record.config.minClusterCells, + identity_feature_limit=request_record.config.maxIdentityFeatures, + ), + ) + raw_pca = augment_pca_evaluations( + store, + raw_pca, + feature_selection=artifact_model_to_ref(handoff.graphFeatures), + nominated_families=nominated_families, + protected_families=protected_families, + technical_columns=study_contract.technicalBatchColumns, + protected_columns=study_contract.protectedColumns, + qc_columns=[ + column + for column in plan.cellQc.attributes + if column in store.cells.columns + ], + ) + pca_items: list[DecisionEvidence] = [] + pca_evaluations: list[ParameterCandidateEvaluation] = [] + eligible_pca_dimensions: list[int] = [] + pca_evidence_by_dimensions: dict[int, list[str]] = {} + for evaluation in raw_pca: + evidence_ids: list[str] = [] + if evaluation.status == "done" and evaluation.eligible: + eligible_pca_dimensions.append(evaluation.parameters.dimensions) + technical_id = f"evidence:pca:{evaluation.candidateId}:technical" + pca_items.append( + DecisionEvidence( + evidenceId=technical_id, + evidenceClass="technical", + summary=( + f"The exact PCA candidate used " + f"{evaluation.effectiveDimensions} dimensions; " + f"component variance={evaluation.metrics.componentVariance}; " + "maximum nominated-family loading enrichment=" + f"{evaluation.metrics.loadingFamilyEnrichment}; " + "technical PC association=" + f"{evaluation.metrics.technicalPcaAssociation}; " + "protected PC association=" + f"{evaluation.metrics.protectedPcaAssociation}; " + f"QC PC association={evaluation.metrics.qcPcaAssociation}; " + f"warnings={evaluation.warnings}." + ), + artifactReferences=self._evaluation_artifacts(evaluation), + ) + ) + evidence_ids.append(technical_id) + geometric_id = f"evidence:pca:{evaluation.candidateId}:geometric" + pca_items.append( + DecisionEvidence( + evidenceId=geometric_id, + evidenceClass="geometric", + summary=( + "PCA and graph silhouette diagnostics are " + f"{evaluation.metrics.pcaSilhouette} and " + f"{evaluation.metrics.graphSilhouetteMedian}; " + f"the registered graph produced " + f"{evaluation.metrics.nClusters} clusters; adjacent-prefix " + "neighbor overlap=" + f"{evaluation.metrics.neighborPrefixOverlap}." + ), + artifactReferences=self._evaluation_artifacts(evaluation), + ) + ) + evidence_ids.append(geometric_id) + pca_evidence_by_dimensions[evaluation.parameters.dimensions] = list( + evidence_ids + ) + else: + failure_id = f"evidence:pca:{evaluation.candidateId}:failure" + pca_items.append( + DecisionEvidence( + evidenceId=failure_id, + evidenceClass="other", + summary=( + f"The candidate was not eligible: " + f"{evaluation.error or evaluation.eligibilityReasons}." + ), + artifactReferences=self._evaluation_artifacts(evaluation), + ) + ) + evidence_ids.append(failure_id) + pca_evaluations.append( + evaluation.model_copy( + update={ + "evidenceIds": list( + dict.fromkeys([*evaluation.evidenceIds, *evidence_ids]) + ) + } + ) + ) + pca_bundle = self._tuning_evidence_bundle("pcaPrefix", pca_items) + pca_definition = build_pca_prefix_decision( + evidence_bundle_id=pca_bundle.bundleId, + matrix_rank=min(handoff.nCells, handoff.nFeatures) - 1, + candidate_dimensions=( + eligible_pca_dimensions + if eligible_pca_dimensions + else [candidate.dimensions for candidate in pca_plan.candidates] + ), + ) + pca_definition = require_option_evidence( + pca_definition, + { + option.optionId: pca_evidence_by_dimensions[option.payload.dimensions] + for option in pca_definition.executorOptions + if isinstance(option.payload, PcaPrefixExecutorPayload) + and option.payload.dimensions in pca_evidence_by_dimensions + }, + ) + pca_rule_selection = ( + DecisionSelection( + selectedOptionId="pcaPrefix:defer", + evidenceIds=[item.evidenceId for item in pca_bundle.evidence], + rationale=( + "No registered PCA candidate completed with the required " + "technical and geometric evidence." + ), + confidence="notApplicable", + ) + if not eligible_pca_dimensions + else None + ) + pca_resolution = self._resolve_rna_decision( + store, + request_record, + pca_definition, + pca_bundle, + answers, + rule_selection=pca_rule_selection, + ) + pca_payload = ( + pca_resolution.compiled.executorPayload + if pca_resolution.compiled is not None + else None + ) + if pca_payload is not None and not isinstance( + pca_payload, PcaPrefixExecutorPayload + ): + raise TypeError("PCA decision compiled an unexpected payload") + pca_phase = self._phase_from_resolution( + pca_plan, + pca_evaluations, + pca_resolution, + payload_field="dimensions", + payload_value=(pca_payload.dimensions if pca_payload is not None else -1), + ) + phase_evidence.append(pca_phase) + if pca_resolution.record is not None: + decision_sources["pcaPrefix"] = pca_resolution.record.source + selected_pca = pca_phase.selected_evaluation() + if selected_pca is None: + return return_pending(pca_resolution) + + license_evidence_id = "evidence:correctionLicense:studyContract" + license_bundle = self._tuning_evidence_bundle( + "correctionLicense", + [ + DecisionEvidence( + evidenceId=license_evidence_id, + evidenceClass="design", + summary=( + f"The StudyContract license is " + f"{study_contract.correctionLicense}; technical columns=" + f"{study_contract.technicalBatchColumns}; protected columns=" + f"{study_contract.protectedColumns}." + ), + ) + ], + ) + license_definition = build_correction_license_decision( + evidence_bundle_id=license_bundle.bundleId, + license=study_contract.correctionLicense, + ) + license_definition = require_option_evidence( + license_definition, + { + f"correctionLicense:{study_contract.correctionLicense}": [ + license_evidence_id + ] + }, + ) + license_resolution = self._resolve_rna_decision( + store, + request_record, + license_definition, + license_bundle, + answers, + rule_selection=DecisionSelection( + selectedOptionId=( + f"correctionLicense:{study_contract.correctionLicense}" + ), + evidenceIds=[license_evidence_id], + rationale="Apply the exact deterministic StudyContract license.", + ), + ) + if license_resolution.compiled is None: + return return_pending( + license_resolution, + correction_license=study_contract.correctionLicense, + ) + if license_resolution.record is not None: + decision_sources["correctionLicense"] = license_resolution.record.source + license_payload = license_resolution.compiled.executorPayload + if not isinstance(license_payload, CorrectionLicensePayload): + raise TypeError("Correction license compiled an unexpected payload") + + correction_need: str | None = None + if license_payload.license == "safe": + need_items = [ + DecisionEvidence( + evidenceId="evidence:correctionNeed:design", + evidenceClass="design", + summary=( + "The design license is safe, but an indeterminate choice " + "remains available if representation evidence is incomplete." + ), + ) + ] + if ( + selected_pca.metrics.batchMixing + or selected_pca.metrics.technicalPcaAssociation + ): + need_items.append( + DecisionEvidence( + evidenceId="evidence:correctionNeed:batch", + evidenceClass="batchRemoval", + summary=( + "Native representation batch-mixing metrics are " + f"{selected_pca.metrics.batchMixing}; per-PC technical " + "associations are " + f"{selected_pca.metrics.technicalPcaAssociation}." + ), + artifactReferences=self._evaluation_artifacts(selected_pca), + ) + ) + if ( + selected_pca.metrics.biologicalPreservation + or not study_contract.protectedColumns + ): + need_items.append( + DecisionEvidence( + evidenceId="evidence:correctionNeed:biology", + evidenceClass="biologicalConservation", + summary=( + "Native protected-variable diagnostics are " + f"{selected_pca.metrics.biologicalPreservation}; " + f"declared protected columns=" + f"{study_contract.protectedColumns}." + ), + artifactReferences=self._evaluation_artifacts(selected_pca), + ) + ) + need_bundle = self._tuning_evidence_bundle( + "correctionNeed", + need_items, + ) + need_definition = build_correction_need_decision( + evidence_bundle_id=need_bundle.bundleId, + license=license_payload.license, + ) + comparative_need_ids = [ + item.evidenceId + for item in need_items + if item.evidenceClass in {"batchRemoval", "biologicalConservation"} + ] + need_definition = require_option_evidence( + need_definition, + { + "correctionNeed:needed": comparative_need_ids, + "correctionNeed:notNeeded": comparative_need_ids, + "correctionNeed:indeterminate": ["evidence:correctionNeed:design"], + }, + ) + need_resolution = self._resolve_rna_decision( + store, + request_record, + need_definition, + need_bundle, + answers, + ) + if need_resolution.compiled is None: + pending_reason = ( + need_resolution.pending.reason + if need_resolution.pending is not None + else "Correction need remains unresolved." + ) + correction_need_selection = CorrectionNeedSelection( + status="needsInput", + selectedOptionId="correctionNeed:indeterminate", + rationale=pending_reason, + ) + return return_pending( + need_resolution, + correction_license=license_payload.license, + ) + if need_resolution.record is not None: + decision_sources["correctionNeed"] = need_resolution.record.source + need_payload = need_resolution.compiled.executorPayload + if not isinstance(need_payload, CorrectionNeedPayload): + raise TypeError("Correction need compiled an unexpected payload") + correction_need = need_payload.need + assert need_resolution.record is not None + need_option_id: Literal[ + "correctionNeed:needed", + "correctionNeed:notNeeded", + ] = ( + "correctionNeed:needed" + if need_payload.need == "needed" + else "correctionNeed:notNeeded" + ) + correction_need_selection = CorrectionNeedSelection( + status="selected", + selectedOptionId=need_option_id, + evidenceIds=list(need_resolution.record.evidenceIds), + rationale=need_resolution.record.rationale, + ) + + full_correction_plan = planner.batch_correction_phase(selected_pca.parameters) + correction_candidates = list(full_correction_plan.candidates) + if not (license_payload.license == "safe" and correction_need == "needed"): + correction_candidates = [ + candidate + for candidate in correction_candidates + if not candidate.useHarmony + ] + correction_plan = ParameterPhasePlan.model_validate( + { + **full_correction_plan.model_dump(mode="json"), + "candidates": [ + candidate.model_dump(mode="json") + for candidate in correction_candidates + ], + } + ) + correction_evaluations = list( + phase_evaluations( + correction_plan, + lambda: execute_parameter_phase( + store, + normalized=normalized, + plan=correction_plan, + batch_columns=( + experimental_handoff.batchColumns + if any( + candidate.useHarmony + for candidate in correction_plan.candidates + ) + else [] + ), + preservation_columns=experimental_handoff.preservationColumns, + experimental_handoff=experimental_handoff, + min_cluster_cells=request_record.config.minClusterCells, + identity_feature_limit=request_record.config.maxIdentityFeatures, + ), + ) + ) + correction_evaluations = list( + augment_cluster_evaluations( + store, + correction_evaluations, + marker_assay=plan.markerAssay, + marker_features=artifact_model_to_ref(handoff.markerFeatures), + independent_unit_columns=study_contract.independentUnitColumns, + technical_columns=study_contract.technicalBatchColumns, + nominated_families=nominated_families, + protected_families=protected_families, + ) + ) + native_evaluation = next( + ( + evaluation + for evaluation in correction_evaluations + if not evaluation.parameters.useHarmony + and evaluation.status == "done" + and evaluation.eligible + ), + None, + ) + harmony_evaluation = next( + ( + evaluation + for evaluation in correction_evaluations + if evaluation.parameters.useHarmony + and evaluation.status == "done" + and evaluation.eligible + ), + None, + ) + outcome_items: list[DecisionEvidence] = [] + native_biology_id: str | None = None + if native_evaluation is not None: + native_biology_id = "evidence:correctionOutcome:nativeBiology" + outcome_items.append( + DecisionEvidence( + evidenceId=native_biology_id, + evidenceClass="biologicalConservation", + summary=( + "Native protected-variable diagnostics are " + f"{native_evaluation.metrics.biologicalPreservation}; " + "cross-unit support is " + f"{native_evaluation.metrics.crossUnitSupport}; marker " + f"coherence is {native_evaluation.metrics.markerCoherence}." + ), + artifactReferences=self._evaluation_artifacts(native_evaluation), + ) + ) + + harmony_eligible, harmony_gate_reasons = harmony_acceptance_gate( + native_evaluation, + harmony_evaluation, + batch_columns=study_contract.technicalBatchColumns, + protected_columns=study_contract.protectedColumns, + independent_unit_columns=study_contract.independentUnitColumns, + ) + harmony_evidence_ids: list[str] = [] + if harmony_evaluation is not None: + harmony_biology_id = "evidence:correctionOutcome:harmonyBiology" + outcome_items.append( + DecisionEvidence( + evidenceId=harmony_biology_id, + evidenceClass="biologicalConservation", + summary=( + "Harmony protected-variable diagnostics are " + f"{harmony_evaluation.metrics.biologicalPreservation}; " + "cross-unit support is " + f"{harmony_evaluation.metrics.crossUnitSupport}; marker " + f"coherence is {harmony_evaluation.metrics.markerCoherence}; " + f"acceptance gate findings are {harmony_gate_reasons}." + ), + artifactReferences=self._evaluation_artifacts(harmony_evaluation), + ) + ) + harmony_evidence_ids.append(harmony_biology_id) + batch_id = "evidence:correctionOutcome:batchRemoval" + outcome_items.append( + DecisionEvidence( + evidenceId=batch_id, + evidenceClass="batchRemoval", + summary=( + "Matched native and Harmony batch-mixing metrics are " + f"{native_evaluation.metrics.batchMixing if native_evaluation else {}} " + f"and {harmony_evaluation.metrics.batchMixing}; gate findings " + f"are {harmony_gate_reasons}." + ), + artifactReferences=self._evaluation_artifacts(harmony_evaluation), + ) + ) + harmony_evidence_ids.append(batch_id) + if harmony_eligible: + protected_id = "evidence:correctionOutcome:protectedPreservation" + outcome_items.append( + DecisionEvidence( + evidenceId=protected_id, + evidenceClass="protectedVariablePreservation", + summary=( + "Harmony improved at least one approved batch metric " + "beyond 0.05 and did not materially degrade protected, " + "cross-unit, graph-connectivity, or marker evidence." + ), + artifactReferences=self._evaluation_artifacts( + harmony_evaluation + ), + ) + ) + harmony_evidence_ids.append(protected_id) + + outcome_bundle = self._tuning_evidence_bundle( + "correctionOutcome", + outcome_items, + ) + outcome_definition = build_correction_outcome_decision( + evidence_bundle_id=outcome_bundle.bundleId, + license=license_payload.license, + need=( + cast(Any, correction_need) + if license_payload.license == "safe" + else None + ), + harmony_eligible=harmony_eligible, + ) + outcome_definition = require_option_evidence( + outcome_definition, + { + **( + {"correctionOutcome:retainNative": [native_biology_id]} + if native_biology_id is not None + else {} + ), + **( + {"correctionOutcome:acceptHarmony": harmony_evidence_ids} + if harmony_eligible + else {} + ), + }, + ) + native_rule = None + if not harmony_eligible: + native_rule = DecisionSelection( + selectedOptionId=( + "correctionOutcome:retainNative" + if native_biology_id is not None + else "correctionOutcome:indeterminate" + ), + evidenceIds=( + [native_biology_id] + if native_biology_id is not None + else [item.evidenceId for item in outcome_items] + ), + rationale=( + "Retain the mandatory native representation because Harmony " + "did not demonstrate both material batch improvement and " + "preserved biological evidence: " + f"{harmony_gate_reasons}." + if native_biology_id is not None + else "Native biological-conservation evidence is unavailable." + ), + ) + outcome_resolution = self._resolve_rna_decision( + store, + request_record, + outcome_definition, + outcome_bundle, + answers, + rule_selection=native_rule, + ) + outcome_payload = ( + outcome_resolution.compiled.executorPayload + if outcome_resolution.compiled is not None + else None + ) + if outcome_payload is not None and not isinstance( + outcome_payload, + CorrectionOutcomeExecutorPayload, + ): + raise TypeError("Correction outcome compiled an unexpected payload") + augmented_correction: list[ParameterCandidateEvaluation] = [] + for evaluation in correction_evaluations: + correction_extra = ( + [native_biology_id] + if not evaluation.parameters.useHarmony + and native_biology_id is not None + else harmony_evidence_ids + if evaluation.parameters.useHarmony + else [] + ) + augmented_correction.append( + evaluation.model_copy( + update={ + "evidenceIds": list( + dict.fromkeys([*evaluation.evidenceIds, *correction_extra]) + ) + } + ) + ) + correction_phase = self._phase_from_resolution( + correction_plan, + augmented_correction, + outcome_resolution, + payload_field="useHarmony", + payload_value=( + outcome_payload.useHarmony if outcome_payload is not None else False + ), + ) + phase_evidence.append(correction_phase) + if outcome_resolution.record is not None: + decision_sources["correctionOutcome"] = outcome_resolution.record.source + selected_correction = correction_phase.selected_evaluation() + if selected_correction is None: + return return_pending( + outcome_resolution, + correction_license=license_payload.license, + ) + + graph_plan = planner.graph_phase(selected_correction.parameters) + raw_graph = phase_evaluations( + graph_plan, + lambda: execute_parameter_phase( + store, + normalized=normalized, + plan=graph_plan, + batch_columns=( + experimental_handoff.batchColumns + if selected_correction.parameters.useHarmony + else [] + ), + preservation_columns=experimental_handoff.preservationColumns, + experimental_handoff=experimental_handoff, + min_cluster_cells=request_record.config.minClusterCells, + identity_feature_limit=request_record.config.maxIdentityFeatures, + ), + ) + graph_items: list[DecisionEvidence] = [] + graph_evaluations: list[ParameterCandidateEvaluation] = [] + eligible_graph_values: list[int] = [] + graph_evidence_by_k: dict[int, list[str]] = {} + for evaluation in raw_graph: + graph_extra: list[str] = [] + if evaluation.status == "done" and evaluation.eligible: + eligible_graph_values.append(evaluation.parameters.neighborsK) + evidence_id = f"evidence:graph:{evaluation.candidateId}:geometry" + graph_items.append( + DecisionEvidence( + evidenceId=evidence_id, + evidenceClass="geometric", + summary=( + f"The graph has k={evaluation.parameters.neighborsK}, " + f"{evaluation.metrics.nClusters} clusters, silhouette " + f"{evaluation.metrics.graphSilhouetteMedian}, and " + f"minimum cluster size " + f"{evaluation.metrics.minClusterCells}." + ), + artifactReferences=self._evaluation_artifacts(evaluation), + ) + ) + graph_extra.append(evidence_id) + graph_evidence_by_k[evaluation.parameters.neighborsK] = list( + graph_extra + ) + else: + evidence_id = f"evidence:graph:{evaluation.candidateId}:failure" + graph_items.append( + DecisionEvidence( + evidenceId=evidence_id, + evidenceClass="other", + summary=( + f"The graph candidate was not eligible: " + f"{evaluation.error or evaluation.eligibilityReasons}." + ), + artifactReferences=self._evaluation_artifacts(evaluation), + ) + ) + graph_extra.append(evidence_id) + graph_evaluations.append( + evaluation.model_copy( + update={ + "evidenceIds": list( + dict.fromkeys([*evaluation.evidenceIds, *graph_extra]) + ) + } + ) + ) + graph_bundle = self._tuning_evidence_bundle("graphK", graph_items) + graph_definition = build_graph_k_decision( + evidence_bundle_id=graph_bundle.bundleId, + n_cells=handoff.nCells, + candidate_neighbors=( + eligible_graph_values + if eligible_graph_values + else [candidate.neighborsK for candidate in graph_plan.candidates] + ), + ) + graph_definition = require_option_evidence( + graph_definition, + { + option.optionId: graph_evidence_by_k[option.payload.neighborsK] + for option in graph_definition.executorOptions + if isinstance(option.payload, GraphExecutorPayload) + and option.payload.neighborsK in graph_evidence_by_k + }, + ) + graph_rule_selection = ( + DecisionSelection( + selectedOptionId="graphScale:defer", + evidenceIds=[item.evidenceId for item in graph_bundle.evidence], + rationale=( + "No registered graph candidate completed with geometric evidence." + ), + confidence="notApplicable", + ) + if not eligible_graph_values + else None + ) + graph_resolution = self._resolve_rna_decision( + store, + request_record, + graph_definition, + graph_bundle, + answers, + rule_selection=graph_rule_selection, + ) + graph_payload = ( + graph_resolution.compiled.executorPayload + if graph_resolution.compiled is not None + else None + ) + if graph_payload is not None and not isinstance( + graph_payload, GraphExecutorPayload + ): + raise TypeError("Graph decision compiled an unexpected payload") + graph_phase = self._phase_from_resolution( + graph_plan, + graph_evaluations, + graph_resolution, + payload_field="neighborsK", + payload_value=( + graph_payload.neighborsK if graph_payload is not None else -1 + ), + ) + phase_evidence.append(graph_phase) + if graph_resolution.record is not None: + decision_sources["graphK"] = graph_resolution.record.source + selected_graph = graph_phase.selected_evaluation() + if selected_graph is None: + return return_pending( + graph_resolution, + correction_license=license_payload.license, + ) + + doublet_evidence = score_advisory_doublets( + store, + selected_graph, + graph_evaluations, + assay=handoff.assay, + feature_selection=artifact_model_to_ref(handoff.graphFeatures), + capture_column=study_contract.physicalCaptureColumn, + ) + cluster_plan = planner.clustering_phase(selected_graph.parameters) + persisted_cluster = prior_phases.get(cluster_plan.phase) + if persisted_cluster is not None: + if persisted_cluster.plan != cluster_plan: + raise ValueError( + "Persisted clusteringResolution plan differs from the " + "current registered plan" + ) + logger.info( + f"Workflow {workflow.workflowRunId}: reusing persisted " + "clusteringResolution executor evidence" + ) + raw_clusters: Sequence[ParameterCandidateEvaluation] = ( + persisted_cluster.evaluations + ) + else: + raw_clusters = execute_parameter_phase( + store, + normalized=normalized, + plan=cluster_plan, + batch_columns=( + experimental_handoff.batchColumns + if selected_graph.parameters.useHarmony + else [] + ), + preservation_columns=experimental_handoff.preservationColumns, + experimental_handoff=experimental_handoff, + min_cluster_cells=request_record.config.minClusterCells, + identity_feature_limit=request_record.config.maxIdentityFeatures, + ) + cluster_evaluations = list( + augment_cluster_evaluations( + store, + raw_clusters, + marker_assay=plan.markerAssay, + marker_features=artifact_model_to_ref(handoff.markerFeatures), + independent_unit_columns=study_contract.independentUnitColumns, + technical_columns=study_contract.technicalBatchColumns, + nominated_families=nominated_families, + protected_families=protected_families, + doublet_evidence=doublet_evidence, + ) + ) + cluster_items: list[DecisionEvidence] = [] + scored: list[tuple[float, float, ParameterCandidateEvaluation]] = [] + eligible_cluster_values: list[float] = [] + augmented_clusters: list[ParameterCandidateEvaluation] = [] + cluster_evidence_by_resolution: dict[float, list[str]] = {} + for evaluation in cluster_evaluations: + cluster_extra: list[str] = [] + if evaluation.status == "done" and evaluation.eligible: + eligible_cluster_values.append(evaluation.parameters.leidenResolution) + geometry_id = f"evidence:cluster:{evaluation.candidateId}:geometry" + stability_id = f"evidence:cluster:{evaluation.candidateId}:stability" + marker_id = f"evidence:cluster:{evaluation.candidateId}:markers" + cluster_items.extend( + [ + DecisionEvidence( + evidenceId=geometry_id, + evidenceClass="geometric", + summary=( + f"Resolution " + f"{evaluation.parameters.leidenResolution:g} " + f"has silhouette " + f"{evaluation.metrics.graphSilhouetteMedian}, " + f"{evaluation.metrics.nClusters} clusters, and " + f"minimum cluster size " + f"{evaluation.metrics.minClusterCells}." + ), + artifactReferences=self._evaluation_artifacts(evaluation), + ), + DecisionEvidence( + evidenceId=stability_id, + evidenceClass="resamplingStability", + summary=( + f"Alternate-seed ARI is " + f"{evaluation.metrics.seedStability}; deterministic " + f"subsample ARI is " + f"{evaluation.metrics.subsampleStability}." + ), + artifactReferences=self._evaluation_artifacts(evaluation), + ), + DecisionEvidence( + evidenceId=marker_id, + evidenceClass="markerCoherence", + summary=( + "The fraction of clusters with marker programs is " + f"{evaluation.metrics.markerCoherence}; nominated " + "family marker enrichment is " + f"{evaluation.metrics.markerFamilyEnrichment}; " + "protected families observed among markers are " + f"{evaluation.metrics.protectedMarkerFamilies}." + ), + artifactReferences=self._evaluation_artifacts(evaluation), + ), + ] + ) + cluster_extra.extend([geometry_id, stability_id, marker_id]) + if evaluation.metrics.crossUnitSupport is not None: + support_id = ( + f"evidence:cluster:{evaluation.candidateId}:unitSupport" + ) + cluster_items.append( + DecisionEvidence( + evidenceId=support_id, + evidenceClass="crossUnitSupport", + summary=( + "The fraction of clusters represented in at least " + "two independent units is " + f"{evaluation.metrics.crossUnitSupport}." + ), + artifactReferences=self._evaluation_artifacts(evaluation), + ) + ) + cluster_extra.append(support_id) + if evaluation.metrics.biologicalPreservation: + protected_id = ( + f"evidence:cluster:{evaluation.candidateId}:protected" + ) + cluster_items.append( + DecisionEvidence( + evidenceId=protected_id, + evidenceClass="protectedVariablePreservation", + summary=( + "Protected-variable metrics are " + f"{evaluation.metrics.biologicalPreservation}." + ), + artifactReferences=self._evaluation_artifacts(evaluation), + ) + ) + cluster_extra.append(protected_id) + if evaluation.metrics.technicalAssociation: + technical_id = ( + f"evidence:cluster:{evaluation.candidateId}:technical" + ) + cluster_items.append( + DecisionEvidence( + evidenceId=technical_id, + evidenceClass="technical", + summary=( + "Cluster-to-technical association is " + f"{evaluation.metrics.technicalAssociation}." + ), + artifactReferences=self._evaluation_artifacts(evaluation), + ) + ) + cluster_extra.append(technical_id) + if evaluation.metrics.doubletHighScoreConcentration is not None: + doublet_id = f"evidence:cluster:{evaluation.candidateId}:doublet" + cluster_items.append( + DecisionEvidence( + evidenceId=doublet_id, + evidenceClass="qualityControl", + summary=( + "Maximum cluster enrichment for the top decile of " + "capture-aware advisory doublet scores is " + f"{evaluation.metrics.doubletHighScoreConcentration}." + ), + artifactReferences=self._evaluation_artifacts(evaluation), + ) + ) + cluster_extra.append(doublet_id) + geometry = ( + evaluation.metrics.graphSilhouetteMedian + if evaluation.metrics.graphSilhouetteMedian is not None + else -1.0 + ) + stability = ( + ( + evaluation.metrics.seedStability + + evaluation.metrics.subsampleStability + ) + / 2 + if evaluation.metrics.seedStability is not None + and evaluation.metrics.subsampleStability is not None + else -1.0 + ) + marker = evaluation.metrics.markerCoherence or 0.0 + unit_support = evaluation.metrics.crossUnitSupport or 0.0 + technical = max( + evaluation.metrics.technicalAssociation.values(), + default=0.0, + ) + score = ( + geometry + + 0.25 * stability + + 0.2 * marker + + 0.1 * unit_support + - 0.1 * technical + ) + scored.append( + ( + score, + -evaluation.parameters.leidenResolution, + evaluation, + ) + ) + cluster_evidence_by_resolution[ + evaluation.parameters.leidenResolution + ] = list(cluster_extra) + else: + failure_id = f"evidence:cluster:{evaluation.candidateId}:failure" + cluster_items.append( + DecisionEvidence( + evidenceId=failure_id, + evidenceClass="other", + summary=( + f"The partition was not eligible: " + f"{evaluation.error or evaluation.eligibilityReasons}." + ), + artifactReferences=self._evaluation_artifacts(evaluation), + ) + ) + cluster_extra.append(failure_id) + augmented_clusters.append( + evaluation.model_copy( + update={ + "evidenceIds": list( + dict.fromkeys([*evaluation.evidenceIds, *cluster_extra]) + ) + } + ) + ) + known_cluster_ids = { + 0.25: "clusterResolution:veryCoarse", + 0.5: "clusterResolution:coarse", + 0.75: "clusterResolution:balanced", + 1.0: "clusterResolution:detailed", + 1.25: "clusterResolution:fine", + 1.5: "clusterResolution:veryFine", + } -class TuningStagesMixin: - """Execute parameter searches, integration comparisons, and graph selection.""" + def cluster_option_id(resolution: float) -> str: + return known_cluster_ids.get( + resolution, + f"clusterResolution:r{str(resolution).replace('.', 'p')}", + ) - model: Any + candidate_resolutions = ( + eligible_cluster_values + if eligible_cluster_values + else [candidate.leidenResolution for candidate in cluster_plan.candidates] + ) + preferred_resolution = ( + max(scored, key=lambda value: (value[0], value[1]))[ + 2 + ].parameters.leidenResolution + if scored + else candidate_resolutions[0] + ) + cluster_bundle = self._tuning_evidence_bundle( + "clusterPartition", + cluster_items, + ) + cluster_definition = build_cluster_partition_decision( + evidence_bundle_id=cluster_bundle.bundleId, + metric_preferred_option_id=cluster_option_id(preferred_resolution), + resolution_candidates=candidate_resolutions, + ) + cluster_definition = require_option_evidence( + cluster_definition, + { + option.optionId: cluster_evidence_by_resolution[ + option.payload.leidenResolution + ] + for option in cluster_definition.executorOptions + if isinstance(option.payload, ClusterExecutorPayload) + and option.payload.leidenResolution in cluster_evidence_by_resolution + }, + ) + cluster_rule_selection = ( + DecisionSelection( + selectedOptionId="clusterPartition:abstain", + evidenceIds=[item.evidenceId for item in cluster_bundle.evidence], + rationale=( + "No registered cluster partition completed with the required " + "independent evidence." + ), + confidence="notApplicable", + ) + if not eligible_cluster_values + else None + ) + cluster_resolution = self._resolve_rna_decision( + store, + request_record, + cluster_definition, + cluster_bundle, + answers, + rule_selection=cluster_rule_selection, + ) + if cluster_resolution.compiled is None: + cluster_phase = ParameterPhaseEvidence( + plan=cluster_plan, + evaluations=augmented_clusters, + selection=ParameterPhaseSelection( + phase="clusteringResolution", + status="needsInput", + rationale=( + cluster_resolution.pending.reason + if cluster_resolution.pending is not None + else "Cluster partition remains unresolved." + ), + ), + ) + phase_evidence.append(cluster_phase) + return return_pending( + cluster_resolution, + correction_license=license_payload.license, + ) + if cluster_resolution.record is not None: + decision_sources["clusterPartition"] = cluster_resolution.record.source + if cluster_resolution.record is not None and ( + cluster_resolution.record.status == "abstain" + ): + cluster_phase = ParameterPhaseEvidence( + plan=cluster_plan, + evaluations=augmented_clusters, + selection=ParameterPhaseSelection( + phase="clusteringResolution", + status="abstained", + evidenceIds=list(cluster_resolution.record.evidenceIds), + rationale=cluster_resolution.record.rationale, + ), + ) + phase_evidence.append(cluster_phase) + state = build_state( + correction_license=license_payload.license, + ) + return ( + sequential_evidence_to_report( + state, + marker_assay=plan.markerAssay, + ), + state, + ) + cluster_payload = cluster_resolution.compiled.executorPayload + if not isinstance(cluster_payload, ClusterExecutorPayload): + raise TypeError("Cluster decision compiled an unexpected payload") + cluster_phase = self._phase_from_resolution( + cluster_plan, + augmented_clusters, + cluster_resolution, + payload_field="leidenResolution", + payload_value=cluster_payload.leidenResolution, + ) + phase_evidence.append(cluster_phase) + selected_cluster = cluster_phase.selected_evaluation() + if selected_cluster is None: + raise RuntimeError("Completed cluster decision lacks an exact candidate") + state = build_state( + correction_license=license_payload.license, + final_candidate_id=selected_cluster.candidateId, + ) + return ( + sequential_evidence_to_report( + state, + marker_assay=plan.markerAssay, + ), + state, + ) + + def feature_policy_review_stage( + self, + store: DataStore, + workflow: AgentWorkflowRun, + request_record: OrchestrationRequestRecord, + parents: Sequence[WorkflowStageLink], + plan: AutomatedPreprocessingPlan, + tuning_report: ParameterTuningReport, + answers: Mapping[str, Any], + *, + resume_record: OrchestrationResumeRecord | None = None, + ) -> tuple[WorkflowStageAttempt, AutomatedPreprocessingPlan, bool]: + """Review the baseline feature policy against PCA and marker evidence.""" + prefix = journal._ensure_orchestration_store(store) + existing = journal._validated_done_outcome( + store, + prefix, + workflow.workflowRunId, + "feature_policy_review", + request_record, + parents, + ) + if existing is not None: + return ( + existing, + AutomatedPreprocessingPlan.model_validate( + existing.outputs["preprocessingPlan"] + ), + bool(existing.outputs["revised"]), + ) + started = journal._start_attempt( + store.zw, + prefix, + workflow.workflowRunId, + "feature_policy_review", + request_record, + parents, + inputs={ + "preprocessingPlan": plan.model_dump(mode="json"), + "tuningReportSha256": hashlib.sha256( + record_io.canonical_json_bytes( + tuning_report.model_dump(mode="json") + ) + ).hexdigest(), + }, + resume_record=resume_record, + ) + try: + assay_plan = next( + value for value in plan.assays if value.assay == plan.primaryAssay + ) + assay_report = tuning_report.assayReports[plan.primaryAssay] + selected = next( + evaluation + for evaluation in assay_report.evaluations + if evaluation.candidateId == assay_report.recommendedCandidateId + ) + loading_evaluation = next( + ( + evaluation + for evaluation in assay_report.evaluations + if evaluation.status == "done" + and evaluation.eligible + and evaluation.parameters.dimensions + == selected.parameters.dimensions + and evaluation.metrics.loadingFamilyEnrichment + ), + None, + ) + aliases = {"sex": "sexLinked"} + allowed_families = { + "mitochondrial", + "ribosomal", + "histone", + "hemoglobin", + "immuneReceptor", + "cellCycle", + "stress", + "dissociation", + "sexLinked", + } + nominated = [ + aliases.get(str(value), str(value)) + for value in cast( + list[str], + assay_plan.featureParameters.get( + "proposedExcludeFamilies", + [], + ), + ) + ] + protected = [ + aliases.get(str(value), str(value)) + for value in cast( + list[str], + assay_plan.featureParameters.get("protectFamilies", []), + ) + ] + nominated = [ + value for value in dict.fromkeys(nominated) if value in allowed_families + ] + protected = [ + value for value in dict.fromkeys(protected) if value in allowed_families + ] + loading_enrichment = ( + loading_evaluation.metrics.loadingFamilyEnrichment + if loading_evaluation is not None + else {} + ) + marker_enrichment = selected.metrics.markerFamilyEnrichment + eligible = [ + cast(ConditionalGeneFamily, family) + for family in nominated + if family not in protected + and ( + loading_enrichment.get(family, 0.0) >= 2.0 + or marker_enrichment.get(family, 0.0) >= 2.0 + ) + ] + loading_id = "evidence:featurePolicyReview:pcaLoadings" + marker_id = "evidence:featurePolicyReview:clusterMarkers" + protected_id = "evidence:featurePolicyReview:protectedFamilies" + evidence = [ + DecisionEvidence( + evidenceId=loading_id, + evidenceClass="technical", + summary=( + "Maximum top-loading family enrichments are " + f"{loading_enrichment}; the registered gate is 2.0." + ), + artifactReferences=( + self._evaluation_artifacts(loading_evaluation) + if loading_evaluation is not None + else [] + ), + ), + DecisionEvidence( + evidenceId=marker_id, + evidenceClass="markerCoherence", + summary=( + "Selected-partition family marker enrichments are " + f"{marker_enrichment}; the registered gate is 2.0." + ), + artifactReferences=self._evaluation_artifacts(selected), + ), + DecisionEvidence( + evidenceId=protected_id, + evidenceClass="protectedVariablePreservation", + summary=( + f"Protected families are {protected}; eligible nominated " + f"families after the veto are {eligible}." + ), + ), + ] + bundle = self._tuning_evidence_bundle("featurePolicy", evidence) + definition = build_feature_policy_decision( + evidence_bundle_id=bundle.bundleId, + proposed_exclusion_families=eligible, + dominant_families=eligible, + protected_families=[ + cast(ConditionalGeneFamily, value) for value in protected + ], + ) + requirements: dict[str, list[str]] = { + "featurePolicy:keepAll": [loading_id, marker_id, protected_id] + } + if eligible: + requirements["featurePolicy:excludeEligibleBundle"] = [ + loading_id, + marker_id, + protected_id, + ] + definition = require_option_evidence(definition, requirements) + if eligible: + review = self._reconsider_rna_decision( + store, + request_record, + definition, + bundle, + answers, + ) + if review.question is not None: + outcome = journal._complete_attempt( + started, + status="needsInput", + outputs={ + "decisionSnapshotSha256": review.snapshotSha256, + "eligibleFamilies": list(eligible), + }, + needs_input=WorkflowNeedsInput(questions=[review.question]), + notes=[ + "Feature-policy review requires a registered selection." + ], + ) + journal._save_outcome(store.zw, prefix, outcome) + return outcome, plan, False + if review.selection is None: + raise RuntimeError("Feature-policy review lacks a selection") + review_selection = review.selection + selected_option_id = review.selection.selectedOptionId + revised = review.revised + snapshot_sha256 = review.snapshotSha256 + payload = ( + review.resolution.compiled.executorPayload + if review.resolution is not None + and review.resolution.compiled is not None + else FeaturePolicyExecutorPayload( + policy="keepAll", + excludedFamilies=[], + ) + ) + else: + review_selection = DecisionSelection( + selectedOptionId="featurePolicy:keepAll", + evidenceIds=[loading_id, marker_id, protected_id], + rationale=( + "No nominated, unprotected family passed the registered " + "loading or marker-enrichment gate." + ), + confidence="notApplicable", + ) + selected_option_id = "featurePolicy:keepAll" + revised = False + _workflow, snapshot_sha256 = self._load_or_create_decision_workflow( + store, + request_record, + ) + payload = FeaturePolicyExecutorPayload( + policy="keepAll", + excludedFamilies=[], + ) + if not isinstance(payload, FeaturePolicyExecutorPayload): + raise TypeError("Feature-policy review compiled an unexpected payload") + reviewed_plan = apply_feature_policy_to_plan(plan, payload) + artifacts: dict[str, ArtifactReferenceModel] = {} + if ( + loading_evaluation is not None + and "representationDiagnostic" in loading_evaluation.artifacts + ): + artifacts["pcaRepresentationDiagnostic"] = ( + ArtifactReferenceModel.model_validate( + loading_evaluation.artifacts[ + "representationDiagnostic" + ].model_dump() + ) + ) + if "markerTable" in selected.artifacts: + artifacts["clusterMarkerTable"] = ArtifactReferenceModel.model_validate( + selected.artifacts["markerTable"].model_dump() + ) + outcome = journal._complete_attempt( + started, + status="done", + artifacts=artifacts, + outputs={ + "preprocessingPlan": reviewed_plan.model_dump(mode="json"), + "revised": revised, + "selectedOptionId": selected_option_id, + "decisionSelection": review_selection.model_dump(mode="json"), + "evidenceBundle": bundle.model_dump(mode="json"), + "eligibleFamilies": list(eligible), + "decisionSnapshotSha256": snapshot_sha256, + }, + actions=[ + "review_feature_policy", + ("revise_feature_policy" if revised else "retain_feature_policy"), + ], + ) + journal._save_outcome(store.zw, prefix, outcome) + return outcome, reviewed_plan, revised + except Exception as exc: + outcome = journal.finish_exception( + store, + prefix, + workflow, + started, + exc, + ) + return outcome, plan, False + + def reuse_feature_policy_tuning_stage( + self, + store: DataStore, + workflow: AgentWorkflowRun, + request_record: OrchestrationRequestRecord, + parents: Sequence[WorkflowStageLink], + baseline_outcome: WorkflowStageAttempt, + baseline_report: ParameterTuningReport, + *, + resume_record: OrchestrationResumeRecord | None = None, + ) -> tuple[WorkflowStageAttempt, ParameterTuningReport]: + """Record deterministic reuse when no feature-policy revision occurred.""" + prefix = journal._ensure_orchestration_store(store) + existing = journal._validated_done_outcome( + store, + prefix, + workflow.workflowRunId, + "feature_policy_tuning", + request_record, + parents, + ) + if existing is not None: + return existing, baseline_report + started = journal._start_attempt( + store.zw, + prefix, + workflow.workflowRunId, + "feature_policy_tuning", + request_record, + parents, + inputs={ + "baselineAttemptId": baseline_outcome.attemptId, + "baselineReportReferences": [ + value.model_dump(mode="json") + for value in baseline_outcome.reportReferences + ], + }, + resume_record=resume_record, + ) + outcome = journal._complete_attempt( + started, + status="done", + artifacts=dict(baseline_outcome.artifacts), + outputs={ + "reusedBaselineAttemptId": baseline_outcome.attemptId, + "recommendedByAssay": dict(baseline_report.recommendedByAssay), + "operations": [ + { + "operation": "reuse_baseline_parameter_tuning", + "attemptId": baseline_outcome.attemptId, + } + ], + }, + actions=["reuse_baseline_parameter_tuning"], + ) + journal._save_outcome(store.zw, prefix, outcome) + return outcome, baseline_report def parameter_tuning_stage( self, @@ -69,14 +1844,16 @@ def parameter_tuning_stage( experimental_reference: AgentReportReference, answers: Mapping[str, Any], *, + study_contract: StudyContract | None = None, resume_record: OrchestrationResumeRecord | None = None, + stage_name: WorkflowStageName = "parameter_tuning", ) -> tuple[WorkflowStageAttempt, ParameterTuningReport]: prefix = journal._ensure_orchestration_store(store) existing = journal._validated_done_outcome( store, prefix, workflow.workflowRunId, - "parameter_tuning", + stage_name, request_record, parents, ) @@ -90,15 +1867,20 @@ def parameter_tuning_stage( store, prefix, workflow.workflowRunId, - "parameter_tuning", + stage_name, request_record, parents, required_status="needsInput", ) resumable_report: ParameterTuningReport | None = None + prior_sequential: SequentialAssayTuningEvidence | None = None if paused is not None and paused.reportReferences: loaded = journal.load_stage_report(store, paused, ParameterTuningReport) candidate_report = cast(ParameterTuningReport, loaded) + if paused.outputs.get("sequentialEvidence") is not None: + prior_sequential = SequentialAssayTuningEvidence.model_validate( + paused.outputs["sequentialEvidence"] + ) if ( candidate_report.finalSelection is not None and candidate_report.finalSelection.status == "needsInput" @@ -123,11 +1905,18 @@ def parameter_tuning_stage( tuning_directions = tuning_answer.strip() else: tuning_directions = "" + objective_direction = ( + "Authoritative study objective: " + f"{request_record.request.studyObjective.strip()}" + ) + tuning_directions = "\n".join( + value for value in (objective_direction, tuning_directions) if value + ) started = journal._start_attempt( store.zw, prefix, workflow.workflowRunId, - "parameter_tuning", + stage_name, request_record, parents, inputs={ @@ -143,11 +1932,8 @@ def parameter_tuning_stage( "pairedAssays": plan.pairedAssays, "finalGraphOptionId": answers.get("finalGraphOptionId"), "parameterTuning": tuning_answer, - "resumeFromAttempt": ( - paused.attemptId - if paused is not None and resumable_report is not None - else None - ), + "studyObjective": request_record.request.studyObjective, + "resumeFromAttempt": (paused.attemptId if paused is not None else None), }, resume_record=resume_record, ) @@ -165,11 +1951,15 @@ def parameter_tuning_stage( self.model, config=request_record.config.agentRunConfig, ) - recovered = journal._recover_persisted_stage_report( - store, - started, - agent_name="parameter_tuning", - expected_type=ParameterTuningReport, + recovered = ( + None + if paused is not None + else journal._recover_persisted_stage_report( + store, + started, + agent_name="parameter_tuning", + expected_type=ParameterTuningReport, + ) ) if recovered is not None: recovered_report, recovered_reference = recovered @@ -206,6 +1996,52 @@ def parameter_tuning_stage( actions, persisted_reference=recovered_reference, ) + if len(preprocessed) == 1 and not plan.pairedAssays: + if study_contract is None: + raise ValueError( + "Decision-driven RNA tuning requires a StudyContract" + ) + report, sequential_evidence = self._run_sequential_rna_tuning( + store, + workflow, + request_record, + plan, + preprocessed, + experimental_handoff, + study_contract, + answers, + prior_sequential, + ) + candidate_payload = { + sequential_evidence.assay: [ + candidate.model_dump(mode="json") + for phase in sequential_evidence.phases + for candidate in phase.plan.candidates + ] + } + actions.extend( + f"adjudicate_{phase.plan.phase}" + for phase in sequential_evidence.phases + ) + return self.save_parameter_tuning_outcome( + store, + prefix, + workflow, + request_record, + started, + report, + plan, + preprocessed, + [], + candidate_payload, + [], + enrichment_reference, + experimental_reference, + experimental_handoff, + agent, + actions, + sequential_evidence=sequential_evidence, + ) if resumable_report is not None: assert paused is not None resumed_integration_evaluations = list( @@ -282,12 +2118,17 @@ def parameter_tuning_stage( if handoff.assay == plan.primaryAssay else request_record.config.secondaryInitialCandidates ) - neighbors_k = common_k or min(11, handoff.nCells - 1) + neighbors_k = common_k or min(21, handoff.nCells - 1) candidates = self.initial_parameter_candidates( workflow.workflowRunId, handoff, count=initial_count, neighbors_k=neighbors_k, + dimension_candidates=(request_record.config.pcaCandidateDimensions), + neighbor_candidates=(request_record.config.graphNeighborCandidates), + resolution_candidates=( + request_record.config.leidenResolutionCandidates + ), ) if ( experimental_handoff.batchAction == "evaluateHarmony" @@ -548,6 +2389,7 @@ def save_parameter_tuning_outcome( *, prior_tuning_reference: AgentReportReference | None = None, persisted_reference: AgentReportReference | None = None, + sequential_evidence: SequentialAssayTuningEvidence | None = None, ) -> tuple[WorkflowStageAttempt, ParameterTuningReport]: if experimental_handoff.cellSelection is None: raise ValueError("Parameter tuning handoff lacks an exact cell selection") @@ -749,6 +2591,13 @@ def save_parameter_tuning_outcome( if report.status == "needsInput": needs_input = report.needsInput assert needs_input is not None + if ( + sequential_evidence is not None + and sequential_evidence.pendingDecisionId is None + ): + raise ValueError( + "Sequential tuning needsInput lacks a pending decision ID" + ) outcome = journal._complete_attempt( started, status="needsInput", @@ -756,6 +2605,11 @@ def save_parameter_tuning_outcome( artifacts=stage_artifacts, outputs={ "candidateCount": report.totalCandidates, + "sequentialEvidence": ( + sequential_evidence.model_dump(mode="json") + if sequential_evidence is not None + else None + ), "operations": operations, }, actions=actions, @@ -763,11 +2617,18 @@ def save_parameter_tuning_outcome( questions=[ WorkflowQuestion( questionId=( - "finalGraphOptionId" + f"decision:{sequential_evidence.pendingDecisionId}" + if sequential_evidence is not None + else "finalGraphOptionId" if report.finalSelection is not None and report.finalSelection.status == "needsInput" else "parameter_tuning" ), + decisionId=( + sequential_evidence.pendingDecisionId + if sequential_evidence is not None + else None + ), question=needs_input.question, options=list(needs_input.options), evidenceIds=list(needs_input.evidenceIds), @@ -776,6 +2637,27 @@ def save_parameter_tuning_outcome( ), notes=report.limitations, ) + elif report.status == "abstained": + outcome = journal._complete_attempt( + started, + status="abstained", + report_references=stage_report_references, + artifacts=stage_artifacts, + outputs={ + "candidateCount": report.totalCandidates, + "sequentialEvidence": ( + sequential_evidence.model_dump(mode="json") + if sequential_evidence is not None + else None + ), + "operations": operations, + }, + actions=actions, + notes=( + report.limitations + or ["No defensible discrete clustering partition was found."] + ), + ) elif report.status == "failed": outcome = journal._complete_attempt( started, @@ -784,6 +2666,11 @@ def save_parameter_tuning_outcome( artifacts=stage_artifacts, outputs={ "candidateCount": report.totalCandidates, + "sequentialEvidence": ( + sequential_evidence.model_dump(mode="json") + if sequential_evidence is not None + else None + ), "operations": operations, }, actions=actions, @@ -799,6 +2686,11 @@ def save_parameter_tuning_outcome( "candidateCount": report.totalCandidates, "recommendedByAssay": report.recommendedByAssay, "recommendedIntegrationId": report.recommendedIntegrationId, + "sequentialEvidence": ( + sequential_evidence.model_dump(mode="json") + if sequential_evidence is not None + else None + ), "operations": operations, }, actions=actions, @@ -821,6 +2713,16 @@ def initial_parameter_candidates( *, count: int, neighbors_k: int, + dimension_candidates: Sequence[int] = (10, 20, 30, 50), + neighbor_candidates: Sequence[int] = (11, 21, 41), + resolution_candidates: Sequence[float] = ( + 0.25, + 0.5, + 0.75, + 1.0, + 1.25, + 1.5, + ), ) -> list[ParameterCandidate]: max_dimensions = min(handoff.nCells, handoff.nFeatures) - 1 if neighbors_k < 2 or neighbors_k >= handoff.nCells: @@ -846,21 +2748,42 @@ def initial_parameter_candidates( min(70, max_dimensions), ] else: - dimensions = min(21, max_dimensions) dimension_values = [ - dimensions, - min(15, max_dimensions), - min(30, max_dimensions), + min(value, max_dimensions) + for value in dimension_candidates + if value >= 2 ] + if not dimension_values: + dimension_values = [min(20, max_dimensions)] + dimensions = min(20, max_dimensions) + if dimensions not in dimension_values: + dimension_values.append(dimensions) unique_dimensions = list( dict.fromkeys(value for value in dimension_values if value >= 2) ) - specifications: list[tuple[int, float]] = [(unique_dimensions[0], 1.0)] - specifications.extend((value, 1.0) for value in unique_dimensions[1:]) - for resolution in (0.5, 1.5, 0.75, 1.25, 0.35, 1.75): + baseline_dimensions = ( + min(20, max_dimensions) + if handoff.reductionMethod == "pca" + else unique_dimensions[0] + ) + unique_dimensions = [ + baseline_dimensions, + *(value for value in unique_dimensions if value != baseline_dimensions), + ] + specifications: list[tuple[int, float, int]] = [ + (value, 1.0, neighbors_k) for value in unique_dimensions + ] + for candidate_k in neighbor_candidates: + effective_k = min(candidate_k, handoff.nCells - 1) + specification = (baseline_dimensions, 1.0, effective_k) + if effective_k >= 2 and specification not in specifications: + specifications.append(specification) + for resolution in resolution_candidates: if len(specifications) >= count: break - specifications.append((unique_dimensions[0], resolution)) + specification = (baseline_dimensions, float(resolution), neighbors_k) + if specification not in specifications: + specifications.append(specification) token = workflow_run_id[:10] assay_token = journal._safe_label(handoff.assay).lower() if len(assay_token) > 32: @@ -888,7 +2811,7 @@ def initial_parameter_candidates( neighborsK=neighbors_k, ) ) - for resolution in (0.5, 1.5, 0.75, 1.25, 0.35, 1.75): + for resolution in resolution_candidates: if len(candidates) >= count: break index = len(candidates) @@ -908,9 +2831,11 @@ def initial_parameter_candidates( reductionMethod=cast(Any, handoff.reductionMethod), dimensions=dimension, leidenResolution=resolution, - neighborsK=neighbors_k, + neighborsK=candidate_k, + ) + for index, (dimension, resolution, candidate_k) in enumerate( + specifications[:count] ) - for index, (dimension, resolution) in enumerate(specifications[:count]) ] def load_integration_checkpoint( diff --git a/scarf/agent/parameter_tuning.py b/scarf/agent/parameter_tuning.py index 68cb2733..c9c70d0c 100644 --- a/scarf/agent/parameter_tuning.py +++ b/scarf/agent/parameter_tuning.py @@ -107,6 +107,20 @@ class ParameterMetrics(AgentDataModel): pcaSilhouette: float | None = None macroF1: float | None = None weightedF1: float | None = None + seedStability: float | None = None + subsampleStability: float | None = None + markerCoherence: float | None = None + crossUnitSupport: float | None = None + technicalAssociation: dict[str, float] = Field(default_factory=dict) + componentVariance: list[float] = Field(default_factory=list) + loadingFamilyEnrichment: dict[str, float] = Field(default_factory=dict) + technicalPcaAssociation: dict[str, float] = Field(default_factory=dict) + protectedPcaAssociation: dict[str, float] = Field(default_factory=dict) + qcPcaAssociation: dict[str, float] = Field(default_factory=dict) + neighborPrefixOverlap: float | None = None + markerFamilyEnrichment: dict[str, float] = Field(default_factory=dict) + protectedMarkerFamilies: list[str] = Field(default_factory=list) + doubletHighScoreConcentration: float | None = None batchMixing: dict[str, float] = Field(default_factory=dict) biologicalPreservation: dict[str, dict[str, float]] = Field(default_factory=dict) @@ -2169,13 +2183,17 @@ def validate_parameter_tuning_batch_report( ) -def fallback_parameter_tuning_report( +def pending_parameter_tuning_report( deps: ParameterTuningDependencies, *, search_plan: ParameterSearchPlan, agent_name: str, ) -> ParameterTuningReport: - """Retain the first eligible branch when structured selection is unavailable.""" + """Pause when structured selection is unavailable. + + Completed executor evidence is retained for an exact human resume, but it is + never converted into an implicit scientific recommendation. + """ evaluations = [ deps.evaluations[candidate_id] @@ -2184,107 +2202,35 @@ def fallback_parameter_tuning_report( ] successful = [item for item in evaluations if item.status == "done"] eligible = [item for item in successful if item.eligible] - comparison_required = len(deps.candidates) > 1 and deps.maxCandidates > 1 - evidence_by_candidate = { - item.candidateId: next( - ( - evidence_id - for evidence_id in item.evidenceIds - if evidence_id == f"candidate:{item.candidateId}:clusters" - ), - next(iter(item.evidenceIds), None), - ) - for item in successful - } - cannot_recommend = ( - not eligible - or (comparison_required and len(successful) < 2) - or ( - comparison_required - and "baseline" in deps.candidates - and not any(item.candidateId == "baseline" for item in successful) - ) - or any(evidence_by_candidate[item.candidateId] is None for item in successful) - ) - if cannot_recommend: - logger.warning( - f"Parameter tuning fallback for assay {deps.fromAssay!r} requires " - f"input: completed={len(successful)}, eligible={len(eligible)}" - ) - known_evidence = sorted( - { - evidence_id - for evaluation in evaluations - for evidence_id in evaluation.evidenceIds - } - ) - report = ParameterTuningReport( - status="needsInput", - confidence="low", - rationale=( - "Structured model selection was unavailable and the executed " - "screen does not support a conservative automatic fallback." - ), - limitations=[ - "No parameter branch was selected without complete eligible " - "executor evidence." - ], - stopReason="The bounded screen completed without an automatic choice.", - needsInput=ParameterTuningNeedsInput( - question="Select one eligible executed parameter candidate.", - options=[item.candidateId for item in eligible], - evidenceIds=known_evidence, - ), - runInfo=AgentRunInfo(agentName=agent_name), - ) - return validate_parameter_tuning_report( - report, - deps, - search_plan=search_plan, - ) - - selected = eligible[0] logger.warning( - f"Parameter tuning fallback retained candidate " - f"{selected.candidateId!r} for assay {deps.fromAssay!r} from " - f"{len(eligible)} eligible candidate(s)" + f"Parameter tuning for assay {deps.fromAssay!r} requires input after " + f"model exhaustion: completed={len(successful)}, eligible={len(eligible)}" + ) + known_evidence = sorted( + { + evidence_id + for evaluation in evaluations + for evidence_id in evaluation.evidenceIds + } ) - selected_evidence = evidence_by_candidate[selected.candidateId] - assert selected_evidence is not None - comparisons: list[CandidateComparison] = [] - if comparison_required: - for item in successful: - if item.candidateId == selected.candidateId: - continue - comparator_evidence = evidence_by_candidate[item.candidateId] - assert comparator_evidence is not None - comparisons.append( - CandidateComparison( - candidateId=item.candidateId, - summary=( - "This executed branch remains a grounded comparator to the " - "conservatively retained first eligible branch." - ), - evidenceIds=[selected_evidence, comparator_evidence], - ) - ) report = ParameterTuningReport( - status="done", - recommendedCandidateId=selected.candidateId, + status="needsInput", confidence="low", rationale=( - "The bounded structured model selection was unavailable; the first " - "eligible authorized branch was retained conservatively." + "The bounded structured selection was unavailable. Executor evidence " + "is complete enough to resume, but it cannot choose a scientific " + "alternative by itself." ), - evidenceIds=[selected_evidence], - comparisons=comparisons, - tradeoffs=["No model-authored metric trade-off ranking was available."], - limitations=[ - "The fallback does not claim that the retained branch is metric-optimal." - ], - stopReason=( - "The deterministic screen completed and retained its first eligible " - "authorized branch." + evidenceIds=known_evidence, + limitations=["No candidate was selected merely to complete the workflow."], + stopReason="The bounded screen completed without a valid decision.", + needsInput=ParameterTuningNeedsInput( + question=( + "Select one eligible executed candidate and provide a scientific " + "rationale tied to the cited evidence." + ), + options=[item.candidateId for item in eligible], + evidenceIds=known_evidence, ), runInfo=AgentRunInfo(agentName=agent_name), ) @@ -2295,61 +2241,32 @@ def fallback_parameter_tuning_report( ) -def fallback_parameter_tuning_batch_report( +def pending_parameter_tuning_batch_report( dependencies: Mapping[str, ParameterTuningDependencies], *, search_plans: Mapping[str, ParameterSearchPlan], primary_assay: str, ) -> ParameterTuningReport: - """Build one grounded aggregate fallback over completed assay screens.""" + """Build one grounded pause over completed assay screens.""" logger.warning( - f"Using parameter tuning batch fallback for {len(dependencies)} assay(s)" + f"Pausing parameter tuning after model exhaustion for " + f"{len(dependencies)} assay(s)" ) assay_reports = { - assay: fallback_parameter_tuning_report( + assay: pending_parameter_tuning_report( deps, search_plan=search_plans[assay], - agent_name="parameter_tuning_batch_fallback", + agent_name="parameter_tuning_batch_needs_input", ) for assay, deps in dependencies.items() } - if any(item.status != "done" for item in assay_reports.values()): - primary = assay_reports[primary_assay] - if primary.status == "done": - primary = ParameterTuningReport( - status="needsInput", - confidence="low", - rationale=( - "At least one assay lacks a conservative automatic parameter " - "selection." - ), - limitations=[ - "The multimodal native screen requires an explicit selection." - ], - stopReason="The bounded native screens completed without all choices.", - needsInput=ParameterTuningNeedsInput( - question="Select eligible parameter candidates for every assay.", - options=[], - evidenceIds=list(primary.evidenceIds), - ), - runInfo=AgentRunInfo(agentName="parameter_tuning_batch_fallback"), - ) - assay_reports[primary_assay] = validate_parameter_tuning_report( - primary, - dependencies[primary_assay], - search_plan=search_plans[primary_assay], - ) aggregate = ParameterTuningReport( - status=( - "done" - if all(item.status == "done" for item in assay_reports.values()) - else "needsInput" - ), + status="needsInput", assayReports=assay_reports, rationale=( - "Structured model selection was unavailable; each completed native " - "screen used the conservative fallback policy." + "Structured model selection was unavailable. All completed evidence " + "was retained without choosing a branch." ), evidenceIds=list( dict.fromkeys( @@ -2358,15 +2275,12 @@ def fallback_parameter_tuning_batch_report( for evidence_id in assay_report.evidenceIds ) ), - limitations=[ - "Fallback recommendations retain first eligible authorized branches " - "without claiming a metric-optimal ranking." - ], - stopReason="The bounded native screens completed.", - runInfo=AgentRunInfo(agentName="parameter_tuning_batch_fallback"), + limitations=["No assay candidate was selected merely to finish the workflow."], + stopReason="The bounded native screens completed without valid decisions.", + runInfo=AgentRunInfo(agentName="parameter_tuning_batch_needs_input"), ) logger.warning( - f"Parameter tuning batch fallback status={aggregate.status}; " + f"Parameter tuning batch pause status={aggregate.status}; " f"completed_assays={sum(item.status == 'done' for item in assay_reports.values())}" ) return validate_parameter_tuning_batch_report( @@ -2687,7 +2601,7 @@ def select_final_parameter_graph( ), ), runInfo=AgentRunInfo( - agentName="parameter_tuning_final_graph_fallback" + agentName="parameter_tuning_final_graph_needs_input" ), ), report, @@ -3364,9 +3278,9 @@ def tune_parameters_batch( logger.warning( "Batched parameter selection model run failed within its bounds " f"({type(exc).__name__}); " - "using the conservative executor-evidence fallback" + "returning needsInput with the completed executor evidence" ) - return fallback_parameter_tuning_batch_report( + return pending_parameter_tuning_batch_report( dependencies, search_plans=batch_plan.assayPlans, primary_assay=resolved_primary, @@ -3552,13 +3466,13 @@ def tune_parameters( except (UnexpectedModelBehavior, UsageLimitExceeded) as exc: logger.warning( f"Parameter selection for assay {from_assay!r} failed within its " - f"model-run bounds ({type(exc).__name__}); using the conservative " - "executor-evidence fallback" + f"model-run bounds ({type(exc).__name__}); returning needsInput " + "with the completed executor evidence" ) - return fallback_parameter_tuning_report( + return pending_parameter_tuning_report( deps, search_plan=plan, - agent_name="parameter_tuning_fallback", + agent_name="parameter_tuning_needs_input", ) if not isinstance(selection_execution.output, ParameterTuningReport): raise TypeError("Parameter tuning agent returned an unexpected output type") diff --git a/scarf/agent/persistence.py b/scarf/agent/persistence.py index 1ccb29e4..4df391f0 100644 --- a/scarf/agent/persistence.py +++ b/scarf/agent/persistence.py @@ -59,8 +59,14 @@ | BiologicalInterpretationReport ) type AgentPersistenceTarget = str | Path | zarr.Group | DataStore -type AgentWorkflowStatus = Literal["running", "completed", "failed", "abandoned"] -type AgentTerminalStatus = Literal["completed", "failed", "abandoned"] +type AgentWorkflowStatus = Literal[ + "running", + "completed", + "abstained", + "failed", + "abandoned", +] +type AgentTerminalStatus = Literal["completed", "abstained", "failed", "abandoned"] _FORMAT = "scarf_agent_reports" _RUN_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,127}$") @@ -1509,8 +1515,8 @@ def finalize_agent_workflow( workspace: str | None = None, ) -> AgentWorkflowRun: """Write the one terminal event allowed for a running workflow.""" - if status not in {"completed", "failed", "abandoned"}: - raise ValueError("status must be completed, failed, or abandoned") + if status not in {"completed", "abstained", "failed", "abandoned"}: + raise ValueError("status must be completed, abstained, failed, or abandoned") if not isinstance(message, str): raise TypeError("message must be a string") group, datastore, resolved_workspace, _analysis_store = _resolve_target( @@ -1541,8 +1547,10 @@ def finalize_agent_workflow( workflow_run_id, resolved_workspace, ) - if status == "completed" and not reports: - raise ValueError("A completed workflow must contain at least one report") + if status in {"completed", "abstained"} and not reports: + raise ValueError( + "A completed or abstained workflow must contain at least one report" + ) finalization = AgentWorkflowFinalization( workflowRunId=workflow_run_id, workspace=resolved_workspace, diff --git a/scarf/agent/qc_execution.py b/scarf/agent/qc_execution.py new file mode 100644 index 00000000..3d941f3a --- /dev/null +++ b/scarf/agent/qc_execution.py @@ -0,0 +1,370 @@ +"""Persist exact outputs from a registered agent cell-quality decision.""" + +from collections.abc import Iterable, Mapping +from numbers import Real +from typing import Any + +import numpy as np + +from ..metadata.artifacts import ( + plan_cell_data_artifact, + write_cell_data_artifact, +) +from ..metadata.rows import read_metadata_rows_chunkwise +from ..metadata.selection import NamedCellArtifact, resolve_cell_aligned_artifact +from ..storage.artifacts import canonical_bytes, fingerprint_array, fingerprint_strings +from ..storage.refs import ArtifactRef +from ..storage.selections import ( + read_stored_selection_mask, + resolve_generated_selection_artifact, +) +from ..utils.logging import logger +from .qc_profiles import ( + REGISTERED_CELL_QC_PROFILES, + RegisteredCellQcProfile, + project_registered_qc_profile, +) + + +def _validated_named_cell_artifacts( + values: Iterable[NamedCellArtifact] | None, + *, + expected_kind: str, + label: str, +) -> list[NamedCellArtifact]: + sources = list(values or ()) + names: set[str] = set() + for source in sources: + if not isinstance(source, NamedCellArtifact): + raise TypeError(f"{label} must contain NamedCellArtifact values") + if source.artifact.kind != expected_kind: + raise ValueError(f"{label} must reference {expected_kind!r} artifacts") + if source.name in names: + raise ValueError(f"{label} must use unique semantic names") + names.add(source.name) + return sources + + +def execute_registered_cell_qc( + store: Any, + profile: RegisteredCellQcProfile, + *, + profile_parameters: Mapping[str, Any], + expected_active_cells: int, + expected_retained_cells: int, + expected_flag_counts: Mapping[str, int], + attrs: Iterable[str] | None = None, + artifact_metrics: Iterable[NamedCellArtifact] | None = None, + cell_selection: ArtifactRef | None = None, + sample_column: str | None = None, + sample_artifact: NamedCellArtifact | None = None, + invalidate_cache: bool = False, +) -> tuple[ArtifactRef, ArtifactRef | None]: + """Recompute, verify, and persist one registered cell-QC decision.""" + if profile not in REGISTERED_CELL_QC_PROFILES: + raise ValueError(f"Unknown registered cell-QC profile {profile!r}") + if ( + isinstance(expected_active_cells, bool) + or not isinstance(expected_active_cells, int) + or expected_active_cells < 1 + ): + raise ValueError("expected_active_cells must be a positive integer") + if ( + isinstance(expected_retained_cells, bool) + or not isinstance(expected_retained_cells, int) + or expected_retained_cells < 0 + ): + raise ValueError("expected_retained_cells must be a non-negative integer") + flag_counts = dict(expected_flag_counts) + if any( + not isinstance(name, str) + or not name + or isinstance(count, bool) + or not isinstance(count, int) + or count < 0 + for name, count in flag_counts.items() + ): + raise ValueError( + "expected_flag_counts must map non-empty names to non-negative integers" + ) + + parameters = dict(profile_parameters) + required_parameter_keys = { + "policyVersion", + "profile", + "nMads", + "boundPolicy", + "resolvedBounds", + "captureSizes", + "captureComparisons", + "captureComparisonSource", + "pooledReferenceCaptures", + } + if set(parameters) != required_parameter_keys: + raise ValueError("Registered cell-QC parameters do not match policy version 1") + if ( + isinstance(parameters["policyVersion"], bool) + or parameters["policyVersion"] != 1 + ): + raise ValueError("Registered cell-QC policyVersion must be 1") + if parameters["profile"] != profile: + raise ValueError("Registered cell-QC profile and parameters disagree") + expected_n_mads = 3.0 if profile == "captureMad3Sensitivity" else 5.0 + if ( + isinstance(parameters["nMads"], bool) + or not isinstance(parameters["nMads"], Real) + or float(parameters["nMads"]) != expected_n_mads + ): + raise ValueError( + f"Registered cell-QC profile {profile!r} requires nMads={expected_n_mads}" + ) + expected_bound_policy = { + "count": {"remove": "lower", "flag": "upper"}, + "feature": {"remove": "lower", "flag": "upper"}, + "mitochondrial": {"remove": "upper", "fixedCutoff": None}, + "diagnostic": {"remove": "none"}, + } + if parameters["boundPolicy"] != expected_bound_policy: + raise ValueError("Registered cell-QC boundPolicy is not supported") + raw_bounds = parameters["resolvedBounds"] + if not isinstance(raw_bounds, list) or any( + not isinstance(value, Mapping) for value in raw_bounds + ): + raise TypeError("Registered cell-QC resolvedBounds must be a list of maps") + pooled_references = parameters["pooledReferenceCaptures"] + if not isinstance(pooled_references, list) or any( + not isinstance(value, str) for value in pooled_references + ): + raise TypeError( + "Registered cell-QC pooledReferenceCaptures must be a list of strings" + ) + canonical_bytes(parameters) + + attrs_list = list(attrs or ()) + if any(not isinstance(attr, str) for attr in attrs_list): + raise TypeError("attrs must contain only column names") + metric_artifacts = _validated_named_cell_artifacts( + artifact_metrics, + expected_kind="quality_metric", + label="artifact_metrics", + ) + resolved_sample_artifact: NamedCellArtifact | None = None + if sample_artifact is not None: + resolved_sample_artifact = _validated_named_cell_artifacts( + [sample_artifact], + expected_kind="hto_identity", + label="sample_artifact", + )[0] + if sample_column is not None and resolved_sample_artifact is not None: + raise ValueError("sample_column and sample_artifact are mutually exclusive") + capture_profile = profile in { + "captureMad5", + "captureMad3Sensitivity", + "pooledReferenceMad5", + } + has_capture_source = (sample_column is None) != (resolved_sample_artifact is None) + if capture_profile and not has_capture_source: + raise ValueError( + f"Registered cell-QC profile {profile!r} requires one capture source" + ) + if not capture_profile and has_capture_source: + raise ValueError( + f"Registered cell-QC profile {profile!r} cannot use a capture source" + ) + if sample_column is not None and sample_column not in store.cells.columns: + raise ValueError(f"sample_column '{sample_column}' not found in cell metadata") + missing = [attr for attr in attrs_list if attr not in store.cells.columns] + if missing: + joined = ", ".join(repr(attr) for attr in missing) + raise KeyError(f"Cell metadata columns not found: {joined}") + artifact_names = {source.name for source in metric_artifacts} + duplicate_names = sorted(set(attrs_list).intersection(artifact_names)) + if duplicate_names: + raise ValueError( + "Metadata and artifact QC metrics must use distinct names: " + f"{duplicate_names}" + ) + + prior = store._filter_input_selection(cell_selection) + active = read_stored_selection_mask( + store.zw, + prior, + kind="cell_selection", + scope="datastore", + assay=None, + table_path="cellData", + ) + active_idx = np.flatnonzero(active).astype(np.int64, copy=False) + if len(active_idx) != expected_active_cells: + raise ValueError( + "Registered cell-QC active-cell count differs from its evidence" + ) + + values_by_name: dict[str, np.ndarray] = {} + metadata_fingerprints: dict[str, str] = {} + for attr in attrs_list: + values = np.asarray( + read_metadata_rows_chunkwise(store.cells, attr, active_idx), + dtype=float, + ) + if values.shape != (len(active_idx),): + raise ValueError( + f"QC metadata column {attr!r} does not align with cell_selection" + ) + if not np.isfinite(values).all(): + raise ValueError(f"QC values in {attr!r} contain non-finite entries") + values_by_name[attr] = values + metadata_fingerprints[attr] = fingerprint_array(values) + for source in metric_artifacts: + resolved = resolve_cell_aligned_artifact( + store.zw, + source.artifact, + cell_selection=prior, + expected_kind="quality_metric", + ) + values = np.asarray(resolved.values, dtype=float) + if not np.isfinite(values).all(): + raise ValueError( + f"QC artifact values in {source.name!r} contain non-finite entries" + ) + values_by_name[source.name] = values + + sample_labels: np.ndarray | None = None + sample_inputs: dict[str, Any] = {} + if sample_column is not None: + sample_labels = np.asarray( + read_metadata_rows_chunkwise(store.cells, sample_column, active_idx) + ) + sample_inputs["capture_assignments_fingerprint"] = fingerprint_strings( + sample_labels + ) + elif resolved_sample_artifact is not None: + resolved_sample = resolve_cell_aligned_artifact( + store.zw, + resolved_sample_artifact.artifact, + cell_selection=prior, + expected_kind="hto_identity", + ) + sample_labels = np.asarray(resolved_sample.values) + sample_inputs["capture_artifact"] = resolved_sample_artifact.artifact + + projection = project_registered_qc_profile( + profile, + values_by_metric=values_by_name, + active=np.ones(len(active_idx), dtype=bool), + capture_labels=sample_labels, + grouping_proven=capture_profile, + min_cells_per_capture=20, + pooled_reference_captures=tuple(pooled_references), + ) + recomputed_bounds = [value.to_dict() for value in projection.thresholds] + if canonical_bytes(recomputed_bounds) != canonical_bytes(raw_bounds): + raise ValueError("Registered cell-QC resolved bounds do not match the inputs") + if capture_profile: + if projection.captureSizes != parameters["captureSizes"]: + raise ValueError( + "Registered cell-QC capture sizes do not match the exact inputs" + ) + comparisons = [ + comparison.to_dict() for comparison in projection.captureComparisons + ] + if canonical_bytes(comparisons) != canonical_bytes( + parameters["captureComparisons"] + ): + raise ValueError( + "Registered cell-QC capture comparisons do not match the inputs" + ) + if projection.retainedCells != expected_retained_cells: + raise ValueError( + "Registered cell-QC retained-cell count differs from its evidence" + ) + if projection.flagCounts != flag_counts: + raise ValueError( + "Registered cell-QC diagnostic-flag counts differ from their evidence" + ) + + metric_sources = [ + {"name": attr, "source": "metadataColumn", "column": attr} + for attr in attrs_list + ] + metric_sources.extend( + {"name": source.name, "source": "artifact"} for source in metric_artifacts + ) + source_inputs: dict[str, Any] = { + "metadata_fingerprints": metadata_fingerprints, + "artifact_metrics": { + source.name: source.artifact for source in metric_artifacts + }, + **sample_inputs, + } + execution_parameters: dict[str, Any] = { + "profile": profile, + "metricSources": metric_sources, + "profileParameters": parameters, + "flagCounts": projection.flagCounts, + } + canonical_bytes( + { + "operation": "run_registered_cell_qc", + "parameters": execution_parameters, + "inputs": {"prior_cell_selection": prior, **source_inputs}, + } + ) + + flag_names = tuple(sorted(projection.flags)) + flag_ref: ArtifactRef | None = None + if flag_names: + flag_values = np.column_stack( + [projection.flags[name] for name in flag_names] + ).astype(bool, copy=False) + planned_flags = plan_cell_data_artifact( + store.zw, + scope="datastore", + kind="metadata_snapshot", + operation="run_registered_cell_qc_flags", + parameters={ + **execution_parameters, + "flagNames": list(flag_names), + }, + inputs=source_inputs, + execution_options={}, + cell_selection=prior, + arrays={"values": (flag_values.shape, "b")}, + invalidate_cache=invalidate_cache, + ) + write_cell_data_artifact( + store.zw, + planned_flags, + {"values": flag_values}, + fingerprint_payload=True, + ) + flag_ref = planned_flags.ref + + keep = np.zeros(store.cells.N, dtype=bool) + keep[active_idx] = projection.keep + selection_inputs: dict[str, Any] = { + "prior_cell_selection": prior, + **source_inputs, + } + if flag_ref is not None: + selection_inputs["diagnostic_flags"] = flag_ref + ref, stored = resolve_generated_selection_artifact( + store.zw, + scope="datastore", + kind="cell_selection", + values=keep, + row_ids=np.asarray(store.cells.fetch_all("ids")), + operation="run_registered_cell_qc", + parameters=execution_parameters, + inputs=selection_inputs, + source_column="artifact", + invalidate_cache=invalidate_cache, + ) + logger.info( + f"Registered cell QC {profile!r} retained " + f"{int(stored.sum())}/{store.cells.N} cells" + ) + return ref, flag_ref + + +__all__ = ["execute_registered_cell_qc"] diff --git a/scarf/agent/qc_profiles.py b/scarf/agent/qc_profiles.py new file mode 100644 index 00000000..33a66b03 --- /dev/null +++ b/scarf/agent/qc_profiles.py @@ -0,0 +1,581 @@ +"""Registered one-sided cell-quality profiles and bounded projections.""" + +from dataclasses import dataclass, field +from typing import Literal + +import numpy as np + +from ..quality_control.filtering import ( + _clamp_metric_bound, + _from_work_scale, + _mad_bounds, + _validated_sample_labels, + _validated_work_scale, +) + +type RegisteredCellQcProfile = Literal[ + "retainWithFlags", + "globalMad5", + "captureMad5", + "captureMad3Sensitivity", + "pooledReferenceMad5", +] +type QcMetricRole = Literal["count", "feature", "mitochondrial", "diagnostic"] +type QcRemovalDirection = Literal["lower", "upper", "none"] + +REGISTERED_CELL_QC_PROFILES: tuple[RegisteredCellQcProfile, ...] = ( + "retainWithFlags", + "globalMad5", + "captureMad5", + "captureMad3Sensitivity", + "pooledReferenceMad5", +) + + +@dataclass(frozen=True, slots=True) +class RegisteredQcThreshold: + """One data-derived threshold for one metric and reference population.""" + + metric: str + role: QcMetricRole + group: str + nMads: float + transform: Literal["identity", "log1p"] + removalDirection: QcRemovalDirection + median: float + scaledMad: float + lowerRemoval: float | None + upperRemoval: float | None + upperFlag: float | None + skipReason: Literal["zeroMad"] | None = None + + def to_dict(self) -> dict[str, str | float | None]: + """Return JSON-safe threshold evidence.""" + return { + "metric": self.metric, + "role": self.role, + "group": self.group, + "nMads": self.nMads, + "transform": self.transform, + "removalDirection": self.removalDirection, + "median": self.median, + "scaledMad": self.scaledMad, + "lowerRemoval": self.lowerRemoval, + "upperRemoval": self.upperRemoval, + "upperFlag": self.upperFlag, + "skipReason": self.skipReason, + } + + +@dataclass(frozen=True, slots=True) +class CaptureQcComparison: + """Comparison of one capture median with the complete active population.""" + + capture: str + cells: int + adverseGlobalOutlier: bool + reasons: tuple[str, ...] = () + metricComparisons: dict[str, dict[str, float | str | None]] = field( + default_factory=dict + ) + + def to_dict(self) -> dict[str, object]: + """Return JSON-safe failed-capture evidence.""" + return { + "capture": self.capture, + "cells": self.cells, + "adverseGlobalOutlier": self.adverseGlobalOutlier, + "reasons": list(self.reasons), + "metricComparisons": self.metricComparisons, + } + + +@dataclass(frozen=True, slots=True) +class RegisteredQcProjection: + """Exact masks and evidence for one registered cell-QC profile.""" + + profile: RegisteredCellQcProfile + keep: np.ndarray + flags: dict[str, np.ndarray] + thresholds: tuple[RegisteredQcThreshold, ...] + captureSizes: dict[str, int] = field(default_factory=dict) + retainedByCapture: dict[str, int] = field(default_factory=dict) + captureComparisons: tuple[CaptureQcComparison, ...] = () + failedCaptureCandidates: tuple[str, ...] = () + warnings: tuple[str, ...] = () + + @property + def retainedCells(self) -> int: + """Number of active cells retained by the projection.""" + return int(self.keep.sum()) + + @property + def flagCounts(self) -> dict[str, int]: + """Counts for each non-removal diagnostic flag.""" + return {name: int(mask.sum()) for name, mask in self.flags.items()} + + +def registered_qc_metric_role(metric: str) -> QcMetricRole: + """Classify one conventional cell-quality metric without gene inspection.""" + normalized = metric.replace("_", "").lower() + if normalized.endswith("ncounts") or normalized.endswith("totalcounts"): + return "count" + if normalized.endswith("nfeatures") or normalized.endswith("ngenesbycounts"): + return "feature" + if ( + normalized.endswith("percentmito") + or normalized.endswith("pctcountsmt") + or normalized.endswith("mitochondrialpercent") + ): + return "mitochondrial" + return "diagnostic" + + +def _metric_policy( + role: QcMetricRole, +) -> tuple[Literal["identity", "log1p"], QcRemovalDirection]: + if role in {"count", "feature"}: + return "log1p", "lower" + if role == "mitochondrial": + return "identity", "upper" + return "identity", "none" + + +def _validated_inputs( + values_by_metric: dict[str, np.ndarray], + active: np.ndarray, +) -> tuple[dict[str, np.ndarray], np.ndarray]: + active_mask = np.asarray(active, dtype=bool) + if active_mask.ndim != 1: + raise ValueError("active must be a one-dimensional boolean vector") + if not active_mask.any(): + raise ValueError("active must select at least one cell") + values: dict[str, np.ndarray] = {} + for metric, raw in values_by_metric.items(): + if ( + not isinstance(metric, str) + or not metric.strip() + or metric != metric.strip() + ): + raise ValueError("QC metric names must be non-empty trimmed strings") + array = np.asarray(raw, dtype=float) + if array.ndim != 1 or array.shape != active_mask.shape: + raise ValueError(f"QC metric {metric!r} must align with active") + if not np.isfinite(array[active_mask]).all(): + raise ValueError(f"QC metric {metric!r} contains non-finite active values") + values[metric] = array + return values, active_mask + + +def _threshold( + metric: str, + values: np.ndarray, + reference: np.ndarray, + *, + group: str, + n_mads: float, +) -> RegisteredQcThreshold: + role = registered_qc_metric_role(metric) + transform, removal_direction = _metric_policy(role) + work = _validated_work_scale(values[reference], attr=metric, transform=transform) + median_work = float(np.median(work)) + low_work, high_work, scaled_mad = _mad_bounds(work, n_mads) + if scaled_mad == 0.0: + median = _clamp_metric_bound( + _from_work_scale(median_work, transform), + transform=transform, + is_percent=role == "mitochondrial", + ) + return RegisteredQcThreshold( + metric=metric, + role=role, + group=group, + nMads=n_mads, + transform=transform, + removalDirection=removal_direction, + median=median, + scaledMad=0.0, + lowerRemoval=None, + upperRemoval=None, + upperFlag=None, + skipReason="zeroMad", + ) + + median = _clamp_metric_bound( + _from_work_scale(median_work, transform), + transform=transform, + is_percent=role == "mitochondrial", + ) + low = _clamp_metric_bound( + _from_work_scale(low_work, transform), + transform=transform, + is_percent=role == "mitochondrial", + ) + high = _clamp_metric_bound( + _from_work_scale(high_work, transform), + transform=transform, + is_percent=role == "mitochondrial", + ) + return RegisteredQcThreshold( + metric=metric, + role=role, + group=group, + nMads=n_mads, + transform=transform, + removalDirection=removal_direction, + median=median, + scaledMad=scaled_mad, + lowerRemoval=low if removal_direction == "lower" else None, + upperRemoval=high if removal_direction == "upper" else None, + upperFlag=high if role in {"count", "feature"} else None, + ) + + +def _apply_threshold( + values: np.ndarray, + target: np.ndarray, + threshold: RegisteredQcThreshold, + keep: np.ndarray, + flags: dict[str, np.ndarray], + *, + apply_removal: bool, +) -> None: + if threshold.lowerRemoval is not None: + removed = target & (values < threshold.lowerRemoval) + low_flag = flags.setdefault( + f"{threshold.metric}:lowQuality", + np.zeros(target.shape[0], dtype=bool), + ) + low_flag[removed] = True + if apply_removal: + keep[removed] = False + if threshold.upperRemoval is not None: + removed = target & (values > threshold.upperRemoval) + mito_flag = flags.setdefault( + f"{threshold.metric}:highMito", + np.zeros(target.shape[0], dtype=bool), + ) + mito_flag[removed] = True + if apply_removal: + keep[removed] = False + if threshold.upperFlag is not None: + flag_name = f"{threshold.metric}:high" + flag = flags.setdefault(flag_name, np.zeros(target.shape[0], dtype=bool)) + flag[target & (values > threshold.upperFlag)] = True + + +def _ordered_capture_masks( + capture_labels: np.ndarray, + active: np.ndarray, +) -> list[tuple[str, np.ndarray]]: + labels = _validated_sample_labels( + capture_labels, + active, + label_name="physical capture labels", + ) + captures: list[tuple[str, np.ndarray]] = [] + seen_values: list[object] = [] + seen_keys: set[str] = set() + for raw in labels[active]: + value = raw.item() if isinstance(raw, np.generic) else raw + if any(value == seen for seen in seen_values): + continue + key = value.decode("utf-8") if isinstance(value, bytes) else str(value) + if key in seen_keys: + raise ValueError( + "Physical capture labels collide after provenance encoding" + ) + seen_values.append(value) + seen_keys.add(key) + captures.append((key, active & (labels == raw))) + return captures + + +def _global_capture_comparisons( + values_by_metric: dict[str, np.ndarray], + active: np.ndarray, + captures: list[tuple[str, np.ndarray]], +) -> tuple[CaptureQcComparison, ...]: + global_thresholds = { + metric: _threshold( + metric, + values, + active, + group="globalComparison", + n_mads=5.0, + ) + for metric, values in values_by_metric.items() + if registered_qc_metric_role(metric) != "diagnostic" + } + comparisons: list[CaptureQcComparison] = [] + for capture, mask in captures: + metric_comparisons: dict[str, dict[str, float | str | None]] = {} + reasons: list[str] = [] + for metric, threshold in global_thresholds.items(): + role = threshold.role + transform, _ = _metric_policy(role) + capture_work = _validated_work_scale( + values_by_metric[metric][mask], + attr=metric, + transform=transform, + ) + capture_median_work = float(np.median(capture_work)) + capture_median = _clamp_metric_bound( + _from_work_scale(capture_median_work, transform), + transform=transform, + is_percent=role == "mitochondrial", + ) + global_mad = threshold.scaledMad + standardized_shift = ( + None + if global_mad == 0.0 + else (capture_median_work - np.log1p(threshold.median)) / global_mad + if transform == "log1p" + else (capture_median - threshold.median) / global_mad + ) + adverse = ( + threshold.lowerRemoval is not None + and capture_median < threshold.lowerRemoval + ) or ( + threshold.upperRemoval is not None + and capture_median > threshold.upperRemoval + ) + if adverse: + reasons.append(f"{metric}:{role}:adverseGlobalMedian") + metric_comparisons[metric] = { + "role": role, + "captureMedian": capture_median, + "globalMedian": threshold.median, + "globalLower": threshold.lowerRemoval, + "globalUpper": threshold.upperRemoval, + "standardizedMedianShift": standardized_shift, + } + comparisons.append( + CaptureQcComparison( + capture=capture, + cells=int(mask.sum()), + adverseGlobalOutlier=bool(reasons), + reasons=tuple(reasons), + metricComparisons=metric_comparisons, + ) + ) + return tuple(comparisons) + + +def project_registered_qc_profile( + profile: RegisteredCellQcProfile, + *, + values_by_metric: dict[str, np.ndarray], + active: np.ndarray, + capture_labels: np.ndarray | None = None, + grouping_proven: bool = False, + min_cells_per_capture: int = 20, + pooled_reference_captures: tuple[str, ...] | None = None, +) -> RegisteredQcProjection: + """Project one registered QC profile without reading or writing a matrix.""" + if profile not in REGISTERED_CELL_QC_PROFILES: + raise ValueError(f"Unknown registered cell-QC profile {profile!r}") + if min_cells_per_capture < 2: + raise ValueError("min_cells_per_capture must be at least 2") + values, active_mask = _validated_inputs(values_by_metric, active) + filtering_values = { + metric: metric_values + for metric, metric_values in values.items() + if registered_qc_metric_role(metric) != "diagnostic" + } + if not filtering_values: + if profile != "retainWithFlags": + raise ValueError("MAD profiles require count, feature, or mito metrics") + return RegisteredQcProjection( + profile=profile, + keep=active_mask.copy(), + flags={}, + thresholds=(), + warnings=("No registered count, feature, or mito metrics were available",), + ) + + capture_profile = profile in { + "captureMad5", + "captureMad3Sensitivity", + "pooledReferenceMad5", + } + if capture_profile and (capture_labels is None or not grouping_proven): + raise ValueError( + f"{profile} requires an explicitly proven physical capture grouping" + ) + captures = ( + _ordered_capture_masks(np.asarray(capture_labels), active_mask) + if capture_labels is not None and grouping_proven + else [] + ) + capture_sizes = {name: int(mask.sum()) for name, mask in captures} + if profile in {"captureMad5", "captureMad3Sensitivity"}: + undersized = [ + name for name, size in capture_sizes.items() if size < min_cells_per_capture + ] + if undersized: + raise ValueError( + f"{profile} requires at least {min_cells_per_capture} cells in every " + f"capture; undersized={undersized}" + ) + if profile == "pooledReferenceMad5": + references = tuple(pooled_reference_captures or ()) + if len(references) < 2 or len(references) != len(set(references)): + raise ValueError( + "pooledReferenceMad5 requires at least two unique reference captures" + ) + unknown = sorted(set(references).difference(capture_sizes)) + if unknown: + raise ValueError(f"Unknown pooled reference captures: {unknown}") + pooled_cells = sum(capture_sizes[name] for name in references) + if pooled_cells < min_cells_per_capture: + raise ValueError( + "Pooled reference captures do not contain enough active cells" + ) + + n_mads = 3.0 if profile == "captureMad3Sensitivity" else 5.0 + keep = active_mask.copy() + flags: dict[str, np.ndarray] = {} + thresholds: list[RegisteredQcThreshold] = [] + warnings: list[str] = [] + + reference_groups: list[tuple[str, np.ndarray, np.ndarray]] + if profile in {"retainWithFlags", "globalMad5"}: + reference_groups = [("global", active_mask, active_mask)] + elif profile == "pooledReferenceMad5": + reference_names = set(pooled_reference_captures or ()) + reference = np.zeros(active_mask.shape[0], dtype=bool) + for name, mask in captures: + if name in reference_names: + reference |= mask + reference_groups = [("pooledReference", active_mask, reference)] + else: + reference_groups = [(name, mask, mask) for name, mask in captures] + + for group, target, reference in reference_groups: + for metric, metric_values in filtering_values.items(): + threshold = _threshold( + metric, + metric_values, + reference, + group=group, + n_mads=n_mads, + ) + thresholds.append(threshold) + if threshold.skipReason is not None: + warnings.append( + f"Ignored {metric!r} for {group!r} because its MAD was zero" + ) + continue + _apply_threshold( + metric_values, + target, + threshold, + keep, + flags, + apply_removal=profile != "retainWithFlags", + ) + + comparisons = ( + _global_capture_comparisons(filtering_values, active_mask, captures) + if captures + else () + ) + failed = tuple( + comparison.capture + for comparison in comparisons + if comparison.adverseGlobalOutlier + ) + if failed: + warnings.append( + "Capture medians outside adverse global MAD bounds require review: " + + ", ".join(failed) + ) + retained_by_capture = {name: int((mask & keep).sum()) for name, mask in captures} + return RegisteredQcProjection( + profile=profile, + keep=keep, + flags=flags, + thresholds=tuple(thresholds), + captureSizes=capture_sizes, + retainedByCapture=retained_by_capture, + captureComparisons=comparisons, + failedCaptureCandidates=failed, + warnings=tuple(warnings), + ) + + +def offered_registered_qc_profiles( + *, + values_by_metric: dict[str, np.ndarray], + active: np.ndarray, + capture_labels: np.ndarray | None = None, + grouping_proven: bool = False, + min_cells_per_capture: int = 20, + pooled_reference_captures: tuple[str, ...] | None = None, +) -> list[RegisteredQcProjection]: + """Build only profiles whose deterministic eligibility gates pass.""" + projections = [ + project_registered_qc_profile( + "retainWithFlags", + values_by_metric=values_by_metric, + active=active, + capture_labels=capture_labels, + grouping_proven=grouping_proven, + ) + ] + try: + global_projection = project_registered_qc_profile( + "globalMad5", + values_by_metric=values_by_metric, + active=active, + capture_labels=capture_labels, + grouping_proven=grouping_proven, + ) + except ValueError: + return projections + projections.append(global_projection) + if capture_labels is None or not grouping_proven: + return projections + for profile in ("captureMad5", "captureMad3Sensitivity"): + try: + projection = project_registered_qc_profile( + profile, + values_by_metric=values_by_metric, + active=active, + capture_labels=capture_labels, + grouping_proven=True, + min_cells_per_capture=min_cells_per_capture, + ) + except ValueError: + continue + projections.append(projection) + if pooled_reference_captures: + try: + projection = project_registered_qc_profile( + "pooledReferenceMad5", + values_by_metric=values_by_metric, + active=active, + capture_labels=capture_labels, + grouping_proven=True, + min_cells_per_capture=min_cells_per_capture, + pooled_reference_captures=pooled_reference_captures, + ) + except ValueError: + pass + else: + projections.append(projection) + return projections + + +__all__ = [ + "REGISTERED_CELL_QC_PROFILES", + "CaptureQcComparison", + "QcMetricRole", + "RegisteredCellQcProfile", + "RegisteredQcProjection", + "RegisteredQcThreshold", + "offered_registered_qc_profiles", + "project_registered_qc_profile", + "registered_qc_metric_role", +] diff --git a/scarf/agent/report.py b/scarf/agent/report.py index cc141319..aad8dac9 100644 --- a/scarf/agent/report.py +++ b/scarf/agent/report.py @@ -34,6 +34,7 @@ load_agent_report, load_agent_workflow, ) +from .types import ArtifactReferenceModel MAX_MARKER_DOTPLOT_FEATURES = 24 CLUSTER_COUNT_BLOCK_SIZE = 100_000 @@ -41,6 +42,9 @@ MAX_DOTPLOT_CELLS = 75_000 MAX_CONNECTIVITY_PLOT_CELLS = 100_000 MAX_COMPOSITION_PLOT_CELLS = 1_000_000 +MAX_CHIP_LENGTH = 56 +MAX_TABLE_COLUMNS = 7 +MAX_INLINE_LEAVES = 12 def _local_root(target: str | Path | DataStore) -> Path: @@ -227,23 +231,15 @@ def _collect_history( ): raise ValueError("Workflow parent-stage lineage does not resolve") - biological_reports = [ - reference - for reference in workflow.reports - if reference.agentName == "biological_interpretation" - ] - if not biological_reports: - raise ValueError("Completed workflow lacks a Biological Interpretation report") - terminal_report = biological_reports[-1] terminal_candidates = [ attempt for attempt in attempts.values() - if attempt.stage == "biological_interpretation" - and attempt.status == "done" - and terminal_report in attempt.reportReferences + if attempt.stage == "analysis_finalization" and attempt.status == "done" ] if len(terminal_candidates) != 1: - raise ValueError("Completed workflow lacks one exact terminal stage attempt") + raise ValueError( + "Completed workflow lacks one exact analysis finalization attempt" + ) current = terminal_candidates[0] terminal_chain: set[tuple[str, str]] = set() while True: @@ -619,6 +615,160 @@ def render_plot(name: str, filename: str, create: Any) -> None: return cluster_counts, top_markers, plots, notes +def _hvg_diagnostic_evidence( + store: DataStore, + reference: Mapping[str, Any], +) -> dict[str, Any]: + import numpy as np + + model = ArtifactReferenceModel.model_validate(reference) + group: Any = store.load_artifact(artifact_model_to_ref(model)) + provenance = _mapping(group.attrs.get("provenance")) + parameters = _mapping(provenance.get("parameters")) + ranking = np.asarray(group["ranking"][:], dtype=np.int64) + corrected_variance = np.asarray( + group["global_corrected_variance"][:], + dtype=np.float64, + ) + recurrence = np.asarray(group["recurrence"][:], dtype=np.int64) + eligible = np.asarray(group["eligible"][:], dtype=bool) + if ( + ranking.ndim != 1 + or corrected_variance.ndim != 1 + or recurrence.shape != corrected_variance.shape + or eligible.shape != corrected_variance.shape + ): + raise ValueError("HVG diagnostic arrays are malformed") + if ranking.size and ( + int(ranking.min()) < 0 or int(ranking.max()) >= corrected_variance.size + ): + raise ValueError("HVG diagnostic ranking contains out-of-range indices") + raw_counts = parameters.get("candidate_counts") + if not _is_sequence(raw_counts): + raise ValueError("HVG diagnostic is missing candidate counts") + raw_count_values = cast(Sequence[Any], raw_counts) + candidate_counts = [ + int(value) + for value in raw_count_values + if isinstance(value, int) and not isinstance(value, bool) + ] + if len(candidate_counts) != len(raw_count_values) or any( + value < 1 or value > ranking.size for value in candidate_counts + ): + raise ValueError("HVG diagnostic candidate counts are invalid") + valid_groups = group.attrs.get("valid_groups", []) + if not _is_sequence(valid_groups): + raise ValueError("HVG diagnostic valid groups are malformed") + valid_group_count = len(cast(Sequence[Any], valid_groups)) + excluded_groups = group.attrs.get("excluded_groups", []) + if not _is_sequence(excluded_groups): + raise ValueError("HVG diagnostic excluded groups are malformed") + eligible_variance = float(corrected_variance[eligible].sum()) + recurrence_threshold = max(2, (valid_group_count + 1) // 2) + candidates: list[dict[str, Any]] = [] + for count in candidate_counts: + selected = ranking[:count] + variance_fraction = ( + float(corrected_variance[selected].sum()) / eligible_variance + if eligible_variance > 0 + else 0.0 + ) + candidates.append( + { + "featureCount": count, + "varianceFraction": variance_fraction, + "recurrentFraction": ( + float((recurrence[selected] >= recurrence_threshold).mean()) + if valid_group_count + else None + ), + } + ) + broad = ranking[: max(candidate_counts)] + return { + "rankingMode": group.attrs.get("ranking_mode"), + "eligibleFeatureCount": int(eligible.sum()), + "validTechnicalGroups": valid_group_count, + "excludedTechnicalGroupCount": len(cast(Sequence[Any], excluded_groups)), + "candidateMetrics": candidates, + "meanTechnicalGroupCoverage": ( + float(recurrence[broad].mean()) / valid_group_count + if valid_group_count + else None + ), + "recurrentInTwoGroupsFraction": ( + float((recurrence[broad] >= 2).mean()) if valid_group_count else None + ), + "minimumDetectedCells": parameters.get("min_cells"), + "minimumTechnicalGroupCells": parameters.get("min_group_cells"), + } + + +def _collect_hvg_evidence( + store: DataStore, + stage_attempts: Sequence[Mapping[str, Any]], + preprocessing_plan: Mapping[str, Any], +) -> dict[str, Any]: + selected_name = "" + selected_reference: dict[str, Any] = {} + selected_artifacts: dict[str, Any] = {} + for attempt in reversed(stage_attempts): + artifacts = _mapping(attempt.get("artifacts")) + match = next( + ( + (name, _mapping(reference)) + for name, reference in artifacts.items() + if re.fullmatch(r".+_hvg_diagnostic", str(name)) + ), + None, + ) + if match is not None: + selected_name, selected_reference = match + selected_artifacts = artifacts + break + if not selected_reference: + return {} + assay = selected_name.removesuffix("_hvg_diagnostic") + selected = _hvg_diagnostic_evidence(store, selected_reference) + ranking_references = ( + ("global", selected_artifacts.get(f"{assay}_hvg_global_diagnostic")), + ( + "batchAware", + selected_artifacts.get(f"{assay}_hvg_batchAware_diagnostic"), + ), + ) + rankings: list[dict[str, Any]] = [] + for mode, reference in ranking_references: + if isinstance(reference, Mapping): + summary = _hvg_diagnostic_evidence(store, reference) + if summary.get("rankingMode") != mode: + raise ValueError("HVG diagnostic ranking mode does not match its role") + rankings.append(summary) + if not rankings: + rankings.append(selected) + assay_plan = next( + ( + value + for value in _mappings(preprocessing_plan.get("assays")) + if value.get("assay") == assay + ), + {}, + ) + selected_count = _mapping(assay_plan.get("featureParameters")).get("topN") + return { + "assay": assay, + "selectedRankingMode": selected.get("rankingMode"), + "selectedFeatureCount": selected_count, + "rankings": rankings, + "candidateMetrics": selected.get("candidateMetrics"), + "eligibleFeatureCount": selected.get("eligibleFeatureCount"), + "validTechnicalGroups": selected.get("validTechnicalGroups"), + "excludedTechnicalGroupCount": selected.get("excludedTechnicalGroupCount"), + "minimumDetectedCells": selected.get("minimumDetectedCells"), + "minimumTechnicalGroupCells": selected.get("minimumTechnicalGroupCells"), + } + + REPORT_STYLES = """ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400&display=swap'); @@ -633,20 +783,27 @@ def render_plot(name: str, filename: str, create: Any) -> None: html { background: var(--white); color: var(--black); font-family: Inter, sans-serif; } body { margin: 0; + overflow-x: hidden; background: var(--white); color: var(--black); font-family: Inter, sans-serif; font-weight: 300; letter-spacing: -0.04em; - line-height: 1.2; + line-height: 1.45; + overflow-wrap: break-word; + word-break: normal; } a { color: var(--blue); } header, main, footer { width: min(100%, 1240px); + max-width: 100%; margin: 0 auto; padding-left: clamp(1.25rem, 5vw, 4.5rem); padding-right: clamp(1.25rem, 5vw, 4.5rem); } +.technical-page header, .technical-page main, .technical-page footer { + width: min(100%, 1800px); +} header { display: flex; align-items: center; @@ -691,10 +848,10 @@ def render_plot(name: str, filename: str, create: Any) -> None: letter-spacing: -0.04em; line-height: 1.2; } -p, li, td, th, summary, code, pre, a { +p, li, td, th, summary, code, pre, a, dd, dt { font-family: Inter, sans-serif; letter-spacing: -0.04em; - line-height: 1.2; + line-height: 1.45; } strong { font-weight: 400; } .eyebrow { @@ -709,26 +866,50 @@ def render_plot(name: str, filename: str, create: Any) -> None: font-size: clamp(1.2rem, 2vw, 1.7rem); font-weight: 300; } -.pill-row, .chip-row, .metric-grid { +.report-nav { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: .35rem; +} +.report-nav a { + border-radius: .35rem; + padding: .4rem .65rem; + color: var(--black); + font-size: .8rem; + font-weight: 400; + text-decoration: none; +} +.report-nav a[aria-current="page"] { + box-shadow: inset 0 0 0 1px var(--blue); + color: var(--blue); +} +.pill-row, .chip-row, .metric-grid, .kv-list { display: flex; flex-wrap: wrap; gap: .65rem; + min-width: 0; + max-width: 100%; } .pill-row { margin-top: 1.75rem; } .pill, .chip { display: inline-flex; - align-items: center; - border-radius: 999px; + max-width: 100%; font-size: .82rem; font-weight: 400; - line-height: 1.2; + line-height: 1.35; + overflow-wrap: anywhere; + word-break: normal; } .pill { + align-items: center; border: 1px solid var(--blue); + border-radius: 999px; padding: .68rem 1.1rem; background: var(--blue); color: var(--white); text-decoration: none; + white-space: nowrap; } .pill-outline { background: var(--white); @@ -736,17 +917,30 @@ def render_plot(name: str, filename: str, create: Any) -> None: color: var(--blue); } .chip { + display: inline-block; + border-radius: .35rem; box-shadow: inset 0 0 0 1px var(--blue); - padding: .42rem .75rem; + padding: .4rem .7rem; color: var(--black); + white-space: normal; + overflow: visible; +} +.text-item { + display: block; + min-width: 0; + max-width: 100%; + overflow-wrap: anywhere; + word-break: normal; } .metric-grid { margin-top: 2rem; } .metric { display: flex; - min-width: 9rem; + min-width: 0; + max-width: 100%; + flex: 1 1 9rem; flex-direction: column; gap: .2rem; - border-radius: 999px; + border-radius: 1.5rem; box-shadow: inset 0 0 0 1px var(--blue); padding: .8rem 1.2rem; } @@ -757,7 +951,265 @@ def render_plot(name: str, filename: str, create: Any) -> None: text-transform: uppercase; } .metric-value { font-size: .95rem; font-weight: 400; overflow-wrap: anywhere; } -.section { margin-top: 4rem; border-top: 1px solid var(--black); padding-top: 1.5rem; } +.report-choice-grid, .summary-grid, .interpretation-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr)); + gap: 1rem; + min-width: 0; + max-width: 100%; +} +.report-choice { + display: flex; + min-width: 0; + min-height: 14rem; + flex-direction: column; + border: 1px solid var(--black); + padding: 1.5rem; + color: var(--black); + text-decoration: none; +} +.report-choice:hover, .report-choice:focus-visible { + border-color: var(--blue); +} +.report-choice h2 { margin-bottom: .75rem; } +.report-choice p { max-width: 36rem; } +.report-choice-action { + margin-top: auto; + padding-top: 1.5rem; + color: var(--blue); + font-weight: 400; +} +.summary-card, .interpretation-card { + min-width: 0; + border: 1px solid var(--black); + padding: 1.25rem; +} +.summary-card p:last-child, .interpretation-card p:last-child { margin-bottom: 0; } +.summary-label { + margin-bottom: .5rem; + color: var(--gray); + font-size: .72rem; + font-weight: 400; + text-transform: uppercase; +} +.decision-tree { + min-width: 0; + max-width: 100%; + margin-top: 2rem; +} +.tree-stage { + min-width: 0; + max-width: 100%; + margin: 0; + border: 0; + padding: 0; +} +.tree-question { + display: flex; + width: min(100%, 19rem); + min-height: 8rem; + align-items: center; + justify-content: center; + margin: 0 auto; + clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); + flex-direction: column; + padding: 1.75rem 3rem; + background: var(--blue); + color: var(--white); + text-align: center; +} +.tree-question span { + margin-bottom: .35rem; + font-size: .68rem; + font-weight: 400; + text-transform: uppercase; +} +.tree-question strong { + font-size: .9rem; + line-height: 1.25; +} +.tree-stage-description { + max-width: 42rem; + margin: 1rem auto 0; + color: var(--gray); + text-align: center; +} +.tree-branch-connectors, .tree-selection-connector { + display: block; + width: 100%; + height: 5.25rem; + color: var(--blue); +} +.tree-branch-connectors path, .tree-selection-connector path { + fill: none; + stroke: currentColor; + stroke-width: 1.5; + vector-effect: non-scaling-stroke; +} +.tree-branch-connectors marker path, .tree-selection-connector marker path { + fill: currentColor; + stroke: none; +} +.tree-branches { + display: grid; + grid-template-columns: repeat(var(--branch-count), minmax(0, 1fr)); + gap: .75rem; + min-width: 0; + max-width: 100%; +} +.tree-branch { + min-width: 0; + border: 1px solid var(--gray); + padding: 1rem; + background: var(--white); +} +.tree-branch-selected { + border: 2px solid var(--blue); + box-shadow: inset 0 .25rem 0 var(--blue); +} +.tree-branch-blocked { + border-style: dashed; +} +.tree-branch-status { + display: inline-block; + margin-bottom: .65rem; + border-radius: .3rem; + box-shadow: inset 0 0 0 1px var(--gray); + padding: .25rem .45rem; + color: var(--gray); + font-size: .68rem; + font-weight: 400; + text-transform: uppercase; +} +.tree-branch-selected .tree-branch-status { + box-shadow: inset 0 0 0 1px var(--blue); + color: var(--blue); +} +.tree-branch h3 { margin-bottom: .65rem; font-weight: 400; } +.tree-branch p { margin-bottom: 0; font-size: .82rem; } +.tree-metrics { + margin: 0 0 .8rem; + padding-left: 1rem; + font-size: .76rem; +} +.tree-metrics li { margin: .25rem 0; } +.evidence-accordion { + display: flex; + flex-direction: column; + gap: .8rem; + margin-top: 1.5rem; +} +.evidence-panel { + margin: 0; + border: 1px solid var(--black); + padding: 0; +} +.evidence-panel > summary { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 1rem; + align-items: center; + padding: 1.15rem 1.25rem; + list-style: none; +} +.evidence-panel > summary::-webkit-details-marker { display: none; } +.evidence-panel > summary::after { + color: var(--blue); + content: "+"; + font-size: 1.4rem; + line-height: 1; +} +.evidence-panel[open] > summary::after { content: "−"; } +.evidence-panel-title { + display: block; + margin-bottom: .25rem; + color: var(--gray); + font-size: .7rem; + font-weight: 400; + text-transform: uppercase; +} +.evidence-panel-outcome { + display: block; + font-size: .95rem; + font-weight: 400; +} +.evidence-panel-body { + border-top: 1px solid var(--black); + padding: 1.25rem; +} +.evidence-panel-body > p:first-child { max-width: 55rem; } +.evidence-choice-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr)); + gap: .75rem; + margin-top: 1rem; +} +.evidence-choice { + min-width: 0; + border: 1px solid var(--gray); + padding: 1rem; +} +.evidence-choice-selected { + border: 2px solid var(--blue); + box-shadow: inset 0 .2rem 0 var(--blue); +} +.evidence-choice-rejected { border-style: dashed; } +.evidence-choice-status { + display: inline-block; + margin-bottom: .55rem; + color: var(--gray); + font-size: .68rem; + font-weight: 400; + text-transform: uppercase; +} +.evidence-choice-selected .evidence-choice-status { color: var(--blue); } +.evidence-choice h3 { margin-bottom: .55rem; font-weight: 400; } +.evidence-choice p:last-child { margin-bottom: 0; } +.evidence-choice .plain-list { + margin-bottom: .75rem; + font-size: .8rem; +} +.evidence-measurements { + margin-top: 1.25rem; + border: 0; + border-top: 1px solid var(--gray); + padding-top: .8rem; +} +.evidence-measurements > summary { + color: var(--blue); + font-size: .82rem; +} +.evidence-measurements-body { padding-top: 1rem; } +.evidence-measurement-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr)); + gap: .75rem; +} +.evidence-measurement { + min-width: 0; + border-bottom: 1px solid var(--gray); + padding: .7rem 0; +} +.evidence-measurement dt { + margin-bottom: .35rem; + color: var(--gray); +} +.evidence-measurement dd { font-size: .86rem; } +.evidence-measurement small { + display: block; + margin-top: .35rem; + color: var(--gray); + font-size: .72rem; +} +.plain-list { margin: 0; padding-left: 1.2rem; } +.plain-list li { margin: .55rem 0; } +.section { + margin-top: 4rem; + min-width: 0; + max-width: 100%; + border-top: 1px solid var(--black); + padding-top: 1.5rem; +} .section:target { scroll-margin-top: 1rem; } .section-heading { display: grid; @@ -765,66 +1217,152 @@ def render_plot(name: str, filename: str, create: Any) -> None: gap: 1rem; align-items: start; } -.subsection { margin-top: 2rem; } +.subsection { margin-top: 2rem; min-width: 0; max-width: 100%; } .card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr)); gap: 1rem; + min-width: 0; + max-width: 100%; } -.card, .callout { +.card, .callout, .record { + min-width: 0; + max-width: 100%; border: 1px solid var(--black); padding: 1.25rem; background: var(--white); + overflow: visible; } .callout { border-color: var(--blue); } +.record-stack { + display: flex; + flex-direction: column; + gap: 1rem; + min-width: 0; + max-width: 100%; +} .product-callout { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 1rem; align-items: center; margin-top: 2.5rem; - border-radius: 999px; + border-radius: 1.5rem; box-shadow: inset 0 0 0 1px var(--blue); padding: 1.4rem; } .product-callout p { margin-bottom: 0; max-width: 55rem; } .empty { color: var(--gray); font-style: italic; } -.table-wrap { width: 100%; overflow-x: auto; } -table { width: 100%; border-collapse: collapse; font-size: .86rem; } +.table-wrap { + width: 100%; + max-width: 100%; + overflow: visible; +} +table { + width: 100%; + table-layout: auto; + border-collapse: collapse; + font-size: .86rem; +} th, td { + min-width: 0; + width: auto; border-bottom: 1px solid var(--black); padding: .8rem .7rem; text-align: left; vertical-align: top; + overflow-wrap: break-word; + word-break: normal; + hyphens: auto; +} +th { + position: sticky; + top: 0; + background: var(--white); + color: var(--gray); + font-weight: 400; + overflow-wrap: normal; + text-transform: uppercase; } -th { color: var(--gray); font-weight: 400; text-transform: uppercase; } td { font-weight: 300; overflow-wrap: anywhere; } +td > * { max-width: 100%; } tr.selected { box-shadow: inset 4px 0 0 var(--blue); } -dl { margin: 0; } +.table-records { gap: 1.25rem; } +.table-record { + border-color: var(--gray); + padding: 1rem; +} +.table-record-selected { + border: 2px solid var(--blue); + box-shadow: inset .25rem 0 0 var(--blue); +} +.record-fields { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr)); + gap: .9rem 1.25rem; +} +.record-field { + min-width: 0; + border-bottom: 1px solid var(--gray); + padding-bottom: .65rem; +} +.record-field-wide { grid-column: 1 / -1; } +.record-field dt { margin-bottom: .3rem; } +.record-field dd { + overflow-wrap: anywhere; + word-break: normal; +} +dl { margin: 0; min-width: 0; max-width: 100%; } .details > div { display: grid; - grid-template-columns: minmax(8rem, 14rem) minmax(0, 1fr); + grid-template-columns: minmax(0, 12rem) minmax(0, 1fr); gap: 1rem; + min-width: 0; border-bottom: 1px solid var(--gray); padding: .55rem 0; } +.details .details > div { + grid-template-columns: minmax(0, 1fr); + gap: .2rem; +} dt { color: var(--gray); font-size: .78rem; font-weight: 400; text-transform: uppercase; } -dd { margin: 0; overflow-wrap: anywhere; } +dd { min-width: 0; margin: 0; overflow-wrap: anywhere; } +.kv { display: inline-flex; flex-wrap: wrap; gap: .25rem .4rem; min-width: 0; max-width: 100%; } +.kv-k { + color: var(--gray); + font-size: .72rem; + font-weight: 400; + text-transform: uppercase; +} +.kv-v { overflow-wrap: anywhere; word-break: normal; } +.nested-records { + display: block; + margin: .15rem 0; + border: 0; + padding: 0; + min-width: 0; + max-width: 100%; + overflow: visible; +} +.nested-records > summary { color: var(--blue); font-size: .82rem; } +.nested-records .table-wrap, .nested-records .record-stack { margin-top: .55rem; } .plot-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 26rem), 1fr)); gap: 2rem; + min-width: 0; } -figure { margin: 0; } +figure { margin: 0; min-width: 0; } figure.primary { grid-column: 1 / -1; } figure img { display: block; width: 100%; height: auto; border: 1px solid var(--black); } figcaption { margin-top: .7rem; color: var(--black); font-size: .85rem; } .cluster-row { display: grid; - grid-template-columns: minmax(5rem, auto) minmax(8rem, 1fr) auto; + grid-template-columns: minmax(0, 8rem) minmax(0, 1fr) auto; gap: .7rem; align-items: center; margin: .5rem 0; + min-width: 0; } .cluster-track { height: .7rem; border-radius: 999px; background: var(--gray); overflow: hidden; } .cluster-fill { height: 100%; border-radius: 999px; background: var(--blue); } @@ -834,6 +1372,7 @@ def render_plot(name: str, filename: str, create: Any) -> None: summary { cursor: pointer; font-weight: 400; } pre { max-height: 36rem; + max-width: 100%; overflow: auto; background: var(--white); box-shadow: inset 0 0 0 1px var(--blue); @@ -842,9 +1381,36 @@ def render_plot(name: str, filename: str, create: Any) -> None: white-space: pre-wrap; word-break: break-word; } +@media (max-width: 900px) { + .tree-branches { grid-template-columns: 1fr; } + .tree-branch-connectors, .tree-selection-connector { display: none; } + .tree-question { margin-bottom: 2.5rem; } + .tree-stage:not(:last-child)::after { + display: block; + margin: .25rem 0 2rem; + color: var(--blue); + content: "↓"; + font-size: 1.5rem; + text-align: center; + } + .tree-branch { + position: relative; + margin-bottom: 1.5rem; + } + .tree-branch::before { + position: absolute; + top: -1.65rem; + left: 50%; + color: var(--blue); + content: "↓"; + } +} @media (max-width: 680px) { + header { align-items: flex-start; flex-direction: column; } + .report-nav { justify-content: flex-start; } .section-heading, .product-callout { grid-template-columns: 1fr; } .details > div { grid-template-columns: 1fr; gap: .25rem; } + .cluster-row { grid-template-columns: minmax(0, 1fr) auto; } } """ @@ -895,34 +1461,166 @@ def _mappings(value: Any) -> list[dict[str, Any]]: return [dict(item) for item in value if isinstance(item, Mapping)] +def _is_sequence(value: Any) -> bool: + return isinstance(value, Sequence) and not isinstance( + value, (str, bytes, bytearray) + ) + + +def _is_leaf(value: Any) -> bool: + return not isinstance(value, Mapping) and not _is_sequence(value) + + +def _is_simple(value: Any) -> bool: + if _is_leaf(value): + return True + return _is_sequence(value) and all(_is_leaf(item) for item in value) + + +def _is_mapping_sequence(value: Any) -> bool: + return ( + _is_sequence(value) + and bool(value) + and all(isinstance(item, Mapping) for item in value) + ) + + +def _chip(text: str) -> str: + escaped = html.escape(text) + if len(text) > MAX_CHIP_LENGTH: + return f'{escaped}' + return f'{escaped}' + + def _chips(value: Any, empty: str = "Not provided") -> str: if not _present(value): return f'{html.escape(empty)}' if isinstance(value, Mapping): items = [f"{_label(key)}: {_scalar(item)}" for key, item in value.items()] - elif isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + elif _is_sequence(value): items = list(value) else: items = [value] return '{}'.format( - "".join( - f'{html.escape(_scalar(item))}' for item in items + "".join(_chip(_scalar(item)) for item in items) + ) + + +def _kv_list(mapping: Mapping[str, Any]) -> str: + parts: list[str] = [] + for key, item in mapping.items(): + if not _present(item): + continue + label = html.escape(_label(key)) + if _is_leaf(item): + text = f"{_label(key)}: {_scalar(item)}" + if len(text) <= MAX_CHIP_LENGTH: + parts.append(_chip(text)) + else: + parts.append( + f'{label}' + f'{html.escape(_scalar(item))}' + ) + elif _is_simple(item): + parts.append( + f'{label}{_chips(item)}' + ) + else: + parts.append( + '
' + f"{label}{_value(item)}
" + ) + if not parts: + return 'Not provided' + return f'
{"".join(parts)}
' + + +def _cell(value: Any) -> str: + if not _present(value): + return 'Not provided' + if isinstance(value, Mapping): + return _kv_list(value) + if _is_mapping_sequence(value): + count = len(value) + return ( + '
' + f"{count:,} records" + f"{_mapping_list(value)}
" + ) + if _is_sequence(value) and all(_is_leaf(item) for item in value): + if len(value) > MAX_INLINE_LEAVES: + return html.escape(", ".join(_scalar(item) for item in value)) + return _chips(value) + if _is_sequence(value): + return _chips(value) + return html.escape(_scalar(value)) + + +def _visible_columns(rows: Sequence[Mapping[str, Any]]) -> list[str]: + return list( + dict.fromkeys( + key for row in rows for key in row if not str(key).startswith("_") ) ) +def _render_record_rows( + rows: Sequence[Mapping[str, Any]], + columns: Sequence[str], +) -> str: + records: list[str] = [] + for row in rows: + fields: list[str] = [] + for key in columns: + value = row.get(key) + wide = not _is_simple(value) or ( + isinstance(value, str) and len(value) > MAX_CHIP_LENGTH + ) + field_class = "record-field record-field-wide" if wide else "record-field" + fields.append( + f'
' + f"
{html.escape(_label(key))}
" + f"
{_cell(value)}
" + ) + selected_class = " table-record-selected" if row.get("_selected") else "" + records.append( + f'
' + f'
{"".join(fields)}
' + ) + return f'
{"".join(records)}
' + + +def _mapping_list(rows: Sequence[Mapping[str, Any]]) -> str: + normalized = [dict(row) for row in rows] + if not normalized: + return '

No records available.

' + visible = _visible_columns(normalized) + if len(visible) <= MAX_TABLE_COLUMNS: + return _table(normalized) + return _render_record_rows(normalized, visible) + + def _value(value: Any) -> str: if not _present(value): return 'Not provided' if isinstance(value, Mapping): rows = "".join( - f"
{html.escape(_label(key))}
{_value(item)}
" + "
{}
{}
".format( + html.escape(_label(key)), + ( + _mapping_list(_mappings(item)) + if _is_mapping_sequence(item) + else _value(item) + ), + ) for key, item in value.items() if _present(item) ) return f'
{rows}
' - if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): - if all(not isinstance(item, Mapping) for item in value): + if _is_mapping_sequence(value): + return _mapping_list(value) + if _is_sequence(value): + if all(_is_leaf(item) for item in value): return _chips(value) return '
{}
'.format( "".join(f'
{_value(item)}
' for item in value) @@ -939,15 +1637,13 @@ def _table( normalized = [dict(row) for row in rows] if not normalized: return f'

{html.escape(empty)}

' - visible = list(columns or ()) or list( - dict.fromkeys( - key for row in normalized for key in row if not str(key).startswith("_") - ) - ) + visible = list(columns or ()) or _visible_columns(normalized) + if len(visible) > MAX_TABLE_COLUMNS: + return _render_record_rows(normalized, visible) headings = "".join(f"{html.escape(_label(key))}" for key in visible) body = "".join( ('' if row.get("_selected") else "") - + "".join(f"{_value(row.get(key))}" for key in visible) + + "".join(f"{_cell(row.get(key))}" for key in visible) + "" for row in normalized ) @@ -968,8 +1664,17 @@ def _latest(reports: Mapping[str, Any], agent_name: str) -> dict[str, Any]: return {} -def _render_plots(plots: Mapping[str, str], notes: Sequence[str]) -> str: - titles = { +def _render_plots( + plots: Mapping[str, str], + notes: Sequence[str], + *, + order: Sequence[str] | None = None, + titles: Mapping[str, tuple[str, str]] | None = None, + show_provenance: bool = True, + show_notes: bool = True, + empty_message: str | None = None, +) -> str: + plot_titles = { "umapClusters": ( "Final UMAP by cluster", "The selected final representation, colored by final cluster.", @@ -991,17 +1696,23 @@ def _render_plots(plots: Mapping[str, str], notes: Sequence[str]) -> str: "Connectivity between final clusters in the selected graph.", ), } - order = [ - "umapClusters", - *(name for name in plots if name.startswith("nativeUmap")), - "markerHeatmap", - "markerDotplot", - "clusterComposition", - "clusterConnectivity", - *plots, - ] + if titles is not None: + plot_titles.update(titles) + plot_order = ( + list(order) + if order is not None + else [ + "umapClusters", + *(name for name in plots if name.startswith("nativeUmap")), + "markerHeatmap", + "markerDotplot", + "clusterComposition", + "clusterConnectivity", + *plots, + ] + ) figures: list[str] = [] - for name in dict.fromkeys(order): + for name in dict.fromkeys(plot_order): source = plots.get(name) if source is None: continue @@ -1010,30 +1721,37 @@ def _render_plots(plots: Mapping[str, str], notes: Sequence[str]) -> str: title = f"{assay} native UMAP" caption = f"The finalized native {assay} representation and clusters." else: - title, caption = titles.get( + title, caption = plot_titles.get( name, (_label(name), "A finalized Scarf analysis plot.") ) escaped_source = html.escape(source, quote=True) - provenance = html.escape(source + ".json", quote=True) plot_class = ' class="primary"' if name == "umapClusters" else "" + provenance_markup = "" + if show_provenance: + provenance = html.escape(source + ".json", quote=True) + provenance_markup = f' Plot provenance' figures.append( f"" f'' f"
{html.escape(title)}
" - f"{html.escape(caption)} " - f'Plot provenance
' + f"{html.escape(caption)}{provenance_markup}" ) if not figures: - plot_markup = ( - '

No plots could be rendered. The structured ' - "analysis remains available below. Install Scarf with the " - "extra dependency group to enable plotting.

" - ) + if empty_message is None: + plot_markup = ( + '

No plots could be rendered. The structured ' + "analysis remains available below. Install Scarf with the " + "extra dependency group to enable plotting.

" + ) + else: + plot_markup = ( + f'

{html.escape(empty_message)}

' + ) else: plot_markup = f'
{"".join(figures)}
' note_markup = "" - if notes: + if show_notes and notes: note_markup = ( "
Plot availability notes" '
    ' @@ -1265,15 +1983,2112 @@ def _render_timeline( ) -def _render_document(payload: Mapping[str, Any]) -> str: - reports = _mapping(payload.get("reports")) - workflow_result = _mapping(payload.get("workflowResult")) - request = _mapping(payload.get("request")) - final = _mapping(workflow_result.get("finalAnalysis")) - plan = _mapping(workflow_result.get("preprocessingPlan")) - enrichment = _latest(reports, "data_enrichment") - experimental = _latest(reports, "experimental_context") - parameter = _latest(reports, "parameter_tuning") +def _text_values(value: Any) -> list[str]: + if not _is_sequence(value): + return [] + return [str(item).strip() for item in value if _is_leaf(item) and str(item).strip()] + + +def _specific_references(values: Sequence[str]) -> list[str]: + unique = list(dict.fromkeys(values)) + return [ + value + for value in unique + if not any( + value.casefold() != other.casefold() + and value.casefold() in other.casefold() + for other in unique + ) + ] + + +def _brief_text(value: Any, *, max_length: int = 240) -> str: + if not isinstance(value, str): + return "" + text = " ".join(value.split()) + text = re.sub( + r"\b[0-9a-f]{64}\b", + "recorded result", + text, + flags=re.IGNORECASE, + ) + text = re.sub( + r"\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b", + "recorded value", + text, + flags=re.IGNORECASE, + ) + text = re.sub( + r"\b([A-Za-z][A-Za-z0-9]*)_id\b", + lambda match: _label(match.group(1)).lower(), + text, + ) + if not text: + return "" + first_sentence = re.split(r"(?<=[.!?])\s+", text, maxsplit=1)[0] + if len(first_sentence) <= max_length: + return first_sentence + shortened = first_sentence[: max_length - 3].rsplit(" ", 1)[0] + return f"{shortened or first_sentence[: max_length - 3]}..." + + +def _format_text_list(values: Sequence[str]) -> str: + items = [value for value in dict.fromkeys(values) if value] + if not items: + return "" + if len(items) == 1: + return items[0] + if len(items) == 2: + return f"{items[0]} and {items[1]}" + return f"{', '.join(items[:-1])}, and {items[-1]}" + + +def _study_overview( + payload: Mapping[str, Any], +) -> tuple[str, list[str], list[str]]: + reports = _mapping(payload.get("reports")) + request = _mapping(payload.get("request")) + enrichment = _latest(reports, "data_enrichment") + study = _mapping(enrichment.get("studyContextSummary")) + objective = "" + for candidate in ( + study.get("studyObjective"), + request.get("studyObjective"), + study.get("studyContext"), + request.get("studyContext"), + ): + objective = _brief_text(candidate) + if objective: + break + return ( + objective or "The automated analysis completed successfully.", + _specific_references(_text_values(study.get("organismReferences"))), + _specific_references(_text_values(study.get("tissueReferences"))), + ) + + +def _biological_source( + organisms: Sequence[str], + tissues: Sequence[str], +) -> str: + organism = _format_text_list(organisms) + tissue = _format_text_list(tissues) + if organism and tissue: + return f"{organism} material from {tissue}" + if organism: + return f"{organism} biological material" + if tissue: + return f"biological material from {tissue}" + return "" + + +def _assay_label(value: Any) -> str: + labels = { + "RNA": "RNA", + "ATAC": "chromatin accessibility", + "ADT": "protein abundance", + "HTO": "sample tags", + } + text = str(value or "").strip() + return labels.get(text.upper(), _label(text).lower()) if text else "" + + +def _report_assays(plan: Mapping[str, Any]) -> list[str]: + assays: list[str] = [] + for assay in _mappings(plan.get("assays")): + label = _assay_label(assay.get("assayType") or assay.get("assay")) + if label and label not in assays: + assays.append(label) + return assays + + +def _render_metrics(metrics: Sequence[tuple[str, Any]]) -> str: + markup = "".join( + '' + f'{html.escape(label)}' + f'{html.escape(_scalar(value))}' + for label, value in metrics + if _present(value) + ) + return f'
    {markup}
    ' if markup else "" + + +def _render_report_navigation(active_page: str) -> str: + links = ( + ("index", "index.html", "Report home"), + ("analysis", "analysis.html", "Analysis summary"), + ("technical", "technical.html", "Technical details"), + ) + return ''.format( + "".join( + '{}'.format( + html.escape(path, quote=True), + ' aria-current="page"' if page == active_page else "", + html.escape(label), + ) + for page, path, label in links + ) + ) + + +def _render_report_shell( + *, + title: str, + active_page: str, + body: str, +) -> str: + return f""" + + + + + {html.escape(title)} + + + +
    + Nygen Analytics + {_render_report_navigation(active_page)} +
    +
    +{body} +
    + + + +""" + + +def _selected_qc_profile( + experimental: Mapping[str, Any], + cell_qc: Mapping[str, Any], +) -> dict[str, Any]: + profiles = _mappings(experimental.get("qcProfiles")) + profile_id = cell_qc.get("profileId") + if profile_id: + for profile in profiles: + if profile.get("profileId") == profile_id: + return profile + return profiles[0] if len(profiles) == 1 else {} + + +def _feature_family_label(value: Any) -> str: + labels = { + "ribosomal": "ribosomal genes", + "mitochondrial": "mitochondrial genes", + "sex": "sex-linked genes", + "cellCycle": "cell-cycle genes", + } + text = str(value or "").strip() + return labels.get(text, _label(text).lower()) if text else "" + + +def _public_field_label(value: Any) -> str: + labels = { + "T2D": "T2D status", + "donor_id": "donor", + "library_id": "library", + "sample_id": "sample", + "sex": "sex", + "tissue": "tissue", + } + text = str(value or "").strip() + if not text: + return "" + if text in labels: + return labels[text] + return _label(text.removesuffix("_id")).lower() + + +def _tree_branch( + *, + label: str, + status: str, + state: str, + metrics: Sequence[str], + reason: str, +) -> dict[str, Any]: + return { + "label": label, + "status": status, + "state": state, + "metrics": list(metrics), + "reason": reason, + } + + +def _qc_profile_label(profile: Mapping[str, Any]) -> str: + labels = { + "retainWithFlags": "Retain cells with quality flags", + "globalMad5": "Global quality threshold", + "captureMad5": "Per-library quality threshold", + "captureMad3Sensitivity": "Stricter per-library sensitivity check", + } + registered = str(profile.get("registeredProfile") or "") + if registered in labels: + return labels[registered] + profile_id = str(profile.get("profileId") or "") + for name, label in labels.items(): + if name in profile_id: + return label + action = str(profile.get("action") or "") + return { + "skip": "Retain reviewed cells", + "globalGaussian": "Global quality threshold", + "sampleMad": "Per-sample quality threshold", + "registeredMad": "Registered quality threshold", + }.get(action, "Quality-control option") + + +def _qc_tree_stage( + experimental: Mapping[str, Any], + plan: Mapping[str, Any], + total_cells: int, +) -> dict[str, Any] | None: + decision = _mapping(experimental.get("decision")) + cell_qc = _mapping(plan.get("cellQc")) + if not cell_qc: + cell_qc = _mapping(decision.get("cellQc")) + if not cell_qc: + cell_qc = _mapping(experimental.get("cellQc")) + if not cell_qc: + return None + profiles = _mappings(experimental.get("qcProfiles")) + if not profiles: + profiles = [ + { + **cell_qc, + "activeCells": total_cells or None, + "retainedCells": total_cells or None, + } + ] + selected_id = cell_qc.get("profileId") + selected_name = cell_qc.get("registeredProfile") + branches: list[dict[str, Any]] = [] + for profile in profiles: + selected = bool( + (selected_id and profile.get("profileId") == selected_id) + or ( + not selected_id + and selected_name + and profile.get("registeredProfile") == selected_name + ) + or (len(profiles) == 1) + ) + active = profile.get("activeCells") + retained = profile.get("retainedCells") + metrics: list[str] = [] + removed: int | None = None + if isinstance(active, int) and isinstance(retained, int) and active: + retained_percent = retained / active * 100 + percent_text = "100%" if retained == active else f"{retained_percent:.2f}%" + metrics.append( + f"Retained {retained:,} of {active:,} cells ({percent_text})" + ) + removed = active - retained + if selected: + reason = ( + "Selected because it preserved the reviewed dataset without " + "unsupported filtering." + if removed == 0 + else "Selected as the best-supported balance of cell retention and " + "quality control." + ) + elif removed == 0: + reason = ( + "Not selected because it retained the same cells while adding a " + "filtering rule that was not needed." + ) + elif removed is not None: + reason = ( + f"Not selected because it removed {removed:,} additional cells " + "without stronger support." + ) + else: + reason = "Evaluated but not selected for the final cell set." + branches.append( + _tree_branch( + label=_qc_profile_label(profile), + status="Selected" if selected else "Not selected", + state="selected" if selected else "alternative", + metrics=metrics, + reason=reason, + ) + ) + branches.sort(key=lambda branch: branch["state"] != "selected") + return { + "question": "Which cells should be retained?", + "description": ( + "The workflow compared the registered quality-control choices before " + "changing the cell set." + ), + "branches": branches, + } + + +def _feature_tree_stage(plan: Mapping[str, Any]) -> dict[str, Any] | None: + assay_plans = _mappings(plan.get("assays")) + selected_assay = next( + (assay for assay in assay_plans if assay.get("graphEligible") is True), + assay_plans[0] if assay_plans else {}, + ) + if not selected_assay: + return None + feature_method = str(selected_assay.get("featureMethod") or "none") + feature_labels = { + "hvg": "Most variable genes", + "prevalentPeaks": "Frequently observed chromatin regions", + "panel": "Predefined feature panel", + "none": "No feature subset", + } + parameters = _mapping(selected_assay.get("featureParameters")) + metrics: list[str] = [] + top_n = parameters.get("topN") + min_cells = parameters.get("minCells") + if isinstance(top_n, int): + metrics.append(f"Selected {top_n:,} features") + if isinstance(min_cells, int): + metrics.append(f"Required presence in at least {min_cells:,} cells") + excluded = [ + _feature_family_label(item) + for item in _text_values(parameters.get("excludeFamilies")) + ] + protected = [ + _feature_family_label(item) + for item in _text_values(parameters.get("protectFamilies")) + ] + if excluded: + metrics.append(f"Excluded {_format_text_list(excluded)}") + if protected: + metrics.append(f"Kept {_format_text_list(protected)} eligible") + return { + "question": "Which measurements should shape the cell map?", + "description": ( + "The selected feature policy controls which biological variation can " + "influence the map." + ), + "branches": [ + _tree_branch( + label=feature_labels.get( + feature_method, + "Analysis-specific feature set", + ), + status="Selected", + state="selected", + metrics=metrics, + reason=( + "Selected to emphasize informative variation while limiting " + "known unwanted signal." + ), + ) + ], + } + + +def _batch_tree_stage( + experimental: Mapping[str, Any], + final: Mapping[str, Any], +) -> dict[str, Any] | None: + decision = _mapping(experimental.get("decision")) + batch_plan = _mapping(decision.get("batchCorrection")) + if not batch_plan: + return None + native_analyses = _mappings(final.get("nativeAnalyses")) + if final.get("graphMethod") == "native" and final.get("primaryAssay"): + selected_native = [ + item + for item in native_analyses + if item.get("assay") == final.get("primaryAssay") + ] + else: + selected_native = native_analyses + adjustment_applied = any( + _present(item.get("batchCorrection")) for item in selected_native + ) + safety = _mappings(experimental.get("batchSafety")) + unsafe = [item for item in safety if item.get("status") == "unsafe"] + coefficients = [ + _public_field_label(item.get("coefficient")) + for item in unsafe + if _public_field_label(item.get("coefficient")) + ] + coefficients = list(dict.fromkeys(coefficients)) + remaining_capacity = [ + _mapping(item.get("estimability")).get("estimableDf") for item in unsafe + ] + adjustment_metrics: list[str] = [] + if coefficients: + adjustment_metrics.append( + f"Protected comparisons at risk: {_format_text_list(coefficients)}" + ) + if remaining_capacity and all(value == 0 for value in remaining_capacity): + adjustment_metrics.append("Remaining comparison capacity: 0") + action = str(batch_plan.get("action") or "") + if adjustment_applied: + unadjusted_state = "alternative" + adjusted_state = "selected" + unadjusted_status = "Not selected" + adjusted_status = "Selected" + unadjusted_reason = ( + "The adjusted result provided stronger supported comparability." + ) + adjusted_reason = ( + "Selected because it improved technical comparability while preserving " + "the biological structure being studied." + ) + else: + unadjusted_state = "selected" + adjusted_state = "blocked" if action in {"unsafe", "skip"} else "alternative" + unadjusted_status = "Selected" + adjusted_status = "Not safe" if adjusted_state == "blocked" else "Not selected" + unadjusted_reason = ( + "Selected because adjustment was not shown to improve the data safely." + ) + adjusted_reason = ( + "Not used because technical and biological differences could not be " + "separated without risking the study comparisons." + if adjusted_state == "blocked" + else "Tested but did not provide a safer improvement over the " + "unadjusted data." + ) + return { + "question": "Should technical variation be adjusted?", + "description": ( + "Adjustment was accepted only if it improved comparability without " + "removing protected biological differences." + ), + "branches": [ + _tree_branch( + label="Use the unadjusted representation", + status=unadjusted_status, + state=unadjusted_state, + metrics=["Biological comparisons remain intact"], + reason=unadjusted_reason, + ), + _tree_branch( + label="Apply Harmony batch adjustment", + status=adjusted_status, + state=adjusted_state, + metrics=adjustment_metrics, + reason=adjusted_reason, + ), + ], + } + + +def _selected_parameter_context( + parameter: Mapping[str, Any], + final: Mapping[str, Any], +) -> tuple[dict[str, Any], list[dict[str, Any]], dict[str, Any]]: + assay_reports = _mapping(parameter.get("assayReports")) + preferred_assay = str( + parameter.get("graphAssay") + or final.get("primaryAssay") + or parameter.get("fromAssay") + or "" + ) + report = _mapping(assay_reports.get(preferred_assay)) + if not report and assay_reports: + report = _mapping(next(iter(assay_reports.values()))) + if not report: + report = dict(parameter) + evaluations = _mappings(report.get("evaluations")) + recommended = _mapping(parameter.get("recommendedByAssay")) + selected_id = ( + recommended.get(preferred_assay) + or report.get("recommendedCandidateId") + or parameter.get("recommendedCandidateId") + ) + selected = next( + ( + evaluation + for evaluation in evaluations + if evaluation.get("candidateId") == selected_id + ), + {}, + ) + return report, evaluations, selected + + +def _common_parameter( + evaluations: Sequence[Mapping[str, Any]], + key: str, +) -> Any: + values = [ + _mapping(evaluation.get("parameters")).get(key) + for evaluation in evaluations + if _present(_mapping(evaluation.get("parameters")).get(key)) + ] + return Counter(values).most_common(1)[0][0] if values else None + + +def _parameter_options( + evaluations: Sequence[Mapping[str, Any]], + key: str, + filters: Mapping[str, Any], +) -> list[dict[str, Any]]: + by_value: dict[Any, dict[str, Any]] = {} + for evaluation in evaluations: + if evaluation.get("status") != "done" or evaluation.get("eligible") is False: + continue + parameters = _mapping(evaluation.get("parameters")) + if any(parameters.get(name) != value for name, value in filters.items()): + continue + value = parameters.get(key) + if not _present(value): + continue + current = by_value.get(value) + current_metrics = _mapping(current.get("metrics")) if current else {} + metrics = _mapping(evaluation.get("metrics")) + if current is None or len(metrics) > len(current_metrics): + by_value[value] = dict(evaluation) + return [ + by_value[value] + for value in sorted( + by_value, + key=lambda item: (not isinstance(item, (int, float)), item), + ) + ] + + +def _candidate_metrics( + evaluation: Mapping[str, Any], + *, + include_stability: bool = False, +) -> list[str]: + metrics = _mapping(evaluation.get("metrics")) + values: list[str] = [] + clusters = metrics.get("nClusters") + separation = metrics.get("graphSilhouetteMedian") + smallest = metrics.get("minClusterCells") + if isinstance(clusters, int): + values.append(f"Cell groups: {clusters:,}") + if isinstance(separation, (int, float)): + values.append(f"Separation score: {float(separation):.3f}") + if isinstance(smallest, int): + values.append(f"Smallest group: {smallest:,} cells") + if include_stability: + seed = metrics.get("seedStability") + subsample = metrics.get("subsampleStability") + marker = metrics.get("markerCoherence") + support = metrics.get("crossUnitSupport") + if isinstance(seed, (int, float)): + values.append(f"Repeat-run stability: {float(seed):.3f}") + if isinstance(subsample, (int, float)): + values.append(f"Subsample stability: {float(subsample):.3f}") + if isinstance(marker, (int, float)): + values.append(f"Marker coherence: {float(marker):.3f}") + if isinstance(support, (int, float)): + values.append(f"Cross-sample support: {float(support):.3f}") + return values + + +def _parameter_tree_stage( + *, + question: str, + description: str, + options: Sequence[Mapping[str, Any]], + parameter_name: str, + selected_value: Any, + label: Any, + selected_reason: str, + alternative_reason: Any, + include_stability: bool = False, +) -> dict[str, Any] | None: + if not options: + return None + branches: list[dict[str, Any]] = [] + for evaluation in options: + value = _mapping(evaluation.get("parameters")).get(parameter_name) + selected = value == selected_value + branches.append( + _tree_branch( + label=str(label(value)), + status="Selected" if selected else "Not selected", + state="selected" if selected else "alternative", + metrics=_candidate_metrics( + evaluation, + include_stability=include_stability and selected, + ), + reason=( + selected_reason + if selected + else str(alternative_reason(value, evaluation)) + ), + ) + ) + return { + "question": question, + "description": description, + "branches": branches, + } + + +def _parameter_tree_stages( + parameter: Mapping[str, Any], + final: Mapping[str, Any], +) -> tuple[list[dict[str, Any]], dict[str, Any]]: + _report, evaluations, selected = _selected_parameter_context(parameter, final) + if not evaluations or not selected: + return [], selected + selected_parameters = _mapping(selected.get("parameters")) + selected_dimensions = selected_parameters.get("dimensions") + selected_neighbors = selected_parameters.get("neighborsK") + selected_resolution = selected_parameters.get("leidenResolution") + selected_harmony = selected_parameters.get("useHarmony") + common_neighbors = _common_parameter(evaluations, "neighborsK") + common_resolution = _common_parameter(evaluations, "leidenResolution") + + dimension_options = _parameter_options( + evaluations, + "dimensions", + { + "neighborsK": common_neighbors, + "leidenResolution": common_resolution, + "useHarmony": selected_harmony, + }, + ) + neighbor_options = _parameter_options( + evaluations, + "neighborsK", + { + "dimensions": selected_dimensions, + "leidenResolution": common_resolution, + "useHarmony": selected_harmony, + }, + ) + resolution_options = _parameter_options( + evaluations, + "leidenResolution", + { + "dimensions": selected_dimensions, + "neighborsK": selected_neighbors, + "useHarmony": selected_harmony, + }, + ) + + def dimension_alternative(value: Any, _evaluation: Mapping[str, Any]) -> str: + if isinstance(value, (int, float)) and isinstance( + selected_dimensions, (int, float) + ): + if value > selected_dimensions: + return ( + "Not selected because the smaller representation retained " + "sufficient structure with less added noise." + ) + return "Not selected because it retained too little stable structure." + return "Evaluated but not selected." + + def neighbor_alternative(value: Any, _evaluation: Mapping[str, Any]) -> str: + if isinstance(value, (int, float)) and isinstance( + selected_neighbors, (int, float) + ): + if value < selected_neighbors: + return ( + "Provided finer local detail but produced smaller, less stable " + "groups." + ) + return "Smoothed across more cells and reduced useful local detail." + return "Evaluated but not selected." + + selected_metrics = _mapping(selected.get("metrics")) + selected_separation = selected_metrics.get("graphSilhouetteMedian") + + def resolution_alternative( + _value: Any, + evaluation: Mapping[str, Any], + ) -> str: + metrics = _mapping(evaluation.get("metrics")) + groups = metrics.get("nClusters") + separation = metrics.get("graphSilhouetteMedian") + if isinstance(groups, int) and isinstance(separation, (int, float)): + return ( + f"Produced {groups:,} groups with separation " + f"{float(separation):.3f}, weaker than the selected balance." + ) + if isinstance(selected_separation, (int, float)): + return ( + f"Did not match the selected separation score of " + f"{float(selected_separation):.3f}." + ) + return "Evaluated but not selected." + + stages = [ + stage + for stage in ( + _parameter_tree_stage( + question="How many variation patterns should be retained?", + description=( + "Dimensions are compressed patterns of gene variation used to " + "build the cell map." + ), + options=dimension_options, + parameter_name="dimensions", + selected_value=selected_dimensions, + label=lambda value: f"{int(value):,} dimensions", + selected_reason=( + "Selected as the smallest representation that retained a stable " + "cell map." + ), + alternative_reason=dimension_alternative, + ), + _parameter_tree_stage( + question="How local should each cell neighborhood be?", + description=( + "Smaller neighborhoods emphasize local detail; larger ones " + "produce broader smoothing." + ), + options=neighbor_options, + parameter_name="neighborsK", + selected_value=selected_neighbors, + label=lambda value: f"{int(value):,} nearest neighbors", + selected_reason=( + "Selected to balance local detail with stable cell-group sizes." + ), + alternative_reason=neighbor_alternative, + ), + _parameter_tree_stage( + question="How finely should cells be divided into groups?", + description=( + "Resolution controls whether the final map contains broader or " + "more finely divided cell groups." + ), + options=resolution_options, + parameter_name="leidenResolution", + selected_value=selected_resolution, + label=lambda value: f"Resolution {float(value):g}", + selected_reason=( + "Selected for the strongest supported separation, stability, " + "marker coherence, and group sizes." + ), + alternative_reason=resolution_alternative, + include_stability=True, + ), + ) + if stage is not None and len(stage["branches"]) > 1 + ] + return stages, selected + + +def _analysis_tree_stages(payload: Mapping[str, Any]) -> list[dict[str, Any]]: + reports = _mapping(payload.get("reports")) + workflow_result = _mapping(payload.get("workflowResult")) + plan = _mapping(workflow_result.get("preprocessingPlan")) + final = _mapping(workflow_result.get("finalAnalysis")) + experimental = _latest(reports, "experimental_context") + parameter = _latest(reports, "parameter_tuning") + biology = _latest(reports, "biological_interpretation") + cluster_counts = _mapping(payload.get("clusterCounts")) + total_cells = sum(int(value) for value in cluster_counts.values()) + stages: list[dict[str, Any]] = [] + for stage in ( + _qc_tree_stage(experimental, plan, total_cells), + _feature_tree_stage(plan), + _batch_tree_stage(experimental, final), + ): + if stage is not None: + stages.append(stage) + parameter_stages, selected = _parameter_tree_stages(parameter, final) + stages.extend(parameter_stages) + + interpretations = _mappings(biology.get("clusterInterpretations")) + final_metrics = [f"Cells analyzed: {total_cells:,}"] if total_cells else [] + final_metrics.extend(_candidate_metrics(selected, include_stability=True)) + if not selected and cluster_counts: + final_metrics.append(f"Cell groups: {len(cluster_counts):,}") + stages.append( + { + "question": "Which result became the final analysis?", + "description": ( + "Only the selected branch was carried into visualization and marker " + "analysis." + ), + "branches": [ + _tree_branch( + label=( + f"{len(cluster_counts):,} cell groups" + if cluster_counts + else "Final selected cell map" + ), + status="Final result", + state="selected", + metrics=final_metrics, + reason=( + f"{len(interpretations):,} groups also received biological " + "interpretations." + if interpretations + else "No biological cell-type labels were inferred." + ), + ) + ], + } + ) + return stages + + +def _tree_connector_svg( + branch_count: int, + selected_index: int, + stage_index: int, + *, + continues: bool, +) -> tuple[str, str]: + width = 1200 + centers = [(index + 0.5) * width / branch_count for index in range(branch_count)] + branch_marker_id = f"tree-branch-arrow-{stage_index}" + if branch_count == 1: + branch_paths = ( + f'' + ) + else: + branch_paths = ( + f'' + f'' + + "".join( + f'' + for center in centers + ) + ) + branch_definitions = ( + f'' + '' + ) + branch_svg = ( + '" + ) + if not continues: + return branch_svg, "" + selected_x = centers[selected_index] + selection_marker_id = f"tree-selection-arrow-{stage_index}" + selection_path = ( + f"M {selected_x:g} 0 V 28 H {width / 2:g} V 78" + if selected_x != width / 2 + else f"M {width / 2:g} 0 V 78" + ) + selection_definitions = ( + f'' + "" + ) + selection_svg = ( + '' + ) + return branch_svg, selection_svg + + +def _render_decision_tree(stages: Sequence[Mapping[str, Any]]) -> str: + if not stages: + return '

    No completed analysis decisions were available.

    ' + rendered: list[str] = [] + for stage_index, stage in enumerate(stages, start=1): + branches = _mappings(stage.get("branches")) + if not branches: + continue + selected_index = next( + ( + index + for index, branch in enumerate(branches) + if branch.get("state") == "selected" + ), + 0, + ) + branch_svg, selection_svg = _tree_connector_svg( + len(branches), + selected_index, + stage_index, + continues=stage_index < len(stages), + ) + branch_markup = "".join( + '
    '.format( + html.escape(str(branch.get("state") or "alternative"), quote=True) + ) + + '{}'.format( + html.escape(str(branch.get("status") or "Evaluated")) + ) + + f"

    {html.escape(str(branch.get('label') or 'Option'))}

    " + + ( + '
      ' + + "".join( + f"
    • {html.escape(metric)}
    • " + for metric in _text_values(branch.get("metrics")) + ) + + "
    " + if _present(branch.get("metrics")) + else "" + ) + + ( + f"

    {html.escape(_brief_text(branch.get('reason')))}

    " + if _brief_text(branch.get("reason")) + else "" + ) + + "
    " + for branch in branches + ) + rendered.append( + '
    ' + '
    ' + f"Decision {stage_index}" + f"{html.escape(str(stage.get('question') or 'Analysis decision'))}" + "
    " + + ( + f'

    {html.escape(_brief_text(stage.get("description")))}

    ' + if _brief_text(stage.get("description")) + else "" + ) + + branch_svg + + '
    '.format( + len(branches) + ) + + branch_markup + + "
    " + + selection_svg + + "
    " + ) + return ( + '
    ' + + "".join(rendered) + + "
    " + ) + + +def _render_selection_evidence(payload: Mapping[str, Any]) -> str: + reports = _mapping(payload.get("reports")) + workflow_result = _mapping(payload.get("workflowResult")) + final = _mapping(workflow_result.get("finalAnalysis")) + parameter = _latest(reports, "parameter_tuning") + _report, _evaluations, selected = _selected_parameter_context(parameter, final) + metrics = _mapping(selected.get("metrics")) + cards: list[tuple[str, str, str]] = [] + candidate_count = parameter.get("totalCandidates") + if isinstance(candidate_count, int): + cards.append( + ( + "Settings compared", + f"{candidate_count:,}", + "Completed parameter combinations considered before selection.", + ) + ) + card_specs = ( + ( + "graphSilhouetteMedian", + "Group separation", + "Higher values indicate clearer separation between neighboring groups.", + ), + ( + "minClusterCells", + "Smallest group", + "Number of cells in the smallest selected group.", + ), + ( + "seedStability", + "Repeat-run stability", + "Agreement when clustering is repeated with a different random seed.", + ), + ( + "subsampleStability", + "Subsample stability", + "Agreement when the analysis is repeated on a subset of cells.", + ), + ( + "markerCoherence", + "Marker coherence", + "Consistency of marker support across the selected groups.", + ), + ( + "crossUnitSupport", + "Cross-sample support", + "Support for the selected groups across the study units.", + ), + ) + for key, label, explanation in card_specs: + value = metrics.get(key) + if isinstance(value, int): + display = f"{value:,} cells" if key == "minClusterCells" else f"{value:,}" + elif isinstance(value, float): + display = f"{value:.3f}" + else: + continue + cards.append((label, display, explanation)) + if not cards: + return "" + return '
    {}
    '.format( + "".join( + '
    ' + f'

    {html.escape(label)}

    ' + f"

    {html.escape(value)}

    " + f"

    {html.escape(explanation)}

    " + "
    " + for label, value, explanation in cards + ) + ) + + +def _analysis_percent(value: Any) -> str: + if not isinstance(value, (int, float)) or isinstance(value, bool): + return "Not available" + return f"{float(value):.1%}" + + +def _analysis_number_range(values: Sequence[Any]) -> str: + numbers = [ + float(value) + for value in values + if isinstance(value, (int, float)) and not isinstance(value, bool) + ] + if not numbers: + return "Not available" + low = min(numbers) + high = max(numbers) + if low == high: + return f"{low:,.0f}" + return f"{low:,.0f} to {high:,.0f}" + + +def _render_evidence_choices(choices: Sequence[Mapping[str, Any]]) -> str: + return '
    {}
    '.format( + "".join( + '
    '.format( + html.escape(str(choice.get("state") or "reviewed"), quote=True) + ) + + '{}'.format( + html.escape(str(choice.get("status") or "Reviewed")) + ) + + f"

    {html.escape(str(choice.get('label') or 'Evidence'))}

    " + + _render_plain_list(_text_values(choice.get("metrics"))) + + ( + f"

    {html.escape(_brief_text(choice.get('reason')))}

    " + if _brief_text(choice.get("reason")) + else "" + ) + + "
    " + for choice in choices + ) + ) + + +def _render_evidence_measurements( + measurements: Sequence[tuple[str, str, str]], +) -> str: + if not measurements: + return "" + return '
    {}
    '.format( + "".join( + '
    ' + f"
    {html.escape(label)}
    " + f"
    {html.escape(value)}" + + (f"{html.escape(detail)}" if detail else "") + + "
    " + for label, value, detail in measurements + ) + ) + + +def _render_evidence_panel( + *, + title: str, + outcome: str, + introduction: str, + body: str, + measurements: str = "", +) -> str: + measurement_markup = ( + '
    Measurements' + f'
    {measurements}
    ' + if measurements + else "" + ) + return ( + '
    ' + f'{html.escape(title)}' + f'{html.escape(outcome)}' + "" + '
    ' + f"

    {html.escape(introduction)}

    {body}{measurement_markup}
    " + ) + + +def _qc_profile_scope(profile: Mapping[str, Any]) -> str: + bounds = _mappings(_mapping(profile.get("parameters")).get("resolvedBounds")) + groups = {str(item.get("group")) for item in bounds if _present(item.get("group"))} + return "Per-library thresholds" if len(groups) > 1 else "Global thresholds" + + +def _qc_flag_summary(profile: Mapping[str, Any]) -> list[str]: + labels = ( + ("nCounts:high", "High RNA count flags"), + ("nCounts:lowQuality", "Low RNA count flags"), + ("nFeatures:high", "High detected-gene flags"), + ("nFeatures:lowQuality", "Low detected-gene flags"), + ) + flags = _mapping(profile.get("flaggedCells")) + values: list[str] = [] + for suffix, label in labels: + count = next( + ( + value + for key, value in flags.items() + if str(key).endswith(suffix) and isinstance(value, int) + ), + None, + ) + if count is not None: + values.append(f"{label}: {count:,}") + return values + + +def _qc_bound_summary(profile: Mapping[str, Any]) -> str: + bounds = _mappings(_mapping(profile.get("parameters")).get("resolvedBounds")) + parts: list[str] = [] + for role, label in (("count", "RNA counts"), ("feature", "Detected genes")): + matching = [item for item in bounds if item.get("role") == role] + if not matching: + continue + lower = _analysis_number_range([item.get("lowerRemoval") for item in matching]) + upper = _analysis_number_range([item.get("upperFlag") for item in matching]) + parts.append( + f"{label}: lower removal cutoff {lower}; high-value flag cutoff {upper}" + ) + return ". ".join(parts) + + +def _render_filtering_evidence( + experimental: Mapping[str, Any], + plan: Mapping[str, Any], +) -> str: + profiles = _mappings(experimental.get("qcProfiles")) + if not profiles: + return "" + decision = _mapping(experimental.get("decision")) + cell_qc = _mapping(plan.get("cellQc")) + if not cell_qc: + cell_qc = _mapping(decision.get("cellQc")) + if not cell_qc: + cell_qc = _mapping(experimental.get("cellQc")) + selected = _selected_qc_profile(experimental, cell_qc) + selected_id = selected.get("profileId") + selected_name = selected.get("registeredProfile") + choices: list[dict[str, Any]] = [] + for profile in profiles: + is_selected = bool( + (selected_id and profile.get("profileId") == selected_id) + or ( + not selected_id + and selected_name + and profile.get("registeredProfile") == selected_name + ) + ) + active = profile.get("activeCells") + retained = profile.get("retainedCells") + metrics: list[str] = [] + removed: int | None = None + if isinstance(active, int) and isinstance(retained, int): + removed = active - retained + metrics.extend( + ( + f"Retained: {retained:,} of {active:,}", + f"Removed: {removed:,}", + ) + ) + n_mads = _mapping(profile.get("parameters")).get("nMads") + if isinstance(n_mads, (int, float)): + metrics.append( + f"Threshold distance: {float(n_mads):g} median absolute deviations" + ) + metrics.append(_qc_profile_scope(profile)) + choices.append( + { + "label": _qc_profile_label(profile), + "status": "Selected" if is_selected else "Not selected", + "state": "selected" if is_selected else "rejected", + "metrics": metrics, + "reason": ( + "Preserved every reviewed cell and all recorded study groups." + if is_selected + else ( + f"Removed {removed:,} additional cells without stronger " + "support." + if removed + else "Produced the same retained cell set without improving " + "the selected rule." + ) + ), + } + ) + active = selected.get("activeCells") + retained = selected.get("retainedCells") + selected_label = _qc_profile_label(selected) + outcome = ( + f"{selected_label}; {retained:,} of {active:,} cells retained" + if isinstance(active, int) and isinstance(retained, int) + else f"{selected_label} selected" + ) + measurements: list[tuple[str, str, str]] = [] + for profile in profiles: + parameters = _mapping(profile.get("parameters")) + n_mads = parameters.get("nMads") + rule = ( + f"{float(n_mads):g} median absolute deviations (MAD), " + f"{_qc_profile_scope(profile).lower()}" + if isinstance(n_mads, (int, float)) + else _qc_profile_scope(profile) + ) + measurements.append( + ( + _qc_profile_label(profile), + rule, + ". ".join( + value + for value in ( + _qc_bound_summary(profile), + "; ".join(_qc_flag_summary(profile)), + ) + if value + ), + ) + ) + for column, group_counts in _mapping(selected.get("retainedCellsByColumn")).items(): + counts = list(_mapping(group_counts).values()) + numeric = [value for value in counts if isinstance(value, int)] + if numeric: + measurements.append( + ( + f"Retention across {_public_field_label(column)}", + f"{len(numeric):,} groups", + f"{min(numeric):,} to {max(numeric):,} retained cells per group.", + ) + ) + return _render_evidence_panel( + title="Cell filtering", + outcome=outcome, + introduction=( + "Four registered filtering strategies were compared. The selected " + "strategy retained the published cell set because stricter alternatives " + "did not provide stronger support." + ), + body=_render_evidence_choices(choices), + measurements=_render_evidence_measurements(measurements), + ) + + +def _covariate_pair_measurements( + characterization: Mapping[str, Any], +) -> list[tuple[str, str, str]]: + measurements: list[tuple[str, str, str]] = [] + for item in _mappings(characterization.get("confounding")): + coefficient = _public_field_label(item.get("coefficient")) + for pair in _mappings(item.get("pairs")): + technical = _public_field_label(pair.get("technical")) + association = _mapping(pair.get("association")) + status = str(association.get("status") or "") + value = association.get("value") + uncorrected = association.get("valueUncorrected") + if status == "notComputed": + display = "Not independently measurable" + elif isinstance(value, (int, float)): + display = f"Association score: {float(value):.3f}" + else: + display = "Association not available" + rows_used = association.get("rowsUsed") + details = ( + [f"{rows_used:,} study units"] if isinstance(rows_used, int) else [] + ) + if status == "notComputed" and isinstance(uncorrected, (int, float)): + details.append( + f"uncorrected association score {float(uncorrected):.3f}" + ) + elif status: + details.append("association measured") + measurements.append( + ( + f"{coefficient} and {technical}", + display, + ("; ".join(details) + ".") if details else "", + ) + ) + return measurements + + +def _render_covariate_evidence(experimental: Mapping[str, Any]) -> str: + characterization = _mapping(experimental.get("characterization")) + columns = _mappings(characterization.get("columns")) + if not columns: + return "" + domains = Counter(str(item.get("domain") or "unclassified") for item in columns) + domain_labels = { + "biological": "Biological variables", + "technical": "Technical variables", + "design": "Study-design variables", + "ignore": "Excluded metadata", + "unclassified": "Unclassified metadata", + } + role_choices = [ + { + "label": domain_labels.get(domain, _label(domain)), + "status": "Reviewed", + "state": "reviewed", + "metrics": [f"Columns: {count:,}"], + "reason": "", + } + for domain, count in sorted(domains.items()) + ] + coefficients = _mappings(characterization.get("coefficients")) + coefficient_names = [ + _public_field_label(item.get("name")) + for item in coefficients + if _public_field_label(item.get("name")) + ] + outcome = ( + f"{len(columns):,} columns reviewed; " + f"{_format_text_list(coefficient_names)} selected as study comparisons" + if coefficient_names + else f"{len(columns):,} metadata columns reviewed" + ) + measurements: list[tuple[str, str, str]] = [] + for coefficient in coefficients: + rows = coefficient.get("designRows") + observation = _public_field_label(coefficient.get("observationUnit")) + independent = _public_field_label(coefficient.get("independentUnit")) + scope = { + "betweenUnit": "between independent units", + "withinUnit": "within independent units", + "mixed": "within and between independent units", + }.get( + str(coefficient.get("scope") or ""), + _label(coefficient.get("scope")).lower(), + ) + measurements.append( + ( + _public_field_label(coefficient.get("name")), + ( + f"{int(rows):,} {observation} records" + if isinstance(rows, int) + else "Selected biological comparison" + ), + (f"Independent unit: {independent}; comparison type: {scope}."), + ) + ) + for nesting in _mappings(characterization.get("technicalNesting")): + left = _public_field_label(nesting.get("left")) + right = _public_field_label(nesting.get("right")) + measurements.append( + ( + "Technical nesting", + f"{right} is nested within {left}", + "This structure limits which technical effects can be separated.", + ) + ) + measurements.extend(_covariate_pair_measurements(characterization)) + return _render_evidence_panel( + title="Covariate analysis", + outcome=outcome, + introduction=( + "Metadata were classified by role before correction or clustering. " + "The review separated biological comparisons from technical structure " + "and metadata that should not guide the analysis." + ), + body=_render_evidence_choices(role_choices), + measurements=_render_evidence_measurements(measurements), + ) + + +def _feature_family_counts( + enrichment: Mapping[str, Any], +) -> dict[tuple[str, str], Mapping[str, Any]]: + counts: dict[tuple[str, str], Mapping[str, Any]] = {} + for inspection in _mappings(enrichment.get("inspections")): + assay = str(inspection.get("assay") or "") + for family in _mappings(inspection.get("families")): + counts[(assay, str(family.get("family") or ""))] = family + return counts + + +def _render_normalization_evidence( + enrichment: Mapping[str, Any], + plan: Mapping[str, Any], +) -> str: + assay_plans = _mappings(plan.get("assays")) + if not assay_plans: + return "" + families = _feature_family_counts(enrichment) + choices: list[dict[str, Any]] = [] + measurements: list[tuple[str, str, str]] = [] + outcome_parts: list[str] = [] + for assay_plan in assay_plans: + assay = str(assay_plan.get("assay") or "Assay") + normalization = _mapping(assay_plan.get("normalizationParameters")) + feature_parameters = _mapping(assay_plan.get("featureParameters")) + log_transform = normalization.get("logTransform") is True + renormalize = normalization.get("renormalizeSubset") is True + normalization_metrics = [ + "Log transform applied" if log_transform else "No log transform", + ( + "Selected cells renormalized" + if renormalize + else "Existing normalization retained" + ), + ] + choices.append( + { + "label": f"{_assay_label(assay)} normalization", + "status": "Selected", + "state": "selected", + "metrics": normalization_metrics, + "reason": "Used consistently for map construction.", + } + ) + excluded = [ + str(value) + for value in _text_values(feature_parameters.get("excludeFamilies")) + ] + protected = [ + str(value) + for value in _text_values(feature_parameters.get("protectFamilies")) + ] + if excluded: + choices.append( + { + "label": f"Exclude {_format_text_list([_feature_family_label(value) for value in excluded])}", + "status": "Excluded from map", + "state": "rejected", + "metrics": [ + "Still available for marker testing", + ], + "reason": ( + "Excluded only from map-building features to reduce " + "unwanted signal." + ), + } + ) + if protected: + choices.append( + { + "label": f"Protect {_format_text_list([_feature_family_label(value) for value in protected])}", + "status": "Preserved", + "state": "selected", + "metrics": ["Remained eligible for map construction"], + "reason": ( + "Protected so biological structure was not removed as " + "technical noise." + ), + } + ) + outcome_parts.append( + f"{_assay_label(assay)} log normalization" + if log_transform + else f"{_assay_label(assay)} normalization" + ) + for family_name in dict.fromkeys([*excluded, *protected]): + family = _mapping(families.get((assay, family_name))) + count = family.get("count") + skipped = family.get("skipped") + action = ( + "Excluded from map construction" + if family_name in excluded + else "Protected and retained" + ) + measurements.append( + ( + _feature_family_label(family_name).capitalize(), + ( + "Not counted" + if skipped + else ( + f"{int(count):,} identified features" + if isinstance(count, int) + else "Feature count unavailable" + ) + ), + ( + f"{action}. Inspection was skipped because " + f"{_label(skipped).lower()}." + if skipped + else f"{action}." + ), + ) + ) + min_cells = feature_parameters.get("minCells") + if isinstance(min_cells, int): + measurements.append( + ( + f"{_assay_label(assay)} detection requirement", + f"Present in at least {min_cells:,} cells", + "Applied before variable-gene ranking.", + ) + ) + return _render_evidence_panel( + title="Normalization and feature policy", + outcome="; ".join(outcome_parts), + introduction=( + "Normalization and feature-family rules were fixed before tuning. " + "Representation exclusions changed the map-building features, not the " + "genes available for marker analysis." + ), + body=_render_evidence_choices(choices), + measurements=_render_evidence_measurements(measurements), + ) + + +def _render_batch_evidence( + experimental: Mapping[str, Any], + final: Mapping[str, Any], +) -> str: + decision = _mapping(experimental.get("decision")) + batch_plan = _mapping(decision.get("batchCorrection")) + safety = _mappings(experimental.get("batchSafety")) + if not batch_plan and not safety: + return "" + native_analyses = _mappings(final.get("nativeAnalyses")) + if final.get("graphMethod") == "native" and final.get("primaryAssay"): + native_analyses = [ + item + for item in native_analyses + if item.get("assay") == final.get("primaryAssay") + ] + adjusted = any(_present(item.get("batchCorrection")) for item in native_analyses) + coefficients = list( + dict.fromkeys( + _public_field_label(item.get("coefficient")) + for item in safety + if _public_field_label(item.get("coefficient")) + ) + ) + unsafe = any(item.get("status") == "unsafe" for item in safety) + choices = [ + { + "label": "Use the unadjusted representation", + "status": "Selected" if not adjusted else "Not selected", + "state": "selected" if not adjusted else "rejected", + "metrics": ["Protected biological comparisons remain intact"], + "reason": ( + "Selected because no safe, measurable correction was available." + if not adjusted + else "Not selected after the adjusted result showed a safe benefit." + ), + }, + { + "label": "Apply Harmony correction", + "status": ( + "Selected" if adjusted else ("Not safe" if unsafe else "Not selected") + ), + "state": "rejected" if not adjusted else "selected", + "metrics": ( + [f"Comparisons at risk: {_format_text_list(coefficients)}"] + if coefficients + else [] + ), + "reason": ( + "Not run because library effects could not be separated from the " + "protected study comparisons." + if unsafe + else "Evaluated against the native representation." + ), + }, + ] + measurements: list[tuple[str, str, str]] = [] + for item in safety: + estimability = _mapping(item.get("estimability")) + coefficient = _public_field_label(item.get("coefficient")) + estimable = estimability.get("coefficientEstimable") is True + rows = estimability.get("rowsUsed") + rank = estimability.get("rankTechnical") + residual = estimability.get("residualDf") + remaining = estimability.get("estimableDf") + measurements.append( + ( + f"Harmony safety for {coefficient}", + "Estimable" if estimable else "Not estimable", + "; ".join( + value + for value in ( + f"Study units: {int(rows):,}" if isinstance(rows, int) else "", + f"Technical rank: {int(rank):,}" + if isinstance(rank, int) + else "", + f"Residual degrees of freedom: {int(residual):,}" + if isinstance(residual, int) + else "", + f"Remaining comparison capacity: {int(remaining):,}" + if isinstance(remaining, int) + else "", + ) + if value + ), + ) + ) + measurements.extend( + _covariate_pair_measurements(_mapping(experimental.get("characterization"))) + ) + outcome = ( + "Harmony not applied; protected comparisons were not independently estimable" + if unsafe + else ( + "Harmony correction was selected" + if adjusted + else "No batch correction was selected" + ) + ) + return _render_evidence_panel( + title="Harmony and batch correction", + outcome=outcome, + introduction=( + "Correction was allowed only when technical variation could be reduced " + "without removing tissue, T2D, donor, or sex structure." + ), + body=_render_evidence_choices(choices), + measurements=_render_evidence_measurements(measurements), + ) + + +def _hvg_ranking_label(value: Any) -> str: + return { + "global": "Global variability ranking", + "batchAware": "Group-aware variability ranking", + }.get(str(value or ""), "Variable-gene ranking") + + +def _render_hvg_evidence(evidence: Mapping[str, Any]) -> str: + rankings = _mappings(evidence.get("rankings")) + candidates = _mappings(evidence.get("candidateMetrics")) + if not rankings and not candidates: + return "" + selected_mode = evidence.get("selectedRankingMode") + selected_count = evidence.get("selectedFeatureCount") + ranking_choices: list[dict[str, Any]] = [] + for ranking in rankings: + selected = ranking.get("rankingMode") == selected_mode + ranking_choices.append( + { + "label": _hvg_ranking_label(ranking.get("rankingMode")), + "status": "Selected" if selected else "Not selected", + "state": "selected" if selected else "rejected", + "metrics": [ + "Mean coverage across libraries: " + f"{_analysis_percent(ranking.get('meanTechnicalGroupCoverage'))}", + "Genes recurring in at least two libraries: " + f"{_analysis_percent(ranking.get('recurrentInTwoGroupsFraction'))}", + ], + "reason": ( + "Selected because variable genes were more consistent across " + "the registered libraries." + if selected + else "Not selected because fewer genes recurred across libraries." + ), + } + ) + candidate_choices: list[dict[str, Any]] = [] + for candidate in candidates: + count = candidate.get("featureCount") + if not isinstance(count, int): + continue + selected = count == selected_count + candidate_choices.append( + { + "label": f"{count:,} variable genes", + "status": "Selected" if selected else "Not selected", + "state": "selected" if selected else "rejected", + "metrics": [ + "Corrected variance captured: " + f"{_analysis_percent(candidate.get('varianceFraction'))}", + "Genes recurring across most libraries: " + f"{_analysis_percent(candidate.get('recurrentFraction'))}", + ], + "reason": ( + "Selected as the best balance of captured variation and " + "cross-library reproducibility." + if selected + else ( + "Captured less variation than the selected set." + if count < int(selected_count or 0) + else "Added genes with substantially lower reproducibility." + ) + ), + } + ) + measurements = [ + ( + "Eligible genes", + f"{int(evidence['eligibleFeatureCount']):,}", + "Genes available after detection and feature-family rules.", + ) + if isinstance(evidence.get("eligibleFeatureCount"), int) + else None, + ( + "Libraries represented", + f"{int(evidence['validTechnicalGroups']):,}", + "Registered technical groups used to assess recurrence.", + ) + if isinstance(evidence.get("validTechnicalGroups"), int) + else None, + ( + "Minimum detection", + f"{int(evidence['minimumDetectedCells']):,} cells", + "Required before a gene could enter the ranking.", + ) + if isinstance(evidence.get("minimumDetectedCells"), int) + else None, + ( + "Excluded libraries", + f"{int(evidence['excludedTechnicalGroupCount']):,}", + "Libraries omitted from the group-aware ranking.", + ) + if isinstance(evidence.get("excludedTechnicalGroupCount"), int) + else None, + ] + body = ( + '

    Ranking method

    ' + f"{_render_evidence_choices(ranking_choices)}
    " + '

    Number of variable genes

    ' + f"{_render_evidence_choices(candidate_choices)}
    " + ) + outcome = ( + f"{_hvg_ranking_label(selected_mode)}; {int(selected_count):,} genes selected" + if isinstance(selected_count, int) + else f"{_hvg_ranking_label(selected_mode)} selected" + ) + return _render_evidence_panel( + title="Highly variable genes (HVGs)", + outcome=outcome, + introduction=( + "The workflow first compared how genes were ranked, then compared three " + "registered set sizes. Selection favored signal that recurred across " + "libraries instead of variation driven by only a few libraries." + ), + body=body, + measurements=_render_evidence_measurements( + [item for item in measurements if item is not None] + ), + ) + + +def _render_analysis_evidence(payload: Mapping[str, Any]) -> str: + reports = _mapping(payload.get("reports")) + workflow_result = _mapping(payload.get("workflowResult")) + plan = _mapping(workflow_result.get("preprocessingPlan")) + final = _mapping(workflow_result.get("finalAnalysis")) + enrichment = _latest(reports, "data_enrichment") + experimental = _latest(reports, "experimental_context") + panels = [ + _render_filtering_evidence(experimental, plan), + _render_covariate_evidence(experimental), + _render_normalization_evidence(enrichment, plan), + _render_batch_evidence(experimental, final), + _render_hvg_evidence(_mapping(payload.get("hvgEvidence"))), + ] + panels = [panel for panel in panels if panel] + if not panels: + return "" + return ( + '
    ' + "

    Evidence behind the decisions

    " + "

    Open a section to compare the selected and rejected choices. " + "Each section keeps denser thresholds and scores under Measurements.

    " + f'
    {"".join(panels)}
    ' + ) + + +def _narrative_items(value: Any, keys: Sequence[str]) -> list[str]: + if not _is_sequence(value): + return [] + items: list[str] = [] + for item in value: + if isinstance(item, Mapping): + text = next( + ( + _brief_text(item.get(key)) + for key in keys + if _brief_text(item.get(key)) + ), + "", + ) + else: + text = _brief_text(item) + if text: + items.append(text) + return items + + +def _render_plain_list(items: Sequence[str]) -> str: + if not items: + return "" + return '
      {}
    '.format( + "".join(f"
  • {html.escape(item)}
  • " for item in items) + ) + + +def _render_analysis_biology(biology: Mapping[str, Any]) -> str: + interpretations = _mappings(biology.get("clusterInterpretations")) + observations = _narrative_items( + biology.get("treatmentObservations"), + ("observation",), + ) + follow_ups = _narrative_items( + biology.get("followUps"), + ("question", "rationale"), + ) + if not interpretations and not observations and not follow_ups: + return "" + + cards = "".join( + '
    ' + f'

    Cell group {html.escape(str(item.get("clusterId") or "unresolved"))}

    ' + f"

    {html.escape(str(item.get('proposedIdentity') or 'Unresolved'))}

    " + + ( + f"

    {html.escape(_brief_text(item.get('rationale')))}

    " + if _brief_text(item.get("rationale")) + else "" + ) + + ( + 'Tentative interpretation' + if item.get("identityIsHypothesis") is True + else "" + ) + + "
    " + for item in interpretations + ) + interpretation_markup = ( + f'
    {cards}
    ' if cards else "" + ) + observation_markup = ( + '

    Observed group differences

    ' + f"{_render_plain_list(observations)}
    " + if observations + else "" + ) + follow_up_markup = ( + '

    Recommended follow-up

    ' + f"{_render_plain_list(follow_ups)}
    " + if follow_ups + else "" + ) + return f""" +
    +

    Biological interpretation

    + {interpretation_markup} + {observation_markup} + {follow_up_markup} +
    +""" + + +def _analysis_limitations(payload: Mapping[str, Any]) -> list[str]: + reports = _mapping(payload.get("reports")) + workflow_result = _mapping(payload.get("workflowResult")) + final = _mapping(workflow_result.get("finalAnalysis")) + parameter = _latest(reports, "parameter_tuning") + biology = _latest(reports, "biological_interpretation") + interpretations = _mappings(biology.get("clusterInterpretations")) + limitations: list[str] = [] + if not interpretations: + limitations.append( + "No biological cell-type interpretation was generated, so the cell " + "groups should not be treated as named cell types." + ) + elif any(item.get("identityIsHypothesis") is True for item in interpretations): + limitations.append( + "Cell-group identities are hypotheses based on observed marker patterns " + "and need independent validation." + ) + if _mappings(biology.get("treatmentObservations")): + limitations.append( + "Reported group differences are descriptive and do not establish cause " + "and effect." + ) + if parameter.get("totalCandidates"): + limitations.append( + "The final result was selected only from the analysis settings that " + "were explicitly evaluated." + ) + if _present(final.get("limitations")) or _present(parameter.get("limitations")): + limitations.append( + "Additional technical limitations are recorded in the technical report." + ) + if _present(payload.get("plotNotes")): + limitations.append( + "Some optional visualizations were unavailable; the technical report " + "records the reason." + ) + return limitations + + +def _render_index_document(payload: Mapping[str, Any]) -> str: + workflow_result = _mapping(payload.get("workflowResult")) + plan = _mapping(workflow_result.get("preprocessingPlan")) + cluster_counts = _mapping(payload.get("clusterCounts")) + total_cells = sum(int(value) for value in cluster_counts.values()) + objective, _organisms, _tissues = _study_overview(payload) + assays = _report_assays(plan) + metrics = _render_metrics( + ( + ("Cells analyzed", total_cells or None), + ("Cell groups", len(cluster_counts) or None), + ("Data analyzed", _format_text_list(assays) or None), + ) + ) + body = f"""

    Completed analysis

    +

    Choose the level of detail.

    +

    {html.escape(objective)}

    + {metrics} + +
    + +
    + + +""" + return _render_report_shell( + title="Scarf analysis report", + active_page="index", + body=body, + ) + + +def _render_analysis_document(payload: Mapping[str, Any]) -> str: + reports = _mapping(payload.get("reports")) + workflow_result = _mapping(payload.get("workflowResult")) + plan = _mapping(workflow_result.get("preprocessingPlan")) + biology = _latest(reports, "biological_interpretation") + cluster_counts = { + str(key): int(value) + for key, value in _mapping(payload.get("clusterCounts")).items() + } + plots = { + str(key): str(value) + for key, value in _mapping(payload.get("plotFiles")).items() + } + objective, organisms, tissues = _study_overview(payload) + assays = _report_assays(plan) + total_cells = sum(cluster_counts.values()) + metrics = _render_metrics( + ( + ("Cells analyzed", total_cells or None), + ("Cell groups", len(cluster_counts) or None), + ("Data analyzed", _format_text_list(assays) or None), + ) + ) + source = _biological_source(organisms, tissues) or "Not specified" + source = source[:1].upper() + source[1:] + tree_stages = _analysis_tree_stages(payload) + selection_evidence = _render_selection_evidence(payload) + decision_evidence = _render_analysis_evidence(payload) + analysis_plots = _render_plots( + plots, + (), + order=("umapClusters", "clusterComposition", "markerHeatmap"), + titles={ + "umapClusters": ( + "Final cell map", + "Each point is a cell, colored by its selected cell group.", + ), + "clusterComposition": ( + "Relative group sizes", + "The relative size of each selected cell group.", + ), + "markerHeatmap": ( + "Marker patterns", + "Features that help distinguish the selected cell groups.", + ), + }, + show_provenance=False, + show_notes=False, + empty_message=( + "Visual results are unavailable for this report. Technical details " + "record the reason." + ), + ) + limitations = _analysis_limitations(payload) + biology_markup = _render_analysis_biology(biology) + body = f"""

    Analysis summary

    +

    The analysis, at a glance.

    +

    {html.escape(objective)}

    + {metrics} + +
    +

    What was analyzed

    +
    +
    +

    Biological source

    +

    {html.escape(source)}

    +
    +
    +

    Final result

    +

    {total_cells:,} cells organized into {len(cluster_counts):,} groups

    +
    +
    +
    + +
    +

    Analysis decision tree

    +

    Each decision shows the selected branch, the alternatives considered, their measured values, and why the selected path continued.

    + {_render_decision_tree(tree_stages)} +
    + + {decision_evidence} + +
    +

    Why the final result was selected

    +

    These are the main measurements supporting the final cell map. Values closer to 1 indicate stronger agreement for the stability and coherence measures.

    + {selection_evidence or '

    No final selection measurements were available.

    '} +
    + +
    +

    Visual results

    + {analysis_plots} +
    + + {biology_markup} + +
    +

    Limitations

    + {_render_plain_list(limitations) if limitations else "

    No additional user-facing limitations were recorded.

    "} +
    + + +""" + return _render_report_shell( + title="Scarf analysis summary", + active_page="analysis", + body=body, + ) + + +def _render_technical_document(payload: Mapping[str, Any]) -> str: + reports = _mapping(payload.get("reports")) + workflow_result = _mapping(payload.get("workflowResult")) + request = _mapping(payload.get("request")) + final = _mapping(workflow_result.get("finalAnalysis")) + plan = _mapping(workflow_result.get("preprocessingPlan")) + enrichment = _latest(reports, "data_enrichment") + experimental = _latest(reports, "experimental_context") + parameter = _latest(reports, "parameter_tuning") biology = _latest(reports, "biological_interpretation") cluster_counts = { str(key): int(value) @@ -1300,13 +4115,7 @@ def _render_document(payload: Mapping[str, Any]) -> str: ("Selected graph", final.get("graphMethod")), ("Marker assay", final.get("markerAssay")), ] - metric_markup = "".join( - '' - f'{html.escape(label)}' - f'{html.escape(_scalar(value))}' - for label, value in metrics - if _present(value) - ) + metric_markup = _render_metrics(metrics) interpretation = { "status": biology.get("status"), "clusterInterpretations": biology.get("clusterInterpretations"), @@ -1374,32 +4183,33 @@ def _render_document(payload: Mapping[str, Any]) -> str: raw_json = json.dumps( payload, indent=2, sort_keys=True, ensure_ascii=False, default=str ) + biology_nav = ( + 'Biology' if biology else "" + ) + biology_markup = ( + f""" +
    +

    Biological interpretation

    + {_value(interpretation)} +

    Treatment observations

    {_value(biology.get("treatmentObservations"))}
    +

    Follow-up recommendations

    {_value(biology.get("followUps"))}
    +
    +""" + if biology + else "" + ) title = f"Scarf agent report {workflow_id}" - return f""" - - - - - {html.escape(title)} - - - -
    - Nygen Analytics - Scarf agent workflow -
    -
    -

    Completed analysis

    + body = f"""

    Technical report

    Evidence from an automated analysis.

    -

    The workflow completed and its selected artifacts, decisions, and biological interpretation are summarized here.

    +

    The workflow completed and its selected artifacts and decisions are summarized here.

    Completed {html.escape(_label(workflow_result.get("currentStage") or "completed"))}
    -
    {metric_markup}
    + {metric_markup}
    - - - """ + return _render_report_shell( + title=title, + active_page="technical", + body=body, + ) + + +def _write_report_page(report_dir: Path, filename: str, document: str) -> Path: + destination = report_dir / filename + temporary = report_dir / f".{filename}.{uuid.uuid4().hex}.tmp" + try: + temporary.write_text(document, encoding="utf-8") + os.replace(temporary, destination) + finally: + temporary.unlink(missing_ok=True) + return destination def generate_agent_report( @@ -1458,9 +4276,10 @@ def generate_agent_report( ) -> Path: """Generate a local HTML report for one completed automated workflow. - The returned path points to ``index.html`` beneath the workflow's report - directory. Existing derived report files may be replaced; immutable agent - and orchestration records are only read. + The report directory contains a landing page, an analysis summary, and + technical details. The returned path points to the landing ``index.html``. + Existing derived report files may be replaced; immutable agent and + orchestration records are only read. """ root = _local_root(target) resolved_workspace = ( @@ -1499,6 +4318,16 @@ def generate_agent_report( result, plot_dir, ) + preprocessing_plan = ( + result.preprocessingPlan.model_dump(mode="json") + if result.preprocessingPlan is not None + else {} + ) + hvg_evidence = _collect_hvg_evidence( + store, + stage_attempts, + preprocessing_plan, + ) payload: dict[str, Any] = { "status": result.status, "currentStage": result.currentStage, @@ -1514,15 +4343,18 @@ def generate_agent_report( "topMarkers": top_markers, "plotFiles": plot_files, "plotNotes": plot_notes, + "hvgEvidence": hvg_evidence, } - document = _render_document(payload) + documents = ( + ("analysis.html", _render_analysis_document(payload)), + ("technical.html", _render_technical_document(payload)), + ("index.html", _render_index_document(payload)), + ) destination = report_dir / "index.html" - temporary = report_dir / f".index.{uuid.uuid4().hex}.tmp" - try: - temporary.write_text(document, encoding="utf-8") - os.replace(temporary, destination) - finally: - temporary.unlink(missing_ok=True) + for filename, document in documents: + written = _write_report_page(report_dir, filename, document) + if filename == "index.html": + destination = written logger.info( f"Generated HTML report for agent workflow {workflow_run_id}: {destination}" ) diff --git a/scarf/agent/rna_decisions.py b/scarf/agent/rna_decisions.py new file mode 100644 index 00000000..c1820ef6 --- /dev/null +++ b/scarf/agent/rna_decisions.py @@ -0,0 +1,1490 @@ +"""Deterministic RNA decision definitions and executor-owned option payloads.""" + +from collections.abc import Mapping, Sequence +from typing import Annotated, Literal + +from pydantic import ConfigDict, Field, field_validator, model_validator + +from .decision_kernel import ( + DecisionOption, + DecisionRecord, + DecisionSpec, + DecisionStatus, + DeterministicDecisionAuditor, + EvidenceBundle, + VerificationRecord, +) +from .types import AgentDataModel + +type RnaDecisionCheckpoint = Literal[ + "qcGrouping", + "cellQuality", + "featurePolicy", + "hvgRanking", + "hvgCount", + "pcaPrefix", + "correctionLicense", + "correctionNeed", + "correctionOutcome", + "graphK", + "clusterPartition", +] +type RnaWorkflowNode = Literal[ + "qcGrouping", + "cellQuality", + "featurePolicy", + "hvgRanking", + "hvgCount", + "pcaPrefix", + "correctionLicense", + "correctionNeed", + "correctionOutcome", + "graphK", + "clusterPartition", + "finalize", +] +type DecisionTerminalStatus = Literal["needsInput", "abstained"] +type QcGroupingMode = Literal["global", "physicalCapture", "pooledReference"] +type HvgRankingMode = Literal["global", "batchAware"] +type CellQualityProfile = Literal[ + "retainWithFlags", + "globalMad5", + "captureMad5", + "captureMad3Sensitivity", + "pooledReferenceMad5", +] +type ConditionalGeneFamily = Literal[ + "mitochondrial", + "ribosomal", + "histone", + "hemoglobin", + "immuneReceptor", + "cellCycle", + "stress", + "dissociation", + "sexLinked", +] +type CorrectionLicense = Literal[ + "safe", + "unsafeConfounded", + "indeterminate", + "notApplicable", +] +type CorrectionNeed = Literal["needed", "notNeeded", "indeterminate"] + +_CHECKPOINT_ORDER: tuple[RnaWorkflowNode, ...] = ( + "qcGrouping", + "cellQuality", + "featurePolicy", + "hvgRanking", + "hvgCount", + "pcaPrefix", + "correctionLicense", + "correctionNeed", + "correctionOutcome", + "graphK", + "clusterPartition", + "finalize", +) + + +class RnaDecisionGateError(ValueError): + """Raised when deterministic evidence forbids constructing an option set.""" + + +class RnaDecisionCompilationError(ValueError): + """Raised when an unverified decision cannot compile to an executor payload.""" + + +class RnaRegistryModel(AgentDataModel): + """Base for immutable, closed RNA registry contracts.""" + + model_config = ConfigDict(extra="forbid", frozen=True, validate_default=True) + + +class CellQualityExecutorPayload(RnaRegistryModel): + """Exact cell-quality thresholds owned by the deterministic executor.""" + + operation: Literal["cellQualityProfile"] = "cellQualityProfile" + profile: CellQualityProfile + lowerCountMad: float | None = Field(default=None, ge=0, strict=True) + lowerFeatureMad: float | None = Field(default=None, ge=0, strict=True) + upperMitoMad: float | None = Field(default=None, ge=0, strict=True) + groupByCapture: bool = Field(strict=True) + pooledReference: bool = Field(strict=True) + sensitivityOnly: bool = Field(strict=True) + flagHighCounts: Literal[True] = True + flagHighFeatures: Literal[True] = True + + @model_validator(mode="after") + def validate_profile(self) -> "CellQualityExecutorPayload": + thresholds = (self.lowerCountMad, self.lowerFeatureMad, self.upperMitoMad) + if self.profile == "retainWithFlags": + if any(value is not None for value in thresholds): + raise ValueError("retainWithFlags cannot define removal thresholds") + if self.groupByCapture or self.pooledReference or self.sensitivityOnly: + raise ValueError("retainWithFlags cannot enable filtering modes") + return self + if any(value is None for value in thresholds): + raise ValueError("Filtering profiles require all three MAD thresholds") + if self.profile.startswith("captureMad") != self.groupByCapture: + raise ValueError("Capture profiles and groupByCapture must agree") + if (self.profile == "pooledReferenceMad5") != self.pooledReference: + raise ValueError("pooledReferenceMad5 and pooledReference must agree") + if (self.profile == "captureMad3Sensitivity") != self.sensitivityOnly: + raise ValueError("captureMad3Sensitivity and sensitivityOnly must agree") + return self + + +class QcGroupingExecutorPayload(RnaRegistryModel): + """Exact population used to estimate registered cell-quality thresholds.""" + + operation: Literal["qcGrouping"] = "qcGrouping" + groupingMode: QcGroupingMode + + +class HvgRankingExecutorPayload(RnaRegistryModel): + """Exact variability-ranking route used to construct HVG candidates.""" + + operation: Literal["hvgRanking"] = "hvgRanking" + rankingMode: HvgRankingMode + + +class HvgExecutorPayload(RnaRegistryModel): + """Exact HVG count and ranking mode owned by the executor.""" + + operation: Literal["hvgSelection"] = "hvgSelection" + topN: int = Field(ge=1, strict=True) + rankingMode: HvgRankingMode + + +class FeaturePolicyExecutorPayload(RnaRegistryModel): + """Exact conditional family policy for representation features.""" + + operation: Literal["featurePolicy"] = "featurePolicy" + policy: Literal["keepAll", "excludeEligibleBundle"] + excludedFamilies: list[ConditionalGeneFamily] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_policy(self) -> "FeaturePolicyExecutorPayload": + if len(self.excludedFamilies) != len(set(self.excludedFamilies)): + raise ValueError("excludedFamilies must not contain duplicates") + if self.policy == "keepAll" and self.excludedFamilies: + raise ValueError("keepAll cannot exclude gene families") + if self.policy == "excludeEligibleBundle" and not self.excludedFamilies: + raise ValueError("excludeEligibleBundle requires gene families") + return self + + +class PcaPrefixExecutorPayload(RnaRegistryModel): + """Exact PCA prefix owned by the executor.""" + + operation: Literal["pcaPrefix"] = "pcaPrefix" + dimensions: int = Field(ge=2, le=50, strict=True) + + +class CorrectionLicensePayload(RnaRegistryModel): + """Deterministic correction authorization derived from design evidence.""" + + operation: Literal["correctionLicense"] = "correctionLicense" + license: CorrectionLicense + + +class CorrectionNeedPayload(RnaRegistryModel): + """Observed need for correction in an uncorrected representation.""" + + operation: Literal["correctionNeed"] = "correctionNeed" + need: CorrectionNeed + + +class CorrectionOutcomeExecutorPayload(RnaRegistryModel): + """Exact native or Harmony representation route.""" + + operation: Literal["correctionOutcome"] = "correctionOutcome" + outcome: Literal["retainNative", "acceptHarmony"] + useHarmony: bool = Field(strict=True) + + @model_validator(mode="after") + def validate_outcome(self) -> "CorrectionOutcomeExecutorPayload": + if (self.outcome == "acceptHarmony") != self.useHarmony: + raise ValueError("acceptHarmony and useHarmony must agree") + return self + + +class GraphExecutorPayload(RnaRegistryModel): + """Exact graph neighborhood size owned by the executor.""" + + operation: Literal["graphK"] = "graphK" + neighborsK: int = Field(ge=2, le=41, strict=True) + + +class ClusterExecutorPayload(RnaRegistryModel): + """Exact Leiden resolution owned by the executor.""" + + operation: Literal["clusterResolution"] = "clusterResolution" + leidenResolution: float = Field(gt=0, le=1.5, strict=True) + + +class NoExecutionPayload(RnaRegistryModel): + """Typed terminal or pause outcome with no analytical operation.""" + + operation: Literal["noExecution"] = "noExecution" + reasonCode: Literal[ + "needsInput", + "scientificAbstention", + ] + + +type RnaOptionPayload = Annotated[ + QcGroupingExecutorPayload + | CellQualityExecutorPayload + | HvgRankingExecutorPayload + | HvgExecutorPayload + | FeaturePolicyExecutorPayload + | PcaPrefixExecutorPayload + | CorrectionLicensePayload + | CorrectionNeedPayload + | CorrectionOutcomeExecutorPayload + | GraphExecutorPayload + | ClusterExecutorPayload + | NoExecutionPayload, + Field(discriminator="operation"), +] + + +class RnaExecutorOption(RnaRegistryModel): + """Executor-only payload keyed by the semantic option shown to an agent.""" + + checkpoint: RnaDecisionCheckpoint + optionId: str + payload: RnaOptionPayload + + @field_validator("optionId") + @classmethod + def validate_option_id(cls, value: str) -> str: + if not value or value != value.strip(): + raise ValueError( + "optionId must be non-empty without surrounding whitespace" + ) + return value + + +class RnaDecisionDefinition(RnaRegistryModel): + """Agent-visible decision spec paired with executor-only payloads.""" + + checkpoint: RnaDecisionCheckpoint + spec: DecisionSpec + executorOptions: list[RnaExecutorOption] = Field(min_length=1) + + @model_validator(mode="after") + def validate_exact_registry(self) -> "RnaDecisionDefinition": + if self.spec.checkpoint != self.checkpoint: + raise ValueError( + "DecisionSpec checkpoint must match the registry checkpoint" + ) + visible_ids = [option.optionId for option in self.spec.options] + executor_ids = [option.optionId for option in self.executorOptions] + if len(executor_ids) != len(set(executor_ids)): + raise ValueError("executorOptions must not contain duplicate option IDs") + if executor_ids != visible_ids: + raise ValueError( + "executorOptions must exactly match the ordered visible option IDs" + ) + if any(option.checkpoint != self.checkpoint for option in self.executorOptions): + raise ValueError("Every executor option must match the registry checkpoint") + return self + + def executor_option(self, option_id: str) -> RnaExecutorOption: + """Return one exact registered executor option.""" + for option in self.executorOptions: + if option.optionId == option_id: + return option + raise KeyError(f"Unknown option ID for {self.spec.decisionId}: {option_id}") + + +class RnaDecisionTransition(RnaRegistryModel): + """One option-status transition in the fixed acyclic RNA graph.""" + + fromCheckpoint: RnaDecisionCheckpoint + onStatus: DecisionStatus + toCheckpoint: RnaWorkflowNode | None = None + terminalStatus: DecisionTerminalStatus | None = None + + @model_validator(mode="after") + def validate_destination(self) -> "RnaDecisionTransition": + if (self.toCheckpoint is None) == (self.terminalStatus is None): + raise ValueError( + "A transition requires exactly one checkpoint or terminal destination" + ) + return self + + +class RnaDecisionTransitionGraph(RnaRegistryModel): + """Ordered graph that rejects cycles and ambiguous transitions.""" + + orderedNodes: tuple[RnaWorkflowNode, ...] = _CHECKPOINT_ORDER + transitions: list[RnaDecisionTransition] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_graph(self) -> "RnaDecisionTransitionGraph": + if self.orderedNodes != _CHECKPOINT_ORDER: + raise ValueError("orderedNodes must use the v1 RNA checkpoint order") + positions = {node: position for position, node in enumerate(self.orderedNodes)} + triggers: set[tuple[RnaDecisionCheckpoint, DecisionStatus]] = set() + for transition in self.transitions: + trigger = (transition.fromCheckpoint, transition.onStatus) + if trigger in triggers: + raise ValueError( + "Transitions must have unique checkpoint/status triggers" + ) + triggers.add(trigger) + if transition.toCheckpoint is not None and ( + positions[transition.toCheckpoint] + <= positions[transition.fromCheckpoint] + ): + raise ValueError("RNA decision transitions must point strictly forward") + return self + + def resolve( + self, checkpoint: RnaDecisionCheckpoint, status: DecisionStatus + ) -> tuple[RnaWorkflowNode | None, DecisionTerminalStatus | None]: + """Resolve one exact checkpoint/status transition.""" + for transition in self.transitions: + if ( + transition.fromCheckpoint == checkpoint + and transition.onStatus == status + ): + return transition.toCheckpoint, transition.terminalStatus + raise KeyError(f"No RNA transition for {checkpoint}/{status}") + + +def _transition( + checkpoint: RnaDecisionCheckpoint, + status: DecisionStatus, + *, + to: RnaWorkflowNode | None = None, + terminal: DecisionTerminalStatus | None = None, +) -> RnaDecisionTransition: + return RnaDecisionTransition( + fromCheckpoint=checkpoint, + onStatus=status, + toCheckpoint=to, + terminalStatus=terminal, + ) + + +RNA_DECISION_TRANSITION_GRAPH = RnaDecisionTransitionGraph( + transitions=[ + _transition("qcGrouping", "apply", to="cellQuality"), + _transition("qcGrouping", "defer", terminal="needsInput"), + _transition("cellQuality", "apply", to="featurePolicy"), + _transition("cellQuality", "skip", to="featurePolicy"), + _transition("cellQuality", "defer", terminal="needsInput"), + _transition("featurePolicy", "apply", to="hvgRanking"), + _transition("featurePolicy", "skip", to="hvgRanking"), + _transition("featurePolicy", "defer", terminal="needsInput"), + _transition("hvgRanking", "apply", to="hvgCount"), + _transition("hvgRanking", "defer", terminal="needsInput"), + _transition("hvgCount", "apply", to="pcaPrefix"), + _transition("hvgCount", "defer", terminal="needsInput"), + _transition("pcaPrefix", "apply", to="correctionLicense"), + _transition("pcaPrefix", "defer", terminal="needsInput"), + _transition("correctionLicense", "apply", to="correctionNeed"), + _transition("correctionLicense", "skip", to="correctionOutcome"), + _transition("correctionLicense", "defer", terminal="needsInput"), + _transition("correctionNeed", "apply", to="correctionOutcome"), + _transition("correctionNeed", "skip", to="correctionOutcome"), + _transition("correctionNeed", "defer", terminal="needsInput"), + _transition("correctionOutcome", "apply", to="graphK"), + _transition("correctionOutcome", "skip", to="graphK"), + _transition("correctionOutcome", "defer", terminal="needsInput"), + _transition("graphK", "apply", to="clusterPartition"), + _transition("graphK", "defer", terminal="needsInput"), + _transition("clusterPartition", "apply", to="finalize"), + _transition("clusterPartition", "defer", terminal="needsInput"), + _transition("clusterPartition", "abstain", terminal="abstained"), + ] +) + + +class RnaDecisionRegistry(RnaRegistryModel): + """Ordered definitions for one concrete RNA decision run.""" + + definitions: list[RnaDecisionDefinition] = Field(default_factory=list) + transitionGraph: RnaDecisionTransitionGraph = RNA_DECISION_TRANSITION_GRAPH + + @model_validator(mode="after") + def validate_definitions(self) -> "RnaDecisionRegistry": + decision_ids = [definition.spec.decisionId for definition in self.definitions] + checkpoints = [definition.checkpoint for definition in self.definitions] + if len(decision_ids) != len(set(decision_ids)): + raise ValueError("Registry decision IDs must be unique") + if len(checkpoints) != len(set(checkpoints)): + raise ValueError("Registry checkpoints must be unique") + positions = { + node: position + for position, node in enumerate(self.transitionGraph.orderedNodes) + } + if checkpoints != sorted(checkpoints, key=positions.__getitem__): + raise ValueError("Registry definitions must follow RNA checkpoint order") + for definition in self.definitions: + for option in definition.spec.options: + try: + self.transitionGraph.resolve(definition.checkpoint, option.status) + except KeyError as exc: + raise ValueError( + f"No transition for {definition.checkpoint}/{option.status}" + ) from exc + return self + + def definition(self, checkpoint: RnaDecisionCheckpoint) -> RnaDecisionDefinition: + """Return the exact definition registered for a checkpoint.""" + for definition in self.definitions: + if definition.checkpoint == checkpoint: + return definition + raise KeyError(f"No RNA decision definition for {checkpoint}") + + +class CompiledRnaDecision(RnaRegistryModel): + """Verified executor handoff kept separate from agent-authored records.""" + + decisionRecordId: str + decisionId: str + selectedOptionId: str + status: DecisionStatus + executorPayload: RnaOptionPayload + verification: VerificationRecord + + +def compile_rna_decision( + definition: RnaDecisionDefinition, + evidence: EvidenceBundle, + record: DecisionRecord, + *, + created_at_ns: int = 0, +) -> CompiledRnaDecision: + """Audit an exact selection and resolve its executor-owned payload.""" + verification = DeterministicDecisionAuditor.audit( + definition.spec, + evidence, + record, + created_at_ns=created_at_ns, + ) + failed_checks = [ + check.checkId for check in verification.checks if check.status == "failed" + ] + if failed_checks: + raise RnaDecisionCompilationError( + "Decision failed deterministic verification: " + ", ".join(failed_checks) + ) + if record.verificationId != verification.verificationId: + raise RnaDecisionCompilationError( + "DecisionRecord must reference its deterministic verification ID" + ) + executor_option = definition.executor_option(record.selectedOptionId) + return CompiledRnaDecision( + decisionRecordId=record.recordId, + decisionId=record.decisionId, + selectedOptionId=record.selectedOptionId, + status=record.status, + executorPayload=executor_option.payload, + verification=verification, + ) + + +def _defer_option( + checkpoint: RnaDecisionCheckpoint, option_id: str +) -> tuple[DecisionOption, RnaExecutorOption]: + return ( + DecisionOption( + optionId=option_id, + status="defer", + label="Request input", + description="Pause because the available evidence cannot identify a choice.", + ), + RnaExecutorOption( + checkpoint=checkpoint, + optionId=option_id, + payload=NoExecutionPayload(reasonCode="needsInput"), + ), + ) + + +def _build_definition( + *, + checkpoint: RnaDecisionCheckpoint, + decision_id: str, + evidence_bundle_id: str, + question: str, + visible_options: list[DecisionOption], + executor_options: list[RnaExecutorOption], + baseline_option_id: str | None, + metric_preferred_option_id: str | None = None, + require_override_evidence: bool = False, + allowed_sources: list[Literal["rule", "agent", "human"]] | None = None, +) -> RnaDecisionDefinition: + return RnaDecisionDefinition( + checkpoint=checkpoint, + spec=DecisionSpec( + decisionId=decision_id, + definitionVersion=1, + checkpoint=checkpoint, + question=question, + evidenceBundleId=evidence_bundle_id, + options=visible_options, + baselineOptionId=baseline_option_id, + metricPreferredOptionId=metric_preferred_option_id, + requireIndependentOverrideEvidence=require_override_evidence, + allowedSources=allowed_sources or ["rule", "agent", "human"], + ), + executorOptions=executor_options, + ) + + +def require_option_evidence( + definition: RnaDecisionDefinition, + requirements: Mapping[str, Sequence[str]], +) -> RnaDecisionDefinition: + """Bind exact evidence IDs to the registered options they support.""" + options = definition.spec.option_by_id() + unknown = sorted(set(requirements) - set(options)) + if unknown: + raise ValueError(f"Evidence requirements name unknown options: {unknown}") + updated: list[DecisionOption] = [] + for option in definition.spec.options: + raw_ids = requirements.get(option.optionId, ()) + if isinstance(raw_ids, str | bytes): + raise TypeError("Option evidence requirements must be sequences of IDs") + updated.append( + option.model_copy( + update={"requiredEvidenceIds": list(dict.fromkeys(raw_ids))} + ) + ) + spec = definition.spec.model_copy(update={"options": updated}) + return definition.model_copy(update={"spec": spec}) + + +def build_qc_grouping_decision( + *, + evidence_bundle_id: str, + physical_capture_eligible: bool, + pooled_reference_eligible: bool, +) -> RnaDecisionDefinition: + """Build only quality-threshold grouping modes licensed by design evidence.""" + if pooled_reference_eligible and not physical_capture_eligible: + raise RnaDecisionGateError( + "Pooled-reference QC requires eligible physical captures" + ) + rows: list[tuple[str, str, str, QcGroupingMode]] = [ + ( + "qcGrouping:global", + "Global reference", + "Estimate registered quality boundaries across the selected dataset.", + "global", + ) + ] + if physical_capture_eligible: + rows.append( + ( + "qcGrouping:physicalCapture", + "Physical-capture references", + "Estimate registered boundaries separately within proven captures.", + "physicalCapture", + ) + ) + if pooled_reference_eligible: + rows.append( + ( + "qcGrouping:pooledReference", + "Comparable pooled reference", + "Estimate registered boundaries from the exact comparable captures.", + "pooledReference", + ) + ) + visible = [ + DecisionOption( + optionId=option_id, + status="apply", + label=label, + description=description, + requiredEvidenceClasses=["qualityControl", "design"], + ) + for option_id, label, description, _mode in rows + ] + executor = [ + RnaExecutorOption( + checkpoint="qcGrouping", + optionId=option_id, + payload=QcGroupingExecutorPayload(groupingMode=mode), + ) + for option_id, _label, _description, mode in rows + ] + defer_visible, defer_executor = _defer_option("qcGrouping", "qcGrouping:defer") + visible.append(defer_visible) + executor.append(defer_executor) + return _build_definition( + checkpoint="qcGrouping", + decision_id="qcGrouping", + evidence_bundle_id=evidence_bundle_id, + question="Which reference population should define cell-quality boundaries?", + visible_options=visible, + executor_options=executor, + baseline_option_id="qcGrouping:global", + ) + + +def build_cell_quality_decision( + *, + evidence_bundle_id: str, + available_profiles: Sequence[CellQualityProfile], +) -> RnaDecisionDefinition: + """Build exactly the cell-quality profiles licensed by grouping evidence.""" + if len(available_profiles) != len(set(available_profiles)): + raise RnaDecisionGateError("Cell-quality profiles must not contain duplicates") + if not available_profiles: + raise RnaDecisionGateError("At least one cell-quality profile is required") + definitions: dict[ + CellQualityProfile, + tuple[str, DecisionStatus, str, str, CellQualityExecutorPayload], + ] = { + "retainWithFlags": ( + "cellQuality:retainWithFlags", + "skip", + "Retain with flags", + "Preserve the published cell set and retain diagnostic quality flags.", + CellQualityExecutorPayload( + profile="retainWithFlags", + groupByCapture=False, + pooledReference=False, + sensitivityOnly=False, + ), + ), + "globalMad5": ( + "cellQuality:globalMad5", + "apply", + "Global lenient filter", + "Apply one-sided global five-MAD quality boundaries.", + CellQualityExecutorPayload( + profile="globalMad5", + lowerCountMad=5.0, + lowerFeatureMad=5.0, + upperMitoMad=5.0, + groupByCapture=False, + pooledReference=False, + sensitivityOnly=False, + ), + ), + "captureMad5": ( + "cellQuality:captureMad5", + "apply", + "Capture-aware lenient filter", + "Apply one-sided five-MAD boundaries within physical captures.", + CellQualityExecutorPayload( + profile="captureMad5", + lowerCountMad=5.0, + lowerFeatureMad=5.0, + upperMitoMad=5.0, + groupByCapture=True, + pooledReference=False, + sensitivityOnly=False, + ), + ), + "captureMad3Sensitivity": ( + "cellQuality:captureMad3Sensitivity", + "apply", + "Capture sensitivity branch", + "Evaluate stricter three-MAD capture boundaries as sensitivity evidence.", + CellQualityExecutorPayload( + profile="captureMad3Sensitivity", + lowerCountMad=3.0, + lowerFeatureMad=3.0, + upperMitoMad=3.0, + groupByCapture=True, + pooledReference=False, + sensitivityOnly=True, + ), + ), + "pooledReferenceMad5": ( + "cellQuality:pooledReferenceMad5", + "apply", + "Pooled-reference filter", + "Apply five-MAD boundaries from comparable pooled reference captures.", + CellQualityExecutorPayload( + profile="pooledReferenceMad5", + lowerCountMad=5.0, + lowerFeatureMad=5.0, + upperMitoMad=5.0, + groupByCapture=False, + pooledReference=True, + sensitivityOnly=False, + ), + ), + } + profiles = [definitions[profile] for profile in available_profiles] + + visible = [ + DecisionOption( + optionId=option_id, + status=status, + label=label, + description=description, + requiredEvidenceClasses=["qualityControl"], + ) + for option_id, status, label, description, _payload in profiles + ] + executor = [ + RnaExecutorOption(checkpoint="cellQuality", optionId=option_id, payload=payload) + for option_id, _status, _label, _description, payload in profiles + ] + defer_visible, defer_executor = _defer_option("cellQuality", "cellQuality:defer") + visible.append(defer_visible) + executor.append(defer_executor) + return _build_definition( + checkpoint="cellQuality", + decision_id="cellQuality", + evidence_bundle_id=evidence_bundle_id, + question="Which registered cell-quality profile preserves valid biology?", + visible_options=visible, + executor_options=executor, + baseline_option_id=( + "cellQuality:retainWithFlags" + if "retainWithFlags" in available_profiles + else profiles[0][0] + ), + ) + + +def build_hvg_ranking_decision( + *, + evidence_bundle_id: str, + batch_aware_eligible: bool, +) -> RnaDecisionDefinition: + """Build exact global and, when licensed, technical-group HVG rankings.""" + rows: list[tuple[str, str, str, HvgRankingMode]] = [ + ( + "hvgRanking:global", + "Global variability ranking", + "Rank genes by corrected variability across all selected cells.", + "global", + ) + ] + if batch_aware_eligible: + rows.append( + ( + "hvgRanking:batchAware", + "Technical-group recurrence ranking", + "Rank genes by recurrence and within-group rank across valid groups.", + "batchAware", + ) + ) + visible = [ + DecisionOption( + optionId=option_id, + status="apply", + label=label, + description=description, + requiredEvidenceClasses=["technical"], + ) + for option_id, label, description, _mode in rows + ] + executor = [ + RnaExecutorOption( + checkpoint="hvgRanking", + optionId=option_id, + payload=HvgRankingExecutorPayload(rankingMode=mode), + ) + for option_id, _label, _description, mode in rows + ] + defer_visible, defer_executor = _defer_option("hvgRanking", "hvgRanking:defer") + visible.append(defer_visible) + executor.append(defer_executor) + return _build_definition( + checkpoint="hvgRanking", + decision_id="hvgRanking", + evidence_bundle_id=evidence_bundle_id, + question="Which registered variability ranking is supported by the design?", + visible_options=visible, + executor_options=executor, + baseline_option_id="hvgRanking:global", + ) + + +def _bounded_options( + *, + maximum: int, + fixed: list[tuple[str, str, int]], + maximum_id: str, + maximum_label: str, +) -> list[tuple[str, str, int]]: + if maximum < 1: + raise ValueError("maximum must be positive") + bounded = [item for item in fixed if item[2] <= maximum] + fixed_values = {value for _option_id, _label, value in bounded} + if maximum < fixed[-1][2] and maximum not in fixed_values: + bounded.append((maximum_id, maximum_label, maximum)) + return bounded + + +def _baseline_for_value(options: list[tuple[str, str, int]], desired_value: int) -> str: + return min(options, key=lambda item: (abs(item[2] - desired_value), item[2]))[0] + + +def build_hvg_count_decision( + *, + evidence_bundle_id: str, + eligible_feature_count: int, + ranking_mode: HvgRankingMode, + valid_technical_groups: int = 0, + candidate_counts: Sequence[int] | None = None, +) -> RnaDecisionDefinition: + """Build capped HVG counts with the ranking route fixed by capabilities.""" + if eligible_feature_count < 2: + raise RnaDecisionGateError("HVG selection requires at least two eligible genes") + if ranking_mode == "batchAware" and valid_technical_groups < 2: + raise RnaDecisionGateError( + "Batch-aware HVGs require at least two valid technical groups" + ) + if candidate_counts is None: + candidates = _bounded_options( + maximum=eligible_feature_count, + fixed=[ + ("hvgCount:focused", "Focused HVG set", 1000), + ("hvgCount:standard", "Standard HVG set", 2000), + ("hvgCount:broad", "Broad HVG set", 4000), + ], + maximum_id="hvgCount:allEligible", + maximum_label="All eligible genes", + ) + else: + effective_counts: list[int] = [] + for value in candidate_counts: + if isinstance(value, bool) or not isinstance(value, int) or value < 1: + raise RnaDecisionGateError( + "HVG candidate counts must be positive integers" + ) + effective = min(value, eligible_feature_count) + if effective not in effective_counts: + effective_counts.append(effective) + if not effective_counts: + raise RnaDecisionGateError("At least one HVG candidate is required") + known = { + 1000: ("hvgCount:focused", "Focused HVG set"), + 2000: ("hvgCount:standard", "Standard HVG set"), + 4000: ("hvgCount:broad", "Broad HVG set"), + } + candidates = [ + ( + known.get(value, (f"hvgCount:n{value}", f"{value} HVGs"))[0], + known.get(value, (f"hvgCount:n{value}", f"{value} HVGs"))[1], + value, + ) + for value in effective_counts + ] + visible = [ + DecisionOption( + optionId=option_id, + status="apply", + label=label, + description="Use this registered HVG count for representation diagnostics.", + requiredEvidenceClasses=["technical"], + ) + for option_id, label, _top_n in candidates + ] + executor = [ + RnaExecutorOption( + checkpoint="hvgCount", + optionId=option_id, + payload=HvgExecutorPayload(topN=top_n, rankingMode=ranking_mode), + ) + for option_id, _label, top_n in candidates + ] + defer_visible, defer_executor = _defer_option("hvgCount", "hvgCount:defer") + visible.append(defer_visible) + executor.append(defer_executor) + baseline = _baseline_for_value(candidates, min(2000, eligible_feature_count)) + return _build_definition( + checkpoint="hvgCount", + decision_id="hvgCount", + evidence_bundle_id=evidence_bundle_id, + question="Which registered HVG count retains reproducible signal?", + visible_options=visible, + executor_options=executor, + baseline_option_id=baseline, + ) + + +def build_feature_policy_decision( + *, + evidence_bundle_id: str, + proposed_exclusion_families: list[ConditionalGeneFamily], + dominant_families: list[ConditionalGeneFamily], + protected_families: list[ConditionalGeneFamily], +) -> RnaDecisionDefinition: + """Build keep-all plus at most one licensed conditional exclusion bundle.""" + for field_name, values in ( + ("proposed_exclusion_families", proposed_exclusion_families), + ("dominant_families", dominant_families), + ("protected_families", protected_families), + ): + if len(values) != len(set(values)): + raise RnaDecisionGateError(f"{field_name} must not contain duplicates") + proposed = set(proposed_exclusion_families) + if not proposed.issubset(dominant_families): + raise RnaDecisionGateError( + "Conditional exclusion requires observed dominance evidence" + ) + protected_overlap = proposed.intersection(protected_families) + if protected_overlap: + blocked = ", ".join(sorted(protected_overlap)) + raise RnaDecisionGateError( + f"Objective-protected gene families cannot be excluded: {blocked}" + ) + + visible = [ + DecisionOption( + optionId="featurePolicy:keepAll", + status="skip", + label="Keep conditional families", + description="Keep all conditional biological gene families in representation.", + requiredEvidenceClasses=["technical"], + ) + ] + executor = [ + RnaExecutorOption( + checkpoint="featurePolicy", + optionId="featurePolicy:keepAll", + payload=FeaturePolicyExecutorPayload(policy="keepAll", excludedFamilies=[]), + ) + ] + if proposed_exclusion_families: + visible.append( + DecisionOption( + optionId="featurePolicy:excludeEligibleBundle", + status="apply", + label="Exclude eligible nuisance bundle", + description=( + "Exclude the one deterministic nuisance-family bundle from " + "representation only." + ), + requiredEvidenceClasses=["technical"], + ) + ) + executor.append( + RnaExecutorOption( + checkpoint="featurePolicy", + optionId="featurePolicy:excludeEligibleBundle", + payload=FeaturePolicyExecutorPayload( + policy="excludeEligibleBundle", + excludedFamilies=proposed_exclusion_families, + ), + ) + ) + defer_visible, defer_executor = _defer_option( + "featurePolicy", "featurePolicy:defer" + ) + visible.append(defer_visible) + executor.append(defer_executor) + return _build_definition( + checkpoint="featurePolicy", + decision_id="featurePolicy", + evidence_bundle_id=evidence_bundle_id, + question="Should the eligible nuisance-family bundle leave representation?", + visible_options=visible, + executor_options=executor, + baseline_option_id="featurePolicy:keepAll", + ) + + +def build_pca_prefix_decision( + *, + evidence_bundle_id: str, + matrix_rank: int, + candidate_dimensions: Sequence[int] | None = None, +) -> RnaDecisionDefinition: + """Build PCA prefixes capped by rank and by the v1 fifty-PC limit.""" + maximum = min(matrix_rank, 50) + if maximum < 2: + raise RnaDecisionGateError("PCA requires matrix rank of at least two") + if candidate_dimensions is None: + candidates = _bounded_options( + maximum=maximum, + fixed=[ + ("pcaPrefix:short", "Short PCA prefix", 10), + ("pcaPrefix:standard", "Standard PCA prefix", 20), + ("pcaPrefix:extended", "Extended PCA prefix", 30), + ("pcaPrefix:maximum", "Maximum PCA prefix", 50), + ], + maximum_id="pcaPrefix:maximumAvailable", + maximum_label="Maximum available PCA prefix", + ) + else: + values: list[int] = [] + for value in candidate_dimensions: + if isinstance(value, bool) or not isinstance(value, int) or value < 2: + raise RnaDecisionGateError( + "PCA candidate dimensions must be integers of at least two" + ) + effective = min(value, maximum) + if effective not in values: + values.append(effective) + known = { + 10: ("pcaPrefix:short", "Short PCA prefix"), + 20: ("pcaPrefix:standard", "Standard PCA prefix"), + 30: ("pcaPrefix:extended", "Extended PCA prefix"), + 50: ("pcaPrefix:maximum", "Maximum PCA prefix"), + } + candidates = [ + ( + known.get(value, (f"pcaPrefix:n{value}", f"{value} PCs"))[0], + known.get(value, (f"pcaPrefix:n{value}", f"{value} PCs"))[1], + value, + ) + for value in values + ] + if not candidates: + raise RnaDecisionGateError("At least one PCA candidate is required") + visible = [ + DecisionOption( + optionId=option_id, + status="apply", + label=label, + description="Use this registered prefix of the single computed PCA.", + requiredEvidenceClasses=["geometric", "technical"], + ) + for option_id, label, _dimensions in candidates + ] + executor = [ + RnaExecutorOption( + checkpoint="pcaPrefix", + optionId=option_id, + payload=PcaPrefixExecutorPayload(dimensions=dimensions), + ) + for option_id, _label, dimensions in candidates + ] + defer_visible, defer_executor = _defer_option("pcaPrefix", "pcaPrefix:defer") + visible.append(defer_visible) + executor.append(defer_executor) + baseline = _baseline_for_value(candidates, min(20, maximum)) + return _build_definition( + checkpoint="pcaPrefix", + decision_id="pcaPrefix", + evidence_bundle_id=evidence_bundle_id, + question="What is the smallest registered PCA prefix that stabilizes topology?", + visible_options=visible, + executor_options=executor, + baseline_option_id=baseline, + ) + + +def build_correction_license_decision( + *, evidence_bundle_id: str, license: CorrectionLicense +) -> RnaDecisionDefinition: + """Record the one correction license authorized by deterministic design checks.""" + statuses: dict[CorrectionLicense, DecisionStatus] = { + "safe": "apply", + "unsafeConfounded": "skip", + "indeterminate": "defer", + "notApplicable": "skip", + } + option_id = f"correctionLicense:{license}" + visible = [ + DecisionOption( + optionId=option_id, + status=statuses[license], + label="Correction design license", + description="Use the exact correction license produced by design validation.", + requiredEvidenceClasses=["design"], + ) + ] + executor = [ + RnaExecutorOption( + checkpoint="correctionLicense", + optionId=option_id, + payload=CorrectionLicensePayload(license=license), + ) + ] + return _build_definition( + checkpoint="correctionLicense", + decision_id="correctionLicense", + evidence_bundle_id=evidence_bundle_id, + question="Does the experimental design authorize batch correction?", + visible_options=visible, + executor_options=executor, + baseline_option_id=option_id, + allowed_sources=["rule"], + ) + + +def build_correction_need_decision( + *, evidence_bundle_id: str, license: CorrectionLicense +) -> RnaDecisionDefinition: + """Build correction-need options only after a safe design license.""" + if license != "safe": + raise RnaDecisionGateError( + "Correction need is evaluated only after a safe correction license" + ) + rows: list[tuple[str, DecisionStatus, str, CorrectionNeed]] = [ + ( + "correctionNeed:needed", + "apply", + "Technical separation is present within comparable populations.", + "needed", + ), + ( + "correctionNeed:notNeeded", + "skip", + "The native representation does not show material technical separation.", + "notNeeded", + ), + ( + "correctionNeed:indeterminate", + "defer", + "Available evidence cannot distinguish technical and protected structure.", + "indeterminate", + ), + ] + visible = [ + DecisionOption( + optionId=option_id, + status=status, + label=need, + description=description, + requiredEvidenceClasses=["batchRemoval", "biologicalConservation"] + if need != "indeterminate" + else ["design"], + ) + for option_id, status, description, need in rows + ] + executor = [ + RnaExecutorOption( + checkpoint="correctionNeed", + optionId=option_id, + payload=CorrectionNeedPayload(need=need), + ) + for option_id, _status, _description, need in rows + ] + return _build_definition( + checkpoint="correctionNeed", + decision_id="correctionNeed", + evidence_bundle_id=evidence_bundle_id, + question="Does the native representation show a licensed need for correction?", + visible_options=visible, + executor_options=executor, + baseline_option_id="correctionNeed:notNeeded", + ) + + +def build_correction_outcome_decision( + *, + evidence_bundle_id: str, + license: CorrectionLicense, + need: CorrectionNeed | None = None, + harmony_eligible: bool = True, +) -> RnaDecisionDefinition: + """Build a native baseline and offer Harmony only when licensed and needed.""" + if license == "indeterminate": + raise RnaDecisionGateError( + "Indeterminate correction license must resolve before outcome comparison" + ) + if license == "safe" and need is None: + raise RnaDecisionGateError( + "A safe correction license requires an evaluated correction need" + ) + if license != "safe" and need is not None: + raise RnaDecisionGateError( + "Correction need must not bypass an unsafe or inapplicable license" + ) + if need == "indeterminate": + raise RnaDecisionGateError( + "Indeterminate correction need must resolve before outcome comparison" + ) + + visible = [ + DecisionOption( + optionId="correctionOutcome:retainNative", + status="skip", + label="Retain native representation", + description="Keep the mandatory uncorrected representation baseline.", + requiredEvidenceClasses=["biologicalConservation"], + ) + ] + executor = [ + RnaExecutorOption( + checkpoint="correctionOutcome", + optionId="correctionOutcome:retainNative", + payload=CorrectionOutcomeExecutorPayload( + outcome="retainNative", useHarmony=False + ), + ) + ] + offer_harmony = license == "safe" and need == "needed" and harmony_eligible + if offer_harmony: + visible.append( + DecisionOption( + optionId="correctionOutcome:acceptHarmony", + status="apply", + label="Accept Harmony", + description="Use the matched Harmony representation branch.", + requiredEvidenceClasses=[ + "batchRemoval", + "biologicalConservation", + "protectedVariablePreservation", + ], + ) + ) + executor.append( + RnaExecutorOption( + checkpoint="correctionOutcome", + optionId="correctionOutcome:acceptHarmony", + payload=CorrectionOutcomeExecutorPayload( + outcome="acceptHarmony", useHarmony=True + ), + ) + ) + defer_visible, defer_executor = _defer_option( + "correctionOutcome", "correctionOutcome:indeterminate" + ) + visible.append(defer_visible) + executor.append(defer_executor) + allowed_sources: list[Literal["rule", "agent", "human"]] = ( + ["rule", "agent", "human"] if offer_harmony else ["rule"] + ) + return _build_definition( + checkpoint="correctionOutcome", + decision_id="correctionOutcome", + evidence_bundle_id=evidence_bundle_id, + question="Should the verified final representation remain native or use Harmony?", + visible_options=visible, + executor_options=executor, + baseline_option_id="correctionOutcome:retainNative", + allowed_sources=allowed_sources, + ) + + +def build_graph_k_decision( + *, + evidence_bundle_id: str, + n_cells: int, + candidate_neighbors: Sequence[int] | None = None, +) -> RnaDecisionDefinition: + """Build graph scales capped by the selected cell count.""" + maximum = min(n_cells - 1, 41) + if maximum < 2: + raise RnaDecisionGateError("Graph construction requires at least three cells") + if candidate_neighbors is None: + candidates = _bounded_options( + maximum=maximum, + fixed=[ + ("graphScale:local", "Local graph", 11), + ("graphScale:balanced", "Balanced graph", 21), + ("graphScale:broad", "Broad graph", 41), + ], + maximum_id="graphScale:maximumAvailable", + maximum_label="Maximum available graph", + ) + else: + values: list[int] = [] + for value in candidate_neighbors: + if isinstance(value, bool) or not isinstance(value, int) or value < 2: + raise RnaDecisionGateError( + "Graph candidates must be integers of at least two" + ) + effective = min(value, maximum) + if effective not in values: + values.append(effective) + known = { + 11: ("graphScale:local", "Local graph"), + 21: ("graphScale:balanced", "Balanced graph"), + 41: ("graphScale:broad", "Broad graph"), + } + candidates = [ + ( + known.get(value, (f"graphScale:k{value}", f"{value}-neighbor graph"))[ + 0 + ], + known.get(value, (f"graphScale:k{value}", f"{value}-neighbor graph"))[ + 1 + ], + value, + ) + for value in values + ] + if not candidates: + raise RnaDecisionGateError("At least one graph candidate is required") + visible = [ + DecisionOption( + optionId=option_id, + status="apply", + label=label, + description="Use this registered neighborhood scale for graph diagnostics.", + requiredEvidenceClasses=["geometric"], + ) + for option_id, label, _neighbors in candidates + ] + executor = [ + RnaExecutorOption( + checkpoint="graphK", + optionId=option_id, + payload=GraphExecutorPayload(neighborsK=neighbors), + ) + for option_id, _label, neighbors in candidates + ] + defer_visible, defer_executor = _defer_option("graphK", "graphScale:defer") + visible.append(defer_visible) + executor.append(defer_executor) + baseline = _baseline_for_value(candidates, min(21, maximum)) + return _build_definition( + checkpoint="graphK", + decision_id="graphK", + evidence_bundle_id=evidence_bundle_id, + question="Which registered graph scale is stable and locally informative?", + visible_options=visible, + executor_options=executor, + baseline_option_id=baseline, + ) + + +def build_cluster_partition_decision( + *, + evidence_bundle_id: str, + metric_preferred_option_id: str, + resolution_candidates: Sequence[float] | None = None, +) -> RnaDecisionDefinition: + """Build fixed Leiden resolutions plus explicit defer and abstain outcomes.""" + default_rows: list[tuple[str, str, float]] = [ + ("clusterResolution:veryCoarse", "Very coarse partition", 0.25), + ("clusterResolution:coarse", "Coarse partition", 0.5), + ("clusterResolution:balanced", "Balanced partition", 0.75), + ("clusterResolution:detailed", "Detailed partition", 1.0), + ("clusterResolution:fine", "Fine partition", 1.25), + ("clusterResolution:veryFine", "Very fine partition", 1.5), + ] + if resolution_candidates is None: + rows = default_rows + else: + known = { + resolution: (option_id, label) + for option_id, label, resolution in default_rows + } + rows = [] + seen: set[float] = set() + for raw in resolution_candidates: + resolution = float(raw) + if not 0 < resolution <= 1.5 or resolution in seen: + raise RnaDecisionGateError( + "Cluster resolutions must be unique values in (0, 1.5]" + ) + seen.add(resolution) + token = str(resolution).replace(".", "p") + option_id, label = known.get( + resolution, + ( + f"clusterResolution:r{token}", + f"Leiden resolution {resolution:g}", + ), + ) + rows.append((option_id, label, resolution)) + if not rows: + raise RnaDecisionGateError("At least one cluster resolution is required") + resolution_ids = [option_id for option_id, _label, _resolution in rows] + if metric_preferred_option_id not in resolution_ids: + raise RnaDecisionGateError( + "metric_preferred_option_id must be a registered resolution option" + ) + visible = [ + DecisionOption( + optionId=option_id, + status="apply", + label=label, + description="Use this registered Leiden resolution.", + requiredEvidenceClasses=["geometric"], + ) + for option_id, label, _resolution in rows + ] + executor = [ + RnaExecutorOption( + checkpoint="clusterPartition", + optionId=option_id, + payload=ClusterExecutorPayload(leidenResolution=resolution), + ) + for option_id, _label, resolution in rows + ] + defer_visible, defer_executor = _defer_option( + "clusterPartition", "clusterPartition:defer" + ) + visible.append(defer_visible) + executor.append(defer_executor) + visible.append( + DecisionOption( + optionId="clusterPartition:abstain", + status="abstain", + label="Abstain from discrete clustering", + description="Do not claim a defensible discrete partition.", + ) + ) + executor.append( + RnaExecutorOption( + checkpoint="clusterPartition", + optionId="clusterPartition:abstain", + payload=NoExecutionPayload(reasonCode="scientificAbstention"), + ) + ) + return _build_definition( + checkpoint="clusterPartition", + decision_id="clusterPartition", + evidence_bundle_id=evidence_bundle_id, + question="Which registered partition is scientifically defensible?", + visible_options=visible, + executor_options=executor, + baseline_option_id="clusterResolution:balanced", + metric_preferred_option_id=metric_preferred_option_id, + require_override_evidence=True, + ) + + +__all__ = [ + "CellQualityProfile", + "CellQualityExecutorPayload", + "ClusterExecutorPayload", + "CompiledRnaDecision", + "ConditionalGeneFamily", + "CorrectionLicense", + "CorrectionLicensePayload", + "CorrectionNeed", + "CorrectionNeedPayload", + "CorrectionOutcomeExecutorPayload", + "DecisionTerminalStatus", + "FeaturePolicyExecutorPayload", + "GraphExecutorPayload", + "HvgExecutorPayload", + "HvgRankingExecutorPayload", + "HvgRankingMode", + "NoExecutionPayload", + "PcaPrefixExecutorPayload", + "QcGroupingExecutorPayload", + "QcGroupingMode", + "RNA_DECISION_TRANSITION_GRAPH", + "RnaDecisionCheckpoint", + "RnaDecisionCompilationError", + "RnaDecisionDefinition", + "RnaDecisionGateError", + "RnaDecisionRegistry", + "RnaDecisionTransition", + "RnaDecisionTransitionGraph", + "RnaExecutorOption", + "RnaOptionPayload", + "RnaWorkflowNode", + "build_cell_quality_decision", + "build_cluster_partition_decision", + "build_correction_license_decision", + "build_correction_need_decision", + "build_correction_outcome_decision", + "build_feature_policy_decision", + "build_graph_k_decision", + "build_hvg_count_decision", + "build_hvg_ranking_decision", + "build_pca_prefix_decision", + "build_qc_grouping_decision", + "compile_rna_decision", + "require_option_evidence", +] diff --git a/scarf/agent/sequential_tuning.py b/scarf/agent/sequential_tuning.py new file mode 100644 index 00000000..58cf0a5f --- /dev/null +++ b/scarf/agent/sequential_tuning.py @@ -0,0 +1,713 @@ +"""Causal phase planning for RNA parameter adjudication. + +This module constructs executor-compatible candidate sets one scientific choice +at a time. It does not call a model and it never chooses a fallback candidate. +The orchestration layer can persist ``ParameterPhaseEvidence`` after requesting +an exact candidate ID from an agent or human. +""" + +import hashlib +import re +from collections.abc import Sequence +from typing import Any, Literal + +from pydantic import ConfigDict, Field, model_validator + +from .parameter_tuning import ( + ParameterCandidate, + ParameterCandidateEvaluation, + ParameterTuningNeedsInput, + ParameterTuningReport, + execute_parameter_candidate, + finalize_parameter_tuning_selection, + prepare_parameter_tuning_dependencies, +) +from .types import AgentDataModel, ExperimentalTuningHandoff + + +type ParameterPhase = Literal[ + "pcaPrefix", + "batchCorrection", + "graphK", + "clusteringResolution", +] +type ParameterPhaseStatus = Literal["selected", "needsInput", "abstained"] +type ParameterDecisionSource = Literal["rule", "agent", "human"] +type VariedParameter = Literal[ + "dimensions", + "useHarmony", + "neighborsK", + "leidenResolution", +] + +_PHASE_ORDER: tuple[ParameterPhase, ...] = ( + "pcaPrefix", + "batchCorrection", + "graphK", + "clusteringResolution", +) +_VARIED_PARAMETER: dict[ParameterPhase, VariedParameter] = { + "pcaPrefix": "dimensions", + "batchCorrection": "useHarmony", + "graphK": "neighborsK", + "clusteringResolution": "leidenResolution", +} +_CANDIDATE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_]{0,63}$") + + +class SequentialTuningModel(AgentDataModel): + """Strict immutable base for sequential tuning records.""" + + model_config = ConfigDict(extra="forbid", frozen=True, validate_default=True) + + +class ParameterPhasePlan(SequentialTuningModel): + """One exact candidate set varying a single parameter.""" + + phase: ParameterPhase + assay: str = Field(min_length=1, max_length=256) + variedParameter: VariedParameter + basedOnCandidateId: str | None = None + candidates: list[ParameterCandidate] = Field(min_length=1) + + @model_validator(mode="after") + def validate_causal_candidate_set(self) -> "ParameterPhasePlan": + if self.variedParameter != _VARIED_PARAMETER[self.phase]: + raise ValueError("variedParameter does not match the tuning phase") + if (self.phase == "pcaPrefix") != (self.basedOnCandidateId is None): + raise ValueError("Only the PCA-prefix phase may omit basedOnCandidateId") + if self.basedOnCandidateId is not None and ( + _CANDIDATE_ID.fullmatch(self.basedOnCandidateId) is None + ): + raise ValueError("basedOnCandidateId is not a stable candidate ID") + candidate_ids = [candidate.candidateId for candidate in self.candidates] + if any(_CANDIDATE_ID.fullmatch(value) is None for value in candidate_ids): + raise ValueError("Candidate IDs must be stable non-empty identifiers") + if len(candidate_ids) != len(set(candidate_ids)): + raise ValueError("A parameter phase cannot contain duplicate candidate IDs") + if any(candidate.reductionMethod != "pca" for candidate in self.candidates): + raise ValueError("Sequential v1 tuning accepts RNA PCA candidates only") + + varied_values = [ + getattr(candidate, self.variedParameter) for candidate in self.candidates + ] + if len(varied_values) != len(set(varied_values)): + raise ValueError("A phase must vary its target parameter exactly once") + fixed_names = { + "dimensions", + "useHarmony", + "neighborsK", + "leidenResolution", + } - {self.variedParameter} + for field_name in fixed_names: + values = {getattr(candidate, field_name) for candidate in self.candidates} + if len(values) != 1: + raise ValueError( + f"Phase {self.phase!r} changes non-target parameter {field_name!r}" + ) + if self.phase == "pcaPrefix" and any( + candidate.useHarmony for candidate in self.candidates + ): + raise ValueError("PCA-prefix candidates must use the native representation") + if self.phase == "batchCorrection": + harmony_values = {candidate.useHarmony for candidate in self.candidates} + if False not in harmony_values or not harmony_values.issubset( + {False, True} + ): + raise ValueError( + "Batch-correction candidates must include the native baseline" + ) + return self + + def candidate_by_id(self) -> dict[str, ParameterCandidate]: + """Return this phase's exact executor candidates by ID.""" + return {candidate.candidateId: candidate for candidate in self.candidates} + + +class ParameterPhaseSelection(SequentialTuningModel): + """The only model-authored output for one tuning phase.""" + + phase: ParameterPhase + status: ParameterPhaseStatus + selectedCandidateId: str | None = None + evidenceIds: list[str] = Field(default_factory=list) + rationale: str = Field(min_length=1, max_length=4000) + + @model_validator(mode="after") + def validate_selection_shape(self) -> "ParameterPhaseSelection": + if (self.status == "selected") != (self.selectedCandidateId is not None): + raise ValueError("Only a selected phase may contain selectedCandidateId") + if self.selectedCandidateId is not None and ( + _CANDIDATE_ID.fullmatch(self.selectedCandidateId) is None + ): + raise ValueError("selectedCandidateId is not a stable candidate ID") + if len(self.evidenceIds) != len(set(self.evidenceIds)): + raise ValueError("evidenceIds must not contain duplicates") + if any(not value for value in self.evidenceIds): + raise ValueError("evidenceIds must contain non-empty values") + if self.status == "selected" and not self.evidenceIds: + raise ValueError("A selected phase must cite executor evidence") + if self.rationale != self.rationale.strip(): + raise ValueError("rationale must not contain surrounding whitespace") + return self + + +class ParameterPhaseEvidence(SequentialTuningModel): + """Executed evidence and one validated selection for a causal phase.""" + + plan: ParameterPhasePlan + evaluations: list[ParameterCandidateEvaluation] = Field(min_length=1) + selection: ParameterPhaseSelection + + @model_validator(mode="after") + def validate_execution_and_selection(self) -> "ParameterPhaseEvidence": + if self.selection.phase != self.plan.phase: + raise ValueError("Phase selection does not match its candidate plan") + candidates = self.plan.candidate_by_id() + evaluations = {value.candidateId: value for value in self.evaluations} + if len(evaluations) != len(self.evaluations): + raise ValueError("Phase evaluations contain duplicate candidate IDs") + if set(evaluations) != set(candidates): + raise ValueError("Phase evaluations must cover the exact candidate set") + cell_selections = [ + value.cellSelection + for value in self.evaluations + if value.cellSelection is not None + ] + if cell_selections and any( + value != cell_selections[0] for value in cell_selections[1:] + ): + raise ValueError("Phase evaluations must use one exact cell selection") + for candidate_id, evaluation in evaluations.items(): + if evaluation.parameters != candidates[candidate_id]: + raise ValueError( + f"Evaluation {candidate_id!r} changed its registered parameters" + ) + available_evidence = { + evidence_id + for evaluation in self.evaluations + for evidence_id in evaluation.evidenceIds + } + if not set(self.selection.evidenceIds).issubset(available_evidence): + raise ValueError("Selection cites evidence outside its phase evaluations") + if self.selection.status == "selected": + selected = evaluations.get(self.selection.selectedCandidateId or "") + if selected is None or selected.status != "done" or not selected.eligible: + raise ValueError( + "Selected candidate must be an eligible completed execution" + ) + if selected.cellSelection is None: + raise ValueError("Selected candidate lacks an exact cell selection") + selected_evidence = set(selected.evidenceIds) + if not selected_evidence.intersection(self.selection.evidenceIds): + raise ValueError( + "A selected phase must cite evidence from its selected candidate" + ) + if self.selection.status == "abstained" and self.plan.phase != ( + "clusteringResolution" + ): + raise ValueError("Only clustering may produce scientific abstention") + return self + + def selected_evaluation(self) -> ParameterCandidateEvaluation | None: + """Return the selected eligible evaluation, or None after a pause.""" + if self.selection.selectedCandidateId is None: + return None + return next( + value + for value in self.evaluations + if value.candidateId == self.selection.selectedCandidateId + ) + + +class CorrectionNeedSelection(SequentialTuningModel): + """Separate semantic decision about whether correction is needed.""" + + status: Literal["selected", "needsInput"] + selectedOptionId: Literal[ + "correctionNeed:needed", + "correctionNeed:notNeeded", + "correctionNeed:indeterminate", + ] + evidenceIds: list[str] = Field(default_factory=list) + rationale: str = Field(min_length=1, max_length=4000) + + @model_validator(mode="after") + def validate_need(self) -> "CorrectionNeedSelection": + is_indeterminate = self.selectedOptionId == "correctionNeed:indeterminate" + if (self.status == "needsInput") != is_indeterminate: + raise ValueError( + "Only correctionNeed:indeterminate may have needsInput status" + ) + if len(self.evidenceIds) != len(set(self.evidenceIds)): + raise ValueError("evidenceIds must not contain duplicates") + if self.status == "selected" and not self.evidenceIds: + raise ValueError("A correction-need decision must cite evidence") + if self.rationale != self.rationale.strip(): + raise ValueError("rationale must not contain surrounding whitespace") + return self + + +class SequentialAssayTuningEvidence(SequentialTuningModel): + """Ordered, persistable evidence for one RNA assay's four decisions.""" + + assay: str = Field(min_length=1, max_length=256) + phases: list[ParameterPhaseEvidence] = Field(min_length=1, max_length=4) + correctionLicense: Literal[ + "safe", "unsafeConfounded", "indeterminate", "notApplicable" + ] = "notApplicable" + correctionNeed: CorrectionNeedSelection | None = None + decisionSources: dict[str, ParameterDecisionSource] = Field(default_factory=dict) + pendingDecisionId: ( + Literal[ + "pcaPrefix", + "correctionLicense", + "correctionNeed", + "correctionOutcome", + "graphK", + "clusterPartition", + ] + | None + ) = None + pendingOptionIds: list[str] = Field(default_factory=list) + pendingEvidenceIds: list[str] = Field(default_factory=list) + finalCandidateId: str | None = None + + @model_validator(mode="after") + def validate_phase_lineage(self) -> "SequentialAssayTuningEvidence": + observed_order = tuple(item.plan.phase for item in self.phases) + if observed_order != _PHASE_ORDER[: len(observed_order)]: + raise ValueError("Sequential tuning phases are missing or out of order") + if any(item.plan.assay != self.assay for item in self.phases): + raise ValueError("Sequential tuning phases must use one assay") + valid_decision_ids = { + "pcaPrefix", + "correctionLicense", + "correctionNeed", + "correctionOutcome", + "graphK", + "clusterPartition", + } + if not set(self.decisionSources).issubset(valid_decision_ids): + raise ValueError("decisionSources contains an unknown RNA decision") + for field_name, values in ( + ("pendingOptionIds", self.pendingOptionIds), + ("pendingEvidenceIds", self.pendingEvidenceIds), + ): + if len(values) != len(set(values)) or any(not value for value in values): + raise ValueError( + f"{field_name} must contain unique non-empty identifiers" + ) + if self.pendingDecisionId is None: + if self.pendingOptionIds or self.pendingEvidenceIds: + raise ValueError( + "Pending option and evidence IDs require pendingDecisionId" + ) + elif not self.pendingOptionIds: + raise ValueError("A pending decision requires its exact offered options") + has_batch_phase = any( + item.plan.phase == "batchCorrection" for item in self.phases + ) + if self.correctionLicense == "safe" and ( + has_batch_phase or self.pendingDecisionId == "correctionNeed" + ): + if self.correctionNeed is None: + raise ValueError("A safe correction branch requires correctionNeed") + elif self.correctionNeed is not None: + raise ValueError( + "Correction need must be absent without a safe correction license" + ) + if self.pendingDecisionId == "correctionNeed" and ( + self.correctionNeed is None or self.correctionNeed.status != "needsInput" + ): + raise ValueError("pending correctionNeed requires an indeterminate need") + if self.pendingDecisionId == "correctionOutcome": + batch_phases = [ + item for item in self.phases if item.plan.phase == "batchCorrection" + ] + if ( + not batch_phases + or batch_phases[-1].selection.status != "needsInput" + or self.correctionNeed is None + or self.correctionNeed.selectedOptionId != "correctionNeed:needed" + ): + raise ValueError( + "pending correctionOutcome requires a needed correction and pause" + ) + for index, phase in enumerate(self.phases[1:], start=1): + previous = self.phases[index - 1] + previous_evaluation = previous.selected_evaluation() + if previous_evaluation is None: + raise ValueError("No phase may follow needsInput or abstained") + if phase.plan.basedOnCandidateId != previous_evaluation.candidateId: + raise ValueError("Phase basedOnCandidateId breaks selection lineage") + target = phase.plan.variedParameter + for candidate in phase.plan.candidates: + for field_name in ( + "dimensions", + "useHarmony", + "neighborsK", + "leidenResolution", + ): + if field_name == target: + continue + if getattr(candidate, field_name) != getattr( + previous_evaluation.parameters, + field_name, + ): + raise ValueError( + f"Phase {phase.plan.phase!r} does not preserve " + f"selected {field_name!r}" + ) + completed = ( + len(self.phases) == len(_PHASE_ORDER) + and self.phases[-1].selection.status == "selected" + ) + expected_final = ( + self.phases[-1].selection.selectedCandidateId if completed else None + ) + if self.finalCandidateId != expected_final: + raise ValueError("finalCandidateId requires four selected causal phases") + return self + + +class SequentialRnaTuningPlanner: + """Construct fixed, rank-capped candidates for four causal RNA phases.""" + + def __init__( + self, + *, + workflow_run_id: str, + assay: str, + n_cells: int, + n_features: int, + harmony_authorized: bool, + matrix_rank: int | None = None, + dimension_candidates: Sequence[int] = (10, 20, 30, 50), + neighbor_candidates: Sequence[int] = (11, 21, 41), + resolution_candidates: Sequence[float] = ( + 0.25, + 0.5, + 0.75, + 1.0, + 1.25, + 1.5, + ), + ) -> None: + if not workflow_run_id or not assay: + raise ValueError("workflow_run_id and assay must be non-empty") + if isinstance(n_cells, bool) or not isinstance(n_cells, int) or n_cells < 3: + raise ValueError("Sequential tuning requires at least three cells") + if ( + isinstance(n_features, bool) + or not isinstance(n_features, int) + or n_features < 3 + ): + raise ValueError("Sequential tuning requires at least three features") + if not isinstance(harmony_authorized, bool): + raise TypeError("harmony_authorized must be a boolean") + maximum_rank = min(n_cells, n_features) - 1 + if matrix_rank is not None: + if ( + isinstance(matrix_rank, bool) + or not isinstance(matrix_rank, int) + or not 2 <= matrix_rank <= maximum_rank + ): + raise ValueError( + "matrix_rank must be between two and the shape-derived rank cap" + ) + maximum_rank = matrix_rank + self.workflow_run_id = workflow_run_id + self.assay = assay + self.n_cells = n_cells + self.n_features = n_features + self.harmony_authorized = harmony_authorized + self.dimensions = self._capped_integers( + dimension_candidates, + maximum=maximum_rank, + name="dimension_candidates", + ) + self.neighbors = self._capped_integers( + neighbor_candidates, + maximum=n_cells - 1, + name="neighbor_candidates", + ) + resolutions = tuple(float(value) for value in resolution_candidates) + if ( + not resolutions + or any(not 0 < value < float("inf") for value in resolutions) + or len(resolutions) != len(set(resolutions)) + ): + raise ValueError( + "resolution_candidates must be unique, finite, and positive" + ) + self.resolutions = resolutions + token = re.sub(r"[^A-Za-z0-9]+", "_", workflow_run_id).strip("_")[:12] + assay_token = re.sub(r"[^A-Za-z0-9]+", "_", assay).strip("_")[:12] + digest = hashlib.blake2b( + f"{workflow_run_id}\0{assay}".encode(), + digest_size=5, + ).hexdigest() + self.prefix = f"seq_{token or 'run'}_{assay_token or 'assay'}_{digest}" + + @staticmethod + def _capped_integers( + values: Sequence[int], + *, + maximum: int, + name: str, + ) -> tuple[int, ...]: + raw = tuple(values) + if not raw or any( + isinstance(value, bool) or not isinstance(value, int) or value < 2 + for value in raw + ): + raise ValueError(f"{name} must contain integers of at least two") + capped = tuple(dict.fromkeys(min(value, maximum) for value in raw)) + if not capped or any(value < 2 for value in capped): + raise ValueError(f"{name} has no rank-valid values") + return capped + + def pca_prefix_phase(self) -> ParameterPhasePlan: + """Vary only the bounded PCA prefix on the native representation.""" + audit_k = min(21, self.n_cells - 1) + return ParameterPhasePlan( + phase="pcaPrefix", + assay=self.assay, + variedParameter="dimensions", + candidates=[ + ParameterCandidate( + candidateId=f"{self.prefix}_pca_{dimensions}", + reductionMethod="pca", + dimensions=dimensions, + leidenResolution=1.0, + neighborsK=audit_k, + useHarmony=False, + ) + for dimensions in self.dimensions + ], + ) + + def batch_correction_phase( + self, + selected: ParameterCandidate, + ) -> ParameterPhasePlan: + """Compare matched native and Harmony representations when licensed.""" + self._require_selected_pca(selected) + methods = (False, True) if self.harmony_authorized else (False,) + return ParameterPhasePlan( + phase="batchCorrection", + assay=self.assay, + variedParameter="useHarmony", + basedOnCandidateId=selected.candidateId, + candidates=[ + selected.model_copy( + update={ + "candidateId": ( + f"{self.prefix}_correction_" + f"{'harmony' if use_harmony else 'native'}" + ), + "useHarmony": use_harmony, + } + ) + for use_harmony in methods + ], + ) + + def graph_phase(self, selected: ParameterCandidate) -> ParameterPhasePlan: + """Vary only graph neighbourhood size after representation selection.""" + self._require_selected_pca(selected) + return ParameterPhasePlan( + phase="graphK", + assay=self.assay, + variedParameter="neighborsK", + basedOnCandidateId=selected.candidateId, + candidates=[ + selected.model_copy( + update={ + "candidateId": f"{self.prefix}_graph_k{k}", + "neighborsK": k, + } + ) + for k in self.neighbors + ], + ) + + def clustering_phase( + self, + selected: ParameterCandidate, + ) -> ParameterPhasePlan: + """Vary only Leiden resolution on the selected graph configuration.""" + self._require_selected_pca(selected) + return ParameterPhasePlan( + phase="clusteringResolution", + assay=self.assay, + variedParameter="leidenResolution", + basedOnCandidateId=selected.candidateId, + candidates=[ + selected.model_copy( + update={ + "candidateId": ( + f"{self.prefix}_resolution_" + f"{str(resolution).replace('.', 'p')}" + ), + "leidenResolution": resolution, + } + ) + for resolution in self.resolutions + ], + ) + + @staticmethod + def _require_selected_pca(candidate: ParameterCandidate) -> None: + if not isinstance(candidate, ParameterCandidate): + raise TypeError("selected must be a ParameterCandidate") + if candidate.reductionMethod != "pca" or not candidate.candidateId: + raise ValueError("selected must be an exact RNA PCA candidate") + + +def validate_parameter_phase_selection( + plan: ParameterPhasePlan, + evaluations: Sequence[ParameterCandidateEvaluation], + selection: ParameterPhaseSelection, +) -> ParameterPhaseEvidence: + """Validate an ID-only selection against complete executor evidence.""" + return ParameterPhaseEvidence( + plan=plan, + evaluations=list(evaluations), + selection=selection, + ) + + +def execute_parameter_phase( + store: Any, + *, + normalized: Any, + plan: ParameterPhasePlan, + batch_columns: Sequence[str] = (), + preservation_columns: Sequence[str] = (), + experimental_handoff: ExperimentalTuningHandoff | None = None, + min_cluster_cells: int = 20, + identity_feature_limit: int = 64, +) -> tuple[ParameterCandidateEvaluation, ...]: + """Execute one phase through the existing deterministic candidate executor.""" + deps, candidate_ids = prepare_parameter_tuning_dependencies( + store, + normalized=normalized, + candidates=plan.candidates, + batch_columns=batch_columns, + preservation_columns=preservation_columns, + experimental_handoff=experimental_handoff, + max_candidates=len(plan.candidates), + max_refined_candidates=0, + min_cluster_cells=min_cluster_cells, + identity_feature_limit=identity_feature_limit, + ) + expected_ids = tuple(candidate.candidateId for candidate in plan.candidates) + if tuple(candidate_ids) != expected_ids: + raise ValueError("Prepared executor candidate inventory changed the phase plan") + return tuple(execute_parameter_candidate(deps, value) for value in candidate_ids) + + +def sequential_evidence_to_report( + evidence: SequentialAssayTuningEvidence, + *, + marker_assay: str | None = None, +) -> ParameterTuningReport: + """Adapt four selected phases to the report consumed by finalization.""" + if evidence.finalCandidateId is None: + final_phase = evidence.phases[-1] + cell_selection = next( + ( + evaluation.cellSelection + for evaluation in final_phase.evaluations + if evaluation.cellSelection is not None + ), + None, + ) + if cell_selection is None: + raise ValueError( + "Incomplete sequential evidence lacks an exact cell selection" + ) + selection_status = final_phase.selection.status + if selection_status == "selected" and evidence.pendingDecisionId is None: + raise ValueError( + "A selected intermediate phase must be followed before report adaptation" + ) + if evidence.pendingDecisionId is not None or selection_status == "needsInput": + report_status: Literal["needsInput", "abstained"] = "needsInput" + else: + report_status = "abstained" + needs_input = ( + ParameterTuningNeedsInput( + question=( + f"Resolve the registered {evidence.pendingDecisionId} decision." + ), + options=list(evidence.pendingOptionIds), + evidenceIds=list(evidence.pendingEvidenceIds), + ) + if report_status == "needsInput" + else None + ) + return ParameterTuningReport( + status=report_status, + fromAssay=evidence.assay, + cellSelection=cell_selection, + evaluations=list(final_phase.evaluations), + rationale=final_phase.selection.rationale, + evidenceIds=list(final_phase.selection.evidenceIds), + limitations=[ + "Sequential parameter adjudication did not select all four phases." + ], + stopReason=report_status, + needsInput=needs_input, + totalCandidates=sum(len(value.evaluations) for value in evidence.phases), + ) + final_phase = evidence.phases[-1] + selected = final_phase.selected_evaluation() + assert selected is not None + evaluations = [ + evaluation for phase in evidence.phases for evaluation in phase.evaluations + ] + assay_report = ParameterTuningReport( + status="done", + fromAssay=evidence.assay, + cellSelection=selected.cellSelection, + evaluations=evaluations, + recommendedCandidateId=selected.candidateId, + selectedArtifacts=dict(selected.artifacts), + confidence="medium", + rationale=" ".join(value.selection.rationale for value in evidence.phases), + evidenceIds=list( + dict.fromkeys( + evidence_id + for value in evidence.phases + for evidence_id in value.selection.evidenceIds + ) + ), + limitations=[], + stopReason="Four causal RNA parameter phases were selected.", + recommendedByAssay={evidence.assay: selected.candidateId}, + totalCandidates=len(evaluations), + ) + report = assay_report.model_copy( + update={"assayReports": {evidence.assay: assay_report}} + ) + return finalize_parameter_tuning_selection( + report, + marker_assay=marker_assay or evidence.assay, + native_assay=evidence.assay, + ) + + +__all__ = [ + "CorrectionNeedSelection", + "execute_parameter_phase", + "ParameterPhaseEvidence", + "ParameterPhasePlan", + "ParameterPhaseSelection", + "SequentialAssayTuningEvidence", + "SequentialRnaTuningPlanner", + "sequential_evidence_to_report", + "validate_parameter_phase_selection", +] diff --git a/scarf/agent/study_contract.py b/scarf/agent/study_contract.py new file mode 100644 index 00000000..c3e82af8 --- /dev/null +++ b/scarf/agent/study_contract.py @@ -0,0 +1,198 @@ +"""Grounded study contracts for decision-driven RNA analysis.""" + +from collections.abc import Iterable +from typing import Any, Literal + +from pydantic import Field, model_validator + +from .types import AgentDataModel + +type AuthorLabelPolicy = Literal["holdout", "preservation"] +type ProcessingGoal = Literal[ + "populationDiscovery", + "conditionPreservingDiscovery", +] +type CorrectionLicense = Literal[ + "safe", + "unsafeConfounded", + "indeterminate", + "notApplicable", +] + + +class StudyContract(AgentDataModel): + """Scientific authority and design constraints for one analysis.""" + + studyContext: str = "" + studyObjective: str = "" + processingGoal: ProcessingGoal = "populationDiscovery" + scientificQuestions: list[str] = Field(default_factory=list) + targetCohort: list[str] = Field(default_factory=list) + physicalCaptureColumn: str | None = None + independentUnitColumns: list[str] = Field(default_factory=list) + conditionColumns: list[str] = Field(default_factory=list) + technicalBatchColumns: list[str] = Field(default_factory=list) + protectedColumns: list[str] = Field(default_factory=list) + authorLabelPolicy: AuthorLabelPolicy = "holdout" + correctionLicense: CorrectionLicense = "notApplicable" + allowedClaims: list[str] = Field(default_factory=list) + unsupportedClaims: list[str] = Field(default_factory=list) + evidenceIds: list[str] = Field(default_factory=list) + limitations: list[str] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_contract(self) -> "StudyContract": + if not self.studyContext.strip(): + raise ValueError("studyContext must be non-empty") + if not self.studyObjective.strip(): + raise ValueError("studyObjective must be non-empty") + for field_name in ( + "independentUnitColumns", + "conditionColumns", + "technicalBatchColumns", + "protectedColumns", + "evidenceIds", + ): + values = getattr(self, field_name) + if len(values) != len(set(values)): + raise ValueError(f"{field_name} must not contain duplicates") + if self.physicalCaptureColumn in self.conditionColumns: + raise ValueError("A condition column cannot be the physical capture") + if set(self.technicalBatchColumns).intersection(self.conditionColumns): + raise ValueError("Technical batch columns cannot also be condition columns") + if self.correctionLicense == "safe" and not self.technicalBatchColumns: + raise ValueError("A safe correction license requires batch columns") + return self + + @classmethod + def get_blank(cls) -> "StudyContract": + return cls(studyContext="Study context", studyObjective="Study objective") + + @classmethod + def get_example(cls) -> "StudyContract": + return cls( + studyContext="Treated and control blood samples from multiple donors.", + studyObjective=( + "Discover stable populations while preserving treatment-associated " + "structure." + ), + processingGoal="conditionPreservingDiscovery", + scientificQuestions=[ + "Discover stable populations while preserving treatment-associated " + "structure." + ], + physicalCaptureColumn="sample", + independentUnitColumns=["donor"], + conditionColumns=["treatment"], + technicalBatchColumns=["batch"], + protectedColumns=["treatment", "donor"], + correctionLicense="safe", + allowedClaims=["Describe reproducible population structure."], + unsupportedClaims=[ + "This workflow does not test differential-expression hypotheses." + ], + evidenceIds=["column:batch", "column:donor", "column:treatment"], + ) + + +def _unique(values: Iterable[str | None]) -> list[str]: + return list(dict.fromkeys(value for value in values if value)) + + +def build_study_contract( + *, + study_context: str, + study_objective: str, + experimental_result: Any, + author_label_policy: AuthorLabelPolicy = "holdout", + physical_capture_column: str | None = None, +) -> StudyContract: + """Build a strict contract from one validated Experimental Context result.""" + + if getattr(experimental_result, "status", None) != "done": + raise ValueError("Experimental Context must be done before contract creation") + decision = experimental_result.decision + batch_plan = decision.batchCorrection + batch_safety = list(experimental_result.batchSafety) + conditions = list(decision.coefficientsOfInterest) + independent_units = _unique( + unit.independentUnit for unit in decision.unitsOfInference.values() + ) + protected = _unique([*conditions, *independent_units, *batch_plan.preserveColumns]) + assessed_batch_columns = _unique( + [ + *batch_plan.batchColumns, + *( + column + for assessment in batch_safety + for column in assessment.batchColumns + ), + ] + ) + correction_license: CorrectionLicense + if any(assessment.status == "unsafe" for assessment in batch_safety): + correction_license = "unsafeConfounded" + elif batch_plan.action == "evaluateHarmony": + correction_license = "safe" + elif batch_plan.action == "unsafe": + correction_license = "unsafeConfounded" + elif batch_plan.action == "needsInput": + correction_license = "indeterminate" + elif assessed_batch_columns and all( + assessment.status == "safe" for assessment in batch_safety + ): + correction_license = "safe" + else: + correction_license = "notApplicable" + processing_goal: ProcessingGoal = ( + "conditionPreservingDiscovery" if conditions else "populationDiscovery" + ) + evidence_ids = _unique( + [ + *decision.evidenceIds, + *batch_plan.evidenceIds, + *(item.evidenceId for item in experimental_result.batchSafety), + ] + ) + limitations = list(experimental_result.notes) + if physical_capture_column is None: + limitations.append( + "Physical capture identity is unresolved; capture-aware doublet removal " + "is not authorized." + ) + if author_label_policy == "preservation": + limitations.append( + "Author labels were available for preservation checks; this run is " + "ineligible for label-based benchmark scoring." + ) + return StudyContract( + studyContext=study_context, + studyObjective=study_objective, + processingGoal=processing_goal, + scientificQuestions=[study_objective], + physicalCaptureColumn=physical_capture_column, + independentUnitColumns=independent_units, + conditionColumns=conditions, + technicalBatchColumns=assessed_batch_columns, + protectedColumns=protected, + authorLabelPolicy=author_label_policy, + correctionLicense=correction_license, + allowedClaims=[ + "Describe population structure supported by the selected representation.", + "Compare preprocessing alternatives against explicit evidence.", + ], + unsupportedClaims=[ + "This workflow does not test differential-expression hypotheses.", + "Cells are not independent biological replicates.", + ], + evidenceIds=evidence_ids, + limitations=limitations, + ) + + +__all__ = [ + "AuthorLabelPolicy", + "ProcessingGoal", + "StudyContract", + "build_study_contract", +] diff --git a/scarf/agent/tuning_diagnostics.py b/scarf/agent/tuning_diagnostics.py new file mode 100644 index 00000000..8d002ee0 --- /dev/null +++ b/scarf/agent/tuning_diagnostics.py @@ -0,0 +1,1104 @@ +"""Deterministic representation and partition evidence for RNA decisions.""" + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import Any, cast + +import numpy as np +from sklearn.metrics import adjusted_rand_score, normalized_mutual_info_score + +from ..clustering.leiden import leiden_membership +from ..metadata.rows import read_metadata_rows_chunkwise +from ..storage.arrays import create_zarr_dataset +from ..storage.artifact_writer import ( + ArrayRequirement, + AttributeRequirement, + finish_artifact, + plan_artifact, + start_artifact, +) +from ..storage.artifacts import fingerprint_stored_arrays +from ..storage.feature_selection import read_feature_selection_indices +from ..storage.refs import ArtifactRef +from ..storage.selections import read_stored_selection_indices +from ..storage.types import as_zarr_array +from .parameter_tuning import ( + ArtifactRecord, + ParameterCandidateEvaluation, +) + +_PCA_DIAGNOSTIC_ARRAYS = ( + "component_variance", + "top_loading_feature_indices", + "top_loading_values", + "family_enrichment", + "covariate_association", + "adjacent_neighbor_overlap", +) + + +@dataclass(frozen=True, slots=True) +class AdvisoryDoubletScores: + """Advisory score artifacts and their exact cell-axis selections.""" + + scores: tuple[ArtifactRef, ...] + cell_selections: tuple[ArtifactRef, ...] + native_graph: ArtifactRef + native_clusters: ArtifactRef + limitations: tuple[str, ...] = () + + +def _artifact_ref( + evaluation: ParameterCandidateEvaluation, + name: str, +) -> ArtifactRef: + artifact = evaluation.artifacts.get(name) + if artifact is None: + raise ValueError( + f"Candidate {evaluation.candidateId!r} lacks {name!r} evidence" + ) + return ArtifactRef( + scope=artifact.scope, + assay=artifact.assay, + kind=artifact.kind, + artifact_id=artifact.artifactId, + ) + + +def _cluster_labels(store: Any, cluster_ref: ArtifactRef) -> np.ndarray: + group = store.load_artifact(cluster_ref) + values = as_zarr_array(group["values"], name="values") + labels = np.asarray(values[:]) + if labels.ndim != 1: + raise ValueError("Cluster evidence must be a one-dimensional label vector") + return labels + + +def _selected_feature_names( + store: Any, + feature_selection: ArtifactRef, +) -> tuple[np.ndarray, np.ndarray]: + if feature_selection.assay is None: + raise ValueError("PCA feature selection must belong to one assay") + indices = read_feature_selection_indices( + store.zw, + feature_selection.assay, + feature_selection, + ).astype(np.int64, copy=False) + names = np.asarray( + store.get_assay(feature_selection.assay).feats.fetch_all("names") + ).astype(str) + return indices, names[indices] + + +def _family_mask(names: np.ndarray, family: str) -> np.ndarray | None: + upper = np.char.upper(names.astype(str)) + if family == "mitochondrial": + return np.char.startswith(upper, "MT-") + if family == "ribosomal": + return np.asarray( + np.logical_or.reduce( + [ + np.char.startswith(upper, prefix) + for prefix in ("RPS", "RPL", "MRPS", "MRPL") + ] + ), + dtype=bool, + ) + if family == "histone": + return np.char.startswith(upper, "HIST") + if family == "hemoglobin": + return np.char.startswith(upper, "HB") + if family == "immuneReceptor": + return np.asarray( + np.logical_or.reduce( + [ + np.char.startswith(upper, prefix) + for prefix in ("IGH", "IGK", "IGL", "TRA", "TRB", "TRD", "TRG") + ] + ), + dtype=bool, + ) + return None + + +def _component_variance(values: Any) -> np.ndarray: + if len(values.shape) != 2: + raise ValueError("PCA coordinates must be a two-dimensional array") + n_rows, n_components = values.shape + if n_rows < 1 or n_components < 1: + raise ValueError("PCA coordinates cannot be empty") + totals = np.zeros(n_components, dtype=np.float64) + totals_squared = np.zeros(n_components, dtype=np.float64) + for start in range(0, n_rows, 65_536): + block = np.asarray(values[start : start + 65_536], dtype=np.float64) + totals += block.sum(axis=0) + totals_squared += np.square(block).sum(axis=0) + variance = totals_squared / n_rows - np.square(totals / n_rows) + return np.asarray(np.maximum(variance, 0.0), dtype=np.float64) + + +def _top_loadings( + loadings: np.ndarray, + selected_indices: np.ndarray, + family_masks: Mapping[str, np.ndarray], +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + if loadings.ndim != 2 or loadings.shape[0] != len(selected_indices): + raise ValueError("PCA loadings do not align with selected features") + top_n = min(20, loadings.shape[0]) + top_indices = np.zeros((loadings.shape[1], top_n), dtype=np.int64) + top_values = np.zeros((loadings.shape[1], top_n), dtype=np.float64) + enrichment = np.zeros( + (len(family_masks), loadings.shape[1]), + dtype=np.float64, + ) + for component in range(loadings.shape[1]): + absolute = np.abs(loadings[:, component]) + order = np.lexsort((np.arange(len(absolute)), -absolute))[:top_n] + top_indices[component] = selected_indices[order] + top_values[component] = absolute[order] + for family_index, mask in enumerate(family_masks.values()): + background = float(mask.mean()) + enrichment[family_index, component] = ( + float(mask[order].mean()) / background if background > 0 else 0.0 + ) + return top_indices, top_values, enrichment + + +def _aligned_metadata_values( + store: Any, + cell_selection: ArtifactRef, + column: str, +) -> np.ndarray: + indices = read_stored_selection_indices( + store.zw, + cell_selection, + kind="cell_selection", + scope="datastore", + assay=None, + table_path="cellData", + ).astype(np.int64, copy=False) + values = np.asarray(read_metadata_rows_chunkwise(store.cells, column, indices)) + if values.shape != (len(indices),): + raise ValueError(f"Metadata column {column!r} does not align with PCA") + return values + + +def _numeric_association(coordinates: Any, values: np.ndarray) -> np.ndarray: + numeric = np.asarray(values, dtype=np.float64) + if not np.isfinite(numeric).all(): + raise ValueError("Numeric PCA covariates must be finite") + n_rows, n_components = coordinates.shape + total_x = np.zeros(n_components, dtype=np.float64) + total_x2 = np.zeros(n_components, dtype=np.float64) + total_xy = np.zeros(n_components, dtype=np.float64) + total_y = float(numeric.sum()) + total_y2 = float(np.square(numeric).sum()) + for start in range(0, n_rows, 65_536): + block = np.asarray(coordinates[start : start + 65_536], dtype=np.float64) + y = numeric[start : start + len(block)] + total_x += block.sum(axis=0) + total_x2 += np.square(block).sum(axis=0) + total_xy += (block * y[:, None]).sum(axis=0) + numerator = n_rows * total_xy - total_x * total_y + denominator = np.sqrt( + np.maximum(n_rows * total_x2 - np.square(total_x), 0.0) + * max(n_rows * total_y2 - total_y * total_y, 0.0) + ) + return np.asarray( + np.divide( + np.abs(numerator), + denominator, + out=np.zeros_like(numerator), + where=denominator > 0, + ), + dtype=np.float64, + ) + + +def _categorical_association(coordinates: Any, values: np.ndarray) -> np.ndarray: + labels = values.astype(str) + _levels, codes = np.unique(labels, return_inverse=True) + n_rows, n_components = coordinates.shape + totals = np.zeros(n_components, dtype=np.float64) + totals_squared = np.zeros(n_components, dtype=np.float64) + group_sums = np.zeros((int(codes.max()) + 1, n_components), dtype=np.float64) + group_counts = np.bincount(codes, minlength=group_sums.shape[0]).astype(np.float64) + for start in range(0, n_rows, 65_536): + block = np.asarray(coordinates[start : start + 65_536], dtype=np.float64) + block_codes = codes[start : start + len(block)] + totals += block.sum(axis=0) + totals_squared += np.square(block).sum(axis=0) + np.add.at(group_sums, block_codes, block) + grand_mean = totals / n_rows + group_means = np.divide( + group_sums, + group_counts[:, None], + out=np.zeros_like(group_sums), + where=group_counts[:, None] > 0, + ) + between = ( + group_counts[:, None] * np.square(group_means - grand_mean[None, :]) + ).sum(axis=0) + total = totals_squared - n_rows * np.square(grand_mean) + return np.asarray( + np.sqrt( + np.divide( + between, + total, + out=np.zeros_like(between), + where=total > 0, + ) + ), + dtype=np.float64, + ) + + +def _covariate_associations( + store: Any, + cell_selection: ArtifactRef, + coordinates: Any, + columns: Sequence[str], +) -> np.ndarray: + associations = np.zeros((len(columns), coordinates.shape[1]), dtype=np.float64) + for index, column in enumerate(columns): + values = _aligned_metadata_values(store, cell_selection, column) + if values.dtype.kind in {"i", "u", "f"} and len(np.unique(values)) > 10: + associations[index] = _numeric_association(coordinates, values) + else: + associations[index] = _categorical_association(coordinates, values) + return associations + + +def _neighbor_overlap(store: Any, left: ArtifactRef, right: ArtifactRef) -> float: + left_values = as_zarr_array(store.load_artifact(left)["indices"], name="indices") + right_values = as_zarr_array(store.load_artifact(right)["indices"], name="indices") + if left_values.shape != right_values.shape or len(left_values.shape) != 2: + raise ValueError("Adjacent PCA neighbor artifacts must align exactly") + total = 0.0 + rows = left_values.shape[0] + for start in range(0, rows, 4096): + left_block = np.asarray(left_values[start : start + 4096]) + right_block = np.asarray(right_values[start : start + 4096]) + intersection = ( + (left_block[:, :, None] == right_block[:, None, :]).any(axis=2).sum(axis=1) + ) + union = left_block.shape[1] + right_block.shape[1] - intersection + total += float(np.divide(intersection, union).sum()) + return total / rows + + +def _write_pca_diagnostic( + store: Any, + evaluation: ParameterCandidateEvaluation, + *, + feature_selection: ArtifactRef, + selected_indices: np.ndarray, + family_masks: Mapping[str, np.ndarray], + covariate_columns: Sequence[str], + covariate_roles: Sequence[str], + adjacent_overlap: float | None, +) -> tuple[ArtifactRef, np.ndarray, np.ndarray, np.ndarray]: + reduction = _artifact_ref(evaluation, "pca") + neighbors = _artifact_ref(evaluation, "neighbors") + reduction_group = store.load_artifact(reduction) + coordinates = as_zarr_array(reduction_group["data"], name="data") + loadings = np.asarray( + as_zarr_array(reduction_group["loadings"], name="loadings")[:], + dtype=np.float64, + ) + component_variance = _component_variance(coordinates) + top_indices, top_values, family_enrichment = _top_loadings( + loadings, + selected_indices, + family_masks, + ) + associations = ( + _covariate_associations( + store, + ArtifactRef( + scope=evaluation.cellSelection.scope, + assay=evaluation.cellSelection.assay, + kind=evaluation.cellSelection.kind, + artifact_id=evaluation.cellSelection.artifactId, + ), + coordinates, + covariate_columns, + ) + if evaluation.cellSelection is not None + else np.zeros((len(covariate_columns), coordinates.shape[1]), dtype=np.float64) + ) + overlap_array = np.asarray( + [np.nan if adjacent_overlap is None else adjacent_overlap], + dtype=np.float64, + ) + payload = { + "component_variance": component_variance, + "top_loading_feature_indices": top_indices, + "top_loading_values": top_values, + "family_enrichment": family_enrichment, + "covariate_association": associations, + "adjacent_neighbor_overlap": overlap_array, + } + planned = plan_artifact( + store.zw, + scope="assay", + assay=reduction.assay, + kind="feature_summary", + operation="diagnose_pca_representation", + parameters={ + "family_names": list(family_masks), + "covariate_columns": list(covariate_columns), + "covariate_roles": list(covariate_roles), + "top_loading_count": top_indices.shape[1], + "adjacent_neighbor_overlap": adjacent_overlap, + }, + inputs={ + "reduction": reduction, + "neighbors": neighbors, + "feature_selection": feature_selection, + }, + execution_options={}, + invalidate_cache=False, + required_arrays=tuple( + ArrayRequirement(name, shape=values.shape, dtype=values.dtype) + for name, values in payload.items() + ), + required_attributes=( + AttributeRequirement("family_names", expected_types=(list,)), + AttributeRequirement("covariate_columns", expected_types=(list,)), + AttributeRequirement("covariate_roles", expected_types=(list,)), + AttributeRequirement("payload_fingerprint", expected_types=(str,)), + ), + ) + if not planned.reused: + group = start_artifact(store.zw, planned) + for name, values in payload.items(): + chunks = tuple(max(1, min(size, 4096)) for size in values.shape) + array = create_zarr_dataset( + group, + name, + chunks, + values.dtype, + values.shape, + ) + array[:] = values + group.attrs["family_names"] = list(family_masks) + group.attrs["covariate_columns"] = list(covariate_columns) + group.attrs["covariate_roles"] = list(covariate_roles) + group.attrs["payload_fingerprint"] = fingerprint_stored_arrays( + group, + _PCA_DIAGNOSTIC_ARRAYS, + ) + finish_artifact(group, planned) + return planned.ref, component_variance, family_enrichment, associations + + +def augment_pca_evaluations( + store: Any, + evaluations: Sequence[ParameterCandidateEvaluation], + *, + feature_selection: ArtifactRef, + nominated_families: Sequence[str], + protected_families: Sequence[str], + technical_columns: Sequence[str], + protected_columns: Sequence[str], + qc_columns: Sequence[str], +) -> tuple[ParameterCandidateEvaluation, ...]: + """Attach persisted PCA loading, variance, topology, and covariate evidence.""" + selected_indices, selected_names = _selected_feature_names( + store, + feature_selection, + ) + family_masks = { + family: mask + for family in dict.fromkeys([*nominated_families, *protected_families]) + for mask in [_family_mask(selected_names, family)] + if mask is not None and bool(mask.any()) + } + columns: list[str] = [] + roles: list[str] = [] + for role, values in ( + ("technical", technical_columns), + ("protected", protected_columns), + ("qc", qc_columns), + ): + for column in values: + if column in store.cells.columns and column not in columns: + columns.append(column) + roles.append(role) + completed = [ + evaluation + for evaluation in evaluations + if evaluation.status == "done" + and evaluation.eligible + and evaluation.cellSelection is not None + and "pca" in evaluation.artifacts + and "neighbors" in evaluation.artifacts + ] + previous_by_id: dict[str, float | None] = {} + previous: ParameterCandidateEvaluation | None = None + for evaluation in sorted( + completed, + key=lambda value: value.parameters.dimensions, + ): + overlap = ( + _neighbor_overlap( + store, + _artifact_ref(previous, "neighbors"), + _artifact_ref(evaluation, "neighbors"), + ) + if previous is not None + else None + ) + previous_by_id[evaluation.candidateId] = overlap + previous = evaluation + + augmented: list[ParameterCandidateEvaluation] = [] + for evaluation in evaluations: + if evaluation.candidateId not in previous_by_id: + augmented.append(evaluation) + continue + diagnostic, variance, family_enrichment, associations = _write_pca_diagnostic( + store, + evaluation, + feature_selection=feature_selection, + selected_indices=selected_indices, + family_masks=family_masks, + covariate_columns=columns, + covariate_roles=roles, + adjacent_overlap=previous_by_id[evaluation.candidateId], + ) + family_maxima = { + family: float(family_enrichment[index].max(initial=0.0)) + for index, family in enumerate(family_masks) + } + role_associations = { + role: { + column: float(associations[index].max(initial=0.0)) + for index, (column, column_role) in enumerate( + zip(columns, roles, strict=True) + ) + if column_role == role + } + for role in ("technical", "protected", "qc") + } + metrics = evaluation.metrics.model_copy( + update={ + "componentVariance": variance.tolist(), + "loadingFamilyEnrichment": family_maxima, + "technicalPcaAssociation": role_associations["technical"], + "protectedPcaAssociation": role_associations["protected"], + "qcPcaAssociation": role_associations["qc"], + "neighborPrefixOverlap": previous_by_id[evaluation.candidateId], + } + ) + artifact = ArtifactRecord.from_ref(diagnostic) + augmented.append( + evaluation.model_copy( + update={ + "metrics": metrics, + "artifacts": { + **evaluation.artifacts, + "representationDiagnostic": artifact, + }, + "evidenceIds": list( + dict.fromkeys( + [ + *evaluation.evidenceIds, + f"candidate:{evaluation.candidateId}:pcaVariance", + f"candidate:{evaluation.candidateId}:pcaLoadings", + f"candidate:{evaluation.candidateId}:pcaCovariates", + *( + [ + f"candidate:{evaluation.candidateId}:neighborPrefixOverlap" + ] + if previous_by_id[evaluation.candidateId] + is not None + else [] + ), + ] + ) + ), + } + ) + ) + return tuple(augmented) + + +def _select_capture_cells( + store: Any, + parent: ArtifactRef, + *, + column: str, + value: str, + active_indices: np.ndarray, + active_values: np.ndarray, +) -> tuple[ArtifactRef, int]: + if active_indices.shape != active_values.shape: + raise ValueError("Capture values must align with the selected cells") + labels = active_values.astype(str) + selected = labels == value + expected = np.zeros(store.cells.N, dtype=bool) + expected[active_indices] = selected + reference = store.filter_cells( + [column], + [value], + [value], + cell_selection=parent, + keep_bounds=True, + invalidate_cache=False, + ) + stored_array = cast(Any, store.load_artifact(reference)["values"]) + stored = np.asarray(stored_array[:], dtype=bool) + if not np.array_equal(stored, expected): + raise RuntimeError("Capture selection does not match its validated evidence") + return reference, int(stored.sum()) + + +def resolve_native_doublet_inputs( + store: Any, + selected: ParameterCandidateEvaluation, + evaluations: Sequence[ParameterCandidateEvaluation], +) -> tuple[ArtifactRef, ArtifactRef]: + """Return or reconstruct the parameter-matched uncorrected graph and clusters.""" + parameters = selected.parameters + exact_native = next( + ( + evaluation + for evaluation in evaluations + if evaluation.status == "done" + and evaluation.eligible + and not evaluation.parameters.useHarmony + and evaluation.parameters.dimensions == parameters.dimensions + and evaluation.parameters.neighborsK == parameters.neighborsK + and evaluation.parameters.leidenResolution == parameters.leidenResolution + and "clusters" in evaluation.artifacts + and "connectivityMap" in evaluation.artifacts + ), + None, + ) + if exact_native is not None: + return ( + _artifact_ref(exact_native, "clusters"), + _artifact_ref(exact_native, "connectivityMap"), + ) + if not selected.parameters.useHarmony: + return ( + _artifact_ref(selected, "clusters"), + _artifact_ref(selected, "connectivityMap"), + ) + reduction = _artifact_ref(selected, "pca") + ann = store.build_ann_index( + reduction, + ann_metric="l2", + ann_parallel=False, + rand_state=4444, + invalidate_cache=False, + ) + neighbors = store.query_neighbors( + ann, + coordinates=reduction, + k=parameters.neighborsK, + invalidate_cache=False, + ) + graph = store.build_connectivity_map( + neighbors, + local_connectivity=1.0, + bandwidth=1.5, + invalidate_cache=False, + ) + clusters = store.run_leiden_clustering( + graph, + resolution=parameters.leidenResolution, + backend="igraph", + symmetric_graph=False, + graph_upper_only=False, + random_seed=4444, + invalidate_cache=False, + ) + return clusters, graph + + +def score_advisory_doublets( + store: Any, + selected: ParameterCandidateEvaluation, + evaluations: Sequence[ParameterCandidateEvaluation], + *, + assay: str, + feature_selection: ArtifactRef, + capture_column: str | None, +) -> AdvisoryDoubletScores: + """Score doublet evidence without making singlet or removal decisions.""" + if selected.cellSelection is None: + raise ValueError("Doublet scoring requires an exact selected cell axis") + parent_selection = ArtifactRef( + scope=selected.cellSelection.scope, + assay=selected.cellSelection.assay, + kind=selected.cellSelection.kind, + artifact_id=selected.cellSelection.artifactId, + ) + native_clusters, native_graph = resolve_native_doublet_inputs( + store, + selected, + evaluations, + ) + limitations: list[str] = [] + if capture_column is None or capture_column not in store.cells.columns: + score = store.run_doublet_detection( + native_clusters, + native_graph, + from_assay=assay, + invalidate_cache=False, + ) + limitations.append( + "Physical capture identity was unavailable, so advisory doublet " + "scores were computed across the selected dataset." + ) + return AdvisoryDoubletScores( + scores=(score,), + cell_selections=(parent_selection,), + native_graph=native_graph, + native_clusters=native_clusters, + limitations=tuple(limitations), + ) + + active_indices = read_stored_selection_indices( + store.zw, + parent_selection, + kind="cell_selection", + scope="datastore", + assay=None, + table_path="cellData", + ).astype(np.int64, copy=False) + capture_values = read_metadata_rows_chunkwise( + store.cells, + capture_column, + active_indices, + ) + capture_groups = sorted(set(capture_values.astype(str).tolist())) + if len(capture_groups) == 1: + score = store.run_doublet_detection( + native_clusters, + native_graph, + from_assay=assay, + invalidate_cache=False, + ) + return AdvisoryDoubletScores( + scores=(score,), + cell_selections=(parent_selection,), + native_graph=native_graph, + native_clusters=native_clusters, + ) + + n_features = len(read_feature_selection_indices(store.zw, assay, feature_selection)) + scores: list[ArtifactRef] = [] + selections: list[ArtifactRef] = [] + for capture_value in capture_groups: + capture_selection, capture_cells = _select_capture_cells( + store, + parent_selection, + column=capture_column, + value=capture_value, + active_indices=active_indices, + active_values=capture_values, + ) + dimensions = min( + selected.parameters.dimensions, + capture_cells - 1, + n_features - 1, + ) + neighbors_k = min(selected.parameters.neighborsK, capture_cells - 1) + if dimensions < 2 or neighbors_k < 2: + limitations.append( + "Advisory doublet scores were not computed for capture " + f"{capture_value!r} because it contains only {capture_cells} " + "selected cells." + ) + continue + normalized = store.run_normalization( + capture_selection, + features=feature_selection, + log_transform=True, + renormalize_subset=True, + invalidate_cache=False, + ) + reduction = store.run_pca( + normalized, + dims=dimensions, + feat_scaling=True, + invalidate_cache=False, + ) + ann = store.build_ann_index( + reduction, + ann_metric="l2", + ann_parallel=False, + rand_state=4444, + invalidate_cache=False, + ) + neighbors = store.query_neighbors( + ann, + coordinates=reduction, + k=neighbors_k, + invalidate_cache=False, + ) + graph = store.build_connectivity_map( + neighbors, + local_connectivity=1.0, + bandwidth=1.5, + invalidate_cache=False, + ) + clusters = store.run_leiden_clustering( + graph, + resolution=selected.parameters.leidenResolution, + backend="igraph", + symmetric_graph=False, + graph_upper_only=False, + random_seed=4444, + invalidate_cache=False, + ) + scores.append( + store.run_doublet_detection( + clusters, + graph, + from_assay=assay, + invalidate_cache=False, + ) + ) + selections.append(capture_selection) + if not scores: + raise ValueError("No physical capture had enough cells for doublet scoring") + return AdvisoryDoubletScores( + scores=tuple(scores), + cell_selections=tuple(selections), + native_graph=native_graph, + native_clusters=native_clusters, + limitations=tuple(limitations), + ) + + +def _doublet_concentration( + store: Any, + labels: np.ndarray, + cell_selection: ArtifactRef, + evidence: AdvisoryDoubletScores, +) -> float | None: + parent_indices = read_stored_selection_indices( + store.zw, + cell_selection, + kind="cell_selection", + scope="datastore", + assay=None, + table_path="cellData", + ).astype(np.int64, copy=False) + if labels.shape != parent_indices.shape: + raise ValueError("Cluster labels do not align with advisory doublet evidence") + positions = {int(value): index for index, value in enumerate(parent_indices)} + high_score = np.zeros(len(parent_indices), dtype=bool) + covered = np.zeros(len(parent_indices), dtype=bool) + for score_ref, selection_ref in zip( + evidence.scores, + evidence.cell_selections, + strict=True, + ): + score_values = np.asarray( + as_zarr_array(store.load_artifact(score_ref)["values"], name="values")[:], + dtype=np.float64, + ) + selection_indices = read_stored_selection_indices( + store.zw, + selection_ref, + kind="cell_selection", + scope="datastore", + assay=None, + table_path="cellData", + ).astype(np.int64, copy=False) + if score_values.shape != selection_indices.shape: + raise ValueError("Doublet scores do not align with their cell selection") + local_positions = np.asarray( + [positions[int(value)] for value in selection_indices], + dtype=np.int64, + ) + threshold = float(np.quantile(score_values, 0.9)) + covered[local_positions] = True + high_score[local_positions] = score_values >= threshold + if not covered.any() or not high_score[covered].any(): + return None + baseline = float(high_score[covered].mean()) + enrichments = [ + float(high_score[covered & (labels == cluster)].mean()) / baseline + for cluster in np.unique(labels[covered]) + if bool((covered & (labels == cluster)).any()) + ] + return max(enrichments, default=0.0) + + +def _aligned_metadata( + store: Any, + cell_selection: ArtifactRef, + column: str, +) -> np.ndarray: + indices = read_stored_selection_indices( + store.zw, + cell_selection, + kind="cell_selection", + scope="datastore", + assay=None, + table_path="cellData", + ).astype(np.int64, copy=False) + values = np.asarray(read_metadata_rows_chunkwise(store.cells, column, indices)) + if values.shape != (len(indices),): + raise ValueError(f"Metadata column {column!r} does not align with clusters") + return values.astype(str) + + +def _cross_unit_support(labels: np.ndarray, units: np.ndarray) -> float | None: + available_units = np.unique(units) + if len(available_units) < 2: + return None + supported = [ + len(np.unique(units[labels == cluster])) >= 2 for cluster in np.unique(labels) + ] + return float(np.mean(supported)) if supported else None + + +def _subsample_partition_stability( + graph: Any, + labels: np.ndarray, + resolution: float, +) -> float: + if graph.shape != (len(labels), len(labels)): + raise ValueError("Candidate graph does not align with cluster labels") + selected = np.arange(len(labels)) % 5 != 0 + if int(selected.sum()) < 3: + selected = np.ones(len(labels), dtype=bool) + subsample_labels = leiden_membership( + graph[selected][:, selected], + resolution, + 4444, + backend="igraph", + ) + return float(adjusted_rand_score(labels[selected], subsample_labels)) + + +def augment_cluster_evaluations( + store: Any, + evaluations: Sequence[ParameterCandidateEvaluation], + *, + marker_assay: str, + marker_features: ArtifactRef, + independent_unit_columns: Sequence[str], + technical_columns: Sequence[str], + nominated_families: Sequence[str] = (), + protected_families: Sequence[str] = (), + doublet_evidence: AdvisoryDoubletScores | None = None, +) -> tuple[ParameterCandidateEvaluation, ...]: + """Add seed, marker, unit-support, and technical-association evidence.""" + _marker_indices, marker_feature_names = _selected_feature_names( + store, + marker_features, + ) + family_masks = { + family: mask + for family in dict.fromkeys([*nominated_families, *protected_families]) + for mask in [_family_mask(marker_feature_names, family)] + if mask is not None and bool(mask.any()) + } + augmented: list[ParameterCandidateEvaluation] = [] + for evaluation in evaluations: + if evaluation.status != "done" or evaluation.cellSelection is None: + augmented.append(evaluation) + continue + graph_ref = _artifact_ref(evaluation, "connectivityMap") + clusters_ref = _artifact_ref(evaluation, "clusters") + labels = _cluster_labels(store, clusters_ref) + alternative_ref = store.run_leiden_clustering( + graph_ref, + resolution=evaluation.parameters.leidenResolution, + backend="igraph", + symmetric_graph=False, + graph_upper_only=False, + random_seed=9173, + invalidate_cache=False, + ) + alternative = _cluster_labels(store, alternative_ref) + if alternative.shape != labels.shape: + raise ValueError("Alternate-seed clusters do not align with the candidate") + seed_stability = float(adjusted_rand_score(labels, alternative)) + graph = store.load_graph(graph_ref) + subsample_stability = _subsample_partition_stability( + graph, + labels, + evaluation.parameters.leidenResolution, + ) + + marker_ref = store.run_marker_search( + clusters_ref, + from_assay=marker_assay, + features=marker_features, + invalidate_cache=False, + ) + markers = store.get_markers( + marker_ref, + min_score=0.25, + min_frac_exp=0.2, + ) + marker_groups = ( + set(markers["group_id"].astype(str)) + if "group_id" in markers.columns + else set() + ) + cluster_count = len(np.unique(labels)) + marker_coherence = ( + float(len(marker_groups) / cluster_count) if cluster_count else 0.0 + ) + marker_names = ( + markers["feature_name"].astype(str).to_numpy() + if "feature_name" in markers.columns + else np.asarray([], dtype=str) + ) + marker_family_enrichment: dict[str, float] = {} + protected_marker_families: list[str] = [] + for family, mask in family_masks.items(): + marker_mask = _family_mask(marker_names, family) + marker_fraction = ( + float(marker_mask.mean()) + if marker_mask is not None and marker_mask.size + else 0.0 + ) + background = float(mask.mean()) + enrichment = marker_fraction / background if background > 0 else 0.0 + if family in nominated_families: + marker_family_enrichment[family] = enrichment + if family in protected_families and marker_fraction > 0: + protected_marker_families.append(family) + + selection_ref = ArtifactRef( + scope=evaluation.cellSelection.scope, + assay=evaluation.cellSelection.assay, + kind=evaluation.cellSelection.kind, + artifact_id=evaluation.cellSelection.artifactId, + ) + doublet_concentration = ( + _doublet_concentration( + store, + labels, + selection_ref, + doublet_evidence, + ) + if doublet_evidence is not None + else None + ) + unit_scores = [ + score + for column in independent_unit_columns + if column in store.cells.columns + for score in [ + _cross_unit_support( + labels, + _aligned_metadata(store, selection_ref, column), + ) + ] + if score is not None + ] + cross_unit_support = min(unit_scores) if unit_scores else None + technical_association = { + column: float( + normalized_mutual_info_score( + labels, + _aligned_metadata(store, selection_ref, column), + ) + ) + for column in technical_columns + if column in store.cells.columns + } + + metrics = evaluation.metrics.model_copy( + update={ + "seedStability": seed_stability, + "subsampleStability": subsample_stability, + "markerCoherence": marker_coherence, + "crossUnitSupport": cross_unit_support, + "technicalAssociation": technical_association, + "markerFamilyEnrichment": marker_family_enrichment, + "protectedMarkerFamilies": protected_marker_families, + "doubletHighScoreConcentration": doublet_concentration, + } + ) + evidence_ids = [ + *evaluation.evidenceIds, + f"candidate:{evaluation.candidateId}:seedStability", + f"candidate:{evaluation.candidateId}:subsampleStability", + f"candidate:{evaluation.candidateId}:markerCoherence", + f"candidate:{evaluation.candidateId}:markerFamilies", + *( + [f"candidate:{evaluation.candidateId}:crossUnitSupport"] + if cross_unit_support is not None + else [] + ), + *[ + f"candidate:{evaluation.candidateId}:technicalAssociation:{column}" + for column in technical_association + ], + *( + [f"candidate:{evaluation.candidateId}:doubletConcentration"] + if doublet_concentration is not None + else [] + ), + ] + artifacts = { + **evaluation.artifacts, + "stabilityClusters": ArtifactRecord.from_ref(alternative_ref), + "markerTable": ArtifactRecord.from_ref(marker_ref), + **( + { + f"doubletScore:{index}": ArtifactRecord.from_ref(score) + for index, score in enumerate(doublet_evidence.scores) + } + if doublet_evidence is not None + else {} + ), + **( + { + "doubletNativeGraph": ArtifactRecord.from_ref( + doublet_evidence.native_graph + ), + "doubletNativeClusters": ArtifactRecord.from_ref( + doublet_evidence.native_clusters + ), + } + if doublet_evidence is not None + else {} + ), + } + augmented.append( + evaluation.model_copy( + update={ + "metrics": metrics, + "evidenceIds": list(dict.fromkeys(evidence_ids)), + "artifacts": artifacts, + "warnings": list( + dict.fromkeys( + [ + *evaluation.warnings, + *( + doublet_evidence.limitations + if doublet_evidence is not None + else () + ), + ] + ) + ), + } + ) + ) + return tuple(augmented) + + +__all__ = [ + "AdvisoryDoubletScores", + "augment_cluster_evaluations", + "augment_pca_evaluations", + "resolve_native_doublet_inputs", + "score_advisory_doublets", +] diff --git a/scarf/agent/types.py b/scarf/agent/types.py index 824fbd2c..c380c2a7 100644 --- a/scarf/agent/types.py +++ b/scarf/agent/types.py @@ -10,7 +10,7 @@ raise ImportError(AGENT_INSTALL_HINT) from exc -type StageStatus = Literal["done", "needsInput", "failed"] +type StageStatus = Literal["done", "needsInput", "abstained", "failed"] type BatchCorrectionAction = Literal[ "skip", "evaluateHarmony", From e3ad6a5f58f1ae9ee640a3b7117f2e465f95e8c6 Mon Sep 17 00:00:00 2001 From: gautam8387 Date: Sat, 5 Sep 2026 22:17:11 +0200 Subject: [PATCH 06/21] fix agents; testing; automated run --- scarf/agent/characterize_covariates.py | 460 +++- scarf/agent/characterize_features.py | 80 + scarf/agent/config/agent_exec.py | 176 +- scarf/agent/data_enrichment.py | 181 ++ scarf/agent/experimental_context.py | 2020 ++++++++++++-- scarf/agent/hvg_diagnostics.py | 143 +- scarf/agent/hypothesis_testing.py | 360 +++ scarf/agent/ingest/manifest.py | 51 + scarf/agent/orchestrator/context.py | 40 +- scarf/agent/orchestrator/decisions.py | 400 ++- scarf/agent/orchestrator/finalization.py | 327 +++ scarf/agent/orchestrator/journal.py | 44 +- scarf/agent/orchestrator/main.py | 92 + scarf/agent/orchestrator/models.py | 22 +- scarf/agent/orchestrator/preprocessing.py | 779 +++++- scarf/agent/orchestrator/tuning.py | 3003 ++++++++++++++++++++- scarf/agent/parameter_tuning.py | 821 +++++- scarf/agent/qc_execution.py | 370 ++- scarf/agent/qc_profiles.py | 336 ++- scarf/agent/report.py | 1705 +++++++++++- scarf/agent/rna_decisions.py | 49 +- scarf/agent/sequential_tuning.py | 415 ++- scarf/agent/tuning_diagnostics.py | 674 ++++- 23 files changed, 11791 insertions(+), 757 deletions(-) create mode 100644 scarf/agent/hypothesis_testing.py diff --git a/scarf/agent/characterize_covariates.py b/scarf/agent/characterize_covariates.py index e8a85366..fcf5a7e5 100644 --- a/scarf/agent/characterize_covariates.py +++ b/scarf/agent/characterize_covariates.py @@ -102,6 +102,12 @@ class CovariateCharacterization(AgentDataModel): coefficients: list[dict[str, Any]] = Field(default_factory=list) technicalNesting: list[dict[str, Any]] = Field(default_factory=list) confounding: list[dict[str, Any]] = Field(default_factory=list) + unitLevelCounts: list[dict[str, Any]] = Field(default_factory=list) + groupImbalance: list[dict[str, Any]] = Field(default_factory=list) + missingness: list[dict[str, Any]] = Field(default_factory=list) + designStructures: list[dict[str, Any]] = Field(default_factory=list) + pairedCoverage: list[dict[str, Any]] = Field(default_factory=list) + coefficientEstimability: list[dict[str, Any]] = Field(default_factory=list) @classmethod def get_blank(cls) -> "CovariateCharacterization": @@ -127,6 +133,42 @@ class _ColumnProfile: summary: str digest: PartitionDigest artifact: ArtifactRef | None = None + levelCounts: tuple[dict[str, Any], ...] = () + levelCountsTruncated: bool = False + + +_MAX_LEVEL_COUNT_ITEMS = 32 +_MAX_STRUCTURE_ITEMS = 32 + + +def _json_scalar(value: Any) -> str | int | float | bool | None: + if isinstance(value, np.generic): + value = value.item() + missing = pd.isna(value) + if isinstance(missing, bool | np.bool_) and bool(missing): + return None + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + if isinstance(value, str | int | float | bool): + return value + return str(value) + + +def _bounded_level_counts( + values: Sequence[Any] | np.ndarray | pd.Series, + *, + limit: int = _MAX_LEVEL_COUNT_ITEMS, +) -> tuple[tuple[dict[str, Any], ...], bool]: + series = pd.Series(values) + counts = series.value_counts(dropna=False, sort=False) + output = tuple( + { + "value": _json_scalar(value), + "count": int(count), + } + for value, count in counts.iloc[:limit].items() + ) + return output, len(counts) > limit class _SelectionBoundCells: @@ -387,12 +429,18 @@ def _profile_column( resolved_kind = kind or _infer_kind(values) summary = _summarize(values, resolved_kind) digest = column_partition_digest(store.cells, name, cell_key=cell_key) + level_counts: tuple[dict[str, Any], ...] = () + level_counts_truncated = False + if resolved_kind == "categorical": + level_counts, level_counts_truncated = _bounded_level_counts(values) artifact_source = getattr(store.cells, "artifact_source", lambda _name: None)(name) return _ColumnProfile( kind=resolved_kind, summary=summary, digest=digest, artifact=artifact_source, + levelCounts=level_counts, + levelCountsTruncated=level_counts_truncated, ) @@ -773,6 +821,194 @@ def _independent_is_coarser( return nesting in {"leftInRight", "equivalent"} +def _valid_value_mask(values: Sequence[Any] | np.ndarray | pd.Series) -> np.ndarray: + missing = pd.isna(np.asarray(values, dtype=object)) + return np.asarray(~missing, dtype=bool) + + +def _ordered_group_values(values: pd.Series) -> list[str | int | float | bool]: + raw = values.to_numpy(dtype=object, copy=False) + valid = _valid_value_mask(raw) + return [ + cast(str | int | float | bool, _json_scalar(value)) + for value in pd.unique(raw[valid]) + ] + + +def _group_counts( + values: pd.Series, +) -> tuple[list[dict[str, Any]], bool]: + raw = values.to_numpy(dtype=object, copy=False) + valid = _valid_value_mask(raw) + counts, truncated = _bounded_level_counts(raw[valid]) + return list(counts), truncated + + +def _imbalance_from_counts(counts: Sequence[dict[str, Any]]) -> dict[str, Any]: + positive = [int(item["count"]) for item in counts if int(item["count"]) > 0] + if not positive: + return { + "minimum": 0, + "maximum": 0, + "maxToMinRatio": None, + "balanced": False, + } + minimum = min(positive) + maximum = max(positive) + return { + "minimum": minimum, + "maximum": maximum, + "maxToMinRatio": float(maximum / minimum), + "balanced": minimum == maximum, + } + + +def _distinct_units_by_group( + design: pd.DataFrame, + *, + coefficient: str, + unit: str, + group_order: Sequence[str | int | float | bool], +) -> list[dict[str, Any]]: + output: list[dict[str, Any]] = [] + coefficient_values = design[coefficient].to_numpy(dtype=object, copy=False) + unit_values = design[unit].to_numpy(dtype=object, copy=False) + for group in group_order: + mask = coefficient_values == group + valid_units = unit_values[mask & _valid_value_mask(unit_values)] + output.append( + { + "group": group, + "count": int(pd.Series(valid_units).nunique(dropna=True)), + } + ) + return output + + +def _design_missingness( + design: pd.DataFrame, + columns: Sequence[str], +) -> list[dict[str, Any]]: + output: list[dict[str, Any]] = [] + rows = int(len(design)) + for column in dict.fromkeys(columns): + values = design[column].to_numpy(dtype=object, copy=False) + missing = int((~_valid_value_mask(values)).sum()) + output.append( + { + "column": column, + "rows": rows, + "missing": missing, + "missingFraction": missing / rows if rows else 0.0, + } + ) + return output + + +def _categorical_structure( + design: pd.DataFrame, + left: str, + right: str, +) -> dict[str, Any]: + left_values = design[left].to_numpy(dtype=object, copy=False) + right_values = design[right].to_numpy(dtype=object, copy=False) + mapping = directional_mapping(left_values, right_values) + valid = _valid_value_mask(left_values) & _valid_value_mask(right_values) + fully_crossed = False + if valid.any(): + table = pd.crosstab(left_values[valid], right_values[valid]) + fully_crossed = bool(table.size and np.all(table.to_numpy() > 0)) + nesting = str(mapping.get("nesting", "none")) + relationship = ( + nesting + if nesting != "none" + else "crossed" + if fully_crossed + else "partiallyCrossed" + ) + return { + "left": left, + "right": right, + "relationship": relationship, + "fullyCrossed": fully_crossed, + "directionalMapping": mapping, + } + + +def _paired_coverage( + design: pd.DataFrame, + *, + coefficient: str, + pair_by: str, + group_order: Sequence[str | int | float | bool], +) -> dict[str, Any]: + groups = design[coefficient].to_numpy(dtype=object, copy=False) + pairs = design[pair_by].to_numpy(dtype=object, copy=False) + valid = _valid_value_mask(groups) & _valid_value_mask(pairs) + frame = pd.DataFrame({"group": groups[valid], "pair": pairs[valid]}) + required = set(group_order) + complete = 0 + incomplete = 0 + duplicate_pair_groups = 0 + single_group_pairs = 0 + incomplete_examples: list[dict[str, Any]] = [] + for pair, pair_frame in frame.groupby("pair", sort=False, observed=False): + counts = pair_frame["group"].value_counts(dropna=False) + present = {_json_scalar(value) for value in counts.index} + has_duplicate = bool((counts > 1).any()) + if len(present) == 1: + single_group_pairs += 1 + if present == required and not has_duplicate: + complete += 1 + continue + incomplete += 1 + duplicate_pair_groups += int((counts > 1).sum()) + if len(incomplete_examples) < _MAX_LEVEL_COUNT_ITEMS: + incomplete_examples.append( + { + "pair": _json_scalar(pair), + "presentGroups": [ + value for value in group_order if value in present + ], + "duplicateGroups": [ + _json_scalar(value) + for value, count in counts.items() + if int(count) > 1 + ], + } + ) + total_pairs = int(frame["pair"].nunique(dropna=True)) + return { + "pairBy": pair_by, + "requiredGroups": list(group_order), + "pairs": total_pairs, + "completePairs": complete, + "incompletePairs": incomplete, + "duplicatePairGroups": duplicate_pair_groups, + "betweenIndependentUnits": ( + total_pairs >= 2 and single_group_pairs == total_pairs + ), + "design": ( + "paired" + if total_pairs >= 2 + and complete == total_pairs + and incomplete == 0 + and duplicate_pair_groups == 0 + else "betweenIndependentUnits" + if total_pairs >= 2 and single_group_pairs == total_pairs + else "mixedOrIncomplete" + ), + "incompleteExamples": incomplete_examples, + "examplesTruncated": incomplete > len(incomplete_examples), + "complete": ( + total_pairs >= 2 + and complete == total_pairs + and incomplete == 0 + and duplicate_pair_groups == 0 + ), + } + + def _resolve_units( run: _Run, coefficient: str, @@ -941,6 +1177,7 @@ def _characterize_coefficient( observation_unit: str | None, independent_unit: str | None, technical: Sequence[str], + design_columns: Sequence[str] = (), ) -> tuple[dict[str, Any], dict[str, Any] | None]: record: dict[str, Any] = { "name": coefficient, @@ -948,6 +1185,16 @@ def _characterize_coefficient( "observationUnit": observation_unit, "independentUnit": independent_unit, "scope": "unresolvedUnit", + "groupOrder": [], + "groupCounts": [], + "groupCountsTruncated": False, + "groupImbalance": {}, + "unitLevelCounts": {}, + "replication": {}, + "missingness": [], + "designStructures": [], + "pairedCoverage": {}, + "estimability": {}, } if observation_unit is None or observation_unit not in run.profiles: run.note( @@ -1024,7 +1271,28 @@ def _characterize_coefficient( ) independent_unit = None record["independentUnit"] = None - columns = list(dict.fromkeys([*group_cols, coefficient, *unit_constant])) + design_constant = [ + name + for name in design_columns + if name not in {coefficient, observation_unit, independent_unit} + and name in run.profiles + and column_constant_within( + run.store.cells, + name, + observation_unit, + cell_key=run.cell_key, + ) + ] + columns = list( + dict.fromkeys( + [ + *group_cols, + coefficient, + *unit_constant, + *design_constant, + ] + ) + ) design = reduce_observation_units( run.store.cells, observation_unit, @@ -1049,6 +1317,94 @@ def _characterize_coefficient( record["scope"] = "betweenUnit" record["designRows"] = design_rows + group_order = _ordered_group_values(design[coefficient]) + group_counts, group_counts_truncated = _group_counts(design[coefficient]) + record["groupOrder"] = group_order + record["groupCounts"] = group_counts + record["groupCountsTruncated"] = group_counts_truncated + record["groupImbalance"] = _imbalance_from_counts(group_counts) + + unit_level_counts: dict[str, Any] = { + "observationUnit": { + "column": observation_unit, + "levels": design_rows, + }, + "coefficient": { + "column": coefficient, + "levels": len(group_order), + "counts": group_counts, + "truncated": group_counts_truncated, + }, + } + observation_by_group = [ + { + "group": item["value"], + "count": item["count"], + } + for item in group_counts + ] + independent_by_group: list[dict[str, Any]] = [] + if independent_unit is not None: + independent_counts, independent_counts_truncated = _bounded_level_counts( + design[independent_unit] + ) + independent_by_group = _distinct_units_by_group( + design, + coefficient=coefficient, + unit=independent_unit, + group_order=group_order, + ) + unit_level_counts["independentUnit"] = { + "column": independent_unit, + "levels": int(design[independent_unit].nunique(dropna=True)), + "counts": list(independent_counts), + "truncated": independent_counts_truncated, + "byGroup": independent_by_group, + } + record["unitLevelCounts"] = unit_level_counts + replication_counts = ( + independent_by_group if independent_unit is not None else observation_by_group + ) + minimum_replication = min( + (int(item["count"]) for item in replication_counts), + default=0, + ) + record["replication"] = { + "unit": independent_unit or observation_unit, + "observationUnitsByGroup": observation_by_group, + "independentUnitsByGroup": independent_by_group, + "minimumPerGroup": minimum_replication, + "sufficient": ( + len(group_order) >= 2 + and not group_counts_truncated + and minimum_replication >= 2 + ), + } + record["missingness"] = _design_missingness(design, columns) + + structural_columns = list( + dict.fromkeys( + [ + *([independent_unit] if independent_unit is not None else []), + *unit_constant, + *design_constant, + ] + ) + ) + structures: list[dict[str, Any]] = [] + if run.kind(coefficient) == "categorical": + for name in structural_columns[:_MAX_STRUCTURE_ITEMS]: + if run.kind(name) != "categorical": + continue + structures.append(_categorical_structure(design, coefficient, name)) + record["designStructures"] = structures + if independent_unit is not None and len(group_order) >= 2: + record["pairedCoverage"] = _paired_coverage( + design, + coefficient=coefficient, + pair_by=independent_unit, + group_order=group_order, + ) report = report_confounding( design, @@ -1062,6 +1418,16 @@ def _characterize_coefficient( ) report["observationUnit"] = observation_unit report["independentUnit"] = independent_unit + report["groupOrder"] = group_order + report["groupCounts"] = group_counts + report["groupCountsTruncated"] = group_counts_truncated + report["groupImbalance"] = record["groupImbalance"] + report["unitLevelCounts"] = unit_level_counts + report["replication"] = record["replication"] + report["missingness"] = record["missingness"] + report["designStructures"] = structures + report["pairedCoverage"] = record["pairedCoverage"] + record["estimability"] = dict(report.get("estimability") or {}) run.actions.append(f"confounding:{coefficient}") return record, report @@ -1090,6 +1456,7 @@ def _characterize_coefficients( observation_unit=observation, independent_unit=independent, technical=technical, + design_columns=design_columns, ) records.append(record) if report is not None: @@ -1140,6 +1507,16 @@ def _column_records( "domain": run.domains[name], "summary": profile.summary, "aliases": list(aliases.get(name, [])), + "nRows": profile.digest.nRows, + "nLevels": profile.digest.nLevels, + "nMissing": profile.digest.nMissing, + "missingFraction": ( + profile.digest.nMissing / profile.digest.nRows + if profile.digest.nRows + else 0.0 + ), + "levelCounts": list(profile.levelCounts), + "levelCountsTruncated": profile.levelCountsTruncated, } if profile.artifact is None: record.update( @@ -1157,14 +1534,36 @@ def _column_records( ) records.append(record) for name, reason in dropped: + dropped_profile = run.profiles.get(name) record = { "name": name, "kind": "continuous", "domain": "ignore", "summary": f"dropped before triage ({CONFIG._DROP_REASONS[reason]})", "aliases": [], + "nRows": ( + dropped_profile.digest.nRows if dropped_profile is not None else 0 + ), + "nLevels": ( + dropped_profile.digest.nLevels if dropped_profile is not None else 0 + ), + "nMissing": ( + dropped_profile.digest.nMissing if dropped_profile is not None else 0 + ), + "missingFraction": ( + dropped_profile.digest.nMissing / dropped_profile.digest.nRows + if dropped_profile is not None and dropped_profile.digest.nRows + else 0.0 + ), + "levelCounts": ( + list(dropped_profile.levelCounts) if dropped_profile is not None else [] + ), + "levelCountsTruncated": ( + dropped_profile.levelCountsTruncated + if dropped_profile is not None + else False + ), } - dropped_profile = run.profiles.get(name) if dropped_profile is not None and dropped_profile.artifact is not None: record.update( { @@ -1300,6 +1699,12 @@ def characterize_covariates( categorical_technical = [ name for name in technical if run.kind(name) == "categorical" ] + column_records = _column_records( + run, + candidates, + aliases=aliases, + dropped=dropped, + ) return CovariateCharacterization( status="done", cellSelection=artifact_reference(cellSelection), @@ -1310,7 +1715,7 @@ def characterize_covariates( "deterministic drops and ontology alias collapse" ], decisions=run.decisions, - columns=_column_records(run, candidates, aliases=aliases, dropped=dropped), + columns=column_records, coefficients=records, technicalNesting=( _technical_nesting_reports( @@ -1322,4 +1727,53 @@ def characterize_covariates( else [] ), confounding=reports, + unitLevelCounts=[ + { + "coefficient": record["name"], + **dict(record["unitLevelCounts"]), + } + for record in records + if record.get("unitLevelCounts") + ], + groupImbalance=[ + { + "coefficient": record["name"], + **dict(record["groupImbalance"]), + } + for record in records + if record.get("groupImbalance") + ], + missingness=[ + { + "column": record["name"], + "rows": record.get("nRows", 0), + "missing": record.get("nMissing", 0), + "missingFraction": record.get("missingFraction", 0.0), + } + for record in column_records + ], + designStructures=[ + { + "coefficient": record["name"], + **structure, + } + for record in records + for structure in record.get("designStructures", []) + ], + pairedCoverage=[ + { + "coefficient": record["name"], + **dict(record["pairedCoverage"]), + } + for record in records + if record.get("pairedCoverage") + ], + coefficientEstimability=[ + { + "coefficient": record["name"], + **dict(record["estimability"]), + } + for record in records + if record.get("estimability") + ], ) diff --git a/scarf/agent/characterize_features.py b/scarf/agent/characterize_features.py index b86f9b6b..97e8f60c 100644 --- a/scarf/agent/characterize_features.py +++ b/scarf/agent/characterize_features.py @@ -1,5 +1,6 @@ """Characterize feature identity, species, families, and exogenous candidates.""" +import re from collections.abc import Mapping, Sequence from pathlib import Path from typing import Any @@ -20,6 +21,7 @@ reference_misses, resolve_species, ) +from ..features.variability import DEFAULT_HVG_BLACKLIST from ..quality_control.cell_cycle_genes import ( g2m_phase_genes, g2m_phase_genes_mouse, @@ -47,6 +49,23 @@ "mus_musculus": {"s": s_phase_genes_mouse, "g2m": g2m_phase_genes_mouse}, } _SEX_COEFFICIENT_TOKENS = frozenset({"sex", "gender", "Sex", "Gender"}) +_FEATURE_INVENTORY_EXAMPLE_LIMIT = 8 +_DEFAULT_HVG_FAMILY_PATTERNS = ( + ("mitochondrial", r"^MT-"), + ("ribosomalProtein", r"^RPS|^RPL"), + ("mitoribosomal", r"^MRPS|^MRPL"), + ("cellCycleCcn", r"^CCN"), + ("hla", r"^HLA-"), + ("h2", r"^H2-"), + ("histone", r"^HIST"), + ( + "sexLinked", + ( + r"^XIST$|^DDX3Y$|^USP9Y$|^EIF1AY$|^KDM5D$|^SRY$|^ZFY$|^UTY$|" + r"^TMSB4Y$|^NLGN4Y$" + ), + ), +) class FeatureCharacterization(AgentDataModel): @@ -79,6 +98,54 @@ def _bounded_context(study_context: str | None) -> str: ) +def _feature_pattern_matches(names: Sequence[str], pattern: str) -> list[str]: + compiled = re.compile(pattern.upper()) + return [name for name in names if compiled.match(name.upper()) is not None] + + +def _bounded_feature_examples(matches: Sequence[str]) -> list[str]: + return sorted(set(matches), key=lambda value: (value.casefold(), value))[ + :_FEATURE_INVENTORY_EXAMPLE_LIMIT + ] + + +def _scarf_default_feature_inventory( + assay_name: str, + names: Sequence[str], +) -> dict[str, Any]: + evidence_prefix = f"assay:{assay_name}:scarfDefaultHvg" + combined_matches = _feature_pattern_matches(names, DEFAULT_HVG_BLACKLIST) + families: list[dict[str, Any]] = [] + family_evidence_ids: list[str] = [] + for family, pattern in _DEFAULT_HVG_FAMILY_PATTERNS: + matches = _feature_pattern_matches(names, pattern) + evidence_id = f"{evidence_prefix}:family:{family}" + families.append( + { + "family": family, + "pattern": pattern, + "caseInsensitive": True, + "count": len(matches), + "examples": _bounded_feature_examples(matches), + "evidenceId": evidence_id, + } + ) + family_evidence_ids.append(evidence_id) + evidence_id = f"{evidence_prefix}:combined" + return { + "source": "scarfDefaultHvgBlacklist", + "policyEffect": "evidenceOnly", + "featureColumn": "names", + "totalFeatures": len(names), + "blacklist": DEFAULT_HVG_BLACKLIST, + "matchCount": len(combined_matches), + "examples": _bounded_feature_examples(combined_matches), + "families": families, + "evidenceId": evidence_id, + "evidenceIds": [evidence_id, *family_evidence_ids], + } + + def _audit( audit_log: list[dict[str, Any]], *, @@ -324,6 +391,19 @@ def _characterize_assay( ) return record + default_inventory = _scarf_default_feature_inventory(assay_name, names) + record["defaultFeatureInventory"] = default_inventory + _audit( + audit_log, + kind="scarfDefaultFeatureInventory", + detail=( + f"Scarf's default HVG blacklist matched " + f"{default_inventory['matchCount']} of {len(names)} RNA features" + ), + assay=assay_name, + evidenceIds=default_inventory["evidenceIds"], + ) + species_by_assay = dict(directions.get("speciesByAssay") or {}) directed_species = species_by_assay.get(assay_name) resolution = resolve_species( diff --git a/scarf/agent/config/agent_exec.py b/scarf/agent/config/agent_exec.py index d8c520e5..e9a1b7c6 100644 --- a/scarf/agent/config/agent_exec.py +++ b/scarf/agent/config/agent_exec.py @@ -5,8 +5,9 @@ import time from collections.abc import Callable, Sequence from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass from inspect import isawaitable, iscoroutinefunction -from typing import Any +from typing import TYPE_CHECKING, Any, Literal from ...utils.logging import logger from ..types import ( @@ -18,7 +19,127 @@ from . import AgentRunConfig, get_model_settings, get_usage_limits from ._deps import require_pydantic_ai -__all__ = ["run_agent", "run_agent_sync"] +if TYPE_CHECKING: + from pydantic_ai.messages import UserContent +else: + UserContent = Any + +type AgentUserPrompt = str | Sequence[UserContent] +type ImageMediaType = Literal["image/png", "image/jpeg", "image/webp"] + +_MAX_VISUAL_EVIDENCE_ITEMS = 8 +_MAX_VISUAL_EVIDENCE_ITEM_BYTES = 4 * 1024 * 1024 +_MAX_VISUAL_EVIDENCE_TOTAL_BYTES = 16 * 1024 * 1024 + +__all__ = [ + "AgentUserPrompt", + "build_visual_evidence_prompt", + "ImageEvidence", + "ImageInputUnsupportedError", + "ImageMediaType", + "run_agent", + "run_agent_async", + "run_agent_sync", +] + + +@dataclass(frozen=True, slots=True) +class ImageEvidence: + """One in-memory image supplied to a bounded agent comparison.""" + + identifier: str + data: bytes + media_type: ImageMediaType = "image/png" + + +class ImageInputUnsupportedError(RuntimeError): + """The configured model or provider rejected image input.""" + + +def _image_input_is_unsupported(exc: Exception) -> bool: + from pydantic_ai.exceptions import ModelHTTPError, UserError + + if isinstance(exc, UserError): + detail = str(exc).casefold() + elif isinstance(exc, ModelHTTPError) and exc.status_code in {400, 415, 422}: + detail = str(exc.body).casefold() + else: + return False + return any( + marker in detail + for marker in ( + "binary content is not supported", + "binary input is not supported", + "does not support binary content", + "does not support multimodal", + "doesn't support multimodal", + "image content is not supported", + "multimodal input is not supported", + "image input is not supported", + "image inputs are not supported", + "images are not supported", + "does not support image", + "unsupported image input", + "only text input", + ) + ) + + +def build_visual_evidence_prompt( + prompt: str, + images: Sequence[ImageEvidence], +) -> tuple[UserContent, ...]: + """Build bounded Pydantic AI image content without creating report files.""" + + if not isinstance(prompt, str) or not prompt.strip(): + raise ValueError("Visual evidence prompt must be a non-empty string") + values = tuple(images) + if not values: + raise ValueError("Visual evidence requires at least one image") + if len(values) > _MAX_VISUAL_EVIDENCE_ITEMS: + raise ValueError( + "Visual evidence exceeds the maximum of " + f"{_MAX_VISUAL_EVIDENCE_ITEMS} images" + ) + identifiers = [value.identifier for value in values] + if any(not value or value != value.strip() for value in identifiers): + raise ValueError("Visual evidence identifiers must be non-empty and trimmed") + if len(identifiers) != len(set(identifiers)): + raise ValueError("Visual evidence identifiers must be unique") + total_bytes = 0 + for value in values: + if value.media_type not in {"image/png", "image/jpeg", "image/webp"}: + raise ValueError( + f"Visual evidence image {value.identifier!r} has unsupported media type" + ) + if not isinstance(value.data, bytes) or not value.data: + raise ValueError("Visual evidence images must contain non-empty bytes") + if len(value.data) > _MAX_VISUAL_EVIDENCE_ITEM_BYTES: + raise ValueError( + f"Visual evidence image {value.identifier!r} exceeds " + f"{_MAX_VISUAL_EVIDENCE_ITEM_BYTES} bytes" + ) + total_bytes += len(value.data) + if total_bytes > _MAX_VISUAL_EVIDENCE_TOTAL_BYTES: + raise ValueError( + "Visual evidence exceeds the total byte limit of " + f"{_MAX_VISUAL_EVIDENCE_TOTAL_BYTES}" + ) + + require_pydantic_ai() + from pydantic_ai.messages import BinaryContent + + return ( + prompt, + *( + BinaryContent( + data=value.data, + media_type=value.media_type, + identifier=value.identifier, + ) + for value in values + ), + ) def _tool_definitions( @@ -224,7 +345,7 @@ def run_agent_sync( model: Any, output_type: Any, system_prompt: str, - user_prompt: str, + user_prompt: AgentUserPrompt, tools: Sequence[Callable[..., Any] | Any] = (), deps_type: type[Any] | None = None, deps: Any = None, @@ -266,12 +387,21 @@ async def execute() -> AgentExecutionResult: started = time.monotonic() try: async with agent: - result = await agent.run( - user_prompt, - deps=deps, - message_history=message_history, - usage_limits=usage_limits, - ) + try: + result = await agent.run( + user_prompt, + deps=deps, + message_history=message_history, + usage_limits=usage_limits, + ) + except Exception as exc: + if not isinstance(user_prompt, str) and _image_input_is_unsupported( + exc + ): + raise ImageInputUnsupportedError( + "The configured model does not accept image input" + ) from exc + raise except Exception as exc: error_detail = str(exc).replace("\n", " ").strip()[:500] cause = exc.__cause__ @@ -302,12 +432,12 @@ async def execute() -> AgentExecutionResult: return pool.submit(asyncio.run, execute()).result() -async def run_agent( +async def run_agent_async( *, model: Any, output_type: Any, system_prompt: str, - user_prompt: str, + user_prompt: AgentUserPrompt, tools: Sequence[Callable[..., Any] | Any] = (), deps_type: type[Any] | None = None, deps: Any = None, @@ -341,12 +471,21 @@ async def run_agent( started = time.monotonic() try: async with agent: - result = await agent.run( - user_prompt, - deps=deps, - message_history=message_history, - usage_limits=usage_limits, - ) + try: + result = await agent.run( + user_prompt, + deps=deps, + message_history=message_history, + usage_limits=usage_limits, + ) + except Exception as exc: + if not isinstance(user_prompt, str) and _image_input_is_unsupported( + exc + ): + raise ImageInputUnsupportedError( + "The configured model does not accept image input" + ) from exc + raise except Exception as exc: error_detail = str(exc).replace("\n", " ").strip()[:500] cause = exc.__cause__ @@ -367,3 +506,6 @@ async def run_agent( started=started, tools=tools, ) + + +run_agent = run_agent_async diff --git a/scarf/agent/data_enrichment.py b/scarf/agent/data_enrichment.py index a47c889c..83d326da 100644 --- a/scarf/agent/data_enrichment.py +++ b/scarf/agent/data_enrichment.py @@ -7,6 +7,7 @@ from typing import Any, Literal from ..features.gene_reference import species_registry +from ..features.variability import DEFAULT_HVG_BLACKLIST from ..utils.logging import logger from .characterize_features import characterize_features from .config import CONFIG, AgentRunConfig @@ -39,6 +40,7 @@ "DataEnrichmentDependencies", "DataEnrichmentReport", "DataEnrichmentToolCall", + "DefaultHvgFamilyEvidence", "ExogenousFeatureEvidence", "FeatureFamilyEvidence", "FeatureLookupResult", @@ -47,6 +49,7 @@ "FeatureReference", "FeatureSelectionPolicy", "HtoTagEvidence", + "RnaFeatureInventoryEvidence", "StudyContextSummary", "find_present_features", "find_present_features_batch", @@ -76,6 +79,10 @@ representation-sensitivity bundle from observed families with defaultExclude=true. It is not an instruction to remove those families. Never nominate a family with defaultExclude=false. + The defaultFeatureInventory is separate deterministic evidence for Scarf's + exact default HVG blacklist. It is evidence only, not an automatic + exclusion or a source of policy nominations. Keep marker eligibility + broader than any graph-feature exclusion. Persisted assay types determine modality routes; never infer a route from an assay label. The validator fills assay type, modality eligibility, ADT @@ -315,6 +322,64 @@ def get_example(cls) -> "FeatureFamilyEvidence": ) +class DefaultHvgFamilyEvidence(AgentDataModel): + """One case-insensitive family within Scarf's default HVG blacklist.""" + + family: str = "" + pattern: str = "" + caseInsensitive: Literal[True] = True + count: int = 0 + examples: list[str] = Field(default_factory=list) + evidenceId: str = "" + + @classmethod + def get_blank(cls) -> "DefaultHvgFamilyEvidence": + return cls() + + @classmethod + def get_example(cls) -> "DefaultHvgFamilyEvidence": + return cls( + family="mitochondrial", + pattern="^MT-", + count=2, + examples=["MT-CO1", "MT-CYB"], + evidenceId="assay:RNA:scarfDefaultHvg:family:mitochondrial", + ) + + +class RnaFeatureInventoryEvidence(AgentDataModel): + """Exact name-column matches for Scarf's default HVG blacklist.""" + + source: Literal["scarfDefaultHvgBlacklist"] = "scarfDefaultHvgBlacklist" + policyEffect: Literal["evidenceOnly"] = "evidenceOnly" + featureColumn: Literal["names"] = "names" + totalFeatures: int = 0 + blacklist: str = "" + matchCount: int = 0 + examples: list[str] = Field(default_factory=list) + families: list[DefaultHvgFamilyEvidence] = Field(default_factory=list) + evidenceId: str = "" + evidenceIds: list[str] = Field(default_factory=list) + + @classmethod + def get_blank(cls) -> "RnaFeatureInventoryEvidence": + return cls() + + @classmethod + def get_example(cls) -> "RnaFeatureInventoryEvidence": + family = DefaultHvgFamilyEvidence.get_example() + evidence_id = "assay:RNA:scarfDefaultHvg:combined" + return cls( + totalFeatures=20_000, + blacklist=DEFAULT_HVG_BLACKLIST, + matchCount=2, + examples=["MT-CO1", "MT-CYB"], + families=[family], + evidenceId=evidence_id, + evidenceIds=[evidence_id, family.evidenceId], + ) + + class ExogenousFeatureEvidence(AgentDataModel): """One bounded candidate for an artificial or exogenous feature.""" @@ -349,6 +414,7 @@ class AssayFeatureInspection(AgentDataModel): speciesMethod: str | None = None speciesReason: str = "" families: list[FeatureFamilyEvidence] = Field(default_factory=list) + defaultFeatureInventory: RnaFeatureInventoryEvidence | None = None exogenous: list[ExogenousFeatureEvidence] = Field(default_factory=list) modalityEvidence: AssayModalityEvidence = Field( default_factory=AssayModalityEvidence.get_blank @@ -363,6 +429,7 @@ def get_blank(cls) -> "AssayFeatureInspection": @classmethod def get_example(cls) -> "AssayFeatureInspection": family = FeatureFamilyEvidence.get_example() + default_inventory = RnaFeatureInventoryEvidence.get_example() modality = AssayModalityEvidence( assayType="RNA", modality="RNA", @@ -380,11 +447,13 @@ def get_example(cls) -> "AssayFeatureInspection": speciesMethod="ensemblPrefix", speciesReason="Most feature IDs carry the ENSG prefix", families=[family], + defaultFeatureInventory=default_inventory, modalityEvidence=modality, evidenceIds=[ "assay:RNA:identity", "assay:RNA:species", family.evidenceId, + *default_inventory.evidenceIds, *modality.evidenceIds, ], ) @@ -917,6 +986,25 @@ async def inspect_assay_features( record = characterization.assays[0] family_evidence: list[FeatureFamilyEvidence] = [] evidence_ids = [f"assay:{assay_name}:identity", f"assay:{assay_name}:species"] + raw_default_inventory = record.get("defaultFeatureInventory") + default_inventory: RnaFeatureInventoryEvidence | None = None + if raw_default_inventory is not None: + default_inventory = RnaFeatureInventoryEvidence.model_validate( + raw_default_inventory + ) + if default_inventory.blacklist != DEFAULT_HVG_BLACKLIST: + raise ModelRetry( + "RNA feature inventory does not use Scarf's exact default HVG blacklist" + ) + evidence_prefix = f"assay:{assay_name}:scarfDefaultHvg" + default_inventory.evidenceId = f"{evidence_prefix}:combined" + for family in default_inventory.families: + family.evidenceId = f"{evidence_prefix}:family:{family.family}" + default_inventory.evidenceIds = [ + default_inventory.evidenceId, + *(family.evidenceId for family in default_inventory.families), + ] + evidence_ids.extend(default_inventory.evidenceIds) for family in record.get("families", []): family_name = str(family.get("family", "")) evidence_id = f"assay:{assay_name}:family:{family_name}" @@ -972,6 +1060,7 @@ async def inspect_assay_features( speciesMethod=record.get("speciesMethod"), speciesReason=str(resolution.get("reason", "")), families=family_evidence, + defaultFeatureInventory=default_inventory, exogenous=exogenous_evidence, modalityEvidence=modality_evidence, notes=[str(value) for value in record.get("notes", [])], @@ -990,6 +1079,8 @@ async def inspect_assay_features( "Data Enrichment inspected " f"assay={assay_name!r}, modality={modality_evidence.modality}, " f"species={inspection.species}, families={len(family_evidence)}, " + f"defaultHvgMatches=" + f"{default_inventory.matchCount if default_inventory is not None else 0}, " f"exogenous={len(exogenous_evidence)}, evidence={len(evidence_ids)}" ) return inspection @@ -1524,6 +1615,79 @@ def pending_data_enrichment_report( return validated +def deterministic_data_enrichment_report( + deps: DataEnrichmentDependencies, + *, + error: Exception, + model_name: str, +) -> DataEnrichmentReport: + """Use inspected feature evidence when an unattended model run is invalid.""" + if set(deps.inspections) != set(deps.assays): + raise error + policies = [] + for assay in deps.assays: + inspection = deps.inspections[assay] + evidence_ids = list(inspection.evidenceIds) + if not evidence_ids: + raise ValueError(f"Assay {assay!r} has no deterministic feature evidence") + policies.append( + FeatureSelectionPolicy( + assay=assay, + species=( + inspection.species + if inspection.species in {*_SUPPORTED_SPECIES, "unknown"} + else "unknown" + ), + speciesConfidence=( + "high" if inspection.species in _SUPPORTED_SPECIES else "unknown" + ), + speciesRationale=( + inspection.speciesReason + or "Feature inspection did not resolve a supported species." + ), + excludeFamilies=[ + item.family + for item in inspection.families + if item.defaultExclude is True + ], + protectFamilies=[ + item.family + for item in inspection.families + if item.defaultExclude is False + ], + rationale=( + "Use the exact observed default-exclusion families as the " + "initial representation-sensitivity policy." + ), + evidenceIds=evidence_ids, + ) + ) + summary = StudyContextSummary( + organismReferences=( + [deps.context.organismHint] if deps.context.organismHint else [] + ), + tissueReferences=list(deps.context.tissueReferences), + cellTypeReferences=list(deps.context.cellTypeReferences), + experimentalReferences=list(deps.context.experimentalDetails), + ) + error_detail = str(error).replace("\n", " ").strip()[:500] + report = DataEnrichmentReport( + status="done", + policies=policies, + studyContextSummary=summary, + limitations=[ + "The model feature-policy output was invalid; the workflow used only " + "deterministic assay inspection evidence.", + error_detail, + ], + runInfo=AgentRunInfo( + agentName="data_enrichment_deterministic", + modelName=model_name, + ), + ) + return validate_data_enrichment_report(deps, report) + + class DataEnrichmentAgent: """A small read-only tool agent for feature and organism enrichment.""" @@ -1532,8 +1696,10 @@ def __init__( model: Any, *, config: AgentRunConfig | None = None, + unattended: bool = False, ) -> None: self.model = model + self.unattended = unattended self.config = (config or AgentRunConfig()).with_limits( request_limit=8, tool_call_limit=5, @@ -1678,6 +1844,12 @@ def run( if set(deps.inspections) != set(deps.assays): raise model_name = getattr(self.model, "model_name", type(self.model).__name__) + if self.unattended: + return deterministic_data_enrichment_report( + deps, + error=exc, + model_name=str(model_name), + ) return pending_data_enrichment_report( deps, error=exc, @@ -1685,6 +1857,15 @@ def run( ) report = DataEnrichmentReport.model_validate(execution.output) report = validate_data_enrichment_report(deps, report) + if self.unattended and report.status == "needsInput": + model_name = getattr(self.model, "model_name", type(self.model).__name__) + return deterministic_data_enrichment_report( + deps, + error=RuntimeError( + "The model returned an unresolved data-enrichment policy" + ), + model_name=str(model_name), + ) report.runInfo = execution.runInfo logger.info( "Data Enrichment Agent completed: " diff --git a/scarf/agent/experimental_context.py b/scarf/agent/experimental_context.py index 548ac7e6..1fd1167f 100644 --- a/scarf/agent/experimental_context.py +++ b/scarf/agent/experimental_context.py @@ -2,9 +2,10 @@ import json import math +import re from collections.abc import Mapping, Sequence from textwrap import dedent -from typing import TYPE_CHECKING, Any, Literal +from typing import TYPE_CHECKING, Any, Literal, cast import numpy as np @@ -13,9 +14,14 @@ from ..metadata.selection import resolve_cell_aligned_artifact from ..metrics.association import coefficient_estimability from ..quality_control.filtering import ( - _sample_aware_mad_mask, + _validated_sample_labels, gaussian_quantile_bounds, ) +from ..storage.artifacts import ( + fingerprint_array, + fingerprint_strings, + inspect_artifact, +) from ..storage.refs import ArtifactRef from ..storage.selections import read_stored_selection_mask from ..utils.logging import logger @@ -28,9 +34,13 @@ from .config._deps import AGENT_INSTALL_HINT from .config.agent_exec import run_agent_sync from .qc_profiles import ( + AutoFilterProjection, + QcMetricRole, RegisteredCellQcProfile, RegisteredQcProjection, offered_registered_qc_profiles, + project_auto_filter_profile, + qc_metric_execution_name, registered_qc_metric_role, ) from .tools import artifact_reference, core_artifact_reference @@ -61,6 +71,8 @@ "BatchSafetyEvidence", "CellQcPlan", "CellQcProfileEvidence", + "CaptureFailureEvidence", + "ContrastPlan", "CovariateEvidence", "ExperimentalContextAgent", "ExperimentalContextDecision", @@ -68,9 +80,12 @@ "ExperimentalContextResult", "InferenceUnit", "NamedArtifactSource", + "QcMetricSourceEvidence", + "QcSourceConcordance", "RepresentationEvaluation", "RegisteredCellQcProfile", "analyze_experimental_design", + "contrast_plans_from_characterization", "inspect_cell_covariates", "score_current_representation", "validate_experimental_context", @@ -94,7 +109,6 @@ _CONTEXT_LIMIT = 1200 _MAX_QC_SAMPLE_PROFILES = 4 -_MAX_SAMPLE_RETENTION_ITEMS = 20 class InferenceUnit(AgentDataModel): @@ -179,6 +193,203 @@ def get_example(cls) -> "NamedArtifactSource": ) +class QcMetricSourceEvidence(AgentDataModel): + """One source-specific quality metric on the exact active cells.""" + + sourceId: str = "" + metricName: str = "" + metricRole: QcMetricRole = "diagnostic" + assay: str | None = None + sourceType: Literal["metadataColumn", "artifact"] = "metadataColumn" + origin: Literal[ + "ingestionMetadata", + "derivedArtifact", + "externalArtifact", + ] = "ingestionMetadata" + executionName: str = "" + metadataColumn: str | None = None + artifact: ArtifactReferenceModel | None = None + cellSelection: ArtifactReferenceModel | None = None + inputArtifacts: list[ArtifactReferenceModel] = Field(default_factory=list) + provenanceOperation: str | None = None + valuesFingerprint: str = "" + activeCells: int = 0 + missingCells: int = 0 + missingCellsByCapture: dict[str, int] = Field(default_factory=dict) + usableForFiltering: bool = False + notes: list[str] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_source(self) -> "QcMetricSourceEvidence": + if ( + not self.sourceId + and not self.metricName + and self.metadataColumn is None + and self.artifact is None + ): + return self + if self.sourceType == "metadataColumn": + if self.metadataColumn is None or self.artifact is not None: + raise ValueError( + "A metadata QC source requires only metadataColumn provenance" + ) + if self.origin != "ingestionMetadata": + raise ValueError( + "Metadata QC sources must use ingestionMetadata origin" + ) + elif self.artifact is None or self.metadataColumn is not None: + raise ValueError("An artifact QC source requires only artifact provenance") + if self.activeCells < 0 or not 0 <= self.missingCells <= self.activeCells: + raise ValueError("QC source active and missing counts are inconsistent") + if self.usableForFiltering and self.missingCells: + raise ValueError("A QC source with missing values cannot drive filtering") + return self + + +class QcSourceConcordance(AgentDataModel): + """Observed agreement between imported and derived forms of one metric.""" + + metricRole: QcMetricRole = "diagnostic" + leftSourceId: str = "" + rightSourceId: str = "" + comparedCells: int = 0 + missingCells: int = 0 + meanAbsoluteDifference: float | None = None + maximumAbsoluteDifference: float | None = None + pearsonCorrelation: float | None = None + exactlyEqual: bool = False + numericallyClose: bool = False + evidenceId: str = "" + + +class CaptureFailureEvidence(AgentDataModel): + """Multi-axis capture anomaly plus exclusion-safety inputs.""" + + capture: str = "" + activeCells: int = 0 + retainedCells: int = 0 + retainedFraction: float = 0.0 + adverseAxes: list[QcMetricRole] = Field(default_factory=list) + independentAdverseAxes: int = 0 + metricMissingFractions: dict[str, float] = Field(default_factory=dict) + reasons: list[str] = Field(default_factory=list) + wholeCaptureFailure: bool = False + conditionAndUnitSafety: list[dict[str, Any]] = Field(default_factory=list) + preservesConditionCoverage: bool = False + preservesIndependentUnitCoverage: bool = False + exclusionEligible: bool = False + doubletEvidenceIds: list[str] = Field(default_factory=list) + evidenceId: str = "" + + @model_validator(mode="after") + def validate_failure(self) -> "CaptureFailureEvidence": + if self.independentAdverseAxes != len(set(self.adverseAxes)): + raise ValueError("Capture failure axis count must match its unique axes") + if self.wholeCaptureFailure != (self.independentAdverseAxes >= 2): + raise ValueError( + "Whole-capture failure requires at least two independent QC axes" + ) + if self.exclusionEligible and ( + not self.wholeCaptureFailure + or not self.preservesConditionCoverage + or not self.preservesIndependentUnitCoverage + ): + raise ValueError( + "Capture exclusion requires failure and preserved design coverage" + ) + return self + + +type ContrastTest = Literal["mann_whitney", "kruskal_wallis", "wilcoxon"] +type ContrastSampleStatistic = Literal["mean", "median", "fraction"] +type ContrastStatus = Literal["licensed", "blocked", "needsInput"] + + +class ContrastPlan(AgentDataModel): + """One deterministic sample-aware statistical-testing license.""" + + coefficient: str = "" + groupOrder: list[str | int | float | bool] = Field(default_factory=list) + sampleBy: str | None = None + pairBy: str | None = None + test: ContrastTest | None = None + sampleStatistic: ContrastSampleStatistic = "mean" + expressionCutoff: float = 0.0 + status: ContrastStatus = "blocked" + betweenUnitDesign: bool = False + replicationPassed: bool = False + estimabilityPassed: bool = False + pairedCoveragePassed: bool | None = None + replication: dict[str, Any] = Field(default_factory=dict) + estimability: dict[str, Any] = Field(default_factory=dict) + pairedCoverage: dict[str, Any] = Field(default_factory=dict) + blockedReasons: list[str] = Field(default_factory=list) + evidenceId: str = "" + evidenceIds: list[str] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_contrast(self) -> "ContrastPlan": + if self.coefficient != self.coefficient.strip(): + raise ValueError( + "Contrast coefficient cannot contain surrounding whitespace" + ) + if self.sampleBy is not None and ( + not self.sampleBy.strip() or self.sampleBy != self.sampleBy.strip() + ): + raise ValueError("Contrast sampleBy must be a non-empty trimmed name") + if self.pairBy is not None and ( + not self.pairBy.strip() or self.pairBy != self.pairBy.strip() + ): + raise ValueError("Contrast pairBy must be a non-empty trimmed name") + group_keys = [(type(value).__name__, repr(value)) for value in self.groupOrder] + if len(group_keys) != len(set(group_keys)): + raise ValueError("Contrast groupOrder must contain unique values") + if any( + isinstance(value, float) and not math.isfinite(value) + for value in self.groupOrder + ): + raise ValueError("Contrast groupOrder cannot contain non-finite values") + if not math.isfinite(self.expressionCutoff): + raise ValueError("Contrast expressionCutoff must be finite") + if self.sampleStatistic != "fraction" and self.expressionCutoff != 0.0: + raise ValueError( + "Contrast expressionCutoff is only used with fraction summaries" + ) + if self.test == "mann_whitney" and len(self.groupOrder) != 2: + raise ValueError("mann_whitney requires exactly two ordered groups") + if self.test == "kruskal_wallis" and len(self.groupOrder) < 3: + raise ValueError("kruskal_wallis requires at least three ordered groups") + if self.test == "wilcoxon": + if len(self.groupOrder) != 2 or self.pairBy is None: + raise ValueError( + "wilcoxon requires exactly two groups and an explicit pairBy" + ) + elif self.pairBy is not None and self.test is not None: + raise ValueError("A paired contrast must use the wilcoxon test") + if self.status == "licensed": + if ( + not self.coefficient + or self.sampleBy is None + or self.test is None + or self.blockedReasons + or not self.betweenUnitDesign + or not self.replicationPassed + or not self.estimabilityPassed + or (self.pairBy is not None and self.pairedCoveragePassed is not True) + ): + raise ValueError( + "A licensed contrast requires resolved design, replication, " + "estimability, and paired coverage" + ) + elif not self.blockedReasons: + raise ValueError("A non-licensed contrast requires blockedReasons") + return self + + @classmethod + def get_blank(cls) -> "ContrastPlan": + return cls(blockedReasons=["unresolvedContrast"]) + + def _validate_qc_sources( *, action: CellQcAction, @@ -187,6 +398,7 @@ def _validate_qc_sources( sample_column: str | None, sample_artifact: NamedArtifactSource | None, registered_profile: RegisteredCellQcProfile | None = None, + allow_metric_name_collisions: bool = False, ) -> None: if len(attributes) != len(set(attributes)): raise ValueError("Cell-QC metadata attributes must be unique") @@ -200,14 +412,14 @@ def _validate_qc_sources( artifact_names = [source.name for source in artifact_metrics] if len(artifact_names) != len(set(artifact_names)): raise ValueError("Cell-QC artifact metric names must be unique") - if any(source.artifact.kind != "quality_metric" for source in artifact_metrics): + if not allow_metric_name_collisions and set(attributes) & set(artifact_names): raise ValueError( - "Cell-QC artifactMetrics must reference quality_metric artifacts" + "Cell-QC metadata and artifact metric names collide; explicitly " + "validated multi-source evidence is required" ) - collisions = sorted(set(attributes).intersection(artifact_names)) - if collisions: + if any(source.artifact.kind != "quality_metric" for source in artifact_metrics): raise ValueError( - f"Cell-QC metadata and artifact metric names collide: {collisions}" + "Cell-QC artifactMetrics must reference quality_metric artifacts" ) if sample_column is not None and sample_artifact is not None: raise ValueError( @@ -279,17 +491,26 @@ class CellQcProfileEvidence(AgentDataModel): driverAssayType: CellQcDriverType | None = None sampleColumn: str | None = None sampleArtifact: NamedArtifactSource | None = None + captureColumn: str | None = None + captureArtifact: NamedArtifactSource | None = None attributes: list[str] = Field(default_factory=list) artifactMetrics: list[NamedArtifactSource] = Field(default_factory=list) + metricSources: list[QcMetricSourceEvidence] = Field(default_factory=list) + sourceConcordance: list[QcSourceConcordance] = Field(default_factory=list) parameters: dict[str, Any] = Field(default_factory=dict) + resolvedBounds: dict[str, Any] | list[dict[str, Any]] = Field(default_factory=dict) activeCells: int = 0 retainedCells: int = 0 retainedFraction: float = 0.0 + activeCellsByCapture: dict[str, int] = Field(default_factory=dict) sampleRetainedCells: dict[str, int] = Field(default_factory=dict) retainedCellsByColumn: dict[str, dict[str, int]] = Field(default_factory=dict) unsafeRetentionGroups: list[str] = Field(default_factory=list) flaggedCells: dict[str, int] = Field(default_factory=dict) + metricFlaggedCells: dict[str, dict[str, int]] = Field(default_factory=dict) failedCaptureCandidates: list[str] = Field(default_factory=list) + captureFailureEvidence: list[CaptureFailureEvidence] = Field(default_factory=list) + excludableCaptureCandidates: list[str] = Field(default_factory=list) notes: list[str] = Field(default_factory=list) evidenceId: str = "" @@ -302,7 +523,36 @@ def validate_sources(self) -> "CellQcProfileEvidence": sample_column=self.sampleColumn, sample_artifact=self.sampleArtifact, registered_profile=self.registeredProfile, + allow_metric_name_collisions=True, + ) + if self.captureColumn is not None and self.captureArtifact is not None: + raise ValueError( + "Cell-QC captureColumn and captureArtifact are mutually exclusive" + ) + if ( + self.captureArtifact is not None + and self.captureArtifact.artifact.kind != "hto_identity" + ): + raise ValueError( + "Cell-QC captureArtifact must reference an hto_identity artifact" + ) + failures = {item.capture: item for item in self.captureFailureEvidence} + if len(failures) != len(self.captureFailureEvidence): + raise ValueError("Cell-QC capture failure evidence must be unique") + expected_failed = sorted( + capture for capture, item in failures.items() if item.wholeCaptureFailure ) + if failures and sorted(self.failedCaptureCandidates) != expected_failed: + raise ValueError( + "Cell-QC failed captures must match their multi-axis evidence" + ) + expected_excludable = sorted( + capture for capture, item in failures.items() if item.exclusionEligible + ) + if failures and sorted(self.excludableCaptureCandidates) != expected_excludable: + raise ValueError( + "Cell-QC excludable captures must match design-safety evidence" + ) return self @classmethod @@ -351,6 +601,7 @@ def validate_sources(self) -> "CellQcPlan": sample_column=self.sampleColumn, sample_artifact=self.sampleArtifact, registered_profile=self.registeredProfile, + allow_metric_name_collisions=True, ) return self @@ -468,6 +719,9 @@ class CovariateEvidence(AgentDataModel): ) batchSafety: list[BatchSafetyEvidence] = Field(default_factory=list) qcProfiles: list[CellQcProfileEvidence] = Field(default_factory=list) + qcMetricSources: list[QcMetricSourceEvidence] = Field(default_factory=list) + qcSourceConcordance: list[QcSourceConcordance] = Field(default_factory=list) + contrastPlans: list[ContrastPlan] = Field(default_factory=list) htoIdentityColumns: list[str] = Field(default_factory=list) htoIdentityArtifacts: list[NamedArtifactSource] = Field(default_factory=list) evidenceIds: list[str] = Field(default_factory=list) @@ -509,6 +763,9 @@ class ExperimentalContextResult(AgentDataModel): cellSelection: ArtifactReferenceModel | None = None cellQc: CellQcPlan = Field(default_factory=CellQcPlan.get_blank) qcProfiles: list[CellQcProfileEvidence] = Field(default_factory=list) + qcMetricSources: list[QcMetricSourceEvidence] = Field(default_factory=list) + qcSourceConcordance: list[QcSourceConcordance] = Field(default_factory=list) + contrastPlans: list[ContrastPlan] = Field(default_factory=list) qualityMetricArtifacts: list[NamedArtifactSource] = Field(default_factory=list) htoIdentityColumns: list[str] = Field(default_factory=list) htoIdentityArtifacts: list[NamedArtifactSource] = Field(default_factory=list) @@ -683,6 +940,9 @@ class ExperimentalContextDependencies(AgentDataModel): characterization: CovariateCharacterization | None = None batchSafety: dict[str, BatchSafetyEvidence] = Field(default_factory=dict) qcProfiles: dict[str, CellQcProfileEvidence] = Field(default_factory=dict) + qcMetricSources: list[QcMetricSourceEvidence] = Field(default_factory=list) + qcSourceConcordance: list[QcSourceConcordance] = Field(default_factory=list) + contrastPlans: dict[str, ContrastPlan] = Field(default_factory=dict) htoIdentityColumns: list[str] = Field(default_factory=list) qualityMetricArtifacts: list[NamedArtifactSource] = Field(default_factory=list) htoIdentityArtifacts: list[NamedArtifactSource] = Field(default_factory=list) @@ -762,6 +1022,132 @@ def characterization_evidence( return evidence_ids +def contrast_plans_from_characterization( + characterization: CovariateCharacterization, +) -> list[ContrastPlan]: + """Build deterministic test licenses from bounded coefficient evidence.""" + reports = { + report.get("coefficient"): report + for report in characterization.confounding + if isinstance(report.get("coefficient"), str) + } + plans: list[ContrastPlan] = [] + for record in characterization.coefficients: + coefficient = record.get("name") + if not isinstance(coefficient, str): + continue + report = reports.get(coefficient, {}) + raw_groups = record.get("groupOrder") + group_order = ( + list(raw_groups) + if isinstance(raw_groups, list) + and all( + isinstance(value, str | int | float | bool) + and not (isinstance(value, float) and not math.isfinite(value)) + for value in raw_groups + ) + else [] + ) + sample_by = record.get("observationUnit") + sample_by = sample_by if isinstance(sample_by, str) else None + independent_unit = record.get("independentUnit") + independent_unit = ( + independent_unit if isinstance(independent_unit, str) else None + ) + between_unit = record.get("scope") == "betweenUnit" + replication = dict(record.get("replication") or {}) + replication_passed = replication.get("sufficient") is True + estimability = dict( + record.get("estimability") or report.get("estimability") or {} + ) + estimability_passed = ( + estimability.get("status") == "ok" + and estimability.get("coefficientEstimable") is True + and estimability.get("rankDeficient") is not True + ) + paired_coverage = dict(record.get("pairedCoverage") or {}) + pair_by: str | None = None + paired_passed: bool | None = None + mixed_independent_design = False + if independent_unit is not None: + if paired_coverage.get("complete") is True: + pair_by = independent_unit + paired_passed = True + elif paired_coverage.get("betweenIndependentUnits") is True: + sample_by = independent_unit + else: + pair_by = independent_unit + paired_passed = False + mixed_independent_design = True + + reasons: list[str] = [] + needs_input = False + if record.get("kind") != "categorical": + reasons.append("coefficientRequiresExplicitCategoricalGroups") + needs_input = True + if not between_unit: + reasons.append("coefficientIsNotBetweenUnit") + if sample_by is None: + reasons.append("sampleByIsUnresolved") + needs_input = True + if len(group_order) < 2: + reasons.append("fewerThanTwoObservedGroups") + needs_input = True + if record.get("groupCountsTruncated") is True: + reasons.append("groupOrderIsTruncated") + needs_input = True + if not replication_passed: + reasons.append("insufficientIndependentReplication") + if not estimability_passed: + reasons.append("coefficientIsNotEstimable") + + test: ContrastTest | None = None + if pair_by is not None: + if len(group_order) != 2: + reasons.append("pairedTestsRequireExactlyTwoGroups") + else: + test = "wilcoxon" + if paired_passed is not True: + reasons.append("pairedCoverageIsIncomplete") + elif mixed_independent_design: + reasons.append("independentUnitStructureIsMixed") + elif len(group_order) == 2: + test = "mann_whitney" + elif len(group_order) >= 3: + test = "kruskal_wallis" + + reasons = list(dict.fromkeys(reasons)) + status: ContrastStatus = ( + "licensed" if not reasons else "needsInput" if needs_input else "blocked" + ) + evidence_ids = [ + f"column:{coefficient}", + f"coefficient:{coefficient}", + f"estimability:{coefficient}", + ] + plans.append( + ContrastPlan( + coefficient=coefficient, + groupOrder=group_order, + sampleBy=sample_by, + pairBy=pair_by, + test=test, + status=status, + betweenUnitDesign=between_unit, + replicationPassed=replication_passed, + estimabilityPassed=estimability_passed, + pairedCoveragePassed=paired_passed, + replication=replication, + estimability=estimability, + pairedCoverage=paired_coverage, + blockedReasons=reasons, + evidenceId=f"contrastPlan:{coefficient}:{status}", + evidenceIds=evidence_ids, + ) + ) + return plans + + def _persisted_assay_type(store: Any, assay_name: str) -> str: """Read one persisted assay type without inferring modality from features.""" root = getattr(store, "zw", None) @@ -873,9 +1259,294 @@ def _resolved_artifact_values( return np.asarray(resolved.values) +def _artifact_input_references( + value: Any, + *, + limit: int = 16, +) -> list[ArtifactReferenceModel]: + refs: list[ArtifactReferenceModel] = [] + seen: set[tuple[str, str | None, str, str]] = set() + + def visit(item: Any) -> None: + if len(refs) >= limit: + return + if isinstance(item, ArtifactRef): + ref = item + elif isinstance(item, Mapping) and { + "scope", + "kind", + "artifact_id", + }.issubset(item): + try: + ref = ArtifactRef.from_dict(item) + except (KeyError, TypeError, ValueError): + ref = None + else: + ref = None + if ref is not None: + key = (ref.scope, ref.assay, ref.kind, ref.artifact_id) + if key not in seen: + seen.add(key) + refs.append(artifact_reference(ref)) + return + if isinstance(item, Mapping): + for nested in item.values(): + visit(nested) + elif isinstance(item, list | tuple): + for nested in item: + visit(nested) + + visit(value) + return refs + + +def _qc_metric_sources( + deps: ExperimentalContextDependencies, + driver: tuple[str, CellQcDriverType], +) -> tuple[ + dict[str, np.ndarray], + list[str], + list[NamedArtifactSource], + list[QcMetricSourceEvidence], + list[QcSourceConcordance], + list[str], + dict[str, np.ndarray], +]: + assay_name, assay_type = driver + del assay_type + selection = _cell_selection_ref(deps) + selection_model = artifact_reference(selection) + active_cells = _active_cell_count(deps) + metadata_names = _qc_attributes(deps.store, assay_name, driver[1]) + artifact_candidates: list[NamedArtifactSource] = [] + for source in deps.qualityMetricArtifacts: + artifact = _source_ref(source, expected_kind="quality_metric") + if artifact.assay == assay_name: + artifact_candidates.append(source) + metadata_collisions = set(metadata_names).intersection( + source.name for source in artifact_candidates + ) + + values_by_execution_name: dict[str, np.ndarray] = {} + values_by_source: dict[str, np.ndarray] = {} + sources: list[QcMetricSourceEvidence] = [] + valid_metadata: list[str] = [] + valid_artifacts: list[NamedArtifactSource] = [] + notes: list[str] = [] + + for name in metadata_names: + raw = np.asarray(deps.cells.fetch(name)) + try: + values = np.asarray(raw, dtype=float) + except (TypeError, ValueError): + fingerprint = fingerprint_strings(raw) + source_id = f"qcMetric:metadata:{assay_name}:{name}:{fingerprint}" + sources.append( + QcMetricSourceEvidence( + sourceId=source_id, + metricName=name, + metricRole=registered_qc_metric_role(name), + assay=assay_name, + sourceType="metadataColumn", + origin="ingestionMetadata", + executionName=name, + metadataColumn=name, + cellSelection=selection_model, + valuesFingerprint=fingerprint, + activeCells=active_cells, + missingCells=active_cells, + notes=["Metric is not numeric and cannot drive filtering"], + ) + ) + notes.append(f"QC metadata source {name!r} is not numeric") + continue + if values.ndim != 1 or values.shape != (active_cells,): + raise ValueError( + f"QC metadata source {name!r} does not align with cellSelection" + ) + fingerprint = fingerprint_array(values) + missing = int((~np.isfinite(values)).sum()) + source_id = f"qcMetric:metadata:{assay_name}:{name}:{fingerprint}" + usable = missing == 0 + source_notes = ( + [] if usable else [f"{missing} active cells have non-finite metric values"] + ) + sources.append( + QcMetricSourceEvidence( + sourceId=source_id, + metricName=name, + metricRole=registered_qc_metric_role(name), + assay=assay_name, + sourceType="metadataColumn", + origin="ingestionMetadata", + executionName=name, + metadataColumn=name, + cellSelection=selection_model, + valuesFingerprint=fingerprint, + activeCells=active_cells, + missingCells=missing, + usableForFiltering=usable, + notes=source_notes, + ) + ) + values_by_source[source_id] = values + if usable: + values_by_execution_name[name] = values + valid_metadata.append(name) + else: + notes.extend(source_notes) + + for source in artifact_candidates: + artifact = _source_ref(source, expected_kind="quality_metric") + values = np.asarray( + _resolved_artifact_values( + deps, + source, + expected_kind="quality_metric", + ), + dtype=float, + ) + if values.ndim != 1 or values.shape != (active_cells,): + raise ValueError( + f"QC artifact {source.name!r} does not align with cellSelection" + ) + execution_name = qc_metric_execution_name( + source.name, + artifact_id=artifact.artifact_id, + collides_with_metadata=source.name in metadata_collisions, + ) + if execution_name in values_by_execution_name: + raise ValueError( + f"QC execution metric name {execution_name!r} is not unique" + ) + fingerprint = fingerprint_array(values) + missing = int((~np.isfinite(values)).sum()) + status = inspect_artifact(deps.store.zw, artifact) + operation = status.operation + origin: Literal[ + "ingestionMetadata", + "derivedArtifact", + "externalArtifact", + ] = ( + "derivedArtifact" + if operation == "run_feature_percentage" + else "externalArtifact" + ) + source_id = ( + f"qcMetric:artifact:{artifact.assay}:{source.name}:{artifact.artifact_id}" + ) + usable = missing == 0 + source_notes = ( + [] if usable else [f"{missing} active cells have non-finite metric values"] + ) + sources.append( + QcMetricSourceEvidence( + sourceId=source_id, + metricName=source.name, + metricRole=registered_qc_metric_role(source.name), + assay=assay_name, + sourceType="artifact", + origin=origin, + executionName=execution_name, + artifact=artifact_reference(artifact), + cellSelection=selection_model, + inputArtifacts=_artifact_input_references(status.inputs or {}), + provenanceOperation=operation, + valuesFingerprint=fingerprint, + activeCells=active_cells, + missingCells=missing, + usableForFiltering=usable, + notes=source_notes, + ) + ) + values_by_source[source_id] = values + if usable: + values_by_execution_name[execution_name] = values + valid_artifacts.append(source) + else: + notes.extend(source_notes) + + concordance: list[QcSourceConcordance] = [] + metadata_sources = [ + source for source in sources if source.sourceType == "metadataColumn" + ] + artifact_sources = [source for source in sources if source.sourceType == "artifact"] + for left in metadata_sources: + for right in artifact_sources: + if left.metricRole != right.metricRole or left.metricRole == "diagnostic": + continue + if right.artifact is None: + raise ValueError("Artifact QC source lacks its exact reference") + left_values = values_by_source.get(left.sourceId) + right_values = values_by_source.get(right.sourceId) + if left_values is None or right_values is None: + continue + finite = np.isfinite(left_values) & np.isfinite(right_values) + compared = int(finite.sum()) + missing = int(len(finite) - compared) + mean_difference: float | None = None + maximum_difference: float | None = None + pearson: float | None = None + exactly_equal = False + numerically_close = False + if compared: + left_finite = left_values[finite] + right_finite = right_values[finite] + differences = np.abs(left_finite - right_finite) + mean_difference = float(differences.mean()) + maximum_difference = float(differences.max()) + exactly_equal = missing == 0 and bool( + np.array_equal(left_finite, right_finite) + ) + numerically_close = missing == 0 and bool( + np.allclose( + left_finite, + right_finite, + rtol=1e-6, + atol=1e-8, + ) + ) + if ( + compared >= 2 + and float(np.std(left_finite)) > 0.0 + and float(np.std(right_finite)) > 0.0 + ): + correlation = float(np.corrcoef(left_finite, right_finite)[0, 1]) + if math.isfinite(correlation): + pearson = correlation + evidence_id = ( + f"qcConcordance:{left.metricRole}:" + f"{left.valuesFingerprint}:{right.artifact.artifactId}" + ) + concordance.append( + QcSourceConcordance( + metricRole=left.metricRole, + leftSourceId=left.sourceId, + rightSourceId=right.sourceId, + comparedCells=compared, + missingCells=missing, + meanAbsoluteDifference=mean_difference, + maximumAbsoluteDifference=maximum_difference, + pearsonCorrelation=pearson, + exactlyEqual=exactly_equal, + numericallyClose=numerically_close, + evidenceId=evidence_id, + ) + ) + return ( + values_by_execution_name, + valid_metadata, + valid_artifacts, + sources, + concordance, + notes, + values_by_source, + ) + + def _qc_attributes(store: Any, assay_name: str, assay_type: str) -> list[str]: del assay_type - suffixes = ["nCounts", "nFeatures"] + suffixes = ["nCounts", "nFeatures", "percentMito", "percentRibo"] available = set(store.cells.columns) return [ f"{assay_name}_{suffix}" @@ -884,6 +1555,85 @@ def _qc_attributes(store: Any, assay_name: str, assay_type: str) -> list[str]: ] +def _derive_missing_percentage_artifacts( + store: Any, + *, + cell_selection: ArtifactRef, + driver: tuple[str, CellQcDriverType] | None, + quality_sources: Sequence[NamedArtifactSource], +) -> list[NamedArtifactSource]: + """Derive missing RNA percentage metrics through public immutable APIs.""" + sources = list(quality_sources) + if driver is None or driver[1] != "RNA": + return sources + if not callable(getattr(store, "set_feature_selection", None)) or not callable( + getattr(store, "run_feature_percentage", None) + ): + return sources + assay_name = driver[0] + available_metadata = set(store.cells.columns) + supplied_roles = { + registered_qc_metric_role(source.name) + for source in sources + if source.artifact.assay == assay_name + } + assay = store.get_assay(assay_name) + feature_ids = np.asarray(assay.feats.fetch_all("ids")).astype(str) + feature_names = np.asarray(assay.feats.fetch_all("names")).astype(str) + specifications: tuple[ + tuple[QcMetricRole, str, re.Pattern[str]], + ..., + ] = ( + ("mitochondrial", "percentMito", re.compile(r"^(MT-|mt-)")), + ( + "ribosomal", + "percentRibo", + re.compile(r"^(RPS|RPL|MRPS|MRPL|Rps|Rpl|Mrps|Mrpl)"), + ), + ) + existing_names = {source.name for source in sources} + for role, suffix, pattern in specifications: + metric_name = f"{assay_name}_{suffix}" + if metric_name in available_metadata or role in supplied_roles: + continue + mask = np.fromiter( + ( + pattern.search(feature_id) is not None + or pattern.search(feature_name) is not None + for feature_id, feature_name in zip( + feature_ids, + feature_names, + strict=True, + ) + ), + dtype=bool, + count=assay.feats.N, + ) + if not mask.any(): + continue + if metric_name in existing_names: + raise ValueError(f"Derived QC metric name {metric_name!r} is not unique") + feature_selection = store.set_feature_selection( + from_assay=assay_name, + mask=mask, + invalidate_cache=False, + ) + metric = store.run_feature_percentage( + cell_selection, + feature_selection, + invalidate_cache=False, + ) + sources.append( + NamedArtifactSource( + name=metric_name, + artifact=artifact_reference(metric), + ) + ) + existing_names.add(metric_name) + supplied_roles.add(role) + return sources + + def _qc_sample_columns( deps: ExperimentalContextDependencies, characterization: CovariateCharacterization | None, @@ -1010,6 +1760,285 @@ def _directed_pooled_reference_captures( return references +def _provenance_label(value: Any) -> str | None: + if isinstance(value, np.generic): + value = value.item() + if value is None: + return None + if isinstance(value, float) and not math.isfinite(value): + return None + if isinstance(value, bytes): + try: + value = value.decode("utf-8") + except UnicodeDecodeError: + return None + if isinstance(value, str) and not value.strip(): + return None + return str(value) + + +def _ordered_labels(values: np.ndarray, mask: np.ndarray) -> list[str]: + output: list[str] = [] + seen: set[str] = set() + for raw in values[mask]: + label = _provenance_label(raw) + if label is None or label in seen: + continue + seen.add(label) + output.append(label) + return output + + +def _capture_design_safety( + deps: ExperimentalContextDependencies, + characterization: CovariateCharacterization | None, + capture_labels: np.ndarray, + capture: str, +) -> tuple[list[dict[str, Any]], bool, bool]: + if characterization is None: + return [], False, False + active = np.ones(len(capture_labels), dtype=bool) + normalized = _validated_sample_labels( + capture_labels, + active, + label_name="physical capture labels", + ) + encoded = np.asarray( + [ + value.decode("utf-8") if isinstance(value, bytes) else str(value) + for value in normalized + ], + dtype=object, + ) + after = encoded != capture + safety: list[dict[str, Any]] = [] + for record in characterization.coefficients: + coefficient = record.get("name") + observation = record.get("observationUnit") + independent = record.get("independentUnit") + if ( + not isinstance(coefficient, str) + or not isinstance(observation, str) + or record.get("scope") != "betweenUnit" + or coefficient not in deps.cells.columns + or observation not in deps.cells.columns + ): + continue + condition_values = np.asarray(deps.cells.fetch(coefficient), dtype=object) + observation_values = np.asarray(deps.cells.fetch(observation), dtype=object) + if ( + condition_values.shape != after.shape + or observation_values.shape != after.shape + ): + raise ValueError("Capture safety columns do not align with cellSelection") + required_groups = _ordered_labels(condition_values, active) + remaining_groups = _ordered_labels(condition_values, after) + preserves_conditions = set(remaining_groups) == set(required_groups) + + observation_counts: list[dict[str, Any]] = [] + independent_counts: list[dict[str, Any]] = [] + independent_values: np.ndarray | None = None + if isinstance(independent, str): + if independent not in deps.cells.columns: + continue + independent_values = np.asarray( + deps.cells.fetch(independent), + dtype=object, + ) + if independent_values.shape != after.shape: + raise ValueError( + "Capture independent-unit column does not align with cellSelection" + ) + for group in required_groups: + group_mask = np.asarray( + [_provenance_label(value) == group for value in condition_values], + dtype=bool, + ) + observation_levels = set( + _ordered_labels(observation_values, after & group_mask) + ) + observation_counts.append( + {"group": group, "count": len(observation_levels)} + ) + if independent_values is not None: + independent_levels = set( + _ordered_labels(independent_values, after & group_mask) + ) + independent_counts.append( + {"group": group, "count": len(independent_levels)} + ) + + replication_counts = ( + independent_counts if independent_values is not None else observation_counts + ) + minimum_units = min( + (int(item["count"]) for item in replication_counts), + default=0, + ) + complete_pairs = 0 + incomplete_pairs = 0 + duplicate_pair_groups = 0 + single_group_pairs = 0 + if independent_values is not None: + pair_groups: dict[str, dict[str, set[str]]] = {} + for index in np.flatnonzero(after): + pair = _provenance_label(independent_values[index]) + pair_group = _provenance_label(condition_values[index]) + observation_value = _provenance_label(observation_values[index]) + if pair is None or pair_group is None or observation_value is None: + continue + pair_groups.setdefault(pair, {}).setdefault(pair_group, set()).add( + observation_value + ) + required_set = set(required_groups) + for groups in pair_groups.values(): + if len(groups) == 1: + single_group_pairs += 1 + duplicate_pair_groups += sum( + len(observations) > 1 for observations in groups.values() + ) + if set(groups) == required_set and all( + len(observations) == 1 for observations in groups.values() + ): + complete_pairs += 1 + else: + incomplete_pairs += 1 + original_pair_design = dict(record.get("pairedCoverage") or {}).get("design") + pair_structure_safe = ( + True + if independent_values is None + else ( + complete_pairs >= 2 + and incomplete_pairs == 0 + and duplicate_pair_groups == 0 + ) + if original_pair_design == "paired" + else (len(pair_groups) >= 2 and single_group_pairs == len(pair_groups)) + if original_pair_design == "betweenIndependentUnits" + else False + ) + preserves_units = ( + preserves_conditions and minimum_units >= 2 and pair_structure_safe + ) + safety.append( + { + "coefficient": coefficient, + "conditionColumn": coefficient, + "observationUnit": observation, + "independentUnit": independent, + "requiredGroups": required_groups, + "remainingGroups": remaining_groups, + "observationUnitsByGroup": observation_counts, + "independentUnitsByGroup": independent_counts, + "minimumIndependentUnitsAfterExclusion": minimum_units, + "completePairsAfterExclusion": complete_pairs, + "incompletePairsAfterExclusion": incomplete_pairs, + "duplicatePairGroupsAfterExclusion": duplicate_pair_groups, + "independentUnitDesign": original_pair_design, + "preservesConditionCoverage": preserves_conditions, + "preservesIndependentUnitCoverage": preserves_units, + } + ) + return ( + safety, + bool(safety) and all(item["preservesConditionCoverage"] for item in safety), + bool(safety) + and all(item["preservesIndependentUnitCoverage"] for item in safety), + ) + + +def _capture_source_missingness( + sources: Sequence[QcMetricSourceEvidence], + values_by_source: Mapping[str, np.ndarray], + capture_labels: np.ndarray | None, +) -> list[QcMetricSourceEvidence]: + if capture_labels is None: + return list(sources) + active = np.ones(len(capture_labels), dtype=bool) + normalized = _validated_sample_labels( + capture_labels, + active, + label_name="physical capture labels", + ) + captures: list[tuple[str, np.ndarray]] = [] + seen: set[str] = set() + for raw in normalized: + value = raw.item() if isinstance(raw, np.generic) else raw + key = value.decode("utf-8") if isinstance(value, bytes) else str(value) + if key in seen: + continue + seen.add(key) + captures.append((key, normalized == value)) + output: list[QcMetricSourceEvidence] = [] + for source in sources: + values = values_by_source.get(source.sourceId) + missing_by_capture: dict[str, int] = {} + if values is not None: + for capture, mask in captures: + missing_by_capture[capture] = int((~np.isfinite(values[mask])).sum()) + elif source.missingCells == source.activeCells: + missing_by_capture = { + capture: int(mask.sum()) for capture, mask in captures + } + output.append( + source.model_copy(update={"missingCellsByCapture": missing_by_capture}) + ) + return output + + +def _capture_failure_models( + projection: RegisteredQcProjection | AutoFilterProjection, + *, + deps: ExperimentalContextDependencies, + characterization: CovariateCharacterization | None, + capture_labels: np.ndarray | None, + metric_sources: Sequence[QcMetricSourceEvidence], +) -> list[CaptureFailureEvidence]: + if capture_labels is None: + return [] + output: list[CaptureFailureEvidence] = [] + source_by_id = {source.sourceId: source for source in metric_sources} + for comparison in projection.captureComparisons: + missing_fractions = { + source_id: ( + source.missingCellsByCapture.get(comparison.capture, 0) + / comparison.cells + if comparison.cells + else 0.0 + ) + for source_id, source in source_by_id.items() + } + safety, condition_safe, unit_safe = _capture_design_safety( + deps, + characterization, + capture_labels, + comparison.capture, + ) + failure = CaptureFailureEvidence( + capture=comparison.capture, + activeCells=comparison.cells, + retainedCells=comparison.retainedCells or 0, + retainedFraction=comparison.retainedFraction or 0.0, + adverseAxes=list(comparison.adverseAxes), + independentAdverseAxes=comparison.independentAdverseAxes, + metricMissingFractions=missing_fractions, + reasons=list(comparison.reasons), + wholeCaptureFailure=comparison.wholeCaptureFailure, + conditionAndUnitSafety=safety, + preservesConditionCoverage=condition_safe, + preservesIndependentUnitCoverage=unit_safe, + exclusionEligible=( + comparison.wholeCaptureFailure and condition_safe and unit_safe + ), + evidenceId=( + f"qcCapture:{comparison.capture}:" + f"{comparison.independentAdverseAxes}axes" + ), + ) + output.append(failure) + return output + + def _registered_profile_evidence( projection: RegisteredQcProjection, *, @@ -1020,21 +2049,19 @@ def _registered_profile_evidence( values_by_attr: dict[str, np.ndarray], metadata_attributes: list[str], artifact_metrics: list[NamedArtifactSource], + metric_sources: list[QcMetricSourceEvidence], + source_concordance: list[QcSourceConcordance], sample_column: str | None, sample_artifact: NamedArtifactSource | None, + capture_column: str | None, + capture_artifact: NamedArtifactSource | None, + capture_labels: np.ndarray | None, pooled_reference_captures: tuple[str, ...] | None, active_cells: int, comparison_source: str | None, ) -> CellQcProfileEvidence: - supported_names = { - name - for name in values_by_attr - if registered_qc_metric_role(name) != "diagnostic" - } - attributes = [name for name in metadata_attributes if name in supported_names] - metric_artifacts = [ - source for source in artifact_metrics if source.name in supported_names - ] + attributes = list(metadata_attributes) + metric_artifacts = list(artifact_metrics) profile_id = _registered_qc_profile_id( projection.profile, driver=driver, @@ -1063,6 +2090,13 @@ def _registered_profile_evidence( "captureComparisonSource": comparison_source, "pooledReferenceCaptures": list(pooled_reference_captures or ()), } + failure_evidence = _capture_failure_models( + projection, + deps=deps, + characterization=characterization, + capture_labels=capture_labels, + metric_sources=metric_sources, + ) cells = deps.cells if deps.cells is not None else deps.store.cells retention_columns: list[str] = [] if characterization is not None: @@ -1100,19 +2134,30 @@ def _registered_profile_evidence( driverAssayType=driver[1], sampleColumn=sample_column, sampleArtifact=sample_artifact, + captureColumn=capture_column, + captureArtifact=capture_artifact, attributes=attributes, artifactMetrics=metric_artifacts, + metricSources=metric_sources, + sourceConcordance=source_concordance, parameters=parameters, + resolvedBounds=parameters["resolvedBounds"], activeCells=active_cells, retainedCells=projection.retainedCells, retainedFraction=( projection.retainedCells / active_cells if active_cells else 0.0 ), + activeCellsByCapture=projection.captureSizes, sampleRetainedCells=projection.retainedByCapture, retainedCellsByColumn=retained_by_column, unsafeRetentionGroups=sorted(unsafe_groups), flaggedCells=projection.flagCounts, + metricFlaggedCells=projection.metricFlagCounts, failedCaptureCandidates=list(projection.failedCaptureCandidates), + captureFailureEvidence=failure_evidence, + excludableCaptureCandidates=[ + item.capture for item in failure_evidence if item.exclusionEligible + ], notes=list(projection.warnings), evidenceId=f"qcProfile:{profile_id}", ) @@ -1127,8 +2172,12 @@ def _registered_qc_profiles( values_by_attr: dict[str, np.ndarray], metadata_attributes: list[str], artifact_metrics: list[NamedArtifactSource], + metric_sources: list[QcMetricSourceEvidence], + source_concordance: list[QcSourceConcordance], + capture: tuple[str | None, NamedArtifactSource | None, np.ndarray] | None = None, ) -> list[CellQcProfileEvidence]: - capture = _directed_capture_source(deps) + if capture is None: + capture = _directed_capture_source(deps) sample_column: str | None = None sample_artifact: NamedArtifactSource | None = None capture_labels: np.ndarray | None = None @@ -1172,8 +2221,13 @@ def _registered_qc_profiles( values_by_attr=values_by_attr, metadata_attributes=metadata_attributes, artifact_metrics=artifact_metrics, + metric_sources=metric_sources, + source_concordance=source_concordance, sample_column=sample_column if uses_capture else None, sample_artifact=sample_artifact if uses_capture else None, + capture_column=sample_column, + capture_artifact=sample_artifact, + capture_labels=capture_labels, pooled_reference_captures=( pooled_references if projection.profile == "pooledReferenceMad5" @@ -1195,50 +2249,110 @@ def _global_qc_profile( metadata_attributes: list[str], artifact_metrics: list[NamedArtifactSource], attribute_notes: list[str], + *, + characterization: CovariateCharacterization | None = None, + metric_sources: list[QcMetricSourceEvidence] | None = None, + source_concordance: list[QcSourceConcordance] | None = None, + capture: tuple[str | None, NamedArtifactSource | None, np.ndarray] | None = None, ) -> CellQcProfileEvidence | None: - """Build the bounded global Gaussian QC profile when bounds are valid.""" - resolved_bounds: dict[str, dict[str, float]] = {} - global_keep = active.copy() - selected_names: list[str] = [] - for attribute, values in values_by_attr.items(): - if float(np.std(values)) == 0.0: - attribute_notes.append(f"Ignored constant QC metric {attribute!r}") + """Build an execution-exact projection of core global auto-filtering.""" + if not values_by_attr: + return None + metric_sources = list(metric_sources or []) + source_concordance = list(source_concordance or []) + executable_values: dict[str, np.ndarray] = {} + for name, values in values_by_attr.items(): + selected = np.asarray(values)[active] + if selected.size and np.all(selected == selected[0]): + attribute_notes.append(f"Ignored constant QC metric {name!r}") continue - low, high = gaussian_quantile_bounds(values, 0.01, 0.99) + low, high = gaussian_quantile_bounds(selected, 0.01, 0.99) if not np.isfinite([low, high]).all(): attribute_notes.append( - f"Ignored QC column {attribute!r} with non-finite Gaussian bounds" + f"Ignored QC metric {name!r} with non-finite Gaussian bounds" ) continue - resolved_bounds[attribute] = {"low": low, "high": high} - selected_names.append(attribute) - global_keep &= (values > low) & (values < high) - if not selected_names: + executable_values[name] = values + if not executable_values: + return None + executable_names = set(executable_values) + metadata_names = set(metadata_attributes) + metadata_attributes = [ + name for name in metadata_attributes if name in executable_names + ] + artifact_metrics = [ + source + for source in artifact_metrics + if qc_metric_execution_name( + source.name, + artifact_id=source.artifact.artifactId, + collides_with_metadata=source.name in metadata_names, + ) + in executable_names + ] + metric_sources = [ + source for source in metric_sources if source.executionName in executable_names + ] + retained_source_ids = {source.sourceId for source in metric_sources} + source_concordance = [ + comparison + for comparison in source_concordance + if comparison.leftSourceId in retained_source_ids + and comparison.rightSourceId in retained_source_ids + ] + capture_column: str | None = None + capture_artifact: NamedArtifactSource | None = None + capture_labels: np.ndarray | None = None + if capture is not None: + capture_column, capture_artifact, capture_labels = capture + try: + projection = project_auto_filter_profile( + "globalGaussian", + values_by_metric=executable_values, + active=active, + sample_labels=capture_labels, + grouping_proven=capture is not None, + ) + except ValueError as exc: + attribute_notes.append(f"Global Gaussian QC is not executable: {exc}") return None - retained_cells = int(global_keep.sum()) profile_id = _qc_profile_id( "globalGaussian", driver=driver, ) - selected = set(selected_names) + failures = _capture_failure_models( + projection, + deps=deps, + characterization=characterization, + capture_labels=capture_labels, + metric_sources=metric_sources, + ) return CellQcProfileEvidence( profileId=profile_id, action="globalGaussian", driverAssay=driver[0], driverAssayType=driver[1], - attributes=[name for name in metadata_attributes if name in selected], - artifactMetrics=[ - source for source in artifact_metrics if source.name in selected - ], - parameters={ - "minP": 0.01, - "maxP": 0.99, - "resolvedBounds": resolved_bounds, - }, + captureColumn=capture_column, + captureArtifact=capture_artifact, + attributes=list(metadata_attributes), + artifactMetrics=list(artifact_metrics), + metricSources=metric_sources, + sourceConcordance=source_concordance, + parameters=projection.parameters, + resolvedBounds=cast(dict[str, Any], projection.parameters["resolvedBounds"]), activeCells=active_cells, - retainedCells=retained_cells, - retainedFraction=retained_cells / active_cells, - notes=attribute_notes, + retainedCells=projection.retainedCells, + retainedFraction=projection.retainedCells / active_cells, + activeCellsByCapture=projection.captureSizes, + sampleRetainedCells=projection.retainedByCapture, + flaggedCells=projection.flagCounts, + metricFlaggedCells=projection.metricFlagCounts, + failedCaptureCandidates=list(projection.failedCaptureCandidates), + captureFailureEvidence=failures, + excludableCaptureCandidates=[ + item.capture for item in failures if item.exclusionEligible + ], + notes=[*attribute_notes, *projection.warnings], evidenceId=f"qcProfile:{profile_id}", ) @@ -1252,21 +2366,52 @@ def _sample_qc_profiles( values_by_attr: dict[str, np.ndarray], metadata_attributes: list[str], artifact_metrics: list[NamedArtifactSource], + metric_sources: list[QcMetricSourceEvidence], + source_concordance: list[QcSourceConcordance], + capture: tuple[str | None, NamedArtifactSource | None, np.ndarray] | None, ) -> list[CellQcProfileEvidence]: - """Build bounded sample-aware MAD profiles from exact sample sources.""" + """Build core-parity sample MAD profiles from exact grouping sources.""" attributes = list(values_by_attr) profiles: list[CellQcProfileEvidence] = [] - sample_sources: list[tuple[str | None, NamedArtifactSource | None]] = [ - (None, source) for source in deps.htoIdentityArtifacts - ] + sample_sources: list[ + tuple[str | None, NamedArtifactSource | None, np.ndarray | None, bool] + ] = [] + if capture is not None: + sample_sources.append((*capture[:2], capture[2], True)) sample_sources.extend( - (column, None) for column in _qc_sample_columns(deps, characterization) + (None, source, None, False) for source in deps.htoIdentityArtifacts ) - for sample_column, sample_artifact in sample_sources[:_MAX_QC_SAMPLE_PROFILES]: + sample_sources.extend( + (column, None, None, False) + for column in _qc_sample_columns(deps, characterization) + ) + seen_sources: set[str] = set() + for ( + sample_column, + sample_artifact, + supplied_labels, + is_physical_capture, + ) in sample_sources: + source_key = ( + f"metadata:{sample_column}" + if sample_column is not None + else ( + f"artifact:{sample_artifact.artifact.artifactId}" + if sample_artifact is not None + else "" + ) + ) + if not source_key or source_key in seen_sources: + continue + seen_sources.add(source_key) + if len(seen_sources) > _MAX_QC_SAMPLE_PROFILES: + break if not attributes: break artifact_labels = ( - None + supplied_labels + if supplied_labels is not None + else None if sample_artifact is None else _resolved_artifact_values( deps, @@ -1276,48 +2421,44 @@ def _sample_qc_profiles( ) try: sample_labels = ( - np.asarray(deps.cells.fetch(sample_column)) + np.asarray(supplied_labels) + if supplied_labels is not None + else np.asarray(deps.cells.fetch(sample_column)) if sample_column is not None else np.asarray(artifact_labels) ) - keep, provenance = _sample_aware_mad_mask( - values_by_attr=values_by_attr, + projection = project_auto_filter_profile( + "sampleMad", + values_by_metric=values_by_attr, sample_labels=sample_labels, active=active, + grouping_proven=True, n_mads=3.0, min_cells_per_sample=20, - attrs=attributes, ) except (TypeError, ValueError): continue - retained_mask = active & keep - retained_cells = int(retained_mask.sum()) - sample_retention: dict[str, int] = {} - seen: set[object] = set() - for label in sample_labels[active]: - value = label.item() if isinstance(label, np.generic) else label - if value in seen: - continue - seen.add(value) - key = value.decode("utf-8") if isinstance(value, bytes) else str(value) - sample_retention[key] = int( - (retained_mask & (sample_labels == label)).sum() - ) - notes = list(provenance["warnings"]) - if len(sample_retention) > _MAX_SAMPLE_RETENTION_ITEMS: - notes.append( - "Per-sample retention was truncated to the first " - f"{_MAX_SAMPLE_RETENTION_ITEMS} samples" - ) - sample_retention = dict( - list(sample_retention.items())[:_MAX_SAMPLE_RETENTION_ITEMS] - ) profile_id = _qc_profile_id( "sampleMad", driver=driver, sample_column=sample_column, sample_artifact=sample_artifact, ) + failures = ( + _capture_failure_models( + projection, + deps=deps, + characterization=characterization, + capture_labels=sample_labels, + metric_sources=metric_sources, + ) + if is_physical_capture + else [] + ) + skip_reasons = cast( + dict[str, object], + projection.parameters["skipReasons"], + ) profiles.append( CellQcProfileEvidence( profileId=profile_id, @@ -1326,19 +2467,39 @@ def _sample_qc_profiles( driverAssayType=driver[1], sampleColumn=sample_column, sampleArtifact=sample_artifact, + captureColumn=sample_column if is_physical_capture else None, + captureArtifact=sample_artifact if is_physical_capture else None, attributes=list(metadata_attributes), artifactMetrics=list(artifact_metrics), + metricSources=metric_sources, + sourceConcordance=source_concordance, parameters={ "nMads": 3.0, "minCellsPerSample": 20, - "nSamples": len(provenance["sample_sizes"]), - "nSkippedSamples": len(provenance["skip_reasons"]), + "nSamples": len(projection.captureSizes), + "nSkippedSamples": len(skip_reasons), }, + resolvedBounds=cast( + dict[str, Any], + projection.parameters["resolvedBounds"], + ), activeCells=active_cells, - retainedCells=retained_cells, - retainedFraction=retained_cells / active_cells, - sampleRetainedCells=sample_retention, - notes=notes, + retainedCells=projection.retainedCells, + retainedFraction=projection.retainedCells / active_cells, + activeCellsByCapture=projection.captureSizes, + sampleRetainedCells=projection.retainedByCapture, + flaggedCells=projection.flagCounts, + metricFlaggedCells=projection.metricFlagCounts, + failedCaptureCandidates=( + list(projection.failedCaptureCandidates) + if is_physical_capture + else [] + ), + captureFailureEvidence=failures, + excludableCaptureCandidates=[ + item.capture for item in failures if item.exclusionEligible + ], + notes=list(projection.warnings), evidenceId=f"qcProfile:{profile_id}", ) ) @@ -1400,54 +2561,58 @@ def _offered_qc_profiles( deps.qcProfiles = {profile.profileId: profile for profile in profiles} return profiles - driver_assay, driver_type = driver - metadata_attributes = _qc_attributes(deps.store, driver_assay, driver_type) - values_by_attr: dict[str, np.ndarray] = {} - attribute_notes: list[str] = [] - for attribute in metadata_attributes: - try: - values = np.asarray( - deps.cells.fetch(attribute), - dtype=float, - ) - except (TypeError, ValueError): - attribute_notes.append(f"Ignored non-numeric QC column {attribute!r}") - continue - if values.ndim != 1 or values.shape != active.shape: - attribute_notes.append(f"Ignored unaligned QC column {attribute!r}") - continue - if not np.isfinite(values).all(): - attribute_notes.append(f"Ignored non-finite QC column {attribute!r}") - continue - values_by_attr[attribute] = values - valid_metadata_attributes = [ - attribute for attribute in metadata_attributes if attribute in values_by_attr - ] - artifact_metrics: list[NamedArtifactSource] = [] - for source in deps.qualityMetricArtifacts: - artifact = _source_ref(source, expected_kind="quality_metric") - if artifact.assay != driver_assay: - continue - if source.name in values_by_attr: - raise ValueError( - f"QC artifact name {source.name!r} collides with a metadata metric" - ) - values = np.asarray( - _resolved_artifact_values( - deps, - source, - expected_kind="quality_metric", - ), - dtype=float, + ( + values_by_attr, + valid_metadata_attributes, + artifact_metrics, + metric_sources, + source_concordance, + attribute_notes, + values_by_source, + ) = _qc_metric_sources(deps, driver) + capture = _directed_capture_source(deps) + capture_column: str | None = None + capture_artifact: NamedArtifactSource | None = None + capture_labels: np.ndarray | None = None + capture_sizes: dict[str, int] = {} + if capture is not None: + capture_column, capture_artifact, capture_labels = capture + normalized = _validated_sample_labels( + capture_labels, + active, + label_name="physical capture labels", ) - if values.ndim != 1 or values.shape != active.shape: - raise ValueError( - f"QC artifact {source.name!r} does not align with cellSelection" + for raw in normalized: + value = raw.item() if isinstance(raw, np.generic) else raw + key = value.decode("utf-8") if isinstance(value, bytes) else str(value) + capture_sizes[key] = capture_sizes.get(key, 0) + 1 + metric_sources = _capture_source_missingness( + metric_sources, + values_by_source, + capture_labels, + ) + deps.qcMetricSources = metric_sources + deps.qcSourceConcordance = source_concordance + if not registered_only: + profiles = [ + CellQcProfileEvidence( + profileId=skip_id, + action="skip", + driverAssay=driver_assay, + driverAssayType=driver_type, + captureColumn=capture_column, + captureArtifact=capture_artifact, + metricSources=metric_sources, + sourceConcordance=source_concordance, + activeCells=active_cells, + retainedCells=active_cells, + retainedFraction=1.0, + activeCellsByCapture=capture_sizes, + sampleRetainedCells=capture_sizes, + notes=[*skip_notes, *attribute_notes], + evidenceId=f"qcProfile:{skip_id}", ) - if not np.isfinite(values).all(): - raise ValueError(f"QC artifact {source.name!r} contains non-finite values") - values_by_attr[source.name] = values - artifact_metrics.append(source) + ] if not registered_only: global_profile = _global_qc_profile( @@ -1459,6 +2624,10 @@ def _offered_qc_profiles( valid_metadata_attributes, artifact_metrics, attribute_notes, + characterization=characterization, + metric_sources=metric_sources, + source_concordance=source_concordance, + capture=capture, ) if global_profile is not None: profiles.append(global_profile) @@ -1472,6 +2641,9 @@ def _offered_qc_profiles( values_by_attr, valid_metadata_attributes, artifact_metrics, + metric_sources, + source_concordance, + capture, ) ) profiles.extend( @@ -1483,6 +2655,9 @@ def _offered_qc_profiles( values_by_attr=values_by_attr, metadata_attributes=valid_metadata_attributes, artifact_metrics=artifact_metrics, + metric_sources=metric_sources, + source_concordance=source_concordance, + capture=capture, ) ) @@ -1511,8 +2686,18 @@ async def inspect_cell_covariates( ) ctx.deps.characterization = characterization qc_profiles = _offered_qc_profiles(ctx.deps) + contrast_plans = contrast_plans_from_characterization(characterization) + ctx.deps.contrastPlans = {plan.coefficient: plan for plan in contrast_plans} evidence_ids = characterization_evidence(characterization) evidence_ids.update(profile.evidenceId for profile in qc_profiles) + evidence_ids.update(source.sourceId for source in ctx.deps.qcMetricSources) + evidence_ids.update(item.evidenceId for item in ctx.deps.qcSourceConcordance) + evidence_ids.update(plan.evidenceId for plan in contrast_plans) + evidence_ids.update( + failure.evidenceId + for profile in qc_profiles + for failure in profile.captureFailureEvidence + ) evidence_ids.update( f"htoIdentity:{column}" for column in ctx.deps.htoIdentityColumns ) @@ -1533,18 +2718,139 @@ async def inspect_cell_covariates( return CovariateEvidence( characterization=characterization, qcProfiles=qc_profiles, + qcMetricSources=ctx.deps.qcMetricSources, + qcSourceConcordance=ctx.deps.qcSourceConcordance, + contrastPlans=contrast_plans, htoIdentityColumns=ctx.deps.htoIdentityColumns, htoIdentityArtifacts=ctx.deps.htoIdentityArtifacts, evidenceIds=sorted(evidence_ids), ) +def _batch_safety_evidence( + deps: ExperimentalContextDependencies, + characterization: CovariateCharacterization, + *, + coefficients: Sequence[str], + batch_columns: Sequence[str], +) -> list[BatchSafetyEvidence]: + column_records = { + record.get("name"): record + for record in characterization.columns + if isinstance(record.get("name"), str) + } + coefficient_records = { + record.get("name"): record + for record in characterization.coefficients + if isinstance(record.get("name"), str) + } + confounding_reports = { + report.get("coefficient"): report + for report in characterization.confounding + if isinstance(report.get("coefficient"), str) + } + canonical_batch_columns = sorted(batch_columns) + batch_safety: list[BatchSafetyEvidence] = [] + for coefficient in coefficients: + if not canonical_batch_columns: + break + coefficient_record = coefficient_records.get(coefficient) + report = confounding_reports.get(coefficient) + coefficient_kind = ( + coefficient_record.get("kind") if coefficient_record is not None else None + ) + if coefficient_kind not in {"categorical", "continuous"}: + coefficient_kind = None + observation_unit = ( + report.get("observationUnit") + if report is not None + else ( + coefficient_record.get("observationUnit") + if coefficient_record is not None + else None + ) + ) + unit_constant = { + pair.get("technical") + for pair in (report.get("pairs", []) if report is not None else []) + if isinstance(pair.get("technical"), str) + } + effective_batch_columns = [ + name for name in canonical_batch_columns if name in unit_constant + ] + estimability: dict[str, Any] + if ( + coefficient_record is None + or coefficient_record.get("scope") != "betweenUnit" + or report is None + or not isinstance(observation_unit, str) + or coefficient_kind is None + ): + estimability = { + "status": "notComputed", + "reason": "unresolvedCoefficientDesign", + } + else: + try: + design = reduce_observation_units( + deps.cells, + observation_unit, + [coefficient, *effective_batch_columns], + cell_key="I", + ) + estimability = coefficient_estimability( + design[coefficient].to_numpy(), + coefficientKind=coefficient_kind, + technicals={ + name: design[name].to_numpy() + for name in effective_batch_columns + }, + technicalKinds={ + name: column_records[name]["kind"] + for name in effective_batch_columns + }, + ) + except (KeyError, TypeError, ValueError) as exc: + logger.debug( + "Experimental Context batch estimability was not computed: " + f"errorType={type(exc).__name__}" + ) + estimability = { + "status": "notComputed", + "reason": type(exc).__name__, + } + if estimability.get("status") != "ok": + safety_status: BatchSafetyStatus = "notComputed" + elif estimability.get("coefficientEstimable") is True and not bool( + estimability.get("rankDeficient") + ): + safety_status = "safe" + else: + safety_status = "unsafe" + batch_token = ",".join(canonical_batch_columns) + safety = BatchSafetyEvidence( + coefficient=coefficient, + coefficientKind=coefficient_kind, + observationUnit=( + observation_unit if isinstance(observation_unit, str) else None + ), + batchColumns=canonical_batch_columns, + unitConstantBatchColumns=effective_batch_columns, + status=safety_status, + estimability=estimability, + evidenceId=f"batchEstimability:{coefficient}:{batch_token}", + ) + batch_safety.append(safety) + deps.batchSafety[safety.evidenceId] = safety + return batch_safety + + async def analyze_experimental_design( ctx: RunContext[ExperimentalContextDependencies], column_domains: dict[str, ColumnDomain], coefficients_of_interest: list[str], units_of_inference: dict[str, InferenceUnit], - batch_columns: list[str] | str | None = None, + batch_columns: list[str], ) -> CovariateEvidence: """Validate proposed domains and inference units and compute confounding. @@ -1554,17 +2860,13 @@ async def analyze_experimental_design( coefficients_of_interest: Biological columns representing study contrasts. units_of_inference: Observation and independent units for each coefficient. batch_columns: Exact technical columns proposed for Harmony evaluation. - A single column may be supplied as either a string or a one-item list. """ - proposed_batch_count = ( - 1 if isinstance(batch_columns, str) else len(batch_columns or []) - ) logger.info( "Experimental Context design analysis started: " f"domains={len(column_domains)}, " f"coefficients={len(coefficients_of_interest)}, " f"inferenceUnits={len(units_of_inference)}, " - f"batchColumns={proposed_batch_count}" + f"batchColumns={len(batch_columns)}" ) directions = dict(ctx.deps.directions) directed_domains = dict(column_domains) @@ -1586,9 +2888,24 @@ async def analyze_experimental_design( directed_units.update(dict(directions.get("unitsOfInference") or {})) directions["unitsOfInference"] = directed_units - proposed_batch_columns = ( - [batch_columns] if isinstance(batch_columns, str) else list(batch_columns or []) - ) + proposed_batch_columns = list(batch_columns) + directed_batch_columns = directions.get("batchColumns") + if directed_batch_columns is not None: + if not isinstance(directed_batch_columns, list) or any( + not isinstance(value, str) or not value.strip() + for value in directed_batch_columns + ): + raise ModelRetry( + "directions.batchColumns must be a list of exact metadata columns" + ) + if len(set(directed_batch_columns)) != len(directed_batch_columns): + raise ModelRetry("directions.batchColumns must be unique") + if proposed_batch_columns != directed_batch_columns: + logger.info( + "Experimental Context replaced model-proposed batch columns with " + "the exact directed columns" + ) + proposed_batch_columns = list(directed_batch_columns) canonical_batch_columns = sorted(set(proposed_batch_columns)) if len(canonical_batch_columns) != len(proposed_batch_columns): logger.warning( @@ -1665,8 +2982,18 @@ async def analyze_experimental_design( if not ctx.deps.htoIdentityColumns: ctx.deps.htoIdentityColumns = _hto_identity_columns(ctx.deps) qc_profiles = _offered_qc_profiles(ctx.deps, characterization) + contrast_plans = contrast_plans_from_characterization(characterization) + ctx.deps.contrastPlans = {plan.coefficient: plan for plan in contrast_plans} evidence_ids = characterization_evidence(characterization) evidence_ids.update(profile.evidenceId for profile in qc_profiles) + evidence_ids.update(source.sourceId for source in ctx.deps.qcMetricSources) + evidence_ids.update(item.evidenceId for item in ctx.deps.qcSourceConcordance) + evidence_ids.update(plan.evidenceId for plan in contrast_plans) + evidence_ids.update( + failure.evidenceId + for profile in qc_profiles + for failure in profile.captureFailureEvidence + ) evidence_ids.update( f"htoIdentity:{column}" for column in ctx.deps.htoIdentityColumns ) @@ -1707,108 +3034,12 @@ async def analyze_experimental_design( f"Batch column {batch_column!r} must be categorical for Harmony" ) - coefficient_records = { - record.get("name"): record - for record in characterization.coefficients - if isinstance(record.get("name"), str) - } - confounding_reports = { - report.get("coefficient"): report - for report in characterization.confounding - if isinstance(report.get("coefficient"), str) - } - batch_safety: list[BatchSafetyEvidence] = [] - for coefficient in directed_coefficients: - if not canonical_batch_columns: - break - coefficient_record = coefficient_records.get(coefficient) - report = confounding_reports.get(coefficient) - coefficient_kind = ( - coefficient_record.get("kind") if coefficient_record is not None else None - ) - if coefficient_kind not in {"categorical", "continuous"}: - coefficient_kind = None - observation_unit = ( - report.get("observationUnit") - if report is not None - else ( - coefficient_record.get("observationUnit") - if coefficient_record is not None - else None - ) - ) - unit_constant = { - pair.get("technical") - for pair in (report.get("pairs", []) if report is not None else []) - if isinstance(pair.get("technical"), str) - } - effective_batch_columns = [ - name for name in canonical_batch_columns if name in unit_constant - ] - estimability: dict[str, Any] - if ( - coefficient_record is None - or coefficient_record.get("scope") != "betweenUnit" - or report is None - or not isinstance(observation_unit, str) - or coefficient_kind is None - ): - estimability = { - "status": "notComputed", - "reason": "unresolvedCoefficientDesign", - } - else: - try: - design = reduce_observation_units( - ctx.deps.cells, - observation_unit, - [coefficient, *effective_batch_columns], - cell_key="I", - ) - estimability = coefficient_estimability( - design[coefficient].to_numpy(), - coefficientKind=coefficient_kind, - technicals={ - name: design[name].to_numpy() - for name in effective_batch_columns - }, - technicalKinds={ - name: column_records[name]["kind"] - for name in effective_batch_columns - }, - ) - except (KeyError, TypeError, ValueError) as exc: - logger.debug( - "Experimental Context batch estimability was not computed: " - f"errorType={type(exc).__name__}" - ) - estimability = { - "status": "notComputed", - "reason": type(exc).__name__, - } - if estimability.get("status") != "ok": - safety_status: BatchSafetyStatus = "notComputed" - elif estimability.get("coefficientEstimable") is True and not bool( - estimability.get("rankDeficient") - ): - safety_status = "safe" - else: - safety_status = "unsafe" - batch_token = ",".join(canonical_batch_columns) - safety = BatchSafetyEvidence( - coefficient=coefficient, - coefficientKind=coefficient_kind, - observationUnit=( - observation_unit if isinstance(observation_unit, str) else None - ), - batchColumns=canonical_batch_columns, - unitConstantBatchColumns=effective_batch_columns, - status=safety_status, - estimability=estimability, - evidenceId=f"batchEstimability:{coefficient}:{batch_token}", - ) - batch_safety.append(safety) - ctx.deps.batchSafety[safety.evidenceId] = safety + batch_safety = _batch_safety_evidence( + ctx.deps, + characterization, + coefficients=directed_coefficients, + batch_columns=canonical_batch_columns, + ) evidence_ids.update(item.evidenceId for item in batch_safety) ctx.deps.evidenceIds.update(evidence_ids) @@ -1829,6 +3060,9 @@ async def analyze_experimental_design( characterization=characterization, batchSafety=batch_safety, qcProfiles=qc_profiles, + qcMetricSources=ctx.deps.qcMetricSources, + qcSourceConcordance=ctx.deps.qcSourceConcordance, + contrastPlans=contrast_plans, htoIdentityColumns=ctx.deps.htoIdentityColumns, htoIdentityArtifacts=ctx.deps.htoIdentityArtifacts, evidenceIds=sorted(evidence_ids), @@ -2168,6 +3402,29 @@ def _validate_batch_correction_plan( if isinstance(report.get("coefficient"), str) } plan = decision.batchCorrection + directed_batch_columns = deps.directions.get("batchColumns") + if directed_batch_columns is not None: + if not isinstance(directed_batch_columns, list) or any( + not isinstance(value, str) or not value.strip() + for value in directed_batch_columns + ): + raise ModelRetry( + "directions.batchColumns must be a list of exact metadata columns" + ) + canonical_directed_batch = sorted(directed_batch_columns) + directed_plan_mismatch = ( + ( + plan.action not in {"evaluateHarmony", "unsafe"} + or sorted(plan.batchColumns) != canonical_directed_batch + ) + if canonical_directed_batch + else plan.action != "skip" or bool(plan.batchColumns) + ) + if directed_plan_mismatch: + raise ModelRetry( + "The batch-correction plan must assess the exact directed batch " + f"columns: {canonical_directed_batch}" + ) unknown_columns = sorted(set(decision.columnDomains) - set(records)) if unknown_columns: raise ModelRetry(f"Unknown column domain assignments: {unknown_columns}") @@ -2405,6 +3662,9 @@ def validate_experimental_context( raise ModelRetry("; ".join(characterization.notes)) deps.characterization = characterization deps.evidenceIds.update(characterization_evidence(characterization)) + contrast_plans = contrast_plans_from_characterization(characterization) + deps.contrastPlans = {plan.coefficient: plan for plan in contrast_plans} + deps.evidenceIds.update(plan.evidenceId for plan in contrast_plans) if "inspect_cell_covariates" not in deps.toolCalls: raise ModelRetry("Call inspect_cell_covariates before returning a decision") @@ -2419,6 +3679,8 @@ def validate_experimental_context( if not deps.qcProfiles: _offered_qc_profiles(deps, characterization) deps.evidenceIds.update(profile.evidenceId for profile in deps.qcProfiles.values()) + deps.evidenceIds.update(source.sourceId for source in deps.qcMetricSources) + deps.evidenceIds.update(item.evidenceId for item in deps.qcSourceConcordance) requested_coefficients = set(directions["coefficientsOfInterest"]) characterized_coefficients = { @@ -2494,6 +3756,196 @@ def validate_experimental_context( return validated +def _deterministic_experimental_context_decision( + deps: ExperimentalContextDependencies, +) -> ExperimentalContextDecision: + characterization = deps.characterization + if characterization is None or characterization.status == "failed": + raise ValueError("Deterministic covariate characterization is unavailable") + records: dict[str, dict[str, Any]] = {} + for record in characterization.columns: + name = record.get("name") + if isinstance(name, str): + records[name] = record + coefficient_records: dict[str, dict[str, Any]] = {} + for record in characterization.coefficients: + name = record.get("name") + if isinstance(name, str): + coefficient_records[name] = record + directions = dict(deps.directions) + raw_batch_columns = directions.get("batchColumns") + if raw_batch_columns is not None: + if not isinstance(raw_batch_columns, list) or any( + not isinstance(value, str) or not value.strip() + for value in raw_batch_columns + ): + raise ValueError( + "directions.batchColumns must be a list of exact metadata columns" + ) + if len(raw_batch_columns) != len(set(raw_batch_columns)): + raise ValueError("directions.batchColumns must be unique") + batch_columns = list(raw_batch_columns) + else: + candidates = sorted( + name + for name, record in records.items() + if record.get("domain") == "technical" + and record.get("kind") == "categorical" + ) + if "batch" in candidates: + batch_columns = ["batch"] + elif len(candidates) <= 1: + batch_columns = candidates + else: + raise ValueError( + "Multiple categorical technical columns remain without one exact " + "batch condition" + ) + + coefficients = [ + str(record["name"]) + for record in characterization.coefficients + if isinstance(record.get("name"), str) + ] + units = { + coefficient: InferenceUnit( + observationUnit=coefficient_records[coefficient].get("observationUnit"), + independentUnit=coefficient_records[coefficient].get("independentUnit"), + ) + for coefficient in coefficients + if coefficient in coefficient_records + } + batch_safety = _batch_safety_evidence( + deps, + characterization, + coefficients=coefficients, + batch_columns=batch_columns, + ) + unresolved_safety = [ + item.coefficient for item in batch_safety if item.status == "notComputed" + ] + if unresolved_safety: + raise ValueError( + "Batch estimability is unavailable for coefficients: " + f"{sorted(unresolved_safety)}" + ) + if batch_columns and any(item.status == "unsafe" for item in batch_safety): + action: BatchCorrectionAction = "unsafe" + elif batch_columns: + action = "evaluateHarmony" + else: + action = "skip" + categorical_coefficients = [ + coefficient + for coefficient in coefficients + if records[coefficient].get("kind") == "categorical" + ] + if action == "evaluateHarmony" and set(categorical_coefficients) != set( + coefficients + ): + raise ValueError( + "Harmony preservation requires categorical coefficients of interest" + ) + + known_evidence = sorted(characterization_evidence(characterization)) + batch_evidence = [ + *(f"column:{column}" for column in batch_columns), + *(item.evidenceId for item in batch_safety), + ] + if not batch_evidence: + batch_evidence = known_evidence[:1] + if not batch_evidence: + raise ValueError("No deterministic evidence supports a batch decision") + deps.evidenceIds.update(known_evidence) + deps.evidenceIds.update(batch_evidence) + if "analyze_experimental_design" not in deps.toolCalls: + deps.toolCalls.append("analyze_experimental_design") + column_domains = { + name: cast(ColumnDomain, record["domain"]) + for name, record in records.items() + if record.get("domain") + in {"biological", "technical", "design", "ignore", "unknown"} + } + metrics_required: list[IntegrationMetric] = [] + if action == "evaluateHarmony": + metrics_required = ["iLISI", "proportionalBatchMixing"] + if categorical_coefficients: + metrics_required.extend(["cLISI", "graphConnectivity"]) + plan = BatchCorrectionPlan( + action=action, + batchColumns=batch_columns if action != "skip" else [], + preserveColumns=( + categorical_coefficients if action == "evaluateHarmony" else [] + ), + metricsRequired=metrics_required, + rationale=( + "Evaluate the exact declared categorical technical batch condition " + "against the uncorrected representation." + if action == "evaluateHarmony" + else "The exact batch condition is confounded with the study design." + if action == "unsafe" + else "No exact categorical technical batch condition was available." + ), + evidenceIds=sorted(set(batch_evidence)), + ) + decision = ExperimentalContextDecision( + columnDomains=column_domains, + coefficientsOfInterest=coefficients, + unitsOfInference=units, + batchCorrection=plan, + rationale=( + "Deterministic covariate characterization resolved the study design " + "after the model tool call failed." + ), + evidenceIds=known_evidence, + ) + return validate_experimental_context(decision, deps) + + +def failed_experimental_context_result( + deps: ExperimentalContextDependencies, + *, + error: Exception, + fallback_error: Exception, + model_name: str, +) -> ExperimentalContextResult: + """Fail unattended execution when deterministic design evidence is insufficient.""" + characterization = deps.characterization or CovariateCharacterization( + status="failed", + notes=["Deterministic covariate characterization is unavailable."], + ) + model_detail = str(error).replace("\n", " ").strip()[:500] + fallback_detail = str(fallback_error).replace("\n", " ").strip()[:500] + return ExperimentalContextResult( + status="failed", + decision=ExperimentalContextDecision( + rationale="No validated experimental-context decision was available.", + evidenceIds=sorted(deps.evidenceIds), + ), + characterization=characterization, + cellSelection=artifact_reference(deps.cellSelection), + cellQc=CellQcPlan.get_blank(), + qcProfiles=list(deps.qcProfiles.values()), + qcMetricSources=deps.qcMetricSources, + qcSourceConcordance=deps.qcSourceConcordance, + contrastPlans=list(deps.contrastPlans.values()), + qualityMetricArtifacts=deps.qualityMetricArtifacts, + htoIdentityColumns=deps.htoIdentityColumns, + htoIdentityArtifacts=deps.htoIdentityArtifacts, + batchSafety=list(deps.batchSafety.values()), + currentRepresentation=deps.currentRepresentation, + notes=[ + "The model did not produce a validated experimental-context decision.", + f"Model failure: {model_detail}", + f"Deterministic recovery failure: {fallback_detail}", + ], + runInfo=AgentRunInfo( + agentName="experimental_context_failed", + modelName=model_name, + ), + ) + + def pending_experimental_context_result( deps: ExperimentalContextDependencies, *, @@ -2519,8 +3971,13 @@ def pending_experimental_context_result( qc_profiles = list(deps.qcProfiles.values()) if not qc_profiles: qc_profiles = _offered_qc_profiles(deps, characterization) + contrast_plans = contrast_plans_from_characterization(characterization) + deps.contrastPlans = {plan.coefficient: plan for plan in contrast_plans} evidence_ids = characterization_evidence(characterization) evidence_ids.update(profile.evidenceId for profile in qc_profiles) + evidence_ids.update(source.sourceId for source in deps.qcMetricSources) + evidence_ids.update(item.evidenceId for item in deps.qcSourceConcordance) + evidence_ids.update(plan.evidenceId for plan in contrast_plans) evidence_ids.update(f"htoIdentity:{column}" for column in deps.htoIdentityColumns) evidence_ids.update( _artifact_evidence_id(source) for source in deps.htoIdentityArtifacts @@ -2550,6 +4007,9 @@ def pending_experimental_context_result( cellSelection=artifact_reference(deps.cellSelection), cellQc=CellQcPlan.get_blank(), qcProfiles=qc_profiles, + qcMetricSources=deps.qcMetricSources, + qcSourceConcordance=deps.qcSourceConcordance, + contrastPlans=contrast_plans, qualityMetricArtifacts=deps.qualityMetricArtifacts, htoIdentityColumns=deps.htoIdentityColumns, htoIdentityArtifacts=deps.htoIdentityArtifacts, @@ -2571,8 +4031,10 @@ def __init__( model: Any, *, config: AgentRunConfig | None = None, + unattended: bool = False, ) -> None: self.model = model + self.unattended = unattended self.config = (config or AgentRunConfig()).with_limits( request_limit=9, tool_call_limit=5, @@ -2701,7 +4163,12 @@ def run( raise ValueError( "neighbors and connectivity_map must use the same cell selection" ) - quality_sources = list(quality_metric_artifacts) + quality_sources = _derive_missing_percentage_artifacts( + store, + cell_selection=cell_selection, + driver=_qc_driver(store), + quality_sources=quality_metric_artifacts, + ) hto_sources = list(hto_identity_artifacts) source_names: set[str] = set() for sources, expected_kind in ( @@ -2805,7 +4272,7 @@ def run( ), Tool( analyze_experimental_design, - max_retries=1, + max_retries=3, prepare=_prepare_experimental_context_tool, sequential=self.config.sequentialTools, timeout=self.config.timeoutSeconds, @@ -2828,12 +4295,59 @@ def run( ) except UnexpectedModelBehavior as exc: model_name = getattr(self.model, "model_name", type(self.model).__name__) - return pending_experimental_context_result( - deps, - error=exc, - model_name=str(model_name), + if self.unattended: + try: + decision = _deterministic_experimental_context_decision(deps) + except ( + ModelRetry, + RuntimeError, + TypeError, + ValueError, + ) as fallback_exc: + return failed_experimental_context_result( + deps, + error=exc, + fallback_error=fallback_exc, + model_name=str(model_name), + ) + run_info = AgentRunInfo( + agentName="experimental_context_deterministic", + modelName=str(model_name), + ) + else: + return pending_experimental_context_result( + deps, + error=exc, + model_name=str(model_name), + ) + else: + decision = ExperimentalContextDecision.model_validate(execution.output) + run_info = execution.runInfo + if self.unattended and ( + decision.needsInput or decision.batchCorrection.action == "needsInput" + ): + try: + decision = _deterministic_experimental_context_decision(deps) + except (ModelRetry, RuntimeError, TypeError, ValueError) as fallback_exc: + model_name = getattr( + self.model, "model_name", type(self.model).__name__ + ) + return failed_experimental_context_result( + deps, + error=RuntimeError( + "The model returned an unresolved experimental-context decision" + ), + fallback_error=fallback_exc, + model_name=str(model_name), + ) + run_info = AgentRunInfo( + agentName="experimental_context_deterministic", + modelName=getattr( + self.model, + "model_name", + type(self.model).__name__, + ), ) - decision = ExperimentalContextDecision.model_validate(execution.output) characterization = deps.characterization if characterization is None: characterization = characterize_covariates( @@ -2857,6 +4371,9 @@ def run( f"coefficients={len(decision.coefficientsOfInterest)}, " f"toolCalls={len(deps.toolCalls)}, evidence={len(deps.evidenceIds)}" ) + contrast_plans = list(deps.contrastPlans.values()) + if not contrast_plans: + contrast_plans = contrast_plans_from_characterization(characterization) return ExperimentalContextResult( status=status, decision=decision, @@ -2864,11 +4381,14 @@ def run( cellSelection=artifact_reference(cell_selection), cellQc=CellQcPlan.get_blank(), qcProfiles=list(deps.qcProfiles.values()), + qcMetricSources=deps.qcMetricSources, + qcSourceConcordance=deps.qcSourceConcordance, + contrastPlans=contrast_plans, qualityMetricArtifacts=deps.qualityMetricArtifacts, htoIdentityColumns=deps.htoIdentityColumns, htoIdentityArtifacts=deps.htoIdentityArtifacts, batchSafety=list(deps.batchSafety.values()), currentRepresentation=deps.currentRepresentation, notes=[*characterization.notes, *decision.needsInput], - runInfo=execution.runInfo, + runInfo=run_info, ) diff --git a/scarf/agent/hvg_diagnostics.py b/scarf/agent/hvg_diagnostics.py index 03fc0c42..c0c8a2b8 100644 --- a/scarf/agent/hvg_diagnostics.py +++ b/scarf/agent/hvg_diagnostics.py @@ -1,3 +1,4 @@ +import re from collections.abc import Callable, Iterable, Mapping, Sequence from dataclasses import dataclass from typing import Any, Literal @@ -6,7 +7,7 @@ import zarr from ..assay import RNAassay -from ..features.variability import fit_lowess +from ..features.variability import DEFAULT_HVG_BLACKLIST, fit_lowess from ..storage.arrays import create_zarr_dataset from ..storage.artifact_writer import ( ArrayRequirement, @@ -36,6 +37,7 @@ from ..storage.types import as_zarr_array HVG_CANDIDATE_TARGETS = (1000, 2000, 4000) +_HVG_COMPARISON_EXAMPLE_LIMIT = 8 _HVG_DIAGNOSTIC_ARRAYS = ( "eligible", "global_corrected_variance", @@ -84,6 +86,39 @@ def candidate_mask(self, top_n: int) -> np.ndarray: return values +@dataclass(frozen=True, slots=True) +class HvgDefaultFamilyLeakage: + """Default-family representation within one agent-ranked HVG candidate.""" + + family: str + pattern: str + inventory_count: int + scarf_default_selected_count: int + agent_selected_count: int + agent_only_count: int + agent_selected_fraction: float + examples: tuple[str, ...] + + +@dataclass(frozen=True, slots=True) +class HvgSelectionComparison: + """Overlap between one Scarf-default selection and one agent candidate.""" + + ranking_mode: Literal["global", "batchAware"] + top_n: int + scarf_default_blacklist: str + scarf_default_count: int + agent_count: int + overlap_count: int + union_count: int + scarf_default_only_count: int + agent_only_count: int + scarf_default_overlap_fraction: float + agent_overlap_fraction: float + jaccard: float + default_family_leakage: tuple[HvgDefaultFamilyLeakage, ...] + + @dataclass(frozen=True, slots=True) class HvgCandidateArtifact: """One persisted candidate with its effective capped feature count.""" @@ -132,6 +167,109 @@ def effective_hvg_candidate_counts( return tuple(resolved) +def compare_hvg_ranking_to_default( + scarf_default_selection: np.ndarray, + ranking: HvgRanking, + *, + feature_names: Sequence[Any], + default_family_patterns: Mapping[str, str], + max_examples: int = _HVG_COMPARISON_EXAMPLE_LIMIT, +) -> tuple[HvgSelectionComparison, ...]: + """Compare registered agent candidates with an exact Scarf-default selection.""" + scarf_default = np.asarray(scarf_default_selection, dtype=bool) + if scarf_default.ndim != 1: + raise ValueError("scarf_default_selection must be a one-dimensional mask") + if ranking.eligible.shape != scarf_default.shape: + raise ValueError("Scarf-default and agent feature axes must align") + if isinstance(feature_names, str | bytes): + raise TypeError("feature_names must be a sequence") + names = np.asarray( + ["" if value is None else str(value) for value in feature_names], + dtype=object, + ) + if names.shape != scarf_default.shape: + raise ValueError("feature_names must align with the feature-selection masks") + if isinstance(max_examples, bool) or not isinstance(max_examples, int): + raise TypeError("max_examples must be an integer") + if not 0 <= max_examples <= _HVG_COMPARISON_EXAMPLE_LIMIT: + raise ValueError( + f"max_examples must be between 0 and {_HVG_COMPARISON_EXAMPLE_LIMIT}" + ) + + family_masks: list[tuple[str, str, np.ndarray]] = [] + for family, pattern in sorted(default_family_patterns.items()): + if not isinstance(family, str) or not family: + raise ValueError("Default-family names must be non-empty strings") + if not isinstance(pattern, str) or not pattern: + raise ValueError("Default-family patterns must be non-empty strings") + compiled = re.compile(pattern.upper()) + mask = np.fromiter( + (compiled.match(name.upper()) is not None for name in names), + dtype=bool, + count=len(names), + ) + family_masks.append((family, pattern, mask)) + + scarf_default_count = int(scarf_default.sum()) + comparisons: list[HvgSelectionComparison] = [] + for top_n in ranking.candidate_counts: + agent = ranking.candidate_mask(top_n) + agent_count = int(agent.sum()) + if agent_count != top_n: + raise ValueError( + "Agent rankings must contain distinct indices for every candidate" + ) + overlap = scarf_default & agent + union = scarf_default | agent + overlap_count = int(overlap.sum()) + union_count = int(union.sum()) + leakage: list[HvgDefaultFamilyLeakage] = [] + for family, pattern, family_mask in family_masks: + selected = agent & family_mask + selected_names = sorted( + set(names[selected].tolist()), + key=lambda value: (value.casefold(), value), + ) + leakage.append( + HvgDefaultFamilyLeakage( + family=family, + pattern=pattern, + inventory_count=int(family_mask.sum()), + scarf_default_selected_count=int( + (scarf_default & family_mask).sum() + ), + agent_selected_count=int(selected.sum()), + agent_only_count=int((selected & ~scarf_default).sum()), + agent_selected_fraction=( + float(selected.sum()) / agent_count if agent_count else 0.0 + ), + examples=tuple(selected_names[:max_examples]), + ) + ) + comparisons.append( + HvgSelectionComparison( + ranking_mode=ranking.ranking_mode, + top_n=top_n, + scarf_default_blacklist=DEFAULT_HVG_BLACKLIST, + scarf_default_count=scarf_default_count, + agent_count=agent_count, + overlap_count=overlap_count, + union_count=union_count, + scarf_default_only_count=int((scarf_default & ~agent).sum()), + agent_only_count=int((agent & ~scarf_default).sum()), + scarf_default_overlap_fraction=( + overlap_count / scarf_default_count if scarf_default_count else 0.0 + ), + agent_overlap_fraction=( + overlap_count / agent_count if agent_count else 0.0 + ), + jaccard=overlap_count / union_count if union_count else 1.0, + default_family_leakage=tuple(leakage), + ) + ) + return tuple(comparisons) + + def corrected_variance_from_summary( summary: Mapping[str, np.ndarray], *, @@ -722,10 +860,13 @@ def run_hvg_diagnostic_artifacts( __all__ = [ "HVG_CANDIDATE_TARGETS", "HvgCandidateArtifact", + "HvgDefaultFamilyLeakage", "HvgDiagnosticArtifacts", "HvgGroupVariability", "HvgRanking", + "HvgSelectionComparison", "aggregate_hvg_rankings", + "compare_hvg_ranking_to_default", "corrected_variance_from_summary", "effective_hvg_candidate_counts", "run_hvg_diagnostic_artifacts", diff --git a/scarf/agent/hypothesis_testing.py b/scarf/agent/hypothesis_testing.py new file mode 100644 index 00000000..eea70ba5 --- /dev/null +++ b/scarf/agent/hypothesis_testing.py @@ -0,0 +1,360 @@ +"""Evidence-gated execution of existing Scarf statistical tests.""" + +from typing import Any, Literal + +from ..metadata.selection import CellField +from ..storage.refs import ArtifactRef +from .config._deps import AGENT_INSTALL_HINT +from .experimental_context import ContrastPlan +from .tools import artifact_reference, core_artifact_reference +from .types import AgentDataModel, ArtifactReferenceModel + +try: + from pydantic import Field, model_validator +except ImportError as exc: + raise ImportError(AGENT_INSTALL_HINT) from exc + +type FeaturePanelPurpose = Literal["explicit", "exploratoryMarkers"] +type HypothesisExecutionStatus = Literal["executed", "blocked", "needsInput"] + +_NORMALIZED_EXPRESSION_SCOPE = ( + "Sample-level normalized-expression distribution testing. This is not a " + "raw-count pseudobulk differential-expression model." +) + + +class HypothesisFeaturePanel(AgentDataModel): + """Features kept under one explicit or exploratory provenance label.""" + + panelId: str = "" + purpose: FeaturePanelPurpose = "explicit" + features: list[str] = Field(default_factory=list) + sourceArtifact: ArtifactReferenceModel | None = None + evidenceIds: list[str] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_panel(self) -> "HypothesisFeaturePanel": + if self.panelId != self.panelId.strip(): + raise ValueError("Feature panel ids cannot contain surrounding whitespace") + if any( + not feature.strip() or feature != feature.strip() + for feature in self.features + ): + raise ValueError("Feature names must be non-empty trimmed strings") + if len(self.features) != len(set(self.features)): + raise ValueError("Feature names must be unique within a panel") + if self.sourceArtifact is not None and not self.sourceArtifact.artifactId: + raise ValueError("Feature panel source artifacts must be exact") + if self.purpose == "exploratoryMarkers" and self.sourceArtifact is None: + raise ValueError( + "Exploratory marker panels require their exact source artifact" + ) + return self + + +class ClusterSelectionContract(AgentDataModel): + """An exact cluster artifact and labels used for a within-cluster test.""" + + clusterArtifact: ArtifactReferenceModel + include: list[str | int | float | bool] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_selection(self) -> "ClusterSelectionContract": + if not self.clusterArtifact.artifactId: + raise ValueError("Cluster selection requires an exact artifact") + if not self.include: + raise ValueError("Cluster selection requires at least one label") + keys = [(type(value).__name__, repr(value)) for value in self.include] + if len(keys) != len(set(keys)): + raise ValueError("Cluster labels must be unique") + return self + + +class HypothesisContract(AgentDataModel): + """One immutable-input hypothesis family licensed by a contrast plan.""" + + contractId: str = "" + familyId: str = "" + contrast: ContrastPlan = Field(default_factory=ContrastPlan.get_blank) + cellSelection: ArtifactReferenceModel | None = None + groupingArtifact: ArtifactReferenceModel | None = None + clusterSelection: ClusterSelectionContract | None = None + featurePanels: list[HypothesisFeaturePanel] = Field(default_factory=list) + fromAssay: str | None = None + adjustment: Literal["fdr_bh"] = "fdr_bh" + evidenceIds: list[str] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_contract(self) -> "HypothesisContract": + for name, value in ( + ("contractId", self.contractId), + ("familyId", self.familyId), + ): + if value != value.strip(): + raise ValueError(f"{name} cannot contain surrounding whitespace") + panel_ids = [panel.panelId for panel in self.featurePanels] + if len(panel_ids) != len(set(panel_ids)): + raise ValueError("Hypothesis feature panel ids must be unique") + if self.cellSelection is not None and ( + self.cellSelection.scope != "datastore" + or self.cellSelection.kind != "cell_selection" + or not self.cellSelection.artifactId + ): + raise ValueError( + "Hypothesis cellSelection must be an exact datastore selection" + ) + if self.groupingArtifact is not None and not self.groupingArtifact.artifactId: + raise ValueError("Hypothesis groupingArtifact must be exact") + return self + + +class HypothesisTestExecution(AgentDataModel): + """Executed artifact references or explicit reasons no test was run.""" + + contractId: str = "" + familyId: str = "" + status: HypothesisExecutionStatus = "blocked" + contrast: ContrastPlan = Field(default_factory=ContrastPlan.get_blank) + featurePanels: list[HypothesisFeaturePanel] = Field(default_factory=list) + testedFeatures: list[str] = Field(default_factory=list) + inputCellSelection: ArtifactReferenceModel | None = None + effectiveCellSelection: ArtifactReferenceModel | None = None + groupingArtifact: ArtifactReferenceModel | None = None + clusterArtifact: ArtifactReferenceModel | None = None + statisticalTestArtifact: ArtifactReferenceModel | None = None + adjustment: Literal["fdr_bh"] = "fdr_bh" + blockedReasons: list[str] = Field(default_factory=list) + claimScope: str = _NORMALIZED_EXPRESSION_SCOPE + evidenceIds: list[str] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_execution(self) -> "HypothesisTestExecution": + if self.status == "executed": + if self.statisticalTestArtifact is None or self.blockedReasons: + raise ValueError( + "Executed hypothesis tests require an artifact and no block" + ) + elif not self.blockedReasons: + raise ValueError("Blocked hypothesis tests require explicit reasons") + return self + + @classmethod + def get_blank(cls) -> "HypothesisTestExecution": + return cls(blockedReasons=["hypothesisContractIsUnresolved"]) + + +def _blocked_execution( + contract: HypothesisContract, + *, + reasons: list[str], + status: HypothesisExecutionStatus, + effective_selection: ArtifactRef | None = None, +) -> HypothesisTestExecution: + return HypothesisTestExecution( + contractId=contract.contractId, + familyId=contract.familyId, + status=status, + contrast=contract.contrast, + featurePanels=contract.featurePanels, + inputCellSelection=contract.cellSelection, + effectiveCellSelection=( + artifact_reference(effective_selection) + if effective_selection is not None + else contract.cellSelection + ), + groupingArtifact=contract.groupingArtifact, + clusterArtifact=( + contract.clusterSelection.clusterArtifact + if contract.clusterSelection is not None + else None + ), + blockedReasons=list(dict.fromkeys(reasons)), + evidenceIds=list( + dict.fromkeys( + [ + *contract.evidenceIds, + *contract.contrast.evidenceIds, + *( + evidence_id + for panel in contract.featurePanels + for evidence_id in panel.evidenceIds + ), + ] + ) + ), + ) + + +def execute_hypothesis_contract( + store: Any, + contract: HypothesisContract, + *, + invalidate_cache: bool = False, +) -> HypothesisTestExecution: + """Execute one fully licensed family through ``run_statistical_testing``.""" + if not isinstance(contract, HypothesisContract): + raise TypeError("contract must be a HypothesisContract") + contrast = contract.contrast + if contrast.status != "licensed": + status: HypothesisExecutionStatus = ( + "needsInput" if contrast.status == "needsInput" else "blocked" + ) + return _blocked_execution( + contract, + reasons=contrast.blockedReasons or ["contrastIsNotLicensed"], + status=status, + ) + safety_failures = [ + reason + for passed, reason in ( + (contrast.betweenUnitDesign, "coefficientIsNotBetweenUnit"), + (contrast.replicationPassed, "insufficientIndependentReplication"), + (contrast.estimabilityPassed, "coefficientIsNotEstimable"), + ( + contrast.pairBy is None or contrast.pairedCoveragePassed is True, + "pairedCoverageIsIncomplete", + ), + ) + if not passed + ] + if safety_failures: + return _blocked_execution( + contract, + reasons=safety_failures, + status="blocked", + ) + if contract.cellSelection is None: + return _blocked_execution( + contract, + reasons=["cellSelectionIsUnresolved"], + status="needsInput", + ) + if not contract.featurePanels: + return _blocked_execution( + contract, + reasons=["featurePanelIsUnresolved"], + status="needsInput", + ) + tested_features = list( + dict.fromkeys( + feature for panel in contract.featurePanels for feature in panel.features + ) + ) + if not tested_features: + return _blocked_execution( + contract, + reasons=["featurePanelIsEmpty"], + status="needsInput", + ) + + input_selection = core_artifact_reference(contract.cellSelection) + if not isinstance(input_selection, ArtifactRef): + raise TypeError("cellSelection must resolve to an ArtifactRef") + effective_selection = input_selection + try: + if contract.clusterSelection is not None: + cluster_artifact = core_artifact_reference( + contract.clusterSelection.clusterArtifact + ) + if not isinstance(cluster_artifact, ArtifactRef): + raise TypeError("clusterArtifact must resolve to an ArtifactRef") + effective_selection = store.select_cells( + cluster_artifact, + include=contract.clusterSelection.include, + cell_selection=input_selection, + invalidate_cache=invalidate_cache, + ) + + grouping = ( + core_artifact_reference(contract.groupingArtifact) + if contract.groupingArtifact is not None + else CellField(contrast.coefficient, kind="categorical") + ) + if not isinstance(grouping, ArtifactRef | CellField): + raise TypeError("grouping source could not be resolved") + if contrast.test is None or contrast.sampleBy is None: + return _blocked_execution( + contract, + reasons=["contrastExecutionFieldsAreUnresolved"], + status="needsInput", + effective_selection=effective_selection, + ) + result = store.run_statistical_testing( + tested_features, + grouping, + cell_selection=effective_selection, + groups=contrast.groupOrder, + test=contrast.test, + adjustment=contract.adjustment, + sample_by=contrast.sampleBy, + pair_by=contrast.pairBy, + sample_stat=contrast.sampleStatistic, + expression_cutoff=contrast.expressionCutoff, + from_assay=contract.fromAssay, + skip_save=False, + invalidate_cache=invalidate_cache, + ) + except (KeyError, TypeError, ValueError) as exc: + return _blocked_execution( + contract, + reasons=[f"coreRejected:{type(exc).__name__}:{exc}"], + status="blocked", + effective_selection=effective_selection, + ) + + artifact = getattr(result, "artifact", None) + if not isinstance(artifact, ArtifactRef): + raise RuntimeError( + "run_statistical_testing did not persist an exact result artifact" + ) + if getattr(result, "method", None) != contrast.test: + raise RuntimeError("Statistical test method differs from its contrast license") + if list(getattr(result, "group_order", ())) != contrast.groupOrder: + raise RuntimeError("Statistical group order differs from its contrast license") + if getattr(result, "sample_by", None) != contrast.sampleBy: + raise RuntimeError("Statistical sample unit differs from its contrast license") + if getattr(result, "pair_by", None) != contrast.pairBy: + raise RuntimeError("Statistical pair unit differs from its contrast license") + return HypothesisTestExecution( + contractId=contract.contractId, + familyId=contract.familyId, + status="executed", + contrast=contrast, + featurePanels=contract.featurePanels, + testedFeatures=tested_features, + inputCellSelection=contract.cellSelection, + effectiveCellSelection=artifact_reference(effective_selection), + groupingArtifact=contract.groupingArtifact, + clusterArtifact=( + contract.clusterSelection.clusterArtifact + if contract.clusterSelection is not None + else None + ), + statisticalTestArtifact=artifact_reference(artifact), + adjustment=contract.adjustment, + evidenceIds=list( + dict.fromkeys( + [ + *contract.evidenceIds, + contrast.evidenceId, + *contrast.evidenceIds, + *( + evidence_id + for panel in contract.featurePanels + for evidence_id in panel.evidenceIds + ), + ] + ) + ), + ) + + +__all__ = [ + "ClusterSelectionContract", + "FeaturePanelPurpose", + "HypothesisContract", + "HypothesisExecutionStatus", + "HypothesisFeaturePanel", + "HypothesisTestExecution", + "execute_hypothesis_contract", +] diff --git a/scarf/agent/ingest/manifest.py b/scarf/agent/ingest/manifest.py index d6a00490..65b90abb 100644 --- a/scarf/agent/ingest/manifest.py +++ b/scarf/agent/ingest/manifest.py @@ -220,6 +220,10 @@ class DatasetManifest(AgentDataModel): assayMetadata: MetadataColumnSummary | None = None suspensionMetadata: MetadataColumnSummary | None = None organismMetadata: MetadataColumnSummary | None = None + declaredBatchColumns: list[str] = Field( + default_factory=list, + exclude_if=lambda value: not value, + ) inventory: H5adInventory priorFiltering: PriorFilteringFacts decision: DatasetManifestDecision @@ -582,6 +586,36 @@ def _read_text_scalar( return None +def _read_text_vector( + h5: h5py.File, + paths: tuple[str, ...], + *, + max_items: int = 32, + max_length: int = 256, +) -> list[str]: + for path in paths: + node = h5.get(path) + if not isinstance(node, h5py.Dataset): + continue + if node.shape == (): + values = [node[()]] + elif len(node.shape) == 1: + if node.shape[0] > max_items: + raise ValueError(f"{path} contains too many values") + values = list(np.asarray(node[:]).reshape(-1)) + else: + continue + resolved = list( + dict.fromkeys( + text + for value in values + if (text := _as_text(value).strip()[:max_length]) + ) + ) + return resolved + return [] + + def _column_by_name( *tables: MetadataTableSummary | None, names: tuple[str, ...], @@ -878,6 +912,22 @@ def inspect_h5ad_manifest( h5, ("uns/schema_reference", "uns/cellxgene_schema_reference"), ) + declared_batch_columns = _read_text_vector( + h5, + ("uns/batch_condition",), + ) + obs_node = h5.get("obs") + obs_columns = ( + set(_column_names(obs_node)) + if isinstance(obs_node, h5py.Group | h5py.Dataset) + else set() + ) + unknown_batch_columns = sorted(set(declared_batch_columns) - obs_columns) + if unknown_batch_columns: + raise ValueError( + "uns/batch_condition references unknown obs columns: " + f"{unknown_batch_columns}" + ) selected_table = raw_var if inspection.featureAttrsKey == "raw/var" else var assay = _column_by_name( @@ -964,6 +1014,7 @@ def inspect_h5ad_manifest( assayMetadata=assay, suspensionMetadata=suspension, organismMetadata=organism, + declaredBatchColumns=declared_batch_columns, inventory=inventory, priorFiltering=prior_filtering, decision=decision, diff --git a/scarf/agent/orchestrator/context.py b/scarf/agent/orchestrator/context.py index dc659f94..931cd7c6 100644 --- a/scarf/agent/orchestrator/context.py +++ b/scarf/agent/orchestrator/context.py @@ -276,6 +276,7 @@ def data_enrichment_stage( agent = DataEnrichmentAgent( self.model, config=request_record.config.agentRunConfig, + unattended=request_record.config.inputPolicy == "unattended", ) report = agent.run( store, @@ -309,6 +310,22 @@ def data_enrichment_stage( f"inspections={len(report.inspections)}" ) if report.status == "needsInput": + if request_record.config.inputPolicy == "unattended": + outcome = journal._complete_attempt( + started, + status="failed", + report_references=[reference], + artifacts={"cellSelection": cell_selection}, + actions=actions, + outputs={"operations": operations}, + error=( + "The unattended Data Enrichment stage returned an " + "unresolved decision" + ), + notes=report.limitations, + ) + journal._save_outcome(store.zw, prefix, outcome) + return outcome, report questions = [ WorkflowQuestion( questionId="dataEnrichmentContext", @@ -889,6 +906,7 @@ def find_held_out_references(value: Any) -> None: agent = ExperimentalContextAgent( self.model, config=request_record.config.agentRunConfig, + unattended=request_record.config.inputPolicy == "unattended", ) report = agent.run( store, @@ -949,6 +967,20 @@ def find_held_out_references(value: Any) -> None: f"{report.decision.batchCorrection.action!r}" ) if report.status == "needsInput": + if request_record.config.inputPolicy == "unattended": + outcome = journal._complete_attempt( + started, + status="failed", + report_references=[reference], + artifacts=context_artifacts, + error=( + "The unattended Experimental Context stage returned an " + "unresolved decision" + ), + notes=report.notes, + ) + journal._save_outcome(store.zw, prefix, outcome) + return outcome, report questions = [ WorkflowQuestion( questionId="experimentalDirections", @@ -975,7 +1007,11 @@ def find_held_out_references(value: Any) -> None: artifacts=context_artifacts, error="; ".join(report.notes) or "Experimental Context failed", ) - elif report.decision.batchCorrection.action == "unsafe": + elif ( + report.decision.batchCorrection.action == "unsafe" + and not request_record.config.runConfoundedHarmonyDiagnostic + and request_record.config.inputPolicy != "unattended" + ): batch_plan = report.decision.batchCorrection outcome = journal._complete_attempt( started, @@ -1003,6 +1039,8 @@ def find_held_out_references(value: Any) -> None: notes=report.notes, ) else: + if report.decision.batchCorrection.action == "unsafe": + actions.append("evaluate_unsafe_harmony_for_diagnosis") physical_capture = directions.get("physicalCaptureColumn") if not isinstance(physical_capture, str) or not physical_capture: physical_capture = None diff --git a/scarf/agent/orchestrator/decisions.py b/scarf/agent/orchestrator/decisions.py index a49c8319..0c9b8f31 100644 --- a/scarf/agent/orchestrator/decisions.py +++ b/scarf/agent/orchestrator/decisions.py @@ -3,7 +3,7 @@ import hashlib import json import time -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from dataclasses import dataclass from typing import Any @@ -87,6 +87,85 @@ def _selection_evidence_for_human( return list(dict.fromkeys([*option.requiredEvidenceIds, *evidence_ids])) +def _selection_for_option( + definition: RnaDecisionDefinition, + evidence: EvidenceBundle, + option_id: str, + *, + rationale: str, +) -> DecisionSelection: + evidence_ids = _selection_evidence_for_human(definition, evidence, option_id) + override_of: str | None = None + override_evidence_ids: list[str] = [] + selected = definition.spec.option_by_id()[option_id] + if ( + definition.spec.requireIndependentOverrideEvidence + and definition.spec.metricPreferredOptionId is not None + and option_id != definition.spec.metricPreferredOptionId + and selected.status in {"apply", "skip"} + ): + override_of = definition.spec.metricPreferredOptionId + required_ids = set(selected.requiredEvidenceIds) + override_evidence_ids = [ + item.evidenceId + for item in evidence.evidence + if item.evidenceClass + in { + "markerCoherence", + "resamplingStability", + "crossUnitSupport", + "protectedVariablePreservation", + } + and (not required_ids or item.evidenceId in required_ids) + ] + evidence_ids = list(dict.fromkeys([*evidence_ids, *override_evidence_ids])) + return _validate_selection( + definition, + evidence, + DecisionSelection( + selectedOptionId=option_id, + evidenceIds=evidence_ids, + rationale=rationale, + confidence="notApplicable", + overrideOfOptionId=override_of, + overrideEvidenceIds=override_evidence_ids, + ), + ) + + +def _unattended_option_id(definition: RnaDecisionDefinition) -> str: + options = definition.spec.option_by_id() + ordered = [ + definition.spec.metricPreferredOptionId, + definition.spec.baselineOptionId, + *(option.optionId for option in definition.spec.options), + ] + for option_id in ordered: + if option_id is not None and options[option_id].status != "defer": + return option_id + raise ValueError("The registered decision has no non-deferred option") + + +def _unattended_selection( + definition: RnaDecisionDefinition, + evidence: EvidenceBundle, + *, + option_id: str | None = None, + reason: str, +) -> DecisionSelection: + if "rule" not in definition.spec.allowedSources: + raise ValueError( + "The registered decision does not allow deterministic resolution" + ) + selected_option_id = option_id or _unattended_option_id(definition) + return _selection_for_option( + definition, + evidence, + selected_option_id, + rationale=reason, + ) + + def _validate_selection( definition: RnaDecisionDefinition, evidence: EvidenceBundle, @@ -254,6 +333,11 @@ def _reconsider_rna_decision( definition: RnaDecisionDefinition, evidence: EvidenceBundle, answers: Mapping[str, Any], + *, + review_instructions: str | None = None, + visual_content: Sequence[Any] = (), + agent_selection: DecisionSelection | None = None, + agent_model_name: str | None = None, ) -> DecisionReconsideration: """Select from new evidence, then create a revision only when it changes.""" evidence = ( @@ -272,6 +356,10 @@ def _reconsider_rna_decision( raise ValueError("Reconsideration requires an active target decision") question_id = f"decision:{definition.spec.decisionId}Review" raw_answer = answers.get(question_id) + if raw_answer is not None and agent_selection is not None: + raise ValueError( + "Decision reconsideration cannot combine human and agent selections" + ) source: DecisionSource model_name: str | None = None if raw_answer is not None: @@ -303,6 +391,14 @@ def _reconsider_rna_decision( ), ) source = "human" + elif agent_selection is not None: + selection = _validate_selection( + definition, + evidence, + agent_selection, + ) + source = "agent" + model_name = agent_model_name else: payload = { "decisionId": definition.spec.decisionId, @@ -316,20 +412,28 @@ def _reconsider_rna_decision( item.model_dump(mode="json") for item in evidence.evidence ], } - user_prompt = json.dumps(payload, indent=2, sort_keys=True) + text_prompt = json.dumps(payload, indent=2, sort_keys=True) + user_prompt: Any = ( + [text_prompt, *visual_content] if visual_content else text_prompt + ) try: execution = run_agent_sync( model=self.model, output_type=DecisionSelection, system_prompt=( - "Select whether the active feature policy should remain " - "unchanged or use the one newly licensed exclusion bundle. " - "Cite every required evidence ID and class. The exclusion " - "affects representation only, never marker testing." + review_instructions + or ( + "Reconsider the active decision using only the " + "new evidence and registered options. Cite every " + "required evidence ID and class. Keep the active option " + "unless the evidence supports a specific replacement. " + "Do not invent operations, parameters, artifacts, or " + "evidence." + ) ), user_prompt=user_prompt, config=request_record.config.agentRunConfig, - name=f"rna_{definition.spec.decisionId}_review", + name=f"decision_{definition.spec.decisionId}_review", output_validator=lambda value: _validate_selection( definition, evidence, @@ -337,8 +441,63 @@ def _reconsider_rna_decision( ), ) except AgentRunError: + if request_record.config.inputPolicy == "unattended": + selection = _unattended_selection( + definition, + evidence, + option_id=target.selectedOptionId, + reason=( + "The reconsideration model failed, so the unattended " + "workflow retained the active registered option." + ), + ) + source = "rule" + else: + return DecisionReconsideration( + selection=None, + resolution=None, + revised=False, + snapshotSha256=snapshot_sha256, + question=WorkflowQuestion( + questionId=question_id, + decisionId=definition.spec.decisionId, + question=definition.spec.question, + options=[ + option.optionId for option in definition.spec.options + ], + evidenceIds=[item.evidenceId for item in evidence.evidence], + ), + ) + else: + if not isinstance(execution.output, DecisionSelection): + raise TypeError( + "Decision reconsideration returned an unexpected output type" + ) + selection = _validate_selection(definition, evidence, execution.output) + source = "agent" + model_name = execution.runInfo.modelName + + selected_status = definition.spec.option_by_id()[ + selection.selectedOptionId + ].status + if selected_status == "defer": + if request_record.config.inputPolicy == "unattended": + selection = _unattended_selection( + definition, + evidence, + option_id=target.selectedOptionId, + reason=( + "The reconsideration model deferred, so the unattended " + "workflow retained the active registered option." + ), + ) + source = "rule" + selected_status = definition.spec.option_by_id()[ + selection.selectedOptionId + ].status + else: return DecisionReconsideration( - selection=None, + selection=selection, resolution=None, revised=False, snapshotSha256=snapshot_sha256, @@ -350,37 +509,46 @@ def _reconsider_rna_decision( evidenceIds=[item.evidenceId for item in evidence.evidence], ), ) - if not isinstance(execution.output, DecisionSelection): - raise TypeError( - "RNA reconsideration model returned an unexpected output type" - ) - selection = _validate_selection(definition, evidence, execution.output) - source = "agent" - model_name = execution.runInfo.modelName - - selected_status = definition.spec.option_by_id()[ - selection.selectedOptionId - ].status - if selected_status == "defer": + if selection.selectedOptionId == target.selectedOptionId: return DecisionReconsideration( selection=selection, resolution=None, revised=False, snapshotSha256=snapshot_sha256, - question=WorkflowQuestion( - questionId=question_id, - decisionId=definition.spec.decisionId, - question=definition.spec.question, - options=[option.optionId for option in definition.spec.options], - evidenceIds=[item.evidenceId for item in evidence.evidence], - ), ) - if selection.selectedOptionId == target.selectedOptionId: + if len(workflow.revisionRequests) >= workflow.maxRevisions: + if request_record.config.inputPolicy == "unattended": + retained = _unattended_selection( + definition, + evidence, + option_id=target.selectedOptionId, + reason=( + "The revision budget is exhausted, so the unattended " + "workflow retained the active registered option." + ), + ) + return DecisionReconsideration( + selection=retained, + resolution=None, + revised=False, + snapshotSha256=snapshot_sha256, + ) return DecisionReconsideration( selection=selection, resolution=None, revised=False, snapshotSha256=snapshot_sha256, + question=WorkflowQuestion( + questionId=question_id, + decisionId=definition.spec.decisionId, + question=( + "The observed evidence supports changing this decision, " + "but the bounded revision budget is exhausted. Retain the " + "active option explicitly or stop the workflow." + ), + options=[target.selectedOptionId], + evidenceIds=[item.evidenceId for item in evidence.evidence], + ), ) target_position = workflow.decisionRecords.index(target) @@ -623,12 +791,23 @@ def _resolve_rna_decision( raise ValueError( "Decision workflow is paused at another exact checkpoint" ) - return DecisionResolution( - workflow=workflow, - record=None, - compiled=None, - snapshotSha256=snapshot_sha256, - ) + if request_record.config.inputPolicy == "unattended": + selection = _unattended_selection( + definition, + evidence, + reason=( + "The unattended workflow resolved the persisted checkpoint " + "with the registered metric-preferred or baseline option." + ), + ) + source = "rule" + else: + return DecisionResolution( + workflow=workflow, + record=None, + compiled=None, + snapshotSha256=snapshot_sha256, + ) else: payload = { "decisionId": definition.spec.decisionId, @@ -670,6 +849,99 @@ def _resolve_rna_decision( ), ) except AgentRunError as exc: + if request_record.config.inputPolicy == "unattended": + selection = _unattended_selection( + definition, + evidence, + reason=( + "The bounded model run failed, so the unattended " + "workflow selected the registered metric-preferred or " + "baseline option." + ), + ) + source = "rule" + prompt_sha256 = None + model_name = None + else: + pending = PendingDecision( + questionId=question_id, + decisionId=definition.spec.decisionId, + definitionVersion=definition.spec.definitionVersion, + evidenceBundleId=evidence.bundleId, + evidenceBundleSha256=evidence_sha256, + offeredOptionIds=[ + option.optionId for option in definition.spec.options + ], + availableEvidenceIds=[ + item.evidenceId for item in evidence.evidence + ], + reason=( + "The bounded model run did not return a valid registered " + f"selection ({type(exc).__name__})." + ), + createdAtNs=time.time_ns(), + ) + paused = pause_decision_workflow(workflow, pending) + snapshot = save_decision_workflow_snapshot( + store, + paused, + workspace=request_record.request.workspace, + ) + return DecisionResolution( + workflow=snapshot.workflow, + record=None, + compiled=None, + snapshotSha256=snapshot.contentSha256, + ) + else: + if not isinstance(execution.output, DecisionSelection): + raise TypeError( + "RNA decision model returned an unexpected output type" + ) + selection = _validate_selection(definition, evidence, execution.output) + source = "agent" + model_name = execution.runInfo.modelName + + selected_option = definition.spec.option_by_id()[selection.selectedOptionId] + if selected_option.status == "defer": + if request_record.config.inputPolicy == "unattended": + selection = _unattended_selection( + definition, + evidence, + reason=( + "The model deferred, so the unattended workflow selected " + "the registered metric-preferred or baseline option." + ), + ) + selected_option = definition.spec.option_by_id()[ + selection.selectedOptionId + ] + source = "rule" + prompt_sha256 = None + model_name = None + else: + if workflow.status == "needsInput": + active_pending = workflow.pendingDecision + if active_pending is None or ( + active_pending.decisionId != definition.spec.decisionId + or active_pending.definitionVersion + != definition.spec.definitionVersion + or active_pending.evidenceBundleId != evidence.bundleId + or active_pending.evidenceBundleSha256 != evidence_sha256 + or active_pending.offeredOptionIds + != [option.optionId for option in definition.spec.options] + or active_pending.availableEvidenceIds + != [item.evidenceId for item in evidence.evidence] + ): + raise ValueError( + "Deferred answer does not match the exact pending checkpoint" + ) + return DecisionResolution( + workflow=workflow, + record=None, + compiled=None, + snapshotSha256=snapshot_sha256, + ) pending = PendingDecision( questionId=question_id, decisionId=definition.spec.decisionId, @@ -682,10 +954,7 @@ def _resolve_rna_decision( availableEvidenceIds=[ item.evidenceId for item in evidence.evidence ], - reason=( - "The bounded model run did not return a valid registered " - f"selection ({type(exc).__name__})." - ), + reason=selection.rationale, createdAtNs=time.time_ns(), ) paused = pause_decision_workflow(workflow, pending) @@ -700,61 +969,6 @@ def _resolve_rna_decision( compiled=None, snapshotSha256=snapshot.contentSha256, ) - if not isinstance(execution.output, DecisionSelection): - raise TypeError("RNA decision model returned an unexpected output type") - selection = _validate_selection(definition, evidence, execution.output) - source = "agent" - model_name = execution.runInfo.modelName - - selected_option = definition.spec.option_by_id()[selection.selectedOptionId] - if selected_option.status == "defer": - if workflow.status == "needsInput": - active_pending = workflow.pendingDecision - if active_pending is None or ( - active_pending.decisionId != definition.spec.decisionId - or active_pending.definitionVersion - != definition.spec.definitionVersion - or active_pending.evidenceBundleId != evidence.bundleId - or active_pending.evidenceBundleSha256 != evidence_sha256 - or active_pending.offeredOptionIds - != [option.optionId for option in definition.spec.options] - or active_pending.availableEvidenceIds - != [item.evidenceId for item in evidence.evidence] - ): - raise ValueError( - "Deferred answer does not match the exact pending checkpoint" - ) - return DecisionResolution( - workflow=workflow, - record=None, - compiled=None, - snapshotSha256=snapshot_sha256, - ) - pending = PendingDecision( - questionId=question_id, - decisionId=definition.spec.decisionId, - definitionVersion=definition.spec.definitionVersion, - evidenceBundleId=evidence.bundleId, - evidenceBundleSha256=evidence_sha256, - offeredOptionIds=[ - option.optionId for option in definition.spec.options - ], - availableEvidenceIds=[item.evidenceId for item in evidence.evidence], - reason=selection.rationale, - createdAtNs=time.time_ns(), - ) - paused = pause_decision_workflow(workflow, pending) - snapshot = save_decision_workflow_snapshot( - store, - paused, - workspace=request_record.request.workspace, - ) - return DecisionResolution( - workflow=snapshot.workflow, - record=None, - compiled=None, - snapshotSha256=snapshot.contentSha256, - ) created_at_ns = time.time_ns() record = _record_from_selection( diff --git a/scarf/agent/orchestrator/finalization.py b/scarf/agent/orchestrator/finalization.py index a80071d1..ae4c2da1 100644 --- a/scarf/agent/orchestrator/finalization.py +++ b/scarf/agent/orchestrator/finalization.py @@ -1,5 +1,6 @@ """Final analysis and biological interpretation workflow stages.""" +import hashlib import json from collections.abc import Mapping, Sequence from typing import Any, Literal, cast @@ -18,6 +19,13 @@ save_decision_workflow_snapshot, ) from ..experimental_context import ExperimentalContextResult +from ..hypothesis_testing import ( + ClusterSelectionContract, + HypothesisContract, + HypothesisFeaturePanel, + HypothesisTestExecution, + execute_hypothesis_contract, +) from ..parameter_tuning import ( ParameterTuningAgent, ParameterTuningReport, @@ -63,6 +71,9 @@ def analysis_finalization_stage( tuning_reference: AgentReportReference, study_contract: StudyContract, *, + experimental: ExperimentalContextResult | None = None, + analysis_review_evidence: Mapping[str, Any] | None = None, + answers: Mapping[str, Any] | None = None, resume_record: OrchestrationResumeRecord | None = None, ) -> tuple[WorkflowStageAttempt, FinalAnalysisHandoff]: prefix = journal._ensure_orchestration_store(store) @@ -115,6 +126,12 @@ def analysis_finalization_stage( if tuning_report.finalClusterArtifact is not None else None ), + "analysisReviewEvidence": dict(analysis_review_evidence or {}), + "hypothesisTestingPolicy": { + "contract": "licensedSampleAwareNormalizedExpression", + "adjustment": "fdr_bh", + "exploratoryMarkers": "selectedFeatureLevelMarkers", + }, }, resume_record=resume_record, ) @@ -238,13 +255,25 @@ def analysis_finalization_stage( for name, artifact in sorted(selected.artifacts.items()) if name.startswith("doubletScore:") ] + doublet_score_selections = [ + ArtifactReferenceModel.model_validate(artifact.model_dump()) + for name, artifact in sorted(selected.artifacts.items()) + if name.startswith("doubletCellSelection:") + ] if not doublet_scores: raise ValueError( "Selected cluster evidence lacks advisory doublet scores" ) + if len(doublet_scores) != len(doublet_score_selections): + raise ValueError( + "Advisory doublet scores lack exact cell-selection lineage" + ) for index, doublet_model in enumerate(doublet_scores): store.load_artifact(artifact_model_to_ref(doublet_model)) artifacts[f"doubletScore{index}"] = doublet_model + doublet_selection = doublet_score_selections[index] + store.load_artifact(artifact_model_to_ref(doublet_selection)) + artifacts[f"doubletScoreSelection{index}"] = doublet_selection limitations.extend( warning for warning in selected.warnings @@ -258,9 +287,253 @@ def analysis_finalization_stage( "artifacts": [ value.model_dump(mode="json") for value in doublet_scores ], + "cellSelections": [ + value.model_dump(mode="json") + for value in doublet_score_selections + ], } ) + hypothesis_directions = request_record.request.experimentalDirections.get( + "hypothesisTesting", + {}, + ) + if not isinstance(hypothesis_directions, Mapping): + raise ValueError( + "experimentalDirections.hypothesisTesting must be a mapping" + ) + raw_explicit_features = hypothesis_directions.get( + "explicitFeatures", + {}, + ) + raw_cluster_scopes = hypothesis_directions.get("clusters", {}) + if not isinstance(raw_explicit_features, Mapping): + raise ValueError( + "hypothesisTesting.explicitFeatures must map coefficients " + "to feature lists" + ) + if not isinstance(raw_cluster_scopes, Mapping): + raise ValueError( + "hypothesisTesting.clusters must map coefficients " + "to cluster-label lists" + ) + + exploratory_features = list( + dict.fromkeys( + feature + for features in selected.metrics.topMarkerGenes.values() + for feature in features + ) + )[: request_record.config.maxIdentityFeatures] + hypothesis_executions: list[HypothesisTestExecution] = [] + hypothesis_questions: list[WorkflowQuestion] = [] + answer_values = answers or {} + for contrast in ( + experimental.contrastPlans if experimental is not None else [] + ): + panels: list[HypothesisFeaturePanel] = [] + directed_features = raw_explicit_features.get( + contrast.coefficient, + raw_explicit_features.get("*", []), + ) + if not isinstance(directed_features, list | tuple) or any( + not isinstance(value, str) for value in directed_features + ): + raise ValueError( + "Each hypothesisTesting.explicitFeatures value must be " + "a list of feature names" + ) + explicit_features = list( + dict.fromkeys( + feature.strip() + for feature in directed_features + if feature.strip() + ) + ) + if explicit_features: + panels.append( + HypothesisFeaturePanel( + panelId=f"explicit:{contrast.coefficient}", + purpose="explicit", + features=explicit_features, + evidenceIds=[ + f"request:hypothesisFeatures:{contrast.coefficient}" + ], + ) + ) + if exploratory_features: + panels.append( + HypothesisFeaturePanel( + panelId=f"exploratoryMarkers:{contrast.coefficient}", + purpose="exploratoryMarkers", + features=exploratory_features, + sourceArtifact=marker_model, + evidenceIds=[f"artifact:markers:{marker_model.artifactId}"], + ) + ) + + cluster_selection = None + directed_clusters = raw_cluster_scopes.get(contrast.coefficient) + if directed_clusters is not None: + if not isinstance(directed_clusters, list | tuple): + raise ValueError( + "Each hypothesisTesting.clusters value must be a " + "cluster-label list" + ) + cluster_selection = ClusterSelectionContract( + clusterArtifact=final_clusters, + include=list(directed_clusters), + ) + grouping_artifact = next( + ( + source.artifact + for source in ( + experimental.htoIdentityArtifacts + if experimental is not None + else [] + ) + if source.name == contrast.coefficient + ), + None, + ) + identity_payload = { + "workflowRunId": workflow.workflowRunId, + "contrast": contrast.model_dump(mode="json"), + "panels": [panel.model_dump(mode="json") for panel in panels], + "clusterSelection": ( + cluster_selection.model_dump(mode="json") + if cluster_selection is not None + else None + ), + "cellSelection": cell_selection.model_dump(mode="json"), + "assay": plan.markerAssay, + } + identity = hashlib.sha256( + json.dumps( + identity_payload, + sort_keys=True, + separators=(",", ":"), + ).encode() + ).hexdigest()[:24] + contract = HypothesisContract( + contractId=f"hypothesis:{identity}", + familyId=f"contrast:{identity}", + contrast=contrast, + cellSelection=cell_selection, + groupingArtifact=grouping_artifact, + clusterSelection=cluster_selection, + featurePanels=panels, + fromAssay=plan.markerAssay, + evidenceIds=[ + contrast.evidenceId, + *contrast.evidenceIds, + f"artifact:clusters:{final_clusters.artifactId}", + ], + ) + execution = execute_hypothesis_contract(store, contract) + question_id = f"analysisContrast:{identity}" + if execution.status == "needsInput": + disposition = answer_values.get(question_id) + if disposition == "skip": + execution = execution.model_copy( + update={ + "status": "blocked", + "blockedReasons": list( + dict.fromkeys( + [ + *execution.blockedReasons, + "callerSkippedUnresolvedContrast", + ] + ) + ), + } + ) + elif disposition is not None: + raise ValueError(f"{question_id} must be answered with 'skip'") + elif request_record.config.inputPolicy == "unattended": + execution = execution.model_copy( + update={ + "status": "blocked", + "blockedReasons": list( + dict.fromkeys( + [ + *execution.blockedReasons, + "unattendedSkippedUnresolvedContrast", + ] + ) + ), + } + ) + else: + hypothesis_questions.append( + WorkflowQuestion( + questionId=question_id, + question=( + f"The contrast for {contrast.coefficient!r} " + "does not have a complete licensed design or " + "feature family. Stop to revise the immutable " + "request, or explicitly skip this unsupported " + "contrast." + ), + options=["skip"], + evidenceIds=list(execution.evidenceIds), + ) + ) + hypothesis_executions.append(execution) + if execution.statisticalTestArtifact is not None: + statistical_artifact = execution.statisticalTestArtifact + artifacts[f"statisticalTest{len(artifacts)}"] = statistical_artifact + actions.append("run_licensed_statistical_testing") + operations.append( + { + "operation": "execute_hypothesis_contract", + "contract": contract.model_dump(mode="json"), + "execution": execution.model_dump(mode="json"), + } + ) + + if hypothesis_questions: + outcome = journal._complete_attempt( + started, + status="needsInput", + artifacts=artifacts, + outputs={ + "hypothesisExecutions": [ + value.model_dump(mode="json") + for value in hypothesis_executions + ], + "operations": operations, + }, + actions=[*actions, "pause_unresolved_hypothesis"], + needs_input=WorkflowNeedsInput(questions=hypothesis_questions), + notes=[ + "At least one requested contrast lacks a licensed, " + "estimable sample-aware test." + ], + ) + journal._save_outcome(store.zw, prefix, outcome) + return outcome, FinalAnalysisHandoff.get_blank() + + statistical_tests = [ + value.statisticalTestArtifact + for value in hypothesis_executions + if value.statisticalTestArtifact is not None + ] + limitations.extend( + ( + f"Contrast {value.contrast.coefficient!r} produced no p-value: " + f"{'; '.join(value.blockedReasons)}." + ) + for value in hypothesis_executions + if value.status != "executed" + ) + if statistical_tests: + limitations.append( + "Statistical results are sample-level normalized-expression " + "distribution tests, not raw-count pseudobulk differential-" + "expression models." + ) + final_analysis = FinalAnalysisHandoff( workflowRunId=workflow.workflowRunId, primaryAssay=plan.primaryAssay, @@ -274,7 +547,61 @@ def analysis_finalization_stage( umap=final_umap, markerFeatures=preprocessed_assay.markerFeatures, markers=marker_model, + statisticalTests=statistical_tests, doubletScores=doublet_scores, + doubletScoreSelections=doublet_score_selections, + doubletEvidence={ + "scoreQuantiles": dict(selected.metrics.doubletScoreQuantiles), + "scoreByCapture": { + capture: dict(summary) + for capture, summary in ( + selected.metrics.doubletScoreByCapture.items() + ) + }, + "captureCoverage": (selected.metrics.doubletCaptureCoverage), + "maximumClusterConcentration": ( + selected.metrics.doubletHighScoreConcentration + ), + "policy": "scoreAndFlagWithoutRemoval", + }, + markerEvidence={ + "coherence": selected.metrics.markerCoherence, + "specificityMedian": (selected.metrics.markerSpecificityMedian), + "specificityByCluster": dict( + selected.metrics.markerSpecificityByCluster + ), + "aucByCluster": dict(selected.metrics.markerAucByCluster), + "topFeaturesByCluster": { + cluster: list(features) + for cluster, features in ( + selected.metrics.topMarkerGenes.items() + ) + }, + "defaultAndContextFamilyEnrichment": dict( + selected.metrics.markerFamilyEnrichment + ), + "protectedFamilies": list(selected.metrics.protectedMarkerFamilies), + }, + analysisEvidence={ + "analysisReview": dict(analysis_review_evidence or {}), + "hypothesisTests": [ + value.model_dump(mode="json") for value in hypothesis_executions + ], + **( + { + "contrastPlans": [ + value.model_dump(mode="json") + for value in getattr( + experimental, + "contrastPlans", + [], + ) + ] + } + if experimental is not None + else {} + ), + }, parameterReport=tuning_reference, limitations=list(dict.fromkeys(limitations)), ).with_handoff_id() diff --git a/scarf/agent/orchestrator/journal.py b/scarf/agent/orchestrator/journal.py index 04f0627c..fcac3cae 100644 --- a/scarf/agent/orchestrator/journal.py +++ b/scarf/agent/orchestrator/journal.py @@ -1083,7 +1083,25 @@ def paused_or_failed_result( ) -> AutomatedWorkflowResult: prefix = _ensure_orchestration_store(store) current = load_agent_workflow(store, workflow.workflowRunId) - if outcome.status == "abstained" and current.status == "running": + unattended_pause = ( + request_record.config.inputPolicy == "unattended" + and outcome.status == "needsInput" + ) + if (outcome.status == "failed" or unattended_pause) and current.status == "running": + current = finalize_agent_workflow( + store, + workflow.workflowRunId, + status="failed", + message=( + outcome.error + or ( + "The unattended workflow encountered an unresolved decision." + if unattended_pause + else "A workflow stage failed." + ) + ), + ) + elif outcome.status == "abstained" and current.status == "running": current = finalize_agent_workflow( store, workflow.workflowRunId, @@ -1095,7 +1113,9 @@ def paused_or_failed_result( ), ) status: AutomatedWorkflowStatus = ( - "needsInput" + "failed" + if unattended_pause + else "needsInput" if outcome.status == "needsInput" else "abstained" if outcome.status == "abstained" @@ -1112,8 +1132,24 @@ def paused_or_failed_result( studyContract=study_contract, finalAnalysis=final_analysis, decisionRunId=request_record.workflowRunId, - needsInput=outcome.needsInput, - notes=[*outcome.notes, *([outcome.error] if outcome.error else [])], + needsInput=None if unattended_pause else outcome.needsInput, + unresolvedClaims=( + [question.question for question in outcome.needsInput.questions] + if unattended_pause and outcome.needsInput is not None + else [] + ), + notes=[ + *outcome.notes, + *( + [ + "The unattended workflow stopped because a stage returned " + "an unresolved decision." + ] + if unattended_pause + else [] + ), + *([outcome.error] if outcome.error else []), + ], ) result = result.model_copy(update={"contentSha256": _record_checksum(result)}) if status in {"failed", "abstained"}: diff --git a/scarf/agent/orchestrator/main.py b/scarf/agent/orchestrator/main.py index b06fb965..a26eab49 100644 --- a/scarf/agent/orchestrator/main.py +++ b/scarf/agent/orchestrator/main.py @@ -142,8 +142,55 @@ def run(self, request: AutomatedWorkflowRequest) -> AutomatedWorkflowResult: currentStage="ingest", notes=[f"CELLxGENE manifest inspection failed: {exc}"], ) + if dataset_manifest.declaredBatchColumns: + experimental_directions = dict(request.experimentalDirections) + raw_batch_columns = experimental_directions.get("batchColumns") + if raw_batch_columns is None: + experimental_directions["batchColumns"] = list( + dataset_manifest.declaredBatchColumns + ) + elif not isinstance(raw_batch_columns, list) or any( + not isinstance(value, str) or not value.strip() + for value in raw_batch_columns + ): + return AutomatedWorkflowResult( + status="failed", + currentStage="ingest", + datasetManifest=dataset_manifest, + notes=[ + "experimentalDirections.batchColumns must be a list " + "of exact observation-column names" + ], + ) + elif not set(dataset_manifest.declaredBatchColumns).issubset( + raw_batch_columns + ): + return AutomatedWorkflowResult( + status="failed", + currentStage="ingest", + datasetManifest=dataset_manifest, + notes=[ + "experimentalDirections.batchColumns must include the " + "CELLxGENE uns/batch_condition columns" + ], + ) + request = request.model_copy( + update={"experimentalDirections": experimental_directions} + ) manifest_decision = dataset_manifest.decision if manifest_decision.status == "needsInput": + if self.config.inputPolicy == "unattended": + return AutomatedWorkflowResult( + status="abstained", + currentStage="ingest", + datasetManifest=dataset_manifest, + limitations=list(dataset_manifest.priorFiltering.limitations), + unresolvedClaims=[manifest_decision.summary], + notes=[ + "The unattended workflow abstained because the count " + "matrix was ambiguous." + ], + ) return AutomatedWorkflowResult( status="needsInput", currentStage="ingest", @@ -239,6 +286,20 @@ def run(self, request: AutomatedWorkflowRequest) -> AutomatedWorkflowResult: ) ] ) + if needs_input is not None and self.config.inputPolicy == "unattended": + return AutomatedWorkflowResult( + status="abstained", + currentStage="ingest", + zarrPath=ingest_result.zarrPath, + unresolvedClaims=[ + question.question for question in needs_input.questions + ], + notes=[ + *ingest_result.notes, + "The unattended workflow abstained instead of waiting for " + "an ingest decision.", + ], + ) return AutomatedWorkflowResult( status=("needsInput" if needs_input is not None else "failed"), currentStage="ingest", @@ -1149,6 +1210,34 @@ def _continue( ) parents = [journal._parent_link(tuning_outcome)] + ( + analysis_review_outcome, + tuning_report, + tuning_reference, + ) = self.analysis_review_stage( + store, + workflow, + request_record, + parents, + preprocessing_plan, + tuning_report, + tuning_reference, + study_contract, + answers, + resume_record=resume_record, + ) + if analysis_review_outcome.status != "done": + return journal.paused_or_failed_result( + store, + workflow, + request_record, + analysis_review_outcome, + dataset_manifest=dataset_manifest, + preprocessing_plan=preprocessing_plan, + study_contract=study_contract, + ) + parents = [journal._parent_link(analysis_review_outcome)] + finalization_outcome, final_analysis = self.analysis_finalization_stage( store, workflow, @@ -1159,6 +1248,9 @@ def _continue( tuning_report, tuning_reference, study_contract, + experimental=experimental, + analysis_review_evidence=analysis_review_outcome.outputs, + answers=answers, resume_record=resume_record, ) if finalization_outcome.status != "done": diff --git a/scarf/agent/orchestrator/models.py b/scarf/agent/orchestrator/models.py index 50aa6c9d..c864dbe6 100644 --- a/scarf/agent/orchestrator/models.py +++ b/scarf/agent/orchestrator/models.py @@ -23,6 +23,7 @@ "failed", "abandoned", ] +type WorkflowInputPolicy = Literal["pause", "unattended"] type WorkflowStageStatus = Literal[ "started", "done", "needsInput", "abstained", "failed" ] @@ -37,6 +38,7 @@ "feature_policy_review", "feature_policy_preprocessing", "feature_policy_tuning", + "analysis_review", "analysis_finalization", "biological_interpretation", ] @@ -58,6 +60,7 @@ "feature_policy_review", "feature_policy_preprocessing", "feature_policy_tuning", + "analysis_review", "analysis_finalization", ) @@ -270,6 +273,13 @@ class PreprocessedAssayHandoff(AgentDataModel): graphFeatures: ArtifactReferenceModel | None = None markerFeatures: ArtifactReferenceModel | None = None normalized: ArtifactReferenceModel | None = None + graphFeatureCandidates: dict[str, ArtifactReferenceModel] = Field( + default_factory=dict + ) + normalizedCandidates: dict[str, ArtifactReferenceModel] = Field( + default_factory=dict + ) + featureCandidateEvaluations: list[dict[str, Any]] = Field(default_factory=list) nCells: int = 0 nFeatures: int = 0 @@ -341,6 +351,11 @@ class FinalAnalysisHandoff(AgentDataModel): markerFeatures: ArtifactReferenceModel | None = None markers: ArtifactReferenceModel | None = None doubletScores: list[ArtifactReferenceModel] = Field(default_factory=list) + doubletScoreSelections: list[ArtifactReferenceModel] = Field(default_factory=list) + doubletEvidence: dict[str, Any] = Field(default_factory=dict) + markerEvidence: dict[str, Any] = Field(default_factory=dict) + statisticalTests: list[ArtifactReferenceModel] = Field(default_factory=list) + analysisEvidence: dict[str, Any] = Field(default_factory=dict) parameterReport: AgentReportReference | None = None limitations: list[str] = Field(default_factory=list) @@ -399,10 +414,15 @@ def get_example(cls) -> "FinalAnalysisHandoff": class AutomatedWorkflowConfig(AgentDataModel): """Bounded execution policy for automated workflows.""" + inputPolicy: WorkflowInputPolicy = Field( + default="pause", + exclude_if=lambda value: value == "pause", + ) primaryInitialCandidates: int = Field(default=11, ge=1) secondaryInitialCandidates: int = Field(default=3, ge=1) - maxRefinedCandidatesPerAssay: int = Field(default=0, ge=0, le=0) + maxRefinedCandidatesPerAssay: int = Field(default=1, ge=0, le=1) maxHarmonyCandidatesPerAssay: int = Field(default=1, ge=0, le=1) + runConfoundedHarmonyDiagnostic: bool = False integrationResolutionCandidates: int = Field(default=3, ge=1) maxCandidateBranches: int = Field(default=24, ge=1) minClusterCells: int = Field(default=20, ge=1) diff --git a/scarf/agent/orchestrator/preprocessing.py b/scarf/agent/orchestrator/preprocessing.py index c78b045f..6c4357e7 100644 --- a/scarf/agent/orchestrator/preprocessing.py +++ b/scarf/agent/orchestrator/preprocessing.py @@ -6,11 +6,19 @@ from typing import Any, cast import numpy as np +from sklearn.metrics import adjusted_rand_score, normalized_mutual_info_score from ...assay import RNAassay from ...datastore.datastore import DataStore from ...datastore.summary import AssaySummary +from ...features.variability import DEFAULT_HVG_BLACKLIST from ...metadata.selection import NamedCellArtifact +from ...quality_control.cell_cycle_genes import ( + g2m_phase_genes, + g2m_phase_genes_mouse, + s_phase_genes, + s_phase_genes_mouse, +) from ...storage.refs import ArtifactRef from ...storage.selections import read_stored_selection_mask from ...storage.types import as_zarr_array @@ -27,8 +35,18 @@ CellQcProfileEvidence, ExperimentalContextResult, ) -from ..hvg_diagnostics import run_hvg_diagnostic_artifacts +from ..hvg_diagnostics import ( + HvgRanking, + compare_hvg_ranking_to_default, + run_hvg_diagnostic_artifacts, +) from ..persistence import AgentWorkflowRun +from ..parameter_tuning import ( + ParameterCandidate, + ParameterCandidateEvaluation, + execute_parameter_candidate, + prepare_parameter_tuning_dependencies, +) from ..qc_execution import execute_registered_cell_qc from ..rna_decisions import ( CellQualityExecutorPayload, @@ -45,6 +63,11 @@ require_option_evidence, ) from ..study_contract import StudyContract +from ..tuning_diagnostics import ( + SCARF_DEFAULT_DIAGNOSTIC_FAMILIES, + augment_cluster_evaluations, + augment_pca_evaluations, +) from ..types import ArtifactReferenceModel from . import journal from .decisions import DecisionStagesMixin @@ -87,6 +110,7 @@ def apply_feature_policy_to_plan( parameters = { **assay.featureParameters, "excludeFamilies": list(payload.excludedFamilies), + "useScarfDefaultBlacklist": payload.useScarfDefaultBlacklist, } assays.append(assay.model_copy(update={"featureParameters": parameters})) updated = plan.model_copy(update={"assays": assays, "planChecksum": ""}) @@ -185,17 +209,34 @@ def _profile_is_safe(profile: CellQcProfileEvidence) -> bool: @staticmethod def _profile_evidence(profile: CellQcProfileEvidence) -> DecisionEvidence: + def retention_range(values: Sequence[int]) -> str: + if not values: + return "no groups" + ordered = sorted(int(value) for value in values) + return ( + f"{len(ordered)} groups, min/median/max=" + f"{ordered[0]}/{ordered[len(ordered) // 2]}/{ordered[-1]}" + ) + + capture_summary = retention_range(list(profile.sampleRetainedCells.values())) + design_summary = "; ".join( + f"{column}: {retention_range(list(groups.values()))}" + for column, groups in sorted(profile.retainedCellsByColumn.items()) + ) + summary = ( + f"{profile.registeredProfile} retains " + f"{profile.retainedCells}/{profile.activeCells} active cells; " + f"capture retention={capture_summary}; design retention=" + f"{design_summary or 'no groups'}; failed capture candidates=" + f"{profile.failedCaptureCandidates}; unsafe retention groups=" + f"{profile.unsafeRetentionGroups}." + ) + if len(summary) > 2_000: + summary = f"{summary[:1_997].rstrip()}..." return DecisionEvidence( evidenceId=profile.evidenceId, evidenceClass="qualityControl", - summary=( - f"{profile.registeredProfile} retains " - f"{profile.retainedCells}/{profile.activeCells} active cells; " - f"retention by capture={profile.sampleRetainedCells}; " - f"retention by design column={profile.retainedCellsByColumn}; " - f"failed capture candidates={profile.failedCaptureCandidates}; " - f"unsafe retention groups={profile.unsafeRetentionGroups}." - ), + summary=summary, artifactReferences=[ *[source.artifact for source in profile.artifactMetrics], *( @@ -427,7 +468,25 @@ def _resolve_feature_policy_decision( ) nominations = list(policy.excludeFamilies) if policy is not None else [] protected = list(policy.protectFamilies) if policy is not None else [] - evidence_id = f"evidence:featurePolicy:{plan.primaryAssay}" + assay = store.get_assay(plan.primaryAssay) + default_match_count = len(assay.feats.grep(DEFAULT_HVG_BLACKLIST)) + default_families = { + "mitochondrial", + "ribosomal", + "mitoribosomal", + "cellCycle", + "hla", + "h2", + "histone", + "sexLinked", + } + default_eligible = bool( + default_match_count and not default_families.intersection(protected) + ) + evidence_id = f"evidence:featurePolicy:{plan.primaryAssay}:context" + default_evidence_id = ( + f"evidence:featurePolicy:{plan.primaryAssay}:scarfDefaults" + ) bundle = self._decision_evidence_bundle( "featurePolicy", [ @@ -439,7 +498,17 @@ def _resolve_feature_policy_decision( f"protected {sorted(protected)}. No representation-dominance " "evidence exists before the native PCA diagnostic." ), - ) + ), + DecisionEvidence( + evidenceId=default_evidence_id, + evidenceClass="technical", + summary=( + f"The exact core Scarf DEFAULT_HVG_BLACKLIST matches " + f"{default_match_count} features. It is an explicit " + "representation-only baseline, not an automatic winner; " + f"context-protected families are {sorted(protected)}." + ), + ), ], ) definition = build_feature_policy_decision( @@ -447,10 +516,29 @@ def _resolve_feature_policy_decision( proposed_exclusion_families=[], dominant_families=[], protected_families=[], + scarf_default_eligible=default_eligible, ) + requirements = {"featurePolicy:keepAll": [evidence_id, default_evidence_id]} + if default_eligible: + requirements["featurePolicy:excludeScarfDefaults"] = [ + default_evidence_id, + evidence_id, + ] definition = require_option_evidence( definition, - {"featurePolicy:keepAll": [evidence_id]}, + requirements, + ) + rule_selection = ( + None + if default_eligible + else DecisionSelection( + selectedOptionId="featurePolicy:keepAll", + evidenceIds=[evidence_id, default_evidence_id], + rationale=( + "Keep all graph-eligible features because the exact Scarf " + "default bundle conflicts with an objective-protected family." + ), + ) ) resolution = self._resolve_rna_decision( store, @@ -458,17 +546,13 @@ def _resolve_feature_policy_decision( definition, bundle, answers, - rule_selection=DecisionSelection( - selectedOptionId="featurePolicy:keepAll", - evidenceIds=[evidence_id], - rationale=( - "Keep the conditional families until native representation " - "evidence demonstrates technical dominance." - ), - ), + rule_selection=rule_selection, ) if resolution.compiled is None: - raise RuntimeError("A rule-owned feature decision cannot be pending") + raise _DecisionNeedsInput( + self._pending_decision_question(resolution, definition), + resolution.snapshotSha256, + ) payload = resolution.compiled.executorPayload if not isinstance(payload, FeaturePolicyExecutorPayload): raise TypeError("Feature-policy decision compiled an unexpected payload") @@ -614,17 +698,32 @@ def preprocessing_plan_stage( f"routes=[{route_summary}])" ) except _DecisionNeedsInput as pending: - outcome = journal._complete_attempt( - started, - status="needsInput", - artifacts={ - "cellSelection": experimental.cellSelection, - **cell_qc_artifacts, - }, - outputs={"decisionSnapshotSha256": pending.snapshotSha256}, - needs_input=WorkflowNeedsInput(questions=[pending.question]), - notes=["A registered filtering decision requires input."], - ) + if request_record.config.inputPolicy == "unattended": + outcome = journal._complete_attempt( + started, + status="failed", + artifacts={ + "cellSelection": experimental.cellSelection, + **cell_qc_artifacts, + }, + outputs={"decisionSnapshotSha256": pending.snapshotSha256}, + error=( + "The unattended preprocessing plan returned an unresolved " + "registered decision" + ), + ) + else: + outcome = journal._complete_attempt( + started, + status="needsInput", + artifacts={ + "cellSelection": experimental.cellSelection, + **cell_qc_artifacts, + }, + outputs={"decisionSnapshotSha256": pending.snapshotSha256}, + needs_input=WorkflowNeedsInput(questions=[pending.question]), + notes=["A registered filtering decision requires input."], + ) journal._save_outcome(store.zw, prefix, outcome) return outcome, AutomatedPreprocessingPlan.get_blank() except Exception as exc: @@ -863,6 +962,15 @@ def build_assay_preprocessing_plan( "protectFamilies": ( list(policy.protectFamilies) if policy is not None else [] ), + "species": ( + inspection.species if inspection is not None else "unknown" + ), + "defaultFeatureInventory": ( + inspection.defaultFeatureInventory.model_dump(mode="json") + if inspection is not None + and inspection.defaultFeatureInventory is not None + else None + ), }, normalizationParameters={ "logTransform": True, @@ -1170,22 +1278,42 @@ def preprocessing_stage( ) return outcome, handoffs, resolved_plan except _DecisionNeedsInput as pending: - outcome = journal._complete_attempt( - started, - status="needsInput", - artifacts={ - name: value - for name, value in artifacts.items() - if value is not None - }, - outputs={ - "operations": operations, - "decisionSnapshotSha256": pending.snapshotSha256, - }, - needs_input=WorkflowNeedsInput(questions=[pending.question]), - actions=actions, - notes=["A registered RNA preprocessing decision requires input."], - ) + if request_record.config.inputPolicy == "unattended": + outcome = journal._complete_attempt( + started, + status="failed", + artifacts={ + name: value + for name, value in artifacts.items() + if value is not None + }, + outputs={ + "operations": operations, + "decisionSnapshotSha256": pending.snapshotSha256, + }, + actions=actions, + error=( + "The unattended preprocessing stage returned an unresolved " + "registered decision" + ), + ) + else: + outcome = journal._complete_attempt( + started, + status="needsInput", + artifacts={ + name: value + for name, value in artifacts.items() + if value is not None + }, + outputs={ + "operations": operations, + "decisionSnapshotSha256": pending.snapshotSha256, + }, + needs_input=WorkflowNeedsInput(questions=[pending.question]), + actions=actions, + notes=["A registered RNA preprocessing decision requires input."], + ) journal._save_outcome(store.zw, prefix, outcome) return outcome, [], plan except Exception as exc: @@ -1298,6 +1426,11 @@ def preprocess_assay( assay = store.get_assay(assay_plan.assay) min_cells = int(assay_plan.featureParameters.get("minCells", 1)) marker_features: ArtifactRef + graph_feature_candidates: dict[str, ArtifactRef] = {} + normalized_candidates: dict[str, ArtifactRef] = {} + feature_candidate_evaluations: list[ParameterCandidateEvaluation] = [] + feature_candidate_agreement: dict[str, dict[str, float]] = {} + selected_feature_branch_key: str | None = None if assay_plan.featureMethod == "hvg": if request_record is None or study_contract is None: raise ValueError( @@ -1322,14 +1455,56 @@ def preprocess_assay( detected, include_families=False, ) - technical_columns = [ - value - for value in ( - study_contract.physicalCaptureColumn, - *study_contract.technicalBatchColumns, + species = str(assay_plan.featureParameters.get("species", "unknown")) + cycle_genes = { + "homo_sapiens": (s_phase_genes, g2m_phase_genes), + "mus_musculus": (s_phase_genes_mouse, g2m_phase_genes_mouse), + }.get(species) + if cycle_genes is not None: + available_feature_names = set( + np.asarray(assay.feats.fetch_all("names")).astype(str) ) - if value is not None and value in store.cells.columns - ] + s_genes, g2m_genes = cycle_genes + s_coverage = sum( + value in available_feature_names for value in s_genes + ) / len(s_genes) + g2m_coverage = sum( + value in available_feature_names for value in g2m_genes + ) / len(g2m_genes) + if min(s_coverage, g2m_coverage) >= 0.8: + cell_cycle = store.run_cell_cycle_scoring( + cell_selection, + from_assay=assay_plan.assay, + s_genes=list(s_genes), + g2m_genes=list(g2m_genes), + invalidate_cache=False, + ) + artifacts[f"{assay_plan.assay}_cell_cycle"] = ( + ArtifactReferenceModel.from_artifact_ref(cell_cycle) + ) + actions.append(f"score_cell_cycle:{assay_plan.assay}") + operations.append( + { + "operation": "run_cell_cycle_scoring", + "assay": assay_plan.assay, + "species": species, + "sGeneCoverage": s_coverage, + "g2mGeneCoverage": g2m_coverage, + "artifact": ArtifactReferenceModel.from_artifact_ref( + cell_cycle + ).model_dump(mode="json"), + } + ) + technical_columns = list( + dict.fromkeys( + value + for value in ( + study_contract.physicalCaptureColumn, + *study_contract.technicalBatchColumns, + ) + if value is not None and value in store.cells.columns + ) + ) technical_column = technical_columns[0] if technical_columns else None diagnostics = run_hvg_diagnostic_artifacts( store.zw, @@ -1347,6 +1522,257 @@ def preprocess_assay( ) if not diagnostics: raise ValueError("HVG diagnostics produced no registered ranking") + inventory = assay_plan.featureParameters.get("defaultFeatureInventory") + inventory_map = inventory if isinstance(inventory, Mapping) else {} + raw_default_families = inventory_map.get("families", []) + if not isinstance(raw_default_families, list): + raise ValueError("Default feature-family inventory is malformed") + default_family_patterns = { + str(value["family"]): str(value["pattern"]) + for value in raw_default_families + if isinstance(value, Mapping) + and isinstance(value.get("family"), str) + and isinstance(value.get("pattern"), str) + } + if not default_family_patterns: + raise ValueError( + "RNA preprocessing requires the deterministic Scarf default " + "feature-family inventory" + ) + registered_counts = sorted( + { + candidate.top_n + for value in diagnostics + for candidate in value.candidates + } + ) + scarf_default_hvgs: dict[int, ArtifactRef] = {} + for count in registered_counts: + default_ref = store.select_hvgs( + cell_selection, + from_assay=assay_plan.assay, + min_cells=min_cells, + top_n=count, + n_bins=200, + lowess_frac=0.1, + blacklist=DEFAULT_HVG_BLACKLIST, + show_plot=False, + invalidate_cache=False, + ) + scarf_default_hvgs[count] = default_ref + artifacts[f"{assay_plan.assay}_hvg_scarf_default_{count}"] = ( + ArtifactReferenceModel.from_artifact_ref(default_ref) + ) + operations.append( + { + "operation": "select_hvgs", + "assay": assay_plan.assay, + "policy": "scarfDefault", + "topN": count, + "blacklist": DEFAULT_HVG_BLACKLIST, + "artifact": ArtifactReferenceModel.from_artifact_ref( + default_ref + ).model_dump(mode="json"), + } + ) + feature_branches: list[tuple[str, ArtifactRef]] = [ + *( + (f"scarfDefault:{count}", reference) + for count, reference in sorted(scarf_default_hvgs.items()) + ), + *( + ( + f"{diagnostic.ranking_mode}:{candidate.top_n}", + candidate.features, + ) + for diagnostic in diagnostics + for candidate in diagnostic.candidates + ), + ] + nominated_families = cast( + list[str], + assay_plan.featureParameters.get("proposedExcludeFamilies", []), + ) + protected_families = cast( + list[str], + assay_plan.featureParameters.get("protectFamilies", []), + ) + diagnostic_families = list( + dict.fromkeys( + [ + *SCARF_DEFAULT_DIAGNOSTIC_FAMILIES, + *nominated_families, + ] + ) + ) + fixed_dimensions = min(20, active_cells - 1, min(registered_counts)) + fixed_neighbors = min(21, active_cells - 1) + if fixed_dimensions < 2 or fixed_neighbors < 2: + raise ValueError( + "HVG downstream comparison requires at least three active cells" + ) + for branch_key, feature_ref in feature_branches: + normalized_ref = store.run_normalization( + cell_selection, + features=feature_ref, + log_transform=cast( + bool, + assay_plan.normalizationParameters.get("logTransform"), + ), + renormalize_subset=cast( + bool, + assay_plan.normalizationParameters.get("renormalizeSubset"), + ), + invalidate_cache=False, + ) + graph_feature_candidates[branch_key] = feature_ref + normalized_candidates[branch_key] = normalized_ref + candidate_id = "hvg_" + branch_key.replace(":", "_") + parameter = ParameterCandidate( + candidateId=candidate_id, + reductionMethod="pca", + dimensions=fixed_dimensions, + neighborsK=fixed_neighbors, + leidenResolution=1.0, + useHarmony=False, + ) + dependencies, candidate_ids = prepare_parameter_tuning_dependencies( + store, + normalized=normalized_ref, + candidates=[parameter], + batch_columns=technical_columns, + preservation_columns=study_contract.protectedColumns, + max_candidates=1, + max_refined_candidates=0, + min_cluster_cells=request_record.config.minClusterCells, + identity_feature_limit=(request_record.config.maxIdentityFeatures), + ) + evaluation = execute_parameter_candidate( + dependencies, + candidate_ids[0], + ) + evaluation = augment_pca_evaluations( + store, + [evaluation], + feature_selection=feature_ref, + nominated_families=diagnostic_families, + protected_families=protected_families, + technical_columns=technical_columns, + batch_columns=technical_columns, + protected_columns=study_contract.protectedColumns, + qc_columns=[ + value + for value in ( + "RNA_nCounts", + "RNA_nFeatures", + "RNA_percentMito", + "RNA_percentRibo", + ) + if value in store.cells.columns + ], + )[0] + evaluation = augment_cluster_evaluations( + store, + [evaluation], + marker_assay=assay_plan.assay, + marker_features=marker_features, + independent_unit_columns=study_contract.independentUnitColumns, + technical_columns=technical_columns, + nominated_families=diagnostic_families, + protected_families=protected_families, + )[0] + feature_candidate_evaluations.append(evaluation) + artifacts[f"{assay_plan.assay}_{candidate_id}_features"] = ( + ArtifactReferenceModel.from_artifact_ref(feature_ref) + ) + artifacts[f"{assay_plan.assay}_{candidate_id}_normalized"] = ( + ArtifactReferenceModel.from_artifact_ref(normalized_ref) + ) + for artifact_name, artifact in evaluation.artifacts.items(): + artifacts[f"{assay_plan.assay}_{candidate_id}_{artifact_name}"] = ( + ArtifactReferenceModel.model_validate(artifact.model_dump()) + ) + completed_feature_candidates = [ + value + for value in feature_candidate_evaluations + if value.status == "done" + and value.eligible + and "clusters" in value.artifacts + and "neighbors" in value.artifacts + ] + labels_by_id: dict[str, np.ndarray] = {} + neighbors_by_id: dict[str, np.ndarray] = {} + for evaluation in completed_feature_candidates: + cluster_model = ArtifactReferenceModel.model_validate( + evaluation.artifacts["clusters"].model_dump() + ) + neighbor_model = ArtifactReferenceModel.model_validate( + evaluation.artifacts["neighbors"].model_dump() + ) + cluster_group = store.load_artifact( + artifact_model_to_ref(cluster_model) + ) + neighbor_group = store.load_artifact( + artifact_model_to_ref(neighbor_model) + ) + labels_by_id[evaluation.candidateId] = np.asarray( + as_zarr_array( + cluster_group["values"], + name="values", + )[:] + ) + neighbors_by_id[evaluation.candidateId] = np.asarray( + as_zarr_array( + neighbor_group["indices"], + name="indices", + )[:], + dtype=np.int64, + ) + for evaluation in completed_feature_candidates: + ari_values: list[float] = [] + nmi_values: list[float] = [] + neighbor_values: list[float] = [] + labels = labels_by_id[evaluation.candidateId] + neighbors = neighbors_by_id[evaluation.candidateId] + sample_rows = np.linspace( + 0, + len(neighbors) - 1, + min(2_000, len(neighbors)), + dtype=np.int64, + ) + for other in completed_feature_candidates: + if other.candidateId == evaluation.candidateId: + continue + other_labels = labels_by_id[other.candidateId] + other_neighbors = neighbors_by_id[other.candidateId] + if labels.shape != other_labels.shape: + raise ValueError("HVG candidate cluster artifacts do not align") + if neighbors.shape != other_neighbors.shape: + raise ValueError( + "HVG candidate neighbor artifacts do not align" + ) + ari_values.append(float(adjusted_rand_score(labels, other_labels))) + nmi_values.append( + float(normalized_mutual_info_score(labels, other_labels)) + ) + row_overlaps = [ + len(set(neighbors[row]).intersection(other_neighbors[row])) + / neighbors.shape[1] + for row in sample_rows + ] + neighbor_values.append(float(np.mean(row_overlaps))) + feature_candidate_agreement[evaluation.candidateId] = { + "minimumAri": min(ari_values, default=1.0), + "medianAri": float(np.median(ari_values)) if ari_values else 1.0, + "minimumNmi": min(nmi_values, default=1.0), + "medianNmi": float(np.median(nmi_values)) if nmi_values else 1.0, + "minimumNeighborOverlap": min(neighbor_values, default=1.0), + "medianNeighborOverlap": float(np.median(neighbor_values)) + if neighbor_values + else 1.0, + } + feature_names = np.asarray(assay.feats.fetch_all("names")).astype(str) + hvg_comparisons: dict[tuple[str, int], Any] = {} ranking_evidence: list[DecisionEvidence] = [] ranking_evidence_ids: dict[str, str] = {} for candidate_ranking in diagnostics: @@ -1387,6 +1813,78 @@ def preprocess_assay( )[:], dtype=np.float64, ) + corrected_variance_values = np.asarray( + as_zarr_array( + ranking_group["global_corrected_variance"], + name="global_corrected_variance", + )[:], + dtype=np.float64, + ) + eligible_values = np.asarray( + as_zarr_array( + ranking_group["eligible"], + name="eligible", + )[:], + dtype=bool, + ) + ranking_model = HvgRanking( + ranking_mode=mode, + eligible=eligible_values, + global_corrected_variance=corrected_variance_values, + recurrence=recurrence_values, + mean_within_group_rank=within_group_ranks, + ranking=ranking_values, + valid_group_count=len(candidate_ranking.valid_groups), + candidate_counts=tuple( + candidate.top_n for candidate in candidate_ranking.candidates + ), + ) + comparison_summaries: list[str] = [] + for candidate in candidate_ranking.candidates: + default_group = store.load_artifact( + scarf_default_hvgs[candidate.top_n] + ) + default_mask = np.asarray( + as_zarr_array( + default_group["values"], + name="values", + )[:], + dtype=bool, + ) + comparison = next( + value + for value in compare_hvg_ranking_to_default( + default_mask, + ranking_model, + feature_names=feature_names.tolist(), + default_family_patterns=default_family_patterns, + ) + if value.top_n == candidate.top_n + ) + hvg_comparisons[(mode, candidate.top_n)] = comparison + leakage = sum( + value.agent_selected_count + for value in comparison.default_family_leakage + ) + downstream = next( + value + for value in feature_candidate_evaluations + if value.candidateId == f"hvg_{mode}_{candidate.top_n}" + ) + agreement = feature_candidate_agreement.get( + downstream.candidateId, + {}, + ) + comparison_summaries.append( + f"top {candidate.top_n}: default overlap " + f"{comparison.agent_overlap_fraction:.1%}, Jaccard " + f"{comparison.jaccard:.3f}, default-family selections " + f"{leakage}, downstream marker coherence " + f"{downstream.metrics.markerCoherence}, cross-unit support " + f"{downstream.metrics.crossUnitSupport}, technical " + f"association {downstream.metrics.technicalAssociation}, " + f"agreement {agreement}" + ) broad_count = max( candidate.top_n for candidate in candidate_ranking.candidates ) @@ -1422,12 +1920,27 @@ def preprocess_assay( "variability across the exact filtered cell selection." f"{recurrence_summary}" ) + summary += ( + " Exact Scarf-default comparisons: " + + "; ".join(comparison_summaries) + + "." + ) ranking_evidence.append( DecisionEvidence( evidenceId=evidence_id, evidenceClass="technical", summary=summary, - artifactReferences=[diagnostic_model], + artifactReferences=[ + diagnostic_model, + *[ + ArtifactReferenceModel.model_validate( + artifact.model_dump() + ) + for evaluation in feature_candidate_evaluations + if evaluation.candidateId.startswith(f"hvg_{mode}_") + for artifact in evaluation.artifacts.values() + ], + ], ) ) ranking_bundle = self._decision_evidence_bundle( @@ -1558,6 +2071,44 @@ def preprocess_assay( f"{variance_fraction:.1%} of corrected variance across " f"{diagnostic.eligible_feature_count} eligible genes." ) + comparison = hvg_comparisons[(diagnostic.ranking_mode, candidate.top_n)] + downstream = next( + value + for value in feature_candidate_evaluations + if value.candidateId + == f"hvg_{diagnostic.ranking_mode}_{candidate.top_n}" + ) + default_downstream = next( + value + for value in feature_candidate_evaluations + if value.candidateId == f"hvg_scarfDefault_{candidate.top_n}" + ) + agreement = feature_candidate_agreement.get( + downstream.candidateId, + {}, + ) + leakage_by_family = { + value.family: value.agent_selected_count + for value in comparison.default_family_leakage + if value.agent_selected_count + } + summary += ( + " Compared with the exact Scarf-default selection, overlap is " + f"{comparison.agent_overlap_fraction:.1%}, Jaccard is " + f"{comparison.jaccard:.3f}, and selected default-family counts " + f"are {leakage_by_family}. The fixed downstream branch produced marker " + f"coherence {downstream.metrics.markerCoherence}, cross-unit " + f"support {downstream.metrics.crossUnitSupport}, technical " + f"association {downstream.metrics.technicalAssociation}, " + f"doublet concentration " + f"{downstream.metrics.doubletHighScoreConcentration}, and " + f"cross-candidate agreement {agreement}. The matched core " + "Scarf baseline produced marker coherence " + f"{default_downstream.metrics.markerCoherence}, cross-unit " + f"support {default_downstream.metrics.crossUnitSupport}, and " + "technical association " + f"{default_downstream.metrics.technicalAssociation}." + ) if diagnostic.valid_groups: replicated = recurrence[selected_indices] >= max( 2, @@ -1577,6 +2128,18 @@ def preprocess_assay( ArtifactReferenceModel.from_artifact_ref( candidate.features ), + *[ + ArtifactReferenceModel.model_validate( + artifact.model_dump() + ) + for artifact in downstream.artifacts.values() + ], + *[ + ArtifactReferenceModel.model_validate( + artifact.model_dump() + ) + for artifact in default_downstream.artifacts.values() + ], ], ) ) @@ -1629,6 +2192,9 @@ def preprocess_assay( "Selected HVG count has no exact persisted candidate artifact" ) graph_features = selected_candidate.features + selected_feature_branch_key = ( + f"{diagnostic.ranking_mode}:{selected_candidate.top_n}" + ) actions.append(f"audit_hvg_count:{assay_plan.assay}") operations.append( { @@ -1664,6 +2230,12 @@ def preprocess_assay( "excludeFamilies": list( assay_plan.featureParameters.get("excludeFamilies", []) ), + "useScarfDefaultBlacklist": bool( + assay_plan.featureParameters.get( + "useScarfDefaultBlacklist", + False, + ) + ), "artifact": ArtifactReferenceModel.from_artifact_ref( eligible_features ).model_dump(mode="json"), @@ -1758,18 +2330,23 @@ def preprocess_assay( ) else: raise ValueError(f"Unsupported feature route {assay_plan.featureMethod!r}") - normalized = store.run_normalization( - cell_selection, - features=graph_features, - log_transform=cast( - bool, - assay_plan.normalizationParameters.get("logTransform"), - ), - renormalize_subset=cast( - bool, - assay_plan.normalizationParameters.get("renormalizeSubset"), - ), - invalidate_cache=False, + normalized = ( + normalized_candidates[selected_feature_branch_key] + if selected_feature_branch_key is not None + and selected_feature_branch_key in normalized_candidates + else store.run_normalization( + cell_selection, + features=graph_features, + log_transform=cast( + bool, + assay_plan.normalizationParameters.get("logTransform"), + ), + renormalize_subset=cast( + bool, + assay_plan.normalizationParameters.get("renormalizeSubset"), + ), + invalidate_cache=False, + ) ) graph_feature_group = store.load_artifact(graph_features) graph_feature_values = cast(Any, graph_feature_group["values"]) @@ -1787,6 +2364,24 @@ def preprocess_assay( graphFeatures=graph_features_model, markerFeatures=marker_features_model, normalized=normalized_model, + graphFeatureCandidates={ + key: ArtifactReferenceModel.from_artifact_ref(value) + for key, value in graph_feature_candidates.items() + }, + normalizedCandidates={ + key: ArtifactReferenceModel.from_artifact_ref(value) + for key, value in normalized_candidates.items() + }, + featureCandidateEvaluations=[ + { + **value.model_dump(mode="json"), + "agreement": feature_candidate_agreement.get( + value.candidateId, + {}, + ), + } + for value in feature_candidate_evaluations + ], nCells=active_cells, nFeatures=int(selected_values.sum()), ) @@ -2049,16 +2644,32 @@ def apply_cell_qc( raise ValueError(f"Unsupported cell QC action {plan.action!r}") def rna_blacklist(self, plan: AssayPreprocessingPlan) -> str: - patterns: list[str] = [] + patterns: list[str] = ( + [DEFAULT_HVG_BLACKLIST] + if plan.featureParameters.get("useScarfDefaultBlacklist") is True + else [] + ) families = set( cast(list[str], plan.featureParameters.get("excludeFamilies", [])) ) if "mitochondrial" in families: patterns.append(r"^(MT-|mt-)") if "ribosomal" in families: - patterns.append(r"^(RPS|RPL|MRPS|MRPL|Rps|Rpl|Mrps|Mrpl)") + patterns.append(r"^(RPS|RPL)") + if "mitoribosomal" in families: + patterns.append(r"^(MRPS|MRPL)") if "histone" in families: - patterns.append(r"^(HIST|Hist)") + patterns.append(r"^HIST") + if "hla" in families: + patterns.append(r"^HLA-") + if "h2" in families: + patterns.append(r"^H2-") + if "cellCycle" in families: + patterns.append(r"^CCN") + if "sexLinked" in families: + patterns.append( + r"^(XIST|DDX3Y|USP9Y|EIF1AY|KDM5D|SRY|ZFY|UTY|TMSB4Y|NLGN4Y)$" + ) patterns.extend( rf"^{re.escape(value)}$" for value in plan.exactExcludedFeatures if value ) @@ -2077,7 +2688,11 @@ def exclude_exact_features( if include_families else set() ) - if not plan.exactExcludedFeatures and not families: + use_scarf_defaults = bool( + include_families + and plan.featureParameters.get("useScarfDefaultBlacklist") is True + ) + if not plan.exactExcludedFeatures and not families and not use_scarf_defaults: return source assay = store.get_assay(plan.assay) source_group = store.load_artifact(source) @@ -2092,12 +2707,26 @@ def exclude_exact_features( if "mitochondrial" in families: family_patterns.append(r"^(MT-|mt-)") if "ribosomal" in families: - family_patterns.append(r"^(RPS|RPL|MRPS|MRPL|Rps|Rpl|Mrps|Mrpl)") + family_patterns.append(r"^(RPS|RPL)") + if "mitoribosomal" in families: + family_patterns.append(r"^(MRPS|MRPL)") if "histone" in families: - family_patterns.append(r"^(HIST|Hist)") + family_patterns.append(r"^HIST") + if "hla" in families: + family_patterns.append(r"^HLA-") + if "h2" in families: + family_patterns.append(r"^H2-") + if "cellCycle" in families: + family_patterns.append(r"^CCN") + if "sexLinked" in families: + family_patterns.append( + r"^(XIST|DDX3Y|USP9Y|EIF1AY|KDM5D|SRY|ZFY|UTY|TMSB4Y|NLGN4Y)$" + ) + if use_scarf_defaults: + family_patterns.append(DEFAULT_HVG_BLACKLIST) if family_patterns: technical = np.zeros(len(mask), dtype=bool) - combined = re.compile("|".join(family_patterns)) + combined = re.compile("|".join(family_patterns), re.IGNORECASE) technical |= np.fromiter( (combined.search(value) is not None for value in ids), dtype=bool, diff --git a/scarf/agent/orchestrator/tuning.py b/scarf/agent/orchestrator/tuning.py index 36512c8a..93271527 100644 --- a/scarf/agent/orchestrator/tuning.py +++ b/scarf/agent/orchestrator/tuning.py @@ -1,16 +1,29 @@ """Parameter tuning and multimodal integration workflow stages.""" import hashlib +import io import json from collections.abc import Callable, Mapping, Sequence from typing import Any, Literal, cast import numpy as np +from pydantic import Field +from pydantic_ai.exceptions import AgentRunError from sklearn.metrics import adjusted_rand_score, normalized_mutual_info_score from ...datastore.datastore import DataStore +from ...metadata.rows import read_metadata_rows_chunkwise +from ...storage.refs import ArtifactRef +from ...storage.selections import read_stored_selection_indices +from ...storage.types import as_zarr_array from ...utils.logging import logger from .. import record_io +from ..config.agent_exec import ( + ImageEvidence, + ImageInputUnsupportedError, + build_visual_evidence_prompt, + run_agent_sync, +) from ..decision_kernel import DecisionEvidence, DecisionSelection, EvidenceBundle from ..experimental_context import ExperimentalContextResult from ..parameter_tuning import ( @@ -21,11 +34,19 @@ IntegrationMetrics, ParameterCandidate, ParameterCandidateEvaluation, + ParameterSearchPlan, + ParameterTuningDependencies, ParameterTuningAgent, ParameterTuningAssayInput, ParameterTuningReport, final_graph_options, finalize_parameter_tuning_selection, + parameter_search_prompt, + parameter_search_system_prompt, + parameter_tuning_prompt, + parameter_tuning_system_prompt, + pending_parameter_tuning_report, + validate_parameter_tuning_report, validate_final_graph_selection, ) from ..persistence import ( @@ -46,7 +67,10 @@ SequentialAssayTuningEvidence, SequentialRnaTuningPlanner, execute_parameter_phase, + execute_sequential_refinement, + prepare_sequential_refinement_dependencies, sequential_evidence_to_report, + validate_sequential_refinement_plan, validate_parameter_phase_selection, ) from ..rna_decisions import ( @@ -69,11 +93,12 @@ ) from ..study_contract import StudyContract from ..tuning_diagnostics import ( + SCARF_DEFAULT_DIAGNOSTIC_FAMILIES, augment_cluster_evaluations, augment_pca_evaluations, score_advisory_doublets, ) -from ..types import ArtifactReferenceModel, ExperimentalTuningHandoff +from ..types import AgentDataModel, ArtifactReferenceModel, ExperimentalTuningHandoff from . import journal from .decisions import DecisionResolution, DecisionStagesMixin from .models import ( @@ -92,6 +117,11 @@ from .preprocessing import apply_feature_policy_to_plan +def _bounded_evidence_summary(summary: str) -> str: + """Keep prompt-facing evidence within the decision-kernel contract.""" + return summary if len(summary) <= 2_000 else f"{summary[:1_997].rstrip()}..." + + def harmony_acceptance_gate( native: ParameterCandidateEvaluation | None, harmony: ParameterCandidateEvaluation | None, @@ -100,11 +130,20 @@ def harmony_acceptance_gate( protected_columns: Sequence[str], independent_unit_columns: Sequence[str], tolerance: float = 0.05, + require_doublet_evidence: bool = False, ) -> tuple[bool, list[str]]: """Require measured batch improvement without material biological loss.""" + if tolerance < 0 or not np.isfinite(tolerance): + raise ValueError("Harmony gate tolerance must be finite and non-negative") reasons: list[str] = [] if native is None or harmony is None: return False, ["Matched native and Harmony candidates are unavailable."] + if native.status != "done" or not native.eligible: + reasons.append("The matched native candidate is not an eligible execution.") + if harmony.status != "done" or not harmony.eligible: + reasons.append("The matched Harmony candidate is not an eligible execution.") + if native.parameters.useHarmony or not harmony.parameters.useHarmony: + reasons.append("Candidates do not have native and Harmony correction modes.") native_parameters = native.parameters.model_dump( mode="json", exclude={"candidateId", "useHarmony"}, @@ -115,6 +154,10 @@ def harmony_acceptance_gate( ) if native_parameters != harmony_parameters: reasons.append("Native and Harmony candidate parameters are not matched.") + if native.cellSelection != harmony.cellSelection: + reasons.append("Native and Harmony candidates use different cell selections.") + if not batch_columns: + reasons.append("No approved batch metric was supplied.") batch_deltas: dict[str, float] = {} for column in batch_columns: native_score = native.metrics.batchMixing.get(column) @@ -173,9 +216,1097 @@ def harmony_acceptance_gate( reasons.append("Marker-coherence comparison is missing.") elif harmony.metrics.markerCoherence < native.metrics.markerCoherence - tolerance: reasons.append("Harmony materially degraded marker coherence.") + for label, native_value, harmony_value in ( + ( + "marker specificity", + native.metrics.markerSpecificityMedian, + harmony.metrics.markerSpecificityMedian, + ), + ( + "cluster connectivity", + native.metrics.clusterConnectivity, + harmony.metrics.clusterConnectivity, + ), + ( + "membership strength", + native.metrics.membershipStrengthMean, + harmony.metrics.membershipStrengthMean, + ), + ): + if native_value is None and harmony_value is None: + continue + if native_value is None or harmony_value is None: + reasons.append(f"Matched {label} comparison is missing.") + elif harmony_value < native_value - tolerance: + reasons.append(f"Harmony materially degraded {label}.") + native_doublet = native.metrics.doubletHighScoreConcentration + harmony_doublet = harmony.metrics.doubletHighScoreConcentration + if ( + require_doublet_evidence + or native_doublet is not None + or harmony_doublet is not None + ): + if native_doublet is None or harmony_doublet is None: + reasons.append("Matched doublet-concentration comparison is missing.") + elif harmony_doublet > native_doublet + tolerance: + reasons.append("Harmony materially concentrated advisory doublet scores.") return not reasons, reasons +def enforce_harmony_acceptance( + report: ParameterTuningReport, + *, + batch_columns: Sequence[str], + protected_columns: Sequence[str], + independent_unit_columns: Sequence[str], + selectable: bool, +) -> ParameterTuningReport: + """Prevent an unlicensed or unsupported Harmony branch from promotion.""" + updated_reports: dict[str, ParameterTuningReport] = {} + recommended = dict(report.recommendedByAssay) + for assay, assay_report in report.assayReports.items(): + selected = next( + ( + evaluation + for evaluation in assay_report.evaluations + if evaluation.candidateId == assay_report.recommendedCandidateId + ), + None, + ) + if selected is None or not selected.parameters.useHarmony: + updated_reports[assay] = assay_report + continue + selected_parameters = selected.parameters.model_dump( + mode="json", + exclude={"candidateId", "useHarmony"}, + ) + native = next( + ( + evaluation + for evaluation in assay_report.evaluations + if not evaluation.parameters.useHarmony + and evaluation.status == "done" + and evaluation.eligible + and evaluation.parameters.model_dump( + mode="json", + exclude={"candidateId", "useHarmony"}, + ) + == selected_parameters + ), + None, + ) + accepted, reasons = harmony_acceptance_gate( + native, + selected, + batch_columns=batch_columns, + protected_columns=protected_columns, + independent_unit_columns=independent_unit_columns, + require_doublet_evidence=True, + ) + if selectable and accepted: + updated_reports[assay] = assay_report + continue + if native is None: + raise ValueError( + f"Harmony recommendation for assay {assay!r} lacks a matched " + "eligible native candidate" + ) + reason = ( + "Harmony was diagnostic-only." + if not selectable + else f"Harmony did not pass acceptance: {reasons}." + ) + updated_reports[assay] = assay_report.model_copy( + update={ + "recommendedCandidateId": native.candidateId, + "selectedArtifacts": dict(native.artifacts), + "evidenceIds": list( + dict.fromkeys([*assay_report.evidenceIds, *native.evidenceIds]) + ), + "tradeoffs": [*assay_report.tradeoffs, reason], + } + ) + recommended[assay] = native.candidateId + return report.model_copy( + update={ + "assayReports": updated_reports, + "recommendedByAssay": recommended, + } + ) + + +def _cluster_review_values( + evaluation: ParameterCandidateEvaluation, +) -> tuple[dict[str, float], dict[str, float]]: + metrics = evaluation.metrics + maximize = { + "silhouette": metrics.graphSilhouetteMedian or 0.0, + "seedStability": metrics.seedStability or 0.0, + "subsampleStability": metrics.subsampleStability or 0.0, + "markerCoherence": metrics.markerCoherence or 0.0, + "markerSpecificity": metrics.markerSpecificityMedian or 0.0, + "crossUnitSupport": metrics.crossUnitSupport or 0.0, + "membershipStrength": metrics.membershipStrengthMean or 0.0, + "clusterConnectivity": metrics.clusterConnectivity or 0.0, + "minimumClusterFraction": metrics.minClusterFraction or 0.0, + } + minimize = { + "technicalAssociation": max( + metrics.technicalAssociation.values(), + default=0.0, + ), + "doubletConcentration": ( + metrics.doubletHighScoreConcentration + if metrics.doubletHighScoreConcentration is not None + else 1.0 + ), + "protectedAssociation": max( + metrics.protectedPcaAssociation.values(), + default=0.0, + ), + } + return maximize, minimize + + +def _changed_analysis_checkpoint( + candidate: ParameterCandidateEvaluation, + selected: ParameterCandidateEvaluation, +) -> ( + Literal[ + "pcaPrefix", + "correctionOutcome", + "graphK", + "clusterPartition", + ] + | None +): + changed = [ + checkpoint + for checkpoint, differs in ( + ( + "pcaPrefix", + candidate.parameters.dimensions != selected.parameters.dimensions, + ), + ( + "correctionOutcome", + candidate.parameters.useHarmony != selected.parameters.useHarmony, + ), + ( + "graphK", + candidate.parameters.neighborsK != selected.parameters.neighborsK, + ), + ( + "clusterPartition", + candidate.parameters.leidenResolution + != selected.parameters.leidenResolution, + ), + ) + if differs + ] + return cast(Any, changed[0]) if len(changed) == 1 else None + + +def _analysis_parameter_value( + checkpoint: str, + evaluation: ParameterCandidateEvaluation, +) -> int | float | bool: + if checkpoint == "pcaPrefix": + return evaluation.parameters.dimensions + if checkpoint == "correctionOutcome": + return evaluation.parameters.useHarmony + if checkpoint == "graphK": + return evaluation.parameters.neighborsK + if checkpoint == "clusterPartition": + return evaluation.parameters.leidenResolution + raise ValueError(f"Unknown analysis checkpoint {checkpoint!r}") + + +def _dominates_analysis_choice( + candidate: ParameterCandidateEvaluation, + selected: ParameterCandidateEvaluation, + *, + tolerance: float = 0.02, + material: float = 0.05, +) -> bool: + """Admit only a one-checkpoint alternative with independently better evidence.""" + checkpoint = _changed_analysis_checkpoint(candidate, selected) + if ( + candidate.candidateId == selected.candidateId + or candidate.status != "done" + or not candidate.eligible + or checkpoint is None + or (candidate.parameters.useHarmony and not selected.parameters.useHarmony) + ): + return False + candidate_max, candidate_min = _cluster_review_values(candidate) + selected_max, selected_min = _cluster_review_values(selected) + no_worse = all( + candidate_max[name] >= selected_max[name] - tolerance for name in candidate_max + ) and all( + candidate_min[name] <= selected_min[name] + tolerance for name in candidate_min + ) + independently_better = sum( + candidate_max[name] > selected_max[name] + material for name in candidate_max + ) + sum( + candidate_min[name] < selected_min[name] - material for name in candidate_min + ) + return no_worse and independently_better >= 2 + + +class AnalysisVisualAdjudication(AgentDataModel): + """Bounded interpretation of the supplied analysis diagnostics.""" + + status: Literal["acceptable", "concern"] = "concern" + selectedCandidateId: str = "" + featureLevelFindings: list[str] = Field(default_factory=list) + rationale: str = "" + + +_NUMERIC_REVIEW_CANDIDATE_LIMIT = 24 +_NUMERIC_REVIEW_METRICS = ( + "nClusters", + "minClusterCells", + "minClusterFraction", + "graphSilhouetteMedian", + "membershipStrengthMean", + "membershipStrengthP10", + "clusterConnectivity", + "seedStability", + "subsampleStability", + "markerCoherence", + "markerSpecificityMedian", + "crossUnitSupport", + "technicalAssociation", + "batchMixing", + "biologicalPreservation", + "qcPcaAssociation", + "doubletHighScoreConcentration", + "doubletScoreQuantiles", + "doubletCaptureCoverage", + "loadingFamilyEnrichment", + "markerFamilyEnrichment", + "paretoOptimal", + "dominatedByCandidateIds", + "dominatesCandidateIds", +) + + +def _numeric_analysis_review_payload( + study_objective: str, + selected: ParameterCandidateEvaluation, + candidates: Sequence[ParameterCandidateEvaluation], +) -> dict[str, Any]: + comparisons = [ + candidate + for candidate in candidates + if candidate.candidateId != selected.candidateId + and candidate.status == "done" + and candidate.eligible + and _changed_analysis_checkpoint(candidate, selected) is not None + ] + checkpoint_order = { + "pcaPrefix": 0, + "correctionOutcome": 1, + "graphK": 2, + "clusterPartition": 3, + } + comparisons.sort( + key=lambda candidate: ( + checkpoint_order[ + cast(str, _changed_analysis_checkpoint(candidate, selected)) + ], + float( + _analysis_parameter_value( + cast(str, _changed_analysis_checkpoint(candidate, selected)), + candidate, + ) + ), + candidate.candidateId, + ) + ) + + def compact_candidate( + candidate: ParameterCandidateEvaluation, + ) -> dict[str, Any]: + metrics = candidate.metrics.model_dump(mode="json", exclude_none=True) + return { + "candidateId": candidate.candidateId, + "changedCheckpoint": _changed_analysis_checkpoint(candidate, selected), + "parameters": candidate.parameters.model_dump(mode="json"), + "effectiveDimensions": candidate.effectiveDimensions, + "metrics": { + name: metrics[name] + for name in _NUMERIC_REVIEW_METRICS + if name in metrics and metrics[name] not in ({}, []) + }, + "warnings": list(candidate.warnings), + } + + return { + "studyObjective": study_objective, + "evidenceMode": "numeric", + "evidenceLimitation": ( + "The configured model did not accept image input. Spatial and visual " + "distribution patterns are unavailable for this review." + ), + "selectedCandidate": { + "candidateId": selected.candidateId, + "parameters": selected.parameters.model_dump(mode="json"), + "effectiveDimensions": selected.effectiveDimensions, + "metrics": selected.metrics.model_dump(mode="json", exclude_none=True), + "warnings": list(selected.warnings), + }, + "comparisonCandidates": [ + compact_candidate(candidate) + for candidate in comparisons[:_NUMERIC_REVIEW_CANDIDATE_LIMIT] + ], + "comparisonCandidateCount": len(comparisons), + "includedComparisonCandidateCount": min( + len(comparisons), + _NUMERIC_REVIEW_CANDIDATE_LIMIT, + ), + } + + +def _run_analysis_adjudication( + *, + model: Any, + config: AutomatedWorkflowConfig, + study_objective: str, + selected: ParameterCandidateEvaluation, + candidates: Sequence[ParameterCandidateEvaluation], + visual_content: Sequence[ImageEvidence], +) -> tuple[AnalysisVisualAdjudication, Literal["multimodal", "numeric"]]: + def validate(value: AnalysisVisualAdjudication) -> AnalysisVisualAdjudication: + if ( + value.selectedCandidateId != selected.candidateId + or not value.rationale.strip() + ): + raise ValueError( + "Analysis review must identify the exact selected candidate " + "and provide a rationale" + ) + return value + + visual_payload = { + "studyObjective": study_objective, + "selectedCandidateId": selected.candidateId, + "metrics": selected.metrics.model_dump(mode="json"), + "warnings": selected.warnings, + } + try: + execution = run_agent_sync( + model=model, + output_type=AnalysisVisualAdjudication, + system_prompt=( + "Adjudicate the bounded diagnostic board together with the supplied " + "exact metrics. Report only feature-level, partition-level, batch, " + "QC, and doublet findings. Do not assign cell types. Mark concern " + "only when an image shows a specific conflict with numeric evidence." + ), + user_prompt=build_visual_evidence_prompt( + json.dumps(visual_payload, indent=2, sort_keys=True), + visual_content, + ), + config=config.agentRunConfig, + name="analysis_visual_review", + output_validator=validate, + ) + mode: Literal["multimodal", "numeric"] = "multimodal" + except ImageInputUnsupportedError: + logger.info( + "The configured model does not accept image input; retrying analysis " + "review with exact numeric evidence" + ) + execution = run_agent_sync( + model=model, + output_type=AnalysisVisualAdjudication, + system_prompt=( + "Adjudicate the selected analysis using only the supplied exact " + "numeric evidence. Evaluate feature, partition, batch, QC, and " + "doublet measurements. Do not infer spatial patterns or cell types. " + "Mark concern only when a supplied measurement conflicts with the " + "selected analysis." + ), + user_prompt=json.dumps( + _numeric_analysis_review_payload( + study_objective, + selected, + candidates, + ), + indent=2, + sort_keys=True, + ), + config=config.agentRunConfig, + name="analysis_numeric_review", + output_validator=validate, + ) + mode = "numeric" + if not isinstance(execution.output, AnalysisVisualAdjudication): + raise TypeError("Analysis review returned an unexpected output type") + return execution.output, mode + + +def _analysis_visual_content( + store: DataStore, + selected: ParameterCandidateEvaluation, + candidates: Sequence[ParameterCandidateEvaluation], + *, + qc_columns: Sequence[str] = (), + qc_artifact_metrics: Sequence[tuple[str, ArtifactReferenceModel]] = (), +) -> list[ImageEvidence]: + """Render one bounded diagnostic board for multimodal review.""" + try: + import matplotlib.pyplot as plt + except ImportError as exc: + raise RuntimeError( + "Visual adjudication requires the installed plotting dependencies" + ) from exc + + def image_content(figure: Any, identifier: str) -> ImageEvidence: + buffer = io.BytesIO() + figure.savefig(buffer, format="png", dpi=120) + plt.close(figure) + return ImageEvidence( + identifier=identifier, + data=buffer.getvalue(), + media_type="image/png", + ) + + def sampled_values(array: Any, selection: tuple[Any, ...]) -> np.ndarray: + return np.asarray( + cast(Any, as_zarr_array(array, name="diagnostic")).get_orthogonal_selection( + selection + ) + ) + + coordinate_name = ( + "harmony" + if selected.parameters.useHarmony + else selected.parameters.reductionMethod + ) + coordinate_record = selected.artifacts.get(coordinate_name) + cluster_record = selected.artifacts.get("clusters") + if coordinate_record is None or cluster_record is None: + raise ValueError( + "Selected candidate lacks visualizable coordinates or clusters" + ) + coordinate_group = store.load_artifact( + artifact_model_to_ref( + ArtifactReferenceModel.model_validate(coordinate_record.model_dump()) + ) + ) + cluster_group = store.load_artifact( + artifact_model_to_ref( + ArtifactReferenceModel.model_validate(cluster_record.model_dump()) + ) + ) + coordinate_values = as_zarr_array(coordinate_group["data"], name="data") + cluster_values = as_zarr_array(cluster_group["values"], name="values") + if len(coordinate_values.shape) != 2 or coordinate_values.shape[1] < 2: + raise ValueError("Selected coordinates need at least two dimensions") + if coordinate_values.shape[0] != cluster_values.shape[0]: + raise ValueError("Selected coordinates and clusters do not align") + sample_count = min(5_000, coordinate_values.shape[0]) + sample_indices = np.linspace( + 0, + coordinate_values.shape[0] - 1, + sample_count, + dtype=np.int64, + ) + coordinates = sampled_values( + coordinate_values, + (sample_indices, slice(0, 2)), + ).astype(np.float32, copy=False) + labels = sampled_values(cluster_values, (sample_indices,)) + _, label_codes = np.unique(labels, return_inverse=True) + + comparison = max( + ( + candidate + for candidate in candidates + if candidate.candidateId != selected.candidateId + and candidate.status == "done" + and candidate.eligible + and candidate.parameters.dimensions == selected.parameters.dimensions + and candidate.parameters.neighborsK == selected.parameters.neighborsK + and candidate.parameters.useHarmony == selected.parameters.useHarmony + ), + key=lambda value: ( + value.metrics.markerCoherence or 0.0, + value.metrics.seedStability or 0.0, + ), + default=None, + ) + + figure, axes = plt.subplots(2, 3, figsize=(15, 9), constrained_layout=True) + variance = np.asarray(selected.metrics.componentVariance, dtype=float) + if variance.size: + axes[0, 0].plot(np.arange(1, variance.size + 1), variance, marker=".") + axes[0, 0].set_title("PCA explained variance") + axes[0, 0].set_xlabel("Component") + else: + axes[0, 0].text(0.5, 0.5, "Variance unavailable", ha="center") + axes[0, 0].set_axis_off() + + axes[0, 1].scatter( + coordinates[:, 0], + coordinates[:, 1], + c=label_codes, + cmap="tab20", + s=2, + alpha=0.65, + rasterized=True, + ) + axes[0, 1].set_title(f"Selected partition: {selected.candidateId}") + + if comparison is not None and "clusters" in comparison.artifacts: + comparison_group = store.load_artifact( + artifact_model_to_ref( + ArtifactReferenceModel.model_validate( + comparison.artifacts["clusters"].model_dump() + ) + ) + ) + comparison_labels = sampled_values( + comparison_group["values"], + (sample_indices,), + ) + _, comparison_codes = np.unique( + comparison_labels, + return_inverse=True, + ) + axes[0, 2].scatter( + coordinates[:, 0], + coordinates[:, 1], + c=comparison_codes, + cmap="tab20", + s=2, + alpha=0.65, + rasterized=True, + ) + axes[0, 2].set_title(f"Matched alternative: {comparison.candidateId}") + else: + axes[0, 2].text(0.5, 0.5, "No matched alternative", ha="center") + axes[0, 2].set_axis_off() + + completed = [ + candidate + for candidate in candidates + if candidate.status == "done" + and candidate.eligible + and candidate.parameters.dimensions == selected.parameters.dimensions + and candidate.parameters.neighborsK == selected.parameters.neighborsK + and candidate.parameters.useHarmony == selected.parameters.useHarmony + ] + resolutions = [value.parameters.leidenResolution for value in completed] + axes[1, 0].plot( + resolutions, + [value.metrics.markerCoherence or 0.0 for value in completed], + marker="o", + label="marker coherence", + ) + axes[1, 0].plot( + resolutions, + [value.metrics.seedStability or 0.0 for value in completed], + marker="o", + label="seed stability", + ) + axes[1, 0].plot( + resolutions, + [value.metrics.crossUnitSupport or 0.0 for value in completed], + marker="o", + label="cross-unit support", + ) + axes[1, 0].set_title("Partition evidence") + axes[1, 0].set_xlabel("Leiden resolution") + axes[1, 0].legend(fontsize=8) + + doublet_records = { + name.removeprefix("doubletScore:"): value + for name, value in selected.artifacts.items() + if name.startswith("doubletScore:") + } + doublet_selections = { + name.removeprefix("doubletCellSelection:"): value + for name, value in selected.artifacts.items() + if name.startswith("doubletCellSelection:") + } + doublet_sample = np.full(sample_count, np.nan, dtype=np.float64) + doublet_hist_values: list[np.ndarray] = [] + if doublet_records: + if selected.cellSelection is None: + raise ValueError("Doublet visuals require an exact cell selection") + parent_selection = artifact_model_to_ref(selected.cellSelection) + parent_indices = read_stored_selection_indices( + store.zw, + parent_selection, + kind="cell_selection", + scope="datastore", + assay=None, + table_path="cellData", + ).astype(np.int64, copy=False) + sampled_global_indices = parent_indices[sample_indices] + for score_index, record in sorted(doublet_records.items()): + score_group = store.load_artifact( + artifact_model_to_ref( + ArtifactReferenceModel.model_validate(record.model_dump()) + ) + ) + score_values = as_zarr_array(score_group["values"], name="values") + selection_record = doublet_selections.get(score_index) + if selection_record is None: + if score_values.shape != parent_indices.shape: + raise ValueError( + "Doublet score artifact lacks its exact cell selection" + ) + local_positions = sample_indices + matched_positions = np.arange(sample_count, dtype=np.int64) + else: + score_selection = artifact_model_to_ref( + ArtifactReferenceModel.model_validate(selection_record.model_dump()) + ) + score_indices = read_stored_selection_indices( + store.zw, + score_selection, + kind="cell_selection", + scope="datastore", + assay=None, + table_path="cellData", + ).astype(np.int64, copy=False) + if score_values.shape != score_indices.shape: + raise ValueError( + "Doublet scores do not align with their cell selection" + ) + candidate_positions = np.searchsorted( + score_indices, + sampled_global_indices, + ) + within = candidate_positions < len(score_indices) + matched = np.zeros(sample_count, dtype=bool) + matched[within] = ( + score_indices[candidate_positions[within]] + == sampled_global_indices[within] + ) + matched_positions = np.flatnonzero(matched) + local_positions = candidate_positions[matched] + sampled_scores = sampled_values(score_values, (local_positions,)).astype( + np.float64, + copy=False, + ) + doublet_sample[matched_positions] = sampled_scores + doublet_hist_values.append(sampled_scores) + finite_doublets = np.concatenate(doublet_hist_values) + finite_doublets = finite_doublets[np.isfinite(finite_doublets)] + axes[1, 1].hist(finite_doublets, bins=40) + axes[1, 1].set_title("Advisory doublet scores") + else: + axes[1, 1].text(0.5, 0.5, "Doublet scores unavailable", ha="center") + axes[1, 1].set_axis_off() + + family_values = { + **selected.metrics.loadingFamilyEnrichment, + **selected.metrics.markerFamilyEnrichment, + } + if family_values: + ordered = sorted( + family_values.items(), + key=lambda item: (-item[1], item[0]), + )[:12] + axes[1, 2].barh( + [name for name, _value in reversed(ordered)], + [value for _name, value in reversed(ordered)], + ) + axes[1, 2].set_title("Feature-family enrichment") + else: + axes[1, 2].text(0.5, 0.5, "Family evidence unavailable", ha="center") + axes[1, 2].set_axis_off() + + content = [image_content(figure, "analysis-overview")] + + paired_corrections: list[ + tuple[ParameterCandidateEvaluation, ParameterCandidateEvaluation] + ] = [] + candidates_by_parameters: dict[ + tuple[str, int, int, float], + dict[bool, ParameterCandidateEvaluation], + ] = {} + for candidate in candidates: + if candidate.status != "done" or not candidate.eligible: + continue + key = ( + candidate.parameters.reductionMethod, + candidate.parameters.dimensions, + candidate.parameters.neighborsK, + candidate.parameters.leidenResolution, + ) + candidates_by_parameters.setdefault(key, {})[ + candidate.parameters.useHarmony + ] = candidate + for correction_pair in candidates_by_parameters.values(): + if False in correction_pair and True in correction_pair: + paired_corrections.append((correction_pair[False], correction_pair[True])) + if paired_corrections: + native, harmony = min( + paired_corrections, + key=lambda pair: ( + abs(pair[0].parameters.dimensions - selected.parameters.dimensions), + abs(pair[0].parameters.neighborsK - selected.parameters.neighborsK), + abs( + pair[0].parameters.leidenResolution + - selected.parameters.leidenResolution + ), + ), + ) + correction_figure, correction_axes = plt.subplots( + 2, + 2, + figsize=(11, 10), + constrained_layout=True, + ) + batch_columns = [ + column + for column in ( + *native.metrics.batchMixing, + *harmony.metrics.batchMixing, + ) + if column in store.cells.columns + ] + batch_codes: np.ndarray | None = None + batch_label = "Batch unavailable" + if batch_columns and selected.cellSelection is not None: + parent_selection = artifact_model_to_ref(selected.cellSelection) + parent_indices = read_stored_selection_indices( + store.zw, + parent_selection, + kind="cell_selection", + scope="datastore", + assay=None, + table_path="cellData", + ).astype(np.int64, copy=False) + batch_label = batch_columns[0] + batch_values = read_metadata_rows_chunkwise( + store.cells, + batch_label, + parent_indices[sample_indices], + ).astype(str) + _, batch_codes = np.unique(batch_values, return_inverse=True) + for column_index, candidate in enumerate((native, harmony)): + name = ( + "harmony" + if candidate.parameters.useHarmony + else candidate.parameters.reductionMethod + ) + candidate_coordinate = candidate.artifacts.get(name) + candidate_cluster = candidate.artifacts.get("clusters") + if candidate_coordinate is None or candidate_cluster is None: + raise ValueError("Matched correction candidate lacks visual artifacts") + candidate_coordinate_group = store.load_artifact( + artifact_model_to_ref( + ArtifactReferenceModel.model_validate( + candidate_coordinate.model_dump() + ) + ) + ) + candidate_cluster_group = store.load_artifact( + artifact_model_to_ref( + ArtifactReferenceModel.model_validate( + candidate_cluster.model_dump() + ) + ) + ) + candidate_coordinates = sampled_values( + candidate_coordinate_group["data"], + (sample_indices, slice(0, 2)), + ) + candidate_labels = sampled_values( + candidate_cluster_group["values"], + (sample_indices,), + ) + _, candidate_codes = np.unique( + candidate_labels, + return_inverse=True, + ) + title = "Harmony" if candidate.parameters.useHarmony else "Native PCA" + correction_axes[0, column_index].scatter( + candidate_coordinates[:, 0], + candidate_coordinates[:, 1], + c=candidate_codes, + cmap="tab20", + s=2, + alpha=0.65, + rasterized=True, + ) + correction_axes[0, column_index].set_title(f"{title}, partition colors") + if batch_codes is not None: + correction_axes[1, column_index].scatter( + candidate_coordinates[:, 0], + candidate_coordinates[:, 1], + c=batch_codes, + cmap="tab20", + s=2, + alpha=0.65, + rasterized=True, + ) + correction_axes[1, column_index].set_title( + f"{title}, {batch_label} colors" + ) + else: + correction_axes[1, column_index].text( + 0.5, + 0.5, + batch_label, + ha="center", + ) + correction_axes[1, column_index].set_axis_off() + correction_figure.suptitle( + "Parameter-matched native and Harmony representations" + ) + content.append(image_content(correction_figure, "native-harmony-comparison")) + + marker_record = selected.artifacts.get("markerTable") + marker_genes = list( + dict.fromkeys( + gene for genes in selected.metrics.topMarkerGenes.values() for gene in genes + ) + )[:24] + if marker_record is not None and marker_genes: + marker_ref = artifact_model_to_ref( + ArtifactReferenceModel.model_validate(marker_record.model_dump()) + ) + marker_table = store.get_markers( + marker_ref, + min_score=0.25, + min_frac_exp=0.2, + ) + required_marker_columns = {"group_id", "feature_name", "score"} + if required_marker_columns.issubset(marker_table.columns): + marker_groups = sorted( + selected.metrics.topMarkerGenes, + key=lambda value: ( + (0, int(value)) if value.lstrip("-").isdigit() else (1, value) + ), + ) + marker_scores = np.zeros( + (len(marker_groups), len(marker_genes)), + dtype=np.float64, + ) + group_positions = { + value: index for index, value in enumerate(marker_groups) + } + gene_positions = {value: index for index, value in enumerate(marker_genes)} + for row in marker_table.itertuples(index=False): + group = str(getattr(row, "group_id")) + gene = str(getattr(row, "feature_name")) + if group not in group_positions or gene not in gene_positions: + continue + score = float(getattr(row, "score")) + if np.isfinite(score): + marker_scores[group_positions[group], gene_positions[gene]] = max( + marker_scores[ + group_positions[group], + gene_positions[gene], + ], + score, + ) + + def tagged_gene(gene: str) -> str: + upper = gene.upper() + if upper.startswith("MT-"): + return f"{gene} [mitochondrial]" + if upper.startswith(("MRPS", "MRPL")): + return f"{gene} [mitoribosomal]" + if upper.startswith(("RPS", "RPL")): + return f"{gene} [ribosomal]" + if upper.startswith("CCN"): + return f"{gene} [CCN]" + if upper.startswith("HLA-"): + return f"{gene} [HLA]" + if upper.startswith("H2-"): + return f"{gene} [H2]" + if upper.startswith("HIST"): + return f"{gene} [histone]" + if upper in { + "XIST", + "DDX3Y", + "USP9Y", + "EIF1AY", + "KDM5D", + "SRY", + "ZFY", + "UTY", + "TMSB4Y", + "NLGN4Y", + }: + return f"{gene} [sex-linked]" + return gene + + marker_figure, marker_axis = plt.subplots( + figsize=(max(9, len(marker_genes) * 0.45), 6), + constrained_layout=True, + ) + marker_image = marker_axis.imshow( + marker_scores, + aspect="auto", + interpolation="nearest", + cmap="viridis", + ) + marker_axis.set_xticks( + np.arange(len(marker_genes)), + [tagged_gene(gene) for gene in marker_genes], + rotation=75, + ha="right", + fontsize=8, + ) + marker_axis.set_yticks( + np.arange(len(marker_groups)), + marker_groups, + ) + marker_axis.set_xlabel("Top marker feature") + marker_axis.set_ylabel("Cluster") + marker_axis.set_title("Feature-level marker score heatmap") + marker_figure.colorbar(marker_image, ax=marker_axis, label="marker score") + content.append(image_content(marker_figure, "marker-score-heatmap")) + + available_qc_columns = [ + column + for column in dict.fromkeys([*qc_columns, *selected.metrics.qcPcaAssociation]) + if column in store.cells.columns + ] + qc_sources: list[tuple[str, ArtifactReferenceModel | None]] = [ + (column, None) for column in available_qc_columns + ] + known_qc_names = set(available_qc_columns) + for qc_name, reference in qc_artifact_metrics: + if qc_name not in known_qc_names: + qc_sources.append((qc_name, reference)) + known_qc_names.add(qc_name) + qc_sources = qc_sources[:4] + if qc_sources or np.isfinite(doublet_sample).any(): + diagnostic_count = len(qc_sources) + 1 + qc_figure, qc_axes = plt.subplots( + 1, + diagnostic_count, + figsize=(max(5, diagnostic_count * 3.2), 4), + constrained_layout=True, + ) + qc_axis_list = np.atleast_1d(qc_axes).tolist() + if selected.cellSelection is None: + raise ValueError("QC visuals require an exact cell selection") + parent_selection = artifact_model_to_ref(selected.cellSelection) + parent_indices = read_stored_selection_indices( + store.zw, + parent_selection, + kind="cell_selection", + scope="datastore", + assay=None, + table_path="cellData", + ).astype(np.int64, copy=False) + sampled_parent_indices = parent_indices[sample_indices] + for axis, (qc_name, artifact) in zip( + qc_axis_list, + qc_sources, + strict=False, + ): + if artifact is None: + values = np.asarray( + read_metadata_rows_chunkwise( + store.cells, + qc_name, + sampled_parent_indices, + ), + dtype=np.float64, + ) + else: + artifact_ref = artifact_model_to_ref(artifact) + artifact_group = store.load_artifact(artifact_ref) + artifact_values = as_zarr_array( + artifact_group["values"], + name="values", + ) + if artifact_values.shape == parent_indices.shape: + artifact_positions = sample_indices + else: + artifact_status = store.inspect_artifact(artifact_ref) + raw_selection = ( + getattr(artifact_status, "inputs", None) or {} + ).get("cell_selection") + if not isinstance(raw_selection, Mapping): + raise ValueError( + f"QC artifact {qc_name!r} lacks its cell selection" + ) + artifact_selection = ArtifactRef.from_dict(dict(raw_selection)) + artifact_indices = read_stored_selection_indices( + store.zw, + artifact_selection, + kind="cell_selection", + scope="datastore", + assay=None, + table_path="cellData", + ).astype(np.int64, copy=False) + candidate_positions = np.searchsorted( + artifact_indices, + sampled_parent_indices, + ) + if np.any( + candidate_positions >= len(artifact_indices) + ) or not np.array_equal( + artifact_indices[candidate_positions], + sampled_parent_indices, + ): + raise ValueError( + f"QC artifact {qc_name!r} does not cover selected cells" + ) + artifact_positions = candidate_positions + values = sampled_values( + artifact_values, + (artifact_positions,), + ).astype(np.float64, copy=False) + finite_values = values[np.isfinite(values)] + axis.violinplot(finite_values, showmedians=True) + jitter = ( + (np.arange(len(finite_values), dtype=np.float64) % 23.0) - 11.0 + ) / 115.0 + axis.scatter( + np.ones(len(finite_values)) + jitter, + finite_values, + s=1, + alpha=0.15, + rasterized=True, + ) + axis.set_xticks([]) + axis.set_title(qc_name) + doublet_axis = qc_axis_list[-1] + finite_doublet_mask = np.isfinite(doublet_sample) + if finite_doublet_mask.any(): + doublet_plot = doublet_axis.scatter( + coordinates[finite_doublet_mask, 0], + coordinates[finite_doublet_mask, 1], + c=doublet_sample[finite_doublet_mask], + cmap="magma", + s=2, + alpha=0.7, + rasterized=True, + ) + doublet_axis.set_title("Advisory doublet score") + qc_figure.colorbar( + doublet_plot, + ax=doublet_axis, + label="score", + ) + else: + doublet_axis.text( + 0.5, + 0.5, + "Doublet embedding unavailable", + ha="center", + ) + doublet_axis.set_axis_off() + qc_figure.suptitle("Selected-cell QC and non-removing doublet evidence") + content.append(image_content(qc_figure, "qc-doublet-diagnostics")) + return content + + class TuningStagesMixin(DecisionStagesMixin): """Execute parameter searches, integration comparisons, and graph selection.""" @@ -197,26 +1328,342 @@ def _tuning_evidence_bundle( evidence=evidence, ).with_content_sha256() - @staticmethod - def _evaluation_artifacts( - evaluation: Any, - ) -> list[ArtifactReferenceModel]: - references: list[ArtifactReferenceModel] = [] - identities: set[tuple[str, str | None, str, str]] = set() - for name in sorted(evaluation.artifacts): - reference = ArtifactReferenceModel.model_validate( - evaluation.artifacts[name].model_dump() + @staticmethod + def _evaluation_artifacts( + evaluation: Any, + ) -> list[ArtifactReferenceModel]: + references: list[ArtifactReferenceModel] = [] + identities: set[tuple[str, str | None, str, str]] = set() + for name in sorted(evaluation.artifacts): + reference = ArtifactReferenceModel.model_validate( + evaluation.artifacts[name].model_dump() + ) + identity = ( + reference.scope, + reference.assay, + reference.kind, + reference.artifactId, + ) + if identity not in identities: + identities.add(identity) + references.append(reference) + return references + + def _analysis_candidate_evidence( + self, + checkpoint: str, + evaluation: ParameterCandidateEvaluation, + ) -> list[DecisionEvidence]: + metrics = evaluation.metrics + artifacts = self._evaluation_artifacts(evaluation) + summaries = { + "geometric": ( + f"candidate={evaluation.candidateId}; dimensions=" + f"{evaluation.parameters.dimensions}; neighbors=" + f"{evaluation.parameters.neighborsK}; resolution=" + f"{evaluation.parameters.leidenResolution}; silhouette=" + f"{metrics.graphSilhouetteMedian}; connectivity=" + f"{metrics.clusterConnectivity}; membership=" + f"{metrics.membershipStrengthMean}; minimum cluster fraction=" + f"{metrics.minClusterFraction}." + ), + "technical": ( + f"batch PC association={metrics.batchPcaAssociation}; technical " + f"PC association={metrics.technicalPcaAssociation}; QC PC " + f"association={metrics.qcPcaAssociation}; neighbour-prefix " + f"overlap={metrics.neighborPrefixOverlap}." + ), + "batchRemoval": ( + f"Harmony={evaluation.parameters.useHarmony}; batch mixing=" + f"{metrics.batchMixing}." + ), + "biologicalConservation": ( + f"biological preservation={metrics.biologicalPreservation}; " + f"marker coherence={metrics.markerCoherence}; marker specificity=" + f"{metrics.markerSpecificityMedian}; cross-unit support=" + f"{metrics.crossUnitSupport}." + ), + "protectedVariablePreservation": ( + f"protected PC association={metrics.protectedPcaAssociation}; " + f"protected marker families={metrics.protectedMarkerFamilies}." + ), + "markerCoherence": ( + f"marker coherence={metrics.markerCoherence}; specificity=" + f"{metrics.markerSpecificityMedian}; family enrichment=" + f"{metrics.markerFamilyEnrichment}." + ), + "resamplingStability": ( + f"seed ARI={metrics.seedStability}; subsample ARI=" + f"{metrics.subsampleStability}." + ), + "crossUnitSupport": ( + f"cross-unit support={metrics.crossUnitSupport}; technical " + f"association={metrics.technicalAssociation}." + ), + "qualityControl": ( + f"doublet concentration={metrics.doubletHighScoreConcentration}; " + f"doublet capture coverage={metrics.doubletCaptureCoverage}; " + f"score quantiles={metrics.doubletScoreQuantiles}." + ), + } + return [ + DecisionEvidence( + evidenceId=( + f"evidence:analysisReview:{checkpoint}:" + f"{evaluation.candidateId}:{evidence_class}" + ), + evidenceClass=cast(Any, evidence_class), + summary=_bounded_evidence_summary(summary), + artifactReferences=artifacts, + ) + for evidence_class, summary in summaries.items() + ] + + @staticmethod + def _payload_option_id( + definition: Any, + payload_type: type[Any], + field_name: str, + value: Any, + ) -> str: + matches = [ + option.optionId + for option in definition.executorOptions + if isinstance(option.payload, payload_type) + and getattr(option.payload, field_name) == value + ] + if len(matches) != 1: + raise ValueError( + f"Decision {definition.spec.decisionId!r} lacks one exact " + f"{field_name!r} option for {value!r}" + ) + return cast(str, matches[0]) + + def _restore_tuning_descendants( + self, + store: DataStore, + request_record: OrchestrationRequestRecord, + study_contract: StudyContract, + replacement: ParameterCandidateEvaluation, + *, + revised_checkpoint: str, + previous_options: Mapping[str, str], + model_name: str | None, + ) -> str: + """Recreate invalidated tuning decisions from one executed replacement.""" + order = ( + "pcaPrefix", + "correctionLicense", + "correctionNeed", + "correctionOutcome", + "graphK", + "clusterPartition", + ) + try: + start = order.index(revised_checkpoint) + 1 + except ValueError as exc: + raise ValueError( + f"Unknown revised tuning checkpoint {revised_checkpoint!r}" + ) from exc + latest_snapshot = "" + + def resolve( + definition: Any, + evidence: list[DecisionEvidence], + option_id: str, + *, + rule_owned: bool = False, + ) -> None: + nonlocal latest_snapshot + bundle = self._tuning_evidence_bundle( + definition.spec.decisionId, + evidence, + ) + if definition.spec.evidenceBundleId != bundle.bundleId: + raise ValueError("Successor definition has stale evidence identity") + selection = DecisionSelection( + selectedOptionId=option_id, + evidenceIds=[value.evidenceId for value in evidence], + rationale=( + "The upstream analysis decision changed, so this descendant " + "was recomputed from the exact executed replacement candidate." + ), + confidence="medium", + ) + resolved = self._resolve_rna_decision( + store, + request_record, + definition, + bundle, + {}, + **( + {"rule_selection": selection} + if rule_owned + else { + "agent_selection": selection, + "agent_model_name": model_name, + } + ), + ) + if resolved.compiled is None or resolved.record is None: + raise RuntimeError( + f"Successor {definition.spec.decisionId!r} did not resolve" + ) + latest_snapshot = resolved.snapshotSha256 + + artifacts = self._evaluation_artifacts(replacement) + generic = self._analysis_candidate_evidence( + "successor", + replacement, + ) + if "correctionLicense" in order[start:]: + license_evidence = [ + DecisionEvidence( + evidenceId=("evidence:analysisSuccessor:correctionLicense:design"), + evidenceClass="design", + summary=( + "The validated study contract licenses correction as " + f"{study_contract.correctionLicense!r} with technical " + f"columns {study_contract.technicalBatchColumns} and " + f"protected columns {study_contract.protectedColumns}." + ), + artifactReferences=artifacts, + ) + ] + license_bundle = self._tuning_evidence_bundle( + "correctionLicense", + license_evidence, + ) + license_definition = build_correction_license_decision( + evidence_bundle_id=license_bundle.bundleId, + license=cast(Any, study_contract.correctionLicense), + ) + resolve( + license_definition, + license_evidence, + f"correctionLicense:{study_contract.correctionLicense}", + rule_owned=True, + ) + + need_value: Literal["needed", "notNeeded"] | None = None + previous_need = previous_options.get("correctionNeed") + if study_contract.correctionLicense == "safe": + need_value = ( + "needed" + if previous_need == "correctionNeed:needed" + or replacement.parameters.useHarmony + else "notNeeded" + ) + if "correctionNeed" in order[start:] and need_value is not None: + need_evidence = [ + value + for value in generic + if value.evidenceClass in {"batchRemoval", "biologicalConservation"} + ] + need_bundle = self._tuning_evidence_bundle( + "correctionNeed", + need_evidence, + ) + need_definition = build_correction_need_decision( + evidence_bundle_id=need_bundle.bundleId, + license="safe", + ) + resolve( + need_definition, + need_evidence, + f"correctionNeed:{need_value}", + ) + + if "correctionOutcome" in order[start:]: + outcome_evidence = [ + value + for value in generic + if value.evidenceClass + in { + "batchRemoval", + "biologicalConservation", + "protectedVariablePreservation", + } + ] + outcome_bundle = self._tuning_evidence_bundle( + "correctionOutcome", + outcome_evidence, + ) + outcome_definition = build_correction_outcome_decision( + evidence_bundle_id=outcome_bundle.bundleId, + license=cast(Any, study_contract.correctionLicense), + need=need_value, + harmony_eligible=True, + ) + outcome_id = ( + "correctionOutcome:acceptHarmony" + if replacement.parameters.useHarmony + else "correctionOutcome:retainNative" + ) + resolve( + outcome_definition, + outcome_evidence, + outcome_id, + rule_owned=outcome_definition.spec.allowedSources == ["rule"], + ) + + if "graphK" in order[start:]: + graph_evidence = [ + value for value in generic if value.evidenceClass == "geometric" + ] + graph_bundle = self._tuning_evidence_bundle("graphK", graph_evidence) + graph_definition = build_graph_k_decision( + evidence_bundle_id=graph_bundle.bundleId, + n_cells=max( + replacement.parameters.neighborsK + 1, + replacement.metrics.minClusterCells or 3, + ), + candidate_neighbors=[replacement.parameters.neighborsK], + ) + graph_option = self._payload_option_id( + graph_definition, + GraphExecutorPayload, + "neighborsK", + replacement.parameters.neighborsK, + ) + resolve( + graph_definition, + graph_evidence, + graph_option, + ) + + if "clusterPartition" in order[start:]: + cluster_evidence = [ + value for value in generic if value.evidenceClass == "geometric" + ] + cluster_bundle = self._tuning_evidence_bundle( + "clusterPartition", + cluster_evidence, ) - identity = ( - reference.scope, - reference.assay, - reference.kind, - reference.artifactId, + resolution = replacement.parameters.leidenResolution + known_ids = { + 0.25: "clusterResolution:veryCoarse", + 0.5: "clusterResolution:coarse", + 0.75: "clusterResolution:balanced", + 1.0: "clusterResolution:detailed", + 1.25: "clusterResolution:fine", + 1.5: "clusterResolution:veryFine", + } + preferred = known_ids.get( + resolution, + f"clusterResolution:r{str(resolution).replace('.', 'p')}", ) - if identity not in identities: - identities.add(identity) - references.append(reference) - return references + cluster_definition = build_cluster_partition_decision( + evidence_bundle_id=cluster_bundle.bundleId, + metric_preferred_option_id=preferred, + resolution_candidates=[resolution], + ) + resolve( + cluster_definition, + cluster_evidence, + preferred, + ) + return latest_snapshot @staticmethod def _phase_from_resolution( @@ -262,6 +1709,156 @@ def _phase_from_resolution( ) return validate_parameter_phase_selection(plan, evaluations, selection) + @staticmethod + def _augment_legacy_scientific_evidence( + store: DataStore, + report: ParameterTuningReport, + *, + plan: AutomatedPreprocessingPlan, + preprocessed: Sequence[PreprocessedAssayHandoff], + study_contract: StudyContract | None, + ) -> ParameterTuningReport: + handoff_by_assay = {value.assay: value for value in preprocessed} + plan_by_assay = {value.assay: value for value in plan.assays} + updated_reports: dict[str, ParameterTuningReport] = {} + for assay, assay_report in report.assayReports.items(): + handoff = handoff_by_assay[assay] + assay_plan = plan_by_assay[assay] + diagnostic_batch_columns = ( + [ + column + for column in dict.fromkeys( + ( + study_contract.physicalCaptureColumn, + *study_contract.technicalBatchColumns, + ) + ) + if column is not None + ] + if study_contract is not None + else [] + ) + if handoff.graphFeatures is None or handoff.markerFeatures is None: + raise ValueError( + f"Assay {assay!r} lacks feature selections for diagnostics" + ) + nominated_families = cast( + list[str], + assay_plan.featureParameters.get( + "proposedExcludeFamilies", + [], + ), + ) + diagnostic_families = list( + dict.fromkeys( + [ + *SCARF_DEFAULT_DIAGNOSTIC_FAMILIES, + *nominated_families, + ] + ) + ) + protected_families = cast( + list[str], + assay_plan.featureParameters.get("protectFamilies", []), + ) + pca_augmented = list( + augment_pca_evaluations( + store, + assay_report.evaluations, + feature_selection=artifact_model_to_ref(handoff.graphFeatures), + nominated_families=diagnostic_families, + protected_families=protected_families, + technical_columns=diagnostic_batch_columns, + batch_columns=diagnostic_batch_columns, + protected_columns=( + study_contract.protectedColumns + if study_contract is not None + else [] + ), + qc_columns=[ + column + for column in plan.cellQc.attributes + if column in store.cells.columns + ], + ) + ) + selected = next( + ( + evaluation + for evaluation in pca_augmented + if evaluation.candidateId == assay_report.recommendedCandidateId + ), + None, + ) + if selected is None: + raise ValueError( + f"Assay {assay!r} lacks its recommended candidate execution" + ) + native = next( + ( + evaluation + for evaluation in pca_augmented + if not evaluation.parameters.useHarmony + and evaluation.status == "done" + and evaluation.eligible + ), + None, + ) + if native is None: + raise ValueError( + f"Assay {assay!r} lacks an eligible native doublet baseline" + ) + doublet_evidence = score_advisory_doublets( + store, + native, + pca_augmented, + assay=assay, + feature_selection=artifact_model_to_ref(handoff.graphFeatures), + capture_column=( + study_contract.physicalCaptureColumn + if study_contract is not None + else None + ), + ) + augmented = list( + augment_cluster_evaluations( + store, + pca_augmented, + marker_assay=plan.markerAssay, + marker_features=artifact_model_to_ref(handoff.markerFeatures), + independent_unit_columns=( + study_contract.independentUnitColumns + if study_contract is not None + else [] + ), + technical_columns=diagnostic_batch_columns, + nominated_families=diagnostic_families, + protected_families=protected_families, + doublet_evidence=doublet_evidence, + ) + ) + augmented_selected = next( + value + for value in augmented + if value.candidateId == selected.candidateId + ) + updated_reports[assay] = assay_report.model_copy( + update={ + "evaluations": augmented, + "selectedArtifacts": dict(augmented_selected.artifacts), + } + ) + root_updates: dict[str, Any] = {"assayReports": updated_reports} + if report.fromAssay in updated_reports: + primary = updated_reports[report.fromAssay] + root_updates.update( + { + "evaluations": list(primary.evaluations), + "selectedArtifacts": dict(primary.selectedArtifacts), + } + ) + return report.model_copy(update=root_updates) + def _run_sequential_rna_tuning( self, store: DataStore, @@ -279,6 +1876,7 @@ def _run_sequential_rna_tuning( handoff = preprocessed[0] if ( handoff.assayType != "RNA" + or handoff.cellSelection is None or handoff.normalized is None or handoff.graphFeatures is None or handoff.markerFeatures is None @@ -310,17 +1908,48 @@ def phase_evaluations( ) return tuple(persisted.evaluations) - harmony_authorized = ( + diagnostic_batch_candidates = ( + tuple(study_contract.technicalBatchColumns) + if study_contract.correctionLicense == "safe" + and experimental_handoff.batchAction == "evaluateHarmony" + else ( + ( + study_contract.physicalCaptureColumn, + *study_contract.technicalBatchColumns, + ) + if request_record.config.runConfoundedHarmonyDiagnostic + else tuple(study_contract.technicalBatchColumns) + ) + ) + diagnostic_batch_columns = [ + column + for column in dict.fromkeys(diagnostic_batch_candidates) + if column is not None + and column in store.cells.columns + and len(np.unique(store.cells.fetch(column, key="I"))) > 1 + ] + selectable_harmony = bool( study_contract.correctionLicense == "safe" and experimental_handoff.batchAction == "evaluateHarmony" - and bool(experimental_handoff.batchColumns) + and diagnostic_batch_columns + and sorted(diagnostic_batch_columns) + == sorted(experimental_handoff.batchColumns) + ) + evaluate_harmony = bool( + diagnostic_batch_columns + and request_record.config.maxHarmonyCandidatesPerAssay == 1 + and ( + selectable_harmony + or request_record.config.runConfoundedHarmonyDiagnostic + ) ) + tuning_handoff = experimental_handoff if selectable_harmony else None planner = SequentialRnaTuningPlanner( workflow_run_id=workflow.workflowRunId, assay=handoff.assay, n_cells=handoff.nCells, n_features=handoff.nFeatures, - harmony_authorized=harmony_authorized, + harmony_authorized=evaluate_harmony, dimension_candidates=request_record.config.pcaCandidateDimensions, neighbor_candidates=request_record.config.graphNeighborCandidates, resolution_candidates=request_record.config.leidenResolutionCandidates, @@ -400,6 +2029,14 @@ def return_pending( list[str], assay_plan.featureParameters.get("protectFamilies", []), ) + diagnostic_families = list( + dict.fromkeys( + [ + *SCARF_DEFAULT_DIAGNOSTIC_FAMILIES, + *nominated_families, + ] + ) + ) pca_plan = planner.pca_prefix_phase() raw_pca = phase_evaluations( pca_plan, @@ -407,11 +2044,9 @@ def return_pending( store, normalized=normalized, plan=pca_plan, - batch_columns=( - experimental_handoff.batchColumns if harmony_authorized else [] - ), + batch_columns=diagnostic_batch_columns, preservation_columns=experimental_handoff.preservationColumns, - experimental_handoff=experimental_handoff, + experimental_handoff=tuning_handoff, min_cluster_cells=request_record.config.minClusterCells, identity_feature_limit=request_record.config.maxIdentityFeatures, ), @@ -420,9 +2055,10 @@ def return_pending( store, raw_pca, feature_selection=artifact_model_to_ref(handoff.graphFeatures), - nominated_families=nominated_families, + nominated_families=diagnostic_families, protected_families=protected_families, - technical_columns=study_contract.technicalBatchColumns, + technical_columns=diagnostic_batch_columns, + batch_columns=diagnostic_batch_columns, protected_columns=study_contract.protectedColumns, qc_columns=[ column @@ -439,23 +2075,40 @@ def return_pending( if evaluation.status == "done" and evaluation.eligible: eligible_pca_dimensions.append(evaluation.parameters.dimensions) technical_id = f"evidence:pca:{evaluation.candidateId}:technical" + loading_preview = { + component: genes[:3] + for component, genes in list( + evaluation.metrics.topLoadingGenes.items() + )[:10] + } + cumulative_variance = ( + evaluation.metrics.pcaCumulativeExplainedVarianceRatio[-1] + if evaluation.metrics.pcaCumulativeExplainedVarianceRatio + else None + ) + pca_summary = _bounded_evidence_summary( + f"The exact PCA candidate used " + f"{evaluation.effectiveDimensions} dimensions; " + f"first component variances=" + f"{evaluation.metrics.componentVariance[:10]}; " + f"first explained variance ratios=" + f"{evaluation.metrics.pcaExplainedVarianceRatio[:10]}; " + f"total cumulative explained variance={cumulative_variance}; " + f"top loading-gene preview={loading_preview}; " + "maximum default/context-family loading enrichment=" + f"{evaluation.metrics.loadingFamilyEnrichment}; " + "technical PC association=" + f"{evaluation.metrics.technicalPcaAssociation}; " + "protected PC association=" + f"{evaluation.metrics.protectedPcaAssociation}; " + f"QC PC association={evaluation.metrics.qcPcaAssociation}; " + f"warnings={evaluation.warnings}." + ) pca_items.append( DecisionEvidence( evidenceId=technical_id, evidenceClass="technical", - summary=( - f"The exact PCA candidate used " - f"{evaluation.effectiveDimensions} dimensions; " - f"component variance={evaluation.metrics.componentVariance}; " - "maximum nominated-family loading enrichment=" - f"{evaluation.metrics.loadingFamilyEnrichment}; " - "technical PC association=" - f"{evaluation.metrics.technicalPcaAssociation}; " - "protected PC association=" - f"{evaluation.metrics.protectedPcaAssociation}; " - f"QC PC association={evaluation.metrics.qcPcaAssociation}; " - f"warnings={evaluation.warnings}." - ), + summary=pca_summary, artifactReferences=self._evaluation_artifacts(evaluation), ) ) @@ -487,7 +2140,7 @@ def return_pending( DecisionEvidence( evidenceId=failure_id, evidenceClass="other", - summary=( + summary=_bounded_evidence_summary( f"The candidate was not eligible: " f"{evaluation.error or evaluation.eligibilityReasons}." ), @@ -733,7 +2386,7 @@ def return_pending( full_correction_plan = planner.batch_correction_phase(selected_pca.parameters) correction_candidates = list(full_correction_plan.candidates) - if not (license_payload.license == "safe" and correction_need == "needed"): + if not evaluate_harmony: correction_candidates = [ candidate for candidate in correction_candidates @@ -755,21 +2408,36 @@ def return_pending( store, normalized=normalized, plan=correction_plan, - batch_columns=( - experimental_handoff.batchColumns - if any( - candidate.useHarmony - for candidate in correction_plan.candidates - ) - else [] - ), + batch_columns=diagnostic_batch_columns, preservation_columns=experimental_handoff.preservationColumns, - experimental_handoff=experimental_handoff, + experimental_handoff=tuning_handoff, min_cluster_cells=request_record.config.minClusterCells, identity_feature_limit=request_record.config.maxIdentityFeatures, ), ) ) + correction_native = next( + ( + evaluation + for evaluation in correction_evaluations + if not evaluation.parameters.useHarmony + and evaluation.status == "done" + and evaluation.eligible + ), + None, + ) + correction_doublets = ( + score_advisory_doublets( + store, + correction_native, + correction_evaluations, + assay=handoff.assay, + feature_selection=artifact_model_to_ref(handoff.graphFeatures), + capture_column=study_contract.physicalCaptureColumn, + ) + if correction_native is not None + else None + ) correction_evaluations = list( augment_cluster_evaluations( store, @@ -777,9 +2445,10 @@ def return_pending( marker_assay=plan.markerAssay, marker_features=artifact_model_to_ref(handoff.markerFeatures), independent_unit_columns=study_contract.independentUnitColumns, - technical_columns=study_contract.technicalBatchColumns, - nominated_families=nominated_families, + technical_columns=diagnostic_batch_columns, + nominated_families=diagnostic_families, protected_families=protected_families, + doublet_evidence=correction_doublets, ) ) native_evaluation = next( @@ -824,10 +2493,21 @@ def return_pending( harmony_eligible, harmony_gate_reasons = harmony_acceptance_gate( native_evaluation, harmony_evaluation, - batch_columns=study_contract.technicalBatchColumns, + batch_columns=diagnostic_batch_columns, protected_columns=study_contract.protectedColumns, independent_unit_columns=study_contract.independentUnitColumns, + require_doublet_evidence=True, + ) + harmony_eligible = ( + harmony_eligible + and license_payload.license == "safe" + and correction_need == "needed" ) + if harmony_evaluation is not None and not selectable_harmony: + harmony_gate_reasons = [ + *harmony_gate_reasons, + "Harmony was executed for diagnosis but is not licensed for selection.", + ] harmony_evidence_ids: list[str] = [] if harmony_evaluation is not None: harmony_biology_id = "evidence:correctionOutcome:harmonyBiology" @@ -1005,6 +2685,37 @@ def return_pending( identity_feature_limit=request_record.config.maxIdentityFeatures, ), ) + graph_doublet_reference = next( + ( + evaluation + for evaluation in raw_graph + if evaluation.status == "done" and evaluation.eligible + ), + None, + ) + graph_doublet_evidence = ( + score_advisory_doublets( + store, + graph_doublet_reference, + raw_graph, + assay=handoff.assay, + feature_selection=artifact_model_to_ref(handoff.graphFeatures), + capture_column=study_contract.physicalCaptureColumn, + ) + if graph_doublet_reference is not None + else None + ) + raw_graph = augment_cluster_evaluations( + store, + raw_graph, + marker_assay=plan.markerAssay, + marker_features=artifact_model_to_ref(handoff.markerFeatures), + independent_unit_columns=study_contract.independentUnitColumns, + technical_columns=diagnostic_batch_columns, + nominated_families=diagnostic_families, + protected_families=protected_families, + doublet_evidence=graph_doublet_evidence, + ) graph_items: list[DecisionEvidence] = [] graph_evaluations: list[ParameterCandidateEvaluation] = [] eligible_graph_values: list[int] = [] @@ -1029,6 +2740,63 @@ def return_pending( ) ) graph_extra.append(evidence_id) + graph_summaries = ( + ( + "stability", + "resamplingStability", + ( + f"seed ARI={evaluation.metrics.seedStability}; " + f"subsample ARI={evaluation.metrics.subsampleStability}; " + "membership strength=" + f"{evaluation.metrics.membershipStrengthMean}; cluster " + f"connectivity={evaluation.metrics.clusterConnectivity}." + ), + ), + ( + "markers", + "markerCoherence", + ( + f"marker coherence={evaluation.metrics.markerCoherence}; " + "marker specificity=" + f"{evaluation.metrics.markerSpecificityMedian}; " + "default/context-family enrichment=" + f"{evaluation.metrics.markerFamilyEnrichment}; protected " + f"families={evaluation.metrics.protectedMarkerFamilies}." + ), + ), + ( + "support", + "crossUnitSupport", + ( + f"cross-unit support={evaluation.metrics.crossUnitSupport}; " + "technical association=" + f"{evaluation.metrics.technicalAssociation}." + ), + ), + ( + "doublets", + "qualityControl", + ( + "advisory doublet concentration=" + f"{evaluation.metrics.doubletHighScoreConcentration}; " + "score quantiles=" + f"{evaluation.metrics.doubletScoreQuantiles}." + ), + ), + ) + for suffix, evidence_class, summary in graph_summaries: + graph_evidence_id = ( + f"evidence:graph:{evaluation.candidateId}:{suffix}" + ) + graph_items.append( + DecisionEvidence( + evidenceId=graph_evidence_id, + evidenceClass=cast(Any, evidence_class), + summary=summary, + artifactReferences=self._evaluation_artifacts(evaluation), + ) + ) + graph_extra.append(graph_evidence_id) graph_evidence_by_k[evaluation.parameters.neighborsK] = list( graph_extra ) @@ -1038,7 +2806,7 @@ def return_pending( DecisionEvidence( evidenceId=evidence_id, evidenceClass="other", - summary=( + summary=_bounded_evidence_summary( f"The graph candidate was not eligible: " f"{evaluation.error or evaluation.eligibilityReasons}." ), @@ -1122,14 +2890,11 @@ def return_pending( correction_license=license_payload.license, ) - doublet_evidence = score_advisory_doublets( - store, - selected_graph, - graph_evaluations, - assay=handoff.assay, - feature_selection=artifact_model_to_ref(handoff.graphFeatures), - capture_column=study_contract.physicalCaptureColumn, - ) + if graph_doublet_evidence is None: + raise ValueError( + "Selected graph lacks the required advisory doublet evidence" + ) + doublet_evidence = graph_doublet_evidence cluster_plan = planner.clustering_phase(selected_graph.parameters) persisted_cluster = prior_phases.get(cluster_plan.phase) if persisted_cluster is not None: @@ -1167,8 +2932,8 @@ def return_pending( marker_assay=plan.markerAssay, marker_features=artifact_model_to_ref(handoff.markerFeatures), independent_unit_columns=study_contract.independentUnitColumns, - technical_columns=study_contract.technicalBatchColumns, - nominated_families=nominated_families, + technical_columns=diagnostic_batch_columns, + nominated_families=diagnostic_families, protected_families=protected_families, doublet_evidence=doublet_evidence, ) @@ -1181,6 +2946,15 @@ def return_pending( for evaluation in cluster_evaluations: cluster_extra: list[str] = [] if evaluation.status == "done" and evaluation.eligible: + marker_auc_preview = dict( + list(evaluation.metrics.markerAucByCluster.items())[:20] + ) + marker_gene_preview = { + cluster: genes[:3] + for cluster, genes in list( + evaluation.metrics.topMarkerGenes.items() + )[:20] + } eligible_cluster_values.append(evaluation.parameters.leidenResolution) geometry_id = f"evidence:cluster:{evaluation.candidateId}:geometry" stability_id = f"evidence:cluster:{evaluation.candidateId}:stability" @@ -1197,7 +2971,11 @@ def return_pending( f"{evaluation.metrics.graphSilhouetteMedian}, " f"{evaluation.metrics.nClusters} clusters, and " f"minimum cluster size " - f"{evaluation.metrics.minClusterCells}." + f"{evaluation.metrics.minClusterCells}; membership " + "strength=" + f"{evaluation.metrics.membershipStrengthMean}; " + f"cluster connectivity=" + f"{evaluation.metrics.clusterConnectivity}." ), artifactReferences=self._evaluation_artifacts(evaluation), ), @@ -1215,11 +2993,16 @@ def return_pending( DecisionEvidence( evidenceId=marker_id, evidenceClass="markerCoherence", - summary=( + summary=_bounded_evidence_summary( "The fraction of clusters with marker programs is " f"{evaluation.metrics.markerCoherence}; nominated " "family marker enrichment is " f"{evaluation.metrics.markerFamilyEnrichment}; " + "median marker specificity is " + f"{evaluation.metrics.markerSpecificityMedian}; " + "per-cluster marker AUC preview is " + f"{marker_auc_preview}; top-feature preview is " + f"{marker_gene_preview}; " "protected families observed among markers are " f"{evaluation.metrics.protectedMarkerFamilies}." ), @@ -1308,17 +3091,32 @@ def return_pending( else -1.0 ) marker = evaluation.metrics.markerCoherence or 0.0 + marker_specificity = evaluation.metrics.markerSpecificityMedian or 0.0 unit_support = evaluation.metrics.crossUnitSupport or 0.0 + membership = evaluation.metrics.membershipStrengthMean or 0.0 + connectivity = evaluation.metrics.clusterConnectivity or 0.0 technical = max( evaluation.metrics.technicalAssociation.values(), default=0.0, ) + doublet_penalty = max( + 0.0, + (evaluation.metrics.doubletHighScoreConcentration or 1.0) - 1.0, + ) + protected_penalty = float( + bool(evaluation.metrics.protectedMarkerFamilies) + ) score = ( geometry + 0.25 * stability + 0.2 * marker + + 0.15 * marker_specificity + 0.1 * unit_support + + 0.1 * membership + + 0.1 * connectivity - 0.1 * technical + - 0.1 * doublet_penalty + - 0.1 * protected_penalty ) scored.append( ( @@ -1336,7 +3134,7 @@ def return_pending( DecisionEvidence( evidenceId=failure_id, evidenceClass="other", - summary=( + summary=_bounded_evidence_summary( f"The partition was not eligible: " f"{evaluation.error or evaluation.eligibilityReasons}." ), @@ -1485,10 +3283,241 @@ def cluster_option_id(resolution: float) -> str: correction_license=license_payload.license, final_candidate_id=selected_cluster.candidateId, ) + if request_record.config.maxRefinedCandidatesPerAssay == 0: + return ( + sequential_evidence_to_report( + state, + marker_assay=plan.markerAssay, + ), + state, + ) + + refinement_deps, initial_candidate_ids = ( + prepare_sequential_refinement_dependencies( + store, + normalized=normalized, + evidence=state, + batch_columns=diagnostic_batch_columns, + preservation_columns=experimental_handoff.preservationColumns, + experimental_handoff=tuning_handoff, + min_cluster_cells=request_record.config.minClusterCells, + identity_feature_limit=request_record.config.maxIdentityFeatures, + ) + ) + completed_evaluations = [ + refinement_deps.evaluations[candidate_id] + for candidate_id in initial_candidate_ids + ] + try: + planning_execution = run_agent_sync( + model=self.model, + output_type=ParameterSearchPlan, + system_prompt=parameter_search_system_prompt(), + user_prompt=parameter_search_prompt( + from_assay=handoff.assay, + cell_selection=handoff.cellSelection, + evaluations=completed_evaluations, + batch_columns=diagnostic_batch_columns, + preservation_columns=experimental_handoff.preservationColumns, + harmony_authorized=selectable_harmony, + max_refined_candidates=1, + ), + deps_type=ParameterTuningDependencies, + deps=refinement_deps, + config=request_record.config.agentRunConfig, + name="sequential_parameter_refinement", + output_validator=lambda proposed: validate_sequential_refinement_plan( + proposed, + refinement_deps, + initial_candidate_ids, + ), + ) + except AgentRunError: + pending_plan = ParameterSearchPlan( + status="complete", + basedOnCandidateIds=[selected_cluster.candidateId], + rationale=( + "The required bounded refinement review could not be completed." + ), + evidenceIds=list(selected_cluster.evidenceIds), + stoppingCriteria=[ + "Obtain a grounded refinement decision before final selection." + ], + ) + return ( + pending_parameter_tuning_report( + refinement_deps, + search_plan=pending_plan, + agent_name="sequential_parameter_refinement_needs_input", + ), + state, + ) + if not isinstance(planning_execution.output, ParameterSearchPlan): + raise TypeError("Sequential refinement returned an unexpected output") + refinement = execute_sequential_refinement( + refinement_deps, + planning_execution.output.model_copy( + update={"runInfo": planning_execution.runInfo} + ), + initial_candidate_ids, + ) + if refinement.evaluation is not None: + refined_pca = augment_pca_evaluations( + store, + [refinement.evaluation], + feature_selection=artifact_model_to_ref(handoff.graphFeatures), + nominated_families=diagnostic_families, + protected_families=protected_families, + technical_columns=diagnostic_batch_columns, + batch_columns=diagnostic_batch_columns, + protected_columns=study_contract.protectedColumns, + qc_columns=[ + column + for column in plan.cellQc.attributes + if column in store.cells.columns + ], + ) + refined_cluster = augment_cluster_evaluations( + store, + refined_pca, + marker_assay=plan.markerAssay, + marker_features=artifact_model_to_ref(handoff.markerFeatures), + independent_unit_columns=study_contract.independentUnitColumns, + technical_columns=diagnostic_batch_columns, + nominated_families=diagnostic_families, + protected_families=protected_families, + doublet_evidence=doublet_evidence, + )[0] + refined_evidence_ids = [ + f"candidate:{refined_cluster.candidateId}:refinedPca", + f"candidate:{refined_cluster.candidateId}:refinedGraph", + f"candidate:{refined_cluster.candidateId}:refinedMarkers", + f"candidate:{refined_cluster.candidateId}:refinedUnitSupport", + f"candidate:{refined_cluster.candidateId}:refinedTechnical", + f"candidate:{refined_cluster.candidateId}:refinedDoublets", + ] + refined_cluster = refined_cluster.model_copy( + update={ + "evidenceIds": list( + dict.fromkeys( + [ + *refined_cluster.evidenceIds, + *refined_evidence_ids, + ] + ) + ) + } + ) + refinement_deps.evaluations[refined_cluster.candidateId] = refined_cluster + + if refinement.evaluation is None: + completed_report = sequential_evidence_to_report( + state, + marker_assay=plan.markerAssay, + ) + assay_report = completed_report.assayReports[handoff.assay].model_copy( + update={"searchPlan": refinement.plan} + ) + return ( + completed_report.model_copy( + update={ + "searchPlan": refinement.plan, + "assayReports": {handoff.assay: assay_report}, + "stopReason": ( + f"{completed_report.stopReason} Refinement review " + f"stopped because {refinement.plan.rationale}" + ), + } + ), + state, + ) + + selection_ids = [ + selected_cluster.candidateId, + refinement.evaluation.candidateId, + ] + selection_deps = refinement_deps.model_copy( + update={ + "candidates": { + candidate_id: refinement_deps.candidates[candidate_id] + for candidate_id in selection_ids + }, + "candidatePhases": { + candidate_id: refinement_deps.candidatePhases[candidate_id] + for candidate_id in selection_ids + }, + "evaluations": { + candidate_id: refinement_deps.evaluations[candidate_id] + for candidate_id in selection_ids + }, + "executionOrder": selection_ids, + "maxCandidates": len(selection_ids), + } + ) + selection_evaluations = [ + selection_deps.evaluations[candidate_id] for candidate_id in selection_ids + ] + try: + selection_execution = run_agent_sync( + model=self.model, + output_type=ParameterTuningReport, + system_prompt=parameter_tuning_system_prompt( + request_record.config.minClusterCells + ), + user_prompt=parameter_tuning_prompt( + from_assay=handoff.assay, + cell_selection=handoff.cellSelection, + evaluations=selection_evaluations, + batch_columns=diagnostic_batch_columns, + preservation_columns=experimental_handoff.preservationColumns, + search_plan=refinement.plan, + ), + deps_type=ParameterTuningDependencies, + deps=selection_deps, + config=request_record.config.agentRunConfig, + name="sequential_parameter_selection", + output_validator=lambda proposed: validate_parameter_tuning_report( + proposed, + selection_deps, + search_plan=refinement.plan, + ), + ) + except AgentRunError: + return ( + pending_parameter_tuning_report( + selection_deps, + search_plan=refinement.plan, + agent_name="sequential_parameter_selection_needs_input", + ), + state, + ) + if not isinstance(selection_execution.output, ParameterTuningReport): + raise TypeError("Sequential final selection returned an unexpected output") + selected_report = validate_parameter_tuning_report( + selection_execution.output, + selection_deps, + search_plan=refinement.plan, + ).model_copy(update={"runInfo": selection_execution.runInfo}) + complete_inventory = [ + refinement_deps.evaluations[candidate_id] + for candidate_id in refinement_deps.executionOrder + if candidate_id in refinement_deps.evaluations + ] + selected_report = selected_report.model_copy( + update={ + "evaluations": complete_inventory, + "totalCandidates": len(complete_inventory), + } + ) + assay_report = selected_report.model_copy(update={"assayReports": {}}) + selected_report = selected_report.model_copy( + update={"assayReports": {handoff.assay: assay_report}} + ) return ( - sequential_evidence_to_report( - state, + finalize_parameter_tuning_selection( + selected_report, marker_assay=plan.markerAssay, + native_assay=handoff.assay, ), state, ) @@ -1562,11 +3591,20 @@ def feature_policy_review_stage( ), None, ) - aliases = {"sex": "sexLinked"} + aliases = { + "sex": "sexLinked", + "ribosomalProtein": "ribosomal", + "cellCycleCcn": "cellCycle", + "HLA": "hla", + "H2": "h2", + } allowed_families = { "mitochondrial", "ribosomal", + "mitoribosomal", "histone", + "hla", + "h2", "hemoglobin", "immuneReceptor", "cellCycle", @@ -1603,15 +3641,51 @@ def feature_policy_review_stage( else {} ) marker_enrichment = selected.metrics.markerFamilyEnrichment + normalized_loading: dict[str, float] = {} + normalized_markers: dict[str, float] = {} + for name, value in loading_enrichment.items(): + canonical = aliases.get(name, name) + normalized_loading[canonical] = max( + normalized_loading.get(canonical, 0.0), + value, + ) + for name, value in marker_enrichment.items(): + canonical = aliases.get(name, name) + normalized_markers[canonical] = max( + normalized_markers.get(canonical, 0.0), + value, + ) eligible = [ cast(ConditionalGeneFamily, family) for family in nominated if family not in protected and ( - loading_enrichment.get(family, 0.0) >= 2.0 - or marker_enrichment.get(family, 0.0) >= 2.0 + normalized_loading.get(family, 0.0) >= 2.0 + or normalized_markers.get(family, 0.0) >= 2.0 ) ] + scarf_default_families = { + "mitochondrial", + "ribosomal", + "mitoribosomal", + "cellCycle", + "hla", + "h2", + "histone", + "sexLinked", + } + default_dominant = any( + normalized_loading.get(family, 0.0) >= 2.0 + or normalized_markers.get(family, 0.0) >= 2.0 + for family in scarf_default_families + ) + current_default = ( + assay_plan.featureParameters.get("useScarfDefaultBlacklist") is True + ) + default_option = bool( + (current_default or default_dominant) + and not scarf_default_families.intersection(protected) + ) loading_id = "evidence:featurePolicyReview:pcaLoadings" marker_id = "evidence:featurePolicyReview:clusterMarkers" protected_id = "evidence:featurePolicyReview:protectedFamilies" @@ -1655,6 +3729,7 @@ def feature_policy_review_stage( protected_families=[ cast(ConditionalGeneFamily, value) for value in protected ], + scarf_default_eligible=default_option, ) requirements: dict[str, list[str]] = { "featurePolicy:keepAll": [loading_id, marker_id, protected_id] @@ -1665,14 +3740,51 @@ def feature_policy_review_stage( marker_id, protected_id, ] + if default_option: + requirements["featurePolicy:excludeScarfDefaults"] = [ + loading_id, + marker_id, + protected_id, + ] definition = require_option_evidence(definition, requirements) - if eligible: + active_excluded = [ + cast(ConditionalGeneFamily, value) + for value in assay_plan.featureParameters.get( + "excludeFamilies", + [], + ) + if value in allowed_families + ] + active_payload = ( + FeaturePolicyExecutorPayload( + policy="excludeScarfDefaults", + useScarfDefaultBlacklist=True, + ) + if current_default + else FeaturePolicyExecutorPayload( + policy="excludeEligibleBundle", + excludedFamilies=active_excluded, + ) + if active_excluded + else FeaturePolicyExecutorPayload( + policy="keepAll", + excludedFamilies=[], + ) + ) + if eligible or default_option: review = self._reconsider_rna_decision( store, request_record, definition, bundle, answers, + review_instructions=( + "Reconsider the active graph-feature policy among the exact " + "registered keep-all, Scarf-default, and context-derived " + "alternatives. Cite every required evidence ID and class. " + "Any exclusion affects representation only, never marker " + "testing." + ), ) if review.question is not None: outcome = journal._complete_attempt( @@ -1699,10 +3811,7 @@ def feature_policy_review_stage( review.resolution.compiled.executorPayload if review.resolution is not None and review.resolution.compiled is not None - else FeaturePolicyExecutorPayload( - policy="keepAll", - excludedFamilies=[], - ) + else active_payload ) else: review_selection = DecisionSelection( @@ -1721,8 +3830,9 @@ def feature_policy_review_stage( request_record, ) payload = FeaturePolicyExecutorPayload( - policy="keepAll", - excludedFamilies=[], + policy=active_payload.policy, + excludedFamilies=list(active_payload.excludedFamilies), + useScarfDefaultBlacklist=(active_payload.useScarfDefaultBlacklist), ) if not isinstance(payload, FeaturePolicyExecutorPayload): raise TypeError("Feature-policy review compiled an unexpected payload") @@ -1831,6 +3941,625 @@ def reuse_feature_policy_tuning_stage( journal._save_outcome(store.zw, prefix, outcome) return outcome, baseline_report + def analysis_review_stage( + self, + store: DataStore, + workflow: AgentWorkflowRun, + request_record: OrchestrationRequestRecord, + parents: Sequence[WorkflowStageLink], + plan: AutomatedPreprocessingPlan, + tuning_report: ParameterTuningReport, + tuning_reference: AgentReportReference, + study_contract: StudyContract, + answers: Mapping[str, Any], + *, + resume_record: OrchestrationResumeRecord | None = None, + ) -> tuple[ + WorkflowStageAttempt, + ParameterTuningReport, + AgentReportReference, + ]: + """Reconsider one dominated analysis checkpoint through the revision ledger.""" + prefix = journal._ensure_orchestration_store(store) + existing = journal._validated_done_outcome( + store, + prefix, + workflow.workflowRunId, + "analysis_review", + request_record, + parents, + ) + if existing is not None: + if existing.reportReferences: + loaded = journal.load_stage_report( + store, + existing, + ParameterTuningReport, + ) + return ( + existing, + cast(ParameterTuningReport, loaded), + existing.reportReferences[0], + ) + return existing, tuning_report, tuning_reference + started = journal._start_attempt( + store.zw, + prefix, + workflow.workflowRunId, + "analysis_review", + request_record, + parents, + inputs={ + "parameterReport": tuning_reference.model_dump(mode="json"), + "studyContractSha256": hashlib.sha256( + record_io.canonical_json_bytes( + study_contract.model_dump(mode="json") + ) + ).hexdigest(), + "reviewPolicy": { + "maximumRevisions": request_record.config.maxRevisions, + "dominanceTolerance": 0.02, + "materialDifference": 0.05, + }, + }, + resume_record=resume_record, + ) + try: + assay_report = tuning_report.assayReports.get( + plan.primaryAssay, + tuning_report, + ) + selected = next( + ( + evaluation + for evaluation in assay_report.evaluations + if evaluation.candidateId == assay_report.recommendedCandidateId + ), + None, + ) + if selected is None: + raise ValueError("Analysis review lacks the selected tuning candidate") + visual_answer = answers.get("analysisVisualReview") + if visual_answer is not None: + if not isinstance(visual_answer, Mapping): + raise ValueError("analysisVisualReview answer must be a mapping") + visual_review = AnalysisVisualAdjudication.model_validate( + dict(visual_answer) + ) + adjudication_mode: Literal["multimodal", "numeric", "provided"] = ( + "provided" + ) + else: + try: + visual_content = _analysis_visual_content( + store, + selected, + assay_report.evaluations, + qc_columns=plan.cellQc.attributes, + qc_artifact_metrics=[ + (value.name, value.artifact) + for value in plan.cellQc.artifactMetrics + ], + ) + visual_review, adjudication_mode = _run_analysis_adjudication( + model=self.model, + config=request_record.config, + study_objective=request_record.request.studyObjective, + selected=selected, + candidates=assay_report.evaluations, + visual_content=visual_content, + ) + except (AgentRunError, RuntimeError, ValueError) as exc: + evidence_ids = list( + dict.fromkeys( + [ + *selected.evidenceIds, + *( + f"artifact:{value.artifactId}" + for value in self._evaluation_artifacts(selected) + ), + *( + f"artifact:{value.artifact.artifactId}" + for value in plan.cellQc.artifactMetrics + ), + ] + ) + ) + if request_record.config.inputPolicy == "unattended": + visual_review = AnalysisVisualAdjudication( + status="acceptable", + selectedCandidateId=selected.candidateId, + featureLevelFindings=[ + "Model adjudication was unavailable; deterministic " + "candidate gates remained authoritative." + ], + rationale=( + "The selected candidate already passed the registered " + "geometric, stability, marker, cross-unit, technical, " + "protected-variable, QC, and doublet gates. The " + "unattended workflow retained it after model review " + f"failed with {type(exc).__name__}." + ), + ) + adjudication_mode = "numeric" + else: + question = WorkflowQuestion( + questionId="analysisVisualReview", + question=( + "Analysis adjudication could not be completed. Review " + "the selected PCA, partition, marker, QC, and doublet " + "artifacts and provide an acceptable or concern result " + f"for candidate {selected.candidateId!r}. Cause: {exc}" + ), + options=["acceptable", "concern"], + evidenceIds=evidence_ids, + ) + outcome = journal._complete_attempt( + started, + status="needsInput", + outputs={ + "revised": False, + "reviewedCandidateId": selected.candidateId, + "visualEvidenceIds": evidence_ids, + }, + needs_input=WorkflowNeedsInput(questions=[question]), + actions=[ + "review_analysis_evidence", + "pause_visual_review", + ], + ) + journal._save_outcome(store.zw, prefix, outcome) + return outcome, tuning_report, tuning_reference + if visual_review.selectedCandidateId != selected.candidateId: + raise ValueError("Visual review references a stale selected candidate") + if not visual_review.rationale.strip(): + raise ValueError("Visual review rationale must be non-empty") + alternatives = [ + evaluation + for evaluation in assay_report.evaluations + if _dominates_analysis_choice(evaluation, selected) + ] + if not alternatives: + if visual_review.status == "concern": + concern_answer = answers.get("analysisVisualConcern") + if concern_answer == "stop": + outcome = journal._complete_attempt( + started, + status="abstained", + outputs={ + "revised": False, + "reviewedCandidateId": selected.candidateId, + "adjudicationMode": adjudication_mode, + "visualAdjudication": visual_review.model_dump( + mode="json" + ), + }, + actions=[ + "review_analysis_evidence", + "stop_on_visual_concern", + ], + ) + journal._save_outcome(store.zw, prefix, outcome) + return outcome, tuning_report, tuning_reference + if concern_answer == "retainSelected": + visual_review = visual_review.model_copy( + update={ + "status": "acceptable", + "rationale": ( + f"{visual_review.rationale} Human review " + "retained the exact selected partition." + ), + } + ) + elif concern_answer is not None: + raise ValueError( + "analysisVisualConcern must be retainSelected or stop" + ) + if visual_review.status == "concern": + if request_record.config.inputPolicy == "unattended": + visual_review = visual_review.model_copy( + update={ + "status": "acceptable", + "rationale": ( + f"{visual_review.rationale} No executed matched " + "alternative passed the deterministic dominance " + "gate, so the unattended workflow retained the " + "selected partition." + ), + } + ) + else: + question = WorkflowQuestion( + questionId="analysisVisualConcern", + question=( + "Visual adjudication found a concern, but no executed " + "matched alternative passed the deterministic " + "dominance gate. Decide whether to retain the selected " + "partition or stop for a revised analysis request." + ), + options=["retainSelected", "stop"], + evidenceIds=list(selected.evidenceIds), + ) + outcome = journal._complete_attempt( + started, + status="needsInput", + outputs={ + "revised": False, + "reviewedCandidateId": selected.candidateId, + "adjudicationMode": adjudication_mode, + "visualAdjudication": visual_review.model_dump( + mode="json" + ), + }, + needs_input=WorkflowNeedsInput(questions=[question]), + actions=[ + "review_analysis_evidence", + "pause_visual_concern", + ], + ) + journal._save_outcome(store.zw, prefix, outcome) + return outcome, tuning_report, tuning_reference + outcome = journal._complete_attempt( + started, + status="done", + outputs={ + "revised": False, + "reviewedCandidateId": selected.candidateId, + "adjudicationMode": adjudication_mode, + "stoppingReason": ( + "No eligible one-checkpoint alternative dominated the " + "selected candidate across geometric, stability, marker, " + "cross-unit, technical, protected-variable, and doublet " + "evidence." + ), + "visualAdjudication": visual_review.model_dump(mode="json"), + }, + actions=["review_analysis_evidence", "retain_selected_analysis"], + ) + journal._save_outcome(store.zw, prefix, outcome) + return outcome, tuning_report, tuning_reference + + best = max( + alternatives, + key=lambda evaluation: ( + sum(_cluster_review_values(evaluation)[0].values()) + - sum(_cluster_review_values(evaluation)[1].values()), + -evaluation.parameters.leidenResolution, + ), + ) + checkpoint = _changed_analysis_checkpoint(best, selected) + if checkpoint is None: + raise RuntimeError( + "Dominating analysis alternative lacks one exact checkpoint" + ) + raw_candidates = [ + selected, + *[ + value + for value in alternatives + if _changed_analysis_checkpoint(value, selected) == checkpoint + ], + ] + candidates_by_value: dict[Any, ParameterCandidateEvaluation] = {} + for candidate in raw_candidates: + value = _analysis_parameter_value(checkpoint, candidate) + current = candidates_by_value.get(value) + if current is None or ( + sum(_cluster_review_values(candidate)[0].values()) + - sum(_cluster_review_values(candidate)[1].values()) + > sum(_cluster_review_values(current)[0].values()) + - sum(_cluster_review_values(current)[1].values()) + ): + candidates_by_value[value] = candidate + candidates = list(candidates_by_value.values()) + evidence: list[DecisionEvidence] = [] + candidate_evidence: dict[str, list[str]] = {} + for evaluation in candidates: + values = self._analysis_candidate_evidence( + checkpoint, + evaluation, + ) + evidence.extend(values) + candidate_evidence[evaluation.candidateId] = [ + value.evidenceId for value in values + ] + best = max( + alternatives, + key=lambda evaluation: ( + sum(_cluster_review_values(evaluation)[0].values()) + - sum(_cluster_review_values(evaluation)[1].values()), + -evaluation.parameters.leidenResolution, + ), + ) + bundle = self._tuning_evidence_bundle(checkpoint, evidence) + if checkpoint == "pcaPrefix": + definition = build_pca_prefix_decision( + evidence_bundle_id=bundle.bundleId, + matrix_rank=max( + evaluation.parameters.dimensions for evaluation in candidates + ), + candidate_dimensions=[ + evaluation.parameters.dimensions for evaluation in candidates + ], + ) + option_for_candidate = { + evaluation.candidateId: self._payload_option_id( + definition, + PcaPrefixExecutorPayload, + "dimensions", + evaluation.parameters.dimensions, + ) + for evaluation in candidates + } + elif checkpoint == "correctionOutcome": + definition = build_correction_outcome_decision( + evidence_bundle_id=bundle.bundleId, + license="safe", + need="needed", + harmony_eligible=True, + ) + option_for_candidate = { + evaluation.candidateId: ( + "correctionOutcome:acceptHarmony" + if evaluation.parameters.useHarmony + else "correctionOutcome:retainNative" + ) + for evaluation in candidates + } + elif checkpoint == "graphK": + definition = build_graph_k_decision( + evidence_bundle_id=bundle.bundleId, + n_cells=max( + evaluation.parameters.neighborsK for evaluation in candidates + ) + + 1, + candidate_neighbors=[ + evaluation.parameters.neighborsK for evaluation in candidates + ], + ) + option_for_candidate = { + evaluation.candidateId: self._payload_option_id( + definition, + GraphExecutorPayload, + "neighborsK", + evaluation.parameters.neighborsK, + ) + for evaluation in candidates + } + else: + known_ids = { + 0.25: "clusterResolution:veryCoarse", + 0.5: "clusterResolution:coarse", + 0.75: "clusterResolution:balanced", + 1.0: "clusterResolution:detailed", + 1.25: "clusterResolution:fine", + 1.5: "clusterResolution:veryFine", + } + + def resolution_option(value: float) -> str: + return known_ids.get( + value, + f"clusterResolution:r{str(value).replace('.', 'p')}", + ) + + definition = build_cluster_partition_decision( + evidence_bundle_id=bundle.bundleId, + metric_preferred_option_id=resolution_option( + best.parameters.leidenResolution + ), + resolution_candidates=[ + evaluation.parameters.leidenResolution + for evaluation in candidates + ], + ) + option_for_candidate = { + evaluation.candidateId: resolution_option( + evaluation.parameters.leidenResolution + ) + for evaluation in candidates + } + requirements = { + option_for_candidate[evaluation.candidateId]: candidate_evidence[ + evaluation.candidateId + ] + for evaluation in candidates + } + definition = require_option_evidence(definition, requirements) + decision_workflow, _snapshot = self._load_or_create_decision_workflow( + store, + request_record, + ) + previous_options = { + record.decisionId: record.selectedOptionId + for record in decision_workflow.active_decision_records() + } + review = self._reconsider_rna_decision( + store, + request_record, + definition, + bundle, + answers, + review_instructions=( + f"Reconsider the active {checkpoint} decision only because an " + "executed one-checkpoint alternative passed the registered " + "dominance gate. Cite the option-specific geometric, technical, " + "stability, marker, protected-variable, cross-unit, and " + "quality-control evidence. Keep the current option unless at " + "least two independent evidence classes justify replacement." + ), + ) + if review.question is not None: + outcome = journal._complete_attempt( + started, + status="needsInput", + outputs={ + "revised": False, + "adjudicationMode": adjudication_mode, + "decisionSnapshotSha256": review.snapshotSha256, + "dominatingCandidateIds": [ + value.candidateId for value in alternatives + ], + }, + needs_input=WorkflowNeedsInput(questions=[review.question]), + actions=["review_analysis_evidence"], + ) + journal._save_outcome(store.zw, prefix, outcome) + return outcome, tuning_report, tuning_reference + if review.selection is None: + raise RuntimeError("Analysis review returned no selection") + replacement = next( + evaluation + for evaluation in candidates + if option_for_candidate[evaluation.candidateId] + == review.selection.selectedOptionId + ) + if not review.revised: + outcome = journal._complete_attempt( + started, + status="done", + outputs={ + "revised": False, + "reviewedCandidateId": selected.candidateId, + "adjudicationMode": adjudication_mode, + "decisionSelection": review.selection.model_dump(mode="json"), + "decisionSnapshotSha256": review.snapshotSha256, + "visualAdjudication": visual_review.model_dump(mode="json"), + }, + actions=["review_analysis_evidence", "retain_selected_analysis"], + ) + journal._save_outcome(store.zw, prefix, outcome) + return outcome, tuning_report, tuning_reference + + descendant_snapshot = self._restore_tuning_descendants( + store, + request_record, + study_contract, + replacement, + revised_checkpoint=checkpoint, + previous_options=previous_options, + model_name=( + tuning_report.runInfo.modelName + or assay_report.runInfo.modelName + or None + ), + ) + updated_assay = assay_report.model_copy( + update={ + "recommendedCandidateId": replacement.candidateId, + "selectedArtifacts": dict(replacement.artifacts), + "evidenceIds": list(review.selection.evidenceIds), + "rationale": review.selection.rationale, + "tradeoffs": [ + *assay_report.tradeoffs, + ( + "The bounded analysis review superseded " + f"{selected.candidateId!r} with " + f"{replacement.candidateId!r}." + ), + ], + } + ) + reports = dict(tuning_report.assayReports) + reports[plan.primaryAssay] = updated_assay + updated_report = tuning_report.model_copy( + update={ + "assayReports": reports, + "recommendedByAssay": { + **tuning_report.recommendedByAssay, + plan.primaryAssay: replacement.candidateId, + }, + **( + { + "recommendedCandidateId": replacement.candidateId, + "selectedArtifacts": dict(replacement.artifacts), + "evidenceIds": list(review.selection.evidenceIds), + "rationale": review.selection.rationale, + } + if tuning_report.fromAssay == plan.primaryAssay + else {} + ), + } + ) + final_selection = updated_report.finalSelection + if final_selection is not None: + final_selection = final_selection.model_copy( + update={ + "selectedOptionId": ( + f"native:{plan.primaryAssay}:{replacement.candidateId}" + ), + "nativeAssay": plan.primaryAssay, + "nativeCandidateId": replacement.candidateId, + "integrationId": None, + "evidenceIds": list(review.selection.evidenceIds), + "rationale": review.selection.rationale, + } + ) + updated_report = finalize_parameter_tuning_selection( + updated_report, + marker_assay=plan.markerAssay, + native_assay=plan.primaryAssay, + final_selection=final_selection, + ) + stage_artifacts = { + name: ArtifactReferenceModel.model_validate(value.model_dump()) + for name, value in replacement.artifacts.items() + } + saved, reference = journal._save_stage_report( + store, + started, + updated_report, + invocation=AgentInvocation( + agentName="parameter_tuning", + parentReports=[journal._report_link(tuning_reference)], + inputs={ + "selectedCandidateId": selected.candidateId, + "replacementCandidateId": replacement.candidateId, + "revisedCheckpoint": checkpoint, + "evidenceBundle": bundle.model_dump(mode="json"), + "adjudicationMode": adjudication_mode, + "visualAdjudication": visual_review.model_dump(mode="json"), + }, + artifacts=stage_artifacts, + runConfig=request_record.config.agentRunConfig, + ), + expected_type=ParameterTuningReport, + ) + updated_report = cast(ParameterTuningReport, saved) + outcome = journal._complete_attempt( + started, + status="done", + report_references=[reference], + artifacts=stage_artifacts, + outputs={ + "revised": True, + "selectedCandidateId": selected.candidateId, + "replacementCandidateId": replacement.candidateId, + "revisedCheckpoint": checkpoint, + "adjudicationMode": adjudication_mode, + "decisionSelection": review.selection.model_dump(mode="json"), + "decisionSnapshotSha256": ( + descendant_snapshot or review.snapshotSha256 + ), + "visualAdjudication": visual_review.model_dump(mode="json"), + }, + actions=[ + "review_analysis_evidence", + f"revise_{checkpoint}", + "recompute_invalidated_tuning_decisions", + ], + ) + journal._save_outcome(store.zw, prefix, outcome) + return outcome, updated_report, reference + except Exception as exc: + outcome = journal.finish_exception( + store, + prefix, + workflow, + started, + exc, + ) + return outcome, tuning_report, tuning_reference + def parameter_tuning_stage( self, store: DataStore, @@ -2014,15 +4743,20 @@ def parameter_tuning_stage( ) candidate_payload = { sequential_evidence.assay: [ - candidate.model_dump(mode="json") - for phase in sequential_evidence.phases - for candidate in phase.plan.candidates + evaluation.parameters.model_dump(mode="json") + for evaluation in report.evaluations ] } actions.extend( f"adjudicate_{phase.plan.phase}" for phase in sequential_evidence.phases ) + if report.searchPlan is not None: + actions.append("review_parameter_refinement") + actions.extend( + f"execute_refined_candidate:{candidate.candidateId}" + for candidate in report.searchPlan.candidates + ) return self.save_parameter_tuning_outcome( store, prefix, @@ -2109,6 +4843,38 @@ def parameter_tuning_stage( if len(paired) >= 2 else 0 ) + diagnostic_batch_candidates = ( + ( + tuple(study_contract.technicalBatchColumns) + if study_contract.correctionLicense == "safe" + and experimental_handoff.batchAction == "evaluateHarmony" + else ( + ( + study_contract.physicalCaptureColumn, + *study_contract.technicalBatchColumns, + ) + if request_record.config.runConfoundedHarmonyDiagnostic + else tuple(study_contract.technicalBatchColumns) + ) + ) + if study_contract is not None + else tuple(experimental_handoff.batchColumns) + ) + diagnostic_batch_columns = [ + column + for column in dict.fromkeys(diagnostic_batch_candidates) + if column is not None + and column in store.cells.columns + and len(np.unique(store.cells.fetch(column, key="I"))) > 1 + ] + harmony_selectable = bool( + study_contract is not None + and study_contract.correctionLicense == "safe" + and experimental_handoff.batchAction == "evaluateHarmony" + and diagnostic_batch_columns + and sorted(diagnostic_batch_columns) + == sorted(experimental_handoff.batchColumns) + ) assay_inputs: list[ParameterTuningAssayInput] = [] for handoff in preprocessed: if handoff.normalized is None: @@ -2131,8 +4897,12 @@ def parameter_tuning_stage( ), ) if ( - experimental_handoff.batchAction == "evaluateHarmony" + diagnostic_batch_columns and request_record.config.maxHarmonyCandidatesPerAssay == 1 + and ( + harmony_selectable + or request_record.config.runConfoundedHarmonyDiagnostic + ) ): baseline = candidates[0] candidates.append( @@ -2156,18 +4926,12 @@ def parameter_tuning_stage( ParameterTuningAssayInput( normalized=artifact_model_to_ref(handoff.normalized), candidates=candidates, - batchColumns=( - list(experimental_handoff.batchColumns) - if experimental_handoff.batchAction == "evaluateHarmony" - else [] - ), + batchColumns=list(diagnostic_batch_columns), preservationColumns=list( experimental_handoff.preservationColumns ), experimentalHandoff=( - None - if experimental_handoff.batchAction == "evaluateHarmony" - else experimental_handoff + experimental_handoff if harmony_selectable else None ), maxCandidates=( len(candidates) @@ -2176,7 +4940,7 @@ def parameter_tuning_stage( maxRefinedCandidates=( request_record.config.maxRefinedCandidatesPerAssay ), - allowHarmonyRefinement=False, + allowHarmonyRefinement=harmony_selectable, minClusterCells=request_record.config.minClusterCells, identityFeatureLimit=request_record.config.maxIdentityFeatures, ) @@ -2204,6 +4968,28 @@ def parameter_tuning_stage( ), selection_directions=tuning_directions, ) + report = self._augment_legacy_scientific_evidence( + store, + report, + plan=plan, + preprocessed=preprocessed, + study_contract=study_contract, + ) + report = enforce_harmony_acceptance( + report, + batch_columns=diagnostic_batch_columns, + protected_columns=( + study_contract.protectedColumns + if study_contract is not None + else experimental_handoff.preservationColumns + ), + independent_unit_columns=( + study_contract.independentUnitColumns + if study_contract is not None + else [] + ), + selectable=harmony_selectable, + ) logger.info( f"Workflow {workflow.workflowRunId}: Parameter Tuning returned " f"status={report.status!r}, evaluated={report.totalCandidates}" @@ -2588,12 +5374,38 @@ def save_parameter_tuning_outcome( ), } ) - if report.status == "needsInput": + if ( + report.status == "needsInput" + and request_record.config.inputPolicy == "unattended" + ): + outcome = journal._complete_attempt( + started, + status="failed", + report_references=stage_report_references, + artifacts=stage_artifacts, + outputs={ + "candidateCount": report.totalCandidates, + "sequentialEvidence": ( + sequential_evidence.model_dump(mode="json") + if sequential_evidence is not None + else None + ), + "operations": operations, + }, + actions=actions, + error=( + "The unattended Parameter Tuning stage returned an unresolved " + "decision" + ), + notes=report.limitations, + ) + elif report.status == "needsInput": needs_input = report.needsInput assert needs_input is not None if ( sequential_evidence is not None and sequential_evidence.pendingDecisionId is None + and report.searchPlan is None ): raise ValueError( "Sequential tuning needsInput lacks a pending decision ID" @@ -2619,6 +5431,7 @@ def save_parameter_tuning_outcome( questionId=( f"decision:{sequential_evidence.pendingDecisionId}" if sequential_evidence is not None + and sequential_evidence.pendingDecisionId is not None else "finalGraphOptionId" if report.finalSelection is not None and report.finalSelection.status == "needsInput" diff --git a/scarf/agent/parameter_tuning.py b/scarf/agent/parameter_tuning.py index c9c70d0c..42c205f7 100644 --- a/scarf/agent/parameter_tuning.py +++ b/scarf/agent/parameter_tuning.py @@ -8,7 +8,9 @@ import numpy as np +from ..metrics import graph_connectivity from ..storage.refs import ArtifactRef +from ..storage.types import as_zarr_array from .config import CONFIG, AgentRunConfig from .config._deps import AGENT_INSTALL_HINT from .config.agent_exec import run_agent_sync @@ -107,13 +109,33 @@ class ParameterMetrics(AgentDataModel): pcaSilhouette: float | None = None macroF1: float | None = None weightedF1: float | None = None + membershipStrengthMean: float | None = None + membershipStrengthMedian: float | None = None + membershipStrengthP10: float | None = None + membershipStrengthByCluster: dict[str, float] = Field(default_factory=dict) + membershipStrengthSampleSize: int | None = None + clusterConnectivity: float | None = None seedStability: float | None = None subsampleStability: float | None = None markerCoherence: float | None = None + markerSpecificityMedian: float | None = None + markerSpecificityByCluster: dict[str, float] = Field(default_factory=dict) + markerAucByCluster: dict[str, float] = Field(default_factory=dict) + topMarkerGenes: dict[str, list[str]] = Field(default_factory=dict) crossUnitSupport: float | None = None technicalAssociation: dict[str, float] = Field(default_factory=dict) componentVariance: list[float] = Field(default_factory=list) + pcaExplainedVarianceRatio: list[float] = Field(default_factory=list) + pcaCumulativeExplainedVarianceRatio: list[float] = Field(default_factory=list) + topLoadingGenes: dict[str, list[str]] = Field(default_factory=dict) loadingFamilyEnrichment: dict[str, float] = Field(default_factory=dict) + loadingFamilyEnrichmentByComponent: dict[str, dict[str, float]] = Field( + default_factory=dict + ) + pcaComponentAssociations: dict[str, dict[str, list[float]]] = Field( + default_factory=dict + ) + batchPcaAssociation: dict[str, float] = Field(default_factory=dict) technicalPcaAssociation: dict[str, float] = Field(default_factory=dict) protectedPcaAssociation: dict[str, float] = Field(default_factory=dict) qcPcaAssociation: dict[str, float] = Field(default_factory=dict) @@ -121,8 +143,15 @@ class ParameterMetrics(AgentDataModel): markerFamilyEnrichment: dict[str, float] = Field(default_factory=dict) protectedMarkerFamilies: list[str] = Field(default_factory=list) doubletHighScoreConcentration: float | None = None + doubletScoreQuantiles: dict[str, float] = Field(default_factory=dict) + doubletScoreByCapture: dict[str, dict[str, float]] = Field(default_factory=dict) + doubletCaptureCoverage: float | None = None batchMixing: dict[str, float] = Field(default_factory=dict) biologicalPreservation: dict[str, dict[str, float]] = Field(default_factory=dict) + paretoOptimal: bool | None = None + dominatedByCandidateIds: list[str] = Field(default_factory=list) + dominatesCandidateIds: list[str] = Field(default_factory=list) + dominanceMetrics: dict[str, list[str]] = Field(default_factory=dict) @classmethod def get_blank(cls) -> "ParameterMetrics": @@ -801,6 +830,19 @@ def parameter_evaluation_payload( evaluation: ParameterCandidateEvaluation, ) -> dict[str, Any]: """Return only candidate evidence needed for planning and selection.""" + metrics = evaluation.metrics.model_dump(mode="json") + loading_items = list(evaluation.metrics.topLoadingGenes.items()) + bounded_loading_items = [ + *loading_items[:10], + *(loading_items[-3:] if len(loading_items) > 13 else loading_items[10:]), + ] + metrics["topLoadingGenes"] = { + component: genes[:10] for component, genes in bounded_loading_items + } + metrics["topMarkerGenes"] = { + cluster: genes[:10] + for cluster, genes in list(evaluation.metrics.topMarkerGenes.items())[:30] + } return { "candidateId": evaluation.candidateId, "phase": evaluation.phase, @@ -809,7 +851,7 @@ def parameter_evaluation_payload( "eligible": evaluation.eligible, "parameters": evaluation.parameters.model_dump(mode="json"), "effectiveDimensions": evaluation.effectiveDimensions, - "metrics": evaluation.metrics.model_dump(mode="json"), + "metrics": metrics, "evidenceIds": evaluation.evidenceIds, "eligibilityReasons": evaluation.eligibilityReasons, "warnings": [warning[:500] for warning in evaluation.warnings[:10]], @@ -890,7 +932,10 @@ def parameter_tuning_system_prompt(min_cluster_cells: int) -> str: evidence for parameter quality. Treat pcaSilhouette, macroF1, and weightedF1 only as PCA cluster-separability metrics. Biological preservation evidence exists only in a non-empty biologicalPreservation - map. Do not call any metric highest, lowest, improved, degraded, or + map. A candidate with non-empty dominatedByCandidateIds is Pareto + dominated. Selecting a dominated graph or resolution requires at least + two independent non-geometric evidence classes that explain the + tradeoff. Do not call any metric highest, lowest, improved, degraded, or monotonic without checking its exact value across every relevant candidate. Narrative fields contain plain prose only and must not contain serialized JSON keys or objects. When multiple candidates complete, @@ -1351,6 +1396,96 @@ def run_candidate_reduction( return ref, "identity", effective_dimensions +def _bounded_membership_summary( + values: Any, + labels: np.ndarray, + *, + maximum_sample_size: int = 65_536, +) -> tuple[float, float, float, dict[str, float], int]: + if len(values.shape) != 1 or values.shape != labels.shape: + raise ValueError("Membership strengths must align with cluster labels") + n_values = int(values.shape[0]) + if n_values < 1: + raise ValueError("Membership strengths cannot be empty") + stride = max(1, (n_values + maximum_sample_size - 1) // maximum_sample_size) + total = 0.0 + sampled_values: list[np.ndarray] = [] + sampled_labels: list[np.ndarray] = [] + for start in range(0, n_values, 65_536): + block = np.asarray(values[start : start + 65_536], dtype=np.float64) + if not np.isfinite(block).all(): + raise ValueError("Membership strengths must be finite") + total += float(block.sum()) + offset = (-start) % stride + sampled_values.append(block[offset::stride]) + sampled_labels.append(labels[start + offset : start + len(block) : stride]) + sample = np.concatenate(sampled_values) + sample_labels = np.concatenate(sampled_labels) + by_cluster = { + str(cluster): float(np.median(sample[sample_labels == cluster])) + for cluster in np.unique(sample_labels) + } + return ( + total / n_values, + float(np.median(sample)), + float(np.quantile(sample, 0.1)), + by_cluster, + int(len(sample)), + ) + + +def _collect_cluster_structure_metrics( + store: Any, + *, + cluster_ref: Any, + graph_ref: Any, + cluster_values: np.ndarray, + candidate_id: str, + metrics: ParameterMetrics, + evidence_ids: list[str], + warnings: list[str], +) -> ArtifactRef | None: + calculate_membership = getattr(store, "calc_membership_strength", None) + if not callable(calculate_membership): + return None + membership_ref: ArtifactRef | None = None + try: + membership_ref = calculate_membership( + cluster_ref, + graph_ref, + invalidate_cache=False, + ) + membership_group = store.load_artifact(membership_ref) + membership_values = as_zarr_array( + membership_group["values"], + name="values", + ) + mean, median, p10, by_cluster, sample_size = _bounded_membership_summary( + membership_values, + cluster_values, + ) + metrics.membershipStrengthMean = mean + metrics.membershipStrengthMedian = median + metrics.membershipStrengthP10 = p10 + metrics.membershipStrengthByCluster = by_cluster + metrics.membershipStrengthSampleSize = sample_size + evidence_ids.append(f"candidate:{candidate_id}:membershipStrength") + except (KeyError, TypeError, ValueError, RuntimeError) as exc: + warnings.append(f"Cluster membership strength unavailable: {exc}") + membership_ref = None + + try: + graph_group = store.load_artifact(graph_ref) + graph_edges = as_zarr_array(graph_group["edges"], name="edges") + connectivity = float(graph_connectivity(graph_edges, cluster_values)) + if np.isfinite(connectivity): + metrics.clusterConnectivity = connectivity + evidence_ids.append(f"candidate:{candidate_id}:clusterConnectivity") + except (KeyError, TypeError, ValueError, RuntimeError) as exc: + warnings.append(f"Cluster connectivity unavailable: {exc}") + return membership_ref + + def _collect_parameter_candidate_metrics( deps: ParameterTuningDependencies, *, @@ -1363,7 +1498,7 @@ def _collect_parameter_candidate_metrics( cluster_column: str, evidence_ids: list[str], warnings: list[str], -) -> tuple[ParameterMetrics, list[str]]: +) -> tuple[ParameterMetrics, list[str], ArtifactRef | None]: store = deps.store cluster_group = store.load_artifact(cluster_ref) cluster_data = cluster_group["values"] @@ -1382,6 +1517,16 @@ def _collect_parameter_candidate_metrics( minClusterFraction=min_cluster_fraction, ) evidence_ids.append(f"candidate:{candidate_id}:clusters") + membership_ref = _collect_cluster_structure_metrics( + store, + cluster_ref=cluster_ref, + graph_ref=graph_ref, + cluster_values=cluster_values, + candidate_id=candidate_id, + metrics=metrics, + evidence_ids=evidence_ids, + warnings=warnings, + ) try: graph_scores = store.metric_graph_silhouette( @@ -1479,7 +1624,7 @@ def _collect_parameter_candidate_metrics( f"smallest cluster has {min_cluster_cells} cells; " f"minimum is {deps.minClusterCells}" ) - return metrics, eligibility_reasons + return metrics, eligibility_reasons, membership_ref def execute_parameter_candidate( @@ -1643,7 +1788,11 @@ def execute_parameter_candidate( f"Parameter candidate {candidate_id!r}: completed Leiden clustering" ) - metrics, eligibility_reasons = _collect_parameter_candidate_metrics( + ( + metrics, + eligibility_reasons, + membership_ref, + ) = _collect_parameter_candidate_metrics( deps, candidate=candidate, candidate_id=candidate_id, @@ -1655,6 +1804,10 @@ def execute_parameter_candidate( evidence_ids=evidence_ids, warnings=warnings, ) + if membership_ref is not None: + artifacts["membershipStrength"] = ArtifactRecord.from_ref( + membership_ref + ) evaluation = ParameterCandidateEvaluation( candidateId=candidate_id, @@ -1719,6 +1872,361 @@ async def evaluate_parameter_candidate( return execute_parameter_candidate(ctx.deps, candidate_id) +def _finite_metric( + objectives: dict[str, tuple[float, int, str]], + name: str, + value: float | None, + *, + direction: int, + evidence_class: str, +) -> None: + if value is not None and np.isfinite(value): + objectives[name] = (float(value), direction, evidence_class) + + +def _candidate_objectives( + metrics: ParameterMetrics, +) -> dict[str, tuple[float, int, str]]: + objectives: dict[str, tuple[float, int, str]] = {} + for name, value in ( + ("minClusterFraction", metrics.minClusterFraction), + ("graphSilhouetteMedian", metrics.graphSilhouetteMedian), + ("membershipStrengthMean", metrics.membershipStrengthMean), + ("membershipStrengthP10", metrics.membershipStrengthP10), + ("clusterConnectivity", metrics.clusterConnectivity), + ): + _finite_metric( + objectives, + name, + value, + direction=1, + evidence_class="geometric", + ) + for name, value in ( + ("seedStability", metrics.seedStability), + ("subsampleStability", metrics.subsampleStability), + ): + _finite_metric( + objectives, + name, + value, + direction=1, + evidence_class="resamplingStability", + ) + for name, value in ( + ("markerCoherence", metrics.markerCoherence), + ("markerSpecificityMedian", metrics.markerSpecificityMedian), + ): + _finite_metric( + objectives, + name, + value, + direction=1, + evidence_class="markerCoherence", + ) + _finite_metric( + objectives, + "crossUnitSupport", + metrics.crossUnitSupport, + direction=1, + evidence_class="crossUnitSupport", + ) + _finite_metric( + objectives, + "doubletHighScoreConcentration", + metrics.doubletHighScoreConcentration, + direction=-1, + evidence_class="qualityControl", + ) + for column, value in metrics.technicalAssociation.items(): + _finite_metric( + objectives, + f"technicalAssociation:{column}", + value, + direction=-1, + evidence_class="technical", + ) + for column, value in metrics.batchMixing.items(): + _finite_metric( + objectives, + f"batchMixing:{column}", + value, + direction=1, + evidence_class="batchRemoval", + ) + for column, values in metrics.biologicalPreservation.items(): + for name, value in values.items(): + _finite_metric( + objectives, + f"biologicalPreservation:{column}:{name}", + value, + direction=1, + evidence_class="protectedVariablePreservation", + ) + return objectives + + +def _single_varied_parameter( + left: ParameterCandidate, + right: ParameterCandidate, +) -> str | None: + if ( + left.reductionMethod != right.reductionMethod + or left.useHarmony != right.useHarmony + ): + return None + varied = [ + name + for name in ("dimensions", "neighborsK", "leidenResolution") + if getattr(left, name) != getattr(right, name) + ] + return varied[0] if len(varied) == 1 else None + + +def _dominance_metrics( + left: ParameterMetrics, + right: ParameterMetrics, + *, + tolerance: float, +) -> list[str]: + left_objectives = _candidate_objectives(left) + right_objectives = _candidate_objectives(right) + if not left_objectives or set(left_objectives) != set(right_objectives): + return [] + classes = {value[2] for value in left_objectives.values()} + if len(classes) < 2: + return [] + strict: list[str] = [] + for name in sorted(left_objectives): + left_value, direction, _evidence_class = left_objectives[name] + right_value = right_objectives[name][0] + difference = direction * (left_value - right_value) + if difference < -tolerance: + return [] + if difference > tolerance: + strict.append(name) + return strict + + +def annotate_candidate_dominance( + evaluations: Sequence[ParameterCandidateEvaluation], + *, + tolerance: float = 0.02, +) -> tuple[ParameterCandidateEvaluation, ...]: + """Attach conservative pairwise Pareto evidence to comparable candidates.""" + + values = list(evaluations) + if tolerance < 0 or not np.isfinite(tolerance): + raise ValueError("Dominance tolerance must be finite and non-negative") + completed = [value for value in values if value.status == "done" and value.eligible] + dominated_by: dict[str, list[str]] = {value.candidateId: [] for value in completed} + dominates: dict[str, list[str]] = {value.candidateId: [] for value in completed} + metrics_by_id: dict[str, dict[str, list[str]]] = { + value.candidateId: {} for value in completed + } + comparable: set[str] = set() + for left in completed: + for right in completed: + if left.candidateId == right.candidateId or ( + _single_varied_parameter(left.parameters, right.parameters) is None + ): + continue + comparable.add(left.candidateId) + strict = _dominance_metrics( + left.metrics, + right.metrics, + tolerance=tolerance, + ) + if not strict: + continue + dominates[left.candidateId].append(right.candidateId) + dominated_by[right.candidateId].append(left.candidateId) + metrics_by_id[left.candidateId][f"dominates:{right.candidateId}"] = strict + metrics_by_id[right.candidateId][f"dominatedBy:{left.candidateId}"] = strict + + annotated: list[ParameterCandidateEvaluation] = [] + for evaluation in values: + if evaluation.candidateId not in dominated_by: + annotated.append(evaluation) + continue + candidate_id = evaluation.candidateId + candidate_dominators = sorted(set(dominated_by[candidate_id])) + candidate_dominates = sorted(set(dominates[candidate_id])) + updated_metrics = evaluation.metrics.model_copy( + update={ + "paretoOptimal": ( + not candidate_dominators if candidate_id in comparable else None + ), + "dominatedByCandidateIds": candidate_dominators, + "dominatesCandidateIds": candidate_dominates, + "dominanceMetrics": metrics_by_id[candidate_id], + } + ) + prefix = f"candidate:{candidate_id}:" + retained_evidence = [ + evidence_id + for evidence_id in evaluation.evidenceIds + if not ( + evidence_id == f"{prefix}paretoDominance" + or evidence_id.startswith(f"{prefix}dominatedBy:") + or evidence_id.startswith(f"{prefix}dominates:") + ) + ] + dominance_evidence = ( + [f"{prefix}paretoDominance"] if candidate_id in comparable else [] + ) + dominance_evidence.extend( + f"{prefix}dominatedBy:{other}" for other in candidate_dominators + ) + dominance_evidence.extend( + f"{prefix}dominates:{other}" for other in candidate_dominates + ) + annotated.append( + evaluation.model_copy( + update={ + "metrics": updated_metrics, + "evidenceIds": [ + *retained_evidence, + *dominance_evidence, + ], + } + ) + ) + return tuple(annotated) + + +def harmony_acceptance_gate( + native: ParameterCandidateEvaluation | None, + harmony: ParameterCandidateEvaluation | None, + *, + batch_columns: Sequence[str], + protected_columns: Sequence[str], + independent_unit_columns: Sequence[str] = (), + tolerance: float = 0.05, + require_doublet_evidence: bool = False, +) -> tuple[bool, list[str]]: + """Require matched batch improvement without material biological loss.""" + + if tolerance < 0 or not np.isfinite(tolerance): + raise ValueError("Harmony gate tolerance must be finite and non-negative") + reasons: list[str] = [] + if native is None or harmony is None: + return False, ["Matched native and Harmony candidates are unavailable."] + if native.status != "done" or not native.eligible: + reasons.append("The matched native candidate is not an eligible execution.") + if harmony.status != "done" or not harmony.eligible: + reasons.append("The matched Harmony candidate is not an eligible execution.") + if native.parameters.useHarmony or not harmony.parameters.useHarmony: + reasons.append("Candidates do not have native and Harmony correction modes.") + native_parameters = native.parameters.model_dump( + mode="json", + exclude={"candidateId", "useHarmony"}, + ) + harmony_parameters = harmony.parameters.model_dump( + mode="json", + exclude={"candidateId", "useHarmony"}, + ) + if native_parameters != harmony_parameters: + reasons.append("Native and Harmony candidate parameters are not matched.") + if core_artifact_reference(native.cellSelection) != core_artifact_reference( + harmony.cellSelection + ): + reasons.append("Native and Harmony candidates use different cell selections.") + + columns = list(dict.fromkeys(batch_columns)) + if not columns: + reasons.append("No approved batch metric was supplied.") + batch_deltas: dict[str, float] = {} + for column in columns: + native_score = native.metrics.batchMixing.get(column) + harmony_score = harmony.metrics.batchMixing.get(column) + if native_score is None or harmony_score is None: + reasons.append(f"Batch comparison is missing for {column!r}.") + continue + batch_deltas[column] = harmony_score - native_score + if columns and len(batch_deltas) == len(columns): + if not any(delta > tolerance for delta in batch_deltas.values()): + reasons.append( + "Harmony did not improve an approved batch metric beyond tolerance." + ) + if any(delta < -tolerance for delta in batch_deltas.values()): + reasons.append("Harmony materially worsened an approved batch metric.") + + for column in dict.fromkeys(protected_columns): + native_scores = native.metrics.biologicalPreservation.get(column) + harmony_scores = harmony.metrics.biologicalPreservation.get(column) + if not native_scores or not harmony_scores: + reasons.append(f"Protected comparison is missing for {column!r}.") + continue + if set(native_scores) != set(harmony_scores): + reasons.append(f"Protected metrics do not align for {column!r}.") + continue + if any( + harmony_scores[name] < native_scores[name] - tolerance + for name in native_scores + ): + reasons.append( + f"Harmony materially degraded protected evidence for {column!r}." + ) + + if independent_unit_columns: + if ( + native.metrics.crossUnitSupport is None + or harmony.metrics.crossUnitSupport is None + ): + reasons.append("Cross-unit support comparison is missing.") + elif ( + harmony.metrics.crossUnitSupport + < native.metrics.crossUnitSupport - tolerance + ): + reasons.append("Harmony materially degraded cross-unit support.") + + if ( + native.metrics.markerCoherence is None + or harmony.metrics.markerCoherence is None + ): + reasons.append("Marker-coherence comparison is missing.") + elif harmony.metrics.markerCoherence < native.metrics.markerCoherence - tolerance: + reasons.append("Harmony materially degraded marker coherence.") + + for label, native_value, harmony_value in ( + ( + "marker specificity", + native.metrics.markerSpecificityMedian, + harmony.metrics.markerSpecificityMedian, + ), + ( + "cluster connectivity", + native.metrics.clusterConnectivity, + harmony.metrics.clusterConnectivity, + ), + ( + "membership strength", + native.metrics.membershipStrengthMean, + harmony.metrics.membershipStrengthMean, + ), + ): + if native_value is None and harmony_value is None: + continue + if native_value is None or harmony_value is None: + reasons.append(f"Matched {label} comparison is missing.") + elif harmony_value < native_value - tolerance: + reasons.append(f"Harmony materially degraded {label}.") + + native_doublet = native.metrics.doubletHighScoreConcentration + harmony_doublet = harmony.metrics.doubletHighScoreConcentration + if ( + require_doublet_evidence + or native_doublet is not None + or harmony_doublet is not None + ): + if native_doublet is None or harmony_doublet is None: + reasons.append("Matched doublet-concentration comparison is missing.") + elif harmony_doublet > native_doublet + tolerance: + reasons.append("Harmony materially increased doublet concentration.") + return not reasons, reasons + + def validate_parameter_search_plan( plan: ParameterSearchPlan, deps: ParameterTuningDependencies, @@ -1944,6 +2452,74 @@ def validate_parameter_batch_search_plan( return plan.model_copy(update={"assayPlans": validated}) +def parameter_evidence_classes(evidence_ids: Sequence[str]) -> frozenset[str]: + """Infer stable scientific evidence classes from executor evidence IDs.""" + + classes: set[str] = set() + for evidence_id in evidence_ids: + token = evidence_id.casefold() + if ( + "seedstability" in token + or "subsamplestability" in token + or token.endswith(":stability") + ): + classes.add("resamplingStability") + elif "marker" in token: + classes.add("markerCoherence") + elif "crossunitsupport" in token or "unitsupport" in token: + classes.add("crossUnitSupport") + elif ( + "protected" in token + or "clisi" in token + or ("graphconnectivity" in token and "clusterconnectivity" not in token) + ): + classes.add("protectedVariablePreservation") + elif "doublet" in token: + classes.add("qualityControl") + elif "technical" in token or "batchmixing" in token: + classes.add("technical") + elif any( + value in token + for value in ( + "clusterconnectivity", + "clusters", + "geometry", + "membershipstrength", + "neighbor", + "paretodominance", + "silhouette", + ) + ): + classes.add("geometric") + return frozenset(classes) + + +def require_dominated_candidate_evidence( + selected: ParameterCandidateEvaluation, + evidence_ids: Sequence[str], + *, + context: str, +) -> None: + """Require two independent non-geometric classes for a dominated choice.""" + + if not selected.metrics.dominatedByCandidateIds: + return + independent = parameter_evidence_classes(evidence_ids).intersection( + { + "markerCoherence", + "resamplingStability", + "crossUnitSupport", + "protectedVariablePreservation", + "qualityControl", + } + ) + if len(independent) < 2: + raise ValueError( + f"{context} selects a Pareto-dominated candidate and must cite at " + "least two independent non-geometric evidence classes" + ) + + def validate_parameter_tuning_report( report: ParameterTuningReport, deps: ParameterTuningDependencies, @@ -1952,11 +2528,18 @@ def validate_parameter_tuning_report( ) -> ParameterTuningReport: """Ground the model report in candidate executions recorded by the tool.""" - evaluations = [ - deps.evaluations[candidate_id] - for candidate_id in deps.executionOrder - if candidate_id in deps.evaluations - ] + evaluations = list( + annotate_candidate_dominance( + [ + deps.evaluations[candidate_id] + for candidate_id in deps.executionOrder + if candidate_id in deps.evaluations + ] + ) + ) + evaluations_by_id = { + evaluation.candidateId: evaluation for evaluation in evaluations + } known_evidence = { evidence_id for evaluation in evaluations @@ -1998,8 +2581,9 @@ def validate_parameter_tuning_report( ) selected_artifacts: dict[str, ArtifactRecord] = {} + selected_evaluation: ParameterCandidateEvaluation | None = None if report.recommendedCandidateId is not None: - selected = deps.evaluations.get(report.recommendedCandidateId) + selected = evaluations_by_id.get(report.recommendedCandidateId) if selected is None: raise ValueError("Recommended candidate was not executed") if selected.status != "done": @@ -2014,6 +2598,7 @@ def validate_parameter_tuning_report( raise ValueError( "Recommendation evidence must include the selected candidate" ) + selected_evaluation = selected selected_artifacts = dict(selected.artifacts) if report.status == "done" and not comparison_required and report.comparisons: @@ -2069,6 +2654,54 @@ def validate_parameter_tuning_report( raise ValueError( "Each candidate comparison requires a concise grounded summary" ) + if ( + selected_evaluation is not None + and comparison.candidateId + in selected_evaluation.metrics.dominatedByCandidateIds + and _single_varied_parameter( + selected_evaluation.parameters, + evaluations_by_id[comparison.candidateId].parameters, + ) + in {"neighborsK", "leidenResolution"} + ): + require_dominated_candidate_evidence( + selected_evaluation, + comparison.evidenceIds, + context=(f"The comparison with {comparison.candidateId!r}"), + ) + + graph_partition_dominators = ( + [ + candidate_id + for candidate_id in selected_evaluation.metrics.dominatedByCandidateIds + if candidate_id in evaluations_by_id + and _single_varied_parameter( + selected_evaluation.parameters, + evaluations_by_id[candidate_id].parameters, + ) + in {"neighborsK", "leidenResolution"} + ] + if selected_evaluation is not None + else [] + ) + if ( + report.status == "done" + and selected_evaluation is not None + and graph_partition_dominators + ): + selection_evidence = [ + *report.evidenceIds, + *( + evidence_id + for comparison in report.comparisons + for evidence_id in comparison.evidenceIds + ), + ] + require_dominated_candidate_evidence( + selected_evaluation, + selection_evidence, + context="The tuning recommendation", + ) return report.model_copy( update={ @@ -2807,6 +3440,7 @@ def prepare_parameter_tuning_dependencies( max_candidates: int = 5, max_refined_candidates: int = 0, allow_harmony_refinement: bool = True, + pair_harmony_candidates: bool | None = None, min_cluster_cells: int = 20, identity_feature_limit: int = 64, ) -> tuple[ParameterTuningDependencies, list[str]]: @@ -2816,6 +3450,11 @@ def prepare_parameter_tuning_dependencies( raise ValueError("max_candidates must be at least one") if max_refined_candidates < 0: raise ValueError("max_refined_candidates must be non-negative") + if pair_harmony_candidates is not None and not isinstance( + pair_harmony_candidates, + bool, + ): + raise TypeError("pair_harmony_candidates must be a boolean or None") if min_cluster_cells < 1: raise ValueError("min_cluster_cells must be at least one") if identity_feature_limit < 2: @@ -2865,8 +3504,12 @@ def prepare_parameter_tuning_dependencies( f"Initial candidate count exceeds max_candidates={max_candidates}" ) pair_harmony = ( - experimental_handoff is not None - and experimental_handoff.batchAction == "evaluateHarmony" + ( + experimental_handoff is not None + and experimental_handoff.batchAction == "evaluateHarmony" + ) + if pair_harmony_candidates is None + else pair_harmony_candidates ) candidate_values = build_initial_parameter_candidates( seed_candidates, @@ -3038,6 +3681,21 @@ def _execute_parameter_candidates( ) for candidate_id in candidate_ids: execute_parameter_candidate(deps, candidate_id) + _refresh_candidate_dominance(deps) + + +def _refresh_candidate_dominance(deps: ParameterTuningDependencies) -> None: + ordered = [ + deps.evaluations[candidate_id] + for candidate_id in deps.executionOrder + if candidate_id in deps.evaluations + ] + deps.evaluations.update( + { + evaluation.candidateId: evaluation + for evaluation in annotate_candidate_dominance(ordered) + } + ) def _register_refined_parameter_candidates( @@ -3053,6 +3711,32 @@ def _register_refined_parameter_candidates( deps.candidates[candidate.candidateId] = candidate deps.candidatePhases[candidate.candidateId] = "refined" execute_parameter_candidate(deps, candidate.candidateId) + _refresh_candidate_dominance(deps) + + +def execute_parameter_search_plan( + deps: ParameterTuningDependencies, + plan: ParameterSearchPlan, + *, + initial_candidate_ids: Sequence[str], + max_refined_candidates: int, +) -> tuple[ParameterSearchPlan, tuple[ParameterCandidateEvaluation, ...]]: + """Validate and execute one already-proposed bounded refinement plan.""" + + validated = validate_parameter_search_plan( + plan, + deps, + initial_candidate_ids=initial_candidate_ids, + max_refined_candidates=max_refined_candidates, + ) + _register_refined_parameter_candidates(deps, validated.candidates) + return ( + validated, + tuple( + deps.evaluations[candidate.candidateId] + for candidate in validated.candidates + ), + ) def tune_parameters_batch( @@ -3141,6 +3825,7 @@ def tune_parameters_batch( output_token_limit=32768, timeout_seconds=600.0, ) + refinement_planning_failed = False if any(max_refined_by_assay.values()): try: logger.info( @@ -3171,28 +3856,51 @@ def tune_parameters_batch( except (UnexpectedModelBehavior, UsageLimitExceeded) as exc: logger.warning( "Batched parameter refinement model run failed within its bounds " - f"({type(exc).__name__}); " - "skipping optional refinement" + f"({type(exc).__name__}); pausing without a refinement decision" ) + refinement_planning_failed = True + failed_plans = { + assay: ParameterSearchPlan( + status="complete", + basedOnCandidateIds=[ + next( + ( + candidate_id + for candidate_id in initial_ids[assay] + if dependencies[assay].evaluations[candidate_id].status + == "done" + and dependencies[assay] + .evaluations[candidate_id] + .eligible + ), + initial_ids[assay][0], + ) + ], + rationale=( + "The required bounded refinement review was unavailable." + ), + evidenceIds=sorted( + { + evidence_id + for candidate_id in initial_ids[assay] + for evidence_id in dependencies[assay] + .evaluations[candidate_id] + .evidenceIds + } + ), + stoppingCriteria=[ + "Obtain a grounded refinement disposition before selection." + ], + runInfo=AgentRunInfo( + agentName="parameter_batch_search_planning_needs_input" + ), + ) + for assay in assay_names + } batch_plan = ParameterTuningBatchSearchPlan( - assayPlans={ - assay: ParameterSearchPlan( - status="complete", - rationale=( - "The bounded structured refinement plan was unavailable; " - "optional refinement was skipped." - ), - stoppingCriteria=[ - "Use the completed deterministic initial screen." - ], - runInfo=AgentRunInfo( - agentName="parameter_batch_search_planning_fallback" - ), - ) - for assay in assay_names - }, + assayPlans=failed_plans, runInfo=AgentRunInfo( - agentName="parameter_batch_search_planning_fallback" + agentName="parameter_batch_search_planning_needs_input" ), ) else: @@ -3287,6 +3995,12 @@ def tune_parameters_batch( ) if not isinstance(selection_execution.output, ParameterTuningReport): raise TypeError("Batched parameter tuning returned an unexpected type") + if refinement_planning_failed: + return pending_parameter_tuning_batch_report( + dependencies, + search_plans=batch_plan.assayPlans, + primary_assay=resolved_primary, + ) report = validate_parameter_tuning_batch_report( selection_execution.output, dependencies, @@ -3351,6 +4065,7 @@ def tune_parameters( deps.evaluations[candidate_id] for candidate_id in initial_candidate_ids ] + refinement_planning_failed = False if max_refined_candidates == 0: logger.info( f"Skipping parameter refinement for assay {from_assay!r} because it " @@ -3401,17 +4116,34 @@ def tune_parameters( logger.warning( f"Parameter refinement planning for assay {from_assay!r} " f"failed within its model-run bounds ({type(exc).__name__}); " - "skipping optional refinement" + "pausing without a refinement decision" ) - plan = ParameterSearchPlan( + successful_parent = next( + ( + evaluation + for evaluation in initial_evaluations + if evaluation.status == "done" and evaluation.eligible + ), + initial_evaluations[0], + ) + failed_plan = ParameterSearchPlan( status="complete", - rationale=( - "The bounded structured refinement plan was unavailable; " - "optional refinement was skipped." + basedOnCandidateIds=[successful_parent.candidateId], + rationale=("The required bounded refinement review was unavailable."), + evidenceIds=sorted( + { + evidence_id + for evaluation in initial_evaluations + for evidence_id in evaluation.evidenceIds + } ), - stoppingCriteria=["Use the completed deterministic initial screen."], - runInfo=AgentRunInfo(agentName="parameter_search_planning_fallback"), + stoppingCriteria=[ + "Obtain a grounded refinement disposition before selection." + ], + runInfo=AgentRunInfo(agentName="parameter_search_planning_needs_input"), ) + refinement_planning_failed = True + plan = failed_plan else: if not isinstance(planning_execution.output, ParameterSearchPlan): raise TypeError( @@ -3476,6 +4208,12 @@ def tune_parameters( ) if not isinstance(selection_execution.output, ParameterTuningReport): raise TypeError("Parameter tuning agent returned an unexpected output type") + if refinement_planning_failed: + return pending_parameter_tuning_report( + deps, + search_plan=plan, + agent_name="parameter_tuning_needs_input", + ) report = validate_parameter_tuning_report( selection_execution.output, deps, @@ -3494,10 +4232,12 @@ def tune_parameters( __all__ = [ + "annotate_candidate_dominance", "ArtifactRecord", "build_initial_parameter_candidates", "CandidateComparison", "execute_parameter_candidate", + "execute_parameter_search_plan", "final_graph_options", "final_graph_selection_prompt", "final_graph_selection_system_prompt", @@ -3520,17 +4260,20 @@ def tune_parameters( "ParameterTuningReport", "evaluate_parameter_candidate", "get_default_parameter_candidates", + "harmony_acceptance_gate", "parameter_batch_search_prompt", "parameter_batch_search_system_prompt", "parameter_batch_selection_prompt", "parameter_batch_selection_system_prompt", "parameter_search_prompt", "parameter_search_system_prompt", + "parameter_evidence_classes", "parameter_tuning_prompt", "parameter_tuning_system_prompt", "prepare_parameter_tuning_dependencies", "promote_parameter_candidate", "run_candidate_reduction", + "require_dominated_candidate_evidence", "select_final_parameter_graph", "tune_parameters", "tune_parameters_batch", diff --git a/scarf/agent/qc_execution.py b/scarf/agent/qc_execution.py index 3d941f3a..6ffd2847 100644 --- a/scarf/agent/qc_execution.py +++ b/scarf/agent/qc_execution.py @@ -2,7 +2,7 @@ from collections.abc import Iterable, Mapping from numbers import Real -from typing import Any +from typing import Any, cast import numpy as np @@ -21,8 +21,11 @@ from ..utils.logging import logger from .qc_profiles import ( REGISTERED_CELL_QC_PROFILES, + AutoFilterAction, RegisteredCellQcProfile, + project_auto_filter_profile, project_registered_qc_profile, + qc_metric_execution_name, ) @@ -178,12 +181,18 @@ def execute_registered_cell_qc( joined = ", ".join(repr(attr) for attr in missing) raise KeyError(f"Cell metadata columns not found: {joined}") artifact_names = {source.name for source in metric_artifacts} - duplicate_names = sorted(set(attrs_list).intersection(artifact_names)) - if duplicate_names: - raise ValueError( - "Metadata and artifact QC metrics must use distinct names: " - f"{duplicate_names}" + metadata_collisions = set(attrs_list).intersection(artifact_names) + execution_artifacts = [ + NamedCellArtifact( + name=qc_metric_execution_name( + source.name, + artifact_id=source.artifact.artifact_id, + collides_with_metadata=source.name in metadata_collisions, + ), + artifact=source.artifact, ) + for source in metric_artifacts + ] prior = store._filter_input_selection(cell_selection) active = read_stored_selection_mask( @@ -215,7 +224,11 @@ def execute_registered_cell_qc( raise ValueError(f"QC values in {attr!r} contain non-finite entries") values_by_name[attr] = values metadata_fingerprints[attr] = fingerprint_array(values) - for source in metric_artifacts: + for source, execution_source in zip( + metric_artifacts, + execution_artifacts, + strict=True, + ): resolved = resolve_cell_aligned_artifact( store.zw, source.artifact, @@ -227,7 +240,7 @@ def execute_registered_cell_qc( raise ValueError( f"QC artifact values in {source.name!r} contain non-finite entries" ) - values_by_name[source.name] = values + values_by_name[execution_source.name] = values sample_labels: np.ndarray | None = None sample_inputs: dict[str, Any] = {} @@ -283,17 +296,37 @@ def execute_registered_cell_qc( "Registered cell-QC diagnostic-flag counts differ from their evidence" ) - metric_sources = [ - {"name": attr, "source": "metadataColumn", "column": attr} + metric_sources: list[dict[str, Any]] = [ + { + "name": attr, + "executionName": attr, + "source": "metadataColumn", + "column": attr, + } for attr in attrs_list ] metric_sources.extend( - {"name": source.name, "source": "artifact"} for source in metric_artifacts + { + "name": source.name, + "executionName": execution_source.name, + "source": "artifact", + "artifact": source.artifact.to_dict(), + } + for source, execution_source in zip( + metric_artifacts, + execution_artifacts, + strict=True, + ) ) source_inputs: dict[str, Any] = { "metadata_fingerprints": metadata_fingerprints, "artifact_metrics": { - source.name: source.artifact for source in metric_artifacts + execution_source.name: source.artifact + for source, execution_source in zip( + metric_artifacts, + execution_artifacts, + strict=True, + ) }, **sample_inputs, } @@ -367,4 +400,315 @@ def execute_registered_cell_qc( return ref, flag_ref -__all__ = ["execute_registered_cell_qc"] +def execute_auto_cell_qc( + store: Any, + action: AutoFilterAction, + *, + profile_parameters: Mapping[str, Any], + expected_active_cells: int, + expected_retained_cells: int, + expected_flag_counts: Mapping[str, int], + expected_resolved_bounds: Mapping[str, Any], + attrs: Iterable[str] | None = None, + artifact_metrics: Iterable[NamedCellArtifact] | None = None, + cell_selection: ArtifactRef | None = None, + sample_column: str | None = None, + sample_artifact: NamedCellArtifact | None = None, + capture_column: str | None = None, + capture_artifact: NamedCellArtifact | None = None, + invalidate_cache: bool = False, +) -> tuple[ArtifactRef, ArtifactRef | None]: + """Verify evidence, call core auto-filtering, and persist its exact flags.""" + if action not in {"globalGaussian", "sampleMad"}: + raise ValueError(f"Unknown automatic cell-QC action {action!r}") + attrs_list = list(attrs or ()) + if any(not isinstance(attr, str) for attr in attrs_list): + raise TypeError("attrs must contain only column names") + metric_artifacts = _validated_named_cell_artifacts( + artifact_metrics, + expected_kind="quality_metric", + label="artifact_metrics", + ) + sample_sources = _validated_named_cell_artifacts( + [sample_artifact] if sample_artifact is not None else [], + expected_kind="hto_identity", + label="sample_artifact", + ) + capture_sources = _validated_named_cell_artifacts( + [capture_artifact] if capture_artifact is not None else [], + expected_kind="hto_identity", + label="capture_artifact", + ) + resolved_sample_artifact = sample_sources[0] if sample_sources else None + resolved_capture_artifact = capture_sources[0] if capture_sources else None + if sample_column is not None and resolved_sample_artifact is not None: + raise ValueError("sample_column and sample_artifact are mutually exclusive") + if capture_column is not None and resolved_capture_artifact is not None: + raise ValueError("capture_column and capture_artifact are mutually exclusive") + if action == "sampleMad" and ( + (sample_column is None) == (resolved_sample_artifact is None) + ): + raise ValueError("sampleMad requires exactly one sample source") + if action == "globalGaussian" and ( + sample_column is not None or resolved_sample_artifact is not None + ): + raise ValueError("globalGaussian cannot use a core sample source") + for column in (sample_column, capture_column): + if column is not None and column not in store.cells.columns: + raise ValueError(f"QC grouping column {column!r} was not found") + missing = [attr for attr in attrs_list if attr not in store.cells.columns] + if missing: + raise KeyError(f"Cell metadata columns not found: {missing}") + + parameters = dict(profile_parameters) + canonical_bytes(parameters) + if ( + isinstance(expected_active_cells, bool) + or not isinstance(expected_active_cells, int) + or expected_active_cells < 1 + ): + raise ValueError("expected_active_cells must be a positive integer") + if ( + isinstance(expected_retained_cells, bool) + or not isinstance(expected_retained_cells, int) + or expected_retained_cells < 0 + ): + raise ValueError("expected_retained_cells must be a non-negative integer") + flag_counts = dict(expected_flag_counts) + if any( + not isinstance(name, str) + or not name + or isinstance(count, bool) + or not isinstance(count, int) + or count < 0 + for name, count in flag_counts.items() + ): + raise ValueError( + "expected_flag_counts must map non-empty names to non-negative integers" + ) + + prior = store._filter_input_selection(cell_selection) + active = read_stored_selection_mask( + store.zw, + prior, + kind="cell_selection", + scope="datastore", + assay=None, + table_path="cellData", + ) + active_idx = np.flatnonzero(active).astype(np.int64, copy=False) + if len(active_idx) != expected_active_cells: + raise ValueError( + "Automatic cell-QC active-cell count differs from its evidence" + ) + + artifact_names = {source.name for source in metric_artifacts} + metadata_collisions = set(attrs_list).intersection(artifact_names) + execution_artifacts = [ + NamedCellArtifact( + name=qc_metric_execution_name( + source.name, + artifact_id=source.artifact.artifact_id, + collides_with_metadata=source.name in metadata_collisions, + ), + artifact=source.artifact, + ) + for source in metric_artifacts + ] + values_by_name: dict[str, np.ndarray] = {} + metadata_fingerprints: dict[str, str] = {} + for attr in attrs_list: + values = np.asarray( + read_metadata_rows_chunkwise(store.cells, attr, active_idx), + dtype=float, + ) + if values.shape != (len(active_idx),) or not np.isfinite(values).all(): + raise ValueError( + f"QC metadata column {attr!r} is not a finite aligned vector" + ) + values_by_name[attr] = values + metadata_fingerprints[attr] = fingerprint_array(values) + for source, execution_source in zip( + metric_artifacts, + execution_artifacts, + strict=True, + ): + resolved = resolve_cell_aligned_artifact( + store.zw, + source.artifact, + cell_selection=prior, + expected_kind="quality_metric", + ) + values = np.asarray(resolved.values, dtype=float) + if values.shape != (len(active_idx),) or not np.isfinite(values).all(): + raise ValueError( + f"QC artifact {source.name!r} is not a finite aligned vector" + ) + values_by_name[execution_source.name] = values + + projection_labels: np.ndarray | None = None + grouping_source: dict[str, Any] = {} + core_sample_column = sample_column + core_sample_artifact = resolved_sample_artifact + if action == "sampleMad": + if sample_column is not None: + projection_labels = np.asarray( + read_metadata_rows_chunkwise( + store.cells, + sample_column, + active_idx, + ) + ) + grouping_source = { + "source": "metadataColumn", + "column": sample_column, + "fingerprint": fingerprint_strings(projection_labels), + } + else: + assert resolved_sample_artifact is not None + resolved = resolve_cell_aligned_artifact( + store.zw, + resolved_sample_artifact.artifact, + cell_selection=prior, + expected_kind="hto_identity", + ) + projection_labels = np.asarray(resolved.values) + grouping_source = { + "source": "artifact", + "artifact": resolved_sample_artifact.artifact, + } + elif capture_column is not None: + projection_labels = np.asarray( + read_metadata_rows_chunkwise(store.cells, capture_column, active_idx) + ) + grouping_source = { + "source": "metadataColumn", + "column": capture_column, + "fingerprint": fingerprint_strings(projection_labels), + } + elif resolved_capture_artifact is not None: + resolved = resolve_cell_aligned_artifact( + store.zw, + resolved_capture_artifact.artifact, + cell_selection=prior, + expected_kind="hto_identity", + ) + projection_labels = np.asarray(resolved.values) + grouping_source = { + "source": "artifact", + "artifact": resolved_capture_artifact.artifact, + } + + n_mads = float(parameters.get("nMads", 3.0)) + min_cells = int(parameters.get("minCellsPerSample", 20)) + min_p = float(parameters.get("minP", 0.01)) + max_p = float(parameters.get("maxP", 0.99)) + projection = project_auto_filter_profile( + action, + values_by_metric=values_by_name, + active=np.ones(len(active_idx), dtype=bool), + sample_labels=projection_labels, + grouping_proven=projection_labels is not None, + min_p=min_p, + max_p=max_p, + n_mads=n_mads, + min_cells_per_sample=min_cells, + ) + expected_summary: dict[str, Any] + if action == "globalGaussian": + expected_summary = projection.parameters + else: + expected_summary = { + "nMads": n_mads, + "minCellsPerSample": min_cells, + "nSamples": len(projection.captureSizes), + "nSkippedSamples": len( + cast(dict[str, object], projection.parameters["skipReasons"]) + ), + } + if canonical_bytes(parameters) != canonical_bytes(expected_summary): + raise ValueError("Automatic cell-QC parameters do not match the exact inputs") + if canonical_bytes(dict(expected_resolved_bounds)) != canonical_bytes( + projection.parameters["resolvedBounds"] + ): + raise ValueError("Automatic cell-QC resolved bounds differ from its evidence") + if projection.retainedCells != expected_retained_cells: + raise ValueError( + "Automatic cell-QC retained-cell count differs from its evidence" + ) + if projection.flagCounts != flag_counts: + raise ValueError("Automatic cell-QC flag counts differ from its evidence") + + result = store.auto_filter_cells( + attrs_list, + min_p=min_p, + max_p=max_p, + cell_selection=prior, + artifact_metrics=execution_artifacts, + invalidate_cache=invalidate_cache, + sample_column=core_sample_column, + sample_artifact=core_sample_artifact, + n_mads=n_mads, + min_cells_per_sample=min_cells, + ) + actual = read_stored_selection_mask( + store.zw, + result, + kind="cell_selection", + scope="datastore", + assay=None, + table_path="cellData", + ) + expected = np.zeros(store.cells.N, dtype=bool) + expected[active_idx] = projection.keep + if not np.array_equal(actual, expected): + raise RuntimeError( + "DataStore.auto_filter_cells output differs from its projected evidence" + ) + + flag_names = tuple(sorted(projection.flags)) + flag_ref: ArtifactRef | None = None + if flag_names: + values = np.column_stack([projection.flags[name] for name in flag_names]) + source_inputs: dict[str, Any] = { + "prior_cell_selection": prior, + "metadata_fingerprints": metadata_fingerprints, + "artifact_metrics": { + execution_source.name: source.artifact + for source, execution_source in zip( + metric_artifacts, + execution_artifacts, + strict=True, + ) + }, + "grouping_source": grouping_source, + "auto_filter_selection": result, + } + planned = plan_cell_data_artifact( + store.zw, + scope="datastore", + kind="metadata_snapshot", + operation="run_auto_cell_qc_flags", + parameters={ + "action": action, + "profileParameters": parameters, + "resolvedBounds": projection.parameters["resolvedBounds"], + "flagNames": list(flag_names), + }, + inputs=source_inputs, + execution_options={}, + cell_selection=prior, + arrays={"values": (values.shape, "b")}, + invalidate_cache=invalidate_cache, + ) + write_cell_data_artifact( + store.zw, + planned, + {"values": values.astype(bool, copy=False)}, + fingerprint_payload=True, + ) + flag_ref = planned.ref + return result, flag_ref + + +__all__ = ["execute_auto_cell_qc", "execute_registered_cell_qc"] diff --git a/scarf/agent/qc_profiles.py b/scarf/agent/qc_profiles.py index 33a66b03..f799e31b 100644 --- a/scarf/agent/qc_profiles.py +++ b/scarf/agent/qc_profiles.py @@ -1,16 +1,20 @@ -"""Registered one-sided cell-quality profiles and bounded projections.""" +"""Registered and core-parity cell-quality profile projections.""" +from collections.abc import Mapping from dataclasses import dataclass, field -from typing import Literal +from typing import Literal, cast import numpy as np from ..quality_control.filtering import ( + _apply_bounds, _clamp_metric_bound, _from_work_scale, _mad_bounds, + _sample_aware_mad_mask, _validated_sample_labels, _validated_work_scale, + gaussian_quantile_bounds, ) type RegisteredCellQcProfile = Literal[ @@ -20,7 +24,14 @@ "captureMad3Sensitivity", "pooledReferenceMad5", ] -type QcMetricRole = Literal["count", "feature", "mitochondrial", "diagnostic"] +type AutoFilterAction = Literal["globalGaussian", "sampleMad"] +type QcMetricRole = Literal[ + "count", + "feature", + "mitochondrial", + "ribosomal", + "diagnostic", +] type QcRemovalDirection = Literal["lower", "upper", "none"] REGISTERED_CELL_QC_PROFILES: tuple[RegisteredCellQcProfile, ...] = ( @@ -32,6 +43,22 @@ ) +def qc_metric_execution_name( + name: str, + *, + artifact_id: str | None = None, + collides_with_metadata: bool = False, +) -> str: + """Return the deterministic metric name passed to core filtering helpers.""" + if not isinstance(name, str) or not name.strip() or name != name.strip(): + raise ValueError("QC metric names must be non-empty trimmed strings") + if not collides_with_metadata: + return name + if not isinstance(artifact_id, str) or not artifact_id: + raise ValueError("A colliding artifact metric requires an artifact id") + return f"artifact_{artifact_id[:16]}_{name}" + + @dataclass(frozen=True, slots=True) class RegisteredQcThreshold: """One data-derived threshold for one metric and reference population.""" @@ -74,6 +101,11 @@ class CaptureQcComparison: capture: str cells: int adverseGlobalOutlier: bool + adverseAxes: tuple[QcMetricRole, ...] = () + independentAdverseAxes: int = 0 + wholeCaptureFailure: bool = False + retainedCells: int | None = None + retainedFraction: float | None = None reasons: tuple[str, ...] = () metricComparisons: dict[str, dict[str, float | str | None]] = field( default_factory=dict @@ -85,6 +117,11 @@ def to_dict(self) -> dict[str, object]: "capture": self.capture, "cells": self.cells, "adverseGlobalOutlier": self.adverseGlobalOutlier, + "adverseAxes": list(self.adverseAxes), + "independentAdverseAxes": self.independentAdverseAxes, + "wholeCaptureFailure": self.wholeCaptureFailure, + "retainedCells": self.retainedCells, + "retainedFraction": self.retainedFraction, "reasons": list(self.reasons), "metricComparisons": self.metricComparisons, } @@ -114,6 +151,49 @@ def flagCounts(self) -> dict[str, int]: """Counts for each non-removal diagnostic flag.""" return {name: int(mask.sum()) for name, mask in self.flags.items()} + @property + def metricFlagCounts(self) -> dict[str, dict[str, int]]: + """Counts grouped by exact metric and flag name.""" + grouped: dict[str, dict[str, int]] = {} + for name, mask in self.flags.items(): + metric, flag = name.rsplit(":", 1) + grouped.setdefault(metric, {})[flag] = int(mask.sum()) + return grouped + + +@dataclass(frozen=True, slots=True) +class AutoFilterProjection: + """Exact in-memory projection of one core ``auto_filter_cells`` path.""" + + action: AutoFilterAction + keep: np.ndarray + flags: dict[str, np.ndarray] + parameters: dict[str, object] + captureSizes: dict[str, int] = field(default_factory=dict) + retainedByCapture: dict[str, int] = field(default_factory=dict) + captureComparisons: tuple[CaptureQcComparison, ...] = () + failedCaptureCandidates: tuple[str, ...] = () + warnings: tuple[str, ...] = () + + @property + def retainedCells(self) -> int: + """Number of active cells retained by the projection.""" + return int(self.keep.sum()) + + @property + def flagCounts(self) -> dict[str, int]: + """Counts for each exact metric-bound flag.""" + return {name: int(mask.sum()) for name, mask in self.flags.items()} + + @property + def metricFlagCounts(self) -> dict[str, dict[str, int]]: + """Counts grouped by exact metric and bound side.""" + grouped: dict[str, dict[str, int]] = {} + for name, mask in self.flags.items(): + metric, flag = name.rsplit(":", 1) + grouped.setdefault(metric, {})[flag] = int(mask.sum()) + return grouped + def registered_qc_metric_role(metric: str) -> QcMetricRole: """Classify one conventional cell-quality metric without gene inspection.""" @@ -128,6 +208,12 @@ def registered_qc_metric_role(metric: str) -> QcMetricRole: or normalized.endswith("mitochondrialpercent") ): return "mitochondrial" + if ( + normalized.endswith("percentribo") + or normalized.endswith("pctcountsribo") + or normalized.endswith("ribosomalpercent") + ): + return "ribosomal" return "diagnostic" @@ -136,7 +222,7 @@ def _metric_policy( ) -> tuple[Literal["identity", "log1p"], QcRemovalDirection]: if role in {"count", "feature"}: return "log1p", "lower" - if role == "mitochondrial": + if role in {"mitochondrial", "ribosomal"}: return "identity", "upper" return "identity", "none" @@ -184,7 +270,7 @@ def _threshold( median = _clamp_metric_bound( _from_work_scale(median_work, transform), transform=transform, - is_percent=role == "mitochondrial", + is_percent=role in {"mitochondrial", "ribosomal"}, ) return RegisteredQcThreshold( metric=metric, @@ -204,17 +290,17 @@ def _threshold( median = _clamp_metric_bound( _from_work_scale(median_work, transform), transform=transform, - is_percent=role == "mitochondrial", + is_percent=role in {"mitochondrial", "ribosomal"}, ) low = _clamp_metric_bound( _from_work_scale(low_work, transform), transform=transform, - is_percent=role == "mitochondrial", + is_percent=role in {"mitochondrial", "ribosomal"}, ) high = _clamp_metric_bound( _from_work_scale(high_work, transform), transform=transform, - is_percent=role == "mitochondrial", + is_percent=role in {"mitochondrial", "ribosomal"}, ) return RegisteredQcThreshold( metric=metric, @@ -311,6 +397,7 @@ def _global_capture_comparisons( for capture, mask in captures: metric_comparisons: dict[str, dict[str, float | str | None]] = {} reasons: list[str] = [] + adverse_axes: set[QcMetricRole] = set() for metric, threshold in global_thresholds.items(): role = threshold.role transform, _ = _metric_policy(role) @@ -323,7 +410,7 @@ def _global_capture_comparisons( capture_median = _clamp_metric_bound( _from_work_scale(capture_median_work, transform), transform=transform, - is_percent=role == "mitochondrial", + is_percent=role in {"mitochondrial", "ribosomal"}, ) global_mad = threshold.scaledMad standardized_shift = ( @@ -342,6 +429,7 @@ def _global_capture_comparisons( ) if adverse: reasons.append(f"{metric}:{role}:adverseGlobalMedian") + adverse_axes.add(role) metric_comparisons[metric] = { "role": role, "captureMedian": capture_median, @@ -355,6 +443,9 @@ def _global_capture_comparisons( capture=capture, cells=int(mask.sum()), adverseGlobalOutlier=bool(reasons), + adverseAxes=tuple(sorted(adverse_axes)), + independentAdverseAxes=len(adverse_axes), + wholeCaptureFailure=len(adverse_axes) >= 2, reasons=tuple(reasons), metricComparisons=metric_comparisons, ) @@ -362,6 +453,34 @@ def _global_capture_comparisons( return tuple(comparisons) +def _with_capture_retention( + comparisons: tuple[CaptureQcComparison, ...], + captures: list[tuple[str, np.ndarray]], + keep: np.ndarray, +) -> tuple[CaptureQcComparison, ...]: + masks = dict(captures) + output: list[CaptureQcComparison] = [] + for comparison in comparisons: + mask = masks[comparison.capture] + retained = int((mask & keep).sum()) + fraction = retained / comparison.cells if comparison.cells else 0.0 + output.append( + CaptureQcComparison( + capture=comparison.capture, + cells=comparison.cells, + adverseGlobalOutlier=comparison.adverseGlobalOutlier, + adverseAxes=comparison.adverseAxes, + independentAdverseAxes=comparison.independentAdverseAxes, + wholeCaptureFailure=comparison.wholeCaptureFailure, + retainedCells=retained, + retainedFraction=fraction, + reasons=comparison.reasons, + metricComparisons=comparison.metricComparisons, + ) + ) + return tuple(output) + + def project_registered_qc_profile( profile: RegisteredCellQcProfile, *, @@ -381,7 +500,7 @@ def project_registered_qc_profile( filtering_values = { metric: metric_values for metric, metric_values in values.items() - if registered_qc_metric_role(metric) != "diagnostic" + if registered_qc_metric_role(metric) in {"count", "feature", "mitochondrial"} } if not filtering_values: if profile != "retainWithFlags": @@ -476,19 +595,32 @@ def project_registered_qc_profile( apply_removal=profile != "retainWithFlags", ) + comparison_values = { + metric: metric_values + for metric, metric_values in values.items() + if registered_qc_metric_role(metric) != "diagnostic" + } comparisons = ( - _global_capture_comparisons(filtering_values, active_mask, captures) + _with_capture_retention( + _global_capture_comparisons( + comparison_values, + active_mask, + captures, + ), + captures, + keep, + ) if captures else () ) failed = tuple( comparison.capture for comparison in comparisons - if comparison.adverseGlobalOutlier + if comparison.wholeCaptureFailure ) if failed: warnings.append( - "Capture medians outside adverse global MAD bounds require review: " + "Captures failed at least two independent global QC axes: " + ", ".join(failed) ) retained_by_capture = {name: int((mask & keep).sum()) for name, mask in captures} @@ -505,6 +637,180 @@ def project_registered_qc_profile( ) +def _auto_bound_flags( + *, + metric: str, + values: np.ndarray, + target: np.ndarray, + low: float | None, + high: float | None, + flags: dict[str, np.ndarray], +) -> None: + if low is not None: + flags.setdefault( + f"{metric}:low", + np.zeros(target.shape[0], dtype=bool), + )[target & (values <= low)] = True + if high is not None: + flags.setdefault( + f"{metric}:high", + np.zeros(target.shape[0], dtype=bool), + )[target & (values >= high)] = True + + +def project_auto_filter_profile( + action: AutoFilterAction, + *, + values_by_metric: dict[str, np.ndarray], + active: np.ndarray, + sample_labels: np.ndarray | None = None, + grouping_proven: bool = False, + min_p: float = 0.01, + max_p: float = 0.99, + n_mads: float = 3.0, + min_cells_per_sample: int = 20, +) -> AutoFilterProjection: + """Project one existing core auto-filter path without writing data. + + The projection calls the same filtering helpers as + :meth:`DataStore.auto_filter_cells`. It rejects any input for which the + core operation would not produce finite bounds. + """ + if action not in {"globalGaussian", "sampleMad"}: + raise ValueError(f"Unknown automatic cell-QC action {action!r}") + values, active_mask = _validated_inputs(values_by_metric, active) + if not values: + raise ValueError("Automatic cell-QC profiles require at least one metric") + + flags: dict[str, np.ndarray] = {} + warnings: list[str] = [] + captures: list[tuple[str, np.ndarray]] = [] + parameters: dict[str, object] + if action == "globalGaussian": + keep = active_mask.copy() + resolved_bounds: dict[str, dict[str, float]] = {} + for metric, metric_values in values.items(): + low, high = gaussian_quantile_bounds( + metric_values[active_mask], + min_p, + max_p, + ) + if not np.isfinite([low, high]).all(): + raise ValueError( + f"QC metric {metric!r} produced non-finite Gaussian bounds" + ) + resolved_bounds[metric] = {"low": low, "high": high} + keep &= _apply_bounds(metric_values, low, high) + _auto_bound_flags( + metric=metric, + values=metric_values, + target=active_mask, + low=low, + high=high, + flags=flags, + ) + parameters = { + "minP": float(min_p), + "maxP": float(max_p), + "resolvedBounds": resolved_bounds, + } + if sample_labels is not None and grouping_proven: + captures = _ordered_capture_masks( + np.asarray(sample_labels), + active_mask, + ) + else: + if sample_labels is None or not grouping_proven: + raise ValueError( + "sampleMad requires an explicitly proven physical capture grouping" + ) + if min_p != 0.01 or max_p != 0.99: + raise ValueError( + "sampleMad requires the core Gaussian probabilities to remain " + "at 0.01 and 0.99" + ) + keep_from_core, provenance = _sample_aware_mad_mask( + values_by_attr=values, + sample_labels=np.asarray(sample_labels), + active=active_mask, + n_mads=n_mads, + min_cells_per_sample=min_cells_per_sample, + attrs=list(values), + ) + keep = active_mask & keep_from_core + captures = _ordered_capture_masks( + np.asarray(sample_labels), + active_mask, + ) + capture_masks = dict(captures) + raw_bounds = provenance["resolved_bounds"] + for capture, bounds_by_metric in raw_bounds.items(): + target = capture_masks[capture] + for metric, raw_bound in bounds_by_metric.items(): + bound = cast(Mapping[str, object], raw_bound) + low_value = bound.get("low") + high_value = bound.get("high") + sample_low = ( + float(cast(float, low_value)) if low_value is not None else None + ) + sample_high = ( + float(cast(float, high_value)) if high_value is not None else None + ) + _auto_bound_flags( + metric=metric, + values=values[metric], + target=target, + low=sample_low, + high=sample_high, + flags=flags, + ) + warnings.extend(provenance["warnings"]) + parameters = { + "minP": 0.01, + "maxP": 0.99, + "nMads": float(n_mads), + "minCellsPerSample": int(min_cells_per_sample), + "madScale": float(provenance["mad_scale"]), + "metricPolicies": provenance["metric_policies"], + "sampleSizes": provenance["sample_sizes"], + "skipReasons": provenance["skip_reasons"], + "resolvedBounds": provenance["resolved_bounds"], + } + + capture_sizes = {name: int(mask.sum()) for name, mask in captures} + retained_by_capture = {name: int((mask & keep).sum()) for name, mask in captures} + comparisons = ( + _with_capture_retention( + _global_capture_comparisons(values, active_mask, captures), + captures, + keep, + ) + if captures + else () + ) + failed = tuple( + comparison.capture + for comparison in comparisons + if comparison.wholeCaptureFailure + ) + if failed: + warnings.append( + "Captures failed at least two independent global QC axes: " + + ", ".join(failed) + ) + return AutoFilterProjection( + action=action, + keep=keep, + flags=flags, + parameters=parameters, + captureSizes=capture_sizes, + retainedByCapture=retained_by_capture, + captureComparisons=comparisons, + failedCaptureCandidates=failed, + warnings=tuple(warnings), + ) + + def offered_registered_qc_profiles( *, values_by_metric: dict[str, np.ndarray], @@ -570,12 +876,16 @@ def offered_registered_qc_profiles( __all__ = [ "REGISTERED_CELL_QC_PROFILES", + "AutoFilterAction", + "AutoFilterProjection", "CaptureQcComparison", "QcMetricRole", "RegisteredCellQcProfile", "RegisteredQcProjection", "RegisteredQcThreshold", "offered_registered_qc_profiles", + "project_auto_filter_profile", "project_registered_qc_profile", + "qc_metric_execution_name", "registered_qc_metric_role", ] diff --git a/scarf/agent/report.py b/scarf/agent/report.py index aad8dac9..1a17ba63 100644 --- a/scarf/agent/report.py +++ b/scarf/agent/report.py @@ -18,9 +18,12 @@ from .. import __version__ from ..datastore.datastore import DataStore +from ..storage.refs import ArtifactRef from ..storage.stores import zarr_root_path +from ..storage.types import as_zarr_array from ..utils.logging import logger from . import record_io +from .decision_persistence import load_latest_decision_workflow_snapshot from .orchestrator import journal from .orchestrator.models import ( _STAGE_ORDER, @@ -150,6 +153,24 @@ def _collect_reports( return reports +def _collect_active_decisions( + store: DataStore, + workflow_run_id: str, +) -> dict[str, dict[str, Any]]: + try: + snapshot = load_latest_decision_workflow_snapshot(store, workflow_run_id) + except KeyError: + return {} + decisions: dict[str, dict[str, Any]] = {} + for record in snapshot.workflow.active_decision_records(): + if record.decisionId in decisions: + raise ValueError( + f"Decision workflow has multiple active {record.decisionId!r} records" + ) + decisions[record.decisionId] = record.model_dump(mode="json") + return decisions + + def _stage_summary(attempt: WorkflowStageAttempt) -> dict[str, Any]: duration = ( (attempt.completedAtNs - attempt.startedAtNs) / 1_000_000_000 @@ -327,10 +348,120 @@ def _safe_assay_name(value: str, fallback: str) -> str: return label[:64].rstrip("_") or fallback +def _annotate_qc_cutoffs(plot: Any, profile: Mapping[str, Any]) -> None: + bounds = _qc_resolved_bounds(profile) + if not bounds: + return + diagnostic_only = profile.get("action") == "skip" + styles = { + "lowerRemoval": ( + "lower diagnostic bound" if diagnostic_only else "lower removal cutoff", + "#d62728", + "--", + ), + "upperRemoval": ( + "upper diagnostic bound" if diagnostic_only else "upper removal cutoff", + "#d62728", + "--", + ), + "upperFlag": ("high-value diagnostic bound", "#ff7f0e", ":"), + } + recorded: list[dict[str, Any]] = [] + for metric, axis in plot.axes.items(): + metric_bounds = [ + bound for bound in bounds if str(bound.get("metric") or "") == str(metric) + ] + original_limits = axis.get_ylim() + visible_low, visible_high = sorted(float(value) for value in original_limits) + has_legend_entry = False + for field, (label, color, linestyle) in styles.items(): + values = sorted( + { + float(bound[field]) + for bound in metric_bounds + if isinstance(bound.get(field), int | float) + and not isinstance(bound.get(field), bool) + } + ) + if not values: + continue + formatted_values = _analysis_number_range(values) + if len(values) == 1: + if visible_low <= values[0] <= visible_high: + axis.axhline( + values[0], + color=color, + linestyle=linestyle, + linewidth=1.2, + label=f"{label}: {formatted_values}", + ) + else: + axis.plot( + [], + [], + color=color, + linestyle=linestyle, + linewidth=1.2, + label=f"{label}: {formatted_values} (outside plot)", + ) + else: + clipped_low = max(values[0], visible_low) + clipped_high = min(values[-1], visible_high) + if clipped_low <= clipped_high: + range_suffix = ( + " (partly outside plot)" + if values[0] < visible_low or values[-1] > visible_high + else "" + ) + axis.axhspan( + clipped_low, + clipped_high, + color=color, + alpha=0.1, + label=f"{label}: {formatted_values}{range_suffix}", + ) + for value in values: + if visible_low <= value <= visible_high: + axis.axhline( + value, + color=color, + linestyle=linestyle, + linewidth=0.8, + ) + else: + axis.plot( + [], + [], + color=color, + linestyle=linestyle, + linewidth=1.2, + label=f"{label}: {formatted_values} (outside plot)", + ) + has_legend_entry = True + recorded.extend( + { + "metric": str(metric), + "field": field, + "group": bound.get("group"), + "value": bound.get(field), + } + for bound in metric_bounds + if isinstance(bound.get(field), int | float) + and not isinstance(bound.get(field), bool) + ) + if has_legend_entry: + axis.legend(frameon=False, fontsize=6.5, loc="upper left") + axis.set_ylim(original_limits) + plot.provenance.extras["qc_cutoffs"] = recorded + plot.provenance.extras["qc_profile"] = profile.get("registeredProfile") + + def _collect_final_artifacts( store: DataStore, result: AutomatedWorkflowResult, plot_dir: Path, + *, + qc_profile: Mapping[str, Any] | None = None, ) -> tuple[ dict[str, int], list[dict[str, Any]], @@ -470,6 +601,93 @@ def render_plot(name: str, filename: str, create: Any) -> None: f"{MAX_COMPOSITION_PLOT_CELLS:,}" ) + qc_attributes = ( + list(result.preprocessingPlan.cellQc.attributes) + if result.preprocessingPlan is not None + else [] + ) + available_qc_attributes = [ + value for value in qc_attributes if value in store.cells.columns + ] + artifact_qc_metrics = ( + [ + artifact_model_to_ref(value.artifact) + for value in result.preprocessingPlan.cellQc.artifactMetrics + ] + if result.preprocessingPlan is not None + else [] + ) + available_qc_attributes = available_qc_attributes[:4] + + def qc_distribution(selection: Any) -> Any: + plot = store.plots.distribution( + keys=available_qc_attributes, + cell_selection=artifact_model_to_ref(selection), + kind="violin", + max_points=10_000, + show=False, + ) + if qc_profile: + _annotate_qc_cutoffs(plot, qc_profile) + return plot + + active_cells = qc_profile.get("activeCells") if qc_profile else None + retained_cells = qc_profile.get("retainedCells") if qc_profile else None + if ( + available_qc_attributes + and result.preprocessingPlan is not None + and result.preprocessingPlan.cellSelection is not None + and isinstance(active_cells, int) + and isinstance(retained_cells, int) + and retained_cells != active_cells + ): + render_plot( + "qcDistributionsBeforeFiltering", + "qc_distributions_before_filtering.png", + lambda: qc_distribution(result.preprocessingPlan.cellSelection), + ) + if available_qc_attributes: + render_plot( + "qcDistributions", + "qc_distributions.png", + lambda: qc_distribution(final.cellSelection), + ) + remaining_qc_plots = max(0, 4 - len(available_qc_attributes)) + for index, metric in enumerate(artifact_qc_metrics[:remaining_qc_plots]): + render_plot( + f"qcDistributionDerived{index + 1}", + f"qc_distribution_derived_{index + 1}.png", + lambda source=metric: store.plots.distribution( + keys=source, + kind="violin", + max_points=10_000, + show=False, + ), + ) + + for index, score_model in enumerate(final.doubletScores[:4]): + score_ref = artifact_model_to_ref(score_model) + render_plot( + f"doubletDistribution{index + 1}", + f"doublet_distribution_{index + 1}.png", + lambda score=score_ref: store.plots.distribution( + keys=score, + kind="hist", + bins=40, + show=False, + ), + ) + if index == 0 and n_cells <= MAX_EMBEDDING_PLOT_CELLS: + render_plot( + "doubletEmbedding", + "doublet_embedding.png", + lambda score=score_ref: store.plots.embedding( + layout=umap_ref, + color_by=score, + show=False, + ), + ) + top_markers: list[dict[str, Any]] = [] if final.markers is not None: marker_ref = artifact_model_to_ref(final.markers) @@ -704,28 +922,32 @@ def _hvg_diagnostic_evidence( } -def _collect_hvg_evidence( - store: DataStore, +def _latest_hvg_diagnostic_artifacts( stage_attempts: Sequence[Mapping[str, Any]], - preprocessing_plan: Mapping[str, Any], -) -> dict[str, Any]: - selected_name = "" - selected_reference: dict[str, Any] = {} - selected_artifacts: dict[str, Any] = {} +) -> tuple[str, dict[str, Any], dict[str, Any]]: for attempt in reversed(stage_attempts): artifacts = _mapping(attempt.get("artifacts")) match = next( ( - (name, _mapping(reference)) + (str(name), _mapping(reference)) for name, reference in artifacts.items() if re.fullmatch(r".+_hvg_diagnostic", str(name)) ), None, ) if match is not None: - selected_name, selected_reference = match - selected_artifacts = artifacts - break + return match[0], match[1], artifacts + return "", {}, {} + + +def _collect_hvg_evidence( + store: DataStore, + stage_attempts: Sequence[Mapping[str, Any]], + preprocessing_plan: Mapping[str, Any], +) -> dict[str, Any]: + selected_name, selected_reference, selected_artifacts = ( + _latest_hvg_diagnostic_artifacts(stage_attempts) + ) if not selected_reference: return {} assay = selected_name.removesuffix("_hvg_diagnostic") @@ -755,6 +977,21 @@ def _collect_hvg_evidence( {}, ) selected_count = _mapping(assay_plan.get("featureParameters")).get("topN") + default_reference_counts = sorted( + { + int(default_match.group(1)) + for name in selected_artifacts + if ( + default_match := re.fullmatch( + rf"{re.escape(assay)}_hvg_scarf_default_([0-9]+)", + str(name), + ) + ) + } + ) + executed_branch_count = len(default_reference_counts) + sum( + len(_mappings(ranking.get("candidateMetrics"))) for ranking in rankings + ) return { "assay": assay, "selectedRankingMode": selected.get("rankingMode"), @@ -766,9 +1003,196 @@ def _collect_hvg_evidence( "excludedTechnicalGroupCount": selected.get("excludedTechnicalGroupCount"), "minimumDetectedCells": selected.get("minimumDetectedCells"), "minimumTechnicalGroupCells": selected.get("minimumTechnicalGroupCells"), + "scarfDefaultReferenceCounts": default_reference_counts, + "executedBranchCount": executed_branch_count, } +def _collect_hvg_plots( + store: DataStore, + stage_attempts: Sequence[Mapping[str, Any]], + preprocessing_plan: Mapping[str, Any], + plot_dir: Path, +) -> tuple[dict[str, str], list[str]]: + import numpy as np + + selected_name, selected_reference, artifacts = _latest_hvg_diagnostic_artifacts( + stage_attempts + ) + if not selected_reference: + return {}, [] + assay_name = selected_name.removesuffix("_hvg_diagnostic") + assay_plan = next( + ( + value + for value in _mappings(preprocessing_plan.get("assays")) + if value.get("assay") == assay_name + ), + {}, + ) + selected_count = _mapping(assay_plan.get("featureParameters")).get("topN") + if not isinstance(selected_count, int) or isinstance(selected_count, bool): + return {}, ["HVG diagnostics: selected feature count is unavailable"] + + references = ( + ("global", artifacts.get(f"{assay_name}_hvg_global_diagnostic")), + ("batchAware", artifacts.get(f"{assay_name}_hvg_batchAware_diagnostic")), + ) + plots: dict[str, str] = {} + notes: list[str] = [] + seen_artifact_ids: set[str] = set() + plot_dir.mkdir(parents=True, exist_ok=True) + for ranking_mode, raw_reference in references: + if not isinstance(raw_reference, Mapping): + continue + model = ArtifactReferenceModel.model_validate(dict(raw_reference)) + if model.artifactId in seen_artifact_ids: + continue + seen_artifact_ids.add(model.artifactId) + plot_name = "hvgGlobal" if ranking_mode == "global" else "hvgBatchAware" + filename = ( + "hvg_global.png" if ranking_mode == "global" else "hvg_batch_aware.png" + ) + try: + diagnostic_ref = artifact_model_to_ref(model) + diagnostic = store.load_artifact(diagnostic_ref) + observed_mode = diagnostic.attrs.get("ranking_mode") + if observed_mode != ranking_mode: + raise ValueError( + f"HVG diagnostic expected {ranking_mode!r}, got {observed_mode!r}" + ) + status = store.inspect_artifact(diagnostic_ref) + raw_summary = (status.inputs or {}).get("global_feature_summary") + if not isinstance(raw_summary, Mapping): + raise ValueError("HVG diagnostic lacks its global feature summary") + summary_ref = ArtifactRef.from_dict(dict(raw_summary)) + summary = store.load_artifact(summary_ref) + corrected_variance = np.asarray( + as_zarr_array( + diagnostic["global_corrected_variance"], + name="global_corrected_variance", + )[:], + dtype=np.float64, + ) + ranking = np.asarray( + as_zarr_array(diagnostic["ranking"], name="ranking")[:], + dtype=np.int64, + ) + normed_tot = np.asarray( + as_zarr_array(summary["normed_tot"], name="normed_tot")[:], + dtype=np.float64, + ) + normed_n = np.asarray( + as_zarr_array(summary["normed_n"], name="normed_n")[:], + dtype=np.float64, + ) + shape = corrected_variance.shape + if ( + corrected_variance.ndim != 1 + or normed_tot.shape != shape + or normed_n.shape != shape + or selected_count > ranking.size + or ranking.size + and (int(ranking.min()) < 0 or int(ranking.max()) >= shape[0]) + or np.unique(ranking).size != ranking.size + ): + raise ValueError("HVG plotting arrays are malformed") + selected = np.zeros(shape, dtype=bool) + selected[ranking[:selected_count]] = True + mean_nonzero = np.divide( + normed_tot, + normed_n, + out=np.zeros_like(normed_tot), + where=normed_n != 0, + ) + from ..plotting import highly_variable_features + + plot = highly_variable_features( + mean_nonzero=mean_nonzero, + corrected_variance=corrected_variance, + n_cells=normed_n, + selected=selected, + show=False, + ) + plot.axes["highly_variable_features"].set_title( + f"{_hvg_ranking_label(ranking_mode)}\n{selected_count:,} selected genes" + ) + plot.provenance.extras.update( + { + "assay": assay_name, + "diagnostic_artifact_id": model.artifactId, + "ranking_mode": ranking_mode, + "selected_feature_count": selected_count, + } + ) + _save_plot(plot, plot_dir / filename) + plots[plot_name] = f"plots/{filename}" + except Exception as exc: + notes.append(f"{plot_name}: {type(exc).__name__}: {exc}") + return plots, notes + + +def _collect_default_feature_inventories( + store: DataStore, + preprocessing_plan: Mapping[str, Any], +) -> list[dict[str, Any]]: + inventories: list[dict[str, Any]] = [] + for assay_plan in _mappings(preprocessing_plan.get("assays")): + assay_name = str(assay_plan.get("assay") or "") + parameters = _mapping(assay_plan.get("featureParameters")) + inventory = _mapping(parameters.get("defaultFeatureInventory")) + if not inventory: + continue + feature_column = str(inventory.get("featureColumn") or "") + blacklist = str(inventory.get("blacklist") or "") + if not assay_name or not feature_column or not blacklist: + raise ValueError("Scarf default feature inventory is incomplete") + assay = store.get_assay(assay_name) + if feature_column not in assay.feats.columns: + raise ValueError( + f"Scarf default feature column {feature_column!r} is unavailable " + f"for assay {assay_name!r}" + ) + names = [str(value) for value in assay.feats.fetch_all(feature_column)] + try: + compiled = re.compile(blacklist.upper()) + except re.error as exc: + raise ValueError("Scarf default feature blacklist is invalid") from exc + matched = sorted( + (name for name in names if compiled.match(name.upper()) is not None), + key=lambda value: (value.casefold(), value), + ) + expected_total = inventory.get("totalFeatures") + expected_matches = inventory.get("matchCount") + if isinstance(expected_total, int) and expected_total != len(names): + raise ValueError( + f"Scarf default feature inventory for {assay_name!r} has stale " + "total feature evidence" + ) + if isinstance(expected_matches, int) and expected_matches != len(matched): + raise ValueError( + f"Scarf default feature inventory for {assay_name!r} has stale " + "blacklist match evidence" + ) + inventories.append( + { + **inventory, + "assay": assay_name, + "appliedToSelectedRepresentation": ( + parameters.get("useScarfDefaultBlacklist") is True + ), + "selectedExcludeFamilies": _text_values( + parameters.get("excludeFamilies") + ), + "selectedProtectFamilies": _text_values( + parameters.get("protectFamilies") + ), + "matchedFeatures": matched, + } + ) + return inventories + + REPORT_STYLES = """ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400&display=swap'); @@ -1203,6 +1627,14 @@ def _collect_hvg_evidence( } .plain-list { margin: 0; padding-left: 1.2rem; } .plain-list li { margin: .55rem 0; } +.column-list { + columns: 4 12rem; + column-gap: 2rem; +} +.column-list li { + break-inside: avoid; + margin: .25rem 0; +} .section { margin-top: 4rem; min-width: 0; @@ -1695,6 +2127,26 @@ def _render_plots( "Cluster connectivity", "Connectivity between final clusters in the selected graph.", ), + "qcDistributions": ( + "QC distributions after the selected policy", + "Retained-cell distributions with the selected profile's cutoff annotations.", + ), + "qcDistributionsBeforeFiltering": ( + "QC distributions before filtering", + "Input-cell distributions with the selected profile's cutoff annotations.", + ), + "hvgGlobal": ( + "Global HVG diagnostic", + "Mean-variance evidence with genes selected by the global ranking highlighted.", + ), + "hvgBatchAware": ( + "Group-aware HVG diagnostic", + "Mean-variance evidence with genes selected for recurrence across technical groups highlighted.", + ), + "doubletEmbedding": ( + "Advisory doublet scores", + "The final embedding colored by non-removing doublet evidence.", + ), } if titles is not None: plot_titles.update(titles) @@ -1708,6 +2160,13 @@ def _render_plots( "markerDotplot", "clusterComposition", "clusterConnectivity", + "qcDistributionsBeforeFiltering", + "qcDistributions", + *(name for name in plots if name.startswith("qcDistributionDerived")), + "hvgGlobal", + "hvgBatchAware", + "doubletEmbedding", + *(name for name in plots if name.startswith("doubletDistribution")), *plots, ] ) @@ -1720,6 +2179,14 @@ def _render_plots( assay = name.removeprefix("nativeUmap") or "assay" title = f"{assay} native UMAP" caption = f"The finalized native {assay} representation and clusters." + elif name.startswith("doubletDistribution"): + title = "Advisory doublet-score distribution" + caption = ( + "Capture-aware doublet evidence retained as flags without removal." + ) + elif name.startswith("qcDistributionDerived"): + title = "Derived QC metric distribution" + caption = "An immutable feature-family QC metric on the selected cell axis." else: title, caption = plot_titles.get( name, (_label(name), "A finalized Scarf analysis plot.") @@ -2177,9 +2644,16 @@ def _selected_qc_profile( def _feature_family_label(value: Any) -> str: labels = { "ribosomal": "ribosomal genes", + "ribosomalProtein": "ribosomal protein genes", "mitochondrial": "mitochondrial genes", + "mitoribosomal": "mitoribosomal genes", "sex": "sex-linked genes", + "sexLinked": "sex-linked genes", "cellCycle": "cell-cycle genes", + "cellCycleCcn": "CCN-prefixed genes", + "hla": "HLA genes", + "h2": "H2 genes", + "histone": "histone genes", } text = str(value or "").strip() return labels.get(text, _label(text).lower()) if text else "" @@ -2190,6 +2664,10 @@ def _public_field_label(value: Any) -> str: "T2D": "T2D status", "donor_id": "donor", "library_id": "library", + "RNA_nCounts": "RNA counts", + "RNA_nFeatures": "detected genes", + "RNA_percentMito": "mitochondrial percentage", + "RNA_percentRibo": "ribosomal percentage", "sample_id": "sample", "sex": "sex", "tissue": "tissue", @@ -2328,7 +2806,10 @@ def _qc_tree_stage( } -def _feature_tree_stage(plan: Mapping[str, Any]) -> dict[str, Any] | None: +def _feature_tree_stage( + plan: Mapping[str, Any], + inventories: Sequence[Mapping[str, Any]], +) -> dict[str, Any] | None: assay_plans = _mappings(plan.get("assays")) selected_assay = next( (assay for assay in assay_plans if assay.get("graphEligible") is True), @@ -2363,6 +2844,26 @@ def _feature_tree_stage(plan: Mapping[str, Any]) -> dict[str, Any] | None: metrics.append(f"Excluded {_format_text_list(excluded)}") if protected: metrics.append(f"Kept {_format_text_list(protected)} eligible") + inventory = _default_inventory_for_assay( + inventories, + str(selected_assay.get("assay") or ""), + ) + if inventory: + match_count = inventory.get("matchCount") + total_features = inventory.get("totalFeatures") + if isinstance(match_count, int) and isinstance(total_features, int): + metrics.append( + f"Scarf default reference matched {match_count:,} of " + f"{total_features:,} genes" + ) + metrics.append( + "Complete Scarf default blacklist applied: " + + ( + "yes" + if inventory.get("appliedToSelectedRepresentation") is True + else "no" + ) + ) return { "question": "Which measurements should shape the cell map?", "description": ( @@ -2389,7 +2890,9 @@ def _feature_tree_stage(plan: Mapping[str, Any]) -> dict[str, Any] | None: def _batch_tree_stage( experimental: Mapping[str, Any], + parameter: Mapping[str, Any], final: Mapping[str, Any], + decisions: Mapping[str, Any], ) -> dict[str, Any] | None: decision = _mapping(experimental.get("decision")) batch_plan = _mapping(decision.get("batchCorrection")) @@ -2407,6 +2910,11 @@ def _batch_tree_stage( adjustment_applied = any( _present(item.get("batchCorrection")) for item in selected_native ) + native_candidate, harmony_candidate = _harmony_candidate_pair(parameter, final) + harmony_executed = _harmony_completed(native_candidate) and _harmony_completed( + harmony_candidate + ) + degraded = _degraded_protected_columns(native_candidate, harmony_candidate) safety = _mappings(experimental.get("batchSafety")) unsafe = [item for item in safety if item.get("status") == "unsafe"] coefficients = [ @@ -2425,6 +2933,35 @@ def _batch_tree_stage( ) if remaining_capacity and all(value == 0 for value in remaining_capacity): adjustment_metrics.append("Remaining comparison capacity: 0") + if harmony_candidate: + harmony_parameters = _mapping(harmony_candidate.get("parameters")) + adjustment_metrics.append( + "Matched parameters: " + f"{_scalar(harmony_parameters.get('dimensions'))} dimensions, " + f"{_scalar(harmony_parameters.get('neighborsK'))} neighbors, " + f"resolution {_scalar(harmony_parameters.get('leidenResolution'))}" + ) + if harmony_executed: + adjustment_metrics.insert(0, "Run status: completed diagnostic") + native_metrics = _mapping(native_candidate.get("metrics")) + harmony_metrics = _mapping(harmony_candidate.get("metrics")) + native_batch = _mapping(native_metrics.get("batchMixing")) + harmony_batch = _mapping(harmony_metrics.get("batchMixing")) + for column in dict.fromkeys([*native_batch, *harmony_batch]): + adjustment_metrics.append( + f"{_public_field_label(column).capitalize()} mixing: " + f"{_score_transition(native_batch.get(column), harmony_batch.get(column))}" + ) + if degraded: + adjustment_metrics.append( + "Protected evidence degraded: " + _format_text_list(degraded) + ) + correction_license = _active_decision(decisions, "correctionLicense") + diagnostic_only = str(correction_license.get("selectedOptionId") or "").endswith( + "unsafeConfounded" + ) + if diagnostic_only: + adjustment_metrics.append("Selection license: diagnostic only") action = str(batch_plan.get("action") or "") if adjustment_applied: unadjusted_state = "alternative" @@ -2440,18 +2977,43 @@ def _batch_tree_stage( ) else: unadjusted_state = "selected" - adjusted_state = "blocked" if action in {"unsafe", "skip"} else "alternative" + adjusted_state = ( + "rejected" + if harmony_executed + else ("blocked" if action in {"unsafe", "skip"} else "alternative") + ) unadjusted_status = "Selected" - adjusted_status = "Not safe" if adjusted_state == "blocked" else "Not selected" + adjusted_status = ( + "Run diagnostically; rejected" + if harmony_executed + else ("Not run" if adjusted_state == "blocked" else "Not selected") + ) unadjusted_reason = ( - "Selected because adjustment was not shown to improve the data safely." + "Selected after the matched diagnostic retained more of the protected " + "biological structure." + if harmony_executed + else "Selected because adjustment was not shown to improve the data safely." ) adjusted_reason = ( - "Not used because technical and biological differences could not be " - "separated without risking the study comparisons." - if adjusted_state == "blocked" - else "Tested but did not provide a safer improvement over the " - "unadjusted data." + "Rejected because protected evidence degraded for " + f"{_format_text_list(degraded)}" + + ( + " and the design allowed diagnostic use only." + if diagnostic_only + else "." + ) + if harmony_executed and degraded + else ( + "Run as a matched diagnostic but not selected." + if harmony_executed + else ( + "Not run because technical and biological differences could " + "not be separated safely." + if adjusted_state == "blocked" + else "Tested but did not provide a safer improvement over the " + "unadjusted data." + ) + ) ) return { "question": "Should technical variation be adjusted?", @@ -2464,7 +3026,10 @@ def _batch_tree_stage( label="Use the unadjusted representation", status=unadjusted_status, state=unadjusted_state, - metrics=["Biological comparisons remain intact"], + metrics=[ + "Final representation: native", + "Protected biological comparisons retained", + ], reason=unadjusted_reason, ), _tree_branch( @@ -2512,6 +3077,212 @@ def _selected_parameter_context( return report, evaluations, selected +def _harmony_candidate_pair( + parameter: Mapping[str, Any], + final: Mapping[str, Any], +) -> tuple[dict[str, Any], dict[str, Any]]: + _report, evaluations, _selected = _selected_parameter_context(parameter, final) + for harmony in reversed(evaluations): + harmony_parameters = _mapping(harmony.get("parameters")) + if harmony_parameters.get("useHarmony") is not True: + continue + signature = { + key: value + for key, value in harmony_parameters.items() + if key not in {"candidateId", "useHarmony"} + } + native_candidates = [ + evaluation + for evaluation in evaluations + if _mapping(evaluation.get("parameters")).get("useHarmony") is False + and { + key: value + for key, value in _mapping(evaluation.get("parameters")).items() + if key not in {"candidateId", "useHarmony"} + } + == signature + ] + if not native_candidates: + continue + expected_native_id = str(harmony.get("candidateId") or "").replace( + "_correction_harmony", + "_correction_native", + ) + native = next( + ( + evaluation + for evaluation in native_candidates + if evaluation.get("candidateId") == expected_native_id + ), + native_candidates[-1], + ) + return native, harmony + return {}, {} + + +def _harmony_completed(evaluation: Mapping[str, Any]) -> bool: + return ( + evaluation.get("status") == "done" and evaluation.get("eligible") is not False + ) + + +def _score_transition(native: Any, harmony: Any) -> str: + if not isinstance(native, (int, float)) or isinstance(native, bool): + return "Not available" + if not isinstance(harmony, (int, float)) or isinstance(harmony, bool): + return "Not available" + delta = float(harmony) - float(native) + return f"{float(native):.3f} to {float(harmony):.3f} (change {delta:+.3f})" + + +def _harmony_metric_rows( + native: Mapping[str, Any], + harmony: Mapping[str, Any], +) -> list[dict[str, Any]]: + native_metrics = _mapping(native.get("metrics")) + harmony_metrics = _mapping(harmony.get("metrics")) + rows: list[dict[str, Any]] = [] + + def add( + category: str, + metric: str, + native_value: Any, + harmony_value: Any, + interpretation: str, + ) -> None: + delta = ( + float(harmony_value) - float(native_value) + if isinstance(native_value, (int, float)) + and not isinstance(native_value, bool) + and isinstance(harmony_value, (int, float)) + and not isinstance(harmony_value, bool) + else None + ) + rows.append( + { + "category": category, + "metric": metric, + "native": native_value, + "Harmony": harmony_value, + "change": delta, + "interpretation": interpretation, + } + ) + + native_batch = _mapping(native_metrics.get("batchMixing")) + harmony_batch = _mapping(harmony_metrics.get("batchMixing")) + for column in dict.fromkeys([*native_batch, *harmony_batch]): + add( + "Batch removal", + f"{_public_field_label(column)} mixing", + native_batch.get(column), + harmony_batch.get(column), + "Higher values indicate stronger mixing across the technical group.", + ) + + native_association = _mapping(native_metrics.get("technicalAssociation")) + harmony_association = _mapping(harmony_metrics.get("technicalAssociation")) + for column in dict.fromkeys([*native_association, *harmony_association]): + add( + "Technical association", + _public_field_label(column), + native_association.get(column), + harmony_association.get(column), + "Lower values indicate less association with the technical group.", + ) + + native_biology = _mapping(native_metrics.get("biologicalPreservation")) + harmony_biology = _mapping(harmony_metrics.get("biologicalPreservation")) + for column in dict.fromkeys([*native_biology, *harmony_biology]): + native_scores = _mapping(native_biology.get(column)) + harmony_scores = _mapping(harmony_biology.get(column)) + for name in dict.fromkeys([*native_scores, *harmony_scores]): + add( + "Protected biology", + f"{_public_field_label(column)} {_label(name)}", + native_scores.get(name), + harmony_scores.get(name), + "Protected evidence should not decrease materially.", + ) + + for key, label, interpretation in ( + ( + "crossUnitSupport", + "Cross-sample support", + "Higher values indicate broader support across study units.", + ), + ( + "markerCoherence", + "Marker coherence", + "Higher values indicate more groups with coherent markers.", + ), + ( + "markerSpecificityMedian", + "Median marker specificity", + "Higher values indicate more group-specific markers.", + ), + ( + "clusterConnectivity", + "Cluster connectivity", + "Higher values indicate better connected groups.", + ), + ( + "membershipStrengthMean", + "Mean membership strength", + "Higher values indicate more stable cluster membership.", + ), + ( + "doubletHighScoreConcentration", + "Doublet-score concentration", + "Lower values indicate less concentration of high doublet scores.", + ), + ): + if key in native_metrics or key in harmony_metrics: + add( + "Supporting diagnostic", + label, + native_metrics.get(key), + harmony_metrics.get(key), + interpretation, + ) + return rows + + +def _degraded_protected_columns( + native: Mapping[str, Any], + harmony: Mapping[str, Any], + *, + tolerance: float = 0.05, +) -> list[str]: + native_biology = _mapping( + _mapping(native.get("metrics")).get("biologicalPreservation") + ) + harmony_biology = _mapping( + _mapping(harmony.get("metrics")).get("biologicalPreservation") + ) + degraded: list[str] = [] + for column, raw_native in native_biology.items(): + native_scores = _mapping(raw_native) + harmony_scores = _mapping(harmony_biology.get(column)) + if any( + isinstance(value, (int, float)) + and not isinstance(value, bool) + and isinstance(harmony_scores.get(name), (int, float)) + and not isinstance(harmony_scores.get(name), bool) + and float(harmony_scores[name]) < float(value) - tolerance + for name, value in native_scores.items() + ): + degraded.append(_public_field_label(column)) + return degraded + + +def _active_decision( + decisions: Mapping[str, Any], + decision_id: str, +) -> dict[str, Any]: + return _mapping(decisions.get(decision_id)) + + def _common_parameter( evaluations: Sequence[Mapping[str, Any]], key: str, @@ -2780,16 +3551,21 @@ def _analysis_tree_stages(payload: Mapping[str, Any]) -> list[dict[str, Any]]: experimental = _latest(reports, "experimental_context") parameter = _latest(reports, "parameter_tuning") biology = _latest(reports, "biological_interpretation") + decisions = _mapping(payload.get("activeDecisions")) + inventories = _mappings(payload.get("defaultFeatureInventories")) cluster_counts = _mapping(payload.get("clusterCounts")) total_cells = sum(int(value) for value in cluster_counts.values()) stages: list[dict[str, Any]] = [] for stage in ( _qc_tree_stage(experimental, plan, total_cells), - _feature_tree_stage(plan), - _batch_tree_stage(experimental, final), + _feature_tree_stage(plan, inventories), ): if stage is not None: stages.append(stage) + stages.extend(_hvg_tree_stages(_mapping(payload.get("hvgEvidence")))) + batch_stage = _batch_tree_stage(experimental, parameter, final, decisions) + if batch_stage is not None: + stages.append(batch_stage) parameter_stages, selected = _parameter_tree_stages(parameter, final) stages.extend(parameter_stages) @@ -3049,9 +3825,15 @@ def _analysis_number_range(values: Sequence[Any]) -> str: return "Not available" low = min(numbers) high = max(numbers) + + def display(value: float) -> str: + if abs(value) >= 100: + return f"{value:,.0f}" + return f"{value:,.3f}".rstrip("0").rstrip(".") + if low == high: - return f"{low:,.0f}" - return f"{low:,.0f} to {high:,.0f}" + return display(low) + return f"{display(low)} to {display(high)}" def _render_evidence_choices(choices: Sequence[Mapping[str, Any]]) -> str: @@ -3100,6 +3882,7 @@ def _render_evidence_panel( introduction: str, body: str, measurements: str = "", + expanded: bool = False, ) -> str: measurement_markup = ( '
    Measurements' @@ -3107,8 +3890,9 @@ def _render_evidence_panel( if measurements else "" ) + open_attribute = " open" if expanded else "" return ( - '
    ' + f'
    ' f'{html.escape(title)}' f'{html.escape(outcome)}' "" @@ -3118,17 +3902,26 @@ def _render_evidence_panel( def _qc_profile_scope(profile: Mapping[str, Any]) -> str: - bounds = _mappings(_mapping(profile.get("parameters")).get("resolvedBounds")) + bounds = _qc_resolved_bounds(profile) groups = {str(item.get("group")) for item in bounds if _present(item.get("group"))} return "Per-library thresholds" if len(groups) > 1 else "Global thresholds" +def _qc_resolved_bounds(profile: Mapping[str, Any]) -> list[dict[str, Any]]: + direct = _mappings(profile.get("resolvedBounds")) + if direct: + return direct + return _mappings(_mapping(profile.get("parameters")).get("resolvedBounds")) + + def _qc_flag_summary(profile: Mapping[str, Any]) -> list[str]: labels = ( ("nCounts:high", "High RNA count flags"), ("nCounts:lowQuality", "Low RNA count flags"), ("nFeatures:high", "High detected-gene flags"), ("nFeatures:lowQuality", "Low detected-gene flags"), + ("percentMito:highMito", "High mitochondrial-percentage flags"), + ("percentRibo:highRibo", "High ribosomal-percentage flags"), ) flags = _mapping(profile.get("flaggedCells")) values: list[str] = [] @@ -3147,25 +3940,159 @@ def _qc_flag_summary(profile: Mapping[str, Any]) -> list[str]: def _qc_bound_summary(profile: Mapping[str, Any]) -> str: - bounds = _mappings(_mapping(profile.get("parameters")).get("resolvedBounds")) + bounds = _qc_resolved_bounds(profile) parts: list[str] = [] - for role, label in (("count", "RNA counts"), ("feature", "Detected genes")): + for role, label in ( + ("count", "RNA counts"), + ("feature", "Detected genes"), + ("mitochondrial", "Mitochondrial percentage"), + ("ribosomal", "Ribosomal percentage"), + ): matching = [item for item in bounds if item.get("role") == role] if not matching: continue lower = _analysis_number_range([item.get("lowerRemoval") for item in matching]) - upper = _analysis_number_range([item.get("upperFlag") for item in matching]) - parts.append( - f"{label}: lower removal cutoff {lower}; high-value flag cutoff {upper}" + upper_removal = _analysis_number_range( + [item.get("upperRemoval") for item in matching] + ) + upper_flag = _analysis_number_range( + [item.get("upperFlag") for item in matching] ) + cutoffs = [ + value + for value in ( + f"lower cutoff {lower}" if lower != "Not available" else "", + ( + f"upper cutoff {upper_removal}" + if upper_removal != "Not available" + else "" + ), + ( + f"high-value flag {upper_flag}" + if upper_flag != "Not available" + else "" + ), + ) + if value + ] + if cutoffs: + parts.append(f"{label}: {'; '.join(cutoffs)}") return ". ".join(parts) -def _render_filtering_evidence( - experimental: Mapping[str, Any], - plan: Mapping[str, Any], -) -> str: - profiles = _mappings(experimental.get("qcProfiles")) +def _qc_metric_rows(profile: Mapping[str, Any]) -> list[dict[str, Any]]: + bounds = _qc_resolved_bounds(profile) + by_metric: dict[str, list[dict[str, Any]]] = {} + for bound in bounds: + metric = str(bound.get("metric") or bound.get("role") or "") + if metric: + by_metric.setdefault(metric, []).append(bound) + capture_comparisons = _mappings( + _mapping(profile.get("parameters")).get("captureComparisons") + ) + if capture_comparisons: + first_metrics = _mapping(capture_comparisons[0].get("metricComparisons")) + for metric, raw in first_metrics.items(): + if str(metric).startswith("artifact_") or metric in by_metric: + continue + comparison = _mapping(raw) + by_metric[metric] = [ + { + "metric": metric, + "group": "global diagnostic", + "role": comparison.get("role"), + "median": comparison.get("globalMedian"), + "diagnosticLower": comparison.get("globalLower"), + "diagnosticUpper": comparison.get("globalUpper"), + } + ] + flags = _mapping(profile.get("metricFlaggedCells")) + rows: list[dict[str, Any]] = [] + for metric, metric_bounds in by_metric.items(): + medians = [item.get("median") for item in metric_bounds] + lower = [item.get("lowerRemoval") for item in metric_bounds] + upper = [item.get("upperRemoval") for item in metric_bounds] + high_flag = [item.get("upperFlag") for item in metric_bounds] + diagnostic_range = [ + item.get(field) + for item in metric_bounds + for field in ("diagnosticLower", "diagnosticUpper") + ] + metric_flags = _mapping(flags.get(metric)) + rows.append( + { + "metric": _public_field_label(metric), + "scope": ( + "Global diagnostic only" + if any(value is not None for value in diagnostic_range) + else _qc_profile_scope({"resolvedBounds": metric_bounds}) + ), + "median": _analysis_number_range(medians), + "diagnostic reference": _analysis_number_range(diagnostic_range), + "lower cutoff": _analysis_number_range(lower), + "upper cutoff": _analysis_number_range(upper), + "high flag": _analysis_number_range(high_flag), + "flagged cells": sum( + int(value) + for value in metric_flags.values() + if isinstance(value, int) + ), + } + ) + return rows + + +def _qc_profile_rows(profiles: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for profile in profiles: + active = profile.get("activeCells") + retained = profile.get("retainedCells") + removed = ( + active - retained + if isinstance(active, int) and isinstance(retained, int) + else None + ) + rows.append( + { + "profile": _qc_profile_label(profile), + "scope": _qc_profile_scope(profile), + "active cells": active, + "retained cells": retained, + "removed cells": removed, + "flags": _qc_flag_summary(profile), + "failed libraries": len( + _text_values(profile.get("failedCaptureCandidates")) + ), + } + ) + return rows + + +def _qc_bound_rows(profiles: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for profile in profiles: + for bound in _qc_resolved_bounds(profile): + rows.append( + { + "profile": _qc_profile_label(profile), + "group": bound.get("group"), + "metric": _public_field_label( + bound.get("metric") or bound.get("role") + ), + "median": bound.get("median"), + "lower removal": bound.get("lowerRemoval"), + "upper removal": bound.get("upperRemoval"), + "upper flag": bound.get("upperFlag"), + } + ) + return rows + + +def _render_filtering_evidence( + experimental: Mapping[str, Any], + plan: Mapping[str, Any], +) -> str: + profiles = _mappings(experimental.get("qcProfiles")) if not profiles: return "" decision = _mapping(experimental.get("decision")) @@ -3205,6 +4132,7 @@ def _render_filtering_evidence( f"Threshold distance: {float(n_mads):g} median absolute deviations" ) metrics.append(_qc_profile_scope(profile)) + metrics.extend(_qc_flag_summary(profile)) choices.append( { "label": _qc_profile_label(profile), @@ -3227,8 +4155,14 @@ def _render_filtering_evidence( active = selected.get("activeCells") retained = selected.get("retainedCells") selected_label = _qc_profile_label(selected) + selected_flags = sum( + int(value) + for value in _mapping(selected.get("flaggedCells")).values() + if isinstance(value, int) + ) outcome = ( - f"{selected_label}; {retained:,} of {active:,} cells retained" + f"{selected_label}; {retained:,} of {active:,} cells retained; " + f"{selected_flags:,} diagnostic flags" if isinstance(active, int) and isinstance(retained, int) else f"{selected_label} selected" ) @@ -3271,12 +4205,32 @@ def _render_filtering_evidence( title="Cell filtering", outcome=outcome, introduction=( - "Four registered filtering strategies were compared. The selected " - "strategy retained the published cell set because stricter alternatives " - "did not provide stronger support." + f"{len(profiles):,} registered filtering strategies were compared. " + "The selected strategy retained the published cell set because stricter " + "alternatives did not provide stronger support. Its cutoffs are " + "diagnostic bounds and did not remove cells." + ), + body=( + _render_evidence_choices(choices) + + '

    Selected QC metrics and cutoffs

    ' + + _table( + _qc_metric_rows(selected), + columns=( + "metric", + "scope", + "median", + "diagnostic reference", + "lower cutoff", + "upper cutoff", + "high flag", + "flagged cells", + ), + empty="No selected QC metric cutoffs were recorded.", + ) + + "
    " ), - body=_render_evidence_choices(choices), measurements=_render_evidence_measurements(measurements), + expanded=True, ) @@ -3412,9 +4366,79 @@ def _feature_family_counts( return counts +def _default_inventory_for_assay( + inventories: Sequence[Mapping[str, Any]], + assay: str, +) -> dict[str, Any]: + matches = [dict(value) for value in inventories if value.get("assay") == assay] + if len(matches) > 1: + raise ValueError( + f"Multiple Scarf default inventories found for assay {assay!r}" + ) + return matches[0] if matches else {} + + +def _default_inventory_family_rows( + inventory: Mapping[str, Any], +) -> list[dict[str, Any]]: + return [ + { + "family": _feature_family_label(family.get("family")), + "pattern": family.get("pattern"), + "matched genes": family.get("count"), + "examples": _text_values(family.get("examples")), + } + for family in _mappings(inventory.get("families")) + ] + + +def _render_default_inventory_summary( + inventory: Mapping[str, Any], + *, + heading: str, +) -> str: + if not inventory: + return "" + match_count = inventory.get("matchCount") + total_features = inventory.get("totalFeatures") + applied = inventory.get("appliedToSelectedRepresentation") is True + count_summary = ( + f"{int(match_count):,} of {int(total_features):,} genes matched." + if isinstance(match_count, int) and isinstance(total_features, int) + else "The exact default pattern was evaluated." + ) + effect = ( + "The complete default blacklist was applied to the selected representation." + if applied + else ( + "The complete default blacklist was evaluated as a reference but was " + "not applied wholesale to the selected representation." + ) + ) + blacklist = str(inventory.get("blacklist") or "") + pattern_markup = ( + "

    Exact combined pattern: " + f"{html.escape(blacklist)}

    " + if blacklist + else "" + ) + return ( + f'

    {html.escape(heading)}

    ' + f"

    {html.escape(count_summary)} {html.escape(effect)}

    " + + _table( + _default_inventory_family_rows(inventory), + columns=("family", "pattern", "matched genes", "examples"), + empty="No default blacklist families were recorded.", + ) + + pattern_markup + + "
    " + ) + + def _render_normalization_evidence( enrichment: Mapping[str, Any], plan: Mapping[str, Any], + inventories: Sequence[Mapping[str, Any]], ) -> str: assay_plans = _mappings(plan.get("assays")) if not assay_plans: @@ -3427,6 +4451,7 @@ def _render_normalization_evidence( assay = str(assay_plan.get("assay") or "Assay") normalization = _mapping(assay_plan.get("normalizationParameters")) feature_parameters = _mapping(assay_plan.get("featureParameters")) + inventory = _default_inventory_for_assay(inventories, assay) log_transform = normalization.get("logTransform") is True renormalize = normalization.get("renormalizeSubset") is True normalization_metrics = [ @@ -3482,11 +4507,51 @@ def _render_normalization_evidence( ), } ) + if inventory: + default_applied = inventory.get("appliedToSelectedRepresentation") is True + match_count = inventory.get("matchCount") + total_features = inventory.get("totalFeatures") + choices.append( + { + "label": "Exact Scarf default HVG blacklist", + "status": ( + "Applied to selected representation" + if default_applied + else "Evaluated as reference" + ), + "state": "selected" if default_applied else "reviewed", + "metrics": ( + [ + f"Matched {int(match_count):,} of " + f"{int(total_features):,} genes" + ] + if isinstance(match_count, int) + and isinstance(total_features, int) + else [] + ), + "reason": ( + "Applied as the complete selected representation blacklist." + if default_applied + else ( + "Not applied wholesale; the final policy used only the " + "families supported by the decision evidence." + ) + ), + } + ) outcome_parts.append( f"{_assay_label(assay)} log normalization" if log_transform else f"{_assay_label(assay)} normalization" ) + if excluded: + outcome_parts.append( + f"excluded {_format_text_list([_feature_family_label(value) for value in excluded])} from map construction" + ) + if inventory and not inventory.get("appliedToSelectedRepresentation"): + outcome_parts.append( + "complete Scarf default blacklist not applied wholesale" + ) for family_name in dict.fromkeys([*excluded, *protected]): family = _mapping(families.get((assay, family_name))) count = family.get("count") @@ -3525,6 +4590,13 @@ def _render_normalization_evidence( "Applied before variable-gene ranking.", ) ) + inventory_markup = "".join( + _render_default_inventory_summary( + inventory, + heading=f"{_assay_label(inventory.get('assay'))} default blacklist audit", + ) + for inventory in inventories + ) return _render_evidence_panel( title="Normalization and feature policy", outcome="; ".join(outcome_parts), @@ -3533,14 +4605,16 @@ def _render_normalization_evidence( "Representation exclusions changed the map-building features, not the " "genes available for marker analysis." ), - body=_render_evidence_choices(choices), + body=_render_evidence_choices(choices) + inventory_markup, measurements=_render_evidence_measurements(measurements), ) def _render_batch_evidence( experimental: Mapping[str, Any], + parameter: Mapping[str, Any], final: Mapping[str, Any], + decisions: Mapping[str, Any], ) -> str: decision = _mapping(experimental.get("decision")) batch_plan = _mapping(decision.get("batchCorrection")) @@ -3555,6 +4629,11 @@ def _render_batch_evidence( if item.get("assay") == final.get("primaryAssay") ] adjusted = any(_present(item.get("batchCorrection")) for item in native_analyses) + native_candidate, harmony_candidate = _harmony_candidate_pair(parameter, final) + harmony_executed = _harmony_completed(native_candidate) and _harmony_completed( + harmony_candidate + ) + degraded = _degraded_protected_columns(native_candidate, harmony_candidate) coefficients = list( dict.fromkeys( _public_field_label(item.get("coefficient")) @@ -3563,6 +4642,57 @@ def _render_batch_evidence( ) ) unsafe = any(item.get("status") == "unsafe" for item in safety) + correction_outcome = _active_decision(decisions, "correctionOutcome") + correction_license = _active_decision(decisions, "correctionLicense") + diagnostic_only = str(correction_license.get("selectedOptionId") or "").endswith( + "unsafeConfounded" + ) + native_metrics = _mapping(native_candidate.get("metrics")) + harmony_metrics = _mapping(harmony_candidate.get("metrics")) + native_batch = _mapping(native_metrics.get("batchMixing")) + harmony_batch = _mapping(harmony_metrics.get("batchMixing")) + harmony_choice_metrics: list[str] = [] + if harmony_candidate: + parameters = _mapping(harmony_candidate.get("parameters")) + harmony_choice_metrics.append( + "Matched parameters: " + f"{_scalar(parameters.get('dimensions'))} dimensions, " + f"{_scalar(parameters.get('neighborsK'))} neighbors, " + f"resolution {_scalar(parameters.get('leidenResolution'))}" + ) + if harmony_executed: + harmony_choice_metrics.insert(0, "Run status: completed") + for column in dict.fromkeys([*native_batch, *harmony_batch]): + harmony_choice_metrics.append( + f"{_public_field_label(column).capitalize()} mixing: " + f"{_score_transition(native_batch.get(column), harmony_batch.get(column))}" + ) + if degraded: + harmony_choice_metrics.append( + "Protected evidence degraded: " + _format_text_list(degraded) + ) + if coefficients: + harmony_choice_metrics.append( + f"Design-confounded comparisons: {_format_text_list(coefficients)}" + ) + if diagnostic_only: + harmony_choice_metrics.append("Selection license: diagnostic only") + recorded_rationale = str(correction_outcome.get("rationale") or "").strip() + if harmony_executed and degraded: + harmony_reason = ( + "Rejected because protected evidence degraded for " + f"{_format_text_list(degraded)}" + + ("; the design license was diagnostic only." if diagnostic_only else ".") + ) + elif harmony_executed: + harmony_reason = "Executed as a matched diagnostic but not selected." + elif unsafe: + harmony_reason = ( + "Not run because library effects could not be separated safely from " + "the protected study comparisons." + ) + else: + harmony_reason = "No completed matched Harmony diagnostic was recorded." choices = [ { "label": "Use the unadjusted representation", @@ -3570,7 +4700,10 @@ def _render_batch_evidence( "state": "selected" if not adjusted else "rejected", "metrics": ["Protected biological comparisons remain intact"], "reason": ( - "Selected because no safe, measurable correction was available." + "Selected after the matched diagnostic retained more protected " + "biological structure." + if harmony_executed and not adjusted + else "Selected because no safe, measurable correction was available." if not adjusted else "Not selected after the adjusted result showed a safe benefit." ), @@ -3578,20 +4711,17 @@ def _render_batch_evidence( { "label": "Apply Harmony correction", "status": ( - "Selected" if adjusted else ("Not safe" if unsafe else "Not selected") + "Run and selected" + if adjusted + else ( + "Run diagnostically; rejected" + if harmony_executed + else ("Not run" if unsafe else "Not selected") + ) ), "state": "rejected" if not adjusted else "selected", - "metrics": ( - [f"Comparisons at risk: {_format_text_list(coefficients)}"] - if coefficients - else [] - ), - "reason": ( - "Not run because library effects could not be separated from the " - "protected study comparisons." - if unsafe - else "Evaluated against the native representation." - ), + "metrics": harmony_choice_metrics, + "reason": harmony_reason, }, ] measurements: list[tuple[str, str, str]] = [] @@ -3629,22 +4759,52 @@ def _render_batch_evidence( _covariate_pair_measurements(_mapping(experimental.get("characterization"))) ) outcome = ( - "Harmony not applied; protected comparisons were not independently estimable" - if unsafe + "Harmony completed and was selected" + if adjusted else ( - "Harmony correction was selected" - if adjusted - else "No batch correction was selected" + "Diagnostic Harmony completed; rejected and native representation retained" + if harmony_executed + else ( + "Harmony not applied; protected comparisons were not independently estimable" + if unsafe + else "No batch correction was selected" + ) + ) + ) + comparison_markup = ( + '

    Matched native versus Harmony metrics

    ' + + _table( + _harmony_metric_rows(native_candidate, harmony_candidate), + columns=( + "category", + "metric", + "native", + "Harmony", + "change", + "interpretation", + ), + empty="No matched Harmony measurements were recorded.", ) + + "
    " + if harmony_executed + else "" + ) + rationale_markup = ( + '

    Recorded correction decision

    ' + f"

    {html.escape(recorded_rationale)}

    " + if recorded_rationale + else "" ) return _render_evidence_panel( title="Harmony and batch correction", outcome=outcome, introduction=( - "Correction was allowed only when technical variation could be reduced " - "without removing tissue, T2D, donor, or sex structure." + "Selection required measured technical improvement without material " + "loss of protected tissue, T2D, donor, or sex structure. A diagnostic " + "run could still be completed when the design was not licensed for " + "corrected-result selection." ), - body=_render_evidence_choices(choices), + body=(_render_evidence_choices(choices) + comparison_markup + rationale_markup), measurements=_render_evidence_measurements(measurements), ) @@ -3656,9 +4816,117 @@ def _hvg_ranking_label(value: Any) -> str: }.get(str(value or ""), "Variable-gene ranking") +def _hvg_tree_stages(evidence: Mapping[str, Any]) -> list[dict[str, Any]]: + rankings = _mappings(evidence.get("rankings")) + candidates = _mappings(evidence.get("candidateMetrics")) + default_counts = [ + int(value) + for value in evidence.get("scarfDefaultReferenceCounts", []) + if isinstance(value, int) + ] + selected_mode = evidence.get("selectedRankingMode") + selected_count = evidence.get("selectedFeatureCount") + stages: list[dict[str, Any]] = [] + if rankings: + ranking_branches: list[dict[str, Any]] = [] + for ranking in rankings: + selected = ranking.get("rankingMode") == selected_mode + ranking_branches.append( + _tree_branch( + label=_hvg_ranking_label(ranking.get("rankingMode")), + status="Selected" if selected else "Not selected", + state="selected" if selected else "alternative", + metrics=[ + "Mean library coverage: " + f"{_analysis_percent(ranking.get('meanTechnicalGroupCoverage'))}", + "Recurring in at least two libraries: " + f"{_analysis_percent(ranking.get('recurrentInTwoGroupsFraction'))}", + ], + reason=( + "Selected after the combined recurrence, default-overlap, " + "technical-association, and downstream-stability comparison." + if selected + else ( + "Not selected after the combined upstream and downstream " + "comparison." + ) + ), + ) + ) + if default_counts: + ranking_branches.append( + _tree_branch( + label="Exact Scarf-default blacklist reference", + status="Reference evaluated", + state="reviewed", + metrics=[ + "Executed set sizes: " + + ", ".join(f"{value:,}" for value in default_counts) + ], + reason=( + "Used as an exact comparison reference; it was not a " + "selectable ranking mode." + ), + ) + ) + stages.append( + { + "question": "How should highly variable genes be ranked?", + "description": ( + "The workflow compared a global variability ranking with a " + "ranking that emphasized recurrence across libraries." + ), + "branches": ranking_branches, + } + ) + if candidates: + count_branches: list[dict[str, Any]] = [] + for candidate in candidates: + count = candidate.get("featureCount") + if not isinstance(count, int): + continue + selected = count == selected_count + count_branches.append( + _tree_branch( + label=f"{count:,} variable genes", + status="Selected" if selected else "Not selected", + state="selected" if selected else "alternative", + metrics=[ + "Corrected variance captured: " + f"{_analysis_percent(candidate.get('varianceFraction'))}", + "Recurring across most libraries: " + f"{_analysis_percent(candidate.get('recurrentFraction'))}", + ], + reason=( + "Selected as the supported balance of captured variation, " + "reproducibility, and downstream stability." + if selected + else "Not selected after comparison with the supported set size." + ), + ) + ) + if count_branches: + stages.append( + { + "question": "How many highly variable genes should be used?", + "description": ( + "Registered focused, standard, and broad feature-set sizes " + "were all executed and compared." + ), + "branches": count_branches, + } + ) + return stages + + def _render_hvg_evidence(evidence: Mapping[str, Any]) -> str: rankings = _mappings(evidence.get("rankings")) candidates = _mappings(evidence.get("candidateMetrics")) + default_counts = [ + int(value) + for value in evidence.get("scarfDefaultReferenceCounts", []) + if isinstance(value, int) + ] if not rankings and not candidates: return "" selected_mode = evidence.get("selectedRankingMode") @@ -3678,10 +4946,29 @@ def _render_hvg_evidence(evidence: Mapping[str, Any]) -> str: f"{_analysis_percent(ranking.get('recurrentInTwoGroupsFraction'))}", ], "reason": ( - "Selected because variable genes were more consistent across " - "the registered libraries." + "Selected after combining recurrence, exact Scarf-default " + "overlap, technical association, and downstream stability." if selected - else "Not selected because fewer genes recurred across libraries." + else ( + "Not selected after the combined upstream and downstream " + "comparison." + ) + ), + } + ) + if default_counts: + ranking_choices.append( + { + "label": "Exact Scarf-default blacklist reference", + "status": "Reference evaluated", + "state": "reviewed", + "metrics": [ + "Executed set sizes: " + + ", ".join(f"{value:,}" for value in default_counts) + ], + "reason": ( + "Used as a fixed comparison reference, not as a selectable " + "ranking mode." ), } ) @@ -3743,6 +5030,13 @@ def _render_hvg_evidence(evidence: Mapping[str, Any]) -> str: ) if isinstance(evidence.get("excludedTechnicalGroupCount"), int) else None, + ( + "HVG branches executed", + f"{int(evidence['executedBranchCount']):,}", + "Global, group-aware, and exact Scarf-default reference branches.", + ) + if isinstance(evidence.get("executedBranchCount"), int) + else None, ] body = ( '

    Ranking method

    ' @@ -3760,13 +5054,15 @@ def _render_hvg_evidence(evidence: Mapping[str, Any]) -> str: outcome=outcome, introduction=( "The workflow first compared how genes were ranked, then compared three " - "registered set sizes. Selection favored signal that recurred across " - "libraries instead of variation driven by only a few libraries." + "registered set sizes. Selection combined recurrence, exact " + "Scarf-default overlap, technical association, and downstream " + "stability rather than using one metric alone." ), body=body, measurements=_render_evidence_measurements( [item for item in measurements if item is not None] ), + expanded=True, ) @@ -3777,11 +5073,14 @@ def _render_analysis_evidence(payload: Mapping[str, Any]) -> str: final = _mapping(workflow_result.get("finalAnalysis")) enrichment = _latest(reports, "data_enrichment") experimental = _latest(reports, "experimental_context") + parameter = _latest(reports, "parameter_tuning") + decisions = _mapping(payload.get("activeDecisions")) + inventories = _mappings(payload.get("defaultFeatureInventories")) panels = [ _render_filtering_evidence(experimental, plan), _render_covariate_evidence(experimental), - _render_normalization_evidence(enrichment, plan), - _render_batch_evidence(experimental, final), + _render_normalization_evidence(enrichment, plan, inventories), + _render_batch_evidence(experimental, parameter, final, decisions), _render_hvg_evidence(_mapping(payload.get("hvgEvidence"))), ] panels = [panel for panel in panels if panel] @@ -3880,6 +5179,167 @@ def _render_analysis_biology(biology: Mapping[str, Any]) -> str: """ +def _render_column_list(items: Sequence[str]) -> str: + if not items: + return '

    No matched feature names were recorded.

    ' + return '
      {}
    '.format( + "".join(f"
  • {html.escape(item)}
  • " for item in items) + ) + + +def _render_qc_technical_audit( + experimental: Mapping[str, Any], + plan: Mapping[str, Any], +) -> str: + profiles = _mappings(experimental.get("qcProfiles")) + if not profiles: + return "" + decision = _mapping(experimental.get("decision")) + cell_qc = _mapping(plan.get("cellQc")) + if not cell_qc: + cell_qc = _mapping(decision.get("cellQc")) + if not cell_qc: + cell_qc = _mapping(experimental.get("cellQc")) + selected = _selected_qc_profile(experimental, cell_qc) + selected_id = selected.get("profileId") + selected_name = selected.get("registeredProfile") + profile_rows = _qc_profile_rows(profiles) + for row, profile in zip(profile_rows, profiles, strict=True): + row["_selected"] = bool( + (selected_id and profile.get("profileId") == selected_id) + or ( + not selected_id + and selected_name + and profile.get("registeredProfile") == selected_name + ) + ) + selected_metrics = _qc_metric_rows(selected) + return f""" +
    +

    Cell QC audit

    +

    Selected and alternative filtering profiles, diagnostic flags, and every persisted cutoff are shown below. A cutoff in a retain-with-flags profile is diagnostic and did not remove cells.

    +

    Profile comparison

    {_table(profile_rows, columns=("profile", "scope", "active cells", "retained cells", "removed cells", "flags", "failed libraries"))}
    +

    Selected-profile metric summary

    {_table(selected_metrics, columns=("metric", "scope", "median", "diagnostic reference", "lower cutoff", "upper cutoff", "high flag", "flagged cells"))}
    +
    All global and per-library cutoffs{_table(_qc_bound_rows(profiles), columns=("profile", "group", "metric", "median", "lower removal", "upper removal", "upper flag"), empty="No persisted QC cutoffs were recorded.")}
    +
    +""" + + +def _render_feature_technical_audit( + plan: Mapping[str, Any], + inventories: Sequence[Mapping[str, Any]], +) -> str: + if not inventories: + return "" + policy_rows: list[dict[str, Any]] = [] + for assay_plan in _mappings(plan.get("assays")): + parameters = _mapping(assay_plan.get("featureParameters")) + if not parameters: + continue + policy_rows.append( + { + "assay": assay_plan.get("assay"), + "selected features": parameters.get("topN"), + "minimum detected cells": parameters.get("minCells"), + "excluded families": _text_values(parameters.get("excludeFamilies")), + "protected families": _text_values(parameters.get("protectFamilies")), + "complete default blacklist applied": ( + parameters.get("useScarfDefaultBlacklist") is True + ), + } + ) + inventory_markup: list[str] = [] + for inventory in inventories: + assay = _assay_label(inventory.get("assay")) or "Assay" + match_count = inventory.get("matchCount") + names = _text_values(inventory.get("matchedFeatures")) + inventory_markup.append( + _render_default_inventory_summary( + inventory, + heading=f"{assay} exact Scarf-default blacklist", + ) + + "
    All " + + ( + f"{int(match_count):,}" + if isinstance(match_count, int) + else f"{len(names):,}" + ) + + " matched feature names" + + _render_column_list(names) + + "
    " + ) + return f""" +
    +

    Normalization and feature-selection audit

    +

    The selected representation policy is separate from the exact Scarf-default blacklist reference. Genes excluded from map construction remained available to marker testing.

    + {_table(policy_rows, columns=("assay", "selected features", "minimum detected cells", "excluded families", "protected families", "complete default blacklist applied"))} + {"".join(inventory_markup)} +
    +""" + + +def _render_harmony_technical_audit( + experimental: Mapping[str, Any], + parameter: Mapping[str, Any], + final: Mapping[str, Any], + decisions: Mapping[str, Any], +) -> str: + native, harmony = _harmony_candidate_pair(parameter, final) + if not native and not harmony: + return "" + outcome = _active_decision(decisions, "correctionOutcome") + license_record = _active_decision(decisions, "correctionLicense") + rationale = str(outcome.get("rationale") or "").strip() + license_option = str(license_record.get("selectedOptionId") or "not recorded") + license_label = _label(license_option.rpartition(":")[2]) + run_status = ( + "completed" if _harmony_completed(harmony) else _scalar(harmony.get("status")) + ) + rationale_markup = ( + '

    Recorded rejection rationale

    ' + f"

    {html.escape(rationale)}

    " + if rationale + else "" + ) + candidate_rows = [ + { + "candidate": "Native", + "status": native.get("status"), + "eligible": native.get("eligible"), + **_mapping(native.get("parameters")), + }, + { + "candidate": "Harmony", + "status": harmony.get("status"), + "eligible": harmony.get("eligible"), + **_mapping(harmony.get("parameters")), + }, + ] + safety_rows: list[dict[str, Any]] = [] + for item in _mappings(experimental.get("batchSafety")): + estimability = _mapping(item.get("estimability")) + safety_rows.append( + { + "comparison": _public_field_label(item.get("coefficient")), + "status": item.get("status"), + "study units": estimability.get("rowsUsed"), + "technical rank": estimability.get("rankTechnical"), + "residual degrees of freedom": estimability.get("residualDf"), + "remaining capacity": estimability.get("estimableDf"), + } + ) + return f""" +
    +

    Harmony diagnostic audit

    +

    Run status: {html.escape(run_status)}. Selection license: {html.escape(license_label)}.

    +

    Matched candidates

    {_table(candidate_rows, columns=("candidate", "status", "eligible", "dimensions", "neighborsK", "leidenResolution", "useHarmony"))}
    +

    Native versus Harmony measurements

    {_table(_harmony_metric_rows(native, harmony), columns=("category", "metric", "native", "Harmony", "change", "interpretation"))}
    +

    Design safety

    {_table(safety_rows, columns=("comparison", "status", "study units", "technical rank", "residual degrees of freedom", "remaining capacity"), empty="No design-safety rows were recorded.")}
    + {rationale_markup} +
    +""" + + def _analysis_limitations(payload: Mapping[str, Any]) -> list[str]: reports = _mapping(payload.get("reports")) workflow_result = _mapping(payload.get("workflowResult")) @@ -4021,6 +5481,35 @@ def _render_analysis_document(payload: Mapping[str, Any]) -> str: "record the reason." ), ) + diagnostic_plot_order = ( + "qcDistributionsBeforeFiltering", + "qcDistributions", + *(name for name in plots if name.startswith("qcDistributionDerived")), + "hvgGlobal", + "hvgBatchAware", + ) + diagnostic_plots = ( + _render_plots( + plots, + (), + order=diagnostic_plot_order, + show_provenance=False, + show_notes=False, + ) + if any(name in plots for name in diagnostic_plot_order) + else "" + ) + diagnostic_section = ( + """ +
    +

    Quality control and variable-gene diagnostics

    +

    QC panels show the selected cutoff annotations. HVG panels highlight the genes retained by each executed ranking at the selected feature count.

    + {plots} +
    +""".format(plots=diagnostic_plots) + if diagnostic_plots + else "" + ) limitations = _analysis_limitations(payload) biology_markup = _render_analysis_biology(biology) body = f"""

    Analysis summary

    @@ -4050,6 +5539,8 @@ def _render_analysis_document(payload: Mapping[str, Any]) -> str: {decision_evidence} + {diagnostic_section} +

    Why the final result was selected

    These are the main measurements supporting the final cell map. Values closer to 1 indicate stronger agreement for the stability and coherence measures.

    @@ -4090,6 +5581,8 @@ def _render_technical_document(payload: Mapping[str, Any]) -> str: experimental = _latest(reports, "experimental_context") parameter = _latest(reports, "parameter_tuning") biology = _latest(reports, "biological_interpretation") + decisions = _mapping(payload.get("activeDecisions")) + inventories = _mappings(payload.get("defaultFeatureInventories")) cluster_counts = { str(key): int(value) for key, value in _mapping(payload.get("clusterCounts")).items() @@ -4107,6 +5600,8 @@ def _render_technical_document(payload: Mapping[str, Any]) -> str: total_cells = sum(cluster_counts.values()) assay_plans = _mappings(plan.get("assays")) assays = [str(item.get("assay")) for item in assay_plans if item.get("assay")] + doublet_evidence = _mapping(final.get("doubletEvidence")) + marker_evidence = _mapping(final.get("markerEvidence")) metrics = [ ("Final cells", total_cells or None), ("Final clusters", len(cluster_counts) or None), @@ -4114,6 +5609,12 @@ def _render_technical_document(payload: Mapping[str, Any]) -> str: ("Candidates", parameter.get("totalCandidates")), ("Selected graph", final.get("graphMethod")), ("Marker assay", final.get("markerAssay")), + ("Marker specificity", marker_evidence.get("specificityMedian")), + ("Doublet capture coverage", doublet_evidence.get("captureCoverage")), + ( + "Statistical test artifacts", + len(_mappings(final.get("statisticalTests"))) or None, + ), ] metric_markup = _render_metrics(metrics) interpretation = { @@ -4129,6 +5630,7 @@ def _render_technical_document(payload: Mapping[str, Any]) -> str: "status": enrichment.get("status"), "policies": enrichment.get("policies"), "inspections": enrichment.get("inspections"), + "defaultFeatureEvidence": enrichment.get("defaultFeatureEvidence"), "evidenceIds": enrichment.get("evidenceIds"), "unresolvedQuestions": enrichment.get("unresolvedQuestions"), } @@ -4139,6 +5641,7 @@ def _render_technical_document(payload: Mapping[str, Any]) -> str: "qcProfiles": experimental.get("qcProfiles"), "batchSafety": experimental.get("batchSafety"), "characterization": experimental.get("characterization"), + "contrastPlans": experimental.get("contrastPlans"), } preprocessing_summary = { "primaryAssay": plan.get("primaryAssay"), @@ -4198,6 +5701,25 @@ def _render_technical_document(payload: Mapping[str, Any]) -> str: if biology else "" ) + qc_audit = _render_qc_technical_audit(experimental, plan) + feature_audit = _render_feature_technical_audit(plan, inventories) + harmony_audit = _render_harmony_technical_audit( + experimental, + parameter, + final, + decisions, + ) + qc_nav = 'QC' if qc_audit else "" + feature_nav = ( + 'Features' + if feature_audit + else "" + ) + harmony_nav = ( + 'Harmony' + if harmony_audit + else "" + ) title = f"Scarf agent report {workflow_id}" body = f"""

    Technical report

    Evidence from an automated analysis.

    @@ -4211,6 +5733,9 @@ def _render_technical_document(payload: Mapping[str, Any]) -> str: Visual results {biology_nav} Context + {qc_nav} + {feature_nav} + {harmony_nav} Tuning Workflow @@ -4229,16 +5754,23 @@ def _render_technical_document(payload: Mapping[str, Any]) -> str:

    Final partition evidence

    Final cluster sizes

    {_render_clusters(cluster_counts)}

    Top marker evidence

    {_table(top_markers, columns=marker_columns, empty="No marker table was available.")}
    +

    Marker-family summary

    {_value(marker_evidence)}
    +

    Advisory doublet summary

    {_value(doublet_evidence)}
    {biology_markup} + {qc_audit} + {feature_audit} + {harmony_audit} +

    Study context

    {_value(study)}

    Data enrichment

    {_value(enrichment_summary)}

    Experimental design

    {_value(experimental_summary)}

    Preprocessing plan

    {_value(preprocessing_summary)}

    Parameter tuning and graph selection

    {_render_parameter_tuning(parameter)}
    +

    Bounded analysis review and hypothesis tests

    {_value({"analysisEvidence": final.get("analysisEvidence"), "statisticalTests": final.get("statisticalTests")})}

    Workflow execution

    {_render_timeline(attempts, resumes)}

    Agent execution

    {_render_executions(reports)}

    Limitations and workflow notes

    {_value(limitations) if limitations else '

    No limitations were recorded.

    '}
    @@ -4313,21 +5845,40 @@ def generate_agent_report( raise ValueError("Agent report path resolves outside the analysis store") plot_dir = report_dir / "plots" report_dir.mkdir(parents=True, exist_ok=True) - cluster_counts, top_markers, plot_files, plot_notes = _collect_final_artifacts( - store, - result, - plot_dir, - ) preprocessing_plan = ( result.preprocessingPlan.model_dump(mode="json") if result.preprocessingPlan is not None else {} ) + experimental = _latest(reports, "experimental_context") + selected_qc_profile = _selected_qc_profile( + experimental, + _mapping(preprocessing_plan.get("cellQc")), + ) + cluster_counts, top_markers, plot_files, plot_notes = _collect_final_artifacts( + store, + result, + plot_dir, + qc_profile=selected_qc_profile, + ) hvg_evidence = _collect_hvg_evidence( store, stage_attempts, preprocessing_plan, ) + hvg_plots, hvg_plot_notes = _collect_hvg_plots( + store, + stage_attempts, + preprocessing_plan, + plot_dir, + ) + plot_files.update(hvg_plots) + plot_notes.extend(hvg_plot_notes) + active_decisions = _collect_active_decisions(store, workflow_run_id) + default_feature_inventories = _collect_default_feature_inventories( + store, + preprocessing_plan, + ) payload: dict[str, Any] = { "status": result.status, "currentStage": result.currentStage, @@ -4344,6 +5895,8 @@ def generate_agent_report( "plotFiles": plot_files, "plotNotes": plot_notes, "hvgEvidence": hvg_evidence, + "activeDecisions": active_decisions, + "defaultFeatureInventories": default_feature_inventories, } documents = ( ("analysis.html", _render_analysis_document(payload)), diff --git a/scarf/agent/rna_decisions.py b/scarf/agent/rna_decisions.py index c1820ef6..99d6b9da 100644 --- a/scarf/agent/rna_decisions.py +++ b/scarf/agent/rna_decisions.py @@ -56,7 +56,10 @@ type ConditionalGeneFamily = Literal[ "mitochondrial", "ribosomal", + "mitoribosomal", "histone", + "hla", + "h2", "hemoglobin", "immuneReceptor", "cellCycle", @@ -162,17 +165,33 @@ class FeaturePolicyExecutorPayload(RnaRegistryModel): """Exact conditional family policy for representation features.""" operation: Literal["featurePolicy"] = "featurePolicy" - policy: Literal["keepAll", "excludeEligibleBundle"] + policy: Literal[ + "keepAll", + "excludeScarfDefaults", + "excludeEligibleBundle", + ] excludedFamilies: list[ConditionalGeneFamily] = Field(default_factory=list) + useScarfDefaultBlacklist: bool = Field(default=False, strict=True) @model_validator(mode="after") def validate_policy(self) -> "FeaturePolicyExecutorPayload": if len(self.excludedFamilies) != len(set(self.excludedFamilies)): raise ValueError("excludedFamilies must not contain duplicates") - if self.policy == "keepAll" and self.excludedFamilies: + if self.policy == "keepAll" and ( + self.excludedFamilies or self.useScarfDefaultBlacklist + ): raise ValueError("keepAll cannot exclude gene families") + if self.policy == "excludeScarfDefaults": + if self.excludedFamilies or not self.useScarfDefaultBlacklist: + raise ValueError( + "excludeScarfDefaults requires only the Scarf default blacklist" + ) if self.policy == "excludeEligibleBundle" and not self.excludedFamilies: raise ValueError("excludeEligibleBundle requires gene families") + if self.policy == "excludeEligibleBundle" and self.useScarfDefaultBlacklist: + raise ValueError( + "excludeEligibleBundle cannot silently add the Scarf defaults" + ) return self @@ -919,8 +938,9 @@ def build_feature_policy_decision( proposed_exclusion_families: list[ConditionalGeneFamily], dominant_families: list[ConditionalGeneFamily], protected_families: list[ConditionalGeneFamily], + scarf_default_eligible: bool = False, ) -> RnaDecisionDefinition: - """Build keep-all plus at most one licensed conditional exclusion bundle.""" + """Build exact representation-only feature-policy alternatives.""" for field_name, values in ( ("proposed_exclusion_families", proposed_exclusion_families), ("dominant_families", dominant_families), @@ -956,6 +976,29 @@ def build_feature_policy_decision( payload=FeaturePolicyExecutorPayload(policy="keepAll", excludedFamilies=[]), ) ] + if scarf_default_eligible: + visible.append( + DecisionOption( + optionId="featurePolicy:excludeScarfDefaults", + status="apply", + label="Use the Scarf default blacklist", + description=( + "Exclude the exact core Scarf default blacklist from " + "representation only." + ), + requiredEvidenceClasses=["technical"], + ) + ) + executor.append( + RnaExecutorOption( + checkpoint="featurePolicy", + optionId="featurePolicy:excludeScarfDefaults", + payload=FeaturePolicyExecutorPayload( + policy="excludeScarfDefaults", + useScarfDefaultBlacklist=True, + ), + ) + ) if proposed_exclusion_families: visible.append( DecisionOption( diff --git a/scarf/agent/sequential_tuning.py b/scarf/agent/sequential_tuning.py index 58cf0a5f..40e0e112 100644 --- a/scarf/agent/sequential_tuning.py +++ b/scarf/agent/sequential_tuning.py @@ -16,12 +16,19 @@ from .parameter_tuning import ( ParameterCandidate, ParameterCandidateEvaluation, + ParameterSearchPlan, + ParameterTuningDependencies, ParameterTuningNeedsInput, ParameterTuningReport, + annotate_candidate_dominance, execute_parameter_candidate, + execute_parameter_search_plan, finalize_parameter_tuning_selection, prepare_parameter_tuning_dependencies, + require_dominated_candidate_evidence, + validate_parameter_search_plan, ) +from .tools import core_artifact_reference from .types import AgentDataModel, ExperimentalTuningHandoff @@ -203,6 +210,16 @@ def validate_execution_and_selection(self) -> "ParameterPhaseEvidence": raise ValueError( "A selected phase must cite evidence from its selected candidate" ) + if self.plan.phase in {"graphK", "clusteringResolution"}: + dominance_evaluations = { + evaluation.candidateId: evaluation + for evaluation in annotate_candidate_dominance(self.evaluations) + } + require_dominated_candidate_evidence( + dominance_evaluations[selected.candidateId], + self.selection.evidenceIds, + context=f"The {self.plan.phase} selection", + ) if self.selection.status == "abstained" and self.plan.phase != ( "clusteringResolution" ): @@ -371,6 +388,64 @@ def validate_phase_lineage(self) -> "SequentialAssayTuningEvidence": return self +class SequentialRefinementResult(SequentialTuningModel): + """One validated post-grid refinement disposition and optional execution.""" + + plan: ParameterSearchPlan + evaluation: ParameterCandidateEvaluation | None = None + + @model_validator(mode="after") + def validate_refinement_result(self) -> "SequentialRefinementResult": + if ( + not self.plan.basedOnCandidateIds + or not self.plan.evidenceIds + or not self.plan.rationale.strip() + or not self.plan.stoppingCriteria + ): + raise ValueError( + "Sequential refinement results require parents, evidence, " + "rationale, and stopping criteria" + ) + if self.plan.status == "complete": + if self.plan.candidates or self.evaluation is not None: + raise ValueError( + "A complete refinement review cannot contain an execution" + ) + return self + if len(self.plan.candidates) != 1 or self.evaluation is None: + raise ValueError( + "A refinement review must contain exactly one candidate execution" + ) + candidate = self.plan.candidates[0] + if ( + self.evaluation.candidateId != candidate.candidateId + or self.evaluation.parameters != candidate + or self.evaluation.phase != "refined" + ): + raise ValueError("Refinement execution does not match its validated plan") + return self + + +class SequentialRefinementSelection(SequentialTuningModel): + """Explicit final choice after an optional refinement execution.""" + + selectedCandidateId: str + evidenceIds: list[str] = Field(min_length=1) + rationale: str = Field(min_length=1, max_length=4000) + + @model_validator(mode="after") + def validate_selection(self) -> "SequentialRefinementSelection": + if _CANDIDATE_ID.fullmatch(self.selectedCandidateId) is None: + raise ValueError("selectedCandidateId is not a stable candidate ID") + if len(self.evidenceIds) != len(set(self.evidenceIds)): + raise ValueError("evidenceIds must not contain duplicates") + if any(not value for value in self.evidenceIds): + raise ValueError("evidenceIds must contain non-empty values") + if self.rationale != self.rationale.strip(): + raise ValueError("rationale must not contain surrounding whitespace") + return self + + class SequentialRnaTuningPlanner: """Construct fixed, rank-capped candidates for four causal RNA phases.""" @@ -574,7 +649,7 @@ def validate_parameter_phase_selection( """Validate an ID-only selection against complete executor evidence.""" return ParameterPhaseEvidence( plan=plan, - evaluations=list(evaluations), + evaluations=list(annotate_candidate_dominance(evaluations)), selection=selection, ) @@ -606,16 +681,242 @@ def execute_parameter_phase( expected_ids = tuple(candidate.candidateId for candidate in plan.candidates) if tuple(candidate_ids) != expected_ids: raise ValueError("Prepared executor candidate inventory changed the phase plan") - return tuple(execute_parameter_candidate(deps, value) for value in candidate_ids) + return annotate_candidate_dominance( + tuple(execute_parameter_candidate(deps, value) for value in candidate_ids) + ) + + +def _sequential_candidate_evaluations( + evidence: SequentialAssayTuningEvidence, +) -> tuple[ParameterCandidateEvaluation, ...]: + if evidence.finalCandidateId is None: + raise ValueError("Sequential refinement requires complete grid evidence") + evaluations = tuple( + evaluation for phase in evidence.phases for evaluation in phase.evaluations + ) + if not evaluations: + raise ValueError("Sequential refinement requires executed grid candidates") + candidate_ids = [evaluation.candidateId for evaluation in evaluations] + if len(candidate_ids) != len(set(candidate_ids)): + raise ValueError("Sequential grid candidate IDs must be unique") + if evidence.finalCandidateId not in set(candidate_ids): + raise ValueError("Sequential final candidate is not present in grid evidence") + return evaluations + + +def prepare_sequential_refinement_dependencies( + store: Any, + *, + normalized: Any, + evidence: SequentialAssayTuningEvidence, + batch_columns: Sequence[str] = (), + preservation_columns: Sequence[str] = (), + experimental_handoff: ExperimentalTuningHandoff | None = None, + min_cluster_cells: int = 20, + identity_feature_limit: int = 64, +) -> tuple[ParameterTuningDependencies, list[str]]: + """Prepare one refinement executor from already executed sequential candidates.""" + + evaluations = _sequential_candidate_evaluations(evidence) + candidates = [evaluation.parameters for evaluation in evaluations] + deps, candidate_ids = prepare_parameter_tuning_dependencies( + store, + normalized=normalized, + candidates=candidates, + batch_columns=batch_columns, + preservation_columns=preservation_columns, + experimental_handoff=experimental_handoff, + max_candidates=len(candidates), + max_refined_candidates=1, + min_cluster_cells=min_cluster_cells, + identity_feature_limit=identity_feature_limit, + pair_harmony_candidates=False, + ) + expected_ids = [candidate.candidateId for candidate in candidates] + if candidate_ids != expected_ids: + raise ValueError("Prepared refinement inventory changed the grid candidates") + for evaluation in evaluations: + if ( + evaluation.status == "done" + and core_artifact_reference(evaluation.cellSelection) != deps.cellSelection + ): + raise ValueError( + "Sequential grid evaluation does not match the normalized cell axis" + ) + deps.evaluations = { + evaluation.candidateId: evaluation for evaluation in evaluations + } + deps.executionOrder = list(candidate_ids) + return deps, candidate_ids + + +def _changed_refinement_parameters( + candidate: ParameterCandidate, + parent: ParameterCandidate, +) -> tuple[str, ...]: + tunable_fields = ( + "reductionMethod", + "dimensions", + "neighborsK", + "leidenResolution", + "useHarmony", + ) + return tuple( + field_name + for field_name in tunable_fields + if getattr(candidate, field_name) != getattr(parent, field_name) + ) + + +def validate_sequential_refinement_plan( + plan: ParameterSearchPlan, + deps: ParameterTuningDependencies, + initial_candidate_ids: Sequence[str], +) -> ParameterSearchPlan: + """Validate a no-refinement decision or one bounded sequential candidate.""" + + initial_ids = tuple(initial_candidate_ids) + if not initial_ids or len(initial_ids) != len(set(initial_ids)): + raise ValueError("Sequential refinement requires unique grid candidate IDs") + if set(deps.evaluations) != set(initial_ids): + raise ValueError("Refinement dependencies do not match the executed grid") + eligible_ids = { + candidate_id + for candidate_id in initial_ids + if deps.evaluations[candidate_id].status == "done" + and deps.evaluations[candidate_id].eligible + } + if not eligible_ids: + raise ValueError("Sequential refinement requires an eligible successful parent") + + validated = validate_parameter_search_plan( + plan, + deps, + initial_candidate_ids=initial_ids, + max_refined_candidates=1, + ) + parent_ids = tuple(validated.basedOnCandidateIds) + if not parent_ids: + raise ValueError("Sequential refinement must name its successful parent") + if len(parent_ids) != len(set(parent_ids)): + raise ValueError("Sequential refinement parent IDs must be unique") + if any(parent_id not in eligible_ids for parent_id in parent_ids): + raise ValueError( + "Sequential refinement parents must be eligible successful grid candidates" + ) + if any( + not any( + evidence_id.startswith(f"candidate:{parent_id}:") + for evidence_id in validated.evidenceIds + ) + for parent_id in parent_ids + ): + raise ValueError("Sequential refinement must cite each parent candidate") + + if validated.status == "complete": + if not validated.evidenceIds: + raise ValueError("A no-refinement decision requires observed evidence") + if not validated.rationale.strip(): + raise ValueError("A no-refinement decision requires a rationale") + if not validated.stoppingCriteria: + raise ValueError("A no-refinement decision requires a stopping criterion") + return validated + + candidate = validated.candidates[0] + if candidate.useHarmony: + raise ValueError( + "One-candidate sequential refinement cannot evaluate Harmony because " + "acceptance requires a newly parameter-matched native control" + ) + parent_changes = { + parent_id: _changed_refinement_parameters( + candidate, + deps.evaluations[parent_id].parameters, + ) + for parent_id in parent_ids + } + if not any( + len(changes) == 1 + and changes[0] in {"dimensions", "neighborsK", "leidenResolution"} + for changes in parent_changes.values() + ): + raise ValueError( + "The refinement candidate must vary one numeric parameter from a " + "cited parent" + ) + matched_mode = [ + deps.evaluations[candidate_id].parameters + for candidate_id in initial_ids + if ( + deps.evaluations[candidate_id].parameters.reductionMethod + == candidate.reductionMethod + and deps.evaluations[candidate_id].parameters.useHarmony + == candidate.useHarmony + ) + ] + for field_name in ("dimensions", "neighborsK", "leidenResolution"): + observed = [getattr(value, field_name) for value in matched_mode] + if not min(observed) <= getattr(candidate, field_name) <= max(observed): + raise ValueError( + f"Refined {field_name} must remain inside its observed " + "correction-mode envelope" + ) + if not validated.objectives: + raise ValueError("A refinement candidate requires an evidence-based objective") + return validated + + +def execute_sequential_refinement( + deps: ParameterTuningDependencies, + plan: ParameterSearchPlan, + initial_candidate_ids: Sequence[str], +) -> SequentialRefinementResult: + """Execute at most one validated post-grid sequential candidate.""" + + validated = validate_sequential_refinement_plan( + plan, + deps, + initial_candidate_ids, + ) + validated, evaluations = execute_parameter_search_plan( + deps, + validated, + initial_candidate_ids=initial_candidate_ids, + max_refined_candidates=1, + ) + return SequentialRefinementResult( + plan=validated, + evaluation=evaluations[0] if evaluations else None, + ) + + +def sequential_refinement_selection_candidates( + evidence: SequentialAssayTuningEvidence, + refinement: SequentialRefinementResult, +) -> tuple[ParameterCandidateEvaluation, ...]: + """Return the grid and optional refinement as one final-selection inventory.""" + + evaluations = list(_sequential_candidate_evaluations(evidence)) + if refinement.evaluation is not None: + if refinement.evaluation.candidateId in { + evaluation.candidateId for evaluation in evaluations + }: + raise ValueError("Refinement candidate duplicates a grid candidate ID") + evaluations.append(refinement.evaluation) + return annotate_candidate_dominance(evaluations) def sequential_evidence_to_report( evidence: SequentialAssayTuningEvidence, *, marker_assay: str | None = None, + refinement: SequentialRefinementResult | None = None, + refinement_selection: SequentialRefinementSelection | None = None, ) -> ParameterTuningReport: """Adapt four selected phases to the report consumed by finalization.""" if evidence.finalCandidateId is None: + if refinement is not None or refinement_selection is not None: + raise ValueError("Post-grid refinement requires four selected phases") final_phase = evidence.phases[-1] cell_selection = next( ( @@ -666,9 +967,94 @@ def sequential_evidence_to_report( final_phase = evidence.phases[-1] selected = final_phase.selected_evaluation() assert selected is not None - evaluations = [ - evaluation for phase in evidence.phases for evaluation in phase.evaluations - ] + phase_selected = selected + phase_evidence_ids = list( + dict.fromkeys( + evidence_id + for value in evidence.phases + for evidence_id in value.selection.evidenceIds + ) + ) + evaluations = list(_sequential_candidate_evaluations(evidence)) + search_plan: ParameterSearchPlan | None = None + rationale = " ".join(value.selection.rationale for value in evidence.phases) + stop_reason = "Four causal RNA parameter phases were selected." + evidence_ids = phase_evidence_ids + if refinement is not None: + search_plan = refinement.plan + evaluations = list( + sequential_refinement_selection_candidates(evidence, refinement) + ) + evidence_ids = list( + dict.fromkeys([*phase_evidence_ids, *refinement.plan.evidenceIds]) + ) + rationale = f"{rationale} {refinement.plan.rationale}" + if refinement.evaluation is None: + if refinement_selection is not None: + raise ValueError( + "A no-refinement result cannot have a refinement selection" + ) + stop_reason = "The bounded post-grid review found no justified refinement." + else: + if refinement_selection is None: + raise ValueError( + "Executed refinement requires an explicit final selection" + ) + by_id = {evaluation.candidateId: evaluation for evaluation in evaluations} + selected = by_id.get(refinement_selection.selectedCandidateId) + if selected is None: + raise ValueError("Refinement selection references an unknown candidate") + if selected.status != "done" or not selected.eligible: + raise ValueError( + "Refinement selection must choose an eligible execution" + ) + if core_artifact_reference( + selected.cellSelection + ) != core_artifact_reference(phase_selected.cellSelection): + raise ValueError("Refinement selection changed the exact cell axis") + known_evidence = { + evidence_id + for evaluation in evaluations + for evidence_id in evaluation.evidenceIds + } + unknown_evidence = sorted( + set(refinement_selection.evidenceIds) - known_evidence + ) + if unknown_evidence: + raise ValueError( + f"Refinement selection cites unknown evidence {unknown_evidence}" + ) + prefix = f"candidate:{selected.candidateId}:" + if not any( + evidence_id.startswith(prefix) + for evidence_id in refinement_selection.evidenceIds + ): + raise ValueError( + "Refinement selection must cite its selected candidate" + ) + graph_partition_dominators = [ + candidate_id + for candidate_id in selected.metrics.dominatedByCandidateIds + if candidate_id in by_id + and _changed_refinement_parameters( + selected.parameters, + by_id[candidate_id].parameters, + ) + in {("neighborsK",), ("leidenResolution",)} + ] + if graph_partition_dominators: + require_dominated_candidate_evidence( + selected, + refinement_selection.evidenceIds, + context="The post-grid selection", + ) + evidence_ids = list( + dict.fromkeys([*evidence_ids, *refinement_selection.evidenceIds]) + ) + rationale = f"{rationale} {refinement_selection.rationale}" + stop_reason = "One bounded post-grid refinement was adjudicated." + elif refinement_selection is not None: + raise ValueError("Refinement selection requires a refinement result") assay_report = ParameterTuningReport( status="done", fromAssay=evidence.assay, @@ -677,16 +1063,11 @@ def sequential_evidence_to_report( recommendedCandidateId=selected.candidateId, selectedArtifacts=dict(selected.artifacts), confidence="medium", - rationale=" ".join(value.selection.rationale for value in evidence.phases), - evidenceIds=list( - dict.fromkeys( - evidence_id - for value in evidence.phases - for evidence_id in value.selection.evidenceIds - ) - ), + rationale=rationale, + evidenceIds=evidence_ids, limitations=[], - stopReason="Four causal RNA parameter phases were selected.", + stopReason=stop_reason, + searchPlan=search_plan, recommendedByAssay={evidence.assay: selected.candidateId}, totalCandidates=len(evaluations), ) @@ -703,11 +1084,17 @@ def sequential_evidence_to_report( __all__ = [ "CorrectionNeedSelection", "execute_parameter_phase", + "execute_sequential_refinement", "ParameterPhaseEvidence", "ParameterPhasePlan", "ParameterPhaseSelection", + "prepare_sequential_refinement_dependencies", "SequentialAssayTuningEvidence", + "SequentialRefinementResult", + "SequentialRefinementSelection", "SequentialRnaTuningPlanner", "sequential_evidence_to_report", + "sequential_refinement_selection_candidates", "validate_parameter_phase_selection", + "validate_sequential_refinement_plan", ] diff --git a/scarf/agent/tuning_diagnostics.py b/scarf/agent/tuning_diagnostics.py index 8d002ee0..02637cd7 100644 --- a/scarf/agent/tuning_diagnostics.py +++ b/scarf/agent/tuning_diagnostics.py @@ -1,7 +1,7 @@ """Deterministic representation and partition evidence for RNA decisions.""" from collections.abc import Mapping, Sequence -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any, cast import numpy as np @@ -9,6 +9,12 @@ from ..clustering.leiden import leiden_membership from ..metadata.rows import read_metadata_rows_chunkwise +from ..quality_control.cell_cycle_genes import ( + g2m_phase_genes, + g2m_phase_genes_mouse, + s_phase_genes, + s_phase_genes_mouse, +) from ..storage.arrays import create_zarr_dataset from ..storage.artifact_writer import ( ArrayRequirement, @@ -25,16 +31,29 @@ from .parameter_tuning import ( ArtifactRecord, ParameterCandidateEvaluation, + annotate_candidate_dominance, ) _PCA_DIAGNOSTIC_ARRAYS = ( "component_variance", + "explained_variance_ratio", "top_loading_feature_indices", "top_loading_values", "family_enrichment", "covariate_association", "adjacent_neighbor_overlap", ) +_MAX_DOUBLET_CAPTURES = 256 +SCARF_DEFAULT_DIAGNOSTIC_FAMILIES = ( + "mitochondrial", + "ribosomal", + "mitoribosomal", + "cellCycleCcn", + "hla", + "h2", + "histone", + "sexLinked", +) @dataclass(frozen=True, slots=True) @@ -45,6 +64,11 @@ class AdvisoryDoubletScores: cell_selections: tuple[ArtifactRef, ...] native_graph: ArtifactRef native_clusters: ArtifactRef + capture_values: tuple[str, ...] = () + score_summaries: tuple[dict[str, float], ...] = () + score_quantiles: Mapping[str, float] = field(default_factory=dict) + capture_coverage: float | None = None + capture_column: str | None = None limitations: tuple[str, ...] = () @@ -95,18 +119,56 @@ def _family_mask(names: np.ndarray, family: str) -> np.ndarray | None: upper = np.char.upper(names.astype(str)) if family == "mitochondrial": return np.char.startswith(upper, "MT-") - if family == "ribosomal": + if family in {"ribosomal", "ribosomalProtein"}: return np.asarray( np.logical_or.reduce( - [ - np.char.startswith(upper, prefix) - for prefix in ("RPS", "RPL", "MRPS", "MRPL") - ] + [np.char.startswith(upper, prefix) for prefix in ("RPS", "RPL")] + ), + dtype=bool, + ) + if family == "mitoribosomal": + return np.asarray( + np.logical_or.reduce( + [np.char.startswith(upper, prefix) for prefix in ("MRPS", "MRPL")] ), dtype=bool, ) + if family == "cellCycleCcn": + return np.char.startswith(upper, "CCN") + if family == "cellCycle": + cycle_genes = { + *s_phase_genes, + *g2m_phase_genes, + *s_phase_genes_mouse, + *g2m_phase_genes_mouse, + } + return np.asarray( + np.char.startswith(upper, "CCN") + | np.isin(upper, [value.upper() for value in cycle_genes]), + dtype=bool, + ) + if family in {"hla", "HLA"}: + return np.char.startswith(upper, "HLA-") + if family in {"h2", "H2"}: + return np.char.startswith(upper, "H2-") if family == "histone": return np.char.startswith(upper, "HIST") + if family in {"sex", "sexLinked"}: + return np.isin( + upper, + [ + "XIST", + "DDX3Y", + "USP9Y", + "EIF1AY", + "KDM5D", + "SRY", + "ZFY", + "UTY", + "TMSB4Y", + "NLGN4Y", + ], + ) if family == "hemoglobin": return np.char.startswith(upper, "HB") if family == "immuneReceptor": @@ -119,6 +181,34 @@ def _family_mask(names: np.ndarray, family: str) -> np.ndarray | None: ), dtype=bool, ) + if family == "stress": + return np.asarray( + np.logical_or.reduce( + [ + np.char.startswith(upper, prefix) + for prefix in ("FOS", "JUN", "HSP", "DUSP", "EGR") + ] + ), + dtype=bool, + ) + if family == "dissociation": + return np.isin( + upper, + [ + "ATF3", + "BTG1", + "BTG2", + "DUSP1", + "EGR1", + "FOS", + "FOSB", + "IER2", + "JUN", + "JUNB", + "JUND", + "ZFP36", + ], + ) return None @@ -132,35 +222,137 @@ def _component_variance(values: Any) -> np.ndarray: totals_squared = np.zeros(n_components, dtype=np.float64) for start in range(0, n_rows, 65_536): block = np.asarray(values[start : start + 65_536], dtype=np.float64) + if not np.isfinite(block).all(): + raise ValueError("PCA coordinates must be finite") totals += block.sum(axis=0) totals_squared += np.square(block).sum(axis=0) variance = totals_squared / n_rows - np.square(totals / n_rows) return np.asarray(np.maximum(variance, 0.0), dtype=np.float64) +def _scaled_total_variance( + store: Any, + reduction_status: Any, + *, + n_rows: int, + n_features: int, + feature_selection: ArtifactRef, +) -> float: + def input_ref(value: Any, label: str) -> ArtifactRef: + if isinstance(value, ArtifactRef): + return value + if isinstance(value, Mapping): + return ArtifactRef.from_dict(dict(value)) + raise ValueError(f"Candidate PCA lacks its {label} input") + + inputs = getattr(reduction_status, "inputs", None) or {} + normalized_ref = input_ref(inputs.get("normalized"), "normalized matrix") + pca_cell_selection = input_ref( + inputs.get("pca_cell_selection"), + "PCA cell-selection", + ) + normalized_status = store.inspect_artifact(normalized_ref) + normalized_inputs = getattr(normalized_status, "inputs", None) or {} + normalized_cell_selection = input_ref( + normalized_inputs.get("cell_selection"), + "normalized cell-selection", + ) + normalized_feature_selection = input_ref( + normalized_inputs.get("feature_selection"), + "normalized feature-selection", + ) + if pca_cell_selection != normalized_cell_selection: + raise ValueError( + "Explained-variance ratios require PCA fitted on all normalized cells" + ) + if normalized_feature_selection != feature_selection: + raise ValueError("PCA loading genes do not match the normalized features") + normalized_group = store.load_artifact(normalized_ref) + normalized = as_zarr_array(normalized_group["data"], name="data") + if normalized.shape != (n_rows, n_features): + raise ValueError("Candidate PCA and normalized matrix shapes do not align") + if "feature_sum" in normalized_group and "feature_squared_sum" in normalized_group: + totals = np.asarray( + as_zarr_array(normalized_group["feature_sum"], name="feature_sum")[:], + dtype=np.float64, + ) + totals_squared = np.asarray( + as_zarr_array( + normalized_group["feature_squared_sum"], + name="feature_squared_sum", + )[:], + dtype=np.float64, + ) + else: + totals = np.zeros(n_features, dtype=np.float64) + totals_squared = np.zeros(n_features, dtype=np.float64) + for start in range(0, n_rows, 8192): + block = np.asarray( + normalized[start : start + 8192], + dtype=np.float64, + ) + if not np.isfinite(block).all(): + raise ValueError("Normalized PCA input must be finite") + totals += block.sum(axis=0) + totals_squared += np.square(block).sum(axis=0) + if totals.shape != (n_features,) or totals_squared.shape != (n_features,): + raise ValueError("Normalized PCA feature summaries do not align") + variance = np.maximum( + totals_squared / n_rows - np.square(totals / n_rows), + 0.0, + ) + total_scaled_variance = float(np.count_nonzero(variance)) + if total_scaled_variance <= 0: + raise ValueError("Feature-scaled PCA input has no non-constant features") + return total_scaled_variance + + def _top_loadings( - loadings: np.ndarray, + loadings: Any, selected_indices: np.ndarray, family_masks: Mapping[str, np.ndarray], ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - if loadings.ndim != 2 or loadings.shape[0] != len(selected_indices): + if len(loadings.shape) != 2 or loadings.shape[0] != len(selected_indices): raise ValueError("PCA loadings do not align with selected features") + if loadings.shape[0] < 1 or loadings.shape[1] < 1: + raise ValueError("PCA loadings cannot be empty") + if any(mask.shape != selected_indices.shape for mask in family_masks.values()): + raise ValueError("PCA family masks must align with selected features") top_n = min(20, loadings.shape[0]) - top_indices = np.zeros((loadings.shape[1], top_n), dtype=np.int64) - top_values = np.zeros((loadings.shape[1], top_n), dtype=np.float64) + top_rows = np.empty((loadings.shape[1], 0), dtype=np.int64) + top_values = np.empty((loadings.shape[1], 0), dtype=np.float64) + for start in range(0, loadings.shape[0], 8192): + block = np.abs(np.asarray(loadings[start : start + 8192], dtype=np.float64)) + if not np.isfinite(block).all(): + raise ValueError("PCA loadings must be finite") + block_rows = np.arange(start, start + len(block), dtype=np.int64) + retained_rows = np.empty( + (loadings.shape[1], min(top_n, top_rows.shape[1] + len(block))), + dtype=np.int64, + ) + retained_values = np.empty(retained_rows.shape, dtype=np.float64) + for component in range(loadings.shape[1]): + candidate_rows = np.concatenate((top_rows[component], block_rows)) + candidate_values = np.concatenate( + (top_values[component], block[:, component]) + ) + order = np.lexsort((candidate_rows, -candidate_values))[:top_n] + retained_rows[component] = candidate_rows[order] + retained_values[component] = candidate_values[order] + top_rows = retained_rows + top_values = retained_values + top_indices = selected_indices[top_rows] enrichment = np.zeros( (len(family_masks), loadings.shape[1]), dtype=np.float64, ) - for component in range(loadings.shape[1]): - absolute = np.abs(loadings[:, component]) - order = np.lexsort((np.arange(len(absolute)), -absolute))[:top_n] - top_indices[component] = selected_indices[order] - top_values[component] = absolute[order] - for family_index, mask in enumerate(family_masks.values()): + for family_index, mask in enumerate(family_masks.values()): + for component in range(loadings.shape[1]): background = float(mask.mean()) enrichment[family_index, component] = ( - float(mask[order].mean()) / background if background > 0 else 0.0 + float(mask[top_rows[component]].mean()) / background + if background > 0 + else 0.0 ) return top_indices, top_values, enrichment @@ -259,11 +451,18 @@ def _covariate_associations( cell_selection: ArtifactRef, coordinates: Any, columns: Sequence[str], + roles: Sequence[str], ) -> np.ndarray: + if len(columns) != len(roles): + raise ValueError("PCA covariate columns and roles must align") associations = np.zeros((len(columns), coordinates.shape[1]), dtype=np.float64) - for index, column in enumerate(columns): + for index, (column, role) in enumerate(zip(columns, roles, strict=True)): values = _aligned_metadata_values(store, cell_selection, column) - if values.dtype.kind in {"i", "u", "f"} and len(np.unique(values)) > 10: + if ( + role == "qc" + and values.dtype.kind in {"i", "u", "f"} + and len(np.unique(values)) > 10 + ): associations[index] = _numeric_association(coordinates, values) else: associations[index] = _categorical_association(coordinates, values) @@ -298,16 +497,39 @@ def _write_pca_diagnostic( covariate_columns: Sequence[str], covariate_roles: Sequence[str], adjacent_overlap: float | None, -) -> tuple[ArtifactRef, np.ndarray, np.ndarray, np.ndarray]: +) -> tuple[ + ArtifactRef, + np.ndarray, + np.ndarray, + np.ndarray, + np.ndarray, + np.ndarray, + np.ndarray, +]: reduction = _artifact_ref(evaluation, "pca") neighbors = _artifact_ref(evaluation, "neighbors") + reduction_status = store.inspect_artifact(reduction) + reduction_parameters = getattr(reduction_status, "parameters", None) or {} + if reduction_parameters.get("feat_scaling") is not True: + raise ValueError( + "Explained-variance ratios require the feature-scaled candidate PCA" + ) reduction_group = store.load_artifact(reduction) coordinates = as_zarr_array(reduction_group["data"], name="data") - loadings = np.asarray( - as_zarr_array(reduction_group["loadings"], name="loadings")[:], - dtype=np.float64, - ) + loadings = as_zarr_array(reduction_group["loadings"], name="loadings") component_variance = _component_variance(coordinates) + total_scaled_variance = _scaled_total_variance( + store, + reduction_status, + n_rows=int(coordinates.shape[0]), + n_features=len(selected_indices), + feature_selection=feature_selection, + ) + explained_variance_ratio = np.clip( + component_variance / total_scaled_variance, + 0.0, + 1.0, + ) top_indices, top_values, family_enrichment = _top_loadings( loadings, selected_indices, @@ -324,6 +546,7 @@ def _write_pca_diagnostic( ), coordinates, covariate_columns, + covariate_roles, ) if evaluation.cellSelection is not None else np.zeros((len(covariate_columns), coordinates.shape[1]), dtype=np.float64) @@ -334,6 +557,7 @@ def _write_pca_diagnostic( ) payload = { "component_variance": component_variance, + "explained_variance_ratio": explained_variance_ratio, "top_loading_feature_indices": top_indices, "top_loading_values": top_values, "family_enrichment": family_enrichment, @@ -352,6 +576,7 @@ def _write_pca_diagnostic( "covariate_roles": list(covariate_roles), "top_loading_count": top_indices.shape[1], "adjacent_neighbor_overlap": adjacent_overlap, + "explained_variance_basis": "scaled_nonconstant_features", }, inputs={ "reduction": reduction, @@ -391,7 +616,15 @@ def _write_pca_diagnostic( _PCA_DIAGNOSTIC_ARRAYS, ) finish_artifact(group, planned) - return planned.ref, component_variance, family_enrichment, associations + return ( + planned.ref, + component_variance, + explained_variance_ratio, + top_indices, + top_values, + family_enrichment, + associations, + ) def augment_pca_evaluations( @@ -404,6 +637,7 @@ def augment_pca_evaluations( technical_columns: Sequence[str], protected_columns: Sequence[str], qc_columns: Sequence[str], + batch_columns: Sequence[str] = (), ) -> tuple[ParameterCandidateEvaluation, ...]: """Attach persisted PCA loading, variance, topology, and covariate evidence.""" selected_indices, selected_names = _selected_feature_names( @@ -416,10 +650,17 @@ def augment_pca_evaluations( for mask in [_family_mask(selected_names, family)] if mask is not None and bool(mask.any()) } + requested_role_columns = { + "technical": tuple(technical_columns), + "batch": tuple(batch_columns or technical_columns), + "protected": tuple(protected_columns), + "qc": tuple(qc_columns), + } columns: list[str] = [] roles: list[str] = [] for role, values in ( ("technical", technical_columns), + ("batch", batch_columns), ("protected", protected_columns), ("qc", qc_columns), ): @@ -459,7 +700,15 @@ def augment_pca_evaluations( if evaluation.candidateId not in previous_by_id: augmented.append(evaluation) continue - diagnostic, variance, family_enrichment, associations = _write_pca_diagnostic( + ( + diagnostic, + variance, + explained_variance_ratio, + top_indices, + _top_values, + family_enrichment, + associations, + ) = _write_pca_diagnostic( store, evaluation, feature_selection=feature_selection, @@ -473,20 +722,54 @@ def augment_pca_evaluations( family: float(family_enrichment[index].max(initial=0.0)) for index, family in enumerate(family_masks) } + family_by_component = { + f"PC{component + 1}": { + family: float(family_enrichment[family_index, component]) + for family_index, family in enumerate(family_masks) + } + for component in range(family_enrichment.shape[1]) + } + feature_name_by_index = dict( + zip(selected_indices.tolist(), selected_names.tolist(), strict=True) + ) + top_loading_genes = { + f"PC{component + 1}": [ + str(feature_name_by_index[int(index)]) + for index in top_indices[component] + ] + for component in range(top_indices.shape[0]) + } + column_index = {column: index for index, column in enumerate(columns)} + component_associations = { + role: { + column: associations[column_index[column]].tolist() + for column in role_columns + if column in column_index + } + for role, role_columns in requested_role_columns.items() + } role_associations = { role: { - column: float(associations[index].max(initial=0.0)) - for index, (column, column_role) in enumerate( - zip(columns, roles, strict=True) - ) - if column_role == role + column: float(associations[column_index[column]].max(initial=0.0)) + for column in role_columns + if column in column_index } - for role in ("technical", "protected", "qc") + for role, role_columns in requested_role_columns.items() } metrics = evaluation.metrics.model_copy( update={ "componentVariance": variance.tolist(), + "pcaExplainedVarianceRatio": explained_variance_ratio.tolist(), + "pcaCumulativeExplainedVarianceRatio": np.cumsum( + explained_variance_ratio + ) + .clip(max=1.0) + .tolist(), + "topLoadingGenes": top_loading_genes, "loadingFamilyEnrichment": family_maxima, + "loadingFamilyEnrichmentByComponent": family_by_component, + "pcaComponentAssociations": component_associations, + "batchPcaAssociation": role_associations["batch"], "technicalPcaAssociation": role_associations["technical"], "protectedPcaAssociation": role_associations["protected"], "qcPcaAssociation": role_associations["qc"], @@ -507,6 +790,10 @@ def augment_pca_evaluations( [ *evaluation.evidenceIds, f"candidate:{evaluation.candidateId}:pcaVariance", + ( + f"candidate:{evaluation.candidateId}:" + "pcaExplainedVariance" + ), f"candidate:{evaluation.candidateId}:pcaLoadings", f"candidate:{evaluation.candidateId}:pcaCovariates", *( @@ -523,7 +810,131 @@ def augment_pca_evaluations( } ) ) - return tuple(augmented) + return annotate_candidate_dominance(augmented) + + +def _bounded_score_summary( + values: Any, + *, + maximum_sample_size: int, +) -> tuple[dict[str, float], np.ndarray]: + if maximum_sample_size < 1: + raise ValueError("maximum_sample_size must be positive") + if len(values.shape) != 1 or values.shape[0] < 1: + raise ValueError("Doublet scores must be one non-empty vector") + n_values = int(values.shape[0]) + stride = max(1, (n_values + maximum_sample_size - 1) // maximum_sample_size) + sampled: list[np.ndarray] = [] + minimum = float("inf") + maximum = float("-inf") + for start in range(0, n_values, 65_536): + block = np.asarray(values[start : start + 65_536], dtype=np.float64) + if not np.isfinite(block).all(): + raise ValueError("Doublet scores must be finite") + minimum = min(minimum, float(block.min())) + maximum = max(maximum, float(block.max())) + offset = (-start) % stride + sampled.append(block[offset::stride]) + sample = np.concatenate(sampled) + return ( + { + "nCells": float(n_values), + "sampleSize": float(len(sample)), + "minimum": minimum, + "p50": float(np.quantile(sample, 0.5)), + "p90": float(np.quantile(sample, 0.9)), + "p95": float(np.quantile(sample, 0.95)), + "p99": float(np.quantile(sample, 0.99)), + "maximum": maximum, + }, + sample, + ) + + +def _build_advisory_doublet_scores( + store: Any, + *, + scores: Sequence[ArtifactRef], + cell_selections: Sequence[ArtifactRef], + native_graph: ArtifactRef, + native_clusters: ArtifactRef, + parent_selection: ArtifactRef, + capture_values: Sequence[str], + capture_column: str | None, + limitations: Sequence[str], +) -> AdvisoryDoubletScores: + score_refs = tuple(scores) + selections = tuple(cell_selections) + captures = tuple(capture_values) + if not score_refs or not (len(score_refs) == len(selections) == len(captures)): + raise ValueError("Doublet score artifacts require aligned capture summaries") + parent_indices = read_stored_selection_indices( + store.zw, + parent_selection, + kind="cell_selection", + scope="datastore", + assay=None, + table_path="cellData", + ) + if len(parent_indices) < 1: + raise ValueError("Doublet evidence parent selection cannot be empty") + score_arrays = tuple( + as_zarr_array(store.load_artifact(score_ref)["values"], name="values") + for score_ref in score_refs + ) + total_score_cells = sum(int(values.shape[0]) for values in score_arrays) + if total_score_cells < 1: + raise ValueError("Doublet score artifacts cannot all be empty") + summaries: list[dict[str, float]] = [] + samples: list[np.ndarray] = [] + covered_cells = 0 + for values, selection_ref in zip(score_arrays, selections, strict=True): + selection_indices = read_stored_selection_indices( + store.zw, + selection_ref, + kind="cell_selection", + scope="datastore", + assay=None, + table_path="cellData", + ) + if values.shape != selection_indices.shape: + raise ValueError("Doublet scores do not align with their cell selection") + summary, sample = _bounded_score_summary( + values, + maximum_sample_size=max( + 1, + int(65_536 * int(values.shape[0]) / total_score_cells), + ), + ) + summaries.append(summary) + samples.append(sample) + covered_cells += len(selection_indices) + combined = np.concatenate(samples) + aggregate = { + "p50": float(np.quantile(combined, 0.5)), + "p90": float(np.quantile(combined, 0.9)), + "p95": float(np.quantile(combined, 0.95)), + "p99": float(np.quantile(combined, 0.99)), + "maximum": max(summary["maximum"] for summary in summaries), + } + reported_limitations = list(limitations) + if total_score_cells > 65_536: + reported_limitations.append( + "Doublet score quantiles use deterministic bounded samples above " + "65,536 scored cells." + ) + return AdvisoryDoubletScores( + scores=score_refs, + cell_selections=selections, + native_graph=native_graph, + native_clusters=native_clusters, + capture_values=captures, + score_summaries=tuple(summaries), + score_quantiles=aggregate, + capture_coverage=min(1.0, covered_cells / len(parent_indices)), + capture_column=capture_column, + limitations=tuple(reported_limitations), + ) def _select_capture_cells( @@ -531,14 +942,14 @@ def _select_capture_cells( parent: ArtifactRef, *, column: str, - value: str, + value: Any, active_indices: np.ndarray, active_values: np.ndarray, ) -> tuple[ArtifactRef, int]: if active_indices.shape != active_values.shape: raise ValueError("Capture values must align with the selected cells") labels = active_values.astype(str) - selected = labels == value + selected = labels == str(value) expected = np.zeros(store.cells.N, dtype=bool) expected[active_indices] = selected reference = store.filter_cells( @@ -655,12 +1066,16 @@ def score_advisory_doublets( "Physical capture identity was unavailable, so advisory doublet " "scores were computed across the selected dataset." ) - return AdvisoryDoubletScores( + return _build_advisory_doublet_scores( + store, scores=(score,), cell_selections=(parent_selection,), native_graph=native_graph, native_clusters=native_clusters, - limitations=tuple(limitations), + parent_selection=parent_selection, + capture_values=("allSelectedCells",), + capture_column=None, + limitations=limitations, ) active_indices = read_stored_selection_indices( @@ -676,7 +1091,25 @@ def score_advisory_doublets( capture_column, active_indices, ) - capture_groups = sorted(set(capture_values.astype(str).tolist())) + capture_labels = capture_values.astype(str) + unique_capture_labels, first_capture_indices = np.unique( + capture_labels, + return_index=True, + ) + capture_groups = unique_capture_labels.tolist() + raw_capture_values = { + str(label): capture_values[int(index)] + for label, index in zip( + unique_capture_labels, + first_capture_indices, + strict=True, + ) + } + if len(capture_groups) > _MAX_DOUBLET_CAPTURES: + raise ValueError( + "Physical capture column exceeds the advisory doublet limit of " + f"{_MAX_DOUBLET_CAPTURES} values" + ) if len(capture_groups) == 1: score = store.run_doublet_detection( native_clusters, @@ -684,22 +1117,28 @@ def score_advisory_doublets( from_assay=assay, invalidate_cache=False, ) - return AdvisoryDoubletScores( + return _build_advisory_doublet_scores( + store, scores=(score,), cell_selections=(parent_selection,), native_graph=native_graph, native_clusters=native_clusters, + parent_selection=parent_selection, + capture_values=(capture_groups[0],), + capture_column=capture_column, + limitations=limitations, ) n_features = len(read_feature_selection_indices(store.zw, assay, feature_selection)) scores: list[ArtifactRef] = [] selections: list[ArtifactRef] = [] + scored_captures: list[str] = [] for capture_value in capture_groups: capture_selection, capture_cells = _select_capture_cells( store, parent_selection, column=capture_column, - value=capture_value, + value=raw_capture_values[capture_value], active_indices=active_indices, active_values=capture_values, ) @@ -766,14 +1205,19 @@ def score_advisory_doublets( ) ) selections.append(capture_selection) + scored_captures.append(capture_value) if not scores: raise ValueError("No physical capture had enough cells for doublet scoring") - return AdvisoryDoubletScores( - scores=tuple(scores), - cell_selections=tuple(selections), + return _build_advisory_doublet_scores( + store, + scores=scores, + cell_selections=selections, native_graph=native_graph, native_clusters=native_clusters, - limitations=tuple(limitations), + parent_selection=parent_selection, + capture_values=scored_captures, + capture_column=capture_column, + limitations=limitations, ) @@ -793,17 +1237,24 @@ def _doublet_concentration( ).astype(np.int64, copy=False) if labels.shape != parent_indices.shape: raise ValueError("Cluster labels do not align with advisory doublet evidence") - positions = {int(value): index for index, value in enumerate(parent_indices)} + if len(parent_indices) > 1 and np.any(parent_indices[1:] <= parent_indices[:-1]): + raise ValueError("Doublet parent selection indices must be strictly increasing") high_score = np.zeros(len(parent_indices), dtype=bool) covered = np.zeros(len(parent_indices), dtype=bool) - for score_ref, selection_ref in zip( - evidence.scores, - evidence.cell_selections, - strict=True, + if evidence.score_summaries and len(evidence.score_summaries) != len( + evidence.scores ): - score_values = np.asarray( - as_zarr_array(store.load_artifact(score_ref)["values"], name="values")[:], - dtype=np.float64, + raise ValueError("Doublet score summaries do not align with score artifacts") + for score_index, (score_ref, selection_ref) in enumerate( + zip( + evidence.scores, + evidence.cell_selections, + strict=True, + ) + ): + score_values = as_zarr_array( + store.load_artifact(score_ref)["values"], + name="values", ) selection_indices = read_stored_selection_indices( store.zw, @@ -815,13 +1266,35 @@ def _doublet_concentration( ).astype(np.int64, copy=False) if score_values.shape != selection_indices.shape: raise ValueError("Doublet scores do not align with their cell selection") - local_positions = np.asarray( - [positions[int(value)] for value in selection_indices], - dtype=np.int64, + summary = ( + evidence.score_summaries[score_index] + if evidence.score_summaries + else _bounded_score_summary( + score_values, + maximum_sample_size=65_536, + )[0] ) - threshold = float(np.quantile(score_values, 0.9)) - covered[local_positions] = True - high_score[local_positions] = score_values >= threshold + threshold = float(summary["p90"]) + for start in range(0, len(selection_indices), 65_536): + local_indices = selection_indices[start : start + 65_536] + local_positions = np.searchsorted(parent_indices, local_indices) + if np.any(local_positions >= len(parent_indices)) or not np.array_equal( + parent_indices[local_positions], + local_indices, + ): + raise ValueError( + "Doublet score selection is outside its parent selection" + ) + if covered[local_positions].any(): + raise ValueError("Doublet score selections must not overlap") + local_scores = np.asarray( + score_values[start : start + len(local_indices)], + dtype=np.float64, + ) + if not np.isfinite(local_scores).all(): + raise ValueError("Doublet scores must be finite") + covered[local_positions] = True + high_score[local_positions] = local_scores >= threshold if not covered.any() or not high_score[covered].any(): return None baseline = float(high_score[covered].mean()) @@ -957,6 +1430,46 @@ def augment_cluster_evaluations( if "feature_name" in markers.columns else np.asarray([], dtype=str) ) + marker_specificity: dict[str, float] = {} + marker_auc: dict[str, float] = {} + top_marker_genes: dict[str, list[str]] = {} + marker_group_values = ( + markers["group_id"].astype(str) if "group_id" in markers.columns else None + ) + for cluster in np.unique(labels): + cluster_id = str(cluster) + cluster_markers = ( + markers.loc[marker_group_values == cluster_id] + if marker_group_values is not None + else markers.iloc[0:0] + ) + if "score" in cluster_markers: + cluster_markers = cluster_markers.sort_values( + "score", + ascending=False, + kind="stable", + ) + top_scores = cluster_markers["score"].to_numpy( + dtype=np.float64, + )[:10] + top_scores = top_scores[np.isfinite(top_scores)] + if len(top_scores): + marker_specificity[cluster_id] = float(np.median(top_scores)) + if "auc" in cluster_markers: + top_auc = cluster_markers["auc"].to_numpy(dtype=np.float64)[:10] + top_auc = top_auc[np.isfinite(top_auc)] + if len(top_auc): + marker_auc[cluster_id] = float(np.median(top_auc)) + top_marker_genes[cluster_id] = ( + cluster_markers["feature_name"].astype(str).head(10).tolist() + if "feature_name" in cluster_markers + else [] + ) + marker_specificity_median = ( + float(np.median(list(marker_specificity.values()))) + if marker_specificity + else None + ) marker_family_enrichment: dict[str, float] = {} protected_marker_families: list[str] = [] for family, mask in family_masks.items(): @@ -1018,11 +1531,37 @@ def augment_cluster_evaluations( "seedStability": seed_stability, "subsampleStability": subsample_stability, "markerCoherence": marker_coherence, + "markerSpecificityMedian": marker_specificity_median, + "markerSpecificityByCluster": marker_specificity, + "markerAucByCluster": marker_auc, + "topMarkerGenes": top_marker_genes, "crossUnitSupport": cross_unit_support, "technicalAssociation": technical_association, "markerFamilyEnrichment": marker_family_enrichment, "protectedMarkerFamilies": protected_marker_families, "doubletHighScoreConcentration": doublet_concentration, + "doubletScoreQuantiles": ( + dict(doublet_evidence.score_quantiles) + if doublet_evidence is not None + else {} + ), + "doubletScoreByCapture": ( + { + capture: dict(summary) + for capture, summary in zip( + doublet_evidence.capture_values, + doublet_evidence.score_summaries, + strict=True, + ) + } + if doublet_evidence is not None + else {} + ), + "doubletCaptureCoverage": ( + doublet_evidence.capture_coverage + if doublet_evidence is not None + else None + ), } ) evidence_ids = [ @@ -1030,6 +1569,7 @@ def augment_cluster_evaluations( f"candidate:{evaluation.candidateId}:seedStability", f"candidate:{evaluation.candidateId}:subsampleStability", f"candidate:{evaluation.candidateId}:markerCoherence", + f"candidate:{evaluation.candidateId}:markerSpecificity", f"candidate:{evaluation.candidateId}:markerFamilies", *( [f"candidate:{evaluation.candidateId}:crossUnitSupport"] @@ -1045,6 +1585,14 @@ def augment_cluster_evaluations( if doublet_concentration is not None else [] ), + *( + [ + f"candidate:{evaluation.candidateId}:doubletScoreTails", + f"candidate:{evaluation.candidateId}:doubletCaptureCoverage", + ] + if doublet_evidence is not None + else [] + ), ] artifacts = { **evaluation.artifacts, @@ -1058,6 +1606,14 @@ def augment_cluster_evaluations( if doublet_evidence is not None else {} ), + **( + { + f"doubletCellSelection:{index}": ArtifactRecord.from_ref(selection) + for index, selection in enumerate(doublet_evidence.cell_selections) + } + if doublet_evidence is not None + else {} + ), **( { "doubletNativeGraph": ArtifactRecord.from_ref( @@ -1092,7 +1648,7 @@ def augment_cluster_evaluations( } ) ) - return tuple(augmented) + return annotate_candidate_dominance(augmented) __all__ = [ From 715bbf27965d38c07e1deab6e442bc0beb858b4e Mon Sep 17 00:00:00 2001 From: gautam8387 Date: Sat, 5 Sep 2026 22:19:08 +0200 Subject: [PATCH 07/21] agent tests --- .gitignore | 5 +- tests/test_agent_data_enrichment.py | 45 +- tests/test_agent_decision_kernel.py | 577 +++++++++++ tests/test_agent_decision_persistence.py | 932 ++++++++++++++++++ tests/test_agent_exec.py | 26 +- tests/test_agent_experimental_context.py | 167 +++- tests/test_agent_hvg_diagnostics.py | 142 +++ tests/test_agent_ingest_manifest.py | 443 +++++++++ tests/test_agent_orchestrator.py | 284 ++++-- .../test_agent_orchestrator_journal_edges.py | 68 +- tests/test_agent_orchestrator_lifecycle.py | 50 +- tests/test_agent_orchestrator_stages.py | 423 ++++++-- tests/test_agent_parameter_tuning.py | 32 +- tests/test_agent_report.py | 765 +++++++++++++- tests/test_agent_rna_decisions.py | 420 ++++++++ tests/test_agent_sequential_tuning.py | 348 +++++++ tests/test_agent_tuning_diagnostics.py | 26 + tests/test_registered_qc_profiles.py | 622 ++++++++++++ 18 files changed, 5074 insertions(+), 301 deletions(-) create mode 100644 tests/test_agent_decision_kernel.py create mode 100644 tests/test_agent_decision_persistence.py create mode 100644 tests/test_agent_hvg_diagnostics.py create mode 100644 tests/test_agent_ingest_manifest.py create mode 100644 tests/test_agent_rna_decisions.py create mode 100644 tests/test_agent_sequential_tuning.py create mode 100644 tests/test_agent_tuning_diagnostics.py create mode 100644 tests/test_registered_qc_profiles.py diff --git a/.gitignore b/.gitignore index 17f2c444..30523597 100644 --- a/.gitignore +++ b/.gitignore @@ -73,4 +73,7 @@ scarf/_version.py .env temp/ # Dev testing scard reports -modal_scarf/ \ No newline at end of file +modal_scarf/ +notebook/ +PR/ +scarf/agent/*.md diff --git a/tests/test_agent_data_enrichment.py b/tests/test_agent_data_enrichment.py index a45814fb..ce65e686 100644 --- a/tests/test_agent_data_enrichment.py +++ b/tests/test_agent_data_enrichment.py @@ -486,7 +486,7 @@ async def reply( assert state["request"] == 3 -def test_data_enrichment_falls_back_from_completed_inspection( +def test_data_enrichment_pauses_after_completed_inspection_without_selection( monkeypatch: pytest.MonkeyPatch, ) -> None: from scarf.agent import data_enrichment as module @@ -513,19 +513,48 @@ def unavailable_structured_output(**kwargs: object) -> None: context=DataEnrichmentContext(organismHint="human"), ) - assert result.status == "done" - assert result.runInfo.agentName == "data_enrichment_fallback" - assert result.policies[0].species == "homo_sapiens" - assert result.policies[0].speciesConfidence == "medium" - assert result.policies[0].excludeFamilies == ["mitochondrial"] - assert result.policies[0].protectFamilies == ["sex"] - assert result.policies[0].artificialFeatures == [] + assert result.status == "needsInput" + assert result.runInfo.agentName == "data_enrichment_needs_input" + assert result.policies == [] + assert result.unresolvedQuestions + assert result.inspections[0].species == "unknown" + assert "No scientific feature policy was selected" in result.limitations[0] assert tool_retries == { "inspect_assay_features_batch": 1, "find_present_features_batch": 1, } +def test_unattended_data_enrichment_uses_inspected_policy_after_model_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from scarf.agent import data_enrichment as module + + store = ReadOnlyStore() + monkeypatch.setattr( + module, + "characterize_features", + lambda *_args, **_kwargs: characterization(), + ) + + def unavailable_structured_output(**kwargs: object) -> None: + deps = kwargs["deps"] + assert isinstance(deps, DataEnrichmentDependencies) + asyncio.run(module.inspect_assay_features_batch(SimpleNamespace(deps=deps))) + raise UnexpectedModelBehavior("structured output unavailable") + + monkeypatch.setattr(module, "run_agent_sync", unavailable_structured_output) + result = DataEnrichmentAgent(object(), unattended=True).run( + store, + context=DataEnrichmentContext(organismHint="human"), + ) + + assert result.status == "done" + assert [policy.assay for policy in result.policies] == ["RNA"] + assert result.unresolvedQuestions == [] + assert result.runInfo.agentName == "data_enrichment_deterministic" + + def test_feature_lookup_cache_rejects_different_arguments() -> None: deps = DataEnrichmentDependencies( store=ReadOnlyStore(), diff --git a/tests/test_agent_decision_kernel.py b/tests/test_agent_decision_kernel.py new file mode 100644 index 00000000..49f8f04a --- /dev/null +++ b/tests/test_agent_decision_kernel.py @@ -0,0 +1,577 @@ +"""Contract tests for the decision kernel and deterministic auditor.""" + +import pytest +from pydantic import ValidationError + +from scarf.agent.decision_kernel import ( + DecisionEvidence, + DecisionOption, + DecisionRecord, + DecisionSpec, + DecisionWorkflowRun, + DeterministicDecisionAuditor, + EvidenceBundle, + RevisionRequest, + VerificationCheck, + VerificationRecord, +) + + +def _evidence_bundle() -> EvidenceBundle: + return EvidenceBundle( + bundleId="bundle:clusterPartition", + decisionId="clusterPartition", + evidence=[ + DecisionEvidence( + evidenceId="evidence:silhouette", + evidenceClass="geometric", + summary="The coarse partition has the largest silhouette.", + ), + DecisionEvidence( + evidenceId="evidence:markers", + evidenceClass="markerCoherence", + summary="The finer partition has distinct marker programs.", + ), + DecisionEvidence( + evidenceId="evidence:stability", + evidenceClass="resamplingStability", + summary="The finer partition is stable under subsampling.", + ), + DecisionEvidence( + evidenceId="evidence:replicates", + evidenceClass="crossUnitSupport", + summary="The finer populations occur in independent units.", + ), + ], + ) + + +def _decision_spec() -> DecisionSpec: + return DecisionSpec( + decisionId="clusterPartition", + definitionVersion=1, + checkpoint="clustering", + question="Which registered partition is defensible?", + evidenceBundleId="bundle:clusterPartition", + options=[ + DecisionOption( + optionId="partition:coarse", + status="apply", + label="Coarse partition", + description="Use the metric-preferred coarse partition.", + ), + DecisionOption( + optionId="partition:fine", + status="apply", + label="Fine partition", + description="Use the finer marker-supported partition.", + requiredEvidenceClasses=["markerCoherence"], + ), + DecisionOption( + optionId="partition:abstain", + status="abstain", + label="No discrete partition", + description="Do not claim that a discrete partition is supported.", + ), + ], + baselineOptionId="partition:coarse", + metricPreferredOptionId="partition:coarse", + requireIndependentOverrideEvidence=True, + ) + + +def _decision_record( + *, + record_id: str = "decision:cluster:1", + selected_option_id: str = "partition:coarse", + status: str = "apply", + source: str = "agent", + evidence_ids: list[str] | None = None, + override_of: str | None = None, + override_evidence_ids: list[str] | None = None, + available_evidence_ids: list[str] | None = None, + supersedes: str | None = None, +) -> DecisionRecord: + bundle = _evidence_bundle().with_content_sha256() + assert bundle.contentSha256 is not None + return DecisionRecord( + recordId=record_id, + decisionId="clusterPartition", + definitionVersion=1, + evidenceBundleId=bundle.bundleId, + evidenceBundleSha256=bundle.contentSha256, + offeredOptionIds=[ + "partition:coarse", + "partition:fine", + "partition:abstain", + ], + availableEvidenceIds=available_evidence_ids + if available_evidence_ids is not None + else [item.evidenceId for item in bundle.evidence], + selectedOptionId=selected_option_id, + status=status, + source=source, + evidenceIds=evidence_ids or ["evidence:silhouette"], + rationale="The cited evidence supports this registered option.", + confidence="medium", + overrideOfOptionId=override_of, + overrideEvidenceIds=override_evidence_ids or [], + verificationId=f"verification:{record_id}", + supersedes=supersedes, + ) + + +def test_option_contract_rejects_freeform_execution_parameters() -> None: + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + DecisionOption.model_validate( + { + "optionId": "pca:30", + "status": "apply", + "label": "Thirty PCs", + "description": "Use the registered thirty-PC prefix.", + "dimensions": 30, + } + ) + + +def test_evidence_bundle_rejects_duplicate_evidence_ids() -> None: + item = DecisionEvidence( + evidenceId="evidence:qc", + evidenceClass="qualityControl", + summary="Observed cell quality evidence.", + ) + with pytest.raises(ValidationError, match="must not contain duplicates"): + EvidenceBundle( + bundleId="bundle:qc", + decisionId="cellQuality", + evidence=[item, item], + ) + + +@pytest.mark.parametrize( + ("changes", "message"), + [ + ( + {"selectedOptionId": "partition:invented"}, + "selectedOptionId must reference an offered option", + ), + ( + {"evidenceIds": ["evidence:invented"]}, + "evidenceIds must reference only available evidence", + ), + ( + { + "overrideOfOptionId": None, + "evidenceIds": ["evidence:markers"], + "overrideEvidenceIds": ["evidence:markers"], + }, + "overrideEvidenceIds require overrideOfOptionId", + ), + ], +) +def test_decision_record_rejects_non_exact_references( + changes: dict[str, object], message: str +) -> None: + values = _decision_record().model_dump() + values.update(changes) + with pytest.raises(ValidationError, match=message): + DecisionRecord.model_validate(values) + + +def test_auditor_accepts_an_exact_metric_preferred_decision() -> None: + record = _decision_record() + + verification = DeterministicDecisionAuditor.audit( + _decision_spec(), _evidence_bundle(), record, created_at_ns=10 + ) + + assert verification.status == "passed" + assert verification.verificationId == record.verificationId + assert {check.status for check in verification.checks} == {"passed"} + + +def test_auditor_rejects_a_tampered_evidence_bundle_checksum() -> None: + values = _decision_record().model_dump() + values["evidenceBundleSha256"] = "f" * 64 + record = DecisionRecord.model_validate(values) + + verification = DeterministicDecisionAuditor.audit( + _decision_spec(), + _evidence_bundle(), + record, + ) + + assert verification.status == "failed" + assert [ + check.checkId for check in verification.checks if check.status == "failed" + ] == ["decisionIdentity"] + + +def test_auditor_requires_evidence_bound_to_the_selected_option() -> None: + spec_values = _decision_spec().model_dump() + spec_values["options"][0]["requiredEvidenceIds"] = ["evidence:markers"] + spec = DecisionSpec.model_validate(spec_values) + + verification = DeterministicDecisionAuditor.audit( + spec, + _evidence_bundle(), + _decision_record(), + ) + + assert verification.status == "failed" + assert [ + check.checkId for check in verification.checks if check.status == "failed" + ] == ["requiredEvidence"] + + +def test_auditor_rejects_a_tampered_option_or_evidence_inventory() -> None: + record = _decision_record( + available_evidence_ids=["evidence:silhouette"], + ) + values = record.model_dump() + values["offeredOptionIds"] = ["partition:coarse", "partition:abstain"] + record = DecisionRecord.model_validate(values) + + verification = DeterministicDecisionAuditor.audit( + _decision_spec(), _evidence_bundle(), record + ) + + assert verification.status == "failed" + failed = { + check.checkId for check in verification.checks if check.status == "failed" + } + assert failed == {"exactOptionInventory", "exactEvidenceInventory"} + + +def test_auditor_requires_two_independent_non_geometric_override_classes() -> None: + record = _decision_record( + selected_option_id="partition:fine", + evidence_ids=["evidence:silhouette", "evidence:markers"], + override_of="partition:coarse", + override_evidence_ids=["evidence:silhouette", "evidence:markers"], + ) + + verification = DeterministicDecisionAuditor.audit( + _decision_spec(), _evidence_bundle(), record + ) + + assert verification.status == "failed" + failed = [check for check in verification.checks if check.status == "failed"] + assert [check.checkId for check in failed] == ["independentOverrideEvidence"] + + +def test_auditor_accepts_two_independent_non_geometric_override_classes() -> None: + record = _decision_record( + selected_option_id="partition:fine", + evidence_ids=["evidence:markers", "evidence:stability"], + override_of="partition:coarse", + override_evidence_ids=["evidence:markers", "evidence:stability"], + ) + + verification = DeterministicDecisionAuditor.audit( + _decision_spec(), _evidence_bundle(), record + ) + + assert verification.status == "passed" + + +def test_auditor_rejects_override_evidence_from_another_option() -> None: + spec_values = _decision_spec().model_dump() + spec_values["options"][1]["requiredEvidenceIds"] = [ + "evidence:markers", + "evidence:stability", + ] + spec = DecisionSpec.model_validate(spec_values) + record = _decision_record( + selected_option_id="partition:fine", + evidence_ids=[ + "evidence:markers", + "evidence:stability", + "evidence:replicates", + ], + override_of="partition:coarse", + override_evidence_ids=["evidence:markers", "evidence:replicates"], + ) + + verification = DeterministicDecisionAuditor.audit( + spec, + _evidence_bundle(), + record, + ) + + assert verification.status == "failed" + assert [ + check.checkId for check in verification.checks if check.status == "failed" + ] == ["independentOverrideEvidence"] + + +def test_human_choices_obey_the_same_source_and_status_contracts() -> None: + spec_values = _decision_spec().model_dump() + spec_values["allowedSources"] = ["rule", "agent"] + spec = DecisionSpec.model_validate(spec_values) + record = _decision_record(source="human", status="skip") + + verification = DeterministicDecisionAuditor.audit(spec, _evidence_bundle(), record) + + assert verification.status == "failed" + failed = { + check.checkId for check in verification.checks if check.status == "failed" + } + assert failed == {"selectedOption", "decisionSource"} + + +def test_workflow_ledger_accepts_one_verified_revision_chain() -> None: + original = _decision_record(record_id="decision:cluster:1") + original_verification = VerificationRecord( + verificationId="verification:decision:cluster:1", + decisionRecordId=original.recordId, + status="failed", + checks=[ + VerificationCheck( + checkId="clusterAudit", + status="failed", + summary="The coarse partition merges marker-supported populations.", + evidenceIds=["evidence:markers", "evidence:stability"], + ) + ], + ) + revision = RevisionRequest( + revisionId="revision:cluster:1", + targetDecisionRecordId=original.recordId, + verificationId=original_verification.verificationId, + replacementOptionId="partition:fine", + reason="Independent evidence supports the finer registered partition.", + evidenceBundleId="bundle:cluster-revision", + evidenceBundleSha256="1" * 64, + availableEvidenceIds=["evidence:markers", "evidence:stability"], + evidenceIds=["evidence:markers", "evidence:stability"], + ) + replacement = _decision_record( + record_id="decision:cluster:2", + selected_option_id="partition:fine", + evidence_ids=["evidence:markers", "evidence:stability"], + override_of="partition:coarse", + override_evidence_ids=["evidence:markers", "evidence:stability"], + supersedes=original.recordId, + ) + replacement_verification = DeterministicDecisionAuditor.audit( + _decision_spec(), _evidence_bundle(), replacement + ) + + run = DecisionWorkflowRun( + workflowRunId="workflow:1", + decisionRecords=[original, replacement], + verificationRecords=[original_verification, replacement_verification], + revisionRequests=[revision], + ) + + assert run.formatVersion == 2 + assert run.maxRevisions == 2 + + +def test_workflow_ledger_rejects_more_than_two_revisions() -> None: + values = { + "workflowRunId": "workflow:1", + "revisionRequests": [ + { + "revisionId": f"revision:{index}", + "targetDecisionRecordId": "decision:target", + "verificationId": "verification:target", + "replacementOptionId": "option:replacement", + "reason": "Retry a registered alternative.", + } + for index in range(3) + ], + } + + with pytest.raises(ValidationError, match="configured revision limit"): + DecisionWorkflowRun.model_validate(values) + + +def test_workflow_ledger_honors_a_disabled_revision_budget() -> None: + with pytest.raises(ValidationError, match="configured revision limit"): + DecisionWorkflowRun( + workflowRunId="workflow:no-revisions", + maxRevisions=0, + revisionRequests=[ + RevisionRequest( + revisionId="revision:disabled", + targetDecisionRecordId="decision:target", + verificationId="verification:target", + replacementOptionId="option:replacement", + reason="This revision should be rejected before execution.", + ) + ], + ) + + +def test_workflow_ledger_rejects_upstream_revision_invalidation() -> None: + original = _decision_record(record_id="decision:cluster:1") + verification = VerificationRecord( + verificationId="verification:decision:cluster:1", + decisionRecordId=original.recordId, + status="failed", + checks=[ + VerificationCheck( + checkId="clusterAudit", + status="failed", + summary="The partition failed its deterministic audit.", + ) + ], + ) + revision = RevisionRequest( + revisionId="revision:cluster:1", + targetDecisionRecordId=original.recordId, + verificationId=verification.verificationId, + replacementOptionId="partition:fine", + reason="Use the registered alternative.", + invalidatesDecisionRecordIds=[original.recordId], + ) + + with pytest.raises(ValidationError, match="only downstream decisions"): + DecisionWorkflowRun( + workflowRunId="workflow:1", + decisionRecords=[original], + verificationRecords=[verification], + revisionRequests=[revision], + ) + + +def test_revision_replaces_target_and_recomputed_downstream_records() -> None: + def record( + record_id: str, + decision_id: str, + selected_option_id: str, + offered_option_ids: list[str], + *, + supersedes: str | None = None, + ) -> DecisionRecord: + return DecisionRecord( + recordId=record_id, + decisionId=decision_id, + definitionVersion=1, + evidenceBundleId=f"bundle:{record_id}", + evidenceBundleSha256="0" * 64, + offeredOptionIds=offered_option_ids, + availableEvidenceIds=[], + selectedOptionId=selected_option_id, + status="apply", + source="agent", + rationale="The exact registered option is supported.", + verificationId=f"verification:{record_id}", + supersedes=supersedes, + ) + + cell = record( + "decision:cell:1", + "cellQuality", + "cell:global", + ["cell:global"], + ) + feature = record( + "decision:feature:1", + "featurePolicy", + "feature:keep", + ["feature:keep", "feature:exclude"], + ) + old_hvg = record( + "decision:hvg:1", + "hvgCount", + "hvg:standard", + ["hvg:standard"], + ) + revised_feature = record( + "decision:feature:2", + "featurePolicy", + "feature:exclude", + ["feature:keep", "feature:exclude"], + supersedes=feature.recordId, + ) + recomputed_hvg = record( + "decision:hvg:2", + "hvgCount", + "hvg:standard", + ["hvg:standard"], + supersedes=old_hvg.recordId, + ) + records = [cell, feature, old_hvg, revised_feature, recomputed_hvg] + verifications = [ + VerificationRecord( + verificationId=f"verification:{value.recordId}", + decisionRecordId=value.recordId, + status="passed", + checks=[ + VerificationCheck( + checkId="exactContract", + status="passed", + summary="The exact decision contract passed.", + ) + ], + ) + for value in records + ] + revision = RevisionRequest( + revisionId="revision:feature:1", + targetDecisionRecordId=feature.recordId, + verificationId=f"verification:{feature.recordId}", + replacementOptionId="feature:exclude", + reason="Downstream representation evidence supports the registered exclusion.", + evidenceBundleId="bundle:feature-dominance", + evidenceBundleSha256="1" * 64, + availableEvidenceIds=["evidence:feature-dominance"], + evidenceIds=["evidence:feature-dominance"], + invalidatesDecisionRecordIds=[old_hvg.recordId], + ) + + workflow = DecisionWorkflowRun( + workflowRunId="workflow:revision", + decisionRecords=records, + verificationRecords=verifications, + revisionRequests=[revision], + ) + + assert [value.recordId for value in workflow.active_decision_records()] == [ + cell.recordId, + revised_feature.recordId, + recomputed_hvg.recordId, + ] + + +def test_completed_workflow_requires_verified_active_decisions() -> None: + record = _decision_record() + verification = DeterministicDecisionAuditor.audit( + _decision_spec(), _evidence_bundle(), record + ) + + run = DecisionWorkflowRun( + workflowRunId="workflow:complete", + status="completed", + decisionRecords=[record], + verificationRecords=[verification], + finalHandoffId="handoff:1", + ) + + assert run.status == "completed" + assert run.finalHandoffId == "handoff:1" + + +@pytest.mark.parametrize( + ("status", "decision_status"), + [("needsInput", "defer"), ("abstained", "abstain")], +) +def test_non_success_terminal_status_requires_matching_active_decision( + status: str, decision_status: str +) -> None: + values = _decision_record().model_dump() + values["status"] = decision_status + if decision_status == "defer": + values["selectedOptionId"] = "partition:abstain" + record = DecisionRecord.model_validate(values) + + run = DecisionWorkflowRun( + workflowRunId=f"workflow:{status}", + status=status, + decisionRecords=[record], + ) + + assert run.status == status diff --git a/tests/test_agent_decision_persistence.py b/tests/test_agent_decision_persistence.py new file mode 100644 index 00000000..a787c5ce --- /dev/null +++ b/tests/test_agent_decision_persistence.py @@ -0,0 +1,932 @@ +"""Persistence tests for immutable decision-workflow snapshots.""" + +import hashlib +import json +from types import SimpleNamespace +from typing import Any + +import pytest +import zarr +from pydantic_ai.exceptions import AgentRunError +from zarr.core.buffer import default_buffer_prototype +from zarr.core.sync import sync + +import scarf.agent.decision_persistence as persistence_module +import scarf.agent.orchestrator.decisions as decisions_module +from scarf.agent import record_io +from scarf.agent.decision_kernel import ( + DecisionEvidence, + DecisionRecord, + DecisionSelection, + DecisionWorkflowRun, + EvidenceBundle, + VerificationCheck, + VerificationRecord, +) +from scarf.agent.decision_persistence import ( + DecisionPersistenceFormatError, + DecisionWorkflowSnapshot, + attach_audited_rna_decision, + decision_record_checksum, + list_decision_workflow_snapshots, + load_decision_workflow_for_replay, + load_decision_workflow_snapshot, + load_latest_decision_workflow_snapshot, + save_decision_workflow_snapshot, +) +from scarf.agent.orchestrator.models import ( + _ORCHESTRATION_FORMAT, + AutomatedWorkflowConfig, + AutomatedWorkflowRequest, + OrchestrationRequestRecord, +) +from scarf.agent.orchestrator.decisions import DecisionStagesMixin +from scarf.agent.rna_decisions import ( + build_cell_quality_decision, + build_feature_policy_decision, + build_pca_prefix_decision, + build_qc_grouping_decision, + compile_rna_decision, +) +from tests.agent_orchestrator_store import create_store + + +def _set_raw(group: zarr.Group, key: str, payload: bytes) -> None: + buffer = default_buffer_prototype().buffer.from_bytes(payload) + sync(group.store.set(key, buffer)) + + +def _orchestration_request_record( + path: Any, + workflow_run_id: str, +) -> OrchestrationRequestRecord: + request = AutomatedWorkflowRequest( + sourcePath=str(path), + zarrPath=str(path), + studyContext="A test single-cell study.", + studyObjective="Discover stable RNA populations.", + ) + config = AutomatedWorkflowConfig() + record = OrchestrationRequestRecord( + workflowRunId=workflow_run_id, + createdAtNs=1, + request=request, + config=config, + requestSha256=persistence_module._model_checksum(request), + configSha256=persistence_module._model_checksum(config), + ) + return record.model_copy( + update={"contentSha256": persistence_module._record_checksum(record)} + ) + + +def _seed_orchestration( + path: Any, + workflow_run_id: str, + *, + format_version: int = 2, +) -> zarr.Group: + create_store(path) + group = zarr.open_group(str(path), mode="r+") + agents = group.create_group( + "agents", + attributes={"format": "scarf_agent_reports", "format_version": 2}, + ) + agents.create_group( + "orchestrations", + attributes={ + "format": _ORCHESTRATION_FORMAT, + "format_version": format_version, + }, + ) + record = _orchestration_request_record(path, workflow_run_id) + _set_raw( + group, + f"agents/orchestrations/{workflow_run_id}/request.json", + record_io.display_json_bytes(record.model_dump(mode="json")), + ) + return group + + +def _decision_record( + workflow_run_id: str, + *, + status: str, + verification: bool, +) -> tuple[DecisionRecord, VerificationRecord | None]: + option_id = f"option:{status}" + verification_id = f"verification:{workflow_run_id}:1" if verification else None + record = DecisionRecord( + recordId=f"decision:{workflow_run_id}:1", + decisionId="cellQuality", + definitionVersion=1, + evidenceBundleId="bundle:test", + evidenceBundleSha256="0" * 64, + offeredOptionIds=[option_id], + availableEvidenceIds=[], + selectedOptionId=option_id, + status=status, + source="rule", + rationale="Use the exact registered test outcome.", + verificationId=verification_id, + ) + if not verification: + return record, None + return record, VerificationRecord( + verificationId=verification_id, + decisionRecordId=record.recordId, + status="passed", + checks=[ + VerificationCheck( + checkId="exactContract", + status="passed", + summary="The registered test contract is exact.", + ) + ], + ) + + +def _workflow_for_status(workflow_run_id: str, status: str) -> DecisionWorkflowRun: + if status == "running": + return DecisionWorkflowRun(workflowRunId=workflow_run_id) + decision_status = "defer" if status == "needsInput" else "abstain" + if status == "completed": + decision_status = "apply" + record, verification = _decision_record( + workflow_run_id, + status=decision_status, + verification=status == "completed", + ) + return DecisionWorkflowRun( + workflowRunId=workflow_run_id, + status=status, + decisionRecords=[record], + verificationRecords=[verification] if verification is not None else [], + finalHandoffId=f"handoff-{workflow_run_id}" if status == "completed" else None, + ) + + +def _compiled_cell_quality( + workflow_run_id: str, +) -> tuple[DecisionRecord, Any]: + definition = build_cell_quality_decision( + evidence_bundle_id="bundle:cellQuality", + available_profiles=["retainWithFlags", "globalMad5"], + ) + bundle = EvidenceBundle( + bundleId="bundle:cellQuality", + decisionId="cellQuality", + evidence=[ + DecisionEvidence( + evidenceId="evidence:quality", + evidenceClass="qualityControl", + summary="The published cell set passes lenient quality review.", + ) + ], + ).with_content_sha256() + assert bundle.contentSha256 is not None + record = DecisionRecord( + recordId=f"decision:{workflow_run_id}:cellQuality", + decisionId="cellQuality", + definitionVersion=1, + evidenceBundleId=bundle.bundleId, + evidenceBundleSha256=bundle.contentSha256, + offeredOptionIds=[option.optionId for option in definition.spec.options], + availableEvidenceIds=["evidence:quality"], + selectedOptionId="cellQuality:retainWithFlags", + status="skip", + source="agent", + evidenceIds=["evidence:quality"], + rationale="The published cells do not need another destructive filter.", + verificationId=f"verification:decision:{workflow_run_id}:cellQuality", + ) + return record, compile_rna_decision(definition, bundle, record) + + +def _compiled_qc_grouping( + workflow_run_id: str, +) -> tuple[DecisionRecord, Any]: + definition = build_qc_grouping_decision( + evidence_bundle_id="bundle:qcGrouping", + physical_capture_eligible=False, + pooled_reference_eligible=False, + ) + bundle = EvidenceBundle( + bundleId="bundle:qcGrouping", + decisionId="qcGrouping", + evidence=[ + DecisionEvidence( + evidenceId="evidence:quality", + evidenceClass="qualityControl", + summary="Global quality-reference evidence is available.", + ), + DecisionEvidence( + evidenceId="evidence:design", + evidenceClass="design", + summary="No physical capture is registered.", + ), + ], + ).with_content_sha256() + assert bundle.contentSha256 is not None + record = DecisionRecord( + recordId=f"decision:{workflow_run_id}:qcGrouping", + decisionId="qcGrouping", + definitionVersion=1, + evidenceBundleId=bundle.bundleId, + evidenceBundleSha256=bundle.contentSha256, + offeredOptionIds=[option.optionId for option in definition.spec.options], + availableEvidenceIds=[item.evidenceId for item in bundle.evidence], + selectedOptionId="qcGrouping:global", + status="apply", + source="agent", + evidenceIds=[item.evidenceId for item in bundle.evidence], + rationale="Use a global quality reference without proven captures.", + verificationId=f"verification:decision:{workflow_run_id}:qcGrouping", + ) + return record, compile_rna_decision(definition, bundle, record) + + +@pytest.mark.parametrize("status", ["running", "needsInput", "abstained", "completed"]) +def test_snapshot_round_trip_supports_workflow_statuses( + tmp_path: Any, status: str +) -> None: + workflow_run_id = f"workflow-{status.lower()}" + path = tmp_path / f"{status}.zarr" + _seed_orchestration(path, workflow_run_id) + workflow = _workflow_for_status(workflow_run_id, status) + + saved = save_decision_workflow_snapshot(path, workflow, created_at_ns=10) + loaded = load_decision_workflow_snapshot( + path, + workflow_run_id, + saved.contentSha256, + ) + + assert loaded == saved + assert loaded.workflow.status == status + assert loaded.orchestrationRun.workflowRunId == workflow_run_id + assert loaded.orchestrationRun.requestContentSha256 + + +def test_snapshots_are_content_addressed_append_only_and_idempotent( + tmp_path: Any, +) -> None: + workflow_run_id = "workflow-chain" + path = tmp_path / "chain.zarr" + _seed_orchestration(path, workflow_run_id) + initial = DecisionWorkflowRun(workflowRunId=workflow_run_id) + first = save_decision_workflow_snapshot(path, initial, created_at_ns=10) + record, compiled = _compiled_qc_grouping(workflow_run_id) + advanced = attach_audited_rna_decision(initial, record, compiled) + second = save_decision_workflow_snapshot(path, advanced, created_at_ns=20) + + snapshots = list_decision_workflow_snapshots(path, workflow_run_id) + assert [snapshot.sequence for snapshot in snapshots] == [0, 1] + assert second.parentContentSha256 == first.contentSha256 + assert load_latest_decision_workflow_snapshot(path, workflow_run_id) == second + assert ( + load_decision_workflow_snapshot(path, workflow_run_id, first.contentSha256) + == first + ) + + retried = save_decision_workflow_snapshot(path, advanced, created_at_ns=30) + assert retried == second + assert len(list_decision_workflow_snapshots(path, workflow_run_id)) == 2 + + +def test_decision_record_checksum_is_canonical_and_content_sensitive() -> None: + record, _verification = _decision_record( + "workflow-checksum", status="apply", verification=True + ) + expected = hashlib.sha256( + record_io.canonical_json_bytes(record.model_dump(mode="json")) + ).hexdigest() + + assert decision_record_checksum(record) == expected + changed = record.model_copy(update={"rationale": "A different rationale."}) + assert decision_record_checksum(changed) != expected + + +def test_exact_load_rejects_tampered_snapshot_content(tmp_path: Any) -> None: + workflow_run_id = "workflow-tampered" + path = tmp_path / "tampered.zarr" + group = _seed_orchestration(path, workflow_run_id) + snapshot = save_decision_workflow_snapshot( + path, + DecisionWorkflowRun(workflowRunId=workflow_run_id), + created_at_ns=10, + ) + key = persistence_module._snapshot_key( + "agents/orchestrations", + workflow_run_id, + snapshot.contentSha256, + ) + raw = record_io.read_key(group, key) + assert raw is not None + payload = json.loads(raw) + payload["createdAtNs"] = 11 + _set_raw(group, key, record_io.display_json_bytes(payload)) + + with pytest.raises(ValueError, match="does not match its content"): + load_decision_workflow_snapshot(path, workflow_run_id, snapshot.contentSha256) + + +def test_latest_load_rejects_a_gap_or_fork_in_the_chain(tmp_path: Any) -> None: + workflow_run_id = "workflow-gap" + path = tmp_path / "gap.zarr" + group = _seed_orchestration(path, workflow_run_id) + first = save_decision_workflow_snapshot( + path, + DecisionWorkflowRun(workflowRunId=workflow_run_id), + created_at_ns=10, + ) + values = { + "sequence": 2, + "createdAtNs": 20, + "parentContentSha256": first.contentSha256, + "orchestrationRun": first.orchestrationRun, + "workflow": first.workflow, + "contentSha256": "0" * 64, + } + unhashed = DecisionWorkflowSnapshot.model_validate(values) + values["contentSha256"] = persistence_module._snapshot_checksum(unhashed) + orphan = DecisionWorkflowSnapshot.model_validate(values) + key = persistence_module._snapshot_key( + "agents/orchestrations", workflow_run_id, orphan.contentSha256 + ) + _set_raw( + group, + key, + record_io.display_json_bytes(orphan.model_dump(mode="json")), + ) + + with pytest.raises(ValueError, match="gap or fork"): + load_latest_decision_workflow_snapshot(path, workflow_run_id) + + +def test_unknown_or_old_formats_fail_with_actionable_rerun_message( + tmp_path: Any, +) -> None: + workflow_run_id = "workflow-old" + old_path = tmp_path / "old.zarr" + _seed_orchestration(old_path, workflow_run_id, format_version=1) + + with pytest.raises( + DecisionPersistenceFormatError, match="Start a new orchestration run" + ): + save_decision_workflow_snapshot( + old_path, + DecisionWorkflowRun(workflowRunId=workflow_run_id), + created_at_ns=10, + ) + + current_path = tmp_path / "unknown-snapshot.zarr" + group = _seed_orchestration(current_path, workflow_run_id) + snapshot = save_decision_workflow_snapshot( + current_path, + DecisionWorkflowRun(workflowRunId=workflow_run_id), + created_at_ns=10, + ) + key = persistence_module._snapshot_key( + "agents/orchestrations", workflow_run_id, snapshot.contentSha256 + ) + raw = record_io.read_key(group, key) + assert raw is not None + payload = json.loads(raw) + payload["formatVersion"] = 1 + _set_raw(group, key, record_io.display_json_bytes(payload)) + + with pytest.raises( + DecisionPersistenceFormatError, match="Start a new orchestration run" + ): + load_decision_workflow_snapshot( + current_path, workflow_run_id, snapshot.contentSha256 + ) + + +def test_snapshot_requires_exact_orchestration_run_identity(tmp_path: Any) -> None: + path = tmp_path / "identity.zarr" + _seed_orchestration(path, "workflow-identity") + + with pytest.raises(KeyError, match="Unknown orchestration run"): + save_decision_workflow_snapshot( + path, + DecisionWorkflowRun(workflowRunId="workflow-other"), + created_at_ns=10, + ) + + +def test_replay_requires_exact_completed_snapshot_and_handoff(tmp_path: Any) -> None: + workflow_run_id = "workflow-replay" + path = tmp_path / "replay.zarr" + _seed_orchestration(path, workflow_run_id) + completed = _workflow_for_status(workflow_run_id, "completed") + snapshot = save_decision_workflow_snapshot(path, completed, created_at_ns=10) + + replay = load_decision_workflow_for_replay( + path, + workflow_run_id, + snapshot.contentSha256, + expected_handoff_id=completed.finalHandoffId, + ) + assert replay == completed + with pytest.raises(ValueError, match="final handoff identity"): + load_decision_workflow_for_replay( + path, + workflow_run_id, + snapshot.contentSha256, + expected_handoff_id="handoff-other", + ) + + running_id = "workflow-running-replay" + running_path = tmp_path / "running-replay.zarr" + _seed_orchestration(running_path, running_id) + running = save_decision_workflow_snapshot( + running_path, + DecisionWorkflowRun(workflowRunId=running_id), + created_at_ns=10, + ) + with pytest.raises(RuntimeError, match="completed decision workflow"): + load_decision_workflow_for_replay( + running_path, running_id, running.contentSha256 + ) + + +def test_builder_attaches_only_exact_audited_transition_order() -> None: + workflow_run_id = "workflow-builder" + workflow = DecisionWorkflowRun(workflowRunId=workflow_run_id) + grouping_record, grouping_compiled = _compiled_qc_grouping(workflow_run_id) + after_grouping = attach_audited_rna_decision( + workflow, + grouping_record, + grouping_compiled, + ) + cell_record, cell_compiled = _compiled_cell_quality(workflow_run_id) + + after_cell = attach_audited_rna_decision( + after_grouping, + cell_record, + cell_compiled, + ) + assert after_cell.decisionRecords == [grouping_record, cell_record] + assert after_cell.verificationRecords == [ + grouping_compiled.verification, + cell_compiled.verification, + ] + + pca = build_pca_prefix_decision(evidence_bundle_id="bundle:pca", matrix_rank=50) + pca_bundle = EvidenceBundle( + bundleId="bundle:pca", + decisionId="pcaPrefix", + evidence=[ + DecisionEvidence( + evidenceId="evidence:geometry", + evidenceClass="geometric", + summary="The standard prefix has stable neighbors.", + ), + DecisionEvidence( + evidenceId="evidence:technical", + evidenceClass="technical", + summary="The standard prefix is not dominated by technical loadings.", + ), + ], + ).with_content_sha256() + assert pca_bundle.contentSha256 is not None + pca_record = DecisionRecord( + recordId="decision:workflow-builder:pca", + decisionId="pcaPrefix", + definitionVersion=1, + evidenceBundleId=pca_bundle.bundleId, + evidenceBundleSha256=pca_bundle.contentSha256, + offeredOptionIds=[option.optionId for option in pca.spec.options], + availableEvidenceIds=[item.evidenceId for item in pca_bundle.evidence], + selectedOptionId="pcaPrefix:standard", + status="apply", + source="agent", + evidenceIds=[item.evidenceId for item in pca_bundle.evidence], + rationale="The standard prefix is the smallest stable registered option.", + verificationId="verification:decision:workflow-builder:pca", + ) + pca_compiled = compile_rna_decision(pca, pca_bundle, pca_record) + with pytest.raises(ValueError, match="transition order"): + attach_audited_rna_decision(after_cell, pca_record, pca_compiled) + + features = build_feature_policy_decision( + evidence_bundle_id="bundle:features", + proposed_exclusion_families=[], + dominant_families=[], + protected_families=[], + ) + feature_bundle = EvidenceBundle( + bundleId="bundle:features", + decisionId="featurePolicy", + evidence=[ + DecisionEvidence( + evidenceId="evidence:technical", + evidenceClass="technical", + summary="No conditional family dominates the representation.", + ) + ], + ).with_content_sha256() + assert feature_bundle.contentSha256 is not None + feature_record = DecisionRecord( + recordId="decision:workflow-builder:features", + decisionId="featurePolicy", + definitionVersion=1, + evidenceBundleId=feature_bundle.bundleId, + evidenceBundleSha256=feature_bundle.contentSha256, + offeredOptionIds=[option.optionId for option in features.spec.options], + availableEvidenceIds=["evidence:technical"], + selectedOptionId="featurePolicy:keepAll", + status="skip", + source="agent", + evidenceIds=["evidence:technical"], + rationale="No eligible nuisance bundle is supported.", + verificationId="verification:decision:workflow-builder:features", + ) + feature_compiled = compile_rna_decision(features, feature_bundle, feature_record) + after_features = attach_audited_rna_decision( + after_cell, feature_record, feature_compiled + ) + assert [record.decisionId for record in after_features.decisionRecords] == [ + "qcGrouping", + "cellQuality", + "featurePolicy", + ] + + +def test_snapshot_storage_does_not_overwrite_existing_content(tmp_path: Any) -> None: + workflow_run_id = "workflow-no-overwrite" + path = tmp_path / "no-overwrite.zarr" + group = _seed_orchestration(path, workflow_run_id) + snapshot = save_decision_workflow_snapshot( + path, + DecisionWorkflowRun(workflowRunId=workflow_run_id), + created_at_ns=10, + ) + key = persistence_module._snapshot_key( + "agents/orchestrations", workflow_run_id, snapshot.contentSha256 + ) + + with pytest.raises(FileExistsError, match="already exists"): + persistence_module._write_key_once(group, key, b"different") + + assert record_io.read_key(group, key) == record_io.display_json_bytes( + snapshot.model_dump(mode="json") + ) + + +def test_resolver_replays_an_exact_audited_decision_without_provider( + tmp_path: Any, + monkeypatch: pytest.MonkeyPatch, +) -> None: + workflow_run_id = "workflow-decision-replay" + path = tmp_path / "decision-replay.zarr" + _seed_orchestration(path, workflow_run_id) + request_record = _orchestration_request_record(path, workflow_run_id) + evidence = EvidenceBundle( + bundleId="bundle:qc-grouping", + decisionId="qcGrouping", + evidence=[ + DecisionEvidence( + evidenceId="evidence:quality", + evidenceClass="qualityControl", + summary="Global quality-reference evidence is available.", + ), + DecisionEvidence( + evidenceId="evidence:design", + evidenceClass="design", + summary="No physical capture is registered.", + ), + ], + ) + definition = build_qc_grouping_decision( + evidence_bundle_id=evidence.bundleId, + physical_capture_eligible=False, + pooled_reference_eligible=False, + ) + calls = 0 + + def select_once(**_kwargs: Any) -> SimpleNamespace: + nonlocal calls + calls += 1 + return SimpleNamespace( + output=DecisionSelection( + selectedOptionId="qcGrouping:global", + evidenceIds=["evidence:quality", "evidence:design"], + rationale="The registered global reference is supported.", + ), + runInfo=SimpleNamespace(modelName="test-model"), + ) + + monkeypatch.setattr(decisions_module, "run_agent_sync", select_once) + resolver = DecisionStagesMixin() + resolver.model = object() + + first = resolver._resolve_rna_decision( + path, + request_record, + definition, + evidence, + {}, + ) + second = resolver._resolve_rna_decision( + path, + request_record, + definition, + evidence, + {}, + ) + + assert calls == 1 + assert first.record is not None + assert second.record == first.record + assert second.compiled == first.compiled + + +def test_agent_reconsideration_revises_and_recomputes_invalidated_descendant( + tmp_path: Any, + monkeypatch: pytest.MonkeyPatch, +) -> None: + workflow_run_id = "workflow-decision-revision" + path = tmp_path / "decision-revision.zarr" + _seed_orchestration(path, workflow_run_id) + request_record = _orchestration_request_record(path, workflow_run_id) + resolver = DecisionStagesMixin() + resolver.model = object() + + grouping_evidence = EvidenceBundle( + bundleId="bundle:qc-grouping:initial", + decisionId="qcGrouping", + evidence=[ + DecisionEvidence( + evidenceId="evidence:quality:initial", + evidenceClass="qualityControl", + summary="Initial quality evidence supports a global reference.", + ), + DecisionEvidence( + evidenceId="evidence:design:initial", + evidenceClass="design", + summary="No physical capture was initially licensed.", + ), + ], + ) + grouping_definition = build_qc_grouping_decision( + evidence_bundle_id=grouping_evidence.bundleId, + physical_capture_eligible=False, + pooled_reference_eligible=False, + ) + initial_grouping = resolver._resolve_rna_decision( + path, + request_record, + grouping_definition, + grouping_evidence, + {}, + rule_selection=DecisionSelection( + selectedOptionId="qcGrouping:global", + evidenceIds=[ + "evidence:quality:initial", + "evidence:design:initial", + ], + rationale="Use the only licensed global reference.", + ), + ) + assert initial_grouping.record is not None + + cell_evidence = EvidenceBundle( + bundleId="bundle:cell-quality", + decisionId="cellQuality", + evidence=[ + DecisionEvidence( + evidenceId="evidence:cell-quality", + evidenceClass="qualityControl", + summary="The published cells can be retained with diagnostic flags.", + ) + ], + ) + cell_definition = build_cell_quality_decision( + evidence_bundle_id=cell_evidence.bundleId, + available_profiles=["retainWithFlags", "globalMad5"], + ) + initial_cell = resolver._resolve_rna_decision( + path, + request_record, + cell_definition, + cell_evidence, + {}, + rule_selection=DecisionSelection( + selectedOptionId="cellQuality:retainWithFlags", + evidenceIds=["evidence:cell-quality"], + rationale="Retain the initial cells with diagnostic flags.", + ), + ) + assert initial_cell.record is not None + + revision_evidence = EvidenceBundle( + bundleId="bundle:qc-grouping:revision", + decisionId="qcGrouping", + evidence=[ + DecisionEvidence( + evidenceId="evidence:quality:revision", + evidenceClass="qualityControl", + summary="Capture-level projections are now available.", + ), + DecisionEvidence( + evidenceId="evidence:design:revision", + evidenceClass="design", + summary="Physical captures are now explicitly registered.", + ), + ], + ).with_content_sha256() + assert revision_evidence.contentSha256 is not None + revision_definition = build_qc_grouping_decision( + evidence_bundle_id=revision_evidence.bundleId, + physical_capture_eligible=True, + pooled_reference_eligible=False, + ) + monkeypatch.setattr( + decisions_module, + "run_agent_sync", + lambda **_kwargs: SimpleNamespace( + output=DecisionSelection( + selectedOptionId="qcGrouping:physicalCapture", + evidenceIds=[ + "evidence:quality:revision", + "evidence:design:revision", + ], + rationale="Use the newly licensed physical-capture references.", + ), + runInfo=SimpleNamespace(modelName="test-model"), + ), + ) + reconsidered = resolver._reconsider_rna_decision( + path, + request_record, + revision_definition, + revision_evidence, + {}, + ) + assert reconsidered.revised is True + assert reconsidered.resolution is not None + revised_grouping = reconsidered.resolution + revision = revised_grouping.workflow.revisionRequests[0] + assert revised_grouping.record is not None + assert revised_grouping.record.supersedes == initial_grouping.record.recordId + assert revised_grouping.workflow.revisionRequests == [revision] + assert [ + record.recordId + for record in revised_grouping.workflow.active_decision_records() + ] == [revised_grouping.record.recordId] + + revised_cell_definition = build_cell_quality_decision( + evidence_bundle_id=cell_evidence.bundleId, + available_profiles=["retainWithFlags", "captureMad5"], + ) + recomputed_cell = resolver._resolve_rna_decision( + path, + request_record, + revised_cell_definition, + cell_evidence, + {}, + rule_selection=DecisionSelection( + selectedOptionId="cellQuality:captureMad5", + evidenceIds=["evidence:cell-quality"], + rationale="Recompute cell quality within the registered captures.", + ), + ) + assert recomputed_cell.record is not None + assert recomputed_cell.record.supersedes == initial_cell.record.recordId + assert [ + record.recordId for record in recomputed_cell.workflow.active_decision_records() + ] == [revised_grouping.record.recordId, recomputed_cell.record.recordId] + + +def test_resolver_persists_pending_state_after_model_failure( + tmp_path: Any, + monkeypatch: pytest.MonkeyPatch, +) -> None: + workflow_run_id = "workflow-decision-failure" + path = tmp_path / "decision-failure.zarr" + _seed_orchestration(path, workflow_run_id) + request_record = _orchestration_request_record(path, workflow_run_id) + evidence = EvidenceBundle( + bundleId="bundle:qc-grouping", + decisionId="qcGrouping", + evidence=[ + DecisionEvidence( + evidenceId="evidence:quality", + evidenceClass="qualityControl", + summary="Registered cell-quality projections are available.", + ), + DecisionEvidence( + evidenceId="evidence:design", + evidenceClass="design", + summary="No physical capture is registered.", + ), + ], + ) + definition = build_qc_grouping_decision( + evidence_bundle_id=evidence.bundleId, + physical_capture_eligible=False, + pooled_reference_eligible=False, + ) + + def fail_model(**_kwargs: Any) -> None: + raise AgentRunError("bounded model failure") + + monkeypatch.setattr(decisions_module, "run_agent_sync", fail_model) + resolver = DecisionStagesMixin() + resolver.model = object() + + resolution = resolver._resolve_rna_decision( + path, + request_record, + definition, + evidence, + {}, + ) + + assert resolution.compiled is None + assert resolution.record is None + assert resolution.workflow.status == "needsInput" + assert resolution.pending is not None + persisted = load_latest_decision_workflow_snapshot(path, workflow_run_id) + assert persisted.workflow.pendingDecision == resolution.pending + + resumed = resolver._resolve_rna_decision( + path, + request_record, + definition, + evidence, + { + "decision:qcGrouping": { + "decisionId": "qcGrouping", + "optionId": "qcGrouping:global", + "rationale": "Use the completed registered grouping evidence.", + } + }, + ) + + assert resumed.workflow.status == "running" + assert resumed.workflow.pendingDecision is None + assert resumed.record is not None + assert resumed.record.source == "human" + + +def test_unattended_resolver_uses_registered_baseline_after_model_failure( + tmp_path: Any, + monkeypatch: pytest.MonkeyPatch, +) -> None: + workflow_run_id = "workflow-unattended-decision-failure" + path = tmp_path / "unattended-decision-failure.zarr" + _seed_orchestration(path, workflow_run_id) + request_record = _orchestration_request_record( + path, + workflow_run_id, + ).model_copy( + update={ + "config": AutomatedWorkflowConfig(inputPolicy="unattended"), + } + ) + evidence = EvidenceBundle( + bundleId="bundle:qc-grouping", + decisionId="qcGrouping", + evidence=[ + DecisionEvidence( + evidenceId="evidence:quality", + evidenceClass="qualityControl", + summary="Registered cell-quality projections are available.", + ), + DecisionEvidence( + evidenceId="evidence:design", + evidenceClass="design", + summary="No physical capture is registered.", + ), + ], + ) + definition = build_qc_grouping_decision( + evidence_bundle_id=evidence.bundleId, + physical_capture_eligible=False, + pooled_reference_eligible=False, + ) + + def fail_model(**_kwargs: Any) -> None: + raise AgentRunError("bounded model failure") + + monkeypatch.setattr(decisions_module, "run_agent_sync", fail_model) + resolver = DecisionStagesMixin() + resolver.model = object() + + resolution = resolver._resolve_rna_decision( + path, + request_record, + definition, + evidence, + {}, + ) + + assert resolution.workflow.status == "running" + assert resolution.pending is None + assert resolution.record is not None + assert resolution.record.selectedOptionId == "qcGrouping:global" + assert resolution.record.source == "rule" diff --git a/tests/test_agent_exec.py b/tests/test_agent_exec.py index a0237efe..69bdad4e 100644 --- a/tests/test_agent_exec.py +++ b/tests/test_agent_exec.py @@ -20,9 +20,15 @@ from pydantic_ai.models.openai import OpenAIChatModel from pydantic_ai.models.test import TestModel from pydantic_ai.providers.openai import OpenAIProvider +from pydantic_ai.exceptions import ModelHTTPError, UserError from scarf.agent import CovariateCharacterization, FeatureCharacterization, IngestResult -from scarf.agent.config.agent_exec import _model_name, run_agent, run_agent_sync +from scarf.agent.config.agent_exec import ( + _image_input_is_unsupported, + _model_name, + run_agent, + run_agent_sync, +) from scarf.agent.config import AgentRunConfig, get_model_settings, get_usage_limits from scarf.agent.tools import artifact_reference, core_artifact_reference from scarf.agent.types import ( @@ -47,6 +53,24 @@ def get_example(cls) -> "ExampleOutput": return cls(value="complete") +def test_image_input_rejection_is_distinguished_from_other_provider_errors() -> None: + unsupported = ModelHTTPError( + 400, + "text-model", + { + "message": "This model does not support multimodal (image/video/audio) inputs." + }, + ) + authentication = ModelHTTPError(401, "text-model", {"message": "Unauthorized"}) + unsupported_local = UserError("This model does not support binary content") + unrelated_local = UserError("max_retries must be greater than or equal to zero") + + assert _image_input_is_unsupported(unsupported) is True + assert _image_input_is_unsupported(authentication) is False + assert _image_input_is_unsupported(unsupported_local) is True + assert _image_input_is_unsupported(unrelated_local) is False + + def test_shared_models_have_blank_and_example_constructors() -> None: models = ( AgentRunConfig, diff --git a/tests/test_agent_experimental_context.py b/tests/test_agent_experimental_context.py index af56e53c..00c57057 100644 --- a/tests/test_agent_experimental_context.py +++ b/tests/test_agent_experimental_context.py @@ -474,6 +474,26 @@ async def reply( ) ] ) + if request == 2: + decision = _design_decision() + return ModelResponse( + parts=[ + ToolCallPart( + tool_name="analyze_experimental_design", + args={ + "column_domains": decision.columnDomains, + "coefficients_of_interest": ( + decision.coefficientsOfInterest + ), + "units_of_inference": { + name: unit.model_dump() + for name, unit in decision.unitsOfInference.items() + }, + "batch_columns": decision.batchCorrection.batchColumns, + }, + ) + ] + ) return ModelResponse( parts=[ ToolCallPart( @@ -505,6 +525,7 @@ async def reply( assert [call.toolName for call in result.runInfo.toolCalls] == [ "inspect_cell_covariates", "analyze_experimental_design", + "analyze_experimental_design", ] assert tool_names == { "inspect_cell_covariates", @@ -520,7 +541,7 @@ async def reply( assert sorted(store.zw.group_keys()) == ["artifacts", "cellData"] -def test_agent_uses_conservative_fallback_after_tool_retry_exhaustion( +def test_agent_pauses_after_design_tool_retry_exhaustion( monkeypatch: pytest.MonkeyPatch, ) -> None: store = _Store() @@ -558,23 +579,69 @@ def unavailable_design(**kwargs: Any) -> None: cell_selection=store.cell_selection, ) - assert analyze_retries == [1] - assert result.status == "done" - assert result.decision.batchCorrection.action == "skip" + assert analyze_retries == [3] + assert result.status == "needsInput" + assert result.decision.batchCorrection.action == "needsInput" assert result.decision.batchCorrection.batchColumns == [] assert result.cellSelection is not None assert result.cellSelection.artifactId == store.cell_selection.artifact_id - assert result.cellQc.profileId in { - profile.profileId for profile in result.qcProfiles - } - assert result.cellQc.evidenceIds == [ - profile.evidenceId - for profile in result.qcProfiles - if profile.profileId == result.cellQc.profileId - ] - assert result.runInfo.agentName == "experimental_context_fallback" - assert result.to_parameter_tuning_handoff().batchAction == "skip" - assert any("Harmony was skipped" in note for note in result.notes) + assert result.cellQc.profileId == "" + assert result.qcProfiles + assert result.runInfo.agentName == "experimental_context_needs_input" + with pytest.raises(ValueError, match="must be done"): + result.to_parameter_tuning_handoff() + assert any("could not produce" in note for note in result.notes) + + +def test_agent_recovers_malformed_batch_tool_call_without_input( + monkeypatch: pytest.MonkeyPatch, +) -> None: + store = _Store() + + def unavailable_design(**kwargs: Any) -> None: + deps = kwargs["deps"] + asyncio.run( + inspect_cell_covariates( + RunContext( + deps=deps, + model=TestModel(), + usage=RunUsage(), + ) + ) + ) + raise UnexpectedModelBehavior( + "Tool 'analyze_experimental_design' exceeded max retries count of 3; " + "caused by ModelRetry: Unknown batch column 'batchassay'" + ) + + monkeypatch.setattr( + experimental_context_module, + "run_agent_sync", + unavailable_design, + ) + result = ExperimentalContextAgent(object(), unattended=True).run( + store, + study_context="Population discovery across sequencing batches.", + study_objective="Discover stable cell populations.", + cell_selection=store.cell_selection, + directions={ + "columnDomains": { + "batch": "technical", + "donor": "design", + "sample": "design", + }, + "coefficientsOfInterest": [], + "unitsOfInference": {}, + "batchColumns": ["batch"], + }, + ) + + assert result.status == "done" + assert result.decision.needsInput == [] + assert result.decision.batchCorrection.action == "evaluateHarmony" + assert result.decision.batchCorrection.batchColumns == ["batch"] + assert result.to_parameter_tuning_handoff().batchAction == "evaluateHarmony" + assert result.runInfo.agentName == "experimental_context_deterministic" def test_handoff_builders_reject_incomplete_or_ambiguous_results() -> None: @@ -630,6 +697,36 @@ def test_tools_build_a_grounded_design_report_without_mutation() -> None: assert sorted(store.zw.group_keys()) == ["artifacts", "cellData"] +def test_exact_batch_direction_overrides_model_tool_arguments() -> None: + store = _Store() + context = _context(store, directions={"batchColumns": ["batch"]}) + decision = _design_decision() + + asyncio.run(inspect_cell_covariates(context)) + analyzed = asyncio.run( + analyze_experimental_design( + context, + column_domains=decision.columnDomains, + coefficients_of_interest=decision.coefficientsOfInterest, + units_of_inference=decision.unitsOfInference, + batch_columns=[], + ) + ) + + assert analyzed.batchSafety[0].batchColumns == ["batch"] + skip = decision.model_copy( + update={ + "batchCorrection": BatchCorrectionPlan( + action="skip", + rationale="Skip the declared batch condition.", + evidenceIds=["column:batch"], + ) + } + ) + with pytest.raises(ModelRetry, match="exact directed batch"): + validate_experimental_context(skip, context.deps) + + def test_qc_profiles_use_persisted_modality_and_shared_cell_selection() -> None: store = _Store() store.assay_names = ["protein", "peaks", "transcript"] @@ -656,6 +753,7 @@ def test_qc_profiles_use_persisted_modality_and_shared_cell_selection() -> None: assert {profile.action for profile in inspected.qcProfiles} == { "skip", "globalGaussian", + "registeredMad", } for profile in inspected.qcProfiles: assert profile.driverAssay == "transcript" @@ -725,23 +823,12 @@ def test_design_tool_offers_only_grounded_sample_mad_profiles() -> None: assert sample_profile.retainedCells == 12 assert sample_profile.evidenceId in analyzed.evidenceIds - decision.cellQc = CellQcPlan( - action=sample_profile.action, - profileId=sample_profile.profileId, - driverAssay=sample_profile.driverAssay, - driverAssayType=sample_profile.driverAssayType, - sampleColumn=sample_profile.sampleColumn, - sampleArtifact=sample_profile.sampleArtifact, - attributes=sample_profile.attributes, - artifactMetrics=sample_profile.artifactMetrics, - rationale="Use sample-aware retention evidence.", - evidenceIds=[sample_profile.evidenceId], - ) validated = validate_experimental_context(decision, context.deps) - assert validated.cellQc == decision.cellQc + assert validated.cellQc == CellQcPlan.get_blank() + assert sample_profile in context.deps.qcProfiles.values() -def test_caller_qc_direction_overrides_model_profile_selection() -> None: +def test_caller_qc_direction_shapes_evidence_without_preselection() -> None: store = _Store() store.cells._values["RNA_nCounts"] = np.arange(12, dtype=float) + 1 store.cells._values["RNA_nFeatures"] = np.arange(12, dtype=float) + 5 @@ -761,23 +848,13 @@ def test_caller_qc_direction_overrides_model_profile_selection() -> None: batch_columns=decision.batchCorrection.batchColumns, ) ) - decision.cellQc = CellQcPlan( - action="skip", - profileId="model-authored-profile", - evidenceIds=["model-authored-evidence"], - ) - validated = validate_experimental_context(decision, context.deps) - assert validated.cellQc.action == "sampleMad" - assert validated.cellQc.sampleColumn == "sample" - assert validated.cellQc.evidenceIds == [ - next( - profile.evidenceId - for profile in context.deps.qcProfiles.values() - if profile.action == "sampleMad" - ) - ] + assert validated.cellQc == CellQcPlan.get_blank() + assert any( + profile.action == "sampleMad" and profile.sampleColumn == "sample" + for profile in context.deps.qcProfiles.values() + ) def test_adt_and_hto_do_not_drive_qc_and_hto_identity_remains_metadata() -> None: @@ -1819,6 +1896,7 @@ def test_design_analysis_rejects_invalid_batch_proposals( column_domains={}, coefficients_of_interest=[], units_of_inference={}, + batch_columns=[], ) ) @@ -1978,7 +2056,6 @@ def validate( characterization, requested if requested is not None else {"disease"}, units, - candidate.cellQc, candidate_records or records, candidate_coefficients or coefficient_records, ) diff --git a/tests/test_agent_hvg_diagnostics.py b/tests/test_agent_hvg_diagnostics.py new file mode 100644 index 00000000..cf94979a --- /dev/null +++ b/tests/test_agent_hvg_diagnostics.py @@ -0,0 +1,142 @@ +import numpy as np +import pytest + +from scarf.agent.hvg_diagnostics import ( + HvgGroupVariability, + aggregate_hvg_rankings, + effective_hvg_candidate_counts, + run_hvg_diagnostic_artifacts, +) +from scarf.storage.artifacts import inspect_artifact + + +def test_hvg_candidate_counts_are_capped_and_unique() -> None: + assert effective_hvg_candidate_counts(2500) == (1000, 2000, 2500) + assert effective_hvg_candidate_counts(8, (3, 5, 10, 10)) == (3, 5, 8) + with pytest.raises(ValueError, match="greater than 0"): + effective_hvg_candidate_counts(0) + + +def test_batch_aware_hvg_ranking_keeps_nested_registered_candidates() -> None: + corrected = np.asarray([5.0, 4.0, 3.0, 2.0, 1.0]) + eligible = np.asarray([True, True, True, True, False]) + groups = [ + HvgGroupVariability( + group_id="str:a", + cell_count=10, + corrected_variance=np.asarray([5.0, 1.0, 4.0, 3.0, 2.0]), + detected_features=np.ones(5, dtype=bool), + ), + HvgGroupVariability( + group_id="str:b", + cell_count=10, + corrected_variance=np.asarray([1.0, 5.0, 4.0, 3.0, 2.0]), + detected_features=np.ones(5, dtype=bool), + ), + ] + + ranking = aggregate_hvg_rankings( + corrected, + eligible, + groups, + valid_group_count=2, + candidate_targets=(2, 3), + ) + + assert ranking.ranking_mode == "batchAware" + assert ranking.recurrence.tolist() == [1, 1, 2, 2, 0] + narrow = ranking.candidate_mask(2) + broad = ranking.candidate_mask(3) + assert np.all(~narrow | broad) + assert int(narrow.sum()) == 2 + assert int(broad.sum()) == 3 + + +def test_hvg_diagnostic_artifacts_reuse_exact_pooled_candidates( + datastore_ephemeral: object, +) -> None: + store = datastore_ephemeral + cell_selection = store.snapshot_cell_selection("I") + all_features = store.select_all_features(from_assay="RNA") + + first = run_hvg_diagnostic_artifacts( + store.zw, + store.RNA, + cell_selection=cell_selection, + eligible_features=all_features, + all_features=all_features, + technical_group_column=None, + min_group_cells=2, + min_cells=0, + n_bins=20, + lowess_frac=0.2, + invalidate_cache=False, + candidate_targets=(3, 5), + ) + second = run_hvg_diagnostic_artifacts( + store.zw, + store.RNA, + cell_selection=cell_selection, + eligible_features=all_features, + all_features=all_features, + technical_group_column=None, + min_group_cells=2, + min_cells=0, + n_bins=20, + lowess_frac=0.2, + invalidate_cache=False, + candidate_targets=(3, 5), + ) + + assert first == second + assert len(first) == 1 + global_ranking = first[0] + assert global_ranking.ranking_mode == "global" + assert [candidate.top_n for candidate in global_ranking.candidates] == [3, 5] + assert inspect_artifact(store.zw, global_ranking.diagnostic).operation == ( + "diagnose_hvg_candidates" + ) + masks = [ + np.asarray(store.load_artifact(candidate.features)["values"][:], dtype=bool) + for candidate in global_ranking.candidates + ] + assert int(masks[0].sum()) == 3 + assert int(masks[1].sum()) == 5 + assert np.all(~masks[0] | masks[1]) + + +def test_hvg_diagnostics_persist_global_and_batch_aware_rankings( + datastore_ephemeral: object, +) -> None: + store = datastore_ephemeral + midpoint = store.cells.N // 2 + store.cells.insert( + "technical_batch", + np.asarray(["a"] * midpoint + ["b"] * (store.cells.N - midpoint)), + overwrite=True, + ) + rankings = run_hvg_diagnostic_artifacts( + store.zw, + store.RNA, + cell_selection=store.snapshot_cell_selection("I"), + eligible_features=store.select_all_features(from_assay="RNA"), + all_features=store.select_all_features(from_assay="RNA"), + technical_group_column="technical_batch", + min_group_cells=2, + min_cells=0, + n_bins=20, + lowess_frac=0.2, + invalidate_cache=False, + candidate_targets=(3, 5), + ) + + assert [ranking.ranking_mode for ranking in rankings] == [ + "global", + "batchAware", + ] + assert rankings[0].diagnostic != rankings[1].diagnostic + assert len(rankings[1].valid_groups) == 2 + for ranking in rankings: + group = store.load_artifact(ranking.diagnostic) + assert group.attrs["ranking_mode"] == ranking.ranking_mode + assert [candidate.top_n for candidate in ranking.candidates] == [3, 5] diff --git a/tests/test_agent_ingest_manifest.py b/tests/test_agent_ingest_manifest.py new file mode 100644 index 00000000..e9831dd5 --- /dev/null +++ b/tests/test_agent_ingest_manifest.py @@ -0,0 +1,443 @@ +"""Tests for read-only H5AD decision manifests.""" + +from hashlib import sha256 +from pathlib import Path + +import h5py +import numpy as np +from scipy.sparse import csr_matrix + +from scarf.agent import ingest +from scarf.agent.ingest.manifest import inspect_h5ad_manifest + + +def _write_sparse_group( + h5: h5py.File | h5py.Group, + key: str, + values: np.ndarray, +) -> None: + matrix = csr_matrix(values) + group = h5.create_group(key) + group.attrs["encoding-type"] = "csr_matrix" + group.attrs["shape"] = values.shape + group.create_dataset("data", data=matrix.data) + group.create_dataset("indices", data=matrix.indices) + group.create_dataset("indptr", data=matrix.indptr) + + +def _write_categorical( + table: h5py.Group, + name: str, + *, + codes: list[int], + categories: list[str], +) -> None: + group = table.create_group(name) + group.attrs["encoding-type"] = "categorical" + group.create_dataset("codes", data=np.asarray(codes, dtype=np.int16)) + group.create_dataset( + "categories", + data=np.asarray([value.encode() for value in categories]), + ) + + +def _write_metadata_table( + h5: h5py.File, + key: str, + *, + n_rows: int, + feature_types: list[str] | None = None, + filtered: list[bool] | None = None, +) -> h5py.Group: + table = h5.create_group(key) + table.attrs["_index"] = "_index" + table.create_dataset( + "_index", + data=np.asarray( + [f"{key.replace('/', '_')}-{i}".encode() for i in range(n_rows)] + ), + ) + if feature_types is not None: + table.create_dataset( + "feature_name", + data=np.asarray([f"gene-{i}".encode() for i in range(n_rows)]), + ) + table.create_dataset( + "feature_types", + data=np.asarray([value.encode() for value in feature_types]), + ) + if filtered is not None: + table.create_dataset( + "feature_is_filtered", + data=np.asarray(filtered, dtype=bool), + ) + return table + + +def _write_cellxgene_h5ad(path: Path) -> None: + with h5py.File(path, mode="w") as h5: + _write_sparse_group( + h5, + "X", + np.asarray( + [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9]], + dtype=np.float32, + ), + ) + _write_sparse_group( + h5, + "raw/X", + np.asarray( + [[1, 0, 3, 0], [0, 2, 4, 1], [5, 0, 0, 2]], + dtype=np.uint16, + ), + ) + layers = h5.create_group("layers") + _write_sparse_group( + layers, + "log1p", + np.asarray( + [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9]], + dtype=np.float32, + ), + ) + + obs = _write_metadata_table(h5, "obs", n_rows=3) + _write_categorical( + obs, + "assay_ontology_term_id", + codes=[0, 0, 0], + categories=["EFO:0009922"], + ) + _write_categorical( + obs, + "suspension_type", + codes=[0, 0, 0], + categories=["cell"], + ) + _write_categorical( + obs, + "organism_ontology_term_id", + codes=[0, 0, 0], + categories=["NCBITaxon:9606"], + ) + _write_categorical( + obs, + "donor_id", + codes=[0, 0, 1], + categories=["donor-a", "donor-b"], + ) + _write_categorical( + obs, + "batch", + codes=[0, 0, 1], + categories=["batch-a", "batch-b"], + ) + _write_categorical( + obs, + "cell_type", + codes=[0, 1, 1], + categories=["secret-author-label-a", "secret-author-label-b"], + ) + _write_categorical( + obs, + "cell_type_ontology_term_id", + codes=[0, 1, 1], + categories=["CL:0000001", "CL:0000002"], + ) + _write_categorical( + obs, + "leiden", + codes=[0, 1, 1], + categories=["cluster-zero", "cluster-one"], + ) + obs.create_dataset( + "is_primary_data", + data=np.asarray([True, False, True], dtype=bool), + ) + + _write_metadata_table( + h5, + "var", + n_rows=3, + feature_types=["Gene Expression"] * 3, + filtered=[False, False, True], + ) + _write_metadata_table( + h5, + "raw/var", + n_rows=4, + feature_types=["Gene Expression"] * 4, + filtered=[False, True, False, True], + ) + obsm = h5.create_group("obsm") + obsm.create_dataset("X_umap", data=np.zeros((3, 2), dtype=np.float32)) + uns = h5.create_group("uns") + uns.create_dataset("schema_version", data=np.bytes_("7.1.0")) + uns.create_dataset( + "schema_reference", + data=np.bytes_("https://example.invalid/schema/7.1.0"), + ) + uns.create_dataset("batch_condition", data=np.asarray([b"batch"])) + uns.create_dataset( + "leiden_colors", + data=np.asarray([b"#000000", b"#ffffff"]), + ) + uns.create_group("neighbors") + + +def _write_simple_h5ad( + path: Path, + x: np.ndarray, + *, + raw: np.ndarray | None = None, + feature_type: str = "Gene Expression", +) -> None: + with h5py.File(path, mode="w") as h5: + _write_sparse_group(h5, "X", x) + obs = _write_metadata_table(h5, "obs", n_rows=x.shape[0]) + _write_categorical( + obs, + "donor_id", + codes=list(range(x.shape[0])), + categories=[f"d{i}" for i in range(x.shape[0])], + ) + _write_metadata_table( + h5, + "var", + n_rows=x.shape[1], + feature_types=[feature_type] * x.shape[1], + ) + if raw is not None: + _write_sparse_group(h5, "raw/X", raw) + _write_metadata_table( + h5, + "raw/var", + n_rows=raw.shape[1], + feature_types=[feature_type] * raw.shape[1], + ) + + +def test_h5ad_manifest_selects_one_raw_matrix_and_holds_out_labels( + tmp_path: Path, +) -> None: + path = tmp_path / "discover.h5ad" + _write_cellxgene_h5ad(path) + before = path.stat() + + manifest = inspect_h5ad_manifest( + path, + source_uri="cxg://collection/dataset@7.1.0", + chunk_values=2, + ) + + assert manifest.decision.status == "supported" + assert manifest.decision.selectedMatrixKey == "raw/X" + assert manifest.selectedFeatureMetadataKey == "raw/var" + assert manifest.nCells == 3 + assert manifest.nFeatures == 4 + assert manifest.sourceSha256 == sha256(path.read_bytes()).hexdigest() + assert manifest.sourceSizeBytes == path.stat().st_size + assert manifest.sourceUri == "cxg://collection/dataset@7.1.0" + assert [item.key for item in manifest.matrixCandidates if item.selected] == [ + "raw/X" + ] + assert {item.key for item in manifest.matrixCandidates} == { + "X", + "layers/log1p", + "raw/X", + } + + visible_obs = {column.name: column for column in manifest.obs.columns} + assert "cell_type" not in visible_obs + assert "cell_type_ontology_term_id" not in visible_obs + assert "leiden" not in visible_obs + assert manifest.obs.heldOutAuthorColumnCount == 3 + assert manifest.labelBenchmarkEligible is True + assert manifest.assayMetadata is not None + assert manifest.assayMetadata.domainValues == ["EFO:0009922"] + assert manifest.suspensionMetadata is not None + assert manifest.suspensionMetadata.domainValues == ["cell"] + assert manifest.organismMetadata is not None + assert manifest.organismMetadata.domainValues == ["NCBITaxon:9606"] + assert manifest.declaredBatchColumns == ["batch"] + serialized = manifest.model_dump_json() + assert "secret-author-label" not in serialized + assert "cluster-zero" not in serialized + + assert manifest.inventory.layers == ["log1p"] + assert manifest.inventory.obsm == ["X_umap"] + assert manifest.inventory.priorEmbeddings == ["obsm/X_umap"] + assert "leiden_colors" not in manifest.inventory.uns + assert manifest.inventory.heldOutUnsItemCount == 1 + assert manifest.priorFiltering.cellXGeneSchemaDetected is True + assert manifest.priorFiltering.cellXGeneSchemaVersion == "7.1.0" + assert manifest.priorFiltering.cellSetStatus == "publishedCellsOnly" + assert manifest.priorFiltering.rawCountsAvailable is True + assert manifest.priorFiltering.originalDropletsAvailable is False + assert manifest.priorFiltering.filteredFeatureCount == 2 + assert manifest.priorFiltering.nonPrimaryCellCount == 1 + assert path.stat().st_mtime_ns == before.st_mtime_ns + + +def test_h5ad_manifest_preservation_policy_exposes_labels_but_disables_benchmark( + tmp_path: Path, +) -> None: + path = tmp_path / "discover.h5ad" + _write_cellxgene_h5ad(path) + + manifest = inspect_h5ad_manifest(path, author_label_policy="preservation") + + columns = {column.name: column for column in manifest.obs.columns} + assert columns["cell_type"].domainValues == [ + "secret-author-label-a", + "secret-author-label-b", + ] + assert columns["leiden"].domainValues == ["cluster-zero", "cluster-one"] + assert manifest.obs.heldOutAuthorColumnCount == 0 + assert manifest.labelBenchmarkEligible is False + assert "leiden_colors" in manifest.inventory.uns + + +def test_h5ad_manifest_abstains_for_normalized_only_source(tmp_path: Path) -> None: + path = tmp_path / "normalized.h5ad" + _write_simple_h5ad( + path, + np.asarray([[0.1, 0.2], [0.3, 0.4]], dtype=np.float32), + ) + + manifest = inspect_h5ad_manifest(path) + + assert manifest.decision.status == "abstained" + assert manifest.decision.reasonCode == "normalizedOnly" + assert manifest.decision.selectedMatrixKey is None + assert manifest.selectedFeatureMetadataKey is None + assert manifest.priorFiltering.rawCountsAvailable is False + assert not any(candidate.selected for candidate in manifest.matrixCandidates) + + +def test_h5ad_manifest_prefers_authoritative_raw_x_over_integer_x( + tmp_path: Path, +) -> None: + path = tmp_path / "raw-authoritative.h5ad" + _write_simple_h5ad( + path, + np.asarray([[1, 2], [3, 4]], dtype=np.uint16), + raw=np.asarray([[5, 6, 7], [8, 9, 10]], dtype=np.uint16), + ) + + manifest = inspect_h5ad_manifest(path) + + assert manifest.decision.status == "supported" + assert manifest.decision.selectedMatrixKey == "raw/X" + assert [ + candidate.key for candidate in manifest.matrixCandidates if candidate.selected + ] == ["raw/X"] + + +def test_h5ad_manifest_requires_selection_for_conflicting_count_layer( + tmp_path: Path, +) -> None: + path = tmp_path / "ambiguous.h5ad" + _write_simple_h5ad( + path, + np.asarray([[1, 2], [3, 4]], dtype=np.uint16), + raw=np.asarray([[5, 6, 7], [8, 9, 10]], dtype=np.uint16), + ) + with h5py.File(path, mode="a") as h5: + layers = h5.create_group("layers") + _write_sparse_group( + layers, + "counts", + np.asarray([[11, 12], [13, 14]], dtype=np.uint16), + ) + + unresolved = inspect_h5ad_manifest(path) + resolved = inspect_h5ad_manifest(path, matrix_key="raw/X") + + assert unresolved.decision.status == "needsInput" + assert unresolved.decision.reasonCode == "ambiguousCountMatrices" + assert unresolved.decision.options == ["X", "layers/counts", "raw/X"] + assert not any(candidate.selected for candidate in unresolved.matrixCandidates) + assert resolved.decision.status == "supported" + assert resolved.decision.selectedMatrixKey == "raw/X" + + +def test_manifest_selected_matrix_key_is_accepted_by_ingest(tmp_path: Path) -> None: + path = tmp_path / "raw-authoritative.h5ad" + _write_simple_h5ad( + path, + np.asarray([[1, 2], [3, 4]], dtype=np.uint16), + raw=np.asarray([[5, 6, 7], [8, 9, 10]], dtype=np.uint16), + ) + manifest = inspect_h5ad_manifest(path) + + result = ingest( + path=path, + zarrPath=tmp_path / "converted.zarr", + directions={"matrixKey": manifest.decision.selectedMatrixKey}, + ) + + assert result.status == "done" + assert result.acceptedActions[0] == { + "op": "inspect_h5ad", + "path": str(path), + "matrixKey": "raw/X", + } + + +def test_h5ad_manifest_abstains_when_selected_features_have_no_rna( + tmp_path: Path, +) -> None: + path = tmp_path / "adt.h5ad" + _write_simple_h5ad( + path, + np.asarray([[1, 2], [3, 4]], dtype=np.uint16), + feature_type="Antibody Capture", + ) + + manifest = inspect_h5ad_manifest(path) + + assert manifest.decision.status == "abstained" + assert manifest.decision.reasonCode == "rnaModalityUnavailable" + assert manifest.decision.options == ["ADT"] + assert not any(candidate.selected for candidate in manifest.matrixCandidates) + + +def test_h5ad_manifest_caps_domains_while_counting_missing_values( + tmp_path: Path, +) -> None: + path = tmp_path / "domains.h5ad" + with h5py.File(path, mode="w") as h5: + _write_sparse_group( + h5, + "X", + np.asarray([[1], [2], [3], [4]], dtype=np.uint16), + ) + obs = _write_metadata_table(h5, "obs", n_rows=4) + obs.create_dataset( + "free_text_group", + data=np.asarray([b"a", b"b", b"", b"c"]), + ) + _write_metadata_table( + h5, + "var", + n_rows=1, + feature_types=["Gene Expression"], + ) + + manifest = inspect_h5ad_manifest( + path, + chunk_values=1, + max_domain_values=2, + ) + + column = next( + column for column in manifest.obs.columns if column.name == "free_text_group" + ) + assert column.missingCount == 1 + assert column.missingFraction == 0.25 + assert column.domainTruncated is True + assert column.domainSize is None + assert column.domainValues == [] + assert column.valueCounts == {} diff --git a/tests/test_agent_orchestrator.py b/tests/test_agent_orchestrator.py index 1fc29134..4259cc7c 100644 --- a/tests/test_agent_orchestrator.py +++ b/tests/test_agent_orchestrator.py @@ -1,6 +1,6 @@ """Public facade, model, and end-to-end orchestrator contracts.""" -import re +import json from pathlib import Path from typing import Any @@ -27,12 +27,18 @@ FeatureSelectionPolicy, StudyContextSummary, ) +from scarf.agent.decision_kernel import DecisionSelection +from scarf.agent.decision_persistence import ( + load_latest_decision_workflow_snapshot, +) from scarf.agent.experimental_context import ( BatchCorrectionPlan, CellQcPlan, CovariateEvidence, ExperimentalContextDecision, ) +from scarf.agent.parameter_tuning import ParameterTuningReport +from scarf.agent.persistence import load_agent_record from scarf.agent.orchestrator import ( AgentOrchestrator, AssayPreprocessingPlan, @@ -50,15 +56,8 @@ WorkflowStageLink, artifact_model_to_ref, ) -from scarf.agent.persistence import ( - load_agent_record, - load_agent_report, -) -from scarf.agent.parameter_tuning import ( - FinalGraphSelection, - ParameterTuningReport, -) from scarf.datastore.datastore import DataStore +from scarf.storage.refs import ArtifactRef from tests.test_agent_ingest import _write_h5ad @@ -70,6 +69,8 @@ def _rna_workflow_model() -> tuple[FunctionModel, dict[str, int]]: "enrichment": 0, "context": 0, "parameter": 0, + "pca_pauses": 0, + "pca_prompts": 0, "biology": 0, "requests": 0, } @@ -183,7 +184,7 @@ async def reply( profile = next( value for value in context_evidence.qcProfiles - if value.action == "globalGaussian" + if value.registeredProfile is not None ) evidence_id = profile.evidenceId decision = ExperimentalContextDecision( @@ -192,16 +193,6 @@ async def reply( rationale="No trusted technical batch column was supplied.", evidenceIds=[evidence_id], ), - cellQc=CellQcPlan( - action=profile.action, - profileId=profile.profileId, - driverAssay=profile.driverAssay, - driverAssayType=profile.driverAssayType, - attributes=profile.attributes, - artifactMetrics=profile.artifactMetrics, - rationale="Apply the bounded global RNA QC profile.", - evidenceIds=[evidence_id], - ), rationale="No experimental covariates were supplied.", evidenceIds=[evidence_id], ) @@ -287,58 +278,53 @@ async def reply( ) prompt = prompt_text(messages) - if state["parameter"] == 0: - match = re.search( - r'"candidateId"\s*:\s*"([A-Za-z0-9_]+)"', - prompt, + payload, _ = json.JSONDecoder().raw_decode(prompt[prompt.index("{") :]) + decision = payload + decision_id = decision["decisionId"] + if decision_id == "pcaPrefix": + state["pca_prompts"] += 1 + evidence_by_class: dict[str, str] = {} + evidence_class_by_id: dict[str, str] = {} + for item in payload["evidence"]: + evidence_by_class.setdefault( + item["evidenceClass"], + item["evidenceId"], ) - assert match is not None - candidate_id = match.group(1) - evidence_id = f"candidate:{candidate_id}:clusters" - assay_report = ParameterTuningReport( - status="done", - recommendedCandidateId=candidate_id, - confidence="high", - rationale="The only authorized native branch is eligible.", - evidenceIds=[evidence_id], - stopReason="The bounded one-candidate screen completed.", + evidence_class_by_id[item["evidenceId"]] = item["evidenceClass"] + preferred = decision.get("metricPreferredOptionId") + selected = ( + next( + option for option in decision["options"] if option["status"] == "defer" ) - report = ParameterTuningReport( - status="done", - assayReports={"RNA": assay_report}, - rationale="The RNA native screen completed.", - evidenceIds=[evidence_id], - stopReason="Native selection completed.", + if decision_id == "pcaPrefix" and state["pca_pauses"] == 0 + else next( + option + for option in decision["options"] + if option["optionId"] == preferred ) - state["parameter"] = 1 - return ModelResponse( - parts=[ - ToolCallPart( - tool_name=info.output_tools[0].name, - args=report.model_dump(), - ) - ] + if preferred is not None + else next( + option + for option in decision["options"] + if option["status"] in {"apply", "skip"} ) - - match = re.search( - r'"optionId"\s*:\s*"(native:RNA:([A-Za-z0-9_]+))"', - prompt, ) - assert match is not None - option_id, candidate_id = match.groups() - evidence_id = f"native:RNA:candidate:{candidate_id}:clusters" - selection = FinalGraphSelection( - status="done", - selectedOptionId=option_id, - graphMethod="native", - nativeAssay="RNA", - nativeCandidateId=candidate_id, - markerAssay="RNA", + if decision_id == "pcaPrefix" and selected["status"] == "defer": + state["pca_pauses"] += 1 + evidence_ids = list(selected.get("requiredEvidenceIds", [])) + cited_classes = { + evidence_class_by_id[evidence_id] for evidence_id in evidence_ids + } + for evidence_class in selected["requiredEvidenceClasses"]: + if evidence_class not in cited_classes: + evidence_ids.append(evidence_by_class[evidence_class]) + selection = DecisionSelection( + selectedOptionId=selected["optionId"], + evidenceIds=evidence_ids, + rationale="Select the first eligible registered option for this test.", confidence="high", - rationale="The sole eligible native graph is selected.", - evidenceIds=[evidence_id], ) - state["parameter"] = 2 + state["parameter"] += 1 return ModelResponse( parts=[ ToolCallPart( @@ -408,7 +394,12 @@ def test_orchestrator_package_preserves_the_public_facade() -> None: @pytest.mark.slow -def test_rna_h5ad_completes_public_automated_workflow(tmp_path: Path) -> None: +def test_rna_h5ad_completes_public_automated_workflow( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from scarf.agent.orchestrator import tuning as tuning_module + rng = np.random.default_rng(4444) values = rng.poisson(1.0, size=(80, 50)).astype(np.uint16) values[:40, :12] += rng.poisson(9.0, size=(40, 12)).astype(np.uint16) @@ -449,6 +440,18 @@ def test_rna_h5ad_completes_public_automated_workflow(tmp_path: Path) -> None: feature_names=feature_names, ) model, state = _rna_workflow_model() + phase_calls: list[str] = [] + execute_parameter_phase = tuning_module.execute_parameter_phase + + def track_parameter_phase(*args: Any, **kwargs: Any) -> Any: + phase_calls.append(kwargs["plan"].phase) + return execute_parameter_phase(*args, **kwargs) + + monkeypatch.setattr( + tuning_module, + "execute_parameter_phase", + track_parameter_phase, + ) orchestrator = AgentOrchestrator( model, config=AutomatedWorkflowConfig( @@ -462,23 +465,50 @@ def test_rna_h5ad_completes_public_automated_workflow(tmp_path: Path) -> None: ), ) - result = orchestrator.run( - AutomatedWorkflowRequest( - sourcePath=str(source), + request = AutomatedWorkflowRequest( + sourcePath=str(source), + zarrPath=str(target), + studyContext=( + "A human peripheral blood RNA study for a deterministic acceptance test." + ), + studyObjective="Discover stable RNA populations.", + primaryAssay="RNA", + markerAssay="RNA", + analysisAssays=["RNA"], + ) + paused = orchestrator.run(request) + + assert paused.status == "needsInput" + assert paused.currentStage == "parameter_tuning" + assert paused.workflowRun is not None + assert paused.needsInput is not None + assert len(paused.needsInput.questions) == 1 + question = paused.needsInput.questions[0] + assert question.questionId == "decision:pcaPrefix" + assert question.decisionId == "pcaPrefix" + assert question.options + selected_option = next( + option_id for option_id in question.options if option_id != "pcaPrefix:defer" + ) + pca_calls_before_resume = phase_calls.count("pcaPrefix") + result = orchestrator.resume( + AutomatedWorkflowResumeRequest( zarrPath=str(target), - studyContext=( - "A human peripheral blood RNA study for a deterministic " - "acceptance test." - ), - allowAssumptions=True, - primaryAssay="RNA", - markerAssay="RNA", - analysisAssays=["RNA"], + workflowRunId=paused.workflowRun.workflowRunId, + answers={ + question.questionId: { + "decisionId": question.decisionId, + "optionId": selected_option, + "rationale": "Use the completed registered PCA evidence.", + } + }, ) ) assert result.status == "completed", result.notes - assert result.currentStage == "biological_interpretation" + assert phase_calls.count("pcaPrefix") == pca_calls_before_resume + assert state["pca_prompts"] == 1 + assert result.currentStage == "analysis_finalization" assert result.workflowRun is not None assert result.workflowRun.status == "completed" report_path = ( @@ -491,16 +521,27 @@ def test_rna_h5ad_completes_public_automated_workflow(tmp_path: Path) -> None: ) assert report_path.is_file() assert "Nygen Analytics" in report_path.read_text(encoding="utf-8") - assert state["requests"] == 9 + assert state["requests"] >= 8 + assert state["biology"] == 0 assert [reference.agentName for reference in result.reportReferences] == [ "data_enrichment", "experimental_context", "parameter_tuning", - "biological_interpretation", + "parameter_tuning", ] assert result.finalAnalysis is not None assert result.preprocessingPlan is not None + assert result.preprocessingPlan.cellQualityPayload is not None + assert ( + result.preprocessingPlan.cellQualityPayload.profile + == result.preprocessingPlan.cellQc.registeredProfile + ) final = result.finalAnalysis + assert result.finalHandoffId == final.handoffId + assert result.decisionRunId == result.workflowRun.workflowRunId + assert result.verificationSummary + assert "pipelineRunId" not in result.model_dump() + assert "pipelineRunId" not in final.model_dump() assert final.graphMethod == "native" assert final.primaryAssay == final.markerAssay == "RNA" assert final.graph is not None @@ -509,6 +550,7 @@ def test_rna_h5ad_completes_public_automated_workflow(tmp_path: Path) -> None: assert final.embeddingInitialization is not None assert final.umap is not None assert final.markers is not None + assert len(final.doubletScores) == 1 assert final.cellSelection.kind == "cell_selection" assert final.clusters.kind == "cluster_labels" assert final.embeddingInitialization.kind == "embedding_initialization" @@ -522,6 +564,30 @@ def test_rna_h5ad_completes_public_automated_workflow(tmp_path: Path) -> None: ribo_pattern="", zarr_mode="r", ) + tuning_evaluations = [ + evaluation + for reference in result.reportReferences + if reference.agentName == "parameter_tuning" + for evaluation in ParameterTuningReport.model_validate( + load_agent_record(persisted, reference).report + ).evaluations + ] + pca_evidence = next( + evaluation + for evaluation in tuning_evaluations + if evaluation.metrics.componentVariance + ) + assert "representationDiagnostic" in pca_evidence.artifacts + assert any( + evidence_id.endswith(":pcaLoadings") for evidence_id in pca_evidence.evidenceIds + ) + cluster_evidence = next( + evaluation + for evaluation in tuning_evaluations + if evaluation.metrics.doubletHighScoreConcentration is not None + ) + assert cluster_evidence.metrics.markerCoherence is not None + assert "doubletScore:0" in cluster_evidence.artifacts umap_inputs = persisted.inspect_artifact(artifact_model_to_ref(final.umap)).inputs assert umap_inputs is not None assert umap_inputs["graph"] == artifact_model_to_ref(final.graph).to_dict() @@ -538,24 +604,42 @@ def test_rna_h5ad_completes_public_automated_workflow(tmp_path: Path) -> None: marker_inputs["cell_selection"] == artifact_model_to_ref(final.cellSelection).to_dict() ) - - biology_reference = next( - reference - for reference in result.reportReferences - if reference.agentName == "biological_interpretation" + doublet_inputs = persisted.inspect_artifact( + artifact_model_to_ref(final.doubletScores[0]) + ).inputs + assert doublet_inputs is not None + assert ( + doublet_inputs["connectivity_map"] + == artifact_model_to_ref(final.graph).to_dict() ) - biology = load_agent_report(target, biology_reference) - assert isinstance(biology, BiologicalInterpretationReport) - assert biology.status == "done" - assert biology.clusterInterpretations - assert biology.markerArtifact == final.markers - record = load_agent_record(target, biology_reference) - assert len(record.invocation.parentReports) == 3 - assert record.invocation.tuningBiologyHandoff is not None - assert set(record.invocation.artifacts) == { - "cellSelection", - "clusters", - "markers", - "markerFeatures", - } - assert record.invocation.artifacts["cellSelection"] == final.cellSelection + scored_partition = ArtifactRef.from_dict(doublet_inputs["clusters"]) + assert scored_partition.kind == "cluster_labels" + assert persisted.inspect_artifact(scored_partition).complete + decision_snapshot = load_latest_decision_workflow_snapshot( + persisted, + result.workflowRun.workflowRunId, + ) + assert decision_snapshot.workflow.status == "completed" + assert [ + record.decisionId + for record in decision_snapshot.workflow.active_decision_records() + ] == [ + "qcGrouping", + "cellQuality", + "featurePolicy", + "hvgRanking", + "hvgCount", + "pcaPrefix", + "correctionLicense", + "correctionOutcome", + "graphK", + "clusterPartition", + ] + assert decision_snapshot.workflow.finalHandoffId == final.handoffId + pca_record = next( + record + for record in decision_snapshot.workflow.active_decision_records() + if record.decisionId == "pcaPrefix" + ) + assert pca_record.source == "human" + assert "pipeline" not in persisted.zw diff --git a/tests/test_agent_orchestrator_journal_edges.py b/tests/test_agent_orchestrator_journal_edges.py index 34e90889..f7036336 100644 --- a/tests/test_agent_orchestrator_journal_edges.py +++ b/tests/test_agent_orchestrator_journal_edges.py @@ -13,6 +13,7 @@ AutomatedWorkflowRequest, AutomatedWorkflowResult, AutomatedWorkflowResumeRequest, + FinalAnalysisHandoff, WorkflowNeedsInput, WorkflowQuestion, WorkflowStageAttempt, @@ -78,13 +79,21 @@ def _terminal_workflow() -> AgentWorkflowRun: def _terminal_result(workflow: AgentWorkflowRun) -> AutomatedWorkflowResult: + final_analysis = FinalAnalysisHandoff( + workflowRunId=workflow.workflowRunId, + primaryAssay="RNA", + markerAssay="RNA", + ).with_handoff_id() return _with_checksum( AutomatedWorkflowResult( status="completed", - currentStage="biological_interpretation", + currentStage="analysis_finalization", zarrPath="analysis.zarr", workflowRun=workflow, reportReferences=list(workflow.reports), + finalAnalysis=final_analysis, + finalHandoffId=final_analysis.handoffId, + decisionRunId=workflow.workflowRunId, ) ) @@ -111,12 +120,31 @@ def test_orchestration_model_validation_edges() -> None: WorkflowStageAttempt(**updates) invalid_requests = ( - ({"sourcePath": " ", "studyContext": "study"}, "sourcePath"), - ({"sourcePath": "data", "studyContext": " "}, "studyContext"), + ( + { + "sourcePath": " ", + "studyContext": "study", + "studyObjective": "objective", + }, + "sourcePath", + ), + ( + { + "sourcePath": "data", + "studyContext": " ", + "studyObjective": "objective", + }, + "studyContext", + ), + ( + {"sourcePath": "data", "studyContext": "study"}, + "studyObjective", + ), ( { "sourcePath": "data", "studyContext": "study", + "studyObjective": "objective", "analysisAssays": ["RNA", "RNA"], }, "analysisAssays", @@ -125,6 +153,7 @@ def test_orchestration_model_validation_edges() -> None: { "sourcePath": "data", "studyContext": "study", + "studyObjective": "objective", "pairedAssays": ["RNA", "RNA"], }, "pairedAssays must be unique", @@ -133,6 +162,7 @@ def test_orchestration_model_validation_edges() -> None: { "sourcePath": "data", "studyContext": "study", + "studyObjective": "objective", "pairedAssays": ["RNA"], }, "at least two", @@ -179,6 +209,32 @@ def test_journal_storage_guards(monkeypatch: pytest.MonkeyPatch) -> None: journal_module._read_model(root, "bad.json", WorkflowQuestion) +def test_final_handoff_journal_is_content_addressed_and_idempotent() -> None: + root = zarr.open_group(store=MemoryStore(), mode="w") + root.create_group("agents") + store = SimpleNamespace(zw=root) + prefix = journal_module._ensure_orchestration_store(store) + handoff = FinalAnalysisHandoff( + workflowRunId="workflow-1", + primaryAssay="RNA", + markerAssay="RNA", + ).with_handoff_id() + + first = journal_module.save_final_analysis_handoff(store, prefix, handoff) + second = journal_module.save_final_analysis_handoff(store, prefix, handoff) + + assert first == second == handoff + assert ( + journal_module.load_final_analysis_handoff( + store, + prefix, + handoff.workflowRunId, + handoff.handoffId, + ) + == handoff + ) + + def test_orchestration_namespace_validation(monkeypatch: pytest.MonkeyPatch) -> None: root = zarr.open_group(store=MemoryStore(), mode="w") with pytest.raises(RuntimeError, match="Create the agent workflow"): @@ -620,11 +676,11 @@ def load(payload: bytes) -> AutomatedWorkflowResult | None: ) missing_workflow = _with_checksum( - AutomatedWorkflowResult( - status="completed", currentStage="biological_interpretation" + AutomatedWorkflowResult.model_construct( + status="completed", currentStage="analysis_finalization" ) ) - with pytest.raises(ValueError, match="missing its workflow identity"): + with pytest.raises(ValueError, match="Malformed automated workflow result"): load( journal_module.record_io.display_json_bytes( missing_workflow.model_dump(mode="json") diff --git a/tests/test_agent_orchestrator_lifecycle.py b/tests/test_agent_orchestrator_lifecycle.py index 3b51f3d8..939ff9c2 100644 --- a/tests/test_agent_orchestrator_lifecycle.py +++ b/tests/test_agent_orchestrator_lifecycle.py @@ -24,6 +24,7 @@ AutomatedWorkflowRequest, AutomatedWorkflowResult, AutomatedWorkflowResumeRequest, + FinalAnalysisHandoff, WorkflowNeedsInput, WorkflowQuestion, WorkflowStageAttempt, @@ -96,8 +97,12 @@ def fake_ingest( class _CheckpointOrchestrator(AgentOrchestrator): """Minimal deterministic stage machine exercising persistence and resume.""" - def __init__(self) -> None: - super().__init__(object()) + def __init__( + self, + *, + config: AutomatedWorkflowConfig | None = None, + ) -> None: + super().__init__(object(), config=config) self.enrichmentExecutions = 0 def _continue( @@ -221,6 +226,11 @@ def _continue( status="completed", message="Checkpoint workflow completed", ) + final_analysis = FinalAnalysisHandoff( + workflowRunId=terminal.workflowRunId, + primaryAssay="RNA", + markerAssay="RNA", + ).with_handoff_id() result = AutomatedWorkflowResult( status="completed", currentStage="preprocessing_plan", @@ -231,6 +241,9 @@ def _continue( prefix, workflow.workflowRunId, ), + finalAnalysis=final_analysis, + finalHandoffId=final_analysis.handoffId, + decisionRunId=terminal.workflowRunId, ) result = result.model_copy( update={"contentSha256": journal_module._record_checksum(result)} @@ -257,6 +270,7 @@ def _start_paused_workflow( sourcePath=str(path), zarrPath=str(path), studyContext="A deterministic test study.", + studyObjective="Discover stable RNA populations.", workspace=workspace, ) ) @@ -290,6 +304,30 @@ def test_orchestration_records_are_plain_json_with_valid_checksums( assert record_path.name in {"started.json", "outcome.json"} +def test_unattended_workflow_never_returns_needs_input( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + path = create_store(tmp_path / "data.zarr") + _mock_ingest(monkeypatch) + orchestrator = _CheckpointOrchestrator( + config=AutomatedWorkflowConfig(inputPolicy="unattended") + ) + + result = orchestrator.run( + AutomatedWorkflowRequest( + sourcePath=str(path), + zarrPath=str(path), + studyContext="A deterministic unattended test study.", + studyObjective="Discover stable RNA populations.", + ) + ) + + assert result.status == "failed" + assert result.needsInput is None + assert result.unresolvedClaims == ["Approve the preprocessing plan?"] + + def test_approval_resume_reuses_completed_stages_and_persists_answer_lineage( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -697,7 +735,7 @@ def test_failed_stage_preserves_completed_operation_journal(tmp_path: Path) -> N sourcePath=str(path), zarrPath=str(path), studyContext="A failed-stage journal test.", - allowAssumptions=True, + studyObjective="Discover stable RNA populations.", ), config=AutomatedWorkflowConfig(), ) @@ -765,7 +803,7 @@ def test_retryable_model_http_error_leaves_stage_interrupted( sourcePath=str(path), zarrPath=str(path), studyContext="A retryable model failure test.", - allowAssumptions=True, + studyObjective="Discover stable RNA populations.", ), config=AutomatedWorkflowConfig(), ) @@ -826,7 +864,7 @@ def test_nonretryable_model_http_error_remains_terminal( sourcePath=str(path), zarrPath=str(path), studyContext="A terminal model failure test.", - allowAssumptions=True, + studyObjective="Discover stable RNA populations.", ), config=AutomatedWorkflowConfig(), ) @@ -871,7 +909,7 @@ def test_failed_stage_links_report_committed_before_exception(tmp_path: Path) -> sourcePath=str(path), zarrPath=str(path), studyContext="A report-link crash test.", - allowAssumptions=True, + studyObjective="Discover stable RNA populations.", ), config=AutomatedWorkflowConfig(), ) diff --git a/tests/test_agent_orchestrator_stages.py b/tests/test_agent_orchestrator_stages.py index 2f67b825..f83106bf 100644 --- a/tests/test_agent_orchestrator_stages.py +++ b/tests/test_agent_orchestrator_stages.py @@ -1,5 +1,6 @@ """Context, preprocessing, tuning, integration, and finalization contracts.""" +import json import uuid from collections.abc import Mapping from pathlib import Path @@ -13,7 +14,12 @@ import scarf.agent.orchestrator.journal as journal_module import scarf.agent.orchestrator.tuning as tuning_module import scarf.agent.parameter_tuning as parameter_tuning_module +from scarf.agent.orchestrator.preprocessing import PreprocessingStagesMixin from scarf.agent.config import AgentRunConfig +from scarf.agent.config.agent_exec import ( + ImageEvidence, + ImageInputUnsupportedError, +) from scarf.agent.data_enrichment import ( AssayFeatureInspection, DataEnrichmentReport, @@ -22,7 +28,6 @@ FeatureSelectionPolicy, ) from scarf.agent.experimental_context import ( - BatchCorrectionPlan, CellQcPlan, CellQcProfileEvidence, ExperimentalContextResult, @@ -41,11 +46,9 @@ from scarf.agent.orchestrator.models import OrchestrationRequestRecord from scarf.agent.persistence import ( AgentInvocation, - AgentReportReference, create_agent_workflow, list_agent_reports, load_agent_record, - load_agent_workflow, save_agent_report, ) from scarf.agent.parameter_tuning import ( @@ -60,17 +63,77 @@ finalize_parameter_tuning_selection, select_final_parameter_graph, ) +from scarf.agent.qc_profiles import RegisteredCellQcProfile from scarf.agent.types import ( AgentRunInfo, ArtifactReferenceModel, BatchSafetyEvidence, ExperimentalTuningHandoff, ) +from scarf.agent.tuning_diagnostics import ( + _select_capture_cells, + resolve_native_doublet_inputs, +) from scarf.datastore.datastore import DataStore from scarf.storage.refs import ArtifactRef from tests.agent_orchestrator_store import create_store +def test_analysis_review_retries_with_numeric_evidence_when_images_are_unsupported( + monkeypatch: pytest.MonkeyPatch, +) -> None: + selected = ParameterCandidateEvaluation.get_example() + alternative_id = "alternative" + alternative = selected.model_copy( + update={ + "candidateId": alternative_id, + "parameters": selected.parameters.model_copy( + update={ + "candidateId": alternative_id, + "leidenResolution": 0.5, + } + ), + } + ) + prompts: list[object] = [] + + def run_review(**kwargs: Any) -> SimpleNamespace: + user_prompt = kwargs["user_prompt"] + prompts.append(user_prompt) + if not isinstance(user_prompt, str): + raise ImageInputUnsupportedError( + "The configured model does not accept image input" + ) + payload = json.loads(user_prompt) + assert payload["evidenceMode"] == "numeric" + assert payload["selectedCandidate"]["candidateId"] == selected.candidateId + assert payload["comparisonCandidates"][0]["candidateId"] == alternative_id + return SimpleNamespace( + output=tuning_module.AnalysisVisualAdjudication( + status="acceptable", + selectedCandidateId=selected.candidateId, + rationale="The supplied numeric evidence supports the selection.", + ) + ) + + monkeypatch.setattr(tuning_module, "run_agent_sync", run_review) + + review, mode = tuning_module._run_analysis_adjudication( + model=object(), + config=AutomatedWorkflowConfig(), + study_objective="Discover stable populations.", + selected=selected, + candidates=[selected, alternative], + visual_content=[ + ImageEvidence(identifier="diagnostic", data=b"png"), + ], + ) + + assert review.status == "acceptable" + assert mode == "numeric" + assert len(prompts) == 2 + + _PLAN_CHECKSUM = "a" * 64 @@ -171,6 +234,7 @@ def _planning_inputs( DataEnrichmentReport, ExperimentalContextResult, WorkflowStageAttempt, + CellQcPlan, ]: store = _PlanningStore(assays) policies = [ @@ -189,7 +253,7 @@ def _planning_inputs( sourcePath="dataset.zarr", zarrPath="dataset.zarr", studyContext="A bounded plan-construction test.", - allowAssumptions=True, + studyObjective="Discover stable RNA populations.", primaryAssay=primary_assay, markerAssay=marker_assay, analysisAssays=analysis_assays or list(assays), @@ -209,7 +273,14 @@ def _planning_inputs( completedAtNs=2, outputs={"format": "zarr"}, ) - return store, request_record, enrichment, experimental, ingest_outcome + return ( + store, + request_record, + enrichment, + experimental, + ingest_outcome, + CellQcPlan.get_example(), + ) def _build_plan( @@ -347,7 +418,7 @@ def test_unsafe_experimental_context_pauses_and_explicit_skip_reuses_evidence( sourcePath=str(path), zarrPath=str(path), studyContext="Treatment is confounded with batch.", - allowAssumptions=True, + studyObjective="Preserve treatment while discovering populations.", ), ) example = ExperimentalContextResult.get_example() @@ -490,7 +561,7 @@ def test_explicit_no_inference_skip_resolves_context_without_provider_rerun( sourcePath=str(path), zarrPath=str(path), studyContext="A study with unresolved replication.", - allowAssumptions=True, + studyObjective="Discover stable RNA populations.", ), ) example = ExperimentalContextResult.get_example() @@ -603,6 +674,48 @@ def run(self, *_args: Any, **_kwargs: Any) -> ExperimentalContextResult: ) +def test_qc_profile_safety_rejects_self_normalizing_failed_captures() -> None: + def profile( + registered_profile: RegisteredCellQcProfile, + *, + failed: list[str], + references: list[str] | None = None, + ) -> CellQcProfileEvidence: + return CellQcProfileEvidence( + profileId=f"cellQc:RNA:{registered_profile}", + action="registeredMad", + registeredProfile=registered_profile, + driverAssay="RNA", + driverAssayType="RNA", + sampleColumn="capture", + attributes=["RNA_nCounts"], + parameters={"pooledReferenceCaptures": references or []}, + activeCells=100, + retainedCells=90, + retainedFraction=0.9, + failedCaptureCandidates=failed, + evidenceId=f"qcProfile:{registered_profile}", + ) + + assert not PreprocessingStagesMixin._profile_is_safe( + profile("captureMad5", failed=["capture-b"]) + ) + assert PreprocessingStagesMixin._profile_is_safe( + profile( + "pooledReferenceMad5", + failed=["capture-b"], + references=["capture-a"], + ) + ) + assert not PreprocessingStagesMixin._profile_is_safe( + profile( + "pooledReferenceMad5", + failed=["capture-a"], + references=["capture-a"], + ) + ) + + def test_preprocessing_plan_routes_supported_modalities_and_skips_others() -> None: assays = { "peaks": ( @@ -756,7 +869,7 @@ def test_percent_features_follow_deterministic_inspection_not_policy_lists( sourcePath=str(path), zarrPath=str(path), studyContext="A deterministic feature-family test.", - allowAssumptions=True, + studyObjective="Discover stable RNA populations.", ), config=AutomatedWorkflowConfig(), ) @@ -894,7 +1007,7 @@ def test_hto_demultiplexing_is_checkpointed_once_and_never_graph_bearing( sourcePath=str(path), zarrPath=str(path), studyContext="A deterministic HTO checkpoint test.", - allowAssumptions=True, + studyObjective="Discover stable RNA populations.", ), config=AutomatedWorkflowConfig(), ) @@ -1366,70 +1479,13 @@ def test_initial_candidates_reject_fully_invalid_rank_or_neighbor_count() -> Non ) -def test_parameter_tuning_rejects_plan_above_global_branch_cap( +def test_parameter_tuning_rejects_legacy_refinement_budget( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - path = create_store(tmp_path / "branch-cap.zarr") - store = DataStore(str(path), default_assay="RNA", min_features_per_cell=0) - workflow = create_agent_workflow(store, workflow_run_id="branch-cap") - config = AutomatedWorkflowConfig( - primaryInitialCandidates=5, - maxRefinedCandidatesPerAssay=1, - maxHarmonyCandidatesPerAssay=0, - maxCandidateBranches=5, - ) - request_record = OrchestrationRequestRecord( - workflowRunId=workflow.workflowRunId, - request=AutomatedWorkflowRequest( - sourcePath=str(path), - zarrPath=str(path), - studyContext="A branch-cap test.", - allowAssumptions=True, - ), - config=config, - ) - plan = AutomatedPreprocessingPlan( - primaryAssay="RNA", - markerAssay="RNA", - assays=[AssayPreprocessingPlan.get_example()], - ) - handoff = PreprocessedAssayHandoff.get_example() - experimental = ExperimentalContextResult.get_example() - decision = experimental.decision.model_copy( - update={"batchCorrection": BatchCorrectionPlan(action="skip")} - ) - experimental = experimental.model_copy( - update={"decision": decision, "batchSafety": []} - ) - - class UnusedAgent: - def run_batch(self, *_args: Any, **_kwargs: Any) -> None: - pytest.fail("The tuning agent must not run above the global branch cap") - - monkeypatch.setattr( - tuning_module, - "ParameterTuningAgent", - lambda *_args, **_kwargs: UnusedAgent(), - ) - outcome, report = AgentOrchestrator(object()).parameter_tuning_stage( - store, - workflow, - request_record, - [], - plan, - [handoff], - experimental, - AgentReportReference.get_example(), - AgentReportReference.get_example(), - {}, - ) - - assert outcome.status == "failed" - assert outcome.error is not None - assert "exceeds the global branch limit 5" in outcome.error - assert report.status == "failed" - assert load_agent_workflow(store, workflow.workflowRunId).status == "failed" + del tmp_path, monkeypatch + with pytest.raises(ValueError, match="less than or equal to 0"): + AutomatedWorkflowConfig(maxRefinedCandidatesPerAssay=1) def test_final_selection_pause_exposes_exact_options_and_resumes_without_screen( @@ -1480,7 +1536,7 @@ def test_final_selection_pause_exposes_exact_options_and_resumes_without_screen( sourcePath=str(path), zarrPath=str(path), studyContext="A final-selection resume test.", - allowAssumptions=True, + studyObjective="Discover stable RNA populations.", ), ) plan = AutomatedPreprocessingPlan( @@ -1893,7 +1949,7 @@ def test_integration_checkpoints_prevent_retry_execution( sourcePath=str(path), zarrPath=str(path), studyContext="A deterministic integration retry test.", - allowAssumptions=True, + studyObjective="Discover stable RNA populations.", ), config=AutomatedWorkflowConfig(), ) @@ -2149,6 +2205,42 @@ def load_artifact(self, ref: ArtifactRef) -> dict[str, np.ndarray]: ) +def test_capture_cell_selections_are_exact_and_idempotent(tmp_path: Path) -> None: + path = create_store(tmp_path / "capture-selections.zarr") + store = DataStore( + str(path), + default_assay="RNA", + min_features_per_cell=0, + zarr_mode="r+", + ) + capture_values = np.asarray(["a", "a", "b", "b"]) + store.cells.insert("capture", capture_values, overwrite=True) + parent = store.snapshot_cell_selection("I") + active_indices = np.arange(store.cells.N, dtype=np.int64) + + first_a, count_a = _select_capture_cells( + store, + parent, + column="capture", + value="a", + active_indices=active_indices, + active_values=capture_values, + ) + second_a, repeated_count = _select_capture_cells( + store, + parent, + column="capture", + value="a", + active_indices=active_indices, + active_values=capture_values, + ) + selected_a = np.asarray(store.load_artifact(first_a)["values"][:], dtype=bool) + + assert first_a == second_a + assert count_a == repeated_count == 2 + assert selected_a.tolist() == [True, True, False, False] + + def test_exact_feature_exclusion_covers_all_supported_families() -> None: class Features: N = 4 @@ -2413,6 +2505,193 @@ def report( ) +def test_harmony_doublet_graph_matches_selected_native_parameters() -> None: + class Store: + def __init__(self) -> None: + self.neighbors_k: int | None = None + self.resolution: float | None = None + + def load_artifact(self, reference: ArtifactRef) -> dict[str, Any]: + assert reference.kind == "reduction" + return {} + + def build_ann_index( + self, coordinates: ArtifactRef, **_kwargs: Any + ) -> ArtifactRef: + assert coordinates.kind == "reduction" + return ArtifactRef( + scope="assay", + assay="RNA", + kind="ann_index", + artifact_id="2" * 64, + ) + + def query_neighbors( + self, + _ann: ArtifactRef, + *, + k: int, + **_kwargs: Any, + ) -> ArtifactRef: + self.neighbors_k = k + return ArtifactRef( + scope="assay", + assay="RNA", + kind="neighbors", + artifact_id="3" * 64, + ) + + def build_connectivity_map( + self, + _neighbors: ArtifactRef, + **_kwargs: Any, + ) -> ArtifactRef: + return ArtifactRef( + scope="assay", + assay="RNA", + kind="connectivity_map", + artifact_id="4" * 64, + ) + + def run_leiden_clustering( + self, + _graph: ArtifactRef, + *, + resolution: float, + **_kwargs: Any, + ) -> ArtifactRef: + self.resolution = resolution + return ArtifactRef( + scope="assay", + assay="RNA", + kind="cluster_labels", + artifact_id="5" * 64, + ) + + base = ParameterCandidateEvaluation.get_example() + native = base.model_copy( + update={ + "candidateId": "native", + "parameters": base.parameters.model_copy( + update={ + "candidateId": "native", + "dimensions": 20, + "neighborsK": 11, + "leidenResolution": 0.5, + "useHarmony": False, + } + ), + } + ) + selected = base.model_copy( + update={ + "candidateId": "harmony", + "parameters": base.parameters.model_copy( + update={ + "candidateId": "harmony", + "dimensions": 20, + "neighborsK": 41, + "leidenResolution": 1.5, + "useHarmony": True, + } + ), + "artifacts": { + **base.artifacts, + "pca": ArtifactRecord( + assay="RNA", + kind="reduction", + artifactId="1" * 64, + ), + }, + } + ) + store = Store() + clusters, graph = resolve_native_doublet_inputs( + store, + selected, + [native, selected], + ) + + assert store.neighbors_k == 41 + assert store.resolution == 1.5 + assert graph.artifact_id == "4" * 64 + assert clusters.artifact_id == "5" * 64 + + +@pytest.mark.parametrize( + ("batch_mixing", "marker_coherence", "expected"), + [ + (0.56, 0.80, True), + (0.54, 0.80, False), + (0.56, 0.74, False), + (None, 0.80, False), + ], +) +def test_harmony_acceptance_requires_improvement_without_biological_loss( + batch_mixing: float | None, + marker_coherence: float, + expected: bool, +) -> None: + base = ParameterCandidateEvaluation.get_example() + native_parameters = base.parameters.model_copy( + update={"candidateId": "native", "useHarmony": False} + ) + harmony_parameters = base.parameters.model_copy( + update={"candidateId": "harmony", "useHarmony": True} + ) + native = base.model_copy( + update={ + "candidateId": "native", + "parameters": native_parameters, + "metrics": base.metrics.model_copy( + update={ + "batchMixing": {"batch": 0.50}, + "biologicalPreservation": { + "condition": { + "clisi": 0.80, + "graphConnectivity": 0.80, + } + }, + "crossUnitSupport": 0.80, + "markerCoherence": 0.80, + } + ), + } + ) + harmony = base.model_copy( + update={ + "candidateId": "harmony", + "parameters": harmony_parameters, + "metrics": base.metrics.model_copy( + update={ + "batchMixing": ( + {} if batch_mixing is None else {"batch": batch_mixing} + ), + "biologicalPreservation": { + "condition": { + "clisi": 0.80, + "graphConnectivity": 0.80, + } + }, + "crossUnitSupport": 0.80, + "markerCoherence": marker_coherence, + } + ), + } + ) + + accepted, reasons = tuning_module.harmony_acceptance_gate( + native, + harmony, + batch_columns=["batch"], + protected_columns=["condition"], + independent_unit_columns=["donor"], + ) + + assert accepted is expected + assert bool(reasons) is not expected + + def test_preprocessing_plan_rejects_invalid_assay_routing() -> None: orchestrator = AgentOrchestrator(object()) unsupported = _planning_inputs( diff --git a/tests/test_agent_parameter_tuning.py b/tests/test_agent_parameter_tuning.py index f4c2bb14..80ece26e 100644 --- a/tests/test_agent_parameter_tuning.py +++ b/tests/test_agent_parameter_tuning.py @@ -32,7 +32,7 @@ build_initial_parameter_candidates, evaluate_parameter_candidate, execute_parameter_candidate, - fallback_parameter_tuning_report, + pending_parameter_tuning_report, FinalGraphComparison, FinalGraphNeedsInput, FinalGraphSelection, @@ -1813,7 +1813,7 @@ async def reply( assert result.runInfo.usage.requests == 1 -def test_batched_tuning_falls_back_after_structured_output_exhaustion( +def test_batched_tuning_pauses_after_structured_output_exhaustion( monkeypatch: pytest.MonkeyPatch, ) -> None: from scarf.agent import parameter_tuning as module @@ -1843,16 +1843,18 @@ def unavailable_structured_output(**kwargs: Any) -> None: ) assert calls == ["parameter_batch_search_planning", "parameter_tuning_batch"] - assert result.status == "done" - assert result.recommendedByAssay == {"RNA": "baseline"} + assert result.status == "needsInput" + assert result.recommendedByAssay == {} assert result.assayReports["RNA"].confidence == "low" - assert result.assayReports["RNA"].comparisons[0].candidateId == "pca_15" + assert result.assayReports["RNA"].recommendedCandidateId is None + assert result.assayReports["RNA"].needsInput is not None + assert result.assayReports["RNA"].needsInput.options == ["baseline", "pca_15"] assert result.searchPlan is not None assert result.searchPlan.status == "complete" - assert result.runInfo.agentName == "parameter_tuning_batch_fallback" + assert result.runInfo.agentName == "parameter_tuning_batch_needs_input" -def test_single_tuning_falls_back_after_structured_output_exhaustion( +def test_single_tuning_pauses_after_structured_output_exhaustion( monkeypatch: pytest.MonkeyPatch, ) -> None: from scarf.agent import parameter_tuning as module @@ -1873,13 +1875,15 @@ def unavailable_structured_output(**_kwargs: Any) -> None: max_refined_candidates=1, ) - assert result.status == "done" - assert result.recommendedCandidateId == "baseline" + assert result.status == "needsInput" + assert result.recommendedCandidateId is None assert result.confidence == "low" - assert result.runInfo.agentName == "parameter_tuning_fallback" + assert result.needsInput is not None + assert result.needsInput.options == ["baseline", "pca_15"] + assert result.runInfo.agentName == "parameter_tuning_needs_input" -def test_parameter_fallback_does_not_select_without_successful_baseline() -> None: +def test_pending_parameter_report_does_not_select_without_successful_baseline() -> None: candidates = [ ParameterCandidate.get_example(), ParameterCandidate(candidateId="pca_15", dimensions=15), @@ -1900,10 +1904,10 @@ def test_parameter_fallback_does_not_select_without_successful_baseline() -> Non } ) - result = fallback_parameter_tuning_report( + result = pending_parameter_tuning_report( deps, search_plan=ParameterSearchPlan(status="complete"), - agent_name="parameter_tuning_fallback", + agent_name="parameter_tuning_needs_input", ) assert result.status == "needsInput" @@ -2026,7 +2030,7 @@ def unavailable_structured_output(**_kwargs: Any) -> None: ] assert selected.finalSelection is not None assert selected.finalSelection.runInfo.agentName == ( - "parameter_tuning_final_graph_fallback" + "parameter_tuning_final_graph_needs_input" ) diff --git a/tests/test_agent_report.py b/tests/test_agent_report.py index 05481214..54961a35 100644 --- a/tests/test_agent_report.py +++ b/tests/test_agent_report.py @@ -2,6 +2,7 @@ import asyncio import sys +from collections.abc import Mapping from pathlib import Path from types import SimpleNamespace from typing import Any, Literal @@ -50,6 +51,8 @@ ExperimentalContextDependencies, ) from scarf.agent.orchestrator.models import ( + AssayPreprocessingPlan, + AutomatedPreprocessingPlan, NativeAnalysisHandoff, OrchestrationRequestRecord, WorkflowStageAttempt, @@ -106,6 +109,24 @@ def _reports(study_context: str) -> dict[str, list[dict[str, Any]]]: "graphSilhouetteMedian": 0.343, }, } + baseline_candidate = { + "candidateId": "baseline", + "phase": "initial", + "status": "done", + "eligible": True, + "parameters": { + "reductionMethod": "pca", + "dimensions": 21, + "neighborsK": 11, + "leidenResolution": 1.0, + "useHarmony": False, + }, + "metrics": { + "nClusters": 9, + "minClusterCells": 18, + "graphSilhouetteMedian": 0.221, + }, + } return { "data_enrichment": [ { @@ -115,15 +136,205 @@ def _reports(study_context: str) -> dict[str, list[dict[str, Any]]]: "organismReferences": ["human"], "tissueReferences": ["blood"], }, - "policies": [{"assay": "RNA", "policyId": "rna-default"}], + "policies": [ + { + "assay": "RNA", + "excludeFamilies": ["ribosomal"], + "protectFamilies": ["sex", "cellCycle"], + } + ], + "inspections": [ + { + "assay": "RNA", + "families": [ + { + "family": "ribosomal", + "count": 193, + "method": "symbolPrefix", + "skipped": None, + }, + { + "family": "sex", + "count": 0, + "method": "chromosome", + "skipped": "referenceUnavailable", + }, + { + "family": "cellCycle", + "count": 94, + "method": "staticList", + "skipped": None, + }, + ], + } + ], "runInfo": run_info, } ], "experimental_context": [ { "status": "done", - "decision": {"batchCorrection": {"action": "skip"}}, - "cellQc": {"action": "globalGaussian", "driverAssay": "RNA"}, + "decision": {"batchCorrection": {"action": "unsafe"}}, + "cellQc": { + "action": "skip", + "driverAssay": "RNA", + "profileId": "qc-selected", + "registeredProfile": "retainWithFlags", + }, + "qcProfiles": [ + { + "profileId": "qc-selected", + "registeredProfile": "retainWithFlags", + "activeCells": 100, + "retainedCells": 100, + "retainedFraction": 1.0, + "flaggedCells": { + "RNA_nCounts:high": 0, + "RNA_nCounts:lowQuality": 0, + "RNA_nFeatures:high": 0, + "RNA_nFeatures:lowQuality": 0, + }, + "parameters": { + "nMads": 5.0, + "resolvedBounds": [ + { + "group": "global", + "role": "count", + "lowerRemoval": 50.0, + "upperFlag": 200000.0, + }, + { + "group": "global", + "role": "feature", + "lowerRemoval": 125.0, + "upperFlag": 28000.0, + }, + ], + }, + "retainedCellsByColumn": { + "T2D": {"no": 70, "yes": 30}, + "donor_id": {"donor-a": 45, "donor-b": 55}, + "sample_id": {"sample-a": 45, "sample-b": 55}, + "tissue": {"blood": 100}, + }, + }, + { + "profileId": "qc-alternative", + "registeredProfile": "captureMad5", + "activeCells": 100, + "retainedCells": 96, + "retainedFraction": 0.96, + "flaggedCells": { + "RNA_nCounts:high": 2, + "RNA_nCounts:lowQuality": 1, + "RNA_nFeatures:high": 1, + "RNA_nFeatures:lowQuality": 3, + }, + "parameters": { + "nMads": 5.0, + "resolvedBounds": [ + { + "group": "library-a", + "role": "count", + "lowerRemoval": 40.0, + "upperFlag": 180000.0, + }, + { + "group": "library-b", + "role": "count", + "lowerRemoval": 60.0, + "upperFlag": 220000.0, + }, + { + "group": "library-a", + "role": "feature", + "lowerRemoval": 100.0, + "upperFlag": 24000.0, + }, + { + "group": "library-b", + "role": "feature", + "lowerRemoval": 150.0, + "upperFlag": 32000.0, + }, + ], + }, + }, + ], + "characterization": { + "columns": [ + {"name": "T2D", "domain": "biological"}, + {"name": "tissue", "domain": "biological"}, + {"name": "library_id", "domain": "technical"}, + {"name": "sample_id", "domain": "design"}, + {"name": "predicted.id", "domain": "ignore"}, + ], + "coefficients": [ + { + "name": "T2D", + "kind": "categorical", + "designRows": 22, + "observationUnit": "sample_id", + "independentUnit": "donor_id", + "scope": "betweenUnit", + }, + { + "name": "tissue", + "kind": "categorical", + "designRows": 22, + "observationUnit": "sample_id", + "independentUnit": "donor_id", + "scope": "betweenUnit", + }, + ], + "technicalNesting": [ + { + "left": "origin", + "right": "library_id", + "nesting": "rightInLeft", + } + ], + "confounding": [ + { + "coefficient": "T2D", + "pairs": [ + { + "technical": "library_id", + "selected": True, + "association": { + "status": "notComputed", + "rowsUsed": 22, + "valueUncorrected": 1.0, + }, + } + ], + } + ], + }, + "batchSafety": [ + { + "coefficient": "T2D", + "status": "unsafe", + "estimability": { + "coefficientEstimable": False, + "rowsUsed": 22, + "rankTechnical": 22, + "residualDf": 0, + "estimableDf": 0, + }, + }, + { + "coefficient": "tissue", + "status": "unsafe", + "estimability": { + "coefficientEstimable": False, + "rowsUsed": 22, + "rankTechnical": 22, + "residualDf": 0, + "estimableDf": 0, + }, + }, + ], } ], "parameter_tuning": [ @@ -138,7 +349,7 @@ def _reports(study_context: str) -> dict[str, list[dict[str, Any]]]: "RNA": { "recommendedCandidateId": "refined", "confidence": "medium", - "evaluations": [candidate], + "evaluations": [baseline_candidate, candidate], "comparisons": [ { "candidateId": "baseline", @@ -186,20 +397,55 @@ def _patch_completed_workflow( if workspace is not None: group.create_group(workspace) workflow = _workflow(workspace=workspace) - final = FinalAnalysisHandoff.get_example().model_copy( - update={"workflowRunId": workflow.workflowRunId} + final = ( + FinalAnalysisHandoff.get_example() + .model_copy( + update={ + "workflowRunId": workflow.workflowRunId, + "handoffId": "", + } + ) + .with_handoff_id() ) result = AutomatedWorkflowResult( status="completed", - currentStage="biological_interpretation", + currentStage="analysis_finalization", zarrPath=str(root), workflowRun=workflow, + preprocessingPlan=AutomatedPreprocessingPlan( + primaryAssay="RNA", + markerAssay="RNA", + assays=[ + AssayPreprocessingPlan( + assay="RNA", + assayType="RNA", + role="graph", + graphEligible=True, + markerEligible=True, + featureMethod="hvg", + reductionMethod="pca", + featureParameters={ + "topN": 2000, + "minCells": 20, + "excludeFamilies": ["ribosomal"], + "protectFamilies": ["sex", "cellCycle"], + }, + normalizationParameters={ + "logTransform": True, + "renormalizeSubset": True, + }, + ) + ], + ), finalAnalysis=final, + finalHandoffId=final.handoffId, + decisionRunId=workflow.workflowRunId, ) request = AutomatedWorkflowRequest( sourcePath="input.h5ad", zarrPath=str(root), studyContext=study_context, + studyObjective="Discover stable RNA populations.", workspace=workspace, ) request_record = SimpleNamespace( @@ -252,12 +498,44 @@ def _patch_completed_workflow( [], ), ) + monkeypatch.setattr(report_module, "_collect_active_decisions", lambda *_a: {}) + monkeypatch.setattr( + report_module, + "_collect_default_feature_inventories", + lambda *_a: [ + { + "assay": "RNA", + "source": "scarfDefaultHvgBlacklist", + "policyEffect": "evidenceOnly", + "featureColumn": "names", + "totalFeatures": 20_000, + "blacklist": "^MT-|^RPS|^RPL", + "matchCount": 2, + "examples": ["MT-CO1", "MT-CYB"], + "families": [ + { + "family": "mitochondrial", + "pattern": "^MT-", + "count": 2, + "examples": ["MT-CO1", "MT-CYB"], + } + ], + "appliedToSelectedRepresentation": False, + "selectedExcludeFamilies": ["ribosomal"], + "selectedProtectFamilies": ["sex", "cellCycle"], + "matchedFeatures": ["MT-CO1", "MT-CYB"], + } + ], + ) def collect_artifacts( _store: object, _result: AutomatedWorkflowResult, plot_dir: Path, + *, + qc_profile: Mapping[str, Any] | None = None, ) -> tuple[dict[str, int], list[dict[str, Any]], dict[str, str], list[str]]: + assert qc_profile is not None if not plots: return ( {"0": 3, "1": 2}, @@ -278,6 +556,74 @@ def collect_artifacts( ) monkeypatch.setattr(report_module, "_collect_final_artifacts", collect_artifacts) + + def collect_hvg_plots( + _store: object, + _attempts: object, + _plan: object, + plot_dir: Path, + ) -> tuple[dict[str, str], list[str]]: + if not plots: + return {}, [] + (plot_dir / "hvg_global.png").write_bytes(b"hvg") + (plot_dir / "hvg_global.png.json").write_text( + '{"artifact":"hvg"}\n', + encoding="utf-8", + ) + return {"hvgGlobal": "plots/hvg_global.png"}, [] + + monkeypatch.setattr(report_module, "_collect_hvg_plots", collect_hvg_plots) + monkeypatch.setattr( + report_module, + "_collect_hvg_evidence", + lambda *_a, **_k: { + "assay": "RNA", + "selectedRankingMode": "batchAware", + "selectedFeatureCount": 2000, + "rankings": [ + { + "rankingMode": "global", + "eligibleFeatureCount": 29263, + "validTechnicalGroups": 22, + "excludedTechnicalGroupCount": 0, + "meanTechnicalGroupCoverage": 0.366, + "recurrentInTwoGroupsFraction": 0.630, + }, + { + "rankingMode": "batchAware", + "eligibleFeatureCount": 29263, + "validTechnicalGroups": 22, + "excludedTechnicalGroupCount": 0, + "meanTechnicalGroupCoverage": 0.627, + "recurrentInTwoGroupsFraction": 1.0, + }, + ], + "candidateMetrics": [ + { + "featureCount": 1000, + "varianceFraction": 0.146, + "recurrentFraction": 1.0, + }, + { + "featureCount": 2000, + "varianceFraction": 0.190, + "recurrentFraction": 1.0, + }, + { + "featureCount": 4000, + "varianceFraction": 0.270, + "recurrentFraction": 0.655, + }, + ], + "eligibleFeatureCount": 29263, + "validTechnicalGroups": 22, + "excludedTechnicalGroupCount": 0, + "minimumDetectedCells": 20, + "minimumTechnicalGroupCells": 20, + "scarfDefaultReferenceCounts": [1000, 2000, 4000], + "executedBranchCount": 9, + }, + ) return root @@ -295,33 +641,221 @@ def test_public_report_generates_branded_readable_html_and_relative_plots( immutable_record.write_bytes(b'{"immutable":true}\n') report_path = generate_agent_report(root, "report-workflow") - markup = report_path.read_text(encoding="utf-8") + analysis_path = report_path.with_name("analysis.html") + technical_path = report_path.with_name("technical.html") + landing_markup = report_path.read_text(encoding="utf-8") + analysis_markup = analysis_path.read_text(encoding="utf-8") + technical_markup = technical_path.read_text(encoding="utf-8") assert agent_api.generate_agent_report is generate_agent_report assert report_path == root / "agents/runs/report-workflow/report/index.html" + assert analysis_path.is_file() + assert technical_path.is_file() assert immutable_record.read_bytes() == b'{"immutable":true}\n' - assert 'href="https://www.nygen.io/"' in markup - assert ">Nygen Analytics" in markup - assert 'href="https://www.nygen.io/products/scarfweb"' in markup + for markup in (landing_markup, analysis_markup, technical_markup): + assert 'href="index.html"' in markup + assert 'href="analysis.html"' in markup + assert 'href="technical.html"' in markup + assert 'href="https://www.nygen.io/"' in markup + assert ">Nygen Analytics" in markup + assert 'href="https://www.nygen.io/products/scarfweb"' in landing_markup assert ( "Distributed, secure infrastructure for intuitive secondary analysis, " "browser-native." - ) in markup - assert "Human blood <script>alert" in markup - assert '' not in markup - assert "Parameter tuning and graph selection" in markup - assert "refined" in markup - assert "0.343" in markup - assert "The refined candidate retained larger minimum clusters." in markup - assert "Stage artifact inventory" in markup - assert "connectivity_map" in markup - assert "Recorded totals" in markup - assert "evaluate_refined_candidate" in markup - assert 'src="plots/final_umap.png"' in markup - assert 'href="plots/final_umap.png.json"' in markup + ) in landing_markup + assert "Choose the level of detail" in landing_markup + + assert "Analysis decision tree" in analysis_markup + assert '
    ') == 2 + assert analysis_markup.count('
    ') == 5 + assert "2,000 variable genes" in analysis_markup + assert "Corrected variance captured: 19.0%" in analysis_markup + assert "Genes recurring across most libraries: 65.5%" in analysis_markup + assert "Harmony not applied" in analysis_markup + assert "Selected QC metrics and cutoffs" in analysis_markup + assert "Exact Scarf default HVG blacklist" in analysis_markup + assert "Matched 2 of 20,000 genes" in analysis_markup + assert "How should highly variable genes be ranked?" in analysis_markup + assert "How many highly variable genes should be used?" in analysis_markup + assert "HVG branches executed" in analysis_markup + assert "qc-selected" not in analysis_markup + assert "library_id" not in analysis_markup + assert "batchAware" not in analysis_markup + assert "Why the final result was selected" in analysis_markup + assert "T cell" in analysis_markup + assert "Cell QC audit" in technical_markup + assert "All global and per-library cutoffs" in technical_markup + assert "Normalization and feature-selection audit" in technical_markup + assert "All 2 matched feature names" in technical_markup + assert "MT-CO1" in technical_markup + assert "provider-run" not in analysis_markup + assert "a" * 64 not in analysis_markup + assert "refined" not in analysis_markup + assert "Plot provenance" not in analysis_markup + assert 'src="plots/final_umap.png"' in analysis_markup + assert 'src="plots/hvg_global.png"' in analysis_markup + + assert "Human blood <script>alert" in technical_markup + assert '' not in technical_markup + assert "Parameter tuning and graph selection" in technical_markup + assert "refined" in technical_markup + assert "0.343" in technical_markup + assert "The refined candidate retained larger minimum clusters." in technical_markup + assert "Stage artifact inventory" in technical_markup + assert "connectivity_map" in technical_markup + assert "Recorded totals" in technical_markup + assert "evaluate_refined_candidate" in technical_markup + assert '' in technical_markup + assert "table-layout: auto" in technical_markup + assert "table-layout: fixed" not in technical_markup + assert 'class="record table-record table-record-selected"' in technical_markup + assert 'src="plots/final_umap.png"' in technical_markup + assert 'href="plots/final_umap.png.json"' in technical_markup assert (report_path.parent / "plots/final_umap.png").read_bytes() == b"png" +def test_harmony_diagnostic_reports_execution_metrics_and_rejection_reason() -> None: + native = { + "candidateId": "rna_correction_native", + "status": "done", + "eligible": True, + "parameters": { + "candidateId": "rna_correction_native", + "reductionMethod": "pca", + "dimensions": 20, + "neighborsK": 21, + "leidenResolution": 1.0, + "useHarmony": False, + }, + "metrics": { + "batchMixing": {"library_id": 0.05}, + "technicalAssociation": {"library_id": 0.24}, + "biologicalPreservation": { + "tissue": {"clisi": 1.0, "graphConnectivity": 0.99} + }, + "crossUnitSupport": 1.0, + "markerCoherence": 0.82, + "markerSpecificityMedian": 0.42, + "clusterConnectivity": 1.0, + "membershipStrengthMean": 0.96, + "doubletHighScoreConcentration": 9.4, + }, + } + harmony = { + "candidateId": "rna_correction_harmony", + "status": "done", + "eligible": True, + "parameters": { + "candidateId": "rna_correction_harmony", + "reductionMethod": "pca", + "dimensions": 20, + "neighborsK": 21, + "leidenResolution": 1.0, + "useHarmony": True, + }, + "metrics": { + "batchMixing": {"library_id": 0.12}, + "technicalAssociation": {"library_id": 0.09}, + "biologicalPreservation": { + "tissue": {"clisi": 0.62, "graphConnectivity": 0.98} + }, + "crossUnitSupport": 1.0, + "markerCoherence": 0.90, + "markerSpecificityMedian": 0.51, + "clusterConnectivity": 1.0, + "membershipStrengthMean": 0.96, + "doubletHighScoreConcentration": 8.8, + }, + } + parameter = { + "fromAssay": "RNA", + "recommendedByAssay": {"RNA": "rna_correction_native"}, + "assayReports": { + "RNA": { + "recommendedCandidateId": "rna_correction_native", + "evaluations": [native, harmony], + } + }, + } + experimental = { + "decision": {"batchCorrection": {"action": "unsafe"}}, + "batchSafety": [ + { + "coefficient": "tissue", + "status": "unsafe", + "estimability": { + "coefficientEstimable": False, + "rowsUsed": 22, + "rankTechnical": 22, + "residualDf": 0, + "estimableDf": 0, + }, + } + ], + } + final = { + "graphMethod": "native", + "primaryAssay": "RNA", + "nativeAnalyses": [{"assay": "RNA", "batchCorrection": None}], + } + rejection = ( + "Retain native because Harmony materially degraded protected tissue " + "evidence and was diagnostic-only." + ) + decisions = { + "correctionLicense": {"selectedOptionId": "correctionLicense:unsafeConfounded"}, + "correctionOutcome": {"rationale": rejection}, + } + + evidence_markup = report_module._render_batch_evidence( + experimental, + parameter, + final, + decisions, + ) + stage = report_module._batch_tree_stage( + experimental, + parameter, + final, + decisions, + ) + assert stage is not None + tree_markup = report_module._render_decision_tree([stage]) + technical_markup = report_module._render_harmony_technical_audit( + experimental, + parameter, + final, + decisions, + ) + + assert ( + "Diagnostic Harmony completed; rejected and native representation retained" + in evidence_markup + ) + assert "Run status: completed" in evidence_markup + assert "Library mixing: 0.050 to 0.120 (change +0.070)" in evidence_markup + assert "Matched native versus Harmony metrics" in evidence_markup + assert "Recorded correction decision" in evidence_markup + assert rejection in evidence_markup + assert "Run diagnostically; rejected" in tree_markup + assert "Protected evidence degraded: tissue" in tree_markup + assert "Harmony diagnostic audit" in technical_markup + assert "Native versus Harmony measurements" in technical_markup + + def test_report_uses_workspace_path_and_can_be_regenerated( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -335,8 +869,11 @@ def test_report_uses_workspace_path_and_can_be_regenerated( first = generate_agent_report(root, "report-workflow", workspace="analysis") assert first == (root / "analysis/agents/runs/report-workflow/report/index.html") - first_markup = first.read_text(encoding="utf-8") - assert "First context" in first_markup + first_technical = first.with_name("technical.html").read_text(encoding="utf-8") + assert "First context" in first_technical + first.write_text("stale landing", encoding="utf-8") + first.with_name("analysis.html").write_text("stale analysis", encoding="utf-8") + first.with_name("technical.html").write_text("stale technical", encoding="utf-8") monkeypatch.setattr( report_module, @@ -346,9 +883,13 @@ def test_report_uses_workspace_path_and_can_be_regenerated( second = generate_agent_report(root, "report-workflow", workspace="analysis") assert second == first - second_markup = second.read_text(encoding="utf-8") - assert "Regenerated context" in second_markup - assert second_markup != first_markup + second_landing = second.read_text(encoding="utf-8") + second_analysis = second.with_name("analysis.html").read_text(encoding="utf-8") + second_technical = second.with_name("technical.html").read_text(encoding="utf-8") + assert "Choose the level of detail" in second_landing + assert "Analysis decision tree" in second_analysis + assert "Regenerated context" in second_technical + assert second_technical != first_technical def test_report_remains_available_when_optional_plots_fail( @@ -362,12 +903,16 @@ def test_report_remains_available_when_optional_plots_fail( ) report_path = generate_agent_report(root, "report-workflow") - markup = report_path.read_text(encoding="utf-8") + analysis_markup = report_path.with_name("analysis.html").read_text(encoding="utf-8") + technical_markup = report_path.with_name("technical.html").read_text( + encoding="utf-8" + ) assert report_path.is_file() - assert "No plots could be rendered" in markup - assert "plotting dependencies are unavailable" in markup - assert "Final cluster sizes" in markup + assert "Visual results are unavailable for this report" in analysis_markup + assert "No plots could be rendered" in technical_markup + assert "plotting dependencies are unavailable" in technical_markup + assert "Final cluster sizes" in technical_markup def test_report_rejects_remote_and_non_completed_workflows( @@ -594,6 +1139,101 @@ def __init__(self, *args: object, **kwargs: object) -> None: ) +def test_hvg_report_evidence_uses_persisted_diagnostic_values( + tmp_path: Path, +) -> None: + root = zarr.open_group(str(tmp_path / "hvg.zarr"), mode="w", zarr_format=3) + groups: dict[str, Any] = {} + + def diagnostic( + artifact_id: str, + mode: str, + recurrence: list[int], + ) -> ArtifactReferenceModel: + group = root.create_group(artifact_id) + group.attrs["ranking_mode"] = mode + group.attrs["valid_groups"] = ["library-a", "library-b", "library-c"] + group.attrs["excluded_groups"] = [] + group.attrs["provenance"] = { + "parameters": { + "candidate_counts": [2, 4, 6], + "min_cells": 20, + "min_group_cells": 20, + } + } + group.create_array("ranking", data=np.arange(6, dtype=np.int64)) + group.create_array( + "global_corrected_variance", + data=np.array([6, 5, 4, 3, 2, 1], dtype=np.float64), + ) + group.create_array( + "recurrence", + data=np.asarray(recurrence, dtype=np.int32), + ) + group.create_array( + "eligible", + data=np.ones(6, dtype=bool), + ) + groups[artifact_id] = group + return ArtifactReferenceModel( + assay="RNA", + kind="feature_summary", + artifactId=artifact_id, + ) + + global_ref = diagnostic("a" * 64, "global", [3, 2, 1, 1, 0, 0]) + batch_ref = diagnostic("b" * 64, "batchAware", [3, 3, 3, 2, 1, 1]) + + class HvgStore: + def load_artifact(self, ref: Any) -> Any: + return groups[ref.artifact_id] + + evidence = report_module._collect_hvg_evidence( + HvgStore(), + [ + { + "artifacts": { + "RNA_hvg_global_diagnostic": global_ref.model_dump(mode="json"), + "RNA_hvg_batchAware_diagnostic": batch_ref.model_dump(mode="json"), + "RNA_hvg_diagnostic": batch_ref.model_dump(mode="json"), + } + } + ], + { + "assays": [ + { + "assay": "RNA", + "featureParameters": {"topN": 4}, + } + ] + }, + ) + + assert evidence["selectedRankingMode"] == "batchAware" + assert evidence["selectedFeatureCount"] == 4 + assert evidence["eligibleFeatureCount"] == 6 + assert evidence["validTechnicalGroups"] == 3 + assert evidence["candidateMetrics"] == [ + { + "featureCount": 2, + "varianceFraction": 11 / 21, + "recurrentFraction": 1.0, + }, + { + "featureCount": 4, + "varianceFraction": 18 / 21, + "recurrentFraction": 1.0, + }, + { + "featureCount": 6, + "varianceFraction": 1.0, + "recurrentFraction": 4 / 6, + }, + ] + assert evidence["rankings"][0]["meanTechnicalGroupCoverage"] == 7 / 18 + assert evidence["rankings"][1]["meanTechnicalGroupCoverage"] == 13 / 18 + + def test_report_renderer_edge_branches() -> None: assert report_module._safe_assay_name("RNA / strange assay", "fallback") == ( "rna_strange_assay" @@ -602,6 +1242,12 @@ def test_report_renderer_edge_branches() -> None: assert report_module._scalar(None) == "Not provided" assert "Nothing" in report_module._chips(None, empty="Nothing") assert "value" in report_module._chips("value") + public_text = report_module._brief_text( + f"Preserve donor_id from {'a' * 64} and 12345678-1234-1234-1234-123456789abc." + ) + assert "donor_id" not in public_text + assert "a" * 64 not in public_text + assert "12345678-1234-1234-1234-123456789abc" not in public_text assert report_module._latest({"agent": {"status": "done"}}, "agent") == { "status": "done" } @@ -634,6 +1280,22 @@ def test_report_renderer_edge_branches() -> None: assert "No provider execution metadata" in report_module._render_executions({}) +def test_wide_technical_records_use_readable_card_layout() -> None: + wide_row = {f"field_{index}": f"value {index}" for index in range(8)} + wide_row["_selected"] = True + wide_markup = report_module._table([wide_row]) + + assert "Name" in compact_markup + + def test_report_collects_bounded_artifact_branches( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -677,13 +1339,16 @@ def artifact( umap=artifact("embedding", "9"), ), ], - ) + ).with_handoff_id() + workflow = _workflow() result = AutomatedWorkflowResult( status="completed", - currentStage="biological_interpretation", + currentStage="analysis_finalization", zarrPath=str(tmp_path / "data.zarr"), - workflowRun=_workflow(), + workflowRun=workflow, finalAnalysis=final, + finalHandoffId=final.handoffId, + decisionRunId=workflow.workflowRunId, ) class PlotMethods: @@ -777,7 +1442,7 @@ def get_markers( ) -def test_data_enrichment_cache_rollback_and_fallback_branches( +def test_data_enrichment_cache_rollback_and_pending_branches( monkeypatch: pytest.MonkeyPatch, ) -> None: inspection = AssayFeatureInspection.get_example() @@ -823,12 +1488,12 @@ def test_data_enrichment_cache_rollback_and_fallback_branches( provider_error = UnexpectedModelBehavior("provider output failed") with pytest.raises(UnexpectedModelBehavior, match="provider output failed"): - enrichment_module.fallback_data_enrichment_report( + enrichment_module.pending_data_enrichment_report( DataEnrichmentDependencies(assays=["RNA"]), error=provider_error, model_name="test-model", ) - fallback = enrichment_module.fallback_data_enrichment_report( + pending = enrichment_module.pending_data_enrichment_report( DataEnrichmentDependencies( assays=["RNA"], inspections={"RNA": inspection}, @@ -837,8 +1502,9 @@ def test_data_enrichment_cache_rollback_and_fallback_branches( error=provider_error, model_name="test-model", ) - assert fallback.policies[0].species == "homo_sapiens" - assert fallback.policies[0].speciesConfidence == "high" + assert pending.status == "needsInput" + assert pending.policies == [] + assert pending.inspections == [inspection] def fail_before_inspection(**_kwargs: object) -> object: raise UnexpectedModelBehavior("no inspection completed") @@ -908,7 +1574,7 @@ def test_biological_interpretation_cache_and_fallback_branches() -> None: assert needs_markers.evidenceIds == ["composition:clusters"] -def test_experimental_context_rejects_invalid_batches_and_builds_fallback( +def test_experimental_context_rejects_invalid_batches_and_builds_pending_result( monkeypatch: pytest.MonkeyPatch, ) -> None: invalid_batches = ( @@ -965,7 +1631,7 @@ def offer_profile( return [profile] monkeypatch.setattr(experimental_module, "_offered_qc_profiles", offer_profile) - fallback_deps = ExperimentalContextDependencies( + pending_deps = ExperimentalContextDependencies( cellSelection=ArtifactReferenceModel( scope="datastore", kind="cell_selection", @@ -973,14 +1639,17 @@ def offer_profile( ), htoIdentityColumns=["hto_identity"], ) - fallback = experimental_module.fallback_experimental_context_result( - fallback_deps, + pending = experimental_module.pending_experimental_context_result( + pending_deps, error=UnexpectedModelBehavior("design output failed"), model_name="test-model", ) - assert fallback.status == "done" - assert fallback_deps.characterization is characterization - assert fallback.cellQc.profileId == CellQcProfileEvidence.get_example().profileId + assert pending.status == "needsInput" + assert pending_deps.characterization is characterization + assert pending.cellQc.profileId == "" + assert pending.qcProfiles[0].profileId == ( + CellQcProfileEvidence.get_example().profileId + ) def test_agent_execution_logs_nested_failures_for_sync_and_async_runners( diff --git a/tests/test_agent_rna_decisions.py b/tests/test_agent_rna_decisions.py new file mode 100644 index 00000000..de4a32a8 --- /dev/null +++ b/tests/test_agent_rna_decisions.py @@ -0,0 +1,420 @@ +"""Tests for deterministic RNA decision definitions and compilation.""" + +import pytest +from pydantic import ValidationError + +from scarf.agent.decision_kernel import ( + DecisionEvidence, + DecisionRecord, + EvidenceBundle, +) +from scarf.agent.rna_decisions import ( + ClusterExecutorPayload, + CorrectionOutcomeExecutorPayload, + GraphExecutorPayload, + HvgExecutorPayload, + PcaPrefixExecutorPayload, + RNA_DECISION_TRANSITION_GRAPH, + RnaDecisionCompilationError, + RnaDecisionGateError, + RnaDecisionRegistry, + RnaDecisionTransition, + RnaDecisionTransitionGraph, + build_cell_quality_decision, + build_cluster_partition_decision, + build_correction_license_decision, + build_correction_need_decision, + build_correction_outcome_decision, + build_feature_policy_decision, + build_graph_k_decision, + build_hvg_count_decision, + build_pca_prefix_decision, + build_qc_grouping_decision, + compile_rna_decision, +) + + +def _bundle( + decision_id: str, + bundle_id: str, + classes: list[str], +) -> EvidenceBundle: + return EvidenceBundle( + bundleId=bundle_id, + decisionId=decision_id, + evidence=[ + DecisionEvidence( + evidenceId=f"evidence:{evidence_class}:{index}", + evidenceClass=evidence_class, + summary=f"Observed {evidence_class} evidence.", + ) + for index, evidence_class in enumerate(classes) + ], + ) + + +def _record( + definition: object, + bundle: EvidenceBundle, + option_id: str, + *, + source: str = "agent", + evidence_ids: list[str] | None = None, + override_of: str | None = None, + override_evidence_ids: list[str] | None = None, +) -> DecisionRecord: + bundle = bundle.with_content_sha256() + assert bundle.contentSha256 is not None + spec = definition.spec + option = spec.option_by_id()[option_id] + return DecisionRecord( + recordId=f"record:{spec.decisionId}:1", + decisionId=spec.decisionId, + definitionVersion=spec.definitionVersion, + evidenceBundleId=bundle.bundleId, + evidenceBundleSha256=bundle.contentSha256, + offeredOptionIds=[item.optionId for item in spec.options], + availableEvidenceIds=[item.evidenceId for item in bundle.evidence], + selectedOptionId=option_id, + status=option.status, + source=source, + evidenceIds=evidence_ids + if evidence_ids is not None + else [item.evidenceId for item in bundle.evidence], + rationale="The exact cited evidence supports this registered option.", + confidence="medium", + overrideOfOptionId=override_of, + overrideEvidenceIds=override_evidence_ids or [], + verificationId=f"verification:record:{spec.decisionId}:1", + ) + + +def test_cell_quality_registry_gates_capture_profiles() -> None: + global_grouping = build_qc_grouping_decision( + evidence_bundle_id="bundle:cellQuality", + physical_capture_eligible=False, + pooled_reference_eligible=False, + ) + + assert [option.optionId for option in global_grouping.spec.options] == [ + "qcGrouping:global", + "qcGrouping:defer", + ] + global_only = build_cell_quality_decision( + evidence_bundle_id="bundle:cellQuality", + available_profiles=["retainWithFlags", "globalMad5"], + ) + assert [option.optionId for option in global_only.spec.options] == [ + "cellQuality:retainWithFlags", + "cellQuality:globalMad5", + "cellQuality:defer", + ] + global_payload = global_only.executor_option("cellQuality:globalMad5").payload + assert global_payload.operation == "cellQualityProfile" + assert global_payload.lowerCountMad == 5.0 + assert global_payload.flagHighCounts is True + + with pytest.raises(RnaDecisionGateError, match="physical capture"): + build_qc_grouping_decision( + evidence_bundle_id="bundle:cellQuality", + physical_capture_eligible=False, + pooled_reference_eligible=True, + ) + + +def test_cell_quality_registry_offers_only_eligible_pooled_reference() -> None: + definition = build_cell_quality_decision( + evidence_bundle_id="bundle:cellQuality", + available_profiles=[ + "retainWithFlags", + "captureMad5", + "captureMad3Sensitivity", + "pooledReferenceMad5", + ], + ) + + assert "cellQuality:captureMad5" in definition.spec.option_by_id() + assert "cellQuality:captureMad3Sensitivity" in definition.spec.option_by_id() + assert "cellQuality:pooledReferenceMad5" in definition.spec.option_by_id() + + +def test_hvg_counts_are_capped_and_numeric_values_stay_in_payloads() -> None: + definition = build_hvg_count_decision( + evidence_bundle_id="bundle:hvg", + eligible_feature_count=1500, + ranking_mode="global", + ) + + assert [option.optionId for option in definition.spec.options] == [ + "hvgCount:focused", + "hvgCount:allEligible", + "hvgCount:defer", + ] + assert definition.spec.baselineOptionId == "hvgCount:allEligible" + payload = definition.executor_option("hvgCount:allEligible").payload + assert isinstance(payload, HvgExecutorPayload) + assert payload.topN == 1500 + assert "topN" not in definition.spec.model_dump_json() + + +def test_batch_aware_hvgs_require_two_valid_technical_groups() -> None: + with pytest.raises(RnaDecisionGateError, match="at least two"): + build_hvg_count_decision( + evidence_bundle_id="bundle:hvg", + eligible_feature_count=4000, + ranking_mode="batchAware", + valid_technical_groups=1, + ) + + definition = build_hvg_count_decision( + evidence_bundle_id="bundle:hvg", + eligible_feature_count=4000, + ranking_mode="batchAware", + valid_technical_groups=2, + ) + payload = definition.executor_option("hvgCount:standard").payload + assert isinstance(payload, HvgExecutorPayload) + assert payload.rankingMode == "batchAware" + + +def test_feature_policy_requires_dominance_and_blocks_protected_families() -> None: + with pytest.raises(RnaDecisionGateError, match="dominance"): + build_feature_policy_decision( + evidence_bundle_id="bundle:features", + proposed_exclusion_families=["ribosomal"], + dominant_families=[], + protected_families=[], + ) + + with pytest.raises(RnaDecisionGateError, match="protected"): + build_feature_policy_decision( + evidence_bundle_id="bundle:features", + proposed_exclusion_families=["immuneReceptor"], + dominant_families=["immuneReceptor"], + protected_families=["immuneReceptor"], + ) + + definition = build_feature_policy_decision( + evidence_bundle_id="bundle:features", + proposed_exclusion_families=["ribosomal"], + dominant_families=["ribosomal"], + protected_families=["immuneReceptor"], + ) + payload = definition.executor_option("featurePolicy:excludeEligibleBundle").payload + assert payload.operation == "featurePolicy" + assert payload.excludedFamilies == ["ribosomal"] + assert definition.spec.baselineOptionId == "featurePolicy:keepAll" + + +def test_pca_prefixes_are_capped_by_rank_and_compile_to_executor_payload() -> None: + definition = build_pca_prefix_decision( + evidence_bundle_id="bundle:pca", matrix_rank=24 + ) + assert [option.optionId for option in definition.spec.options] == [ + "pcaPrefix:short", + "pcaPrefix:standard", + "pcaPrefix:maximumAvailable", + "pcaPrefix:defer", + ] + assert definition.spec.baselineOptionId == "pcaPrefix:standard" + bundle = _bundle("pcaPrefix", "bundle:pca", ["geometric", "technical"]) + record = _record(definition, bundle, "pcaPrefix:standard") + + compiled = compile_rna_decision(definition, bundle, record) + + assert isinstance(compiled.executorPayload, PcaPrefixExecutorPayload) + assert compiled.executorPayload.dimensions == 20 + record_json = record.model_dump_json() + assert "dimensions" not in record_json + assert "20" not in record_json + + +def test_correction_license_is_rule_owned_and_need_requires_safe_license() -> None: + unsafe = build_correction_license_decision( + evidence_bundle_id="bundle:license", license="unsafeConfounded" + ) + + assert unsafe.spec.allowedSources == ["rule"] + assert unsafe.spec.options[0].status == "skip" + assert unsafe.executorOptions[0].payload.license == "unsafeConfounded" + with pytest.raises(RnaDecisionGateError, match="safe correction license"): + build_correction_need_decision( + evidence_bundle_id="bundle:need", license="unsafeConfounded" + ) + + +def test_harmony_is_offered_only_when_safe_and_needed() -> None: + unsafe = build_correction_outcome_decision( + evidence_bundle_id="bundle:outcome", + license="unsafeConfounded", + ) + assert [option.optionId for option in unsafe.spec.options] == [ + "correctionOutcome:retainNative", + "correctionOutcome:indeterminate", + ] + assert unsafe.spec.baselineOptionId == "correctionOutcome:retainNative" + + safe = build_correction_outcome_decision( + evidence_bundle_id="bundle:outcome", + license="safe", + need="needed", + ) + assert [option.optionId for option in safe.spec.options] == [ + "correctionOutcome:retainNative", + "correctionOutcome:acceptHarmony", + "correctionOutcome:indeterminate", + ] + harmony_payload = safe.executor_option("correctionOutcome:acceptHarmony").payload + assert isinstance(harmony_payload, CorrectionOutcomeExecutorPayload) + assert harmony_payload.useHarmony is True + + +@pytest.mark.parametrize( + ("license", "need", "message"), + [ + ("indeterminate", None, "Indeterminate correction license"), + ("safe", None, "requires an evaluated correction need"), + ("safe", "indeterminate", "Indeterminate correction need"), + ("unsafeConfounded", "needed", "must not bypass"), + ], +) +def test_correction_outcome_rejects_unsafe_or_indeterminate_bypass( + license: str, need: str | None, message: str +) -> None: + with pytest.raises(RnaDecisionGateError, match=message): + build_correction_outcome_decision( + evidence_bundle_id="bundle:outcome", + license=license, + need=need, + ) + + +def test_graph_candidates_are_capped_and_deduplicated() -> None: + definition = build_graph_k_decision(evidence_bundle_id="bundle:graph", n_cells=15) + + assert [option.optionId for option in definition.spec.options] == [ + "graphScale:local", + "graphScale:maximumAvailable", + "graphScale:defer", + ] + assert definition.spec.baselineOptionId == "graphScale:maximumAvailable" + payload = definition.executor_option("graphScale:maximumAvailable").payload + assert isinstance(payload, GraphExecutorPayload) + assert payload.neighborsK == 14 + + +def test_clustering_uses_fixed_resolutions_and_requires_override_evidence() -> None: + definition = build_cluster_partition_decision( + evidence_bundle_id="bundle:cluster", + metric_preferred_option_id="clusterResolution:balanced", + ) + assert definition.spec.requireIndependentOverrideEvidence is True + assert definition.spec.options[-1].optionId == "clusterPartition:abstain" + payload = definition.executor_option("clusterResolution:detailed").payload + assert isinstance(payload, ClusterExecutorPayload) + assert payload.leidenResolution == 1.0 + + bundle = _bundle( + "clusterPartition", + "bundle:cluster", + ["geometric", "markerCoherence", "resamplingStability"], + ) + geometric, marker, stability = [item.evidenceId for item in bundle.evidence] + insufficient = _record( + definition, + bundle, + "clusterResolution:detailed", + evidence_ids=[geometric, marker], + override_of="clusterResolution:balanced", + override_evidence_ids=[marker], + ) + with pytest.raises( + RnaDecisionCompilationError, match="independentOverrideEvidence" + ): + compile_rna_decision(definition, bundle, insufficient) + + supported = _record( + definition, + bundle, + "clusterResolution:detailed", + evidence_ids=[geometric, marker, stability], + override_of="clusterResolution:balanced", + override_evidence_ids=[marker, stability], + ) + compiled = compile_rna_decision(definition, bundle, supported) + assert compiled.verification.status == "passed" + + +def test_clustering_can_abstain_without_inventing_a_resolution() -> None: + definition = build_cluster_partition_decision( + evidence_bundle_id="bundle:cluster", + metric_preferred_option_id="clusterResolution:balanced", + ) + bundle = _bundle("clusterPartition", "bundle:cluster", ["geometric"]) + record = _record(definition, bundle, "clusterPartition:abstain") + + compiled = compile_rna_decision(definition, bundle, record) + + assert compiled.status == "abstain" + assert compiled.executorPayload.operation == "noExecution" + assert compiled.executorPayload.reasonCode == "scientificAbstention" + + +def test_transition_graph_is_forward_only_and_routes_terminal_states() -> None: + assert RNA_DECISION_TRANSITION_GRAPH.resolve("qcGrouping", "apply") == ( + "cellQuality", + None, + ) + assert RNA_DECISION_TRANSITION_GRAPH.resolve("cellQuality", "skip") == ( + "featurePolicy", + None, + ) + assert RNA_DECISION_TRANSITION_GRAPH.resolve("correctionLicense", "defer") == ( + None, + "needsInput", + ) + assert RNA_DECISION_TRANSITION_GRAPH.resolve("clusterPartition", "abstain") == ( + None, + "abstained", + ) + + with pytest.raises(ValidationError, match="strictly forward"): + RnaDecisionTransitionGraph( + transitions=[ + RnaDecisionTransition( + fromCheckpoint="pcaPrefix", + onStatus="apply", + toCheckpoint="hvgCount", + ) + ] + ) + + +def test_registry_requires_ordered_definitions_and_transition_coverage() -> None: + cell_quality = build_cell_quality_decision( + evidence_bundle_id="bundle:cellQuality", + available_profiles=["retainWithFlags", "globalMad5"], + ) + features = build_feature_policy_decision( + evidence_bundle_id="bundle:features", + proposed_exclusion_families=[], + dominant_families=[], + protected_families=[], + ) + + registry = RnaDecisionRegistry(definitions=[cell_quality, features]) + assert registry.definition("featurePolicy") == features + + with pytest.raises(ValidationError, match="checkpoint order"): + RnaDecisionRegistry(definitions=[features, cell_quality]) + + +def test_definition_rejects_executor_inventory_drift() -> None: + definition = build_pca_prefix_decision( + evidence_bundle_id="bundle:pca", matrix_rank=50 + ) + values = definition.model_dump() + values["executorOptions"] = values["executorOptions"][:-1] + + with pytest.raises(ValidationError, match="exactly match"): + type(definition).model_validate(values) diff --git a/tests/test_agent_sequential_tuning.py b/tests/test_agent_sequential_tuning.py new file mode 100644 index 00000000..84305994 --- /dev/null +++ b/tests/test_agent_sequential_tuning.py @@ -0,0 +1,348 @@ +from typing import Any + +import pytest +from pydantic import ValidationError + +from scarf.agent.parameter_tuning import ( + ArtifactRecord, + ParameterCandidate, + ParameterCandidateEvaluation, +) +from scarf.agent.sequential_tuning import ( + CorrectionNeedSelection, + ParameterPhaseEvidence, + ParameterPhasePlan, + ParameterPhaseSelection, + SequentialAssayTuningEvidence, + SequentialRnaTuningPlanner, + execute_parameter_phase, + sequential_evidence_to_report, + validate_parameter_phase_selection, +) +from scarf.agent.types import ArtifactReferenceModel + + +def _evaluation(candidate: ParameterCandidate) -> ParameterCandidateEvaluation: + evidence_id = f"candidate:{candidate.candidateId}:clusters" + return ParameterCandidateEvaluation( + candidateId=candidate.candidateId, + status="done", + eligible=True, + parameters=candidate, + artifacts={ + "connectivityMap": ArtifactRecord( + assay="RNA", + kind="connectivity_map", + artifactId="a" * 64, + ), + "clusters": ArtifactRecord( + assay="RNA", + kind="cluster_labels", + artifactId="b" * 64, + ), + }, + cellSelection=ArtifactReferenceModel( + scope="datastore", + assay=None, + kind="cell_selection", + artifactId="c" * 64, + ), + clusterColumn=f"RNA_{candidate.candidateId}", + clusterLabel=candidate.candidateId, + effectiveDimensions=candidate.dimensions, + evidenceIds=[evidence_id], + ) + + +def _selected_phase( + plan: ParameterPhasePlan, + *, + selected_index: int = -1, +) -> ParameterPhaseEvidence: + evaluations = [_evaluation(candidate) for candidate in plan.candidates] + selected = evaluations[selected_index] + return validate_parameter_phase_selection( + plan, + evaluations, + ParameterPhaseSelection( + phase=plan.phase, + status="selected", + selectedCandidateId=selected.candidateId, + evidenceIds=list(selected.evidenceIds), + rationale=f"Selected registered {plan.phase} evidence.", + ), + ) + + +def test_sequential_planner_caps_registered_rna_candidates() -> None: + planner = SequentialRnaTuningPlanner( + workflow_run_id="workflow:with unsafe punctuation", + assay="RNA sample", + n_cells=35, + n_features=100, + matrix_rank=27, + harmony_authorized=True, + ) + + pca = planner.pca_prefix_phase() + assert [value.dimensions for value in pca.candidates] == [10, 20, 27] + assert all(value.useHarmony is False for value in pca.candidates) + assert all( + len(value.candidateId) <= 64 and value.candidateId.replace("_", "").isalnum() + for value in pca.candidates + ) + + correction = planner.batch_correction_phase(pca.candidates[1]) + assert [value.useHarmony for value in correction.candidates] == [False, True] + assert {value.dimensions for value in correction.candidates} == {20} + + graph = planner.graph_phase(correction.candidates[1]) + assert [value.neighborsK for value in graph.candidates] == [11, 21, 34] + assert all(value.useHarmony for value in graph.candidates) + + clustering = planner.clustering_phase(graph.candidates[1]) + assert [value.leidenResolution for value in clustering.candidates] == [ + 0.25, + 0.5, + 0.75, + 1.0, + 1.25, + 1.5, + ] + assert {value.neighborsK for value in clustering.candidates} == {21} + + +def test_sequential_planner_does_not_offer_unauthorized_harmony() -> None: + planner = SequentialRnaTuningPlanner( + workflow_run_id="workflow", + assay="RNA", + n_cells=100, + n_features=50, + harmony_authorized=False, + ) + selected = planner.pca_prefix_phase().candidates[0] + + correction = planner.batch_correction_phase(selected) + + assert len(correction.candidates) == 1 + assert correction.candidates[0].useHarmony is False + + +def test_phase_contract_rejects_noncausal_and_numeric_model_output() -> None: + first = ParameterCandidate( + candidateId="pca_10", + dimensions=10, + neighborsK=11, + ) + second = ParameterCandidate( + candidateId="pca_20", + dimensions=20, + neighborsK=21, + ) + with pytest.raises(ValidationError, match="non-target parameter"): + ParameterPhasePlan( + phase="pcaPrefix", + assay="RNA", + variedParameter="dimensions", + candidates=[first, second], + ) + + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + ParameterPhaseSelection.model_validate( + { + "phase": "pcaPrefix", + "status": "selected", + "selectedCandidateId": "pca_10", + "evidenceIds": ["candidate:pca_10:clusters"], + "rationale": "Choose the exact registered option.", + "dimensions": 10, + } + ) + + +def test_phase_selection_requires_eligible_execution_and_scoped_evidence() -> None: + plan = SequentialRnaTuningPlanner( + workflow_run_id="workflow", + assay="RNA", + n_cells=100, + n_features=50, + harmony_authorized=False, + ).pca_prefix_phase() + evaluations = [_evaluation(candidate) for candidate in plan.candidates] + + with pytest.raises(ValidationError, match="outside its phase evaluations"): + ParameterPhaseEvidence( + plan=plan, + evaluations=evaluations, + selection=ParameterPhaseSelection( + phase="pcaPrefix", + status="selected", + selectedCandidateId=plan.candidates[0].candidateId, + evidenceIds=["invented:evidence"], + rationale="This cites evidence that was not executed.", + ), + ) + with pytest.raises(ValidationError, match="must cite executor evidence"): + ParameterPhaseSelection( + phase="pcaPrefix", + status="selected", + selectedCandidateId=plan.candidates[0].candidateId, + rationale="This selection omitted its evidence.", + ) + + +def test_completed_sequential_evidence_adapts_for_native_finalization() -> None: + planner = SequentialRnaTuningPlanner( + workflow_run_id="workflow", + assay="RNA", + n_cells=100, + n_features=50, + harmony_authorized=True, + ) + pca = _selected_phase(planner.pca_prefix_phase(), selected_index=1) + assert pca.selected_evaluation() is not None + correction = _selected_phase( + planner.batch_correction_phase(pca.selected_evaluation().parameters), + selected_index=1, + ) + assert correction.selected_evaluation() is not None + graph = _selected_phase( + planner.graph_phase(correction.selected_evaluation().parameters), + selected_index=1, + ) + assert graph.selected_evaluation() is not None + clustering = _selected_phase( + planner.clustering_phase(graph.selected_evaluation().parameters), + selected_index=2, + ) + final_id = clustering.selection.selectedCandidateId + evidence = SequentialAssayTuningEvidence( + assay="RNA", + phases=[pca, correction, graph, clustering], + finalCandidateId=final_id, + ) + + report = sequential_evidence_to_report(evidence) + + assert report.status == "done" + assert report.recommendedCandidateId == final_id + assert report.assayReports["RNA"].recommendedCandidateId == final_id + assert report.graphAssay == "RNA" + assert report.markerAssay == "RNA" + assert report.finalClusterArtifact == report.selectedArtifacts["clusters"] + assert report.finalClusterColumn is not None + assert report.totalCandidates == sum( + len(value.evaluations) for value in evidence.phases + ) + + +def test_pending_sequential_evidence_preserves_exact_resume_options() -> None: + planner = SequentialRnaTuningPlanner( + workflow_run_id="workflow", + assay="RNA", + n_cells=100, + n_features=50, + harmony_authorized=False, + ) + plan = planner.pca_prefix_phase() + evaluations = [_evaluation(candidate) for candidate in plan.candidates] + option_ids = ["pcaPrefix:short", "pcaPrefix:standard", "pcaPrefix:defer"] + evidence_ids = [value.evidenceIds[0] for value in evaluations] + state = SequentialAssayTuningEvidence( + assay="RNA", + phases=[ + ParameterPhaseEvidence( + plan=plan, + evaluations=evaluations, + selection=ParameterPhaseSelection( + phase="pcaPrefix", + status="needsInput", + rationale="The bounded decision run did not select an option.", + ), + ) + ], + pendingDecisionId="pcaPrefix", + pendingOptionIds=option_ids, + pendingEvidenceIds=evidence_ids, + ) + + report = sequential_evidence_to_report(state) + + assert report.status == "needsInput" + assert report.needsInput is not None + assert report.needsInput.options == option_ids + assert report.needsInput.evidenceIds == evidence_ids + + +def test_pending_correction_need_precedes_batch_phase() -> None: + planner = SequentialRnaTuningPlanner( + workflow_run_id="workflow", + assay="RNA", + n_cells=100, + n_features=50, + harmony_authorized=True, + ) + pca = _selected_phase(planner.pca_prefix_phase()) + state = SequentialAssayTuningEvidence( + assay="RNA", + phases=[pca], + correctionLicense="safe", + correctionNeed=CorrectionNeedSelection( + status="needsInput", + selectedOptionId="correctionNeed:indeterminate", + rationale="The native representation evidence is incomplete.", + ), + pendingDecisionId="correctionNeed", + pendingOptionIds=[ + "correctionNeed:needed", + "correctionNeed:notNeeded", + "correctionNeed:indeterminate", + ], + pendingEvidenceIds=["evidence:correctionNeed:design"], + ) + + report = sequential_evidence_to_report(state) + + assert report.status == "needsInput" + assert report.needsInput is not None + assert report.needsInput.options[0] == "correctionNeed:needed" + + +def test_phase_executor_adapter_preserves_registered_order( + monkeypatch: pytest.MonkeyPatch, +) -> None: + planner = SequentialRnaTuningPlanner( + workflow_run_id="workflow", + assay="RNA", + n_cells=50, + n_features=50, + harmony_authorized=False, + ) + plan = planner.pca_prefix_phase() + expected_ids = tuple(value.candidateId for value in plan.candidates) + calls: list[str] = [] + + def prepare(*args: Any, **kwargs: Any) -> tuple[object, list[str]]: + assert kwargs["candidates"] == plan.candidates + return object(), list(expected_ids) + + by_id = {value.candidateId: value for value in plan.candidates} + + def execute(deps: object, candidate_id: str) -> ParameterCandidateEvaluation: + assert deps is not None + calls.append(candidate_id) + return _evaluation(by_id[candidate_id]) + + monkeypatch.setattr( + "scarf.agent.sequential_tuning.prepare_parameter_tuning_dependencies", + prepare, + ) + monkeypatch.setattr( + "scarf.agent.sequential_tuning.execute_parameter_candidate", + execute, + ) + + evaluations = execute_parameter_phase(object(), normalized=object(), plan=plan) + + assert tuple(value.candidateId for value in evaluations) == expected_ids + assert tuple(calls) == expected_ids diff --git a/tests/test_agent_tuning_diagnostics.py b/tests/test_agent_tuning_diagnostics.py new file mode 100644 index 00000000..ce6f620c --- /dev/null +++ b/tests/test_agent_tuning_diagnostics.py @@ -0,0 +1,26 @@ +import numpy as np +import pytest +from scipy.sparse import block_diag, csr_matrix + +from scarf.agent.tuning_diagnostics import ( + _cross_unit_support, + _subsample_partition_stability, +) + + +def test_cross_unit_support_requires_replication_per_cluster() -> None: + labels = np.asarray([1, 1, 2, 2]) + units = np.asarray(["a", "b", "a", "a"]) + + assert _cross_unit_support(labels, units) == 0.5 + assert _cross_unit_support(labels, np.asarray(["a"] * 4)) is None + + +def test_subsample_partition_stability_reclusters_induced_graph() -> None: + community = csr_matrix(np.ones((8, 8)) - np.eye(8)) + graph = block_diag((community, community), format="csr") + labels = np.asarray([1] * 8 + [2] * 8) + + assert _subsample_partition_stability(graph, labels, 0.5) == pytest.approx(1.0) + with pytest.raises(ValueError, match="does not align"): + _subsample_partition_stability(graph, labels[:-1], 0.5) diff --git a/tests/test_registered_qc_profiles.py b/tests/test_registered_qc_profiles.py new file mode 100644 index 00000000..034e1501 --- /dev/null +++ b/tests/test_registered_qc_profiles.py @@ -0,0 +1,622 @@ +"""Tests for registered one-sided cell-quality profiles.""" + +import asyncio +from copy import deepcopy +from typing import Any + +import numpy as np +import pytest +import zarr +from pydantic import ValidationError +from zarr.storage import MemoryStore + +import scarf.agent.experimental_context as experimental_context_module +import scarf.agent.orchestrator.preprocessing as preprocessing_module +from scarf.agent.experimental_context import ( + CellQcPlan, + CellQcProfileEvidence, + ExperimentalContextResult, + inspect_cell_covariates, +) +from scarf.agent.orchestrator.main import AgentOrchestrator +from scarf.agent.qc_execution import execute_registered_cell_qc +from scarf.agent.qc_profiles import ( + RegisteredQcProjection, + offered_registered_qc_profiles, + project_registered_qc_profile, +) +from scarf.agent.types import ArtifactReferenceModel +from scarf.datastore._operations.quality_control import _QualityControlOperationsMixin +from scarf.storage.artifacts import ( + ArtifactRef, + artifact_group, + inspect_artifact, +) +from scarf.storage.selections import ( + read_stored_selection_mask, + resolve_selection_artifact, +) +from tests.test_agent_experimental_context import _Cells, _Store, _context + + +def _quality_values() -> dict[str, np.ndarray]: + return { + "RNA_nCounts": np.concatenate( + [np.linspace(80, 120, 39), np.asarray([1.0, 1_000.0, 100.0])] + ), + "RNA_nFeatures": np.concatenate( + [np.linspace(40, 60, 39), np.asarray([1.0, 500.0, 50.0])] + ), + "RNA_percentMito": np.concatenate([np.linspace(1, 5, 41), np.asarray([40.0])]), + "RNA_percentRibo": np.linspace(5, 25, 42), + } + + +def _profile_parameters( + projection: RegisteredQcProjection, +) -> dict[str, Any]: + return { + "policyVersion": 1, + "profile": projection.profile, + "nMads": 3.0 if projection.profile == "captureMad3Sensitivity" else 5.0, + "boundPolicy": { + "count": {"remove": "lower", "flag": "upper"}, + "feature": {"remove": "lower", "flag": "upper"}, + "mitochondrial": {"remove": "upper", "fixedCutoff": None}, + "diagnostic": {"remove": "none"}, + }, + "resolvedBounds": [threshold.to_dict() for threshold in projection.thresholds], + "captureSizes": projection.captureSizes, + "captureComparisons": [ + comparison.to_dict() for comparison in projection.captureComparisons + ], + "captureComparisonSource": None, + "pooledReferenceCaptures": [], + } + + +class _MemoryQcCells: + def __init__(self, group: zarr.Group) -> None: + self._group = group + self.N = int(group["ids"].shape[0]) + + @property + def columns(self) -> list[str]: + return list(self._group.array_keys()) + + def _get_array(self, column: str): + return self._group[column] + + def fetch_all(self, column: str) -> np.ndarray: + return np.asarray(self._group[column][:]) + + +class _MemoryQcStore(_QualityControlOperationsMixin): + def __init__(self, root: zarr.Group) -> None: + self.zw = root + self.cells = _MemoryQcCells(root["cellData"]) + + def snapshot_cell_selection(self, column: str = "I") -> ArtifactRef: + values = np.asarray(self.cells.fetch_all(column), dtype=bool) + return resolve_selection_artifact( + self.zw, + scope="datastore", + kind="cell_selection", + values=values, + row_ids=self.cells.fetch_all("ids"), + operation="snapshot_cell_selection", + parameters={"column": column}, + inputs={}, + source_column=column, + ) + + def inspect_artifact(self, ref: ArtifactRef): + return inspect_artifact(self.zw, ref) + + +def _memory_qc_store( + values_by_metric: dict[str, np.ndarray], +) -> tuple[_MemoryQcStore, ArtifactRef]: + first = next(iter(values_by_metric.values())) + n_cells = len(first) + if any(len(values) != n_cells for values in values_by_metric.values()): + raise ValueError("Memory QC metrics must have equal lengths") + root = zarr.open_group(store=MemoryStore(), mode="w") + cell_data = root.create_group("cellData") + cell_data.create_array( + "ids", + data=np.asarray([f"cell-{index}" for index in range(n_cells)]), + ) + cell_data.create_array("I", data=np.ones(n_cells, dtype=bool)) + for name, values in values_by_metric.items(): + cell_data.create_array(name, data=np.asarray(values)) + store = _MemoryQcStore(root) + return store, store.snapshot_cell_selection("I") + + +def test_global_registered_profile_uses_one_sided_data_derived_bounds() -> None: + values = _quality_values() + projection = project_registered_qc_profile( + "globalMad5", + values_by_metric=values, + active=np.ones(42, dtype=bool), + ) + + assert projection.keep[39] == np.False_ + assert projection.keep[40] == np.True_ + assert projection.keep[41] == np.False_ + assert projection.flags["RNA_nCounts:high"][40] == np.True_ + assert projection.flags["RNA_nFeatures:high"][40] == np.True_ + assert "RNA_percentRibo:highMito" not in projection.flags + assert not any( + threshold.metric == "RNA_percentRibo" for threshold in projection.thresholds + ) + + count_threshold = next( + threshold + for threshold in projection.thresholds + if threshold.metric == "RNA_nCounts" + ) + mito_threshold = next( + threshold + for threshold in projection.thresholds + if threshold.metric == "RNA_percentMito" + ) + assert count_threshold.lowerRemoval is not None + assert count_threshold.upperRemoval is None + assert count_threshold.upperFlag is not None + assert mito_threshold.lowerRemoval is None + assert mito_threshold.upperRemoval is not None + assert mito_threshold.upperRemoval != pytest.approx(8.0) + + +def test_retain_with_flags_never_removes_flagged_cells() -> None: + projection = project_registered_qc_profile( + "retainWithFlags", + values_by_metric=_quality_values(), + active=np.ones(42, dtype=bool), + ) + + assert projection.retainedCells == 42 + assert projection.keep.all() + assert projection.flags["RNA_nCounts:lowQuality"][39] == np.True_ + assert projection.flags["RNA_nCounts:high"][40] == np.True_ + assert projection.flags["RNA_percentMito:highMito"][41] == np.True_ + + +def test_capture_profiles_require_proof_and_minimum_capture_size() -> None: + values = _quality_values() + active = np.ones(42, dtype=bool) + labels = np.asarray(["a"] * 21 + ["b"] * 21) + + unproven = offered_registered_qc_profiles( + values_by_metric=values, + active=active, + capture_labels=labels, + grouping_proven=False, + ) + proven = offered_registered_qc_profiles( + values_by_metric=values, + active=active, + capture_labels=labels, + grouping_proven=True, + min_cells_per_capture=20, + ) + undersized = offered_registered_qc_profiles( + values_by_metric=values, + active=active, + capture_labels=np.asarray(["a"] * 19 + ["b"] * 23), + grouping_proven=True, + min_cells_per_capture=20, + ) + + assert [projection.profile for projection in unproven] == [ + "retainWithFlags", + "globalMad5", + ] + assert [projection.profile for projection in proven] == [ + "retainWithFlags", + "globalMad5", + "captureMad5", + "captureMad3Sensitivity", + ] + global_projection = next( + projection for projection in proven if projection.profile == "globalMad5" + ) + assert {item.capture for item in global_projection.captureComparisons} == { + "a", + "b", + } + assert [projection.profile for projection in undersized] == [ + "retainWithFlags", + "globalMad5", + ] + + +def test_capture_profiles_surface_adverse_global_capture_comparison() -> None: + labels = np.asarray(["good-a"] * 20 + ["good-b"] * 20 + ["failed"] * 20) + values = { + "RNA_nCounts": np.concatenate( + [ + np.linspace(90, 110, 20), + np.linspace(95, 115, 20), + np.linspace(3, 7, 20), + ] + ), + "RNA_nFeatures": np.concatenate( + [ + np.linspace(45, 55, 20), + np.linspace(48, 58, 20), + np.linspace(2, 6, 20), + ] + ), + "RNA_percentMito": np.concatenate( + [np.linspace(1, 3, 20), np.linspace(1, 4, 20), np.linspace(25, 35, 20)] + ), + } + + projection = project_registered_qc_profile( + "captureMad5", + values_by_metric=values, + active=np.ones(60, dtype=bool), + capture_labels=labels, + grouping_proven=True, + min_cells_per_capture=20, + ) + + assert projection.captureSizes == {"good-a": 20, "good-b": 20, "failed": 20} + assert "failed" in projection.failedCaptureCandidates + failed = next( + comparison + for comparison in projection.captureComparisons + if comparison.capture == "failed" + ) + assert failed.adverseGlobalOutlier is True + assert len(failed.reasons) >= 2 + assert projection.retainedByCapture["failed"] > 0 + + +def test_pooled_reference_profile_requires_explicit_eligible_captures() -> None: + values = _quality_values() + active = np.ones(42, dtype=bool) + labels = np.asarray(["small-a"] * 10 + ["small-b"] * 10 + ["large"] * 22) + + without_reference = offered_registered_qc_profiles( + values_by_metric=values, + active=active, + capture_labels=labels, + grouping_proven=True, + min_cells_per_capture=20, + ) + with_reference = offered_registered_qc_profiles( + values_by_metric=values, + active=active, + capture_labels=labels, + grouping_proven=True, + min_cells_per_capture=20, + pooled_reference_captures=("small-a", "small-b"), + ) + + assert "pooledReferenceMad5" not in { + projection.profile for projection in without_reference + } + pooled = next( + projection + for projection in with_reference + if projection.profile == "pooledReferenceMad5" + ) + assert {threshold.group for threshold in pooled.thresholds} == {"pooledReference"} + + +def test_experimental_context_offers_and_validates_registered_global_profile() -> None: + store = _Store() + store.cells._values["RNA_nCounts"] = np.linspace(10, 100, 12) + store.cells._values["RNA_nFeatures"] = np.linspace(5, 50, 12) + context = _context( + store, + directions={"cellQc": {"registeredProfile": "globalMad5"}}, + ) + + inspected = asyncio.run(inspect_cell_covariates(context)) + offered = { + profile.registeredProfile: profile + for profile in inspected.qcProfiles + if profile.registeredProfile is not None + } + + assert {"retainWithFlags", "globalMad5"}.issubset(offered) + assert offered["retainWithFlags"].action == "skip" + assert offered["globalMad5"].action == "registeredMad" + assert offered["globalMad5"].parameters["boundPolicy"]["count"] == { + "remove": "lower", + "flag": "upper", + } + assert offered["globalMad5"].parameters["boundPolicy"]["mitochondrial"] == { + "remove": "upper", + "fixedCutoff": None, + } + + selected = experimental_context_module._canonical_cell_qc_plan( + CellQcPlan(), + context.deps, + inspected.characterization, + ) + assert selected.registeredProfile == "globalMad5" + assert selected.profileId == offered["globalMad5"].profileId + assert selected.evidenceIds == [offered["globalMad5"].evidenceId] + + with pytest.raises(ValidationError, match="must use the registeredMad action"): + CellQcPlan( + action="globalGaussian", + registeredProfile="globalMad5", + profileId=offered["globalMad5"].profileId, + attributes=offered["globalMad5"].attributes, + ) + + +def test_experimental_context_does_not_infer_capture_from_observation_unit() -> None: + store = _Store() + store.cells._values["RNA_nCounts"] = np.linspace(10, 100, 12) + store.cells._values["RNA_nFeatures"] = np.linspace(5, 50, 12) + context = _context(store) + + inspected = asyncio.run(inspect_cell_covariates(context)) + + registered = { + profile.registeredProfile + for profile in inspected.qcProfiles + if profile.registeredProfile is not None + } + assert registered == {"retainWithFlags", "globalMad5"} + + +def test_experimental_context_offers_capture_profiles_only_for_explicit_source() -> ( + None +): + store = _Store() + n_cells = 60 + store.cells = _Cells( + { + "I": np.ones(n_cells, dtype=bool), + "ids": np.asarray([f"cell-{index}" for index in range(n_cells)]), + "names": np.asarray([f"cell-{index}" for index in range(n_cells)]), + "capture": np.asarray(["a"] * 20 + ["b"] * 20 + ["c"] * 20), + "RNA_nCounts": np.linspace(10, 100, n_cells), + "RNA_nFeatures": np.linspace(5, 50, n_cells), + } + ) + store.zw = zarr.open_group(store=MemoryStore(), mode="w") + cell_data = store.zw.create_group("cellData") + cell_data.create_array("ids", data=store.cells._values["ids"].astype("U16")) + cell_data.create_array("I", data=store.cells._values["I"]) + store.refresh_cell_selection() + context = _context( + store, + directions={ + "physicalCaptureColumn": "capture", + "cellQc": {"pooledReferenceCaptures": ["a", "b"]}, + }, + ) + + inspected = asyncio.run(inspect_cell_covariates(context)) + + registered = { + profile.registeredProfile: profile + for profile in inspected.qcProfiles + if profile.registeredProfile is not None + } + assert set(registered) == { + "retainWithFlags", + "globalMad5", + "captureMad5", + "captureMad3Sensitivity", + "pooledReferenceMad5", + } + assert registered["captureMad5"].sampleColumn == "capture" + assert registered["captureMad5"].parameters["captureSizes"] == { + "a": 20, + "b": 20, + "c": 20, + } + assert registered["globalMad5"].parameters["captureComparisonSource"] == ( + "metadata:capture" + ) + assert registered["pooledReferenceMad5"].parameters["pooledReferenceCaptures"] == [ + "a", + "b", + ] + + +def test_datastore_executes_exact_registered_bounds_and_persists_flags() -> None: + n_cells = 64 + counts = np.linspace(80.0, 120.0, n_cells) + counts[0] = 1.0 + counts[-1] = 10_000.0 + mito = np.linspace(1.0, 5.0, n_cells) + mito[-2] = 80.0 + store, source = _memory_qc_store({"RNA_nCounts": counts, "RNA_percentMito": mito}) + active = np.ones(n_cells, dtype=bool) + projection = project_registered_qc_profile( + "globalMad5", + values_by_metric={ + "RNA_nCounts": counts, + "RNA_percentMito": mito, + }, + active=active, + ) + parameters = _profile_parameters(projection) + live_before = np.asarray(store.cells.fetch_all("I"), dtype=bool).copy() + + selected, flags = execute_registered_cell_qc( + store, + "globalMad5", + profile_parameters=parameters, + expected_active_cells=n_cells, + expected_retained_cells=projection.retainedCells, + expected_flag_counts=projection.flagCounts, + attrs=["RNA_nCounts", "RNA_percentMito"], + cell_selection=source, + ) + + stored_selection = read_stored_selection_mask( + store.zw, + selected, + kind="cell_selection", + scope="datastore", + assay=None, + table_path="cellData", + ) + np.testing.assert_array_equal(stored_selection, projection.keep) + np.testing.assert_array_equal(store.cells.fetch_all("I"), live_before) + assert flags is not None + selection_status = store.inspect_artifact(selected) + assert selection_status.operation == "run_registered_cell_qc" + assert selection_status.parameters["profileParameters"] == parameters + assert selection_status.inputs["diagnostic_flags"] == flags.to_dict() + flag_status = store.inspect_artifact(flags) + flag_names = flag_status.parameters["flagNames"] + flag_values = np.asarray(artifact_group(store.zw, flags)["values"][:], dtype=bool) + assert flag_values.shape == (n_cells, len(flag_names)) + assert { + name: int(flag_values[:, index].sum()) for index, name in enumerate(flag_names) + } == projection.flagCounts + assert projection.keep[-1] + assert projection.flags["RNA_nCounts:high"][-1] + assert not projection.keep[-2] + + repeated, repeated_flags = execute_registered_cell_qc( + store, + "globalMad5", + profile_parameters=parameters, + expected_active_cells=n_cells, + expected_retained_cells=projection.retainedCells, + expected_flag_counts=projection.flagCounts, + attrs=["RNA_nCounts", "RNA_percentMito"], + cell_selection=source, + ) + assert repeated == selected + assert repeated_flags == flags + + +def test_datastore_rejects_modified_registered_bounds() -> None: + counts = np.concatenate([np.linspace(80.0, 120.0, 39), np.asarray([1.0])]) + store, source = _memory_qc_store({"RNA_nCounts": counts}) + active = read_stored_selection_mask( + store.zw, + source, + kind="cell_selection", + scope="datastore", + assay=None, + table_path="cellData", + ) + active_counts = np.asarray(store.cells.fetch_all("RNA_nCounts"), dtype=float)[ + active + ] + projection = project_registered_qc_profile( + "globalMad5", + values_by_metric={"RNA_nCounts": active_counts}, + active=np.ones(len(active_counts), dtype=bool), + ) + parameters = deepcopy(_profile_parameters(projection)) + parameters["resolvedBounds"][0]["lowerRemoval"] += 1.0 + + with pytest.raises(ValueError, match="resolved bounds do not match"): + execute_registered_cell_qc( + store, + "globalMad5", + profile_parameters=parameters, + expected_active_cells=int(active.sum()), + expected_retained_cells=projection.retainedCells, + expected_flag_counts=projection.flagCounts, + attrs=["RNA_nCounts"], + cell_selection=source, + ) + + +def test_orchestrator_executes_retain_with_flags_instead_of_plain_skip( + monkeypatch: pytest.MonkeyPatch, +) -> None: + values = {"RNA_nCounts": np.asarray([1.0, 10.0, 11.0, 100.0])} + projection = project_registered_qc_profile( + "retainWithFlags", + values_by_metric=values, + active=np.ones(4, dtype=bool), + ) + parameters = _profile_parameters(projection) + profile = CellQcProfileEvidence( + profileId="registered-retain", + action="skip", + registeredProfile="retainWithFlags", + driverAssay="RNA", + driverAssayType="RNA", + attributes=["RNA_nCounts"], + parameters=parameters, + activeCells=4, + retainedCells=projection.retainedCells, + retainedFraction=1.0, + flaggedCells=projection.flagCounts, + evidenceId="qcProfile:registered-retain", + ) + plan = CellQcPlan( + action=profile.action, + registeredProfile=profile.registeredProfile, + profileId=profile.profileId, + driverAssay=profile.driverAssay, + driverAssayType=profile.driverAssayType, + attributes=profile.attributes, + evidenceIds=[profile.evidenceId], + ) + experimental = ExperimentalContextResult.get_blank().model_copy( + update={"cellQc": plan, "qcProfiles": [profile]} + ) + source = ArtifactRef( + scope="datastore", + kind="cell_selection", + artifact_id="a" * 64, + ) + selected = ArtifactRef( + scope="datastore", + kind="cell_selection", + artifact_id="b" * 64, + ) + flags = ArtifactRef( + scope="datastore", + kind="metadata_snapshot", + artifact_id="c" * 64, + ) + captured: dict[str, Any] = {} + + class Store: + pass + + def fake_execute( + store: Any, + *args: Any, + **kwargs: Any, + ) -> tuple[ArtifactRef, ArtifactRef]: + captured["store"] = store + captured["args"] = args + captured.update(kwargs) + return selected, flags + + monkeypatch.setattr( + preprocessing_module, + "execute_registered_cell_qc", + fake_execute, + ) + + actions: list[str] = [] + operations: list[dict[str, Any]] = [] + store = Store() + result = AgentOrchestrator(object()).apply_cell_qc( + store, experimental, source, actions, operations + ) + + assert result == selected + assert captured["store"] is store + assert captured["args"] == ("retainWithFlags",) + assert captured["profile_parameters"] == parameters + assert actions == ["cell_qc_registered:retainWithFlags"] + assert operations[0]["diagnosticFlags"] == ( + ArtifactReferenceModel.from_artifact_ref(flags).model_dump(mode="json") + ) From 8faf0c8b185f2bf0edf82012a259072f773f8ae0 Mon Sep 17 00:00:00 2001 From: gautam8387 Date: Sun, 6 Sep 2026 00:15:53 +0200 Subject: [PATCH 08/21] refactor: organize modules by domain owner --- scarf/agent/__init__.py | 16 +- scarf/agent/{config => }/_deps.py | 0 scarf/agent/biological_interpretation.py | 1676 ------- .../biological_interpretation/__init__.py | 41 + .../agent/biological_interpretation/agent.py | 255 + .../biological_interpretation/contracts.py | 383 ++ .../agent/biological_interpretation/tools.py | 483 ++ .../biological_interpretation/validation.py | 609 +++ scarf/agent/cell_quality/__init__.py | 1 + .../execution.py} | 16 +- .../profiles.py} | 2 +- scarf/agent/config/__init__.py | 57 - scarf/agent/config/agent_exec.py | 2 +- scarf/agent/data_enrichment.py | 1875 ------- scarf/agent/data_enrichment/__init__.py | 61 + scarf/agent/data_enrichment/agent.py | 283 ++ .../characterization.py} | 34 +- scarf/agent/data_enrichment/contracts.py | 626 +++ scarf/agent/data_enrichment/tools.py | 615 +++ scarf/agent/data_enrichment/validation.py | 393 ++ scarf/agent/decisions/__init__.py | 1 + .../kernel.py} | 4 +- .../{rna_decisions.py => decisions/rna.py} | 4 +- .../{decide.py => decisions/selection.py} | 4 +- scarf/agent/experimental_context.py | 4394 ----------------- scarf/agent/experimental_context/__init__.py | 53 + scarf/agent/experimental_context/agent.py | 424 ++ .../characterization.py} | 138 +- scarf/agent/experimental_context/contracts.py | 954 ++++ .../agent/experimental_context/qc_evidence.py | 1564 ++++++ .../study.py} | 2 +- scarf/agent/experimental_context/tools.py | 758 +++ .../agent/experimental_context/validation.py | 829 ++++ scarf/agent/hypotheses/__init__.py | 21 + scarf/agent/hypotheses/contracts.py | 141 + .../execution.py} | 159 +- scarf/agent/ingest/common.py | 4 +- scarf/agent/ingest/manifest.py | 4 +- scarf/agent/ingest/result.py | 4 +- scarf/agent/orchestrator/context.py | 12 +- scarf/agent/orchestrator/decisions.py | 14 +- scarf/agent/orchestrator/finalization.py | 32 +- scarf/agent/orchestrator/journal.py | 8 +- scarf/agent/orchestrator/main.py | 10 +- scarf/agent/orchestrator/models.py | 8 +- scarf/agent/orchestrator/preprocessing.py | 46 +- scarf/agent/orchestrator/tuning.py | 92 +- scarf/agent/parameter_tuning.py | 4286 ---------------- scarf/agent/parameter_tuning/__init__.py | 117 + scarf/agent/parameter_tuning/agent.py | 934 ++++ scarf/agent/parameter_tuning/contracts.py | 750 +++ .../diagnostics.py} | 27 +- scarf/agent/parameter_tuning/execution.py | 712 +++ .../hvg.py} | 18 +- scarf/agent/parameter_tuning/prompts.py | 445 ++ scarf/agent/parameter_tuning/selection.py | 1522 ++++++ .../sequential.py} | 14 +- scarf/agent/persistence/__init__.py | 47 + scarf/agent/persistence/contracts.py | 415 ++ .../decisions.py} | 21 +- .../reports.py} | 468 +- scarf/agent/report/__init__.py | 5 + scarf/agent/report/artifacts.py | 547 ++ scarf/agent/report/contracts.py | 243 + scarf/agent/report/decision_tree.py | 1183 +++++ scarf/agent/report/generator.py | 153 + scarf/agent/report/plots.py | 812 +++ .../agent/{report.py => report/rendering.py} | 3180 +----------- scarf/agent/types.py | 2 +- tests/test_agent_biological_interpretation.py | 60 +- tests/test_agent_characterize_covariates.py | 8 +- tests/test_agent_characterize_features.py | 6 +- tests/test_agent_data_enrichment.py | 25 +- tests/test_agent_decide.py | 2 +- tests/test_agent_decision_kernel.py | 2 +- tests/test_agent_decision_persistence.py | 8 +- tests/test_agent_exec.py | 114 + tests/test_agent_experimental_context.py | 73 +- tests/test_agent_hvg_diagnostics.py | 2 +- tests/test_agent_ingest.py | 2 +- tests/test_agent_orchestrator.py | 39 +- tests/test_agent_orchestrator_stages.py | 8 +- tests/test_agent_parameter_tuning.py | 94 +- tests/test_agent_report.py | 188 +- tests/test_agent_rna_decisions.py | 4 +- tests/test_agent_sequential_tuning.py | 6 +- tests/test_agent_tuning_diagnostics.py | 2 +- tests/test_import_architecture.py | 145 + tests/test_registered_qc_profiles.py | 8 +- 89 files changed, 17416 insertions(+), 16393 deletions(-) rename scarf/agent/{config => }/_deps.py (100%) delete mode 100644 scarf/agent/biological_interpretation.py create mode 100644 scarf/agent/biological_interpretation/__init__.py create mode 100644 scarf/agent/biological_interpretation/agent.py create mode 100644 scarf/agent/biological_interpretation/contracts.py create mode 100644 scarf/agent/biological_interpretation/tools.py create mode 100644 scarf/agent/biological_interpretation/validation.py create mode 100644 scarf/agent/cell_quality/__init__.py rename scarf/agent/{qc_execution.py => cell_quality/execution.py} (98%) rename scarf/agent/{qc_profiles.py => cell_quality/profiles.py} (99%) delete mode 100644 scarf/agent/data_enrichment.py create mode 100644 scarf/agent/data_enrichment/__init__.py create mode 100644 scarf/agent/data_enrichment/agent.py rename scarf/agent/{characterize_features.py => data_enrichment/characterization.py} (96%) create mode 100644 scarf/agent/data_enrichment/contracts.py create mode 100644 scarf/agent/data_enrichment/tools.py create mode 100644 scarf/agent/data_enrichment/validation.py create mode 100644 scarf/agent/decisions/__init__.py rename scarf/agent/{decision_kernel.py => decisions/kernel.py} (99%) rename scarf/agent/{rna_decisions.py => decisions/rna.py} (99%) rename scarf/agent/{decide.py => decisions/selection.py} (98%) delete mode 100644 scarf/agent/experimental_context.py create mode 100644 scarf/agent/experimental_context/__init__.py create mode 100644 scarf/agent/experimental_context/agent.py rename scarf/agent/{characterize_covariates.py => experimental_context/characterization.py} (94%) create mode 100644 scarf/agent/experimental_context/contracts.py create mode 100644 scarf/agent/experimental_context/qc_evidence.py rename scarf/agent/{study_contract.py => experimental_context/study.py} (99%) create mode 100644 scarf/agent/experimental_context/tools.py create mode 100644 scarf/agent/experimental_context/validation.py create mode 100644 scarf/agent/hypotheses/__init__.py create mode 100644 scarf/agent/hypotheses/contracts.py rename scarf/agent/{hypothesis_testing.py => hypotheses/execution.py} (54%) delete mode 100644 scarf/agent/parameter_tuning.py create mode 100644 scarf/agent/parameter_tuning/__init__.py create mode 100644 scarf/agent/parameter_tuning/agent.py create mode 100644 scarf/agent/parameter_tuning/contracts.py rename scarf/agent/{tuning_diagnostics.py => parameter_tuning/diagnostics.py} (98%) create mode 100644 scarf/agent/parameter_tuning/execution.py rename scarf/agent/{hvg_diagnostics.py => parameter_tuning/hvg.py} (98%) create mode 100644 scarf/agent/parameter_tuning/prompts.py create mode 100644 scarf/agent/parameter_tuning/selection.py rename scarf/agent/{sequential_tuning.py => parameter_tuning/sequential.py} (99%) create mode 100644 scarf/agent/persistence/__init__.py create mode 100644 scarf/agent/persistence/contracts.py rename scarf/agent/{decision_persistence.py => persistence/decisions.py} (99%) rename scarf/agent/{persistence.py => persistence/reports.py} (71%) create mode 100644 scarf/agent/report/__init__.py create mode 100644 scarf/agent/report/artifacts.py create mode 100644 scarf/agent/report/contracts.py create mode 100644 scarf/agent/report/decision_tree.py create mode 100644 scarf/agent/report/generator.py create mode 100644 scarf/agent/report/plots.py rename scarf/agent/{report.py => report/rendering.py} (50%) diff --git a/scarf/agent/__init__.py b/scarf/agent/__init__.py index b562b268..83fc10e2 100644 --- a/scarf/agent/__init__.py +++ b/scarf/agent/__init__.py @@ -5,16 +5,14 @@ BiologicalInterpretationAgent, BiologicalInterpretationReport, ) -from .characterize_covariates import ( - CovariateCharacterization, - characterize_covariates, -) -from .characterize_features import ( +from .experimental_context.characterization import characterize_covariates +from .experimental_context.contracts import CovariateCharacterization +from .data_enrichment.characterization import ( FeatureCharacterization, characterize_features, ) from .config import AgentRunConfig -from .config import _deps as _deps +from . import _deps as _deps from .config.agent_exec import run_agent, run_agent_sync from .data_enrichment import ( DataEnrichmentAgent, @@ -22,8 +20,8 @@ DataEnrichmentReport, StudyContextSummary, ) -from .decide import DecisionValidationError, decide -from .decision_kernel import ( +from .decisions.selection import DecisionValidationError, decide +from .decisions.kernel import ( DecisionEvidence, DecisionOption, DecisionRecord, @@ -103,7 +101,7 @@ ) from .report import generate_agent_report from .runtime import check_runtime, load_env -from .study_contract import StudyContract +from .experimental_context.study import StudyContract from .types import ( BatchSafetyEvidence, Decision, diff --git a/scarf/agent/config/_deps.py b/scarf/agent/_deps.py similarity index 100% rename from scarf/agent/config/_deps.py rename to scarf/agent/_deps.py diff --git a/scarf/agent/biological_interpretation.py b/scarf/agent/biological_interpretation.py deleted file mode 100644 index 98cd0fc3..00000000 --- a/scarf/agent/biological_interpretation.py +++ /dev/null @@ -1,1676 +0,0 @@ -"""Grounded biological interpretation of Scarf cluster results.""" - -import math -from collections import Counter, defaultdict -from collections.abc import Mapping -from textwrap import dedent -from typing import Any, Literal - -import numpy as np -from pydantic import Field - -from ..metadata.rows import read_metadata_missing_rows, read_metadata_rows -from ..storage.refs import ArtifactRef -from ..storage.selections import read_stored_selection_indices -from .config import CONFIG, AgentRunConfig -from .config.agent_exec import run_agent_sync -from .tools import artifact_reference, core_artifact_reference -from .types import ( - AgentDataModel, - AgentRunInfo, - ArtifactReferenceModel, - ExperimentalBiologyHandoff, - StageStatus, - TuningBiologyHandoff, -) -from ..utils.logging import logger - -try: - from pydantic_ai import ( - ModelRetry, - RunContext, - Tool, - UnexpectedModelBehavior, - UsageLimitExceeded, - ) - from pydantic_ai.tools import ToolDefinition -except ImportError as exc: - from .config._deps import AGENT_INSTALL_HINT - - raise ImportError(AGENT_INSTALL_HINT) from exc - -__all__ = [ - "BiologicalContext", - "BiologicalInterpretationAgent", - "BiologicalInterpretationNeedsInput", - "BiologicalInterpretationReport", - "ClusterCompositionEvidence", - "ClusterInterpretation", - "ClusterMarkerBatchEvidence", - "ClusterMarkerEvidence", - "ConditionClusterSummary", - "FollowUpRecommendation", - "MarkerFeature", - "TreatmentObservation", - "inspect_cluster_composition", - "inspect_cluster_markers_batch", - "inspect_cluster_markers", - "validate_biological_interpretation_report", -] - -type InterpretationConfidence = Literal["low", "medium", "high"] -type TreatmentDirection = Literal["higher", "lower", "equal"] - - -class BiologicalContext(AgentDataModel): - """Caller-supplied facts that constrain biological interpretation.""" - - organism: str = "" - studyContext: str = "" - tissue: str = "" - cellTypeReferences: list[str] = Field(default_factory=list) - experimentalDetails: list[str] = Field(default_factory=list) - treatmentQuestion: str = "" - - @classmethod - def get_blank(cls) -> "BiologicalContext": - return cls() - - @classmethod - def get_example(cls) -> "BiologicalContext": - return cls( - organism="Homo sapiens", - studyContext=( - "Human lung samples were profiled after drug or vehicle treatment." - ), - tissue="lung", - cellTypeReferences=["alveolar macrophage", "T cell"], - experimentalDetails=["drug and vehicle groups"], - treatmentQuestion="Which populations respond selectively to treatment?", - ) - - -class ConditionClusterSummary(AgentDataModel): - """Aggregate cluster abundance for one condition without sample identifiers.""" - - condition: str = "" - clusterId: str = "" - nSamples: int = 0 - meanFraction: float = 0.0 - minFraction: float = 0.0 - maxFraction: float = 0.0 - cellCount: int = 0 - evidenceId: str = "" - - @classmethod - def get_example(cls) -> "ConditionClusterSummary": - return cls( - condition="treated", - clusterId="3", - nSamples=4, - meanFraction=0.18, - minFraction=0.12, - maxFraction=0.25, - cellCount=180, - evidenceId="composition:RNA_cluster:condition:treated:cluster:3", - ) - - -class ClusterCompositionEvidence(AgentDataModel): - """Bounded deterministic evidence about cluster sizes and conditions.""" - - clusterArtifact: ArtifactReferenceModel | None = None - cellSelection: ArtifactReferenceModel | None = None - totalCells: int = 0 - clusterCounts: dict[str, int] = Field(default_factory=dict) - sampleColumn: str | None = None - conditionColumn: str | None = None - conditionSummaries: list[ConditionClusterSummary] = Field(default_factory=list) - evidenceIds: list[str] = Field(default_factory=list) - warnings: list[str] = Field(default_factory=list) - - @classmethod - def get_example(cls) -> "ClusterCompositionEvidence": - summary = ConditionClusterSummary.get_example() - reference_summary = ConditionClusterSummary( - condition="control", - clusterId=summary.clusterId, - nSamples=4, - meanFraction=0.11, - minFraction=0.08, - maxFraction=0.15, - cellCount=110, - evidenceId="composition:RNA_cluster:condition:control:cluster:3", - ) - return cls( - clusterArtifact=ArtifactReferenceModel( - assay="RNA", - kind="cluster_labels", - artifactId="b" * 64, - ), - cellSelection=ArtifactReferenceModel( - scope="datastore", - assay=None, - kind="cell_selection", - artifactId="c" * 64, - ), - totalCells=1000, - clusterCounts={"0": 520, "1": 300, "3": 180}, - sampleColumn="sample", - conditionColumn="treatment", - conditionSummaries=[reference_summary, summary], - evidenceIds=[ - "composition:RNA_cluster:counts", - reference_summary.evidenceId, - summary.evidenceId, - ], - ) - - -class MarkerFeature(AgentDataModel): - """One observed marker feature and its available Scarf statistics.""" - - featureId: str = "" - featureName: str = "" - featureIndex: int | None = None - score: float | None = None - foldChange: float | None = None - fractionExpressed: float | None = None - fractionExpressedRest: float | None = None - mean: float | None = None - meanRest: float | None = None - auc: float | None = None - adjustedPvalue: float | None = None - - @classmethod - def get_example(cls) -> "MarkerFeature": - return cls( - featureId="ENSG00000173372", - featureName="C1QA", - featureIndex=123, - score=0.83, - foldChange=3.4, - fractionExpressed=0.76, - fractionExpressedRest=0.18, - auc=0.91, - adjustedPvalue=0.001, - ) - - -class ClusterMarkerEvidence(AgentDataModel): - """Bounded markers for one exact cluster label.""" - - clusterId: str = "" - markers: list[MarkerFeature] = Field(default_factory=list) - markerArtifact: ArtifactReferenceModel | None = None - evidenceId: str = "" - warnings: list[str] = Field(default_factory=list) - - @classmethod - def get_example(cls) -> "ClusterMarkerEvidence": - return cls( - clusterId="3", - markers=[MarkerFeature.get_example()], - markerArtifact=ArtifactReferenceModel( - assay="RNA", - kind="marker_table", - artifactId="a" * 64, - ), - evidenceId="markers:RNA_cluster:cluster:3", - ) - - -class ClusterMarkerBatchEvidence(AgentDataModel): - """Markers for all model-selected clusters returned by one tool call.""" - - clusters: list[ClusterMarkerEvidence] = Field(default_factory=list) - evidenceIds: list[str] = Field(default_factory=list) - warnings: list[str] = Field(default_factory=list) - - @classmethod - def get_blank(cls) -> "ClusterMarkerBatchEvidence": - return cls() - - @classmethod - def get_example(cls) -> "ClusterMarkerBatchEvidence": - cluster = ClusterMarkerEvidence.get_example() - return cls(clusters=[cluster], evidenceIds=[cluster.evidenceId]) - - -class ClusterInterpretation(AgentDataModel): - """One evidence-linked cluster interpretation or hypothesis.""" - - clusterId: str = "" - proposedIdentity: str = "unresolved" - identityIsHypothesis: bool = True - confidence: InterpretationConfidence = "low" - rationale: str = "" - evidenceIds: list[str] = Field(default_factory=list) - - @classmethod - def get_example(cls) -> "ClusterInterpretation": - return cls( - clusterId="3", - proposedIdentity="alveolar macrophage-like", - identityIsHypothesis=True, - confidence="medium", - rationale="Observed marker pattern is consistent with the proposed identity.", - evidenceIds=["markers:RNA_cluster:cluster:3"], - ) - - -class TreatmentObservation(AgentDataModel): - """Descriptive treatment observation with no unsupported causal claim.""" - - clusterId: str = "" - referenceCondition: str = "" - comparisonCondition: str = "" - direction: TreatmentDirection = "equal" - observation: str = "" - isDescriptiveOnly: Literal[True] = True - evidenceIds: list[str] = Field(default_factory=list) - - @classmethod - def get_example(cls) -> "TreatmentObservation": - return cls( - clusterId="3", - referenceCondition="control", - comparisonCondition="treated", - direction="higher", - observation="Cluster 3 has a higher mean fraction in treated samples.", - evidenceIds=[ - "composition:RNA_cluster:condition:control:cluster:3", - "composition:RNA_cluster:condition:treated:cluster:3", - ], - ) - - -class FollowUpRecommendation(AgentDataModel): - """A bounded next analysis tied to an observed uncertainty.""" - - question: str = "" - operation: str = "" - rationale: str = "" - requiredInputs: list[str] = Field(default_factory=list) - evidenceIds: list[str] = Field(default_factory=list) - - @classmethod - def get_example(cls) -> "FollowUpRecommendation": - return cls( - question="Is the abundance difference reproducible across donors?", - operation="sample-level differential abundance", - rationale="Current evidence is descriptive and requires independent replicates.", - requiredInputs=["sample", "condition", "donor"], - evidenceIds=[ - "composition:RNA_cluster:condition:control:cluster:3", - "composition:RNA_cluster:condition:treated:cluster:3", - ], - ) - - -class BiologicalInterpretationNeedsInput(AgentDataModel): - question: str = "" - requiredInputs: list[str] = Field(default_factory=list) - evidenceIds: list[str] = Field(default_factory=list) - - @classmethod - def get_example(cls) -> "BiologicalInterpretationNeedsInput": - return cls( - question="Provide an exact marker artifact or authorize marker search.", - requiredInputs=["markerArtifact"], - ) - - -class BiologicalInterpretationReport(AgentDataModel): - """Structured, evidence-grounded biological review.""" - - status: StageStatus = "needsInput" - clusterInterpretations: list[ClusterInterpretation] = Field(default_factory=list) - treatmentObservations: list[TreatmentObservation] = Field(default_factory=list) - followUps: list[FollowUpRecommendation] = Field(default_factory=list) - clusterArtifact: ArtifactReferenceModel | None = None - markerArtifact: ArtifactReferenceModel | None = None - graphAssay: str | None = None - markerAssay: str | None = None - evidenceIds: list[str] = Field(default_factory=list) - limitations: list[str] = Field(default_factory=list) - stopReason: str = "" - needsInput: BiologicalInterpretationNeedsInput | None = None - runInfo: AgentRunInfo = Field(default_factory=AgentRunInfo) - - @classmethod - def get_example(cls) -> "BiologicalInterpretationReport": - interpretation = ClusterInterpretation.get_example() - observation = TreatmentObservation.get_example() - follow_up = FollowUpRecommendation.get_example() - return cls( - status="done", - clusterInterpretations=[interpretation], - treatmentObservations=[observation], - followUps=[follow_up], - clusterArtifact=ClusterCompositionEvidence.get_example().clusterArtifact, - markerArtifact=ClusterMarkerEvidence.get_example().markerArtifact, - graphAssay="RNA", - markerAssay="RNA", - evidenceIds=sorted( - { - *interpretation.evidenceIds, - *observation.evidenceIds, - *follow_up.evidenceIds, - } - ), - limitations=[ - "Cell identities remain hypotheses until independently validated." - ], - stopReason="The requested clusters were reviewed.", - ) - - -class BiologicalInterpretationDependencies(AgentDataModel): - """Runtime state available only to biological interpretation tools.""" - - store: Any = Field(default=None, exclude=True) - cluster: Any = Field(default=None, exclude=True) - cellSelection: Any = Field(default=None, exclude=True) - cellIndices: Any = Field(default=None, exclude=True) - fromAssay: str | None = None - graphAssay: str | None = None - markerAssay: str | None = None - markerAssayType: str | None = None - sampleColumn: str | None = None - conditionColumn: str | None = None - marker: Any = Field(default=None, exclude=True) - markerFeatures: Any = Field(default=None, exclude=True) - allowMarkerSearch: bool = False - maxClusters: int = 12 - maxMarkers: int = 10 - markerMinScore: float = 0.25 - markerMinFraction: float = 0.2 - evidenceIds: set[str] = Field(default_factory=set, exclude=True) - clusterValues: dict[str, Any] = Field(default_factory=dict, exclude=True) - markerEvidenceIds: dict[str, str] = Field(default_factory=dict, exclude=True) - markerEvidence: dict[str, ClusterMarkerEvidence] = Field( - default_factory=dict, - exclude=True, - ) - compositionEvidence: ClusterCompositionEvidence | None = Field( - default=None, - exclude=True, - ) - markerBatch: ClusterMarkerBatchEvidence | None = Field( - default=None, - exclude=True, - ) - markerBatchClusterIds: list[str] = Field(default_factory=list, exclude=True) - toolCalls: list[str] = Field(default_factory=list, exclude=True) - conditionEvidence: dict[str, ConditionClusterSummary] = Field( - default_factory=dict, - exclude=True, - ) - designHandoff: ExperimentalBiologyHandoff | None = Field( - default=None, - exclude=True, - ) - - @classmethod - def get_example(cls) -> "BiologicalInterpretationDependencies": - return cls( - cluster=object(), - fromAssay="RNA", - graphAssay="RNA", - markerAssay="RNA", - markerAssayType="RNA", - sampleColumn="sample", - conditionColumn="treatment", - ) - - -def _prepare_biological_interpretation_tool( - ctx: RunContext[BiologicalInterpretationDependencies], - tool_definition: ToolDefinition, -) -> ToolDefinition | None: - """Expose composition and marker batches once and in the required order.""" - completed_calls = set(ctx.deps.toolCalls) - if tool_definition.name == "inspect_cluster_composition": - return None if tool_definition.name in completed_calls else tool_definition - if tool_definition.name == "inspect_cluster_markers_batch": - if ( - "inspect_cluster_composition" not in completed_calls - or tool_definition.name in completed_calls - ): - return None - return tool_definition - return tool_definition - - -_SYSTEM_PROMPT = dedent( - """ - You are Scarf's Biological Interpretation Agent. Use only the supplied - tools and caller context. Call inspect_cluster_composition exactly once. - Then select every cluster you intend to interpret and call - inspect_cluster_markers_batch exactly once with all selected cluster IDs. - Tool calls execute Scarf operations, so wait for their results before - drawing conclusions. Do not split marker inspection across calls. - - This API does not provide trusted per-cluster identities. Treat every cell - identity as a hypothesis and always set identityIsHypothesis=true. Caller - cell-type references are context, not assignments to clusters. Prefer - proposedIdentity="unresolved" when the returned markers do not ground a - specific hypothesis. Do not invent genes, cell types, statistics, artifact - identifiers, or evidence identifiers. Cite only evidenceIds returned by - tools. For each cluster interpretation, copy the exact non-empty marker - evidenceId returned for that cluster into its evidenceIds. Do not interpret - a cluster whose marker evidenceId is empty. Cluster abundance summaries are - descriptive, not tests of significance or causal effects. Treatment observations must - compare two returned independent-unit condition summaries for the same - cluster. Independent units may occur in more than one condition in paired - or repeated-measure designs. Return treatmentObservations empty unless the - exact experimental handoff confirms independent-unit aggregation, a - between-unit coefficient, an estimable coefficient, and at least two - independent units in each cited condition. - Marker p-values describe cluster-versus-rest marker specificity, not - condition effects. Keep treatment content out of cluster identity - interpretations. Never return status=failed for uncertainty or weak - evidence. Return needsInput with one concrete question instead. Recommend a - named follow-up operation when replication, a covariate, or an exact - artifact is missing. Do not write exploratory code, use a shell, access - files, or call arbitrary Scarf methods. Return only fields defined by the - structured output schema. - """ -).strip() - - -def _string_value(value: Any) -> str: - return str(value.item() if isinstance(value, np.generic) else value) - - -def _finite_float(value: Any) -> float | None: - if value is None: - return None - try: - number = float(value) - except (TypeError, ValueError): - return None - return number if np.isfinite(number) else None - - -def _check_column(store: Any, column: str, label: str) -> None: - if column not in set(store.cells.columns): - raise ValueError(f"{label} {column!r} is not present in cell metadata") - - -async def inspect_cluster_composition( - ctx: RunContext[BiologicalInterpretationDependencies], -) -> ClusterCompositionEvidence: - """Inspect bounded cluster and condition composition without identifiers.""" - deps = ctx.deps - if deps.compositionEvidence is not None: - logger.info("Reused completed cluster composition inspection") - return deps.compositionEvidence - logger.info( - f"Inspecting cluster composition from artifact " - f"{getattr(deps.cluster, 'artifact_id', '')!r}; " - f"max_clusters={deps.maxClusters}" - ) - if deps.sampleColumn is not None: - _check_column(deps.store, deps.sampleColumn, "sample column") - if deps.conditionColumn is not None: - _check_column(deps.store, deps.conditionColumn, "condition column") - - if deps.cluster is None: - raise ValueError("An exact cluster artifact is required") - deps.cluster = core_artifact_reference(deps.cluster) - cluster_artifact = artifact_reference(deps.cluster) - if cluster_artifact.kind not in {"cluster_labels", "cluster_cut"}: - raise ValueError( - "cluster must identify a cluster_labels or cluster_cut artifact" - ) - if ( - deps.graphAssay is not None - and cluster_artifact.scope == "assay" - and cluster_artifact.assay != deps.graphAssay - ): - raise ValueError("cluster artifact belongs to a different assay") - if cluster_artifact.scope == "datastore" and cluster_artifact.assay is not None: - raise ValueError("datastore-scoped cluster artifacts must not name an assay") - status = deps.store.inspect_artifact(deps.cluster) - if not getattr(status, "exists", True): - raise ValueError("cluster artifact does not exist") - if not getattr(status, "complete", False): - raise ValueError("cluster artifact is incomplete") - inputs = getattr(status, "inputs", None) or {} - raw_selection = inputs.get("cell_selection") - if not isinstance(raw_selection, Mapping): - raise ValueError("cluster artifact has no cell-selection input") - cell_selection = ArtifactRef.from_dict(dict(raw_selection)) - if ( - cell_selection.scope != "datastore" - or cell_selection.kind != "cell_selection" - or cell_selection.assay is not None - ): - raise ValueError("cluster artifact has an invalid cell-selection input") - if ( - deps.cellSelection is not None - and core_artifact_reference(deps.cellSelection) != cell_selection - ): - raise ValueError( - "cluster artifact cell selection conflicts with the prepared selection" - ) - cell_indices = read_stored_selection_indices( - deps.store.zw, - cell_selection, - kind="cell_selection", - scope="datastore", - assay=None, - table_path="cellData", - ).astype(np.int64, copy=False) - deps.cellSelection = cell_selection - deps.cellIndices = cell_indices - cluster_group = deps.store.load_artifact(deps.cluster) - value_name = "labels" if cluster_artifact.kind == "cluster_cut" else "values" - if value_name not in cluster_group: - raise ValueError( - f"cluster artifact does not contain its {value_name!r} label vector" - ) - cluster_values = np.asarray(cluster_group[value_name][:]) - if cluster_values.ndim != 1 or len(cluster_values) != len(cell_indices): - raise ValueError("cluster artifact labels do not align with its cell selection") - if len(cluster_values) == 0: - raise ValueError("cluster artifact selects no cells") - counts = Counter(_string_value(value) for value in cluster_values) - ordered_clusters = sorted(counts, key=lambda value: (-counts[value], value)) - retained_clusters = ordered_clusters[: deps.maxClusters] - deps.clusterValues = { - _string_value(value): value.item() if isinstance(value, np.generic) else value - for value in cluster_values - if _string_value(value) in retained_clusters - } - evidence_prefix = f"composition:{cluster_artifact.artifactId}" - count_evidence = f"{evidence_prefix}:counts" - deps.evidenceIds.add(count_evidence) - warnings: list[str] = [] - if len(ordered_clusters) > deps.maxClusters: - warnings.append(f"Only the {deps.maxClusters} largest clusters were returned.") - - condition_summaries: list[ConditionClusterSummary] = [] - if deps.conditionColumn is not None: - condition_values = read_metadata_rows( - deps.store.cells, - deps.conditionColumn, - cell_indices, - ) - if len(condition_values) != len(cluster_values): - raise ValueError("condition and cluster columns are not aligned") - condition_missing = read_metadata_missing_rows( - deps.store.cells, - deps.conditionColumn, - cell_indices, - ) - if condition_missing is not None and np.any(condition_missing): - raise ValueError("condition column contains missing selected values") - n_conditions = len({_string_value(value) for value in condition_values}) - if n_conditions > CONFIG._MAX_CONDITIONS: - warnings.append( - f"Only the first {CONFIG._MAX_CONDITIONS} conditions were returned." - ) - if deps.sampleColumn is not None: - sample_values = read_metadata_rows( - deps.store.cells, - deps.sampleColumn, - cell_indices, - ) - if len(sample_values) != len(cluster_values): - raise ValueError("sample and cluster columns are not aligned") - sample_missing = read_metadata_missing_rows( - deps.store.cells, - deps.sampleColumn, - cell_indices, - ) - if sample_missing is not None and np.any(sample_missing): - raise ValueError("sample column contains missing selected values") - summaries = _sample_condition_summaries( - sample_values=sample_values, - condition_values=condition_values, - cluster_values=cluster_values, - retained_clusters=retained_clusters, - evidence_prefix=evidence_prefix, - ) - else: - summaries = _cell_condition_summaries( - condition_values=condition_values, - cluster_values=cluster_values, - retained_clusters=retained_clusters, - evidence_prefix=evidence_prefix, - ) - warnings.append( - "No sample column was supplied; condition fractions are cell-level summaries." - ) - condition_summaries = summaries[ - : CONFIG._MAX_CONDITIONS * len(retained_clusters) - ] - deps.evidenceIds.update(summary.evidenceId for summary in condition_summaries) - deps.conditionEvidence.update( - {summary.evidenceId: summary for summary in condition_summaries} - ) - - deps.toolCalls.append("inspect_cluster_composition") - evidence = ClusterCompositionEvidence( - clusterArtifact=cluster_artifact, - cellSelection=artifact_reference(cell_selection), - totalCells=len(cluster_values), - clusterCounts={cluster: counts[cluster] for cluster in retained_clusters}, - sampleColumn=deps.sampleColumn, - conditionColumn=deps.conditionColumn, - conditionSummaries=condition_summaries, - evidenceIds=sorted(deps.evidenceIds), - warnings=warnings, - ) - deps.compositionEvidence = evidence - logger.info( - f"Completed cluster composition inspection: cells={evidence.totalCells}, " - f"clusters={len(evidence.clusterCounts)}, " - f"condition_summaries={len(evidence.conditionSummaries)}, " - f"warnings={len(evidence.warnings)}" - ) - return evidence - - -def _sample_condition_summaries( - *, - sample_values: np.ndarray, - condition_values: np.ndarray, - cluster_values: np.ndarray, - retained_clusters: list[str], - evidence_prefix: str, -) -> list[ConditionClusterSummary]: - """Aggregate each condition-unit pair without exposing unit identifiers.""" - sample_counts: dict[tuple[str, str], Counter[str]] = defaultdict(Counter) - sample_totals: Counter[tuple[str, str]] = Counter() - for sample, condition, cluster in zip( - sample_values, - condition_values, - cluster_values, - strict=True, - ): - condition_label = _string_value(condition) - sample_label = _string_value(sample) - key = (condition_label, sample_label) - sample_totals[key] += 1 - sample_counts[key][_string_value(cluster)] += 1 - - fractions: dict[tuple[str, str], list[float]] = defaultdict(list) - cell_counts: Counter[tuple[str, str]] = Counter() - for key, total in sample_totals.items(): - condition, _sample = key - for cluster in retained_clusters: - count = sample_counts[key][cluster] - fractions[(condition, cluster)].append(count / total) - cell_counts[(condition, cluster)] += count - - output: list[ConditionClusterSummary] = [] - for condition, cluster in sorted(fractions): - values = fractions[(condition, cluster)] - evidence_id = f"{evidence_prefix}:condition:{condition}:cluster:{cluster}" - output.append( - ConditionClusterSummary( - condition=condition, - clusterId=cluster, - nSamples=len(values), - meanFraction=float(np.mean(values)), - minFraction=float(np.min(values)), - maxFraction=float(np.max(values)), - cellCount=cell_counts[(condition, cluster)], - evidenceId=evidence_id, - ) - ) - return output - - -def _cell_condition_summaries( - *, - condition_values: np.ndarray, - cluster_values: np.ndarray, - retained_clusters: list[str], - evidence_prefix: str, -) -> list[ConditionClusterSummary]: - totals: Counter[str] = Counter(_string_value(value) for value in condition_values) - counts: Counter[tuple[str, str]] = Counter() - for condition, cluster in zip(condition_values, cluster_values, strict=True): - counts[(_string_value(condition), _string_value(cluster))] += 1 - output: list[ConditionClusterSummary] = [] - for condition in sorted(totals): - for cluster in retained_clusters: - count = counts[(condition, cluster)] - fraction = count / totals[condition] - evidence_id = f"{evidence_prefix}:condition:{condition}:cluster:{cluster}" - output.append( - ConditionClusterSummary( - condition=condition, - clusterId=cluster, - meanFraction=fraction, - minFraction=fraction, - maxFraction=fraction, - cellCount=count, - evidenceId=evidence_id, - ) - ) - return output - - -async def inspect_cluster_markers( - ctx: RunContext[BiologicalInterpretationDependencies], - cluster_id: str, -) -> ClusterMarkerEvidence: - """Load markers for one observed cluster, optionally creating one artifact.""" - deps = ctx.deps - logger.debug(f"Inspecting markers for cluster {cluster_id!r}") - if not deps.clusterValues: - raise ModelRetry("Call inspect_cluster_composition before inspecting markers.") - if cluster_id not in deps.clusterValues: - raise ModelRetry(f"cluster_id must be one of {sorted(deps.clusterValues)}") - cached = deps.markerEvidence.get(cluster_id) - if cached is not None: - logger.debug(f"Reused cached markers for cluster {cluster_id!r}") - return cached - if deps.marker is None: - if not deps.allowMarkerSearch: - logger.warning( - f"Markers for cluster {cluster_id!r} are unavailable because no " - "marker artifact was supplied or authorized" - ) - return ClusterMarkerEvidence( - clusterId=cluster_id, - evidenceId="", - warnings=[ - "No exact marker artifact was supplied and marker search was not authorized." - ], - ) - if deps.markerFeatures is None: - logger.warning( - f"Markers for cluster {cluster_id!r} are unavailable because no " - "feature selection was supplied" - ) - return ClusterMarkerEvidence( - clusterId=cluster_id, - evidenceId="", - warnings=["Marker search requires an exact feature selection."], - ) - logger.info( - f"Creating one marker artifact for assay {deps.markerAssay!r} " - f"from cluster artifact {deps.cluster.artifact_id!r}" - ) - deps.marker = deps.store.run_marker_search( - deps.cluster, - features=deps.markerFeatures, - ) - if not hasattr(deps.marker, "artifact_id"): - raise RuntimeError("marker search did not return an artifact reference") - deps.marker = core_artifact_reference(deps.marker) - - marker_artifact = artifact_reference(deps.marker) - if marker_artifact.kind != "marker_table": - raise ModelRetry("marker must identify a marker_table artifact") - if deps.markerAssay is not None and marker_artifact.assay != deps.markerAssay: - raise ModelRetry("marker artifact belongs to a different assay") - if hasattr(deps.store, "inspect_artifact"): - marker_status = deps.store.inspect_artifact(deps.marker) - if not getattr(marker_status, "exists", True): - raise ModelRetry("marker artifact does not exist") - if not getattr(marker_status, "complete", False): - raise ModelRetry("marker artifact is incomplete") - marker_inputs = getattr(marker_status, "inputs", None) or {} - stored_clusters = marker_inputs.get("clusters") - expected_cluster = artifact_reference(deps.cluster) - if ( - not isinstance(stored_clusters, Mapping) - or stored_clusters.get("artifact_id") != expected_cluster.artifactId - or stored_clusters.get("kind") != expected_cluster.kind - or stored_clusters.get("scope") != expected_cluster.scope - or stored_clusters.get("assay") != expected_cluster.assay - ): - raise ModelRetry( - "marker artifact is not linked to the exact cluster artifact" - ) - - frame = deps.store.get_markers( - deps.marker, - group_id=deps.clusterValues[cluster_id], - min_score=deps.markerMinScore, - min_frac_exp=deps.markerMinFraction, - ) - if "score" in frame.columns: - frame = frame.sort_values("score", ascending=False, na_position="last") - markers = [ - _marker_feature(row) - for row in frame.head(min(deps.maxMarkers, CONFIG._MAX_MARKERS)).to_dict( - "records" - ) - ] - cluster_artifact = artifact_reference(deps.cluster) - evidence_id = ( - f"markers:{marker_artifact.artifactId}:clusters:" - f"{cluster_artifact.artifactId}:cluster:{cluster_id}" - ) - if markers: - deps.evidenceIds.add(evidence_id) - deps.markerEvidenceIds[cluster_id] = evidence_id - evidence = ClusterMarkerEvidence( - clusterId=cluster_id, - markers=markers, - markerArtifact=marker_artifact, - evidenceId=evidence_id if markers else "", - warnings=[] if markers else ["No markers passed the requested thresholds."], - ) - deps.markerEvidence[cluster_id] = evidence - logger.debug( - f"Completed marker inspection for cluster {cluster_id!r}: " - f"markers={len(markers)}" - ) - return evidence - - -async def inspect_cluster_markers_batch( - ctx: RunContext[BiologicalInterpretationDependencies], - cluster_ids: list[str], -) -> ClusterMarkerBatchEvidence: - """Inspect every selected cluster in one bounded model tool call.""" - if not ctx.deps.clusterValues: - raise ModelRetry("Call inspect_cluster_composition before inspecting markers.") - if not cluster_ids: - raise ModelRetry("cluster_ids must contain at least one observed cluster") - if len(cluster_ids) > ctx.deps.maxClusters: - raise ModelRetry( - f"cluster_ids may contain at most {ctx.deps.maxClusters} values" - ) - if len(set(cluster_ids)) != len(cluster_ids): - raise ModelRetry("cluster_ids must not contain duplicates") - if ctx.deps.markerBatch is not None: - if cluster_ids != ctx.deps.markerBatchClusterIds: - raise ModelRetry( - "Marker inspection already completed. Use the returned evidence " - "and do not request a different cluster batch." - ) - logger.info("Reused completed cluster marker batch") - return ctx.deps.markerBatch - - logger.info(f"Inspecting markers for {len(cluster_ids)} cluster(s) in one batch") - clusters = [ - await inspect_cluster_markers(ctx, cluster_id=cluster_id) - for cluster_id in cluster_ids - ] - evidence_ids = [cluster.evidenceId for cluster in clusters if cluster.evidenceId] - warnings = [ - f"Cluster {cluster.clusterId}: {warning}" - for cluster in clusters - for warning in cluster.warnings - ] - ctx.deps.toolCalls.append("inspect_cluster_markers_batch") - evidence = ClusterMarkerBatchEvidence( - clusters=clusters, - evidenceIds=evidence_ids, - warnings=warnings, - ) - ctx.deps.markerBatch = evidence - ctx.deps.markerBatchClusterIds = list(cluster_ids) - logger.info( - f"Completed marker batch inspection: clusters={len(clusters)}, " - f"clusters_with_markers={sum(bool(cluster.markers) for cluster in clusters)}, " - f"evidence_records={len(evidence_ids)}" - ) - return evidence - - -def _marker_feature(row: dict[str, Any]) -> MarkerFeature: - raw_index = _finite_float(row.get("feature_index")) - return MarkerFeature( - featureId=str(row.get("feature_id", "")), - featureName=str(row.get("feature_name", "")), - featureIndex=int(raw_index) if raw_index is not None else None, - score=_finite_float(row.get("score")), - foldChange=_finite_float(row.get("fold_change")), - fractionExpressed=_finite_float(row.get("frac_exp")), - fractionExpressedRest=_finite_float(row.get("frac_exp_rest")), - mean=_finite_float(row.get("mean")), - meanRest=_finite_float(row.get("mean_rest")), - auc=_finite_float(row.get("auc")), - adjustedPvalue=_finite_float(row.get("p_value_adjusted")), - ) - - -def _canonicalize_cluster_interpretations( - report: BiologicalInterpretationReport, - deps: BiologicalInterpretationDependencies, -) -> tuple[list[ClusterInterpretation], list[str]]: - canonical_interpretations: list[ClusterInterpretation] = [] - omitted_interpretation_clusters: list[str] = [] - for interpretation in report.clusterInterpretations: - marker_id = deps.markerEvidenceIds.get(interpretation.clusterId) - if marker_id is None: - omitted_interpretation_clusters.append(interpretation.clusterId) - continue - non_marker_evidence = sorted(set(interpretation.evidenceIds) - {marker_id}) - if non_marker_evidence: - raise ModelRetry( - "Cluster identity interpretations may cite only their exact marker " - f"evidence: {non_marker_evidence}" - ) - canonical_interpretations.append( - interpretation.model_copy( - update={ - "evidenceIds": [marker_id], - "identityIsHypothesis": True, - **( - { - "confidence": "low", - } - if deps.markerAssayType == "ATAC" - else {} - ), - } - ) - ) - return canonical_interpretations, omitted_interpretation_clusters - - -def _canonicalize_treatment_observations( - report: BiologicalInterpretationReport, - deps: BiologicalInterpretationDependencies, -) -> list[TreatmentObservation]: - if report.treatmentObservations and deps.conditionColumn is None: - raise ModelRetry("Treatment observations require a condition column.") - if report.treatmentObservations and deps.sampleColumn is None: - raise ModelRetry( - "Treatment observations require independent-unit composition summaries." - ) - if report.treatmentObservations: - handoff = deps.designHandoff - if ( - handoff is None - or not handoff.conditionColumn - or handoff.conditionColumn != deps.conditionColumn - or not handoff.independentUnit - or handoff.independentUnit != deps.sampleColumn - or handoff.coefficientScope != "betweenUnit" - or handoff.estimability.get("status") != "ok" - or handoff.estimability.get("coefficientEstimable") is not True - ): - raise ModelRetry( - "Treatment observations require an explicit condition, aggregation " - "at the independent unit, a between-unit coefficient, and an " - "estimable experimental contrast." - ) - - canonical_observations: list[TreatmentObservation] = [] - for observation in report.treatmentObservations: - if not observation.isDescriptiveOnly: - raise ModelRetry("Treatment observations must remain descriptive.") - if len(observation.evidenceIds) != 2 or len(set(observation.evidenceIds)) != 2: - raise ModelRetry( - "Every treatment observation must cite exactly two distinct " - "condition summaries." - ) - if any( - evidence_id not in deps.conditionEvidence - for evidence_id in observation.evidenceIds - ): - raise ModelRetry( - "Treatment observations may cite only condition composition evidence." - ) - summaries = [ - deps.conditionEvidence[evidence_id] - for evidence_id in observation.evidenceIds - ] - if any(summary.clusterId != observation.clusterId for summary in summaries): - raise ModelRetry( - "Every treatment observation must cite condition summaries for " - "its exact cluster." - ) - if ( - not observation.referenceCondition - or not observation.comparisonCondition - or observation.referenceCondition == observation.comparisonCondition - ): - raise ModelRetry( - "Treatment observations require two distinct named conditions." - ) - summaries_by_condition = {summary.condition: summary for summary in summaries} - expected_conditions = { - observation.referenceCondition, - observation.comparisonCondition, - } - if set(summaries_by_condition) != expected_conditions: - raise ModelRetry( - "Treatment observation conditions must match the two cited " - "condition summaries." - ) - if any(summary.nSamples < 2 for summary in summaries): - raise ModelRetry( - "Sample-level treatment observations require at least two samples " - "in every cited condition." - ) - reference = summaries_by_condition[observation.referenceCondition] - comparison = summaries_by_condition[observation.comparisonCondition] - if math.isclose( - comparison.meanFraction, - reference.meanFraction, - rel_tol=1e-9, - abs_tol=1e-12, - ): - expected_direction: TreatmentDirection = "equal" - elif comparison.meanFraction > reference.meanFraction: - expected_direction = "higher" - else: - expected_direction = "lower" - if observation.direction != expected_direction: - raise ModelRetry( - "Treatment observation direction does not match the cited mean " - "independent-unit fractions." - ) - if expected_direction == "equal": - canonical_text = ( - f"Cluster {observation.clusterId} has equal mean independent-unit " - f"fractions in {comparison.condition} and {reference.condition} " - f"({comparison.meanFraction:.6g}); this is descriptive only." - ) - else: - canonical_text = ( - f"Cluster {observation.clusterId} has a {expected_direction} mean " - f"independent-unit fraction in {comparison.condition} " - f"({comparison.meanFraction:.6g}) than in {reference.condition} " - f"({reference.meanFraction:.6g}); this is descriptive only." - ) - canonical_observations.append( - observation.model_copy(update={"observation": canonical_text}) - ) - return canonical_observations - - -def validate_biological_interpretation_report( - report: BiologicalInterpretationReport, - deps: BiologicalInterpretationDependencies, -) -> BiologicalInterpretationReport: - """Reject invented evidence, clusters, or completed marker-free reviews.""" - if not deps.clusterValues: - raise ModelRetry("Call inspect_cluster_composition before returning a report.") - if report.status == "failed": - raise ModelRetry( - "Do not return failed for biological uncertainty; return needsInput " - "with one concrete question instead." - ) - if report.status == "needsInput" and ( - report.needsInput is None or not report.needsInput.question.strip() - ): - raise ModelRetry("A needsInput report requires one concrete input question.") - if report.status != "needsInput" and report.needsInput is not None: - raise ModelRetry("Only a needsInput report may include an input question.") - expected_cluster_artifact = artifact_reference(deps.cluster) - if ( - report.clusterArtifact is not None - and report.clusterArtifact != expected_cluster_artifact - ): - raise ModelRetry("Report clusterArtifact does not match the inspected artifact") - if deps.marker is not None: - expected_marker_artifact = artifact_reference(deps.marker) - if ( - report.markerArtifact is not None - and report.markerArtifact != expected_marker_artifact - ): - raise ModelRetry( - "Report markerArtifact does not match the inspected artifact" - ) - - cited = set(report.evidenceIds) - for interpretation in report.clusterInterpretations: - cited.update(interpretation.evidenceIds) - for observation in report.treatmentObservations: - cited.update(observation.evidenceIds) - for follow_up in report.followUps: - cited.update(follow_up.evidenceIds) - if report.needsInput is not None: - cited.update(report.needsInput.evidenceIds) - unknown = cited.difference(deps.evidenceIds) - if unknown: - raise ModelRetry(f"Unknown evidenceIds: {sorted(unknown)}") - interpreted_clusters = {item.clusterId for item in report.clusterInterpretations} - observed_clusters = {item.clusterId for item in report.treatmentObservations} - unknown_clusters = (interpreted_clusters | observed_clusters).difference( - deps.clusterValues - ) - if unknown_clusters: - raise ModelRetry(f"Unknown cluster ids: {sorted(unknown_clusters)}") - canonical_interpretations, omitted_interpretation_clusters = ( - _canonicalize_cluster_interpretations(report, deps) - ) - canonical_observations = _canonicalize_treatment_observations(report, deps) - if report.status == "done" and not canonical_interpretations: - raise ModelRetry( - "A done report must contain at least one cluster interpretation with " - "non-empty marker evidence." - ) - limitations = list(report.limitations) - if deps.markerAssayType == "ATAC": - atac_limitation = ( - "ATAC peak markers are descriptive, so all cell identities remain " - "low-confidence hypotheses." - ) - if atac_limitation not in limitations: - limitations.append(atac_limitation) - if omitted_interpretation_clusters: - omitted_clusters = ", ".join(sorted(set(omitted_interpretation_clusters))) - marker_limitation = ( - "Cluster identity interpretations without non-empty marker evidence " - f"were omitted for clusters: {omitted_clusters}." - ) - if marker_limitation not in limitations: - limitations.append(marker_limitation) - if canonical_observations: - descriptive_limitation = ( - "Independent-unit cluster fractions are descriptive summaries, not " - "tests of significance or causal treatment effects." - ) - if descriptive_limitation not in limitations: - limitations.append(descriptive_limitation) - validated = report.model_copy( - update={ - "clusterInterpretations": canonical_interpretations, - "treatmentObservations": canonical_observations, - "evidenceIds": sorted( - { - *report.evidenceIds, - *( - evidence_id - for interpretation in canonical_interpretations - for evidence_id in interpretation.evidenceIds - ), - } - ), - "limitations": limitations, - "clusterArtifact": expected_cluster_artifact, - "markerArtifact": ( - artifact_reference(deps.marker) if deps.marker is not None else None - ), - "graphAssay": deps.graphAssay, - "markerAssay": deps.markerAssay, - } - ) - logger.debug( - f"Validated biological interpretation report: status={validated.status}, " - f"cluster_interpretations={len(validated.clusterInterpretations)}, " - f"treatment_observations={len(validated.treatmentObservations)}, " - f"omitted_interpretations={len(omitted_interpretation_clusters)}" - ) - return validated - - -def fallback_biological_interpretation_report( - deps: BiologicalInterpretationDependencies, - *, - error: UnexpectedModelBehavior | UsageLimitExceeded, - model_name: str, -) -> BiologicalInterpretationReport: - """Return exact unresolved identities when structured interpretation fails.""" - if not deps.clusterValues: - raise error - error_detail = str(error).replace("\n", " ").strip()[:500] - interpretations = [ - ClusterInterpretation( - clusterId=cluster_id, - proposedIdentity="unresolved", - identityIsHypothesis=True, - confidence="low", - rationale=( - "Exact marker evidence was available, but structured biological " - "interpretation was unavailable." - ), - evidenceIds=[evidence_id], - ) - for cluster_id, evidence_id in sorted(deps.markerEvidenceIds.items()) - ] - if interpretations: - report = BiologicalInterpretationReport( - status="done", - clusterInterpretations=interpretations, - evidenceIds=[ - evidence_id - for interpretation in interpretations - for evidence_id in interpretation.evidenceIds - ], - limitations=[ - "Cluster identities remain unresolved because structured model " - "interpretation exhausted its bounded correction budget.", - "No treatment observations were generated by the fallback.", - error_detail, - ], - stopReason=( - "Exact marker-bearing clusters were retained as unresolved " - "low-confidence hypotheses." - ), - runInfo=AgentRunInfo( - agentName="biological_interpretation_fallback", - modelName=model_name, - ), - ) - else: - composition_evidence = sorted( - evidence_id - for evidence_id in deps.evidenceIds - if evidence_id.startswith("composition:") - ) - report = BiologicalInterpretationReport( - status="needsInput", - evidenceIds=composition_evidence, - limitations=[ - "No non-empty marker evidence was available for a grounded cluster " - "interpretation.", - error_detail, - ], - stopReason="Biological interpretation requires marker evidence.", - needsInput=BiologicalInterpretationNeedsInput( - question=( - "Provide an exact marker artifact with non-empty cluster markers " - "or revise the authorized marker thresholds." - ), - requiredInputs=["markerArtifactOrThresholds"], - evidenceIds=composition_evidence, - ), - runInfo=AgentRunInfo( - agentName="biological_interpretation_fallback", - modelName=model_name, - ), - ) - validated = validate_biological_interpretation_report(report, deps) - logger.warning( - "Biological Interpretation used its conservative fallback: " - f"status={validated.status}, clusters=" - f"{len(validated.clusterInterpretations)}, reason={error_detail}" - ) - return validated - - -def _prepare_biological_interpretation_dependencies( - store: Any, - *, - cluster: Any, - from_assay: str | None, - graph_assay: str | None, - marker_assay_type: str | None, - sample_column: str | None, - condition_column: str | None, - tuning_handoff: TuningBiologyHandoff | None, - experimental_handoff: ExperimentalBiologyHandoff | None, - marker: Any, - marker_features: Any, - allow_marker_search: bool, - max_clusters: int, - max_markers: int, - marker_min_score: float, - marker_min_fraction: float, -) -> BiologicalInterpretationDependencies: - expected_selections: list[ArtifactRef] = [] - if tuning_handoff is not None: - if tuning_handoff.clusterArtifact is None: - raise ValueError("tuning_handoff lacks a cluster artifact") - tuning_selection = core_artifact_reference(tuning_handoff.cellSelection) - if not isinstance(tuning_selection, ArtifactRef): - raise ValueError("tuning_handoff lacks an exact cell selection") - expected_selections.append(tuning_selection) - if cluster is not None and ( - artifact_reference(cluster) != tuning_handoff.clusterArtifact - ): - raise ValueError("cluster conflicts with tuning_handoff") - if from_assay is not None and from_assay != tuning_handoff.fromAssay: - raise ValueError("from_assay conflicts with tuning_handoff") - if graph_assay is not None and graph_assay != tuning_handoff.graphAssay: - raise ValueError("graph_assay conflicts with tuning_handoff") - cluster = tuning_handoff.clusterArtifact - from_assay = tuning_handoff.fromAssay - graph_assay = tuning_handoff.graphAssay - if experimental_handoff is not None: - experimental_selection = core_artifact_reference( - experimental_handoff.cellSelection - ) - if not isinstance(experimental_selection, ArtifactRef): - raise ValueError("experimental_handoff lacks an exact cell selection") - expected_selections.append(experimental_selection) - if len(expected_selections) == 2 and ( - expected_selections[0] != expected_selections[1] - ): - raise ValueError( - "Experimental and tuning handoffs use different cell selections" - ) - if ( - condition_column is not None - and condition_column != experimental_handoff.conditionColumn - ): - raise ValueError("condition_column conflicts with experimental_handoff") - aggregation_unit = ( - experimental_handoff.independentUnit or experimental_handoff.observationUnit - ) - if sample_column is not None and sample_column != aggregation_unit: - raise ValueError("sample_column conflicts with experimental_handoff") - condition_column = experimental_handoff.conditionColumn - sample_column = aggregation_unit - if cluster is None: - raise ValueError("cluster must identify an exact cluster artifact") - if not 1 <= max_clusters <= CONFIG._MAX_CLUSTERS: - raise ValueError(f"max_clusters must be between 1 and {CONFIG._MAX_CLUSTERS}") - if not 1 <= max_markers <= CONFIG._MAX_MARKERS: - raise ValueError(f"max_markers must be between 1 and {CONFIG._MAX_MARKERS}") - if not 0 < marker_min_score <= 1: - raise ValueError("marker_min_score must be greater than 0 and at most 1") - if not 0 <= marker_min_fraction <= 1: - raise ValueError("marker_min_fraction must be between 0 and 1") - if allow_marker_search and marker is None and marker_features is None: - raise ValueError("marker_features is required when marker search is authorized") - - cluster = core_artifact_reference(cluster) - marker = core_artifact_reference(marker) - marker_features = core_artifact_reference(marker_features) - if not isinstance(cluster, ArtifactRef): - raise TypeError("cluster must be an ArtifactRef") - if marker is not None and ( - not isinstance(marker, ArtifactRef) or marker.kind != "marker_table" - ): - raise TypeError("marker must be a marker_table ArtifactRef") - if marker_features is not None and ( - not isinstance(marker_features, ArtifactRef) - or marker_features.kind != "feature_selection" - ): - raise TypeError("marker_features must be a feature_selection ArtifactRef") - cluster_artifact = artifact_reference(cluster) - if cluster_artifact.kind not in {"cluster_labels", "cluster_cut"}: - raise ValueError( - "cluster must identify a cluster_labels or cluster_cut artifact" - ) - if cluster_artifact.scope == "datastore" and cluster_artifact.assay is not None: - raise ValueError("datastore-scoped cluster artifacts must not name an assay") - if ( - tuning_handoff is not None - and cluster_artifact.scope == "datastore" - and not tuning_handoff.markerAssay - ): - raise ValueError( - "Integrated tuning handoffs must explicitly identify markerAssay" - ) - resolved_graph_assay = graph_assay or cluster_artifact.assay - if ( - resolved_graph_assay is not None - and cluster_artifact.scope == "assay" - and cluster_artifact.assay != resolved_graph_assay - ): - raise ValueError("cluster belongs to a different assay") - resolved_marker_assay = ( - tuning_handoff.markerAssay or cluster_artifact.assay - if tuning_handoff is not None - else ( - marker.assay - if isinstance(marker, ArtifactRef) - else ( - marker_features.assay - if isinstance(marker_features, ArtifactRef) - else from_assay or cluster_artifact.assay - ) - ) - ) - if cluster_artifact.scope == "datastore" and not resolved_marker_assay: - raise ValueError( - "from_assay is required to resolve markers for integrated clusters" - ) - if isinstance(marker, ArtifactRef) and marker.assay != resolved_marker_assay: - raise ValueError("marker artifact belongs to a different marker assay") - if ( - isinstance(marker_features, ArtifactRef) - and marker_features.assay != resolved_marker_assay - ): - raise ValueError("marker feature selection belongs to a different assay") - - cluster_status = store.inspect_artifact(cluster) - if not getattr(cluster_status, "exists", True): - raise ValueError("cluster artifact does not exist") - if not getattr(cluster_status, "complete", False): - raise ValueError("cluster artifact is incomplete") - raw_selection = (getattr(cluster_status, "inputs", None) or {}).get( - "cell_selection" - ) - if not isinstance(raw_selection, Mapping): - raise ValueError("cluster artifact has no cell-selection input") - cell_selection = ArtifactRef.from_dict(dict(raw_selection)) - if ( - cell_selection.scope != "datastore" - or cell_selection.kind != "cell_selection" - or cell_selection.assay is not None - ): - raise ValueError("cluster artifact has an invalid cell-selection input") - if any(selection != cell_selection for selection in expected_selections): - raise ValueError("handoff cell selection conflicts with cluster") - cell_indices = read_stored_selection_indices( - store.zw, - cell_selection, - kind="cell_selection", - scope="datastore", - assay=None, - table_path="cellData", - ).astype(np.int64, copy=False) - if isinstance(marker, ArtifactRef): - marker_status = store.inspect_artifact(marker) - if not getattr(marker_status, "exists", True): - raise ValueError("marker artifact does not exist") - if not getattr(marker_status, "complete", False): - raise ValueError("marker artifact is incomplete") - marker_inputs = getattr(marker_status, "inputs", None) or {} - stored_clusters = marker_inputs.get("clusters") - expected_cluster = artifact_reference(cluster) - if ( - not isinstance(stored_clusters, Mapping) - or stored_clusters.get("artifact_id") != expected_cluster.artifactId - or stored_clusters.get("kind") != expected_cluster.kind - or stored_clusters.get("scope") != expected_cluster.scope - or stored_clusters.get("assay") != expected_cluster.assay - ): - raise ValueError( - "marker artifact is not linked to the exact cluster artifact" - ) - return BiologicalInterpretationDependencies( - store=store, - cluster=cluster, - cellSelection=cell_selection, - cellIndices=cell_indices, - fromAssay=from_assay or cluster_artifact.assay or resolved_marker_assay, - graphAssay=resolved_graph_assay, - markerAssay=resolved_marker_assay, - markerAssayType=marker_assay_type, - sampleColumn=sample_column, - conditionColumn=condition_column, - designHandoff=experimental_handoff, - marker=marker, - markerFeatures=marker_features, - allowMarkerSearch=allow_marker_search, - maxClusters=max_clusters, - maxMarkers=max_markers, - markerMinScore=marker_min_score, - markerMinFraction=marker_min_fraction, - ) - - -class BiologicalInterpretationAgent: - """Run a bounded biological review through explicit Scarf tools.""" - - def __init__( - self, - model: Any, - *, - config: AgentRunConfig | None = None, - ) -> None: - self.model = model - self.config = (config or AgentRunConfig()).with_limits( - request_limit=8, - tool_call_limit=5, - output_token_limit=32768, - timeout_seconds=600.0, - ) - - def run( - self, - store: Any, - *, - cluster: ArtifactRef | ArtifactReferenceModel | None = None, - biological_context: BiologicalContext | None = None, - from_assay: str | None = None, - graph_assay: str | None = None, - marker_assay_type: str | None = None, - sample_column: str | None = None, - condition_column: str | None = None, - tuning_handoff: TuningBiologyHandoff | None = None, - experimental_handoff: ExperimentalBiologyHandoff | None = None, - marker: ArtifactRef | ArtifactReferenceModel | None = None, - marker_features: ArtifactRef | ArtifactReferenceModel | None = None, - allow_marker_search: bool = False, - max_clusters: int = 12, - max_markers: int = 10, - marker_min_score: float = 0.25, - marker_min_fraction: float = 0.2, - ) -> BiologicalInterpretationReport: - """Interpret cluster results while exposing only bounded tools to the model.""" - deps = _prepare_biological_interpretation_dependencies( - store, - cluster=cluster, - from_assay=from_assay, - graph_assay=graph_assay, - marker_assay_type=marker_assay_type, - sample_column=sample_column, - condition_column=condition_column, - tuning_handoff=tuning_handoff, - experimental_handoff=experimental_handoff, - marker=marker, - marker_features=marker_features, - allow_marker_search=allow_marker_search, - max_clusters=max_clusters, - max_markers=max_markers, - marker_min_score=marker_min_score, - marker_min_fraction=marker_min_fraction, - ) - logger.info( - f"Starting biological interpretation: " - f"cluster_artifact={deps.cluster.artifact_id!r}, " - f"graph_assay={deps.graphAssay!r}, marker_assay={deps.markerAssay!r}, " - f"marker_artifact_supplied={deps.marker is not None}, " - f"marker_search_authorized={deps.allowMarkerSearch}" - ) - context = biological_context or BiologicalContext() - cluster_artifact = artifact_reference(deps.cluster) - marker_state = "provided" if deps.marker is not None else "not provided" - treatment_eligible = bool( - experimental_handoff is not None - and experimental_handoff.conditionColumn - and experimental_handoff.independentUnit - and experimental_handoff.coefficientScope == "betweenUnit" - and experimental_handoff.estimability.get("status") == "ok" - and experimental_handoff.estimability.get("coefficientEstimable") is True - ) - user_prompt = ( - dedent( - """ - Review the exact cluster artifact {cluster_artifact} over - cell-selection artifact {cell_selection}. The graph owner is - {graph_assay}; markers are resolved from {marker_assay}. The exact - marker artifact is {marker_state}; creating a marker artifact is - authorized={allow_marker_search}. Review no more - than {max_clusters} clusters. The tool returns no more than - {max_markers} markers per cluster. Treatment observations are - eligible from the supplied design={treatment_eligible}. - - Caller biological context: - {biological_context} - - Experimental design context: - {experimental_context} - - Call inspect_cluster_composition once. Copy every returned cluster - ID exactly and send the complete unique list in one - inspect_cluster_markers_batch call. Interpret only clusters with a - non-empty returned marker evidenceId. Use proposedIdentity="unresolved" - when markers do not ground a specific hypothesis, and always set - identityIsHypothesis=true. Caller cell-type references are not - cluster labels. If marker evidence is empty, return needsInput with - one populated question. If treatment eligibility is false, return - treatmentObservations=[]. Never return status=failed for biological - uncertainty; use needsInput with a concrete question. Leave artifact - fields null or copy only exact tool-returned values. Each tool is - removed after it succeeds, so request every cluster in that one - marker batch. - """ - ) - .strip() - .format( - graph_assay=deps.graphAssay or "datastore integration", - marker_assay=deps.markerAssay, - cluster_artifact=cluster_artifact.model_dump_json(), - cell_selection=deps.cellSelection.artifact_id, - marker_state=marker_state, - allow_marker_search=allow_marker_search, - max_clusters=max_clusters, - max_markers=max_markers, - treatment_eligible=str(treatment_eligible).lower(), - biological_context=context.model_dump_json(), - experimental_context=( - experimental_handoff.model_dump_json() - if experimental_handoff is not None - else "not provided" - ), - ) - ) - logger.info( - f"Requesting biological interpretation for at most " - f"{deps.maxClusters} clusters" - ) - try: - execution = run_agent_sync( - model=self.model, - output_type=BiologicalInterpretationReport, - system_prompt=_SYSTEM_PROMPT, - user_prompt=user_prompt, - tools=( - Tool( - inspect_cluster_composition, - prepare=_prepare_biological_interpretation_tool, - sequential=self.config.sequentialTools, - timeout=self.config.timeoutSeconds, - ), - Tool( - inspect_cluster_markers_batch, - max_retries=1, - prepare=_prepare_biological_interpretation_tool, - sequential=self.config.sequentialTools, - timeout=self.config.timeoutSeconds, - ), - ), - deps_type=BiologicalInterpretationDependencies, - deps=deps, - config=self.config, - name="biological_interpretation", - output_validator=lambda report: ( - validate_biological_interpretation_report( - report, - deps, - ) - ), - ) - except (UnexpectedModelBehavior, UsageLimitExceeded) as exc: - if not deps.clusterValues: - raise - model_name = getattr(self.model, "model_name", type(self.model).__name__) - return fallback_biological_interpretation_report( - deps, - error=exc, - model_name=str(model_name), - ) - report = BiologicalInterpretationReport.model_validate(execution.output) - report = validate_biological_interpretation_report(report, deps) - report.runInfo = execution.runInfo - logger.info( - f"Completed biological interpretation: status={report.status}, " - f"interpreted_clusters={len(report.clusterInterpretations)}, " - f"treatment_observations={len(report.treatmentObservations)}, " - f"follow_ups={len(report.followUps)}, tool_calls={len(deps.toolCalls)}" - ) - return report diff --git a/scarf/agent/biological_interpretation/__init__.py b/scarf/agent/biological_interpretation/__init__.py new file mode 100644 index 00000000..a9c449a0 --- /dev/null +++ b/scarf/agent/biological_interpretation/__init__.py @@ -0,0 +1,41 @@ +"""Grounded biological interpretation of Scarf cluster results.""" + +from .agent import BiologicalInterpretationAgent +from .contracts import ( + BiologicalContext, + BiologicalInterpretationNeedsInput, + BiologicalInterpretationReport, + ClusterCompositionEvidence, + ClusterInterpretation, + ClusterMarkerBatchEvidence, + ClusterMarkerEvidence, + ConditionClusterSummary, + FollowUpRecommendation, + MarkerFeature, + TreatmentObservation, +) +from .tools import ( + inspect_cluster_composition, + inspect_cluster_markers, + inspect_cluster_markers_batch, +) +from .validation import validate_biological_interpretation_report + +__all__ = [ + "BiologicalContext", + "BiologicalInterpretationAgent", + "BiologicalInterpretationNeedsInput", + "BiologicalInterpretationReport", + "ClusterCompositionEvidence", + "ClusterInterpretation", + "ClusterMarkerBatchEvidence", + "ClusterMarkerEvidence", + "ConditionClusterSummary", + "FollowUpRecommendation", + "MarkerFeature", + "TreatmentObservation", + "inspect_cluster_composition", + "inspect_cluster_markers_batch", + "inspect_cluster_markers", + "validate_biological_interpretation_report", +] diff --git a/scarf/agent/biological_interpretation/agent.py b/scarf/agent/biological_interpretation/agent.py new file mode 100644 index 00000000..6c6b18ef --- /dev/null +++ b/scarf/agent/biological_interpretation/agent.py @@ -0,0 +1,255 @@ +"""Biological interpretation prompt and agent runner.""" + +from textwrap import dedent +from typing import Any + +from ...storage.refs import ArtifactRef +from ...utils.logging import logger +from ..config import AgentRunConfig +from ..config.agent_exec import run_agent_sync +from ..tools import artifact_reference +from ..types import ( + ArtifactReferenceModel, + ExperimentalBiologyHandoff, + TuningBiologyHandoff, +) +from .contracts import ( + BiologicalContext, + BiologicalInterpretationDependencies, + BiologicalInterpretationReport, +) +from .tools import inspect_cluster_composition, inspect_cluster_markers_batch +from .validation import ( + _prepare_biological_interpretation_dependencies, + _prepare_biological_interpretation_tool, + fallback_biological_interpretation_report, + validate_biological_interpretation_report, +) + +try: + from pydantic_ai import Tool, UnexpectedModelBehavior, UsageLimitExceeded +except ImportError as exc: + from .._deps import AGENT_INSTALL_HINT + + raise ImportError(AGENT_INSTALL_HINT) from exc + + +_SYSTEM_PROMPT = dedent( + """ + You are Scarf's Biological Interpretation Agent. Use only the supplied + tools and caller context. Call inspect_cluster_composition exactly once. + Then select every cluster you intend to interpret and call + inspect_cluster_markers_batch exactly once with all selected cluster IDs. + Tool calls execute Scarf operations, so wait for their results before + drawing conclusions. Do not split marker inspection across calls. + + This API does not provide trusted per-cluster identities. Treat every cell + identity as a hypothesis and always set identityIsHypothesis=true. Caller + cell-type references are context, not assignments to clusters. Prefer + proposedIdentity="unresolved" when the returned markers do not ground a + specific hypothesis. Do not invent genes, cell types, statistics, artifact + identifiers, or evidence identifiers. Cite only evidenceIds returned by + tools. For each cluster interpretation, copy the exact non-empty marker + evidenceId returned for that cluster into its evidenceIds. Do not interpret + a cluster whose marker evidenceId is empty. Cluster abundance summaries are + descriptive, not tests of significance or causal effects. Treatment observations must + compare two returned independent-unit condition summaries for the same + cluster. Independent units may occur in more than one condition in paired + or repeated-measure designs. Return treatmentObservations empty unless the + exact experimental handoff confirms independent-unit aggregation, a + between-unit coefficient, an estimable coefficient, and at least two + independent units in each cited condition. + Marker p-values describe cluster-versus-rest marker specificity, not + condition effects. Keep treatment content out of cluster identity + interpretations. Never return status=failed for uncertainty or weak + evidence. Return needsInput with one concrete question instead. Recommend a + named follow-up operation when replication, a covariate, or an exact + artifact is missing. Do not write exploratory code, use a shell, access + files, or call arbitrary Scarf methods. Return only fields defined by the + structured output schema. + """ +).strip() + + +class BiologicalInterpretationAgent: + """Run a bounded biological review through explicit Scarf tools.""" + + def __init__( + self, + model: Any, + *, + config: AgentRunConfig | None = None, + ) -> None: + self.model = model + self.config = (config or AgentRunConfig()).with_limits( + request_limit=8, + tool_call_limit=5, + output_token_limit=32768, + timeout_seconds=600.0, + ) + + def run( + self, + store: Any, + *, + cluster: ArtifactRef | ArtifactReferenceModel | None = None, + biological_context: BiologicalContext | None = None, + from_assay: str | None = None, + graph_assay: str | None = None, + marker_assay_type: str | None = None, + sample_column: str | None = None, + condition_column: str | None = None, + tuning_handoff: TuningBiologyHandoff | None = None, + experimental_handoff: ExperimentalBiologyHandoff | None = None, + marker: ArtifactRef | ArtifactReferenceModel | None = None, + marker_features: ArtifactRef | ArtifactReferenceModel | None = None, + allow_marker_search: bool = False, + max_clusters: int = 12, + max_markers: int = 10, + marker_min_score: float = 0.25, + marker_min_fraction: float = 0.2, + ) -> BiologicalInterpretationReport: + """Interpret cluster results while exposing only bounded tools to the model.""" + deps = _prepare_biological_interpretation_dependencies( + store, + cluster=cluster, + from_assay=from_assay, + graph_assay=graph_assay, + marker_assay_type=marker_assay_type, + sample_column=sample_column, + condition_column=condition_column, + tuning_handoff=tuning_handoff, + experimental_handoff=experimental_handoff, + marker=marker, + marker_features=marker_features, + allow_marker_search=allow_marker_search, + max_clusters=max_clusters, + max_markers=max_markers, + marker_min_score=marker_min_score, + marker_min_fraction=marker_min_fraction, + ) + logger.info( + f"Starting biological interpretation: " + f"cluster_artifact={deps.cluster.artifact_id!r}, " + f"graph_assay={deps.graphAssay!r}, marker_assay={deps.markerAssay!r}, " + f"marker_artifact_supplied={deps.marker is not None}, " + f"marker_search_authorized={deps.allowMarkerSearch}" + ) + context = biological_context or BiologicalContext() + cluster_artifact = artifact_reference(deps.cluster) + marker_state = "provided" if deps.marker is not None else "not provided" + treatment_eligible = bool( + experimental_handoff is not None + and experimental_handoff.conditionColumn + and experimental_handoff.independentUnit + and experimental_handoff.coefficientScope == "betweenUnit" + and experimental_handoff.estimability.get("status") == "ok" + and experimental_handoff.estimability.get("coefficientEstimable") is True + ) + user_prompt = ( + dedent( + """ + Review the exact cluster artifact {cluster_artifact} over + cell-selection artifact {cell_selection}. The graph owner is + {graph_assay}; markers are resolved from {marker_assay}. The exact + marker artifact is {marker_state}; creating a marker artifact is + authorized={allow_marker_search}. Review no more + than {max_clusters} clusters. The tool returns no more than + {max_markers} markers per cluster. Treatment observations are + eligible from the supplied design={treatment_eligible}. + + Caller biological context: + {biological_context} + + Experimental design context: + {experimental_context} + + Call inspect_cluster_composition once. Copy every returned cluster + ID exactly and send the complete unique list in one + inspect_cluster_markers_batch call. Interpret only clusters with a + non-empty returned marker evidenceId. Use proposedIdentity="unresolved" + when markers do not ground a specific hypothesis, and always set + identityIsHypothesis=true. Caller cell-type references are not + cluster labels. If marker evidence is empty, return needsInput with + one populated question. If treatment eligibility is false, return + treatmentObservations=[]. Never return status=failed for biological + uncertainty; use needsInput with a concrete question. Leave artifact + fields null or copy only exact tool-returned values. Each tool is + removed after it succeeds, so request every cluster in that one + marker batch. + """ + ) + .strip() + .format( + graph_assay=deps.graphAssay or "datastore integration", + marker_assay=deps.markerAssay, + cluster_artifact=cluster_artifact.model_dump_json(), + cell_selection=deps.cellSelection.artifact_id, + marker_state=marker_state, + allow_marker_search=allow_marker_search, + max_clusters=max_clusters, + max_markers=max_markers, + treatment_eligible=str(treatment_eligible).lower(), + biological_context=context.model_dump_json(), + experimental_context=( + experimental_handoff.model_dump_json() + if experimental_handoff is not None + else "not provided" + ), + ) + ) + logger.info( + f"Requesting biological interpretation for at most " + f"{deps.maxClusters} clusters" + ) + try: + execution = run_agent_sync( + model=self.model, + output_type=BiologicalInterpretationReport, + system_prompt=_SYSTEM_PROMPT, + user_prompt=user_prompt, + tools=( + Tool( + inspect_cluster_composition, + prepare=_prepare_biological_interpretation_tool, + sequential=self.config.sequentialTools, + timeout=self.config.timeoutSeconds, + ), + Tool( + inspect_cluster_markers_batch, + max_retries=1, + prepare=_prepare_biological_interpretation_tool, + sequential=self.config.sequentialTools, + timeout=self.config.timeoutSeconds, + ), + ), + deps_type=BiologicalInterpretationDependencies, + deps=deps, + config=self.config, + name="biological_interpretation", + output_validator=lambda report: ( + validate_biological_interpretation_report( + report, + deps, + ) + ), + ) + except (UnexpectedModelBehavior, UsageLimitExceeded) as exc: + if not deps.clusterValues: + raise + model_name = getattr(self.model, "model_name", type(self.model).__name__) + return fallback_biological_interpretation_report( + deps, + error=exc, + model_name=str(model_name), + ) + report = BiologicalInterpretationReport.model_validate(execution.output) + report = validate_biological_interpretation_report(report, deps) + report.runInfo = execution.runInfo + logger.info( + f"Completed biological interpretation: status={report.status}, " + f"interpreted_clusters={len(report.clusterInterpretations)}, " + f"treatment_observations={len(report.treatmentObservations)}, " + f"follow_ups={len(report.followUps)}, tool_calls={len(deps.toolCalls)}" + ) + return report diff --git a/scarf/agent/biological_interpretation/contracts.py b/scarf/agent/biological_interpretation/contracts.py new file mode 100644 index 00000000..c237ba1d --- /dev/null +++ b/scarf/agent/biological_interpretation/contracts.py @@ -0,0 +1,383 @@ +"""Serializable contracts for biological interpretation.""" + +from typing import Any, Literal + +from pydantic import Field + +from ..types import ( + AgentDataModel, + AgentRunInfo, + ArtifactReferenceModel, + ExperimentalBiologyHandoff, + StageStatus, +) + +type InterpretationConfidence = Literal["low", "medium", "high"] +type TreatmentDirection = Literal["higher", "lower", "equal"] + +_MAX_CLUSTERS = 20 +_MAX_CONDITIONS = 30 +_MAX_MARKERS = 25 + + +class BiologicalContext(AgentDataModel): + """Caller-supplied facts that constrain biological interpretation.""" + + organism: str = "" + studyContext: str = "" + tissue: str = "" + cellTypeReferences: list[str] = Field(default_factory=list) + experimentalDetails: list[str] = Field(default_factory=list) + treatmentQuestion: str = "" + + @classmethod + def get_blank(cls) -> "BiologicalContext": + return cls() + + @classmethod + def get_example(cls) -> "BiologicalContext": + return cls( + organism="Homo sapiens", + studyContext=( + "Human lung samples were profiled after drug or vehicle treatment." + ), + tissue="lung", + cellTypeReferences=["alveolar macrophage", "T cell"], + experimentalDetails=["drug and vehicle groups"], + treatmentQuestion="Which populations respond selectively to treatment?", + ) + + +class ConditionClusterSummary(AgentDataModel): + """Aggregate cluster abundance for one condition without sample identifiers.""" + + condition: str = "" + clusterId: str = "" + nSamples: int = 0 + meanFraction: float = 0.0 + minFraction: float = 0.0 + maxFraction: float = 0.0 + cellCount: int = 0 + evidenceId: str = "" + + @classmethod + def get_example(cls) -> "ConditionClusterSummary": + return cls( + condition="treated", + clusterId="3", + nSamples=4, + meanFraction=0.18, + minFraction=0.12, + maxFraction=0.25, + cellCount=180, + evidenceId="composition:RNA_cluster:condition:treated:cluster:3", + ) + + +class ClusterCompositionEvidence(AgentDataModel): + """Bounded deterministic evidence about cluster sizes and conditions.""" + + clusterArtifact: ArtifactReferenceModel | None = None + cellSelection: ArtifactReferenceModel | None = None + totalCells: int = 0 + clusterCounts: dict[str, int] = Field(default_factory=dict) + sampleColumn: str | None = None + conditionColumn: str | None = None + conditionSummaries: list[ConditionClusterSummary] = Field(default_factory=list) + evidenceIds: list[str] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) + + @classmethod + def get_example(cls) -> "ClusterCompositionEvidence": + summary = ConditionClusterSummary.get_example() + reference_summary = ConditionClusterSummary( + condition="control", + clusterId=summary.clusterId, + nSamples=4, + meanFraction=0.11, + minFraction=0.08, + maxFraction=0.15, + cellCount=110, + evidenceId="composition:RNA_cluster:condition:control:cluster:3", + ) + return cls( + clusterArtifact=ArtifactReferenceModel( + assay="RNA", + kind="cluster_labels", + artifactId="b" * 64, + ), + cellSelection=ArtifactReferenceModel( + scope="datastore", + assay=None, + kind="cell_selection", + artifactId="c" * 64, + ), + totalCells=1000, + clusterCounts={"0": 520, "1": 300, "3": 180}, + sampleColumn="sample", + conditionColumn="treatment", + conditionSummaries=[reference_summary, summary], + evidenceIds=[ + "composition:RNA_cluster:counts", + reference_summary.evidenceId, + summary.evidenceId, + ], + ) + + +class MarkerFeature(AgentDataModel): + """One observed marker feature and its available Scarf statistics.""" + + featureId: str = "" + featureName: str = "" + featureIndex: int | None = None + score: float | None = None + foldChange: float | None = None + fractionExpressed: float | None = None + fractionExpressedRest: float | None = None + mean: float | None = None + meanRest: float | None = None + auc: float | None = None + adjustedPvalue: float | None = None + + @classmethod + def get_example(cls) -> "MarkerFeature": + return cls( + featureId="ENSG00000173372", + featureName="C1QA", + featureIndex=123, + score=0.83, + foldChange=3.4, + fractionExpressed=0.76, + fractionExpressedRest=0.18, + auc=0.91, + adjustedPvalue=0.001, + ) + + +class ClusterMarkerEvidence(AgentDataModel): + """Bounded markers for one exact cluster label.""" + + clusterId: str = "" + markers: list[MarkerFeature] = Field(default_factory=list) + markerArtifact: ArtifactReferenceModel | None = None + evidenceId: str = "" + warnings: list[str] = Field(default_factory=list) + + @classmethod + def get_example(cls) -> "ClusterMarkerEvidence": + return cls( + clusterId="3", + markers=[MarkerFeature.get_example()], + markerArtifact=ArtifactReferenceModel( + assay="RNA", + kind="marker_table", + artifactId="a" * 64, + ), + evidenceId="markers:RNA_cluster:cluster:3", + ) + + +class ClusterMarkerBatchEvidence(AgentDataModel): + """Markers for all model-selected clusters returned by one tool call.""" + + clusters: list[ClusterMarkerEvidence] = Field(default_factory=list) + evidenceIds: list[str] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) + + @classmethod + def get_blank(cls) -> "ClusterMarkerBatchEvidence": + return cls() + + @classmethod + def get_example(cls) -> "ClusterMarkerBatchEvidence": + cluster = ClusterMarkerEvidence.get_example() + return cls(clusters=[cluster], evidenceIds=[cluster.evidenceId]) + + +class ClusterInterpretation(AgentDataModel): + """One evidence-linked cluster interpretation or hypothesis.""" + + clusterId: str = "" + proposedIdentity: str = "unresolved" + identityIsHypothesis: bool = True + confidence: InterpretationConfidence = "low" + rationale: str = "" + evidenceIds: list[str] = Field(default_factory=list) + + @classmethod + def get_example(cls) -> "ClusterInterpretation": + return cls( + clusterId="3", + proposedIdentity="alveolar macrophage-like", + identityIsHypothesis=True, + confidence="medium", + rationale="Observed marker pattern is consistent with the proposed identity.", + evidenceIds=["markers:RNA_cluster:cluster:3"], + ) + + +class TreatmentObservation(AgentDataModel): + """Descriptive treatment observation with no unsupported causal claim.""" + + clusterId: str = "" + referenceCondition: str = "" + comparisonCondition: str = "" + direction: TreatmentDirection = "equal" + observation: str = "" + isDescriptiveOnly: Literal[True] = True + evidenceIds: list[str] = Field(default_factory=list) + + @classmethod + def get_example(cls) -> "TreatmentObservation": + return cls( + clusterId="3", + referenceCondition="control", + comparisonCondition="treated", + direction="higher", + observation="Cluster 3 has a higher mean fraction in treated samples.", + evidenceIds=[ + "composition:RNA_cluster:condition:control:cluster:3", + "composition:RNA_cluster:condition:treated:cluster:3", + ], + ) + + +class FollowUpRecommendation(AgentDataModel): + """A bounded next analysis tied to an observed uncertainty.""" + + question: str = "" + operation: str = "" + rationale: str = "" + requiredInputs: list[str] = Field(default_factory=list) + evidenceIds: list[str] = Field(default_factory=list) + + @classmethod + def get_example(cls) -> "FollowUpRecommendation": + return cls( + question="Is the abundance difference reproducible across donors?", + operation="sample-level differential abundance", + rationale="Current evidence is descriptive and requires independent replicates.", + requiredInputs=["sample", "condition", "donor"], + evidenceIds=[ + "composition:RNA_cluster:condition:control:cluster:3", + "composition:RNA_cluster:condition:treated:cluster:3", + ], + ) + + +class BiologicalInterpretationNeedsInput(AgentDataModel): + question: str = "" + requiredInputs: list[str] = Field(default_factory=list) + evidenceIds: list[str] = Field(default_factory=list) + + @classmethod + def get_example(cls) -> "BiologicalInterpretationNeedsInput": + return cls( + question="Provide an exact marker artifact or authorize marker search.", + requiredInputs=["markerArtifact"], + ) + + +class BiologicalInterpretationReport(AgentDataModel): + """Structured, evidence-grounded biological review.""" + + status: StageStatus = "needsInput" + clusterInterpretations: list[ClusterInterpretation] = Field(default_factory=list) + treatmentObservations: list[TreatmentObservation] = Field(default_factory=list) + followUps: list[FollowUpRecommendation] = Field(default_factory=list) + clusterArtifact: ArtifactReferenceModel | None = None + markerArtifact: ArtifactReferenceModel | None = None + graphAssay: str | None = None + markerAssay: str | None = None + evidenceIds: list[str] = Field(default_factory=list) + limitations: list[str] = Field(default_factory=list) + stopReason: str = "" + needsInput: BiologicalInterpretationNeedsInput | None = None + runInfo: AgentRunInfo = Field(default_factory=AgentRunInfo) + + @classmethod + def get_example(cls) -> "BiologicalInterpretationReport": + interpretation = ClusterInterpretation.get_example() + observation = TreatmentObservation.get_example() + follow_up = FollowUpRecommendation.get_example() + return cls( + status="done", + clusterInterpretations=[interpretation], + treatmentObservations=[observation], + followUps=[follow_up], + clusterArtifact=ClusterCompositionEvidence.get_example().clusterArtifact, + markerArtifact=ClusterMarkerEvidence.get_example().markerArtifact, + graphAssay="RNA", + markerAssay="RNA", + evidenceIds=sorted( + { + *interpretation.evidenceIds, + *observation.evidenceIds, + *follow_up.evidenceIds, + } + ), + limitations=[ + "Cell identities remain hypotheses until independently validated." + ], + stopReason="The requested clusters were reviewed.", + ) + + +class BiologicalInterpretationDependencies(AgentDataModel): + """Runtime state available only to biological interpretation tools.""" + + store: Any = Field(default=None, exclude=True) + cluster: Any = Field(default=None, exclude=True) + cellSelection: Any = Field(default=None, exclude=True) + cellIndices: Any = Field(default=None, exclude=True) + fromAssay: str | None = None + graphAssay: str | None = None + markerAssay: str | None = None + markerAssayType: str | None = None + sampleColumn: str | None = None + conditionColumn: str | None = None + marker: Any = Field(default=None, exclude=True) + markerFeatures: Any = Field(default=None, exclude=True) + allowMarkerSearch: bool = False + maxClusters: int = 12 + maxMarkers: int = 10 + markerMinScore: float = 0.25 + markerMinFraction: float = 0.2 + evidenceIds: set[str] = Field(default_factory=set, exclude=True) + clusterValues: dict[str, Any] = Field(default_factory=dict, exclude=True) + markerEvidenceIds: dict[str, str] = Field(default_factory=dict, exclude=True) + markerEvidence: dict[str, ClusterMarkerEvidence] = Field( + default_factory=dict, + exclude=True, + ) + compositionEvidence: ClusterCompositionEvidence | None = Field( + default=None, + exclude=True, + ) + markerBatch: ClusterMarkerBatchEvidence | None = Field( + default=None, + exclude=True, + ) + markerBatchClusterIds: list[str] = Field(default_factory=list, exclude=True) + toolCalls: list[str] = Field(default_factory=list, exclude=True) + conditionEvidence: dict[str, ConditionClusterSummary] = Field( + default_factory=dict, + exclude=True, + ) + designHandoff: ExperimentalBiologyHandoff | None = Field( + default=None, + exclude=True, + ) + + @classmethod + def get_example(cls) -> "BiologicalInterpretationDependencies": + return cls( + cluster=object(), + fromAssay="RNA", + graphAssay="RNA", + markerAssay="RNA", + markerAssayType="RNA", + sampleColumn="sample", + conditionColumn="treatment", + ) diff --git a/scarf/agent/biological_interpretation/tools.py b/scarf/agent/biological_interpretation/tools.py new file mode 100644 index 00000000..b5d44de5 --- /dev/null +++ b/scarf/agent/biological_interpretation/tools.py @@ -0,0 +1,483 @@ +"""Bounded evidence tools for biological interpretation.""" + +from collections import Counter, defaultdict +from collections.abc import Mapping +from typing import Any + +import numpy as np + +from ...metadata.rows import read_metadata_missing_rows, read_metadata_rows +from ...storage.refs import ArtifactRef +from ...storage.selections import read_stored_selection_indices +from ...utils.logging import logger +from ..tools import artifact_reference, core_artifact_reference +from .contracts import ( + _MAX_CONDITIONS, + _MAX_MARKERS, + BiologicalInterpretationDependencies, + ClusterCompositionEvidence, + ClusterMarkerBatchEvidence, + ClusterMarkerEvidence, + ConditionClusterSummary, + MarkerFeature, +) + +try: + from pydantic_ai import ModelRetry, RunContext +except ImportError as exc: + from .._deps import AGENT_INSTALL_HINT + + raise ImportError(AGENT_INSTALL_HINT) from exc + + +def _string_value(value: Any) -> str: + return str(value.item() if isinstance(value, np.generic) else value) + + +def _finite_float(value: Any) -> float | None: + if value is None: + return None + try: + number = float(value) + except (TypeError, ValueError): + return None + return number if np.isfinite(number) else None + + +def _check_column(store: Any, column: str, label: str) -> None: + if column not in set(store.cells.columns): + raise ValueError(f"{label} {column!r} is not present in cell metadata") + + +async def inspect_cluster_composition( + ctx: RunContext[BiologicalInterpretationDependencies], +) -> ClusterCompositionEvidence: + """Inspect bounded cluster and condition composition without identifiers.""" + deps = ctx.deps + if deps.compositionEvidence is not None: + logger.info("Reused completed cluster composition inspection") + return deps.compositionEvidence + logger.info( + f"Inspecting cluster composition from artifact " + f"{getattr(deps.cluster, 'artifact_id', '')!r}; " + f"max_clusters={deps.maxClusters}" + ) + if deps.sampleColumn is not None: + _check_column(deps.store, deps.sampleColumn, "sample column") + if deps.conditionColumn is not None: + _check_column(deps.store, deps.conditionColumn, "condition column") + + if deps.cluster is None: + raise ValueError("An exact cluster artifact is required") + deps.cluster = core_artifact_reference(deps.cluster) + cluster_artifact = artifact_reference(deps.cluster) + if cluster_artifact.kind not in {"cluster_labels", "cluster_cut"}: + raise ValueError( + "cluster must identify a cluster_labels or cluster_cut artifact" + ) + if ( + deps.graphAssay is not None + and cluster_artifact.scope == "assay" + and cluster_artifact.assay != deps.graphAssay + ): + raise ValueError("cluster artifact belongs to a different assay") + if cluster_artifact.scope == "datastore" and cluster_artifact.assay is not None: + raise ValueError("datastore-scoped cluster artifacts must not name an assay") + status = deps.store.inspect_artifact(deps.cluster) + if not getattr(status, "exists", True): + raise ValueError("cluster artifact does not exist") + if not getattr(status, "complete", False): + raise ValueError("cluster artifact is incomplete") + inputs = getattr(status, "inputs", None) or {} + raw_selection = inputs.get("cell_selection") + if not isinstance(raw_selection, Mapping): + raise ValueError("cluster artifact has no cell-selection input") + cell_selection = ArtifactRef.from_dict(dict(raw_selection)) + if ( + cell_selection.scope != "datastore" + or cell_selection.kind != "cell_selection" + or cell_selection.assay is not None + ): + raise ValueError("cluster artifact has an invalid cell-selection input") + if ( + deps.cellSelection is not None + and core_artifact_reference(deps.cellSelection) != cell_selection + ): + raise ValueError( + "cluster artifact cell selection conflicts with the prepared selection" + ) + cell_indices = read_stored_selection_indices( + deps.store.zw, + cell_selection, + kind="cell_selection", + scope="datastore", + assay=None, + table_path="cellData", + ).astype(np.int64, copy=False) + deps.cellSelection = cell_selection + deps.cellIndices = cell_indices + cluster_group = deps.store.load_artifact(deps.cluster) + value_name = "labels" if cluster_artifact.kind == "cluster_cut" else "values" + if value_name not in cluster_group: + raise ValueError( + f"cluster artifact does not contain its {value_name!r} label vector" + ) + cluster_values = np.asarray(cluster_group[value_name][:]) + if cluster_values.ndim != 1 or len(cluster_values) != len(cell_indices): + raise ValueError("cluster artifact labels do not align with its cell selection") + if len(cluster_values) == 0: + raise ValueError("cluster artifact selects no cells") + counts = Counter(_string_value(value) for value in cluster_values) + ordered_clusters = sorted(counts, key=lambda value: (-counts[value], value)) + retained_clusters = ordered_clusters[: deps.maxClusters] + deps.clusterValues = { + _string_value(value): value.item() if isinstance(value, np.generic) else value + for value in cluster_values + if _string_value(value) in retained_clusters + } + evidence_prefix = f"composition:{cluster_artifact.artifactId}" + count_evidence = f"{evidence_prefix}:counts" + deps.evidenceIds.add(count_evidence) + warnings: list[str] = [] + if len(ordered_clusters) > deps.maxClusters: + warnings.append(f"Only the {deps.maxClusters} largest clusters were returned.") + + condition_summaries: list[ConditionClusterSummary] = [] + if deps.conditionColumn is not None: + condition_values = read_metadata_rows( + deps.store.cells, + deps.conditionColumn, + cell_indices, + ) + if len(condition_values) != len(cluster_values): + raise ValueError("condition and cluster columns are not aligned") + condition_missing = read_metadata_missing_rows( + deps.store.cells, + deps.conditionColumn, + cell_indices, + ) + if condition_missing is not None and np.any(condition_missing): + raise ValueError("condition column contains missing selected values") + n_conditions = len({_string_value(value) for value in condition_values}) + if n_conditions > _MAX_CONDITIONS: + warnings.append( + f"Only the first {_MAX_CONDITIONS} conditions were returned." + ) + if deps.sampleColumn is not None: + sample_values = read_metadata_rows( + deps.store.cells, + deps.sampleColumn, + cell_indices, + ) + if len(sample_values) != len(cluster_values): + raise ValueError("sample and cluster columns are not aligned") + sample_missing = read_metadata_missing_rows( + deps.store.cells, + deps.sampleColumn, + cell_indices, + ) + if sample_missing is not None and np.any(sample_missing): + raise ValueError("sample column contains missing selected values") + summaries = _sample_condition_summaries( + sample_values=sample_values, + condition_values=condition_values, + cluster_values=cluster_values, + retained_clusters=retained_clusters, + evidence_prefix=evidence_prefix, + ) + else: + summaries = _cell_condition_summaries( + condition_values=condition_values, + cluster_values=cluster_values, + retained_clusters=retained_clusters, + evidence_prefix=evidence_prefix, + ) + warnings.append( + "No sample column was supplied; condition fractions are cell-level summaries." + ) + condition_summaries = summaries[: _MAX_CONDITIONS * len(retained_clusters)] + deps.evidenceIds.update(summary.evidenceId for summary in condition_summaries) + deps.conditionEvidence.update( + {summary.evidenceId: summary for summary in condition_summaries} + ) + + deps.toolCalls.append("inspect_cluster_composition") + evidence = ClusterCompositionEvidence( + clusterArtifact=cluster_artifact, + cellSelection=artifact_reference(cell_selection), + totalCells=len(cluster_values), + clusterCounts={cluster: counts[cluster] for cluster in retained_clusters}, + sampleColumn=deps.sampleColumn, + conditionColumn=deps.conditionColumn, + conditionSummaries=condition_summaries, + evidenceIds=sorted(deps.evidenceIds), + warnings=warnings, + ) + deps.compositionEvidence = evidence + logger.info( + f"Completed cluster composition inspection: cells={evidence.totalCells}, " + f"clusters={len(evidence.clusterCounts)}, " + f"condition_summaries={len(evidence.conditionSummaries)}, " + f"warnings={len(evidence.warnings)}" + ) + return evidence + + +def _sample_condition_summaries( + *, + sample_values: np.ndarray, + condition_values: np.ndarray, + cluster_values: np.ndarray, + retained_clusters: list[str], + evidence_prefix: str, +) -> list[ConditionClusterSummary]: + """Aggregate each condition-unit pair without exposing unit identifiers.""" + sample_counts: dict[tuple[str, str], Counter[str]] = defaultdict(Counter) + sample_totals: Counter[tuple[str, str]] = Counter() + for sample, condition, cluster in zip( + sample_values, + condition_values, + cluster_values, + strict=True, + ): + condition_label = _string_value(condition) + sample_label = _string_value(sample) + key = (condition_label, sample_label) + sample_totals[key] += 1 + sample_counts[key][_string_value(cluster)] += 1 + + fractions: dict[tuple[str, str], list[float]] = defaultdict(list) + cell_counts: Counter[tuple[str, str]] = Counter() + for key, total in sample_totals.items(): + condition, _sample = key + for cluster in retained_clusters: + count = sample_counts[key][cluster] + fractions[(condition, cluster)].append(count / total) + cell_counts[(condition, cluster)] += count + + output: list[ConditionClusterSummary] = [] + for condition, cluster in sorted(fractions): + values = fractions[(condition, cluster)] + evidence_id = f"{evidence_prefix}:condition:{condition}:cluster:{cluster}" + output.append( + ConditionClusterSummary( + condition=condition, + clusterId=cluster, + nSamples=len(values), + meanFraction=float(np.mean(values)), + minFraction=float(np.min(values)), + maxFraction=float(np.max(values)), + cellCount=cell_counts[(condition, cluster)], + evidenceId=evidence_id, + ) + ) + return output + + +def _cell_condition_summaries( + *, + condition_values: np.ndarray, + cluster_values: np.ndarray, + retained_clusters: list[str], + evidence_prefix: str, +) -> list[ConditionClusterSummary]: + totals: Counter[str] = Counter(_string_value(value) for value in condition_values) + counts: Counter[tuple[str, str]] = Counter() + for condition, cluster in zip(condition_values, cluster_values, strict=True): + counts[(_string_value(condition), _string_value(cluster))] += 1 + output: list[ConditionClusterSummary] = [] + for condition in sorted(totals): + for cluster in retained_clusters: + count = counts[(condition, cluster)] + fraction = count / totals[condition] + evidence_id = f"{evidence_prefix}:condition:{condition}:cluster:{cluster}" + output.append( + ConditionClusterSummary( + condition=condition, + clusterId=cluster, + meanFraction=fraction, + minFraction=fraction, + maxFraction=fraction, + cellCount=count, + evidenceId=evidence_id, + ) + ) + return output + + +async def inspect_cluster_markers( + ctx: RunContext[BiologicalInterpretationDependencies], + cluster_id: str, +) -> ClusterMarkerEvidence: + """Load markers for one observed cluster, optionally creating one artifact.""" + deps = ctx.deps + logger.debug(f"Inspecting markers for cluster {cluster_id!r}") + if not deps.clusterValues: + raise ModelRetry("Call inspect_cluster_composition before inspecting markers.") + if cluster_id not in deps.clusterValues: + raise ModelRetry(f"cluster_id must be one of {sorted(deps.clusterValues)}") + cached = deps.markerEvidence.get(cluster_id) + if cached is not None: + logger.debug(f"Reused cached markers for cluster {cluster_id!r}") + return cached + if deps.marker is None: + if not deps.allowMarkerSearch: + logger.warning( + f"Markers for cluster {cluster_id!r} are unavailable because no " + "marker artifact was supplied or authorized" + ) + return ClusterMarkerEvidence( + clusterId=cluster_id, + evidenceId="", + warnings=[ + "No exact marker artifact was supplied and marker search was not authorized." + ], + ) + if deps.markerFeatures is None: + logger.warning( + f"Markers for cluster {cluster_id!r} are unavailable because no " + "feature selection was supplied" + ) + return ClusterMarkerEvidence( + clusterId=cluster_id, + evidenceId="", + warnings=["Marker search requires an exact feature selection."], + ) + logger.info( + f"Creating one marker artifact for assay {deps.markerAssay!r} " + f"from cluster artifact {deps.cluster.artifact_id!r}" + ) + deps.marker = deps.store.run_marker_search( + deps.cluster, + features=deps.markerFeatures, + ) + if not hasattr(deps.marker, "artifact_id"): + raise RuntimeError("marker search did not return an artifact reference") + deps.marker = core_artifact_reference(deps.marker) + + marker_artifact = artifact_reference(deps.marker) + if marker_artifact.kind != "marker_table": + raise ModelRetry("marker must identify a marker_table artifact") + if deps.markerAssay is not None and marker_artifact.assay != deps.markerAssay: + raise ModelRetry("marker artifact belongs to a different assay") + if hasattr(deps.store, "inspect_artifact"): + marker_status = deps.store.inspect_artifact(deps.marker) + if not getattr(marker_status, "exists", True): + raise ModelRetry("marker artifact does not exist") + if not getattr(marker_status, "complete", False): + raise ModelRetry("marker artifact is incomplete") + marker_inputs = getattr(marker_status, "inputs", None) or {} + stored_clusters = marker_inputs.get("clusters") + expected_cluster = artifact_reference(deps.cluster) + if ( + not isinstance(stored_clusters, Mapping) + or stored_clusters.get("artifact_id") != expected_cluster.artifactId + or stored_clusters.get("kind") != expected_cluster.kind + or stored_clusters.get("scope") != expected_cluster.scope + or stored_clusters.get("assay") != expected_cluster.assay + ): + raise ModelRetry( + "marker artifact is not linked to the exact cluster artifact" + ) + + frame = deps.store.get_markers( + deps.marker, + group_id=deps.clusterValues[cluster_id], + min_score=deps.markerMinScore, + min_frac_exp=deps.markerMinFraction, + ) + if "score" in frame.columns: + frame = frame.sort_values("score", ascending=False, na_position="last") + markers = [ + _marker_feature(row) + for row in frame.head(min(deps.maxMarkers, _MAX_MARKERS)).to_dict("records") + ] + cluster_artifact = artifact_reference(deps.cluster) + evidence_id = ( + f"markers:{marker_artifact.artifactId}:clusters:" + f"{cluster_artifact.artifactId}:cluster:{cluster_id}" + ) + if markers: + deps.evidenceIds.add(evidence_id) + deps.markerEvidenceIds[cluster_id] = evidence_id + evidence = ClusterMarkerEvidence( + clusterId=cluster_id, + markers=markers, + markerArtifact=marker_artifact, + evidenceId=evidence_id if markers else "", + warnings=[] if markers else ["No markers passed the requested thresholds."], + ) + deps.markerEvidence[cluster_id] = evidence + logger.debug( + f"Completed marker inspection for cluster {cluster_id!r}: " + f"markers={len(markers)}" + ) + return evidence + + +async def inspect_cluster_markers_batch( + ctx: RunContext[BiologicalInterpretationDependencies], + cluster_ids: list[str], +) -> ClusterMarkerBatchEvidence: + """Inspect every selected cluster in one bounded model tool call.""" + if not ctx.deps.clusterValues: + raise ModelRetry("Call inspect_cluster_composition before inspecting markers.") + if not cluster_ids: + raise ModelRetry("cluster_ids must contain at least one observed cluster") + if len(cluster_ids) > ctx.deps.maxClusters: + raise ModelRetry( + f"cluster_ids may contain at most {ctx.deps.maxClusters} values" + ) + if len(set(cluster_ids)) != len(cluster_ids): + raise ModelRetry("cluster_ids must not contain duplicates") + if ctx.deps.markerBatch is not None: + if cluster_ids != ctx.deps.markerBatchClusterIds: + raise ModelRetry( + "Marker inspection already completed. Use the returned evidence " + "and do not request a different cluster batch." + ) + logger.info("Reused completed cluster marker batch") + return ctx.deps.markerBatch + + logger.info(f"Inspecting markers for {len(cluster_ids)} cluster(s) in one batch") + clusters = [ + await inspect_cluster_markers(ctx, cluster_id=cluster_id) + for cluster_id in cluster_ids + ] + evidence_ids = [cluster.evidenceId for cluster in clusters if cluster.evidenceId] + warnings = [ + f"Cluster {cluster.clusterId}: {warning}" + for cluster in clusters + for warning in cluster.warnings + ] + ctx.deps.toolCalls.append("inspect_cluster_markers_batch") + evidence = ClusterMarkerBatchEvidence( + clusters=clusters, + evidenceIds=evidence_ids, + warnings=warnings, + ) + ctx.deps.markerBatch = evidence + ctx.deps.markerBatchClusterIds = list(cluster_ids) + logger.info( + f"Completed marker batch inspection: clusters={len(clusters)}, " + f"clusters_with_markers={sum(bool(cluster.markers) for cluster in clusters)}, " + f"evidence_records={len(evidence_ids)}" + ) + return evidence + + +def _marker_feature(row: dict[str, Any]) -> MarkerFeature: + raw_index = _finite_float(row.get("feature_index")) + return MarkerFeature( + featureId=str(row.get("feature_id", "")), + featureName=str(row.get("feature_name", "")), + featureIndex=int(raw_index) if raw_index is not None else None, + score=_finite_float(row.get("score")), + foldChange=_finite_float(row.get("fold_change")), + fractionExpressed=_finite_float(row.get("frac_exp")), + fractionExpressedRest=_finite_float(row.get("frac_exp_rest")), + mean=_finite_float(row.get("mean")), + meanRest=_finite_float(row.get("mean_rest")), + auc=_finite_float(row.get("auc")), + adjustedPvalue=_finite_float(row.get("p_value_adjusted")), + ) diff --git a/scarf/agent/biological_interpretation/validation.py b/scarf/agent/biological_interpretation/validation.py new file mode 100644 index 00000000..4e734b76 --- /dev/null +++ b/scarf/agent/biological_interpretation/validation.py @@ -0,0 +1,609 @@ +"""Validation and dependency preparation for biological interpretation.""" + +import math +from collections.abc import Mapping +from typing import Any + +import numpy as np + +from ...storage.refs import ArtifactRef +from ...storage.selections import read_stored_selection_indices +from ...utils.logging import logger +from ..tools import artifact_reference, core_artifact_reference +from ..types import AgentRunInfo, ExperimentalBiologyHandoff, TuningBiologyHandoff +from .contracts import ( + _MAX_CLUSTERS, + _MAX_MARKERS, + BiologicalInterpretationDependencies, + BiologicalInterpretationNeedsInput, + BiologicalInterpretationReport, + ClusterInterpretation, + TreatmentDirection, + TreatmentObservation, +) + +try: + from pydantic_ai import ( + ModelRetry, + RunContext, + UnexpectedModelBehavior, + UsageLimitExceeded, + ) + from pydantic_ai.tools import ToolDefinition +except ImportError as exc: + from .._deps import AGENT_INSTALL_HINT + + raise ImportError(AGENT_INSTALL_HINT) from exc + + +def _prepare_biological_interpretation_tool( + ctx: RunContext[BiologicalInterpretationDependencies], + tool_definition: ToolDefinition, +) -> ToolDefinition | None: + """Expose composition and marker batches once and in the required order.""" + completed_calls = set(ctx.deps.toolCalls) + if tool_definition.name == "inspect_cluster_composition": + return None if tool_definition.name in completed_calls else tool_definition + if tool_definition.name == "inspect_cluster_markers_batch": + if ( + "inspect_cluster_composition" not in completed_calls + or tool_definition.name in completed_calls + ): + return None + return tool_definition + return tool_definition + + +def _canonicalize_cluster_interpretations( + report: BiologicalInterpretationReport, + deps: BiologicalInterpretationDependencies, +) -> tuple[list[ClusterInterpretation], list[str]]: + canonical_interpretations: list[ClusterInterpretation] = [] + omitted_interpretation_clusters: list[str] = [] + for interpretation in report.clusterInterpretations: + marker_id = deps.markerEvidenceIds.get(interpretation.clusterId) + if marker_id is None: + omitted_interpretation_clusters.append(interpretation.clusterId) + continue + non_marker_evidence = sorted(set(interpretation.evidenceIds) - {marker_id}) + if non_marker_evidence: + raise ModelRetry( + "Cluster identity interpretations may cite only their exact marker " + f"evidence: {non_marker_evidence}" + ) + canonical_interpretations.append( + interpretation.model_copy( + update={ + "evidenceIds": [marker_id], + "identityIsHypothesis": True, + **( + { + "confidence": "low", + } + if deps.markerAssayType == "ATAC" + else {} + ), + } + ) + ) + return canonical_interpretations, omitted_interpretation_clusters + + +def _canonicalize_treatment_observations( + report: BiologicalInterpretationReport, + deps: BiologicalInterpretationDependencies, +) -> list[TreatmentObservation]: + if report.treatmentObservations and deps.conditionColumn is None: + raise ModelRetry("Treatment observations require a condition column.") + if report.treatmentObservations and deps.sampleColumn is None: + raise ModelRetry( + "Treatment observations require independent-unit composition summaries." + ) + if report.treatmentObservations: + handoff = deps.designHandoff + if ( + handoff is None + or not handoff.conditionColumn + or handoff.conditionColumn != deps.conditionColumn + or not handoff.independentUnit + or handoff.independentUnit != deps.sampleColumn + or handoff.coefficientScope != "betweenUnit" + or handoff.estimability.get("status") != "ok" + or handoff.estimability.get("coefficientEstimable") is not True + ): + raise ModelRetry( + "Treatment observations require an explicit condition, aggregation " + "at the independent unit, a between-unit coefficient, and an " + "estimable experimental contrast." + ) + + canonical_observations: list[TreatmentObservation] = [] + for observation in report.treatmentObservations: + if not observation.isDescriptiveOnly: + raise ModelRetry("Treatment observations must remain descriptive.") + if len(observation.evidenceIds) != 2 or len(set(observation.evidenceIds)) != 2: + raise ModelRetry( + "Every treatment observation must cite exactly two distinct " + "condition summaries." + ) + if any( + evidence_id not in deps.conditionEvidence + for evidence_id in observation.evidenceIds + ): + raise ModelRetry( + "Treatment observations may cite only condition composition evidence." + ) + summaries = [ + deps.conditionEvidence[evidence_id] + for evidence_id in observation.evidenceIds + ] + if any(summary.clusterId != observation.clusterId for summary in summaries): + raise ModelRetry( + "Every treatment observation must cite condition summaries for " + "its exact cluster." + ) + if ( + not observation.referenceCondition + or not observation.comparisonCondition + or observation.referenceCondition == observation.comparisonCondition + ): + raise ModelRetry( + "Treatment observations require two distinct named conditions." + ) + summaries_by_condition = {summary.condition: summary for summary in summaries} + expected_conditions = { + observation.referenceCondition, + observation.comparisonCondition, + } + if set(summaries_by_condition) != expected_conditions: + raise ModelRetry( + "Treatment observation conditions must match the two cited " + "condition summaries." + ) + if any(summary.nSamples < 2 for summary in summaries): + raise ModelRetry( + "Sample-level treatment observations require at least two samples " + "in every cited condition." + ) + reference = summaries_by_condition[observation.referenceCondition] + comparison = summaries_by_condition[observation.comparisonCondition] + if math.isclose( + comparison.meanFraction, + reference.meanFraction, + rel_tol=1e-9, + abs_tol=1e-12, + ): + expected_direction: TreatmentDirection = "equal" + elif comparison.meanFraction > reference.meanFraction: + expected_direction = "higher" + else: + expected_direction = "lower" + if observation.direction != expected_direction: + raise ModelRetry( + "Treatment observation direction does not match the cited mean " + "independent-unit fractions." + ) + if expected_direction == "equal": + canonical_text = ( + f"Cluster {observation.clusterId} has equal mean independent-unit " + f"fractions in {comparison.condition} and {reference.condition} " + f"({comparison.meanFraction:.6g}); this is descriptive only." + ) + else: + canonical_text = ( + f"Cluster {observation.clusterId} has a {expected_direction} mean " + f"independent-unit fraction in {comparison.condition} " + f"({comparison.meanFraction:.6g}) than in {reference.condition} " + f"({reference.meanFraction:.6g}); this is descriptive only." + ) + canonical_observations.append( + observation.model_copy(update={"observation": canonical_text}) + ) + return canonical_observations + + +def validate_biological_interpretation_report( + report: BiologicalInterpretationReport, + deps: BiologicalInterpretationDependencies, +) -> BiologicalInterpretationReport: + """Reject invented evidence, clusters, or completed marker-free reviews.""" + if not deps.clusterValues: + raise ModelRetry("Call inspect_cluster_composition before returning a report.") + if report.status == "failed": + raise ModelRetry( + "Do not return failed for biological uncertainty; return needsInput " + "with one concrete question instead." + ) + if report.status == "needsInput" and ( + report.needsInput is None or not report.needsInput.question.strip() + ): + raise ModelRetry("A needsInput report requires one concrete input question.") + if report.status != "needsInput" and report.needsInput is not None: + raise ModelRetry("Only a needsInput report may include an input question.") + expected_cluster_artifact = artifact_reference(deps.cluster) + if ( + report.clusterArtifact is not None + and report.clusterArtifact != expected_cluster_artifact + ): + raise ModelRetry("Report clusterArtifact does not match the inspected artifact") + if deps.marker is not None: + expected_marker_artifact = artifact_reference(deps.marker) + if ( + report.markerArtifact is not None + and report.markerArtifact != expected_marker_artifact + ): + raise ModelRetry( + "Report markerArtifact does not match the inspected artifact" + ) + + cited = set(report.evidenceIds) + for interpretation in report.clusterInterpretations: + cited.update(interpretation.evidenceIds) + for observation in report.treatmentObservations: + cited.update(observation.evidenceIds) + for follow_up in report.followUps: + cited.update(follow_up.evidenceIds) + if report.needsInput is not None: + cited.update(report.needsInput.evidenceIds) + unknown = cited.difference(deps.evidenceIds) + if unknown: + raise ModelRetry(f"Unknown evidenceIds: {sorted(unknown)}") + interpreted_clusters = {item.clusterId for item in report.clusterInterpretations} + observed_clusters = {item.clusterId for item in report.treatmentObservations} + unknown_clusters = (interpreted_clusters | observed_clusters).difference( + deps.clusterValues + ) + if unknown_clusters: + raise ModelRetry(f"Unknown cluster ids: {sorted(unknown_clusters)}") + canonical_interpretations, omitted_interpretation_clusters = ( + _canonicalize_cluster_interpretations(report, deps) + ) + canonical_observations = _canonicalize_treatment_observations(report, deps) + if report.status == "done" and not canonical_interpretations: + raise ModelRetry( + "A done report must contain at least one cluster interpretation with " + "non-empty marker evidence." + ) + limitations = list(report.limitations) + if deps.markerAssayType == "ATAC": + atac_limitation = ( + "ATAC peak markers are descriptive, so all cell identities remain " + "low-confidence hypotheses." + ) + if atac_limitation not in limitations: + limitations.append(atac_limitation) + if omitted_interpretation_clusters: + omitted_clusters = ", ".join(sorted(set(omitted_interpretation_clusters))) + marker_limitation = ( + "Cluster identity interpretations without non-empty marker evidence " + f"were omitted for clusters: {omitted_clusters}." + ) + if marker_limitation not in limitations: + limitations.append(marker_limitation) + if canonical_observations: + descriptive_limitation = ( + "Independent-unit cluster fractions are descriptive summaries, not " + "tests of significance or causal treatment effects." + ) + if descriptive_limitation not in limitations: + limitations.append(descriptive_limitation) + validated = report.model_copy( + update={ + "clusterInterpretations": canonical_interpretations, + "treatmentObservations": canonical_observations, + "evidenceIds": sorted( + { + *report.evidenceIds, + *( + evidence_id + for interpretation in canonical_interpretations + for evidence_id in interpretation.evidenceIds + ), + } + ), + "limitations": limitations, + "clusterArtifact": expected_cluster_artifact, + "markerArtifact": ( + artifact_reference(deps.marker) if deps.marker is not None else None + ), + "graphAssay": deps.graphAssay, + "markerAssay": deps.markerAssay, + } + ) + logger.debug( + f"Validated biological interpretation report: status={validated.status}, " + f"cluster_interpretations={len(validated.clusterInterpretations)}, " + f"treatment_observations={len(validated.treatmentObservations)}, " + f"omitted_interpretations={len(omitted_interpretation_clusters)}" + ) + return validated + + +def fallback_biological_interpretation_report( + deps: BiologicalInterpretationDependencies, + *, + error: UnexpectedModelBehavior | UsageLimitExceeded, + model_name: str, +) -> BiologicalInterpretationReport: + """Return exact unresolved identities when structured interpretation fails.""" + if not deps.clusterValues: + raise error + error_detail = str(error).replace("\n", " ").strip()[:500] + interpretations = [ + ClusterInterpretation( + clusterId=cluster_id, + proposedIdentity="unresolved", + identityIsHypothesis=True, + confidence="low", + rationale=( + "Exact marker evidence was available, but structured biological " + "interpretation was unavailable." + ), + evidenceIds=[evidence_id], + ) + for cluster_id, evidence_id in sorted(deps.markerEvidenceIds.items()) + ] + if interpretations: + report = BiologicalInterpretationReport( + status="done", + clusterInterpretations=interpretations, + evidenceIds=[ + evidence_id + for interpretation in interpretations + for evidence_id in interpretation.evidenceIds + ], + limitations=[ + "Cluster identities remain unresolved because structured model " + "interpretation exhausted its bounded correction budget.", + "No treatment observations were generated by the fallback.", + error_detail, + ], + stopReason=( + "Exact marker-bearing clusters were retained as unresolved " + "low-confidence hypotheses." + ), + runInfo=AgentRunInfo( + agentName="biological_interpretation_fallback", + modelName=model_name, + ), + ) + else: + composition_evidence = sorted( + evidence_id + for evidence_id in deps.evidenceIds + if evidence_id.startswith("composition:") + ) + report = BiologicalInterpretationReport( + status="needsInput", + evidenceIds=composition_evidence, + limitations=[ + "No non-empty marker evidence was available for a grounded cluster " + "interpretation.", + error_detail, + ], + stopReason="Biological interpretation requires marker evidence.", + needsInput=BiologicalInterpretationNeedsInput( + question=( + "Provide an exact marker artifact with non-empty cluster markers " + "or revise the authorized marker thresholds." + ), + requiredInputs=["markerArtifactOrThresholds"], + evidenceIds=composition_evidence, + ), + runInfo=AgentRunInfo( + agentName="biological_interpretation_fallback", + modelName=model_name, + ), + ) + validated = validate_biological_interpretation_report(report, deps) + logger.warning( + "Biological Interpretation used its conservative fallback: " + f"status={validated.status}, clusters=" + f"{len(validated.clusterInterpretations)}, reason={error_detail}" + ) + return validated + + +def _prepare_biological_interpretation_dependencies( + store: Any, + *, + cluster: Any, + from_assay: str | None, + graph_assay: str | None, + marker_assay_type: str | None, + sample_column: str | None, + condition_column: str | None, + tuning_handoff: TuningBiologyHandoff | None, + experimental_handoff: ExperimentalBiologyHandoff | None, + marker: Any, + marker_features: Any, + allow_marker_search: bool, + max_clusters: int, + max_markers: int, + marker_min_score: float, + marker_min_fraction: float, +) -> BiologicalInterpretationDependencies: + expected_selections: list[ArtifactRef] = [] + if tuning_handoff is not None: + if tuning_handoff.clusterArtifact is None: + raise ValueError("tuning_handoff lacks a cluster artifact") + tuning_selection = core_artifact_reference(tuning_handoff.cellSelection) + if not isinstance(tuning_selection, ArtifactRef): + raise ValueError("tuning_handoff lacks an exact cell selection") + expected_selections.append(tuning_selection) + if cluster is not None and ( + artifact_reference(cluster) != tuning_handoff.clusterArtifact + ): + raise ValueError("cluster conflicts with tuning_handoff") + if from_assay is not None and from_assay != tuning_handoff.fromAssay: + raise ValueError("from_assay conflicts with tuning_handoff") + if graph_assay is not None and graph_assay != tuning_handoff.graphAssay: + raise ValueError("graph_assay conflicts with tuning_handoff") + cluster = tuning_handoff.clusterArtifact + from_assay = tuning_handoff.fromAssay + graph_assay = tuning_handoff.graphAssay + if experimental_handoff is not None: + experimental_selection = core_artifact_reference( + experimental_handoff.cellSelection + ) + if not isinstance(experimental_selection, ArtifactRef): + raise ValueError("experimental_handoff lacks an exact cell selection") + expected_selections.append(experimental_selection) + if len(expected_selections) == 2 and ( + expected_selections[0] != expected_selections[1] + ): + raise ValueError( + "Experimental and tuning handoffs use different cell selections" + ) + if ( + condition_column is not None + and condition_column != experimental_handoff.conditionColumn + ): + raise ValueError("condition_column conflicts with experimental_handoff") + aggregation_unit = ( + experimental_handoff.independentUnit or experimental_handoff.observationUnit + ) + if sample_column is not None and sample_column != aggregation_unit: + raise ValueError("sample_column conflicts with experimental_handoff") + condition_column = experimental_handoff.conditionColumn + sample_column = aggregation_unit + if cluster is None: + raise ValueError("cluster must identify an exact cluster artifact") + if not 1 <= max_clusters <= _MAX_CLUSTERS: + raise ValueError(f"max_clusters must be between 1 and {_MAX_CLUSTERS}") + if not 1 <= max_markers <= _MAX_MARKERS: + raise ValueError(f"max_markers must be between 1 and {_MAX_MARKERS}") + if not 0 < marker_min_score <= 1: + raise ValueError("marker_min_score must be greater than 0 and at most 1") + if not 0 <= marker_min_fraction <= 1: + raise ValueError("marker_min_fraction must be between 0 and 1") + if allow_marker_search and marker is None and marker_features is None: + raise ValueError("marker_features is required when marker search is authorized") + + cluster = core_artifact_reference(cluster) + marker = core_artifact_reference(marker) + marker_features = core_artifact_reference(marker_features) + if not isinstance(cluster, ArtifactRef): + raise TypeError("cluster must be an ArtifactRef") + if marker is not None and ( + not isinstance(marker, ArtifactRef) or marker.kind != "marker_table" + ): + raise TypeError("marker must be a marker_table ArtifactRef") + if marker_features is not None and ( + not isinstance(marker_features, ArtifactRef) + or marker_features.kind != "feature_selection" + ): + raise TypeError("marker_features must be a feature_selection ArtifactRef") + cluster_artifact = artifact_reference(cluster) + if cluster_artifact.kind not in {"cluster_labels", "cluster_cut"}: + raise ValueError( + "cluster must identify a cluster_labels or cluster_cut artifact" + ) + if cluster_artifact.scope == "datastore" and cluster_artifact.assay is not None: + raise ValueError("datastore-scoped cluster artifacts must not name an assay") + if ( + tuning_handoff is not None + and cluster_artifact.scope == "datastore" + and not tuning_handoff.markerAssay + ): + raise ValueError( + "Integrated tuning handoffs must explicitly identify markerAssay" + ) + resolved_graph_assay = graph_assay or cluster_artifact.assay + if ( + resolved_graph_assay is not None + and cluster_artifact.scope == "assay" + and cluster_artifact.assay != resolved_graph_assay + ): + raise ValueError("cluster belongs to a different assay") + resolved_marker_assay = ( + tuning_handoff.markerAssay or cluster_artifact.assay + if tuning_handoff is not None + else ( + marker.assay + if isinstance(marker, ArtifactRef) + else ( + marker_features.assay + if isinstance(marker_features, ArtifactRef) + else from_assay or cluster_artifact.assay + ) + ) + ) + if cluster_artifact.scope == "datastore" and not resolved_marker_assay: + raise ValueError( + "from_assay is required to resolve markers for integrated clusters" + ) + if isinstance(marker, ArtifactRef) and marker.assay != resolved_marker_assay: + raise ValueError("marker artifact belongs to a different marker assay") + if ( + isinstance(marker_features, ArtifactRef) + and marker_features.assay != resolved_marker_assay + ): + raise ValueError("marker feature selection belongs to a different assay") + + cluster_status = store.inspect_artifact(cluster) + if not getattr(cluster_status, "exists", True): + raise ValueError("cluster artifact does not exist") + if not getattr(cluster_status, "complete", False): + raise ValueError("cluster artifact is incomplete") + raw_selection = (getattr(cluster_status, "inputs", None) or {}).get( + "cell_selection" + ) + if not isinstance(raw_selection, Mapping): + raise ValueError("cluster artifact has no cell-selection input") + cell_selection = ArtifactRef.from_dict(dict(raw_selection)) + if ( + cell_selection.scope != "datastore" + or cell_selection.kind != "cell_selection" + or cell_selection.assay is not None + ): + raise ValueError("cluster artifact has an invalid cell-selection input") + if any(selection != cell_selection for selection in expected_selections): + raise ValueError("handoff cell selection conflicts with cluster") + cell_indices = read_stored_selection_indices( + store.zw, + cell_selection, + kind="cell_selection", + scope="datastore", + assay=None, + table_path="cellData", + ).astype(np.int64, copy=False) + if isinstance(marker, ArtifactRef): + marker_status = store.inspect_artifact(marker) + if not getattr(marker_status, "exists", True): + raise ValueError("marker artifact does not exist") + if not getattr(marker_status, "complete", False): + raise ValueError("marker artifact is incomplete") + marker_inputs = getattr(marker_status, "inputs", None) or {} + stored_clusters = marker_inputs.get("clusters") + expected_cluster = artifact_reference(cluster) + if ( + not isinstance(stored_clusters, Mapping) + or stored_clusters.get("artifact_id") != expected_cluster.artifactId + or stored_clusters.get("kind") != expected_cluster.kind + or stored_clusters.get("scope") != expected_cluster.scope + or stored_clusters.get("assay") != expected_cluster.assay + ): + raise ValueError( + "marker artifact is not linked to the exact cluster artifact" + ) + return BiologicalInterpretationDependencies( + store=store, + cluster=cluster, + cellSelection=cell_selection, + cellIndices=cell_indices, + fromAssay=from_assay or cluster_artifact.assay or resolved_marker_assay, + graphAssay=resolved_graph_assay, + markerAssay=resolved_marker_assay, + markerAssayType=marker_assay_type, + sampleColumn=sample_column, + conditionColumn=condition_column, + designHandoff=experimental_handoff, + marker=marker, + markerFeatures=marker_features, + allowMarkerSearch=allow_marker_search, + maxClusters=max_clusters, + maxMarkers=max_markers, + markerMinScore=marker_min_score, + markerMinFraction=marker_min_fraction, + ) diff --git a/scarf/agent/cell_quality/__init__.py b/scarf/agent/cell_quality/__init__.py new file mode 100644 index 00000000..54a6eb78 --- /dev/null +++ b/scarf/agent/cell_quality/__init__.py @@ -0,0 +1 @@ +"""Registered cell-quality profiles and execution.""" diff --git a/scarf/agent/qc_execution.py b/scarf/agent/cell_quality/execution.py similarity index 98% rename from scarf/agent/qc_execution.py rename to scarf/agent/cell_quality/execution.py index 6ffd2847..856485f8 100644 --- a/scarf/agent/qc_execution.py +++ b/scarf/agent/cell_quality/execution.py @@ -6,20 +6,20 @@ import numpy as np -from ..metadata.artifacts import ( +from ...metadata.artifacts import ( plan_cell_data_artifact, write_cell_data_artifact, ) -from ..metadata.rows import read_metadata_rows_chunkwise -from ..metadata.selection import NamedCellArtifact, resolve_cell_aligned_artifact -from ..storage.artifacts import canonical_bytes, fingerprint_array, fingerprint_strings -from ..storage.refs import ArtifactRef -from ..storage.selections import ( +from ...metadata.rows import read_metadata_rows_chunkwise +from ...metadata.selection import NamedCellArtifact, resolve_cell_aligned_artifact +from ...storage.artifacts import canonical_bytes, fingerprint_array, fingerprint_strings +from ...storage.refs import ArtifactRef +from ...storage.selections import ( read_stored_selection_mask, resolve_generated_selection_artifact, ) -from ..utils.logging import logger -from .qc_profiles import ( +from ...utils.logging import logger +from .profiles import ( REGISTERED_CELL_QC_PROFILES, AutoFilterAction, RegisteredCellQcProfile, diff --git a/scarf/agent/qc_profiles.py b/scarf/agent/cell_quality/profiles.py similarity index 99% rename from scarf/agent/qc_profiles.py rename to scarf/agent/cell_quality/profiles.py index f799e31b..4636da7c 100644 --- a/scarf/agent/qc_profiles.py +++ b/scarf/agent/cell_quality/profiles.py @@ -6,7 +6,7 @@ import numpy as np -from ..quality_control.filtering import ( +from ...quality_control.filtering import ( _apply_bounds, _clamp_metric_bound, _from_work_scale, diff --git a/scarf/agent/config/__init__.py b/scarf/agent/config/__init__.py index d998700d..bff4ed87 100644 --- a/scarf/agent/config/__init__.py +++ b/scarf/agent/config/__init__.py @@ -1,6 +1,5 @@ """Configuration shared by the four Scarf domain agents.""" -import re from typing import Any, Literal from urllib.parse import urlparse @@ -12,62 +11,9 @@ "AgentRunConfig", "get_model_settings", "get_usage_limits", - "CONFIG", ] -class Config: - """Shared configuration for the four Scarf domain agents.""" - - # BiologicalInterpretation - _MAX_CLUSTERS: int = 20 - _MAX_CONDITIONS: int = 30 - _MAX_MARKERS: int = 25 - # CharacterizeFeatures - _MAX_EXOGENOUS: int = 25 - _CONTEXT_LIMIT: int = 1200 - _AUTO_DOWNLOAD_SPECIES: frozenset[str] = frozenset({"homo_sapiens", "mus_musculus"}) - # DataEnrichment - _MAX_FEATURE_QUERIES: int = 50 - # ParameterTuning - _MAX_CANDIDATES_OFFERED: int = 25 - _CANDIDATE_ID: re.Pattern[str] = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_]{0,63}$") - _RANDOM_SEED: int = 4444 - _PCA_RANDOM_SEED: int = 4466 - # CharacterizeCovariates - _CATEGORICAL_MAX_LEVELS: int = 50 - _EMBEDDING_TOKENS: tuple[str, ...] = ( - "umap", - "pca", - "tsne", - "scvi", - "latent", - "phate", - "forceatlas", - "diffmap", - "diffusionmap", - "diffusion", - ) - _DOMAINS: frozenset[str] = frozenset( - {"biological", "technical", "design", "ignore", "unknown"} - ) # Only these domains reach the design table, so only they are worth collapsing. - _ANALYSED: frozenset[str] = frozenset({"biological", "technical", "design"}) - _KINDS: frozenset[str] = frozenset({"categorical", "continuous"}) - _RESERVED_COLUMNS = frozenset({"I", "ids", "names"}) - - _SHORT_EMBEDDING_PARTS: frozenset[str] = frozenset({"fa", "dm", "pc"}) - _INDEXED_NAME: re.Pattern[str] = re.compile(r"(?P.+?)[-_]?(?P\d+)") - _ONTOLOGY_SUFFIX: str = "_ontology_term_id" - _SAMPLE_LEVELS: int = 8 - _ASSOCIATION_FLOOR: float = 0.1 - _DROP_REASONS: dict[str, str] = { - "dropAssayStat": "Scarf assay statistic column", - "dropProvenance": "analysis-linked column", - "dropEmbedding": "embedding-style column", - "dropConstant": "single-level column", - } - - class AgentRunConfig(AgentDataModel): """Bound one agent run without selecting a scientific workflow.""" @@ -202,6 +148,3 @@ def get_usage_limits(config: AgentRunConfig | None = None) -> Any: output_tokens_limit=output_tokens_limit, total_tokens_limit=run_config.totalTokenLimit, ) - - -CONFIG = Config() diff --git a/scarf/agent/config/agent_exec.py b/scarf/agent/config/agent_exec.py index e9a1b7c6..5eae366e 100644 --- a/scarf/agent/config/agent_exec.py +++ b/scarf/agent/config/agent_exec.py @@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Any, Literal from ...utils.logging import logger +from .._deps import require_pydantic_ai from ..types import ( AgentExecutionResult, AgentRunInfo, @@ -17,7 +18,6 @@ ToolCallInfo, ) from . import AgentRunConfig, get_model_settings, get_usage_limits -from ._deps import require_pydantic_ai if TYPE_CHECKING: from pydantic_ai.messages import UserContent diff --git a/scarf/agent/data_enrichment.py b/scarf/agent/data_enrichment.py deleted file mode 100644 index 83d326da..00000000 --- a/scarf/agent/data_enrichment.py +++ /dev/null @@ -1,1875 +0,0 @@ -"""Read-only feature and organism enrichment agent.""" - -import re -from collections.abc import Sequence -from pathlib import Path -from textwrap import dedent -from typing import Any, Literal - -from ..features.gene_reference import species_registry -from ..features.variability import DEFAULT_HVG_BLACKLIST -from ..utils.logging import logger -from .characterize_features import characterize_features -from .config import CONFIG, AgentRunConfig -from .config._deps import AGENT_INSTALL_HINT -from .config.agent_exec import run_agent_sync -from .tools import bounded_list -from .types import AgentDataModel, AgentRunInfo, StageStatus - -try: - from pydantic import ConfigDict, Field, model_validator - from pydantic_ai import ( - ModelRetry, - RunContext, - Tool, - UnexpectedModelBehavior, - UsageLimitExceeded, - ) - from pydantic_ai.tools import ToolDefinition -except ImportError as exc: - raise ImportError(AGENT_INSTALL_HINT) from exc - -__all__ = [ - "AdtControlEvidence", - "AssayFeatureInspection", - "AssayFeatureInspectionBatch", - "AssayModalityEvidence", - "AtacCoordinateEvidence", - "DataEnrichmentAgent", - "DataEnrichmentContext", - "DataEnrichmentDependencies", - "DataEnrichmentReport", - "DataEnrichmentToolCall", - "DefaultHvgFamilyEvidence", - "ExogenousFeatureEvidence", - "FeatureFamilyEvidence", - "FeatureLookupResult", - "FeatureLookupBatch", - "FeatureMatch", - "FeatureReference", - "FeatureSelectionPolicy", - "HtoTagEvidence", - "RnaFeatureInventoryEvidence", - "StudyContextSummary", - "find_present_features", - "find_present_features_batch", - "inspect_assay_features", - "inspect_assay_features_batch", - "validate_data_enrichment_report", -] - -_SUPPORTED_SPECIES = species_registry() -_SYSTEM_PROMPT = ( - dedent( - """ - You are Scarf's Data Enrichment Agent. Work only through the supplied - read-only tools. Inspect every requested assay before making a decision. - Use gene identifiers and names together with the supplied organism hint, - tissue references, cell-type references, and experimental details when - species evidence is ambiguous. Supported species keys are: {supported_species}. - - Call inspect_assay_features_batch once for all requested assays. Never - invent a feature. If individual features are needed, collect all proposed - names across assays and call find_present_features_batch once before - placing them in a policy. Do not call feature lookup when no individual - feature decision is needed. Absent or ambiguous lookup results must never - enter a policy. If inspection resolves a supported species, copy that exact - species key. Use caller organism context only when inspection leaves the - species unknown. Use excludeFamilies only to nominate one conditional - representation-sensitivity bundle from observed families with - defaultExclude=true. It is not an instruction to remove those families. - Never nominate a family with defaultExclude=false. - The defaultFeatureInventory is separate deterministic evidence for Scarf's - exact default HVG blacklist. It is evidence only, not an automatic - exclusion or a source of policy nominations. Keep marker eligibility - broader than any graph-feature exclusion. - - Persisted assay types determine modality routes; never infer a route from - an assay label. The validator fills assay type, modality eligibility, ADT - controls, HTO tags, ATAC-coordinate status, inspections, tool calls, and - report-level evidence. Leave those derived fields at their defaults instead - of copying them into the output. Treat Ensembl release misses as unresolved, - not artificial. Mitochondrial, ribosomal, and histone families may be - sensitivity candidates. Sex-linked and cell-cycle families are protected - by default. Marker testing retains conditional biological families. - - Structure studyContextSummary using only verbatim spans from the supplied - study paragraph, study objective, or exact caller references. Do not - paraphrase, infer, or - invent an organism, tissue, cell type, experiment, hypothesis, or analysis - intent. Empty optional hint lists do not mean that the paragraph lacks - those references. When a category is explicitly present in the paragraph, - include its exact span in the corresponding summary list. The validator - binds the original paragraph and exact caller references. Return a bounded - report with citations copied from tool or context evidence IDs. - Do not write code, mutate the datastore, or request arbitrary Scarf calls. - """ - ) - .strip() - .format(supported_species=", ".join(sorted(_SUPPORTED_SPECIES))) -) - - -class DataEnrichmentContext(AgentDataModel): - """Study evidence that may help resolve organism and feature policy.""" - - studyContext: str = "" - studyObjective: str = "" - organismHint: str = "" - tissueReferences: list[str] = Field(default_factory=list) - cellTypeReferences: list[str] = Field(default_factory=list) - experimentalDetails: list[str] = Field(default_factory=list) - - @classmethod - def get_blank(cls) -> "DataEnrichmentContext": - return cls() - - @classmethod - def get_example(cls) -> "DataEnrichmentContext": - return cls( - studyContext="Single-cell profiling of treated lung tissue", - studyObjective=( - "Discover stable populations while preserving treatment effects." - ), - organismHint="human", - tissueReferences=["lung"], - cellTypeReferences=["alveolar macrophage", "T cell"], - experimentalDetails=["CRISPR perturbation", "10x 3 prime RNA-seq"], - ) - - -class StudyContextSummary(AgentDataModel): - """Verbatim, evidence-backed references extracted from the study context.""" - - studyContext: str = "" - studyObjective: str = "" - organismReferences: list[str] = Field(default_factory=list) - tissueReferences: list[str] = Field(default_factory=list) - cellTypeReferences: list[str] = Field(default_factory=list) - experimentalReferences: list[str] = Field(default_factory=list) - hypothesisReferences: list[str] = Field(default_factory=list) - analysisIntentReferences: list[str] = Field(default_factory=list) - evidenceIds: list[str] = Field(default_factory=list) - - @classmethod - def get_blank(cls) -> "StudyContextSummary": - return cls() - - @classmethod - def get_example(cls) -> "StudyContextSummary": - return cls( - studyContext=( - "Single-cell profiling of treated human lung tests whether " - "treatment changes alveolar macrophage states." - ), - studyObjective=( - "Discover populations while preserving the treatment comparison." - ), - organismReferences=["human"], - tissueReferences=["lung"], - cellTypeReferences=["alveolar macrophage"], - experimentalReferences=["treated"], - hypothesisReferences=["treatment changes alveolar macrophage states"], - analysisIntentReferences=["Single-cell profiling"], - evidenceIds=["context:study"], - ) - - -class AdtControlEvidence(AgentDataModel): - """One exact observed ADT feature carrying an explicit control token.""" - - featureId: str - featureName: str - matchedToken: Literal["control", "isotype"] - evidenceId: str - - @classmethod - def get_blank(cls) -> "AdtControlEvidence": - return cls( - featureId="", - featureName="", - matchedToken="control", - evidenceId="", - ) - - @classmethod - def get_example(cls) -> "AdtControlEvidence": - return cls( - featureId="Mouse-IgG1-Control", - featureName="Mouse IgG1 isotype control", - matchedToken="isotype", - evidenceId="assay:ADT:adtControl:Mouse-IgG1-Control", - ) - - -class HtoTagEvidence(AgentDataModel): - """One exact feature from an assay persisted with the HTO type.""" - - featureId: str - featureName: str - evidenceId: str - - @classmethod - def get_blank(cls) -> "HtoTagEvidence": - return cls(featureId="", featureName="", evidenceId="") - - @classmethod - def get_example(cls) -> "HtoTagEvidence": - return cls( - featureId="HTO-1", - featureName="Sample tag 1", - evidenceId="assay:HTO:htoTag:HTO-1", - ) - - -class AtacCoordinateEvidence(AgentDataModel): - """Validation evidence for exact ATAC feature IDs as genomic intervals.""" - - status: Literal["notApplicable", "valid", "partial", "invalid"] = "notApplicable" - coordinateColumn: Literal["ids"] = "ids" - coordinateFormat: str = "chrom:start-end" - totalFeatures: int = 0 - validFeatures: int = 0 - invalidExamples: list[str] = Field(default_factory=list) - validExamples: list[str] = Field(default_factory=list) - genomeBuild: Literal["unknown"] = "unknown" - evidenceId: str = "" - - @classmethod - def get_blank(cls) -> "AtacCoordinateEvidence": - return cls() - - @classmethod - def get_example(cls) -> "AtacCoordinateEvidence": - return cls( - status="valid", - totalFeatures=2, - validFeatures=2, - validExamples=["chr1:100-200", "chr2:300-450"], - evidenceId="assay:ATAC:atacCoordinates", - ) - - -class AssayModalityEvidence(AgentDataModel): - """Bounded deterministic routing evidence for one persisted assay type.""" - - assayType: str = "Assay" - modality: Literal["RNA", "ATAC", "ADT", "HTO", "unsupported"] = "unsupported" - typeSource: Literal["persisted", "assayClass", "unknown"] = "unknown" - graphEligible: bool = False - markerEligible: bool = False - demultiplexEligible: bool = False - adtControls: list[AdtControlEvidence] = Field(default_factory=list) - htoTags: list[HtoTagEvidence] = Field(default_factory=list) - atacCoordinates: AtacCoordinateEvidence = Field( - default_factory=AtacCoordinateEvidence.get_blank - ) - totalObservedFeatures: int = 0 - reportedFeatures: int = 0 - truncated: bool = False - evidenceIds: list[str] = Field(default_factory=list) - - @classmethod - def get_blank(cls) -> "AssayModalityEvidence": - return cls() - - @classmethod - def get_example(cls) -> "AssayModalityEvidence": - control = AdtControlEvidence.get_example() - return cls( - assayType="ADT", - modality="ADT", - typeSource="persisted", - graphEligible=True, - markerEligible=True, - adtControls=[control], - totalObservedFeatures=20, - reportedFeatures=1, - evidenceIds=["assay:ADT:modality", control.evidenceId], - ) - - -class FeatureFamilyEvidence(AgentDataModel): - """One observed feature family from deterministic Scarf analysis.""" - - family: str - species: str = "unknown" - method: str = "" - count: int = 0 - examples: list[str] = Field(default_factory=list) - defaultExclude: bool | None = None - skipped: str | None = None - catalogSuspect: str | None = None - catalogSize: int | None = None - catalogJoinRate: float | None = None - catalogJoined: int | None = None - evidenceId: str - - @classmethod - def get_blank(cls) -> "FeatureFamilyEvidence": - return cls(family="", evidenceId="") - - @classmethod - def get_example(cls) -> "FeatureFamilyEvidence": - return cls( - family="mitochondrial", - species="homo_sapiens", - method="chromosome", - count=2, - examples=["MT-CO1", "MT-CYB"], - defaultExclude=True, - evidenceId="assay:RNA:family:mitochondrial", - ) - - -class DefaultHvgFamilyEvidence(AgentDataModel): - """One case-insensitive family within Scarf's default HVG blacklist.""" - - family: str = "" - pattern: str = "" - caseInsensitive: Literal[True] = True - count: int = 0 - examples: list[str] = Field(default_factory=list) - evidenceId: str = "" - - @classmethod - def get_blank(cls) -> "DefaultHvgFamilyEvidence": - return cls() - - @classmethod - def get_example(cls) -> "DefaultHvgFamilyEvidence": - return cls( - family="mitochondrial", - pattern="^MT-", - count=2, - examples=["MT-CO1", "MT-CYB"], - evidenceId="assay:RNA:scarfDefaultHvg:family:mitochondrial", - ) - - -class RnaFeatureInventoryEvidence(AgentDataModel): - """Exact name-column matches for Scarf's default HVG blacklist.""" - - source: Literal["scarfDefaultHvgBlacklist"] = "scarfDefaultHvgBlacklist" - policyEffect: Literal["evidenceOnly"] = "evidenceOnly" - featureColumn: Literal["names"] = "names" - totalFeatures: int = 0 - blacklist: str = "" - matchCount: int = 0 - examples: list[str] = Field(default_factory=list) - families: list[DefaultHvgFamilyEvidence] = Field(default_factory=list) - evidenceId: str = "" - evidenceIds: list[str] = Field(default_factory=list) - - @classmethod - def get_blank(cls) -> "RnaFeatureInventoryEvidence": - return cls() - - @classmethod - def get_example(cls) -> "RnaFeatureInventoryEvidence": - family = DefaultHvgFamilyEvidence.get_example() - evidence_id = "assay:RNA:scarfDefaultHvg:combined" - return cls( - totalFeatures=20_000, - blacklist=DEFAULT_HVG_BLACKLIST, - matchCount=2, - examples=["MT-CO1", "MT-CYB"], - families=[family], - evidenceId=evidence_id, - evidenceIds=[evidence_id, family.evidenceId], - ) - - -class ExogenousFeatureEvidence(AgentDataModel): - """One bounded candidate for an artificial or exogenous feature.""" - - featureId: str - featureName: str - score: int = 0 - classification: str = "unresolved" - evidenceId: str - - @classmethod - def get_blank(cls) -> "ExogenousFeatureEvidence": - return cls(featureId="", featureName="", evidenceId="") - - @classmethod - def get_example(cls) -> "ExogenousFeatureEvidence": - return cls( - featureId="ERCC-00002", - featureName="ERCC-00002", - score=4, - classification="potentialExogenous", - evidenceId="assay:RNA:exogenous:ERCC-00002", - ) - - -class AssayFeatureInspection(AgentDataModel): - """Bounded read-only inspection returned to the model.""" - - assay: str - assayKind: str = "" - identity: dict[str, Any] = Field(default_factory=dict) - species: str = "unknown" - speciesMethod: str | None = None - speciesReason: str = "" - families: list[FeatureFamilyEvidence] = Field(default_factory=list) - defaultFeatureInventory: RnaFeatureInventoryEvidence | None = None - exogenous: list[ExogenousFeatureEvidence] = Field(default_factory=list) - modalityEvidence: AssayModalityEvidence = Field( - default_factory=AssayModalityEvidence.get_blank - ) - notes: list[str] = Field(default_factory=list) - evidenceIds: list[str] = Field(default_factory=list) - - @classmethod - def get_blank(cls) -> "AssayFeatureInspection": - return cls(assay="") - - @classmethod - def get_example(cls) -> "AssayFeatureInspection": - family = FeatureFamilyEvidence.get_example() - default_inventory = RnaFeatureInventoryEvidence.get_example() - modality = AssayModalityEvidence( - assayType="RNA", - modality="RNA", - typeSource="persisted", - graphEligible=True, - markerEligible=True, - totalObservedFeatures=20_000, - evidenceIds=["assay:RNA:modality"], - ) - return cls( - assay="RNA", - assayKind="RNAassay", - identity={"nFeatures": 20_000, "nDuplicateIds": 0}, - species="homo_sapiens", - speciesMethod="ensemblPrefix", - speciesReason="Most feature IDs carry the ENSG prefix", - families=[family], - defaultFeatureInventory=default_inventory, - modalityEvidence=modality, - evidenceIds=[ - "assay:RNA:identity", - "assay:RNA:species", - family.evidenceId, - *default_inventory.evidenceIds, - *modality.evidenceIds, - ], - ) - - -class AssayFeatureInspectionBatch(AgentDataModel): - """All requested assay inspections returned by one model tool call.""" - - inspections: list[AssayFeatureInspection] = Field(default_factory=list) - evidenceIds: list[str] = Field(default_factory=list) - - @classmethod - def get_blank(cls) -> "AssayFeatureInspectionBatch": - return cls() - - @classmethod - def get_example(cls) -> "AssayFeatureInspectionBatch": - inspection = AssayFeatureInspection.get_example() - return cls( - inspections=[inspection], - evidenceIds=list(inspection.evidenceIds), - ) - - -class FeatureReference(AgentDataModel): - """An exact feature identifier and name observed in one assay.""" - - featureId: str - featureName: str - - @classmethod - def get_blank(cls) -> "FeatureReference": - return cls(featureId="", featureName="") - - @classmethod - def get_example(cls) -> "FeatureReference": - return cls(featureId="ENSG00000198727", featureName="MT-CYB") - - -class FeatureMatch(AgentDataModel): - """Resolution of one proposed feature against an assay.""" - - query: str - status: Literal["present", "ambiguous", "absent"] - matches: list[FeatureReference] = Field(default_factory=list) - evidenceIds: list[str] = Field(default_factory=list) - - @classmethod - def get_blank(cls) -> "FeatureMatch": - return cls(query="", status="absent") - - @classmethod - def get_example(cls) -> "FeatureMatch": - return cls( - query="MT-CYB", - status="present", - matches=[FeatureReference.get_example()], - evidenceIds=["assay:RNA:feature:ENSG00000198727"], - ) - - -class FeatureLookupResult(AgentDataModel): - """Bounded result from exact feature lookup.""" - - assay: str - results: list[FeatureMatch] = Field(default_factory=list) - evidenceIds: list[str] = Field(default_factory=list) - - @classmethod - def get_blank(cls) -> "FeatureLookupResult": - return cls(assay="") - - @classmethod - def get_example(cls) -> "FeatureLookupResult": - match = FeatureMatch.get_example() - return cls( - assay="RNA", - results=[match], - evidenceIds=list(match.evidenceIds), - ) - - -class FeatureLookupBatch(AgentDataModel): - """Exact feature lookups for every requested assay in one tool result.""" - - lookups: list[FeatureLookupResult] = Field(default_factory=list) - evidenceIds: list[str] = Field(default_factory=list) - - @classmethod - def get_blank(cls) -> "FeatureLookupBatch": - return cls() - - @classmethod - def get_example(cls) -> "FeatureLookupBatch": - lookup = FeatureLookupResult.get_example() - return cls(lookups=[lookup], evidenceIds=list(lookup.evidenceIds)) - - -class FeatureSelectionPolicy(AgentDataModel): - """Grounded feature policy proposed for one assay.""" - - assay: str - species: str = "unknown" - organismName: str = "unknown" - speciesConfidence: Literal["high", "medium", "low", "unknown"] = "unknown" - speciesRationale: str = "" - excludeFamilies: list[str] = Field(default_factory=list) - protectFamilies: list[str] = Field(default_factory=list) - excludeFeatures: list[str] = Field(default_factory=list) - protectFeatures: list[str] = Field(default_factory=list) - artificialFeatures: list[str] = Field(default_factory=list) - tissueReferences: list[str] = Field(default_factory=list) - cellTypeReferences: list[str] = Field(default_factory=list) - experimentalReferences: list[str] = Field(default_factory=list) - assayType: str = "Assay" - assayModality: Literal["RNA", "ATAC", "ADT", "HTO", "unsupported"] = "unsupported" - graphEligible: bool = False - markerEligible: bool = False - demultiplexEligible: bool = False - exactControlFeatures: list[FeatureReference] = Field(default_factory=list) - exactTagFeatures: list[FeatureReference] = Field(default_factory=list) - peakCoordinateStatus: Literal["notApplicable", "valid", "partial", "invalid"] = ( - "notApplicable" - ) - rationale: str = "" - evidenceIds: list[str] = Field(default_factory=list) - - @model_validator(mode="after") - def validate_non_conflicting_policy(self) -> "FeatureSelectionPolicy": - family_overlap = set(self.excludeFamilies) & set(self.protectFamilies) - feature_overlap = set(self.excludeFeatures) & set(self.protectFeatures) - if family_overlap: - raise ValueError( - "feature families cannot be both excluded and protected: " - f"{sorted(family_overlap)}" - ) - if feature_overlap: - raise ValueError( - "features cannot be both excluded and protected: " - f"{sorted(feature_overlap)}" - ) - return self - - @classmethod - def get_blank(cls) -> "FeatureSelectionPolicy": - return cls(assay="") - - @classmethod - def get_example(cls) -> "FeatureSelectionPolicy": - return cls( - assay="RNA", - species="homo_sapiens", - organismName="human", - speciesConfidence="high", - speciesRationale="Gene IDs and study context agree", - excludeFamilies=["mitochondrial", "ribosomal"], - protectFamilies=["cellCycle", "sex"], - artificialFeatures=["ERCC-00002"], - tissueReferences=["lung"], - cellTypeReferences=["alveolar macrophage"], - experimentalReferences=["ERCC spike-in"], - assayType="RNA", - assayModality="RNA", - graphEligible=True, - markerEligible=True, - rationale="Use technical families for feature-selection exclusions", - evidenceIds=["assay:RNA:species", "assay:RNA:family:mitochondrial"], - ) - - -class DataEnrichmentToolCall(AgentDataModel): - """Compact audit record for one read-only model tool call.""" - - name: str - assay: str - evidenceIds: list[str] = Field(default_factory=list) - - @classmethod - def get_blank(cls) -> "DataEnrichmentToolCall": - return cls(name="", assay="") - - @classmethod - def get_example(cls) -> "DataEnrichmentToolCall": - return cls( - name="inspect_assay_features", - assay="RNA", - evidenceIds=["assay:RNA:identity", "assay:RNA:species"], - ) - - -class DataEnrichmentReport(AgentDataModel): - """Final grounded report from :class:`DataEnrichmentAgent`.""" - - status: StageStatus - policies: list[FeatureSelectionPolicy] = Field(default_factory=list) - inspections: list[AssayFeatureInspection] = Field(default_factory=list) - studyContextSummary: StudyContextSummary = Field( - default_factory=StudyContextSummary.get_blank - ) - unresolvedQuestions: list[str] = Field(default_factory=list) - limitations: list[str] = Field(default_factory=list) - evidenceIds: list[str] = Field(default_factory=list) - toolCalls: list[DataEnrichmentToolCall] = Field(default_factory=list) - runInfo: AgentRunInfo = Field(default_factory=AgentRunInfo) - - @model_validator(mode="after") - def validate_status(self) -> "DataEnrichmentReport": - if self.status == "done" and not self.policies: - raise ValueError("done reports require at least one feature policy") - if self.status == "needsInput" and not self.unresolvedQuestions: - raise ValueError("needsInput reports require an unresolved question") - if self.status == "failed" and not self.limitations: - raise ValueError("failed reports require a limitation") - return self - - @classmethod - def get_blank(cls) -> "DataEnrichmentReport": - return cls(status="failed", limitations=["No agent result was produced"]) - - @classmethod - def get_example(cls) -> "DataEnrichmentReport": - policy = FeatureSelectionPolicy.get_example() - inspection = AssayFeatureInspection.get_example() - return cls( - status="done", - policies=[policy], - inspections=[inspection], - studyContextSummary=StudyContextSummary.get_example(), - evidenceIds=list(policy.evidenceIds), - toolCalls=[DataEnrichmentToolCall.get_example()], - runInfo=AgentRunInfo.get_example(), - ) - - -class DataEnrichmentDependencies(AgentDataModel): - """Hidden runtime state supplied to read-only enrichment tools.""" - - model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") - - store: Any = Field(default=None, exclude=True) - context: DataEnrichmentContext = Field( - default_factory=DataEnrichmentContext.get_blank - ) - assays: list[str] = Field(default_factory=list) - assayTypes: dict[str, str] = Field(default_factory=dict) - cacheDir: Path | None = None - allowDownload: bool = False - evidenceIds: set[str] = Field(default_factory=set) - inspections: dict[str, AssayFeatureInspection] = Field(default_factory=dict) - confirmedFeatures: dict[str, set[str]] = Field(default_factory=dict) - lookupBatch: FeatureLookupBatch | None = Field(default=None, exclude=True) - lookupQueries: dict[str, list[str]] = Field(default_factory=dict, exclude=True) - toolCalls: list[DataEnrichmentToolCall] = Field(default_factory=list) - - @classmethod - def get_blank(cls) -> "DataEnrichmentDependencies": - return cls() - - @classmethod - def get_example(cls) -> "DataEnrichmentDependencies": - return cls( - context=DataEnrichmentContext.get_example(), - assays=["RNA"], - cacheDir=Path("/tmp/scarf-gene-reference"), - allowDownload=False, - evidenceIds={"context:organism", "context:tissue:0"}, - ) - - -def _prepare_data_enrichment_tool( - ctx: RunContext[DataEnrichmentDependencies], - tool_definition: ToolDefinition, -) -> ToolDefinition | None: - """Expose each batched enrichment tool only while its work is pending.""" - deps = ctx.deps - completed_calls = {call.name for call in deps.toolCalls} - inspection_complete = "inspect_assay_features_batch" in completed_calls - - if tool_definition.name == "inspect_assay_features_batch": - return None if inspection_complete else tool_definition - if tool_definition.name == "find_present_features_batch": - if not inspection_complete or tool_definition.name in completed_calls: - return None - return tool_definition - return tool_definition - - -def _persisted_assay_types(store: Any, assays: Sequence[str]) -> dict[str, str]: - """Read exact persisted assay types through the public datastore summary.""" - summary_method = getattr(store, "summary", None) - if not callable(summary_method): - return {} - summary = summary_method() - requested = set(assays) - return { - str(item.name): str(item.assay_type) - for item in getattr(summary, "assays", ()) - if str(item.name) in requested - } - - -def _assay_modality( - assay_type: str | None, - assay_kind: str, -) -> tuple[ - Literal["RNA", "ATAC", "ADT", "HTO", "unsupported"], - str, - Literal["persisted", "assayClass", "unknown"], -]: - """Map persisted types to supported routes, with a mock-store class fallback.""" - if assay_type is not None: - if assay_type == "RNA": - return "RNA", assay_type, "persisted" - if assay_type == "ATAC": - return "ATAC", assay_type, "persisted" - if assay_type == "ADT": - return "ADT", assay_type, "persisted" - if assay_type == "HTO": - return "HTO", assay_type, "persisted" - return "unsupported", assay_type, "persisted" - if assay_kind == "RNAassay": - return "RNA", assay_kind, "assayClass" - if assay_kind == "ATACassay": - return "ATAC", assay_kind, "assayClass" - if assay_kind: - return "unsupported", assay_kind, "assayClass" - return "unsupported", "Assay", "unknown" - - -def _feature_tokens(*values: str) -> set[str]: - """Return literal alphanumeric tokens without accepting generated patterns.""" - text = " ".join(values).casefold() - normalized = "".join( - character if character.isalnum() else " " for character in text - ) - return set(normalized.split()) - - -def _valid_peak_coordinate(value: str) -> bool: - """Validate the documented ``chrom:start-end`` representation exactly.""" - chromosome, separator, interval = value.partition(":") - if not separator or not chromosome: - return False - start_text, separator, end_text = interval.partition("-") - if not separator or not start_text or not end_text: - return False - try: - start = int(start_text) - end = int(end_text) - except ValueError: - return False - return start >= 0 and end > start - - -def _inspect_adt_features( - assay_name: str, - feature_rows: list[tuple[str, str]], -) -> list[AdtControlEvidence]: - """Return control candidates from exact observed ADT features.""" - candidates: list[AdtControlEvidence] = [] - for feature_id, feature_name in feature_rows: - tokens = _feature_tokens(feature_id, feature_name) - matched_token: Literal["control", "isotype"] | None = None - if "isotype" in tokens: - matched_token = "isotype" - elif "control" in tokens: - matched_token = "control" - if matched_token is None: - continue - candidates.append( - AdtControlEvidence( - featureId=feature_id, - featureName=feature_name, - matchedToken=matched_token, - evidenceId=f"assay:{assay_name}:adtControl:{feature_id}", - ) - ) - return candidates - - -def _inspect_hto_features( - assay_name: str, - feature_rows: list[tuple[str, str]], -) -> list[HtoTagEvidence]: - """Return HTO tag evidence in exact observed order.""" - return [ - HtoTagEvidence( - featureId=feature_id, - featureName=feature_name, - evidenceId=f"assay:{assay_name}:htoTag:{feature_id}", - ) - for feature_id, feature_name in feature_rows - ] - - -def _inspect_atac_features( - assay_name: str, - feature_ids: list[str], -) -> AtacCoordinateEvidence: - """Validate exact observed ATAC coordinates without inferring a build.""" - valid_ids = [value for value in feature_ids if _valid_peak_coordinate(value)] - invalid_ids = [value for value in feature_ids if not _valid_peak_coordinate(value)] - if not feature_ids or not valid_ids: - coordinate_status: Literal["valid", "partial", "invalid"] = "invalid" - elif invalid_ids: - coordinate_status = "partial" - else: - coordinate_status = "valid" - return AtacCoordinateEvidence( - status=coordinate_status, - totalFeatures=len(feature_ids), - validFeatures=len(valid_ids), - validExamples=bounded_list(valid_ids, limit=5), - invalidExamples=bounded_list(invalid_ids, limit=5), - evidenceId=f"assay:{assay_name}:atacCoordinates", - ) - - -def _inspect_modality_features( - *, - assay_name: str, - assay: Any, - assay_type: str | None, - assay_kind: str, - identity: dict[str, Any], -) -> AssayModalityEvidence: - """Build bounded modality evidence from exact observed feature metadata.""" - modality, resolved_type, type_source = _assay_modality(assay_type, assay_kind) - modality_evidence_id = f"assay:{assay_name}:modality" - total_features = int(identity.get("nFeatures", 0)) - evidence_ids = [modality_evidence_id] - graph_eligible = modality in {"RNA", "ATAC", "ADT"} - marker_eligible = graph_eligible - - adt_controls: list[AdtControlEvidence] = [] - hto_tags: list[HtoTagEvidence] = [] - atac_coordinates = AtacCoordinateEvidence.get_blank() - reported_features = 0 - truncated = False - - if modality in {"ADT", "HTO", "ATAC"}: - feature_ids = [str(value) for value in assay.feats.fetch_all("ids")] - total_features = len(feature_ids) - else: - feature_ids = [] - - if modality in {"ADT", "HTO"}: - feature_names = [str(value) for value in assay.feats.fetch_all("names")] - feature_rows = list(zip(feature_ids, feature_names, strict=True)) - else: - feature_rows = [] - - if modality == "ADT": - control_candidates = _inspect_adt_features(assay_name, feature_rows) - adt_controls = bounded_list( - control_candidates, - limit=CONFIG._MAX_FEATURE_QUERIES, - ) - evidence_ids.extend(item.evidenceId for item in adt_controls) - reported_features = len(adt_controls) - truncated = len(control_candidates) > len(adt_controls) - - if modality == "HTO": - limited_rows = bounded_list(feature_rows, limit=CONFIG._MAX_FEATURE_QUERIES) - hto_tags = _inspect_hto_features(assay_name, limited_rows) - evidence_ids.extend(item.evidenceId for item in hto_tags) - reported_features = len(hto_tags) - truncated = len(feature_rows) > len(limited_rows) - - if modality == "ATAC": - atac_coordinates = _inspect_atac_features( - assay_name, - feature_ids, - ) - evidence_ids.append(atac_coordinates.evidenceId) - reported_features = len(atac_coordinates.validExamples) + len( - atac_coordinates.invalidExamples - ) - truncated = len(feature_ids) > reported_features - - return AssayModalityEvidence( - assayType=resolved_type, - modality=modality, - typeSource=type_source, - graphEligible=graph_eligible, - markerEligible=marker_eligible, - demultiplexEligible=modality == "HTO", - adtControls=adt_controls, - htoTags=hto_tags, - atacCoordinates=atac_coordinates, - totalObservedFeatures=total_features, - reportedFeatures=reported_features, - truncated=truncated, - evidenceIds=evidence_ids, - ) - - -async def inspect_assay_features( - ctx: RunContext[DataEnrichmentDependencies], - assay_name: str, -) -> AssayFeatureInspection: - """Inspect feature identity, species evidence, families, and exogenous cues.""" - deps = ctx.deps - if deps.store is None: - raise ModelRetry("The datastore is unavailable") - if assay_name not in deps.assays: - raise ModelRetry( - f"assay_name must be one of the requested assays: {deps.assays}" - ) - cached = deps.inspections.get(assay_name) - if cached is not None: - logger.debug( - f"Data Enrichment reused cached inspection for assay {assay_name!r}" - ) - return cached - - characterization = characterize_features( - deps.store, - studyContext=deps.context.studyContext, - model=None, - assays=[assay_name], - cacheDir=deps.cacheDir, - allowDownload=deps.allowDownload, - ) - if characterization.status != "done" or not characterization.assays: - logger.warning(f"Data Enrichment could not characterize assay {assay_name!r}") - detail = "; ".join(characterization.notes) or "feature inspection failed" - raise ModelRetry(detail) - - record = characterization.assays[0] - family_evidence: list[FeatureFamilyEvidence] = [] - evidence_ids = [f"assay:{assay_name}:identity", f"assay:{assay_name}:species"] - raw_default_inventory = record.get("defaultFeatureInventory") - default_inventory: RnaFeatureInventoryEvidence | None = None - if raw_default_inventory is not None: - default_inventory = RnaFeatureInventoryEvidence.model_validate( - raw_default_inventory - ) - if default_inventory.blacklist != DEFAULT_HVG_BLACKLIST: - raise ModelRetry( - "RNA feature inventory does not use Scarf's exact default HVG blacklist" - ) - evidence_prefix = f"assay:{assay_name}:scarfDefaultHvg" - default_inventory.evidenceId = f"{evidence_prefix}:combined" - for family in default_inventory.families: - family.evidenceId = f"{evidence_prefix}:family:{family.family}" - default_inventory.evidenceIds = [ - default_inventory.evidenceId, - *(family.evidenceId for family in default_inventory.families), - ] - evidence_ids.extend(default_inventory.evidenceIds) - for family in record.get("families", []): - family_name = str(family.get("family", "")) - evidence_id = f"assay:{assay_name}:family:{family_name}" - family_evidence.append( - FeatureFamilyEvidence( - family=family_name, - species=str(family.get("species", record.get("species", "unknown"))), - method=str(family.get("method", "")), - count=int(family.get("count", 0)), - examples=[str(value) for value in family.get("examples", [])], - defaultExclude=family.get("defaultExclude"), - skipped=family.get("skipped"), - catalogSuspect=family.get("catalogSuspect"), - catalogSize=family.get("catalogSize"), - catalogJoinRate=family.get("catalogJoinRate"), - catalogJoined=family.get("catalogJoined"), - evidenceId=evidence_id, - ) - ) - evidence_ids.append(evidence_id) - - exogenous_evidence: list[ExogenousFeatureEvidence] = [] - for item in record.get("exogenous", []): - feature_id = str(item.get("id", "")) - evidence_id = f"assay:{assay_name}:exogenous:{feature_id}" - exogenous_evidence.append( - ExogenousFeatureEvidence( - featureId=feature_id, - featureName=str(item.get("name", "")), - score=int(item.get("score", 0)), - classification=str(item.get("class", "unresolved")), - evidenceId=evidence_id, - ) - ) - evidence_ids.append(evidence_id) - - resolution = record.get("speciesResolution") or {} - assay = deps.store.get_assay(assay_name) - identity = dict(record.get("identity") or {}) - modality_evidence = _inspect_modality_features( - assay_name=assay_name, - assay=assay, - assay_type=deps.assayTypes.get(assay_name), - assay_kind=str(record.get("assayKind", "")), - identity=identity, - ) - evidence_ids.extend(modality_evidence.evidenceIds) - inspection = AssayFeatureInspection( - assay=assay_name, - assayKind=str(record.get("assayKind", "")), - identity=identity, - species=str(record.get("species", "unknown")), - speciesMethod=record.get("speciesMethod"), - speciesReason=str(resolution.get("reason", "")), - families=family_evidence, - defaultFeatureInventory=default_inventory, - exogenous=exogenous_evidence, - modalityEvidence=modality_evidence, - notes=[str(value) for value in record.get("notes", [])], - evidenceIds=evidence_ids, - ) - deps.inspections[assay_name] = inspection - deps.evidenceIds.update(evidence_ids) - deps.toolCalls.append( - DataEnrichmentToolCall( - name="inspect_assay_features", - assay=assay_name, - evidenceIds=evidence_ids, - ) - ) - logger.debug( - "Data Enrichment inspected " - f"assay={assay_name!r}, modality={modality_evidence.modality}, " - f"species={inspection.species}, families={len(family_evidence)}, " - f"defaultHvgMatches=" - f"{default_inventory.matchCount if default_inventory is not None else 0}, " - f"exogenous={len(exogenous_evidence)}, evidence={len(evidence_ids)}" - ) - return inspection - - -async def inspect_assay_features_batch( - ctx: RunContext[DataEnrichmentDependencies], -) -> AssayFeatureInspectionBatch: - """Inspect every requested assay and return one bounded tool result.""" - deps = ctx.deps - if not deps.assays: - raise ModelRetry("No assays were requested") - if any( - call.name == "inspect_assay_features_batch" for call in deps.toolCalls - ) and all(assay_name in deps.inspections for assay_name in deps.assays): - inspections = [deps.inspections[assay_name] for assay_name in deps.assays] - evidence_ids = list( - dict.fromkeys( - evidence_id - for inspection in inspections - for evidence_id in inspection.evidenceIds - ) - ) - logger.info("Data Enrichment reused the completed feature inspection batch") - return AssayFeatureInspectionBatch( - inspections=inspections, - evidenceIds=evidence_ids, - ) - logger.info( - f"Data Enrichment feature inspection started for {len(deps.assays)} assays" - ) - start = len(deps.toolCalls) - try: - inspections = [ - await inspect_assay_features(ctx, assay_name=assay_name) - for assay_name in deps.assays - ] - except Exception: - del deps.toolCalls[start:] - raise - del deps.toolCalls[start:] - evidence_ids = list( - dict.fromkeys( - evidence_id - for inspection in inspections - for evidence_id in inspection.evidenceIds - ) - ) - deps.toolCalls.append( - DataEnrichmentToolCall( - name="inspect_assay_features_batch", - assay=",".join(deps.assays), - evidenceIds=evidence_ids, - ) - ) - supported_routes = sum( - inspection.modalityEvidence.modality != "unsupported" - for inspection in inspections - ) - logger.info( - "Data Enrichment feature inspection completed: " - f"assays={len(inspections)}, supportedRoutes={supported_routes}, " - f"evidence={len(evidence_ids)}" - ) - return AssayFeatureInspectionBatch( - inspections=inspections, - evidenceIds=evidence_ids, - ) - - -async def find_present_features( - ctx: RunContext[DataEnrichmentDependencies], - assay_name: str, - queries: list[str], -) -> FeatureLookupResult: - """Resolve a bounded list of gene IDs or names against one exact assay.""" - deps = ctx.deps - if deps.store is None: - raise ModelRetry("The datastore is unavailable") - if assay_name not in deps.assays: - raise ModelRetry( - f"assay_name must be one of the requested assays: {deps.assays}" - ) - clean_queries = list( - dict.fromkeys(value.strip() for value in queries if value.strip()) - ) - if not clean_queries or len(clean_queries) > CONFIG._MAX_FEATURE_QUERIES: - raise ModelRetry( - f"queries must contain between 1 and {CONFIG._MAX_FEATURE_QUERIES} values" - ) - - assay = deps.store.get_assay(assay_name) - feature_ids = [str(value) for value in assay.feats.fetch_all("ids")] - feature_names = [str(value) for value in assay.feats.fetch_all("names")] - rows = list(zip(feature_ids, feature_names, strict=True)) - results: list[FeatureMatch] = [] - result_evidence_ids: list[str] = [] - confirmed = deps.confirmedFeatures.setdefault(assay_name, set()) - - for query in clean_queries: - exact = [row for row in rows if query in row] - candidates = exact - if not candidates: - folded = query.casefold() - candidates = [ - row - for row in rows - if folded == row[0].casefold() or folded == row[1].casefold() - ] - unique_candidates = bounded_list( - dict.fromkeys(candidates), - limit=10, - ) - references = [ - FeatureReference(featureId=feature_id, featureName=feature_name) - for feature_id, feature_name in unique_candidates - ] - evidence_ids = [ - f"assay:{assay_name}:feature:{reference.featureId}" - for reference in references - ] - if len(references) == 1: - status: Literal["present", "ambiguous", "absent"] = "present" - confirmed.update({references[0].featureId, references[0].featureName}) - deps.evidenceIds.update(evidence_ids) - result_evidence_ids.extend(evidence_ids) - elif references: - status = "ambiguous" - else: - status = "absent" - results.append( - FeatureMatch( - query=query, - status=status, - matches=references, - evidenceIds=evidence_ids if status == "present" else [], - ) - ) - - result = FeatureLookupResult( - assay=assay_name, - results=results, - evidenceIds=list(dict.fromkeys(result_evidence_ids)), - ) - deps.toolCalls.append( - DataEnrichmentToolCall( - name="find_present_features", - assay=assay_name, - evidenceIds=result.evidenceIds, - ) - ) - return result - - -async def find_present_features_batch( - ctx: RunContext[DataEnrichmentDependencies], - queries_by_assay: dict[str, list[str]], -) -> FeatureLookupBatch: - """Resolve all proposed individual features through one model tool call.""" - deps = ctx.deps - unknown_assays = sorted(set(queries_by_assay) - set(deps.assays)) - if unknown_assays: - raise ModelRetry(f"Unknown requested assays: {unknown_assays}") - if not queries_by_assay: - raise ModelRetry("queries_by_assay must contain at least one assay") - clean_queries_by_assay = { - assay_name: list( - dict.fromkeys(value.strip() for value in queries if value.strip()) - ) - for assay_name, queries in queries_by_assay.items() - } - empty_assays = sorted( - assay_name - for assay_name, queries in clean_queries_by_assay.items() - if not queries - ) - if empty_assays: - raise ModelRetry(f"Feature-query batches cannot be empty: {empty_assays}") - query_count = sum(len(queries) for queries in clean_queries_by_assay.values()) - if query_count > CONFIG._MAX_FEATURE_QUERIES: - raise ModelRetry( - "The batch may contain at most " - f"{CONFIG._MAX_FEATURE_QUERIES} feature queries in total" - ) - if deps.lookupBatch is not None: - if clean_queries_by_assay != deps.lookupQueries: - raise ModelRetry( - "Feature lookup already completed. Use only the returned lookup " - "evidence and do not request a different batch." - ) - logger.info("Data Enrichment reused the completed feature lookup batch") - return deps.lookupBatch - - logger.info( - "Data Enrichment feature lookup started: " - f"assays={len(clean_queries_by_assay)}, queries={query_count}" - ) - - start = len(deps.toolCalls) - lookups = [ - await find_present_features( - ctx, - assay_name=assay_name, - queries=clean_queries_by_assay[assay_name], - ) - for assay_name in deps.assays - if assay_name in clean_queries_by_assay - ] - del deps.toolCalls[start:] - evidence_ids = list( - dict.fromkeys( - evidence_id for lookup in lookups for evidence_id in lookup.evidenceIds - ) - ) - deps.toolCalls.append( - DataEnrichmentToolCall( - name="find_present_features_batch", - assay=",".join(queries_by_assay), - evidenceIds=evidence_ids, - ) - ) - result_counts = {"present": 0, "ambiguous": 0, "absent": 0} - for lookup in lookups: - for result in lookup.results: - result_counts[result.status] += 1 - logger.info( - "Data Enrichment feature lookup completed: " - f"present={result_counts['present']}, " - f"ambiguous={result_counts['ambiguous']}, " - f"absent={result_counts['absent']}, evidence={len(evidence_ids)}" - ) - batch = FeatureLookupBatch(lookups=lookups, evidenceIds=evidence_ids) - deps.lookupBatch = batch - deps.lookupQueries = clean_queries_by_assay - return batch - - -def _ground_study_context_summary( - context: DataEnrichmentContext, - proposed: StudyContextSummary, -) -> StudyContextSummary: - """Bind structured context references to exact caller text.""" - original_context = context.studyContext - original_objective = context.studyObjective - grounded_text = f"{original_context}\n{original_objective}" - organism_references = [context.organismHint] if context.organismHint else [] - for species in _SUPPORTED_SPECIES.values(): - match = re.search( - rf"\b{re.escape(species.label)}\b", - grounded_text, - flags=re.IGNORECASE, - ) - if match is not None: - organism_references.append(match.group(0)) - field_sources = { - "organismReferences": organism_references, - "tissueReferences": list(context.tissueReferences), - "cellTypeReferences": list(context.cellTypeReferences), - "experimentalReferences": list(context.experimentalDetails), - "hypothesisReferences": [], - "analysisIntentReferences": [], - } - grounded: dict[str, list[str]] = {} - for field_name, supplied_values in field_sources.items(): - exact_supplied = [value.strip() for value in supplied_values if value.strip()] - proposed_values = list(getattr(proposed, field_name)) - combined = list( - dict.fromkeys( - value.strip() - for value in [*exact_supplied, *proposed_values] - if value.strip() - ) - ) - if len(combined) > 12: - raise ValueError( - f"studyContextSummary.{field_name} may contain at most 12 values" - ) - supplied = set(exact_supplied) - invalid = [ - value - for value in combined - if value not in supplied and value not in grounded_text - ] - if invalid: - raise ValueError( - f"Study-context references must be verbatim caller text: {invalid}" - ) - oversized = [value for value in combined if len(value) > 240] - if oversized: - raise ValueError("Study-context references may not exceed 240 characters") - grounded[field_name] = combined - - evidence_ids: list[str] = [] - if original_context: - evidence_ids.append("context:study") - if original_objective: - evidence_ids.append("context:objective") - if context.organismHint: - evidence_ids.append("context:organism") - evidence_ids.extend( - f"context:tissue:{index}" - for index, _value in enumerate(context.tissueReferences) - ) - evidence_ids.extend( - f"context:cellType:{index}" - for index, _value in enumerate(context.cellTypeReferences) - ) - evidence_ids.extend( - f"context:experiment:{index}" - for index, _value in enumerate(context.experimentalDetails) - ) - return StudyContextSummary( - studyContext=original_context, - studyObjective=original_objective, - **grounded, - evidenceIds=evidence_ids, - ) - - -def _validate_feature_policy( - deps: DataEnrichmentDependencies, - policy: FeatureSelectionPolicy, - grounded_context: StudyContextSummary, -) -> None: - """Ground and validate one feature policy in deterministic order.""" - supported_species = {*_SUPPORTED_SPECIES, "unknown"} - if policy.species not in supported_species: - raise ValueError( - f"unsupported species {policy.species!r}; choose a supported key or unknown" - ) - if not policy.evidenceIds: - raise ValueError(f"policy for assay {policy.assay!r} requires evidence IDs") - policy.organismName = ( - _SUPPORTED_SPECIES[policy.species].label - if policy.species in _SUPPORTED_SPECIES - else "unknown" - ) - inspection = deps.inspections.get(policy.assay) - if inspection is None: - raise ValueError(f"assay {policy.assay!r} was not inspected") - modality = inspection.modalityEvidence - policy.assayType = modality.assayType - policy.assayModality = modality.modality - policy.graphEligible = modality.graphEligible - policy.markerEligible = modality.markerEligible - policy.demultiplexEligible = modality.demultiplexEligible - policy.exactControlFeatures = [ - FeatureReference( - featureId=item.featureId, - featureName=item.featureName, - ) - for item in modality.adtControls - ] - policy.exactTagFeatures = [ - FeatureReference( - featureId=item.featureId, - featureName=item.featureName, - ) - for item in modality.htoTags - ] - policy.peakCoordinateStatus = modality.atacCoordinates.status - policy.evidenceIds = list( - dict.fromkeys([*policy.evidenceIds, *modality.evidenceIds]) - ) - if ( - inspection.species in _SUPPORTED_SPECIES - and policy.species != inspection.species - ): - raise ValueError( - f"policy species {policy.species!r} conflicts with inspected " - f"species {inspection.species!r}" - ) - if ( - inspection.species == "unknown" - and policy.species != "unknown" - and not any( - evidence_id.startswith("context:") for evidence_id in policy.evidenceIds - ) - ): - raise ValueError( - "A context-derived species decision must cite context evidence" - ) - observed_families = {item.family for item in inspection.families} - cited_families = set(policy.excludeFamilies) | set(policy.protectFamilies) - unknown_families = cited_families - observed_families - if unknown_families: - raise ValueError( - f"policy cites unobserved families: {sorted(unknown_families)}" - ) - protected_defaults = { - item.family for item in inspection.families if item.defaultExclude is False - } - excluded_protected = sorted( - set(policy.excludeFamilies).intersection(protected_defaults) - ) - if excluded_protected: - raise ValueError( - "The initial enrichment policy cannot exclude families that " - f"deterministic evidence protects by default: {excluded_protected}" - ) - confirmed = deps.confirmedFeatures.get(policy.assay, set()) - cited_features = { - *policy.excludeFeatures, - *policy.protectFeatures, - *policy.artificialFeatures, - } - unknown_features = cited_features - confirmed - if unknown_features: - raise ValueError( - "Call find_present_features_batch before citing individual features: " - f"{sorted(unknown_features)}" - ) - exogenous_evidence = { - value: item.evidenceId - for item in inspection.exogenous - for value in (item.featureId, item.featureName) - } - unsupported_artificial: list[str] = [] - for feature in policy.artificialFeatures: - evidence_id = exogenous_evidence.get(feature) - if evidence_id is not None and evidence_id in policy.evidenceIds: - continue - matching_context_ids = { - f"context:experiment:{index}" - for index, detail in enumerate(deps.context.experimentalDetails) - if feature.casefold() in detail.casefold() - } - if matching_context_ids.intersection(policy.evidenceIds): - continue - unsupported_artificial.append(feature) - if unsupported_artificial: - raise ValueError( - "Artificial features require their exogenous evidence ID or a " - "feature-specific experimental-context evidence ID: " - f"{sorted(unsupported_artificial)}" - ) - unknown_evidence = set(policy.evidenceIds) - deps.evidenceIds - if unknown_evidence: - raise ValueError( - f"policy cites unknown evidence IDs: {sorted(unknown_evidence)}" - ) - policy.tissueReferences = list(grounded_context.tissueReferences) - policy.cellTypeReferences = list(grounded_context.cellTypeReferences) - policy.experimentalReferences = list(grounded_context.experimentalReferences) - - -def validate_data_enrichment_report( - deps: DataEnrichmentDependencies, - report: DataEnrichmentReport, -) -> DataEnrichmentReport: - """Ground an agent report in inspected assays, context, and exact lookups.""" - if not deps.inspections: - raise ValueError("Inspect every requested assay before returning the report") - - requested = set(deps.assays) - reported = {policy.assay for policy in report.policies} - if len(reported) != len(report.policies): - raise ValueError("reports may contain only one policy for each assay") - if not reported.issubset(requested): - raise ValueError( - f"policies cite assays outside the requested set: {sorted(reported - requested)}" - ) - if report.status == "done" and reported != requested: - raise ValueError( - f"done reports require one policy for every requested assay: {deps.assays}" - ) - - grounded_context = _ground_study_context_summary( - deps.context, - report.studyContextSummary, - ) - for policy in report.policies: - _validate_feature_policy(deps, policy, grounded_context) - - report.studyContextSummary = grounded_context - report.inspections = [deps.inspections[name] for name in deps.assays] - report.toolCalls = list(deps.toolCalls) - report.evidenceIds = list( - dict.fromkeys( - evidence_id - for evidence_id in [ - *report.studyContextSummary.evidenceIds, - *( - evidence_id - for policy in report.policies - for evidence_id in policy.evidenceIds - ), - ] - ) - ) - logger.debug( - "Data Enrichment report validated: " - f"status={report.status}, policies={len(report.policies)}, " - f"inspections={len(report.inspections)}, " - f"toolCalls={len(report.toolCalls)}, evidence={len(report.evidenceIds)}" - ) - return report - - -def pending_data_enrichment_report( - deps: DataEnrichmentDependencies, - *, - error: UnexpectedModelBehavior | UsageLimitExceeded, - model_name: str, -) -> DataEnrichmentReport: - """Pause after deterministic inspection when no valid policy was selected.""" - if set(deps.inspections) != set(deps.assays): - raise error - error_detail = str(error).replace("\n", " ").strip()[:500] - report = DataEnrichmentReport( - status="needsInput", - studyContextSummary=StudyContextSummary.get_blank(), - unresolvedQuestions=[ - "The Data Enrichment agent did not produce a validated feature policy. " - "Provide explicit organism and representation-feature intent." - ], - limitations=[ - "No scientific feature policy was selected after model failure.", - error_detail, - ], - runInfo=AgentRunInfo( - agentName="data_enrichment_needs_input", - modelName=model_name, - ), - ) - validated = validate_data_enrichment_report(deps, report) - logger.warning( - "Data Enrichment paused without a scientific selection: " - f"assays={len(validated.inspections)}, evidence={len(validated.evidenceIds)}, " - f"reason={error_detail}" - ) - return validated - - -def deterministic_data_enrichment_report( - deps: DataEnrichmentDependencies, - *, - error: Exception, - model_name: str, -) -> DataEnrichmentReport: - """Use inspected feature evidence when an unattended model run is invalid.""" - if set(deps.inspections) != set(deps.assays): - raise error - policies = [] - for assay in deps.assays: - inspection = deps.inspections[assay] - evidence_ids = list(inspection.evidenceIds) - if not evidence_ids: - raise ValueError(f"Assay {assay!r} has no deterministic feature evidence") - policies.append( - FeatureSelectionPolicy( - assay=assay, - species=( - inspection.species - if inspection.species in {*_SUPPORTED_SPECIES, "unknown"} - else "unknown" - ), - speciesConfidence=( - "high" if inspection.species in _SUPPORTED_SPECIES else "unknown" - ), - speciesRationale=( - inspection.speciesReason - or "Feature inspection did not resolve a supported species." - ), - excludeFamilies=[ - item.family - for item in inspection.families - if item.defaultExclude is True - ], - protectFamilies=[ - item.family - for item in inspection.families - if item.defaultExclude is False - ], - rationale=( - "Use the exact observed default-exclusion families as the " - "initial representation-sensitivity policy." - ), - evidenceIds=evidence_ids, - ) - ) - summary = StudyContextSummary( - organismReferences=( - [deps.context.organismHint] if deps.context.organismHint else [] - ), - tissueReferences=list(deps.context.tissueReferences), - cellTypeReferences=list(deps.context.cellTypeReferences), - experimentalReferences=list(deps.context.experimentalDetails), - ) - error_detail = str(error).replace("\n", " ").strip()[:500] - report = DataEnrichmentReport( - status="done", - policies=policies, - studyContextSummary=summary, - limitations=[ - "The model feature-policy output was invalid; the workflow used only " - "deterministic assay inspection evidence.", - error_detail, - ], - runInfo=AgentRunInfo( - agentName="data_enrichment_deterministic", - modelName=model_name, - ), - ) - return validate_data_enrichment_report(deps, report) - - -class DataEnrichmentAgent: - """A small read-only tool agent for feature and organism enrichment.""" - - def __init__( - self, - model: Any, - *, - config: AgentRunConfig | None = None, - unattended: bool = False, - ) -> None: - self.model = model - self.unattended = unattended - self.config = (config or AgentRunConfig()).with_limits( - request_limit=8, - tool_call_limit=5, - output_token_limit=32768, - timeout_seconds=600.0, - ) - - def run( - self, - store: Any, - *, - context: DataEnrichmentContext | None = None, - assays: Sequence[str] | None = None, - cache_dir: Path | str | None = None, - allow_download: bool = False, - ) -> DataEnrichmentReport: - """Run the bounded tool loop without mutating the supplied datastore.""" - available_assays = [str(value) for value in store.assay_names] - selected_assays = ( - [str(value) for value in assays] if assays is not None else available_assays - ) - unknown_assays = sorted(set(selected_assays) - set(available_assays)) - if unknown_assays: - raise ValueError(f"unknown assays: {unknown_assays}") - if not selected_assays: - raise ValueError("at least one assay is required") - - logger.info( - "Data Enrichment Agent started: " - f"assays={len(selected_assays)}, allowDownload={allow_download}" - ) - - enrichment_context = context or DataEnrichmentContext.get_blank() - evidence_ids: set[str] = set() - if enrichment_context.studyContext: - evidence_ids.add("context:study") - if enrichment_context.studyObjective: - evidence_ids.add("context:objective") - if enrichment_context.organismHint: - evidence_ids.add("context:organism") - evidence_ids.update( - f"context:tissue:{index}" - for index, _value in enumerate(enrichment_context.tissueReferences) - ) - evidence_ids.update( - f"context:cellType:{index}" - for index, _value in enumerate(enrichment_context.cellTypeReferences) - ) - evidence_ids.update( - f"context:experiment:{index}" - for index, _value in enumerate(enrichment_context.experimentalDetails) - ) - deps = DataEnrichmentDependencies( - store=store, - context=enrichment_context, - assays=selected_assays, - assayTypes=_persisted_assay_types(store, selected_assays), - cacheDir=Path(cache_dir) if cache_dir is not None else None, - allowDownload=allow_download, - evidenceIds=evidence_ids, - ) - user_prompt = ( - dedent( - """ - Enrich the feature policy for assays: {assays}. - Study context: {study_context} - Study objective: {study_objective} - Organism hint: {organism_hint} - Tissue references: {tissue_references} - Cell-type references: {cell_type_references} - Experimental details: {experimental_details} - - Call inspect_assay_features_batch exactly once to inspect every - assay together. If a policy needs individual features, collect all - proposed names for all assays and call - find_present_features_batch exactly once. Do not call a singular - assay tool or split lookups across calls. A batched tool is removed - after it succeeds, so use each call to request all required data. - If no policy needs an individual feature, do not call feature lookup - and keep excludeFeatures, protectFeatures, and artificialFeatures - empty. Return exactly one policy for every requested assay. Copy a - resolved inspection species exactly; otherwise use unknown unless - exact caller context supports a species. Exclude only observed - defaultExclude=true families and protect every observed - defaultExclude=false family. - Populate studyContextSummary only with exact verbatim spans from - the paragraph or caller references. Empty optional hint fields do - not erase references present in the paragraph. Before returning, - verify that every explicit organism, tissue, cell population, - experiment, hypothesis, and analysis intent has been placed in its - corresponding summary list. Leave inspections, modality-derived - fields, exact controls and tags, toolCalls, and report evidence at - their defaults because validation fills them from exact tool state. - """ - ) - .strip() - .format( - assays=", ".join(selected_assays), - study_context=enrichment_context.studyContext or "not provided", - study_objective=enrichment_context.studyObjective or "not provided", - organism_hint=enrichment_context.organismHint or "not provided", - tissue_references=", ".join(enrichment_context.tissueReferences) - or "not provided", - cell_type_references=", ".join(enrichment_context.cellTypeReferences) - or "not provided", - experimental_details=", ".join(enrichment_context.experimentalDetails) - or "not provided", - ) - ) - try: - execution = run_agent_sync( - model=self.model, - output_type=DataEnrichmentReport, - system_prompt=_SYSTEM_PROMPT, - user_prompt=user_prompt, - tools=[ - Tool( - inspect_assay_features_batch, - max_retries=1, - prepare=_prepare_data_enrichment_tool, - sequential=self.config.sequentialTools, - timeout=self.config.timeoutSeconds, - ), - Tool( - find_present_features_batch, - max_retries=1, - prepare=_prepare_data_enrichment_tool, - sequential=self.config.sequentialTools, - timeout=self.config.timeoutSeconds, - ), - ], - deps_type=DataEnrichmentDependencies, - deps=deps, - config=self.config, - name="data_enrichment", - output_validator=lambda report: validate_data_enrichment_report( - deps, - report, - ), - ) - except (UnexpectedModelBehavior, UsageLimitExceeded) as exc: - if set(deps.inspections) != set(deps.assays): - raise - model_name = getattr(self.model, "model_name", type(self.model).__name__) - if self.unattended: - return deterministic_data_enrichment_report( - deps, - error=exc, - model_name=str(model_name), - ) - return pending_data_enrichment_report( - deps, - error=exc, - model_name=str(model_name), - ) - report = DataEnrichmentReport.model_validate(execution.output) - report = validate_data_enrichment_report(deps, report) - if self.unattended and report.status == "needsInput": - model_name = getattr(self.model, "model_name", type(self.model).__name__) - return deterministic_data_enrichment_report( - deps, - error=RuntimeError( - "The model returned an unresolved data-enrichment policy" - ), - model_name=str(model_name), - ) - report.runInfo = execution.runInfo - logger.info( - "Data Enrichment Agent completed: " - f"status={report.status}, policies={len(report.policies)}, " - f"toolCalls={len(report.toolCalls)}, evidence={len(report.evidenceIds)}" - ) - return report diff --git a/scarf/agent/data_enrichment/__init__.py b/scarf/agent/data_enrichment/__init__.py new file mode 100644 index 00000000..3288727c --- /dev/null +++ b/scarf/agent/data_enrichment/__init__.py @@ -0,0 +1,61 @@ +"""Read-only feature and organism enrichment agent.""" + +from .agent import DataEnrichmentAgent +from .contracts import ( + AdtControlEvidence, + AssayFeatureInspection, + AssayFeatureInspectionBatch, + AssayModalityEvidence, + AtacCoordinateEvidence, + DataEnrichmentContext, + DataEnrichmentDependencies, + DataEnrichmentReport, + DataEnrichmentToolCall, + DefaultHvgFamilyEvidence, + ExogenousFeatureEvidence, + FeatureFamilyEvidence, + FeatureLookupBatch, + FeatureLookupResult, + FeatureMatch, + FeatureReference, + FeatureSelectionPolicy, + HtoTagEvidence, + RnaFeatureInventoryEvidence, + StudyContextSummary, +) +from .tools import ( + find_present_features, + find_present_features_batch, + inspect_assay_features, + inspect_assay_features_batch, +) +from .validation import validate_data_enrichment_report + +__all__ = [ + "AdtControlEvidence", + "AssayFeatureInspection", + "AssayFeatureInspectionBatch", + "AssayModalityEvidence", + "AtacCoordinateEvidence", + "DataEnrichmentAgent", + "DataEnrichmentContext", + "DataEnrichmentDependencies", + "DataEnrichmentReport", + "DataEnrichmentToolCall", + "DefaultHvgFamilyEvidence", + "ExogenousFeatureEvidence", + "FeatureFamilyEvidence", + "FeatureLookupResult", + "FeatureLookupBatch", + "FeatureMatch", + "FeatureReference", + "FeatureSelectionPolicy", + "HtoTagEvidence", + "RnaFeatureInventoryEvidence", + "StudyContextSummary", + "find_present_features", + "find_present_features_batch", + "inspect_assay_features", + "inspect_assay_features_batch", + "validate_data_enrichment_report", +] diff --git a/scarf/agent/data_enrichment/agent.py b/scarf/agent/data_enrichment/agent.py new file mode 100644 index 00000000..aa12257e --- /dev/null +++ b/scarf/agent/data_enrichment/agent.py @@ -0,0 +1,283 @@ +"""Data enrichment prompt and agent runner.""" + +from collections.abc import Sequence +from pathlib import Path +from textwrap import dedent +from typing import Any + +from ...utils.logging import logger +from .._deps import AGENT_INSTALL_HINT +from ..config import AgentRunConfig +from ..config.agent_exec import run_agent_sync +from .contracts import ( + DataEnrichmentContext, + DataEnrichmentDependencies, + DataEnrichmentReport, +) +from .tools import ( + _prepare_data_enrichment_tool, + find_present_features_batch, + inspect_assay_features_batch, +) +from .validation import ( + _SUPPORTED_SPECIES, + deterministic_data_enrichment_report, + pending_data_enrichment_report, + validate_data_enrichment_report, +) + +try: + from pydantic_ai import Tool, UnexpectedModelBehavior, UsageLimitExceeded +except ImportError as exc: + raise ImportError(AGENT_INSTALL_HINT) from exc + +_SYSTEM_PROMPT = ( + dedent( + """ + You are Scarf's Data Enrichment Agent. Work only through the supplied + read-only tools. Inspect every requested assay before making a decision. + Use gene identifiers and names together with the supplied organism hint, + tissue references, cell-type references, and experimental details when + species evidence is ambiguous. Supported species keys are: {supported_species}. + + Call inspect_assay_features_batch once for all requested assays. Never + invent a feature. If individual features are needed, collect all proposed + names across assays and call find_present_features_batch once before + placing them in a policy. Do not call feature lookup when no individual + feature decision is needed. Absent or ambiguous lookup results must never + enter a policy. If inspection resolves a supported species, copy that exact + species key. Use caller organism context only when inspection leaves the + species unknown. Use excludeFamilies only to nominate one conditional + representation-sensitivity bundle from observed families with + defaultExclude=true. It is not an instruction to remove those families. + Never nominate a family with defaultExclude=false. + The defaultFeatureInventory is separate deterministic evidence for Scarf's + exact default HVG blacklist. It is evidence only, not an automatic + exclusion or a source of policy nominations. Keep marker eligibility + broader than any graph-feature exclusion. + + Persisted assay types determine modality routes; never infer a route from + an assay label. The validator fills assay type, modality eligibility, ADT + controls, HTO tags, ATAC-coordinate status, inspections, tool calls, and + report-level evidence. Leave those derived fields at their defaults instead + of copying them into the output. Treat Ensembl release misses as unresolved, + not artificial. Mitochondrial, ribosomal, and histone families may be + sensitivity candidates. Sex-linked and cell-cycle families are protected + by default. Marker testing retains conditional biological families. + + Structure studyContextSummary using only verbatim spans from the supplied + study paragraph, study objective, or exact caller references. Do not + paraphrase, infer, or + invent an organism, tissue, cell type, experiment, hypothesis, or analysis + intent. Empty optional hint lists do not mean that the paragraph lacks + those references. When a category is explicitly present in the paragraph, + include its exact span in the corresponding summary list. The validator + binds the original paragraph and exact caller references. Return a bounded + report with citations copied from tool or context evidence IDs. + Do not write code, mutate the datastore, or request arbitrary Scarf calls. + """ + ) + .strip() + .format(supported_species=", ".join(sorted(_SUPPORTED_SPECIES))) +) + + +def _persisted_assay_types(store: Any, assays: Sequence[str]) -> dict[str, str]: + """Read exact persisted assay types through the public datastore summary.""" + summary_method = getattr(store, "summary", None) + if not callable(summary_method): + return {} + summary = summary_method() + requested = set(assays) + return { + str(item.name): str(item.assay_type) + for item in getattr(summary, "assays", ()) + if str(item.name) in requested + } + + +class DataEnrichmentAgent: + """A small read-only tool agent for feature and organism enrichment.""" + + def __init__( + self, + model: Any, + *, + config: AgentRunConfig | None = None, + unattended: bool = False, + ) -> None: + self.model = model + self.unattended = unattended + self.config = (config or AgentRunConfig()).with_limits( + request_limit=8, + tool_call_limit=5, + output_token_limit=32768, + timeout_seconds=600.0, + ) + + def run( + self, + store: Any, + *, + context: DataEnrichmentContext | None = None, + assays: Sequence[str] | None = None, + cache_dir: Path | str | None = None, + allow_download: bool = False, + ) -> DataEnrichmentReport: + """Run the bounded tool loop without mutating the supplied datastore.""" + available_assays = [str(value) for value in store.assay_names] + selected_assays = ( + [str(value) for value in assays] if assays is not None else available_assays + ) + unknown_assays = sorted(set(selected_assays) - set(available_assays)) + if unknown_assays: + raise ValueError(f"unknown assays: {unknown_assays}") + if not selected_assays: + raise ValueError("at least one assay is required") + + logger.info( + "Data Enrichment Agent started: " + f"assays={len(selected_assays)}, allowDownload={allow_download}" + ) + + enrichment_context = context or DataEnrichmentContext.get_blank() + evidence_ids: set[str] = set() + if enrichment_context.studyContext: + evidence_ids.add("context:study") + if enrichment_context.studyObjective: + evidence_ids.add("context:objective") + if enrichment_context.organismHint: + evidence_ids.add("context:organism") + evidence_ids.update( + f"context:tissue:{index}" + for index, _value in enumerate(enrichment_context.tissueReferences) + ) + evidence_ids.update( + f"context:cellType:{index}" + for index, _value in enumerate(enrichment_context.cellTypeReferences) + ) + evidence_ids.update( + f"context:experiment:{index}" + for index, _value in enumerate(enrichment_context.experimentalDetails) + ) + deps = DataEnrichmentDependencies( + store=store, + context=enrichment_context, + assays=selected_assays, + assayTypes=_persisted_assay_types(store, selected_assays), + cacheDir=Path(cache_dir) if cache_dir is not None else None, + allowDownload=allow_download, + evidenceIds=evidence_ids, + ) + user_prompt = ( + dedent( + """ + Enrich the feature policy for assays: {assays}. + Study context: {study_context} + Study objective: {study_objective} + Organism hint: {organism_hint} + Tissue references: {tissue_references} + Cell-type references: {cell_type_references} + Experimental details: {experimental_details} + + Call inspect_assay_features_batch exactly once to inspect every + assay together. If a policy needs individual features, collect all + proposed names for all assays and call + find_present_features_batch exactly once. Do not call a singular + assay tool or split lookups across calls. A batched tool is removed + after it succeeds, so use each call to request all required data. + If no policy needs an individual feature, do not call feature lookup + and keep excludeFeatures, protectFeatures, and artificialFeatures + empty. Return exactly one policy for every requested assay. Copy a + resolved inspection species exactly; otherwise use unknown unless + exact caller context supports a species. Exclude only observed + defaultExclude=true families and protect every observed + defaultExclude=false family. + Populate studyContextSummary only with exact verbatim spans from + the paragraph or caller references. Empty optional hint fields do + not erase references present in the paragraph. Before returning, + verify that every explicit organism, tissue, cell population, + experiment, hypothesis, and analysis intent has been placed in its + corresponding summary list. Leave inspections, modality-derived + fields, exact controls and tags, toolCalls, and report evidence at + their defaults because validation fills them from exact tool state. + """ + ) + .strip() + .format( + assays=", ".join(selected_assays), + study_context=enrichment_context.studyContext or "not provided", + study_objective=enrichment_context.studyObjective or "not provided", + organism_hint=enrichment_context.organismHint or "not provided", + tissue_references=", ".join(enrichment_context.tissueReferences) + or "not provided", + cell_type_references=", ".join(enrichment_context.cellTypeReferences) + or "not provided", + experimental_details=", ".join(enrichment_context.experimentalDetails) + or "not provided", + ) + ) + try: + execution = run_agent_sync( + model=self.model, + output_type=DataEnrichmentReport, + system_prompt=_SYSTEM_PROMPT, + user_prompt=user_prompt, + tools=[ + Tool( + inspect_assay_features_batch, + max_retries=1, + prepare=_prepare_data_enrichment_tool, + sequential=self.config.sequentialTools, + timeout=self.config.timeoutSeconds, + ), + Tool( + find_present_features_batch, + max_retries=1, + prepare=_prepare_data_enrichment_tool, + sequential=self.config.sequentialTools, + timeout=self.config.timeoutSeconds, + ), + ], + deps_type=DataEnrichmentDependencies, + deps=deps, + config=self.config, + name="data_enrichment", + output_validator=lambda report: validate_data_enrichment_report( + deps, + report, + ), + ) + except (UnexpectedModelBehavior, UsageLimitExceeded) as exc: + if set(deps.inspections) != set(deps.assays): + raise + model_name = getattr(self.model, "model_name", type(self.model).__name__) + if self.unattended: + return deterministic_data_enrichment_report( + deps, + error=exc, + model_name=str(model_name), + ) + return pending_data_enrichment_report( + deps, + error=exc, + model_name=str(model_name), + ) + report = DataEnrichmentReport.model_validate(execution.output) + report = validate_data_enrichment_report(deps, report) + if self.unattended and report.status == "needsInput": + model_name = getattr(self.model, "model_name", type(self.model).__name__) + return deterministic_data_enrichment_report( + deps, + error=RuntimeError( + "The model returned an unresolved data-enrichment policy" + ), + model_name=str(model_name), + ) + report.runInfo = execution.runInfo + logger.info( + "Data Enrichment Agent completed: " + f"status={report.status}, policies={len(report.policies)}, " + f"toolCalls={len(report.toolCalls)}, evidence={len(report.evidenceIds)}" + ) + return report diff --git a/scarf/agent/characterize_features.py b/scarf/agent/data_enrichment/characterization.py similarity index 96% rename from scarf/agent/characterize_features.py rename to scarf/agent/data_enrichment/characterization.py index 97e8f60c..9c733aaf 100644 --- a/scarf/agent/characterize_features.py +++ b/scarf/agent/data_enrichment/characterization.py @@ -5,15 +5,15 @@ from pathlib import Path from typing import Any -from ..assay import RNAassay -from ..features.gene_reference import ( +from ...assay import RNAassay +from ...features.gene_reference import ( GeneReference, default_cache_dir, ensure_reference, load_reference, species_registry, ) -from ..features.identity import ( +from ...features.identity import ( audit_feature_identity, backfill_symbols, exogenous_candidates, @@ -21,17 +21,16 @@ reference_misses, resolve_species, ) -from ..features.variability import DEFAULT_HVG_BLACKLIST -from ..quality_control.cell_cycle_genes import ( +from ...features.variability import DEFAULT_HVG_BLACKLIST +from ...quality_control.cell_cycle_genes import ( g2m_phase_genes, g2m_phase_genes_mouse, s_phase_genes, s_phase_genes_mouse, ) -from .config import CONFIG -from .config._deps import AGENT_INSTALL_HINT -from .decide import DecisionValidationError, decide -from .types import AgentDataModel, Decision, EvidenceItem, StageStatus +from .._deps import AGENT_INSTALL_HINT +from ..decisions.selection import DecisionValidationError, decide +from ..types import AgentDataModel, Decision, EvidenceItem, StageStatus try: from pydantic import Field @@ -50,6 +49,9 @@ } _SEX_COEFFICIENT_TOKENS = frozenset({"sex", "gender", "Sex", "Gender"}) _FEATURE_INVENTORY_EXAMPLE_LIMIT = 8 +_CONTEXT_LIMIT = 1200 +_AUTO_DOWNLOAD_SPECIES = frozenset({"homo_sapiens", "mus_musculus"}) +_MAX_EXOGENOUS = 25 _DEFAULT_HVG_FAMILY_PATTERNS = ( ("mitochondrial", r"^MT-"), ("ribosomalProtein", r"^RPS|^RPL"), @@ -91,11 +93,7 @@ def get_example(cls) -> "FeatureCharacterization": def _bounded_context(study_context: str | None) -> str: text = (study_context or "").strip() - return ( - text - if len(text) <= CONFIG._CONTEXT_LIMIT - else text[: CONFIG._CONTEXT_LIMIT - 3] + "..." - ) + return text if len(text) <= _CONTEXT_LIMIT else text[: _CONTEXT_LIMIT - 3] + "..." def _feature_pattern_matches(names: Sequence[str], pattern: str) -> list[str]: @@ -466,7 +464,7 @@ def _characterize_assay( # Only human/mouse auto-download; any other species needs an explicit direction. may_download = allow_download and ( - species in CONFIG._AUTO_DOWNLOAD_SPECIES or directed_species == species + species in _AUTO_DOWNLOAD_SPECIES or directed_species == species ) reference = _load_or_fetch_reference( species, @@ -525,17 +523,17 @@ def _characterize_assay( assay=assay_name, ) - raw_max = directions.get("maxExogenousCandidates", CONFIG._MAX_EXOGENOUS) + raw_max = directions.get("maxExogenousCandidates", _MAX_EXOGENOUS) try: max_exogenous = int(raw_max) except (TypeError, ValueError): _audit( audit_log, kind="invalidDirection", - detail=f"maxExogenousCandidates={raw_max!r}; using {CONFIG._MAX_EXOGENOUS}", + detail=f"maxExogenousCandidates={raw_max!r}; using {_MAX_EXOGENOUS}", assay=assay_name, ) - max_exogenous = CONFIG._MAX_EXOGENOUS + max_exogenous = _MAX_EXOGENOUS if reference is not None: misses = reference_misses(ids, symbols, reference) if misses["count"]: diff --git a/scarf/agent/data_enrichment/contracts.py b/scarf/agent/data_enrichment/contracts.py new file mode 100644 index 00000000..b7c32129 --- /dev/null +++ b/scarf/agent/data_enrichment/contracts.py @@ -0,0 +1,626 @@ +"""Serializable contracts for data enrichment.""" + +from pathlib import Path +from typing import Any, Literal + +from ...features.variability import DEFAULT_HVG_BLACKLIST +from .._deps import AGENT_INSTALL_HINT +from ..types import AgentDataModel, AgentRunInfo, StageStatus + +try: + from pydantic import ConfigDict, Field, model_validator +except ImportError as exc: + raise ImportError(AGENT_INSTALL_HINT) from exc + + +class DataEnrichmentContext(AgentDataModel): + """Study evidence that may help resolve organism and feature policy.""" + + studyContext: str = "" + studyObjective: str = "" + organismHint: str = "" + tissueReferences: list[str] = Field(default_factory=list) + cellTypeReferences: list[str] = Field(default_factory=list) + experimentalDetails: list[str] = Field(default_factory=list) + + @classmethod + def get_blank(cls) -> "DataEnrichmentContext": + return cls() + + @classmethod + def get_example(cls) -> "DataEnrichmentContext": + return cls( + studyContext="Single-cell profiling of treated lung tissue", + studyObjective=( + "Discover stable populations while preserving treatment effects." + ), + organismHint="human", + tissueReferences=["lung"], + cellTypeReferences=["alveolar macrophage", "T cell"], + experimentalDetails=["CRISPR perturbation", "10x 3 prime RNA-seq"], + ) + + +class StudyContextSummary(AgentDataModel): + """Verbatim, evidence-backed references extracted from the study context.""" + + studyContext: str = "" + studyObjective: str = "" + organismReferences: list[str] = Field(default_factory=list) + tissueReferences: list[str] = Field(default_factory=list) + cellTypeReferences: list[str] = Field(default_factory=list) + experimentalReferences: list[str] = Field(default_factory=list) + hypothesisReferences: list[str] = Field(default_factory=list) + analysisIntentReferences: list[str] = Field(default_factory=list) + evidenceIds: list[str] = Field(default_factory=list) + + @classmethod + def get_blank(cls) -> "StudyContextSummary": + return cls() + + @classmethod + def get_example(cls) -> "StudyContextSummary": + return cls( + studyContext=( + "Single-cell profiling of treated human lung tests whether " + "treatment changes alveolar macrophage states." + ), + studyObjective=( + "Discover populations while preserving the treatment comparison." + ), + organismReferences=["human"], + tissueReferences=["lung"], + cellTypeReferences=["alveolar macrophage"], + experimentalReferences=["treated"], + hypothesisReferences=["treatment changes alveolar macrophage states"], + analysisIntentReferences=["Single-cell profiling"], + evidenceIds=["context:study"], + ) + + +class AdtControlEvidence(AgentDataModel): + """One exact observed ADT feature carrying an explicit control token.""" + + featureId: str + featureName: str + matchedToken: Literal["control", "isotype"] + evidenceId: str + + @classmethod + def get_blank(cls) -> "AdtControlEvidence": + return cls( + featureId="", + featureName="", + matchedToken="control", + evidenceId="", + ) + + @classmethod + def get_example(cls) -> "AdtControlEvidence": + return cls( + featureId="Mouse-IgG1-Control", + featureName="Mouse IgG1 isotype control", + matchedToken="isotype", + evidenceId="assay:ADT:adtControl:Mouse-IgG1-Control", + ) + + +class HtoTagEvidence(AgentDataModel): + """One exact feature from an assay persisted with the HTO type.""" + + featureId: str + featureName: str + evidenceId: str + + @classmethod + def get_blank(cls) -> "HtoTagEvidence": + return cls(featureId="", featureName="", evidenceId="") + + @classmethod + def get_example(cls) -> "HtoTagEvidence": + return cls( + featureId="HTO-1", + featureName="Sample tag 1", + evidenceId="assay:HTO:htoTag:HTO-1", + ) + + +class AtacCoordinateEvidence(AgentDataModel): + """Validation evidence for exact ATAC feature IDs as genomic intervals.""" + + status: Literal["notApplicable", "valid", "partial", "invalid"] = "notApplicable" + coordinateColumn: Literal["ids"] = "ids" + coordinateFormat: str = "chrom:start-end" + totalFeatures: int = 0 + validFeatures: int = 0 + invalidExamples: list[str] = Field(default_factory=list) + validExamples: list[str] = Field(default_factory=list) + genomeBuild: Literal["unknown"] = "unknown" + evidenceId: str = "" + + @classmethod + def get_blank(cls) -> "AtacCoordinateEvidence": + return cls() + + @classmethod + def get_example(cls) -> "AtacCoordinateEvidence": + return cls( + status="valid", + totalFeatures=2, + validFeatures=2, + validExamples=["chr1:100-200", "chr2:300-450"], + evidenceId="assay:ATAC:atacCoordinates", + ) + + +class AssayModalityEvidence(AgentDataModel): + """Bounded deterministic routing evidence for one persisted assay type.""" + + assayType: str = "Assay" + modality: Literal["RNA", "ATAC", "ADT", "HTO", "unsupported"] = "unsupported" + typeSource: Literal["persisted", "assayClass", "unknown"] = "unknown" + graphEligible: bool = False + markerEligible: bool = False + demultiplexEligible: bool = False + adtControls: list[AdtControlEvidence] = Field(default_factory=list) + htoTags: list[HtoTagEvidence] = Field(default_factory=list) + atacCoordinates: AtacCoordinateEvidence = Field( + default_factory=AtacCoordinateEvidence.get_blank + ) + totalObservedFeatures: int = 0 + reportedFeatures: int = 0 + truncated: bool = False + evidenceIds: list[str] = Field(default_factory=list) + + @classmethod + def get_blank(cls) -> "AssayModalityEvidence": + return cls() + + @classmethod + def get_example(cls) -> "AssayModalityEvidence": + control = AdtControlEvidence.get_example() + return cls( + assayType="ADT", + modality="ADT", + typeSource="persisted", + graphEligible=True, + markerEligible=True, + adtControls=[control], + totalObservedFeatures=20, + reportedFeatures=1, + evidenceIds=["assay:ADT:modality", control.evidenceId], + ) + + +class FeatureFamilyEvidence(AgentDataModel): + """One observed feature family from deterministic Scarf analysis.""" + + family: str + species: str = "unknown" + method: str = "" + count: int = 0 + examples: list[str] = Field(default_factory=list) + defaultExclude: bool | None = None + skipped: str | None = None + catalogSuspect: str | None = None + catalogSize: int | None = None + catalogJoinRate: float | None = None + catalogJoined: int | None = None + evidenceId: str + + @classmethod + def get_blank(cls) -> "FeatureFamilyEvidence": + return cls(family="", evidenceId="") + + @classmethod + def get_example(cls) -> "FeatureFamilyEvidence": + return cls( + family="mitochondrial", + species="homo_sapiens", + method="chromosome", + count=2, + examples=["MT-CO1", "MT-CYB"], + defaultExclude=True, + evidenceId="assay:RNA:family:mitochondrial", + ) + + +class DefaultHvgFamilyEvidence(AgentDataModel): + """One case-insensitive family within Scarf's default HVG blacklist.""" + + family: str = "" + pattern: str = "" + caseInsensitive: Literal[True] = True + count: int = 0 + examples: list[str] = Field(default_factory=list) + evidenceId: str = "" + + @classmethod + def get_blank(cls) -> "DefaultHvgFamilyEvidence": + return cls() + + @classmethod + def get_example(cls) -> "DefaultHvgFamilyEvidence": + return cls( + family="mitochondrial", + pattern="^MT-", + count=2, + examples=["MT-CO1", "MT-CYB"], + evidenceId="assay:RNA:scarfDefaultHvg:family:mitochondrial", + ) + + +class RnaFeatureInventoryEvidence(AgentDataModel): + """Exact name-column matches for Scarf's default HVG blacklist.""" + + source: Literal["scarfDefaultHvgBlacklist"] = "scarfDefaultHvgBlacklist" + policyEffect: Literal["evidenceOnly"] = "evidenceOnly" + featureColumn: Literal["names"] = "names" + totalFeatures: int = 0 + blacklist: str = "" + matchCount: int = 0 + examples: list[str] = Field(default_factory=list) + families: list[DefaultHvgFamilyEvidence] = Field(default_factory=list) + evidenceId: str = "" + evidenceIds: list[str] = Field(default_factory=list) + + @classmethod + def get_blank(cls) -> "RnaFeatureInventoryEvidence": + return cls() + + @classmethod + def get_example(cls) -> "RnaFeatureInventoryEvidence": + family = DefaultHvgFamilyEvidence.get_example() + evidence_id = "assay:RNA:scarfDefaultHvg:combined" + return cls( + totalFeatures=20_000, + blacklist=DEFAULT_HVG_BLACKLIST, + matchCount=2, + examples=["MT-CO1", "MT-CYB"], + families=[family], + evidenceId=evidence_id, + evidenceIds=[evidence_id, family.evidenceId], + ) + + +class ExogenousFeatureEvidence(AgentDataModel): + """One bounded candidate for an artificial or exogenous feature.""" + + featureId: str + featureName: str + score: int = 0 + classification: str = "unresolved" + evidenceId: str + + @classmethod + def get_blank(cls) -> "ExogenousFeatureEvidence": + return cls(featureId="", featureName="", evidenceId="") + + @classmethod + def get_example(cls) -> "ExogenousFeatureEvidence": + return cls( + featureId="ERCC-00002", + featureName="ERCC-00002", + score=4, + classification="potentialExogenous", + evidenceId="assay:RNA:exogenous:ERCC-00002", + ) + + +class AssayFeatureInspection(AgentDataModel): + """Bounded read-only inspection returned to the model.""" + + assay: str + assayKind: str = "" + identity: dict[str, Any] = Field(default_factory=dict) + species: str = "unknown" + speciesMethod: str | None = None + speciesReason: str = "" + families: list[FeatureFamilyEvidence] = Field(default_factory=list) + defaultFeatureInventory: RnaFeatureInventoryEvidence | None = None + exogenous: list[ExogenousFeatureEvidence] = Field(default_factory=list) + modalityEvidence: AssayModalityEvidence = Field( + default_factory=AssayModalityEvidence.get_blank + ) + notes: list[str] = Field(default_factory=list) + evidenceIds: list[str] = Field(default_factory=list) + + @classmethod + def get_blank(cls) -> "AssayFeatureInspection": + return cls(assay="") + + @classmethod + def get_example(cls) -> "AssayFeatureInspection": + family = FeatureFamilyEvidence.get_example() + default_inventory = RnaFeatureInventoryEvidence.get_example() + modality = AssayModalityEvidence( + assayType="RNA", + modality="RNA", + typeSource="persisted", + graphEligible=True, + markerEligible=True, + totalObservedFeatures=20_000, + evidenceIds=["assay:RNA:modality"], + ) + return cls( + assay="RNA", + assayKind="RNAassay", + identity={"nFeatures": 20_000, "nDuplicateIds": 0}, + species="homo_sapiens", + speciesMethod="ensemblPrefix", + speciesReason="Most feature IDs carry the ENSG prefix", + families=[family], + defaultFeatureInventory=default_inventory, + modalityEvidence=modality, + evidenceIds=[ + "assay:RNA:identity", + "assay:RNA:species", + family.evidenceId, + *default_inventory.evidenceIds, + *modality.evidenceIds, + ], + ) + + +class AssayFeatureInspectionBatch(AgentDataModel): + """All requested assay inspections returned by one model tool call.""" + + inspections: list[AssayFeatureInspection] = Field(default_factory=list) + evidenceIds: list[str] = Field(default_factory=list) + + @classmethod + def get_blank(cls) -> "AssayFeatureInspectionBatch": + return cls() + + @classmethod + def get_example(cls) -> "AssayFeatureInspectionBatch": + inspection = AssayFeatureInspection.get_example() + return cls( + inspections=[inspection], + evidenceIds=list(inspection.evidenceIds), + ) + + +class FeatureReference(AgentDataModel): + """An exact feature identifier and name observed in one assay.""" + + featureId: str + featureName: str + + @classmethod + def get_blank(cls) -> "FeatureReference": + return cls(featureId="", featureName="") + + @classmethod + def get_example(cls) -> "FeatureReference": + return cls(featureId="ENSG00000198727", featureName="MT-CYB") + + +class FeatureMatch(AgentDataModel): + """Resolution of one proposed feature against an assay.""" + + query: str + status: Literal["present", "ambiguous", "absent"] + matches: list[FeatureReference] = Field(default_factory=list) + evidenceIds: list[str] = Field(default_factory=list) + + @classmethod + def get_blank(cls) -> "FeatureMatch": + return cls(query="", status="absent") + + @classmethod + def get_example(cls) -> "FeatureMatch": + return cls( + query="MT-CYB", + status="present", + matches=[FeatureReference.get_example()], + evidenceIds=["assay:RNA:feature:ENSG00000198727"], + ) + + +class FeatureLookupResult(AgentDataModel): + """Bounded result from exact feature lookup.""" + + assay: str + results: list[FeatureMatch] = Field(default_factory=list) + evidenceIds: list[str] = Field(default_factory=list) + + @classmethod + def get_blank(cls) -> "FeatureLookupResult": + return cls(assay="") + + @classmethod + def get_example(cls) -> "FeatureLookupResult": + match = FeatureMatch.get_example() + return cls( + assay="RNA", + results=[match], + evidenceIds=list(match.evidenceIds), + ) + + +class FeatureLookupBatch(AgentDataModel): + """Exact feature lookups for every requested assay in one tool result.""" + + lookups: list[FeatureLookupResult] = Field(default_factory=list) + evidenceIds: list[str] = Field(default_factory=list) + + @classmethod + def get_blank(cls) -> "FeatureLookupBatch": + return cls() + + @classmethod + def get_example(cls) -> "FeatureLookupBatch": + lookup = FeatureLookupResult.get_example() + return cls(lookups=[lookup], evidenceIds=list(lookup.evidenceIds)) + + +class FeatureSelectionPolicy(AgentDataModel): + """Grounded feature policy proposed for one assay.""" + + assay: str + species: str = "unknown" + organismName: str = "unknown" + speciesConfidence: Literal["high", "medium", "low", "unknown"] = "unknown" + speciesRationale: str = "" + excludeFamilies: list[str] = Field(default_factory=list) + protectFamilies: list[str] = Field(default_factory=list) + excludeFeatures: list[str] = Field(default_factory=list) + protectFeatures: list[str] = Field(default_factory=list) + artificialFeatures: list[str] = Field(default_factory=list) + tissueReferences: list[str] = Field(default_factory=list) + cellTypeReferences: list[str] = Field(default_factory=list) + experimentalReferences: list[str] = Field(default_factory=list) + assayType: str = "Assay" + assayModality: Literal["RNA", "ATAC", "ADT", "HTO", "unsupported"] = "unsupported" + graphEligible: bool = False + markerEligible: bool = False + demultiplexEligible: bool = False + exactControlFeatures: list[FeatureReference] = Field(default_factory=list) + exactTagFeatures: list[FeatureReference] = Field(default_factory=list) + peakCoordinateStatus: Literal["notApplicable", "valid", "partial", "invalid"] = ( + "notApplicable" + ) + rationale: str = "" + evidenceIds: list[str] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_non_conflicting_policy(self) -> "FeatureSelectionPolicy": + family_overlap = set(self.excludeFamilies) & set(self.protectFamilies) + feature_overlap = set(self.excludeFeatures) & set(self.protectFeatures) + if family_overlap: + raise ValueError( + "feature families cannot be both excluded and protected: " + f"{sorted(family_overlap)}" + ) + if feature_overlap: + raise ValueError( + "features cannot be both excluded and protected: " + f"{sorted(feature_overlap)}" + ) + return self + + @classmethod + def get_blank(cls) -> "FeatureSelectionPolicy": + return cls(assay="") + + @classmethod + def get_example(cls) -> "FeatureSelectionPolicy": + return cls( + assay="RNA", + species="homo_sapiens", + organismName="human", + speciesConfidence="high", + speciesRationale="Gene IDs and study context agree", + excludeFamilies=["mitochondrial", "ribosomal"], + protectFamilies=["cellCycle", "sex"], + artificialFeatures=["ERCC-00002"], + tissueReferences=["lung"], + cellTypeReferences=["alveolar macrophage"], + experimentalReferences=["ERCC spike-in"], + assayType="RNA", + assayModality="RNA", + graphEligible=True, + markerEligible=True, + rationale="Use technical families for feature-selection exclusions", + evidenceIds=["assay:RNA:species", "assay:RNA:family:mitochondrial"], + ) + + +class DataEnrichmentToolCall(AgentDataModel): + """Compact audit record for one read-only model tool call.""" + + name: str + assay: str + evidenceIds: list[str] = Field(default_factory=list) + + @classmethod + def get_blank(cls) -> "DataEnrichmentToolCall": + return cls(name="", assay="") + + @classmethod + def get_example(cls) -> "DataEnrichmentToolCall": + return cls( + name="inspect_assay_features", + assay="RNA", + evidenceIds=["assay:RNA:identity", "assay:RNA:species"], + ) + + +class DataEnrichmentReport(AgentDataModel): + """Final grounded report from :class:`DataEnrichmentAgent`.""" + + status: StageStatus + policies: list[FeatureSelectionPolicy] = Field(default_factory=list) + inspections: list[AssayFeatureInspection] = Field(default_factory=list) + studyContextSummary: StudyContextSummary = Field( + default_factory=StudyContextSummary.get_blank + ) + unresolvedQuestions: list[str] = Field(default_factory=list) + limitations: list[str] = Field(default_factory=list) + evidenceIds: list[str] = Field(default_factory=list) + toolCalls: list[DataEnrichmentToolCall] = Field(default_factory=list) + runInfo: AgentRunInfo = Field(default_factory=AgentRunInfo) + + @model_validator(mode="after") + def validate_status(self) -> "DataEnrichmentReport": + if self.status == "done" and not self.policies: + raise ValueError("done reports require at least one feature policy") + if self.status == "needsInput" and not self.unresolvedQuestions: + raise ValueError("needsInput reports require an unresolved question") + if self.status == "failed" and not self.limitations: + raise ValueError("failed reports require a limitation") + return self + + @classmethod + def get_blank(cls) -> "DataEnrichmentReport": + return cls(status="failed", limitations=["No agent result was produced"]) + + @classmethod + def get_example(cls) -> "DataEnrichmentReport": + policy = FeatureSelectionPolicy.get_example() + inspection = AssayFeatureInspection.get_example() + return cls( + status="done", + policies=[policy], + inspections=[inspection], + studyContextSummary=StudyContextSummary.get_example(), + evidenceIds=list(policy.evidenceIds), + toolCalls=[DataEnrichmentToolCall.get_example()], + runInfo=AgentRunInfo.get_example(), + ) + + +class DataEnrichmentDependencies(AgentDataModel): + """Hidden runtime state supplied to read-only enrichment tools.""" + + model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") + + store: Any = Field(default=None, exclude=True) + context: DataEnrichmentContext = Field( + default_factory=DataEnrichmentContext.get_blank + ) + assays: list[str] = Field(default_factory=list) + assayTypes: dict[str, str] = Field(default_factory=dict) + cacheDir: Path | None = None + allowDownload: bool = False + evidenceIds: set[str] = Field(default_factory=set) + inspections: dict[str, AssayFeatureInspection] = Field(default_factory=dict) + confirmedFeatures: dict[str, set[str]] = Field(default_factory=dict) + lookupBatch: FeatureLookupBatch | None = Field(default=None, exclude=True) + lookupQueries: dict[str, list[str]] = Field(default_factory=dict, exclude=True) + toolCalls: list[DataEnrichmentToolCall] = Field(default_factory=list) + + @classmethod + def get_blank(cls) -> "DataEnrichmentDependencies": + return cls() + + @classmethod + def get_example(cls) -> "DataEnrichmentDependencies": + return cls( + context=DataEnrichmentContext.get_example(), + assays=["RNA"], + cacheDir=Path("/tmp/scarf-gene-reference"), + allowDownload=False, + evidenceIds={"context:organism", "context:tissue:0"}, + ) diff --git a/scarf/agent/data_enrichment/tools.py b/scarf/agent/data_enrichment/tools.py new file mode 100644 index 00000000..2e60046a --- /dev/null +++ b/scarf/agent/data_enrichment/tools.py @@ -0,0 +1,615 @@ +"""Read-only feature inspection and lookup tools.""" + +from typing import Any, Literal + +from ...features.variability import DEFAULT_HVG_BLACKLIST +from ...utils.logging import logger +from .._deps import AGENT_INSTALL_HINT +from ..tools import bounded_list +from .characterization import characterize_features +from .contracts import ( + AdtControlEvidence, + AssayFeatureInspection, + AssayFeatureInspectionBatch, + AssayModalityEvidence, + AtacCoordinateEvidence, + DataEnrichmentDependencies, + DataEnrichmentToolCall, + ExogenousFeatureEvidence, + FeatureFamilyEvidence, + FeatureLookupBatch, + FeatureLookupResult, + FeatureMatch, + FeatureReference, + HtoTagEvidence, + RnaFeatureInventoryEvidence, +) + +try: + from pydantic_ai import ModelRetry, RunContext + from pydantic_ai.tools import ToolDefinition +except ImportError as exc: + raise ImportError(AGENT_INSTALL_HINT) from exc + +_MAX_FEATURE_QUERIES = 50 + + +def _prepare_data_enrichment_tool( + ctx: RunContext[DataEnrichmentDependencies], + tool_definition: ToolDefinition, +) -> ToolDefinition | None: + """Expose each batched enrichment tool only while its work is pending.""" + deps = ctx.deps + completed_calls = {call.name for call in deps.toolCalls} + inspection_complete = "inspect_assay_features_batch" in completed_calls + + if tool_definition.name == "inspect_assay_features_batch": + return None if inspection_complete else tool_definition + if tool_definition.name == "find_present_features_batch": + if not inspection_complete or tool_definition.name in completed_calls: + return None + return tool_definition + return tool_definition + + +def _assay_modality( + assay_type: str | None, + assay_kind: str, +) -> tuple[ + Literal["RNA", "ATAC", "ADT", "HTO", "unsupported"], + str, + Literal["persisted", "assayClass", "unknown"], +]: + """Map persisted types to supported routes, with a mock-store class fallback.""" + if assay_type is not None: + if assay_type == "RNA": + return "RNA", assay_type, "persisted" + if assay_type == "ATAC": + return "ATAC", assay_type, "persisted" + if assay_type == "ADT": + return "ADT", assay_type, "persisted" + if assay_type == "HTO": + return "HTO", assay_type, "persisted" + return "unsupported", assay_type, "persisted" + if assay_kind == "RNAassay": + return "RNA", assay_kind, "assayClass" + if assay_kind == "ATACassay": + return "ATAC", assay_kind, "assayClass" + if assay_kind: + return "unsupported", assay_kind, "assayClass" + return "unsupported", "Assay", "unknown" + + +def _feature_tokens(*values: str) -> set[str]: + """Return literal alphanumeric tokens without accepting generated patterns.""" + text = " ".join(values).casefold() + normalized = "".join( + character if character.isalnum() else " " for character in text + ) + return set(normalized.split()) + + +def _valid_peak_coordinate(value: str) -> bool: + """Validate the documented ``chrom:start-end`` representation exactly.""" + chromosome, separator, interval = value.partition(":") + if not separator or not chromosome: + return False + start_text, separator, end_text = interval.partition("-") + if not separator or not start_text or not end_text: + return False + try: + start = int(start_text) + end = int(end_text) + except ValueError: + return False + return start >= 0 and end > start + + +def _inspect_adt_features( + assay_name: str, + feature_rows: list[tuple[str, str]], +) -> list[AdtControlEvidence]: + """Return control candidates from exact observed ADT features.""" + candidates: list[AdtControlEvidence] = [] + for feature_id, feature_name in feature_rows: + tokens = _feature_tokens(feature_id, feature_name) + matched_token: Literal["control", "isotype"] | None = None + if "isotype" in tokens: + matched_token = "isotype" + elif "control" in tokens: + matched_token = "control" + if matched_token is None: + continue + candidates.append( + AdtControlEvidence( + featureId=feature_id, + featureName=feature_name, + matchedToken=matched_token, + evidenceId=f"assay:{assay_name}:adtControl:{feature_id}", + ) + ) + return candidates + + +def _inspect_hto_features( + assay_name: str, + feature_rows: list[tuple[str, str]], +) -> list[HtoTagEvidence]: + """Return HTO tag evidence in exact observed order.""" + return [ + HtoTagEvidence( + featureId=feature_id, + featureName=feature_name, + evidenceId=f"assay:{assay_name}:htoTag:{feature_id}", + ) + for feature_id, feature_name in feature_rows + ] + + +def _inspect_atac_features( + assay_name: str, + feature_ids: list[str], +) -> AtacCoordinateEvidence: + """Validate exact observed ATAC coordinates without inferring a build.""" + valid_ids = [value for value in feature_ids if _valid_peak_coordinate(value)] + invalid_ids = [value for value in feature_ids if not _valid_peak_coordinate(value)] + if not feature_ids or not valid_ids: + coordinate_status: Literal["valid", "partial", "invalid"] = "invalid" + elif invalid_ids: + coordinate_status = "partial" + else: + coordinate_status = "valid" + return AtacCoordinateEvidence( + status=coordinate_status, + totalFeatures=len(feature_ids), + validFeatures=len(valid_ids), + validExamples=bounded_list(valid_ids, limit=5), + invalidExamples=bounded_list(invalid_ids, limit=5), + evidenceId=f"assay:{assay_name}:atacCoordinates", + ) + + +def _inspect_modality_features( + *, + assay_name: str, + assay: Any, + assay_type: str | None, + assay_kind: str, + identity: dict[str, Any], +) -> AssayModalityEvidence: + """Build bounded modality evidence from exact observed feature metadata.""" + modality, resolved_type, type_source = _assay_modality(assay_type, assay_kind) + modality_evidence_id = f"assay:{assay_name}:modality" + total_features = int(identity.get("nFeatures", 0)) + evidence_ids = [modality_evidence_id] + graph_eligible = modality in {"RNA", "ATAC", "ADT"} + marker_eligible = graph_eligible + + adt_controls: list[AdtControlEvidence] = [] + hto_tags: list[HtoTagEvidence] = [] + atac_coordinates = AtacCoordinateEvidence.get_blank() + reported_features = 0 + truncated = False + + if modality in {"ADT", "HTO", "ATAC"}: + feature_ids = [str(value) for value in assay.feats.fetch_all("ids")] + total_features = len(feature_ids) + else: + feature_ids = [] + + if modality in {"ADT", "HTO"}: + feature_names = [str(value) for value in assay.feats.fetch_all("names")] + feature_rows = list(zip(feature_ids, feature_names, strict=True)) + else: + feature_rows = [] + + if modality == "ADT": + control_candidates = _inspect_adt_features(assay_name, feature_rows) + adt_controls = bounded_list( + control_candidates, + limit=_MAX_FEATURE_QUERIES, + ) + evidence_ids.extend(item.evidenceId for item in adt_controls) + reported_features = len(adt_controls) + truncated = len(control_candidates) > len(adt_controls) + + if modality == "HTO": + limited_rows = bounded_list(feature_rows, limit=_MAX_FEATURE_QUERIES) + hto_tags = _inspect_hto_features(assay_name, limited_rows) + evidence_ids.extend(item.evidenceId for item in hto_tags) + reported_features = len(hto_tags) + truncated = len(feature_rows) > len(limited_rows) + + if modality == "ATAC": + atac_coordinates = _inspect_atac_features( + assay_name, + feature_ids, + ) + evidence_ids.append(atac_coordinates.evidenceId) + reported_features = len(atac_coordinates.validExamples) + len( + atac_coordinates.invalidExamples + ) + truncated = len(feature_ids) > reported_features + + return AssayModalityEvidence( + assayType=resolved_type, + modality=modality, + typeSource=type_source, + graphEligible=graph_eligible, + markerEligible=marker_eligible, + demultiplexEligible=modality == "HTO", + adtControls=adt_controls, + htoTags=hto_tags, + atacCoordinates=atac_coordinates, + totalObservedFeatures=total_features, + reportedFeatures=reported_features, + truncated=truncated, + evidenceIds=evidence_ids, + ) + + +async def inspect_assay_features( + ctx: RunContext[DataEnrichmentDependencies], + assay_name: str, +) -> AssayFeatureInspection: + """Inspect feature identity, species evidence, families, and exogenous cues.""" + deps = ctx.deps + if deps.store is None: + raise ModelRetry("The datastore is unavailable") + if assay_name not in deps.assays: + raise ModelRetry( + f"assay_name must be one of the requested assays: {deps.assays}" + ) + cached = deps.inspections.get(assay_name) + if cached is not None: + logger.debug( + f"Data Enrichment reused cached inspection for assay {assay_name!r}" + ) + return cached + + characterization = characterize_features( + deps.store, + studyContext=deps.context.studyContext, + model=None, + assays=[assay_name], + cacheDir=deps.cacheDir, + allowDownload=deps.allowDownload, + ) + if characterization.status != "done" or not characterization.assays: + logger.warning(f"Data Enrichment could not characterize assay {assay_name!r}") + detail = "; ".join(characterization.notes) or "feature inspection failed" + raise ModelRetry(detail) + + record = characterization.assays[0] + family_evidence: list[FeatureFamilyEvidence] = [] + evidence_ids = [f"assay:{assay_name}:identity", f"assay:{assay_name}:species"] + raw_default_inventory = record.get("defaultFeatureInventory") + default_inventory: RnaFeatureInventoryEvidence | None = None + if raw_default_inventory is not None: + default_inventory = RnaFeatureInventoryEvidence.model_validate( + raw_default_inventory + ) + if default_inventory.blacklist != DEFAULT_HVG_BLACKLIST: + raise ModelRetry( + "RNA feature inventory does not use Scarf's exact default HVG blacklist" + ) + evidence_prefix = f"assay:{assay_name}:scarfDefaultHvg" + default_inventory.evidenceId = f"{evidence_prefix}:combined" + for family in default_inventory.families: + family.evidenceId = f"{evidence_prefix}:family:{family.family}" + default_inventory.evidenceIds = [ + default_inventory.evidenceId, + *(family.evidenceId for family in default_inventory.families), + ] + evidence_ids.extend(default_inventory.evidenceIds) + for family in record.get("families", []): + family_name = str(family.get("family", "")) + evidence_id = f"assay:{assay_name}:family:{family_name}" + family_evidence.append( + FeatureFamilyEvidence( + family=family_name, + species=str(family.get("species", record.get("species", "unknown"))), + method=str(family.get("method", "")), + count=int(family.get("count", 0)), + examples=[str(value) for value in family.get("examples", [])], + defaultExclude=family.get("defaultExclude"), + skipped=family.get("skipped"), + catalogSuspect=family.get("catalogSuspect"), + catalogSize=family.get("catalogSize"), + catalogJoinRate=family.get("catalogJoinRate"), + catalogJoined=family.get("catalogJoined"), + evidenceId=evidence_id, + ) + ) + evidence_ids.append(evidence_id) + + exogenous_evidence: list[ExogenousFeatureEvidence] = [] + for item in record.get("exogenous", []): + feature_id = str(item.get("id", "")) + evidence_id = f"assay:{assay_name}:exogenous:{feature_id}" + exogenous_evidence.append( + ExogenousFeatureEvidence( + featureId=feature_id, + featureName=str(item.get("name", "")), + score=int(item.get("score", 0)), + classification=str(item.get("class", "unresolved")), + evidenceId=evidence_id, + ) + ) + evidence_ids.append(evidence_id) + + resolution = record.get("speciesResolution") or {} + assay = deps.store.get_assay(assay_name) + identity = dict(record.get("identity") or {}) + modality_evidence = _inspect_modality_features( + assay_name=assay_name, + assay=assay, + assay_type=deps.assayTypes.get(assay_name), + assay_kind=str(record.get("assayKind", "")), + identity=identity, + ) + evidence_ids.extend(modality_evidence.evidenceIds) + inspection = AssayFeatureInspection( + assay=assay_name, + assayKind=str(record.get("assayKind", "")), + identity=identity, + species=str(record.get("species", "unknown")), + speciesMethod=record.get("speciesMethod"), + speciesReason=str(resolution.get("reason", "")), + families=family_evidence, + defaultFeatureInventory=default_inventory, + exogenous=exogenous_evidence, + modalityEvidence=modality_evidence, + notes=[str(value) for value in record.get("notes", [])], + evidenceIds=evidence_ids, + ) + deps.inspections[assay_name] = inspection + deps.evidenceIds.update(evidence_ids) + deps.toolCalls.append( + DataEnrichmentToolCall( + name="inspect_assay_features", + assay=assay_name, + evidenceIds=evidence_ids, + ) + ) + logger.debug( + "Data Enrichment inspected " + f"assay={assay_name!r}, modality={modality_evidence.modality}, " + f"species={inspection.species}, families={len(family_evidence)}, " + f"defaultHvgMatches=" + f"{default_inventory.matchCount if default_inventory is not None else 0}, " + f"exogenous={len(exogenous_evidence)}, evidence={len(evidence_ids)}" + ) + return inspection + + +async def inspect_assay_features_batch( + ctx: RunContext[DataEnrichmentDependencies], +) -> AssayFeatureInspectionBatch: + """Inspect every requested assay and return one bounded tool result.""" + deps = ctx.deps + if not deps.assays: + raise ModelRetry("No assays were requested") + if any( + call.name == "inspect_assay_features_batch" for call in deps.toolCalls + ) and all(assay_name in deps.inspections for assay_name in deps.assays): + inspections = [deps.inspections[assay_name] for assay_name in deps.assays] + evidence_ids = list( + dict.fromkeys( + evidence_id + for inspection in inspections + for evidence_id in inspection.evidenceIds + ) + ) + logger.info("Data Enrichment reused the completed feature inspection batch") + return AssayFeatureInspectionBatch( + inspections=inspections, + evidenceIds=evidence_ids, + ) + logger.info( + f"Data Enrichment feature inspection started for {len(deps.assays)} assays" + ) + start = len(deps.toolCalls) + try: + inspections = [ + await inspect_assay_features(ctx, assay_name=assay_name) + for assay_name in deps.assays + ] + except Exception: + del deps.toolCalls[start:] + raise + del deps.toolCalls[start:] + evidence_ids = list( + dict.fromkeys( + evidence_id + for inspection in inspections + for evidence_id in inspection.evidenceIds + ) + ) + deps.toolCalls.append( + DataEnrichmentToolCall( + name="inspect_assay_features_batch", + assay=",".join(deps.assays), + evidenceIds=evidence_ids, + ) + ) + supported_routes = sum( + inspection.modalityEvidence.modality != "unsupported" + for inspection in inspections + ) + logger.info( + "Data Enrichment feature inspection completed: " + f"assays={len(inspections)}, supportedRoutes={supported_routes}, " + f"evidence={len(evidence_ids)}" + ) + return AssayFeatureInspectionBatch( + inspections=inspections, + evidenceIds=evidence_ids, + ) + + +async def find_present_features( + ctx: RunContext[DataEnrichmentDependencies], + assay_name: str, + queries: list[str], +) -> FeatureLookupResult: + """Resolve a bounded list of gene IDs or names against one exact assay.""" + deps = ctx.deps + if deps.store is None: + raise ModelRetry("The datastore is unavailable") + if assay_name not in deps.assays: + raise ModelRetry( + f"assay_name must be one of the requested assays: {deps.assays}" + ) + clean_queries = list( + dict.fromkeys(value.strip() for value in queries if value.strip()) + ) + if not clean_queries or len(clean_queries) > _MAX_FEATURE_QUERIES: + raise ModelRetry( + f"queries must contain between 1 and {_MAX_FEATURE_QUERIES} values" + ) + + assay = deps.store.get_assay(assay_name) + feature_ids = [str(value) for value in assay.feats.fetch_all("ids")] + feature_names = [str(value) for value in assay.feats.fetch_all("names")] + rows = list(zip(feature_ids, feature_names, strict=True)) + results: list[FeatureMatch] = [] + result_evidence_ids: list[str] = [] + confirmed = deps.confirmedFeatures.setdefault(assay_name, set()) + + for query in clean_queries: + exact = [row for row in rows if query in row] + candidates = exact + if not candidates: + folded = query.casefold() + candidates = [ + row + for row in rows + if folded == row[0].casefold() or folded == row[1].casefold() + ] + unique_candidates = bounded_list( + dict.fromkeys(candidates), + limit=10, + ) + references = [ + FeatureReference(featureId=feature_id, featureName=feature_name) + for feature_id, feature_name in unique_candidates + ] + evidence_ids = [ + f"assay:{assay_name}:feature:{reference.featureId}" + for reference in references + ] + if len(references) == 1: + status: Literal["present", "ambiguous", "absent"] = "present" + confirmed.update({references[0].featureId, references[0].featureName}) + deps.evidenceIds.update(evidence_ids) + result_evidence_ids.extend(evidence_ids) + elif references: + status = "ambiguous" + else: + status = "absent" + results.append( + FeatureMatch( + query=query, + status=status, + matches=references, + evidenceIds=evidence_ids if status == "present" else [], + ) + ) + + result = FeatureLookupResult( + assay=assay_name, + results=results, + evidenceIds=list(dict.fromkeys(result_evidence_ids)), + ) + deps.toolCalls.append( + DataEnrichmentToolCall( + name="find_present_features", + assay=assay_name, + evidenceIds=result.evidenceIds, + ) + ) + return result + + +async def find_present_features_batch( + ctx: RunContext[DataEnrichmentDependencies], + queries_by_assay: dict[str, list[str]], +) -> FeatureLookupBatch: + """Resolve all proposed individual features through one model tool call.""" + deps = ctx.deps + unknown_assays = sorted(set(queries_by_assay) - set(deps.assays)) + if unknown_assays: + raise ModelRetry(f"Unknown requested assays: {unknown_assays}") + if not queries_by_assay: + raise ModelRetry("queries_by_assay must contain at least one assay") + clean_queries_by_assay = { + assay_name: list( + dict.fromkeys(value.strip() for value in queries if value.strip()) + ) + for assay_name, queries in queries_by_assay.items() + } + empty_assays = sorted( + assay_name + for assay_name, queries in clean_queries_by_assay.items() + if not queries + ) + if empty_assays: + raise ModelRetry(f"Feature-query batches cannot be empty: {empty_assays}") + query_count = sum(len(queries) for queries in clean_queries_by_assay.values()) + if query_count > _MAX_FEATURE_QUERIES: + raise ModelRetry( + "The batch may contain at most " + f"{_MAX_FEATURE_QUERIES} feature queries in total" + ) + if deps.lookupBatch is not None: + if clean_queries_by_assay != deps.lookupQueries: + raise ModelRetry( + "Feature lookup already completed. Use only the returned lookup " + "evidence and do not request a different batch." + ) + logger.info("Data Enrichment reused the completed feature lookup batch") + return deps.lookupBatch + + logger.info( + "Data Enrichment feature lookup started: " + f"assays={len(clean_queries_by_assay)}, queries={query_count}" + ) + + start = len(deps.toolCalls) + lookups = [ + await find_present_features( + ctx, + assay_name=assay_name, + queries=clean_queries_by_assay[assay_name], + ) + for assay_name in deps.assays + if assay_name in clean_queries_by_assay + ] + del deps.toolCalls[start:] + evidence_ids = list( + dict.fromkeys( + evidence_id for lookup in lookups for evidence_id in lookup.evidenceIds + ) + ) + deps.toolCalls.append( + DataEnrichmentToolCall( + name="find_present_features_batch", + assay=",".join(queries_by_assay), + evidenceIds=evidence_ids, + ) + ) + result_counts = {"present": 0, "ambiguous": 0, "absent": 0} + for lookup in lookups: + for result in lookup.results: + result_counts[result.status] += 1 + logger.info( + "Data Enrichment feature lookup completed: " + f"present={result_counts['present']}, " + f"ambiguous={result_counts['ambiguous']}, " + f"absent={result_counts['absent']}, evidence={len(evidence_ids)}" + ) + batch = FeatureLookupBatch(lookups=lookups, evidenceIds=evidence_ids) + deps.lookupBatch = batch + deps.lookupQueries = clean_queries_by_assay + return batch diff --git a/scarf/agent/data_enrichment/validation.py b/scarf/agent/data_enrichment/validation.py new file mode 100644 index 00000000..80687783 --- /dev/null +++ b/scarf/agent/data_enrichment/validation.py @@ -0,0 +1,393 @@ +"""Ground and validate data enrichment reports.""" + +import re + +from ...features.gene_reference import species_registry +from ...utils.logging import logger +from .._deps import AGENT_INSTALL_HINT +from ..types import AgentRunInfo +from .contracts import ( + DataEnrichmentContext, + DataEnrichmentDependencies, + DataEnrichmentReport, + FeatureReference, + FeatureSelectionPolicy, + StudyContextSummary, +) + +try: + from pydantic_ai import UnexpectedModelBehavior, UsageLimitExceeded +except ImportError as exc: + raise ImportError(AGENT_INSTALL_HINT) from exc + +_SUPPORTED_SPECIES = species_registry() + + +def _ground_study_context_summary( + context: DataEnrichmentContext, + proposed: StudyContextSummary, +) -> StudyContextSummary: + """Bind structured context references to exact caller text.""" + original_context = context.studyContext + original_objective = context.studyObjective + grounded_text = f"{original_context}\n{original_objective}" + organism_references = [context.organismHint] if context.organismHint else [] + for species in _SUPPORTED_SPECIES.values(): + match = re.search( + rf"\b{re.escape(species.label)}\b", + grounded_text, + flags=re.IGNORECASE, + ) + if match is not None: + organism_references.append(match.group(0)) + field_sources = { + "organismReferences": organism_references, + "tissueReferences": list(context.tissueReferences), + "cellTypeReferences": list(context.cellTypeReferences), + "experimentalReferences": list(context.experimentalDetails), + "hypothesisReferences": [], + "analysisIntentReferences": [], + } + grounded: dict[str, list[str]] = {} + for field_name, supplied_values in field_sources.items(): + exact_supplied = [value.strip() for value in supplied_values if value.strip()] + proposed_values = list(getattr(proposed, field_name)) + combined = list( + dict.fromkeys( + value.strip() + for value in [*exact_supplied, *proposed_values] + if value.strip() + ) + ) + if len(combined) > 12: + raise ValueError( + f"studyContextSummary.{field_name} may contain at most 12 values" + ) + supplied = set(exact_supplied) + invalid = [ + value + for value in combined + if value not in supplied and value not in grounded_text + ] + if invalid: + raise ValueError( + f"Study-context references must be verbatim caller text: {invalid}" + ) + oversized = [value for value in combined if len(value) > 240] + if oversized: + raise ValueError("Study-context references may not exceed 240 characters") + grounded[field_name] = combined + + evidence_ids: list[str] = [] + if original_context: + evidence_ids.append("context:study") + if original_objective: + evidence_ids.append("context:objective") + if context.organismHint: + evidence_ids.append("context:organism") + evidence_ids.extend( + f"context:tissue:{index}" + for index, _value in enumerate(context.tissueReferences) + ) + evidence_ids.extend( + f"context:cellType:{index}" + for index, _value in enumerate(context.cellTypeReferences) + ) + evidence_ids.extend( + f"context:experiment:{index}" + for index, _value in enumerate(context.experimentalDetails) + ) + return StudyContextSummary( + studyContext=original_context, + studyObjective=original_objective, + **grounded, + evidenceIds=evidence_ids, + ) + + +def _validate_feature_policy( + deps: DataEnrichmentDependencies, + policy: FeatureSelectionPolicy, + grounded_context: StudyContextSummary, +) -> None: + """Ground and validate one feature policy in deterministic order.""" + supported_species = {*_SUPPORTED_SPECIES, "unknown"} + if policy.species not in supported_species: + raise ValueError( + f"unsupported species {policy.species!r}; choose a supported key or unknown" + ) + if not policy.evidenceIds: + raise ValueError(f"policy for assay {policy.assay!r} requires evidence IDs") + policy.organismName = ( + _SUPPORTED_SPECIES[policy.species].label + if policy.species in _SUPPORTED_SPECIES + else "unknown" + ) + inspection = deps.inspections.get(policy.assay) + if inspection is None: + raise ValueError(f"assay {policy.assay!r} was not inspected") + modality = inspection.modalityEvidence + policy.assayType = modality.assayType + policy.assayModality = modality.modality + policy.graphEligible = modality.graphEligible + policy.markerEligible = modality.markerEligible + policy.demultiplexEligible = modality.demultiplexEligible + policy.exactControlFeatures = [ + FeatureReference( + featureId=item.featureId, + featureName=item.featureName, + ) + for item in modality.adtControls + ] + policy.exactTagFeatures = [ + FeatureReference( + featureId=item.featureId, + featureName=item.featureName, + ) + for item in modality.htoTags + ] + policy.peakCoordinateStatus = modality.atacCoordinates.status + policy.evidenceIds = list( + dict.fromkeys([*policy.evidenceIds, *modality.evidenceIds]) + ) + if ( + inspection.species in _SUPPORTED_SPECIES + and policy.species != inspection.species + ): + raise ValueError( + f"policy species {policy.species!r} conflicts with inspected " + f"species {inspection.species!r}" + ) + if ( + inspection.species == "unknown" + and policy.species != "unknown" + and not any( + evidence_id.startswith("context:") for evidence_id in policy.evidenceIds + ) + ): + raise ValueError( + "A context-derived species decision must cite context evidence" + ) + observed_families = {item.family for item in inspection.families} + cited_families = set(policy.excludeFamilies) | set(policy.protectFamilies) + unknown_families = cited_families - observed_families + if unknown_families: + raise ValueError( + f"policy cites unobserved families: {sorted(unknown_families)}" + ) + protected_defaults = { + item.family for item in inspection.families if item.defaultExclude is False + } + excluded_protected = sorted( + set(policy.excludeFamilies).intersection(protected_defaults) + ) + if excluded_protected: + raise ValueError( + "The initial enrichment policy cannot exclude families that " + f"deterministic evidence protects by default: {excluded_protected}" + ) + confirmed = deps.confirmedFeatures.get(policy.assay, set()) + cited_features = { + *policy.excludeFeatures, + *policy.protectFeatures, + *policy.artificialFeatures, + } + unknown_features = cited_features - confirmed + if unknown_features: + raise ValueError( + "Call find_present_features_batch before citing individual features: " + f"{sorted(unknown_features)}" + ) + exogenous_evidence = { + value: item.evidenceId + for item in inspection.exogenous + for value in (item.featureId, item.featureName) + } + unsupported_artificial: list[str] = [] + for feature in policy.artificialFeatures: + evidence_id = exogenous_evidence.get(feature) + if evidence_id is not None and evidence_id in policy.evidenceIds: + continue + matching_context_ids = { + f"context:experiment:{index}" + for index, detail in enumerate(deps.context.experimentalDetails) + if feature.casefold() in detail.casefold() + } + if matching_context_ids.intersection(policy.evidenceIds): + continue + unsupported_artificial.append(feature) + if unsupported_artificial: + raise ValueError( + "Artificial features require their exogenous evidence ID or a " + "feature-specific experimental-context evidence ID: " + f"{sorted(unsupported_artificial)}" + ) + unknown_evidence = set(policy.evidenceIds) - deps.evidenceIds + if unknown_evidence: + raise ValueError( + f"policy cites unknown evidence IDs: {sorted(unknown_evidence)}" + ) + policy.tissueReferences = list(grounded_context.tissueReferences) + policy.cellTypeReferences = list(grounded_context.cellTypeReferences) + policy.experimentalReferences = list(grounded_context.experimentalReferences) + + +def validate_data_enrichment_report( + deps: DataEnrichmentDependencies, + report: DataEnrichmentReport, +) -> DataEnrichmentReport: + """Ground an agent report in inspected assays, context, and exact lookups.""" + if not deps.inspections: + raise ValueError("Inspect every requested assay before returning the report") + + requested = set(deps.assays) + reported = {policy.assay for policy in report.policies} + if len(reported) != len(report.policies): + raise ValueError("reports may contain only one policy for each assay") + if not reported.issubset(requested): + raise ValueError( + f"policies cite assays outside the requested set: {sorted(reported - requested)}" + ) + if report.status == "done" and reported != requested: + raise ValueError( + f"done reports require one policy for every requested assay: {deps.assays}" + ) + + grounded_context = _ground_study_context_summary( + deps.context, + report.studyContextSummary, + ) + for policy in report.policies: + _validate_feature_policy(deps, policy, grounded_context) + + report.studyContextSummary = grounded_context + report.inspections = [deps.inspections[name] for name in deps.assays] + report.toolCalls = list(deps.toolCalls) + report.evidenceIds = list( + dict.fromkeys( + evidence_id + for evidence_id in [ + *report.studyContextSummary.evidenceIds, + *( + evidence_id + for policy in report.policies + for evidence_id in policy.evidenceIds + ), + ] + ) + ) + logger.debug( + "Data Enrichment report validated: " + f"status={report.status}, policies={len(report.policies)}, " + f"inspections={len(report.inspections)}, " + f"toolCalls={len(report.toolCalls)}, evidence={len(report.evidenceIds)}" + ) + return report + + +def pending_data_enrichment_report( + deps: DataEnrichmentDependencies, + *, + error: UnexpectedModelBehavior | UsageLimitExceeded, + model_name: str, +) -> DataEnrichmentReport: + """Pause after deterministic inspection when no valid policy was selected.""" + if set(deps.inspections) != set(deps.assays): + raise error + error_detail = str(error).replace("\n", " ").strip()[:500] + report = DataEnrichmentReport( + status="needsInput", + studyContextSummary=StudyContextSummary.get_blank(), + unresolvedQuestions=[ + "The Data Enrichment agent did not produce a validated feature policy. " + "Provide explicit organism and representation-feature intent." + ], + limitations=[ + "No scientific feature policy was selected after model failure.", + error_detail, + ], + runInfo=AgentRunInfo( + agentName="data_enrichment_needs_input", + modelName=model_name, + ), + ) + validated = validate_data_enrichment_report(deps, report) + logger.warning( + "Data Enrichment paused without a scientific selection: " + f"assays={len(validated.inspections)}, evidence={len(validated.evidenceIds)}, " + f"reason={error_detail}" + ) + return validated + + +def deterministic_data_enrichment_report( + deps: DataEnrichmentDependencies, + *, + error: Exception, + model_name: str, +) -> DataEnrichmentReport: + """Use inspected feature evidence when an unattended model run is invalid.""" + if set(deps.inspections) != set(deps.assays): + raise error + policies = [] + for assay in deps.assays: + inspection = deps.inspections[assay] + evidence_ids = list(inspection.evidenceIds) + if not evidence_ids: + raise ValueError(f"Assay {assay!r} has no deterministic feature evidence") + policies.append( + FeatureSelectionPolicy( + assay=assay, + species=( + inspection.species + if inspection.species in {*_SUPPORTED_SPECIES, "unknown"} + else "unknown" + ), + speciesConfidence=( + "high" if inspection.species in _SUPPORTED_SPECIES else "unknown" + ), + speciesRationale=( + inspection.speciesReason + or "Feature inspection did not resolve a supported species." + ), + excludeFamilies=[ + item.family + for item in inspection.families + if item.defaultExclude is True + ], + protectFamilies=[ + item.family + for item in inspection.families + if item.defaultExclude is False + ], + rationale=( + "Use the exact observed default-exclusion families as the " + "initial representation-sensitivity policy." + ), + evidenceIds=evidence_ids, + ) + ) + summary = StudyContextSummary( + organismReferences=( + [deps.context.organismHint] if deps.context.organismHint else [] + ), + tissueReferences=list(deps.context.tissueReferences), + cellTypeReferences=list(deps.context.cellTypeReferences), + experimentalReferences=list(deps.context.experimentalDetails), + ) + error_detail = str(error).replace("\n", " ").strip()[:500] + report = DataEnrichmentReport( + status="done", + policies=policies, + studyContextSummary=summary, + limitations=[ + "The model feature-policy output was invalid; the workflow used only " + "deterministic assay inspection evidence.", + error_detail, + ], + runInfo=AgentRunInfo( + agentName="data_enrichment_deterministic", + modelName=model_name, + ), + ) + return validate_data_enrichment_report(deps, report) diff --git a/scarf/agent/decisions/__init__.py b/scarf/agent/decisions/__init__.py new file mode 100644 index 00000000..a3b9fa0c --- /dev/null +++ b/scarf/agent/decisions/__init__.py @@ -0,0 +1 @@ +"""Decision contracts and registered RNA decision policies.""" diff --git a/scarf/agent/decision_kernel.py b/scarf/agent/decisions/kernel.py similarity index 99% rename from scarf/agent/decision_kernel.py rename to scarf/agent/decisions/kernel.py index ac7876c1..0ff52f97 100644 --- a/scarf/agent/decision_kernel.py +++ b/scarf/agent/decisions/kernel.py @@ -12,8 +12,8 @@ from pydantic import ConfigDict, Field, field_validator, model_validator -from . import record_io -from .types import AgentDataModel, ArtifactReferenceModel +from .. import record_io +from ..types import AgentDataModel, ArtifactReferenceModel type DecisionStatus = Literal["apply", "skip", "defer", "abstain"] type DecisionSource = Literal["rule", "agent", "human"] diff --git a/scarf/agent/rna_decisions.py b/scarf/agent/decisions/rna.py similarity index 99% rename from scarf/agent/rna_decisions.py rename to scarf/agent/decisions/rna.py index 99d6b9da..d36ccf2b 100644 --- a/scarf/agent/rna_decisions.py +++ b/scarf/agent/decisions/rna.py @@ -5,7 +5,8 @@ from pydantic import ConfigDict, Field, field_validator, model_validator -from .decision_kernel import ( +from ..types import AgentDataModel +from .kernel import ( DecisionOption, DecisionRecord, DecisionSpec, @@ -14,7 +15,6 @@ EvidenceBundle, VerificationRecord, ) -from .types import AgentDataModel type RnaDecisionCheckpoint = Literal[ "qcGrouping", diff --git a/scarf/agent/decide.py b/scarf/agent/decisions/selection.py similarity index 98% rename from scarf/agent/decide.py rename to scarf/agent/decisions/selection.py index fe4e7d97..345c38f2 100644 --- a/scarf/agent/decide.py +++ b/scarf/agent/decisions/selection.py @@ -4,8 +4,8 @@ from textwrap import dedent from typing import Any -from .config.agent_exec import run_agent_sync -from .types import Decision, EvidenceItem +from ..config.agent_exec import run_agent_sync +from ..types import Decision, EvidenceItem _SYSTEM_PROMPT = dedent( """ diff --git a/scarf/agent/experimental_context.py b/scarf/agent/experimental_context.py deleted file mode 100644 index 1fd1167f..00000000 --- a/scarf/agent/experimental_context.py +++ /dev/null @@ -1,4394 +0,0 @@ -"""Tool-driven experimental-design and batch-correction assessment.""" - -import json -import math -import re -from collections.abc import Mapping, Sequence -from textwrap import dedent -from typing import TYPE_CHECKING, Any, Literal, cast - -import numpy as np - -from ..graph.feature_projection import graph_cell_selection -from ..metadata.queries import reduce_observation_units -from ..metadata.selection import resolve_cell_aligned_artifact -from ..metrics.association import coefficient_estimability -from ..quality_control.filtering import ( - _validated_sample_labels, - gaussian_quantile_bounds, -) -from ..storage.artifacts import ( - fingerprint_array, - fingerprint_strings, - inspect_artifact, -) -from ..storage.refs import ArtifactRef -from ..storage.selections import read_stored_selection_mask -from ..utils.logging import logger -from .characterize_covariates import ( - CovariateCharacterization, - _SelectionBoundCells, - characterize_covariates, -) -from .config import AgentRunConfig -from .config._deps import AGENT_INSTALL_HINT -from .config.agent_exec import run_agent_sync -from .qc_profiles import ( - AutoFilterProjection, - QcMetricRole, - RegisteredCellQcProfile, - RegisteredQcProjection, - offered_registered_qc_profiles, - project_auto_filter_profile, - qc_metric_execution_name, - registered_qc_metric_role, -) -from .tools import artifact_reference, core_artifact_reference -from .types import ( - AgentDataModel, - AgentRunInfo, - ArtifactReferenceModel, - BatchCorrectionAction, - BatchSafetyEvidence, - BatchSafetyStatus, - ExperimentalBiologyHandoff, - ExperimentalTuningHandoff, - StageStatus, -) - -if TYPE_CHECKING: - from ..datastore.pipeline_run import PipelineRun - -try: - from pydantic import ConfigDict, Field, model_validator - from pydantic_ai import ModelRetry, RunContext, Tool, UnexpectedModelBehavior - from pydantic_ai.tools import ToolDefinition -except ImportError as exc: - raise ImportError(AGENT_INSTALL_HINT) from exc - -__all__ = [ - "BatchCorrectionPlan", - "BatchSafetyEvidence", - "CellQcPlan", - "CellQcProfileEvidence", - "CaptureFailureEvidence", - "ContrastPlan", - "CovariateEvidence", - "ExperimentalContextAgent", - "ExperimentalContextDecision", - "ExperimentalContextDependencies", - "ExperimentalContextResult", - "InferenceUnit", - "NamedArtifactSource", - "QcMetricSourceEvidence", - "QcSourceConcordance", - "RepresentationEvaluation", - "RegisteredCellQcProfile", - "analyze_experimental_design", - "contrast_plans_from_characterization", - "inspect_cell_covariates", - "score_current_representation", - "validate_experimental_context", -] - -type ColumnDomain = Literal["biological", "technical", "design", "ignore", "unknown"] -type IntegrationMetric = Literal[ - "iLISI", - "cLISI", - "graphConnectivity", - "proportionalBatchMixing", -] -type CellQcAction = Literal[ - "skip", - "globalGaussian", - "sampleMad", - "registeredMad", -] -type LegacyCellQcAction = Literal["skip", "globalGaussian", "sampleMad"] -type CellQcDriverType = Literal["RNA", "ATAC"] - -_CONTEXT_LIMIT = 1200 -_MAX_QC_SAMPLE_PROFILES = 4 - - -class InferenceUnit(AgentDataModel): - """Observation and independent units for one biological coefficient.""" - - observationUnit: str | None = None - independentUnit: str | None = None - - @classmethod - def get_blank(cls) -> "InferenceUnit": - return cls() - - @classmethod - def get_example(cls) -> "InferenceUnit": - return cls(observationUnit="sample", independentUnit="donor") - - -class BatchCorrectionPlan(AgentDataModel): - """A grounded recommendation about whether Harmony should be evaluated.""" - - action: BatchCorrectionAction - batchColumns: list[str] = Field(default_factory=list) - preserveColumns: list[str] = Field(default_factory=list) - metricsRequired: list[IntegrationMetric] = Field(default_factory=list) - rationale: str = "" - evidenceIds: list[str] = Field(default_factory=list) - - @classmethod - def get_blank(cls) -> "BatchCorrectionPlan": - return cls(action="needsInput") - - @classmethod - def get_example(cls) -> "BatchCorrectionPlan": - return cls( - action="evaluateHarmony", - batchColumns=["batch"], - preserveColumns=["cell_type", "treatment"], - metricsRequired=[ - "iLISI", - "cLISI", - "graphConnectivity", - ], - rationale=( - "Batch is technical and crossed with treatment, so compare an exact " - "Harmony candidate while protecting biological labels." - ), - evidenceIds=[ - "column:batch", - "estimability:treatment", - "batchEstimability:treatment:batch", - ], - ) - - -class NamedArtifactSource(AgentDataModel): - """One semantic name bound to an exact immutable artifact.""" - - name: str = "" - artifact: ArtifactReferenceModel = Field(default_factory=ArtifactReferenceModel) - - @model_validator(mode="after") - def validate_source(self) -> "NamedArtifactSource": - if self.name != self.name.strip(): - raise ValueError("Artifact source names cannot have surrounding whitespace") - if bool(self.name.strip()) != bool(self.artifact.artifactId): - raise ValueError("A named artifact source requires both name and artifact") - return self - - @classmethod - def get_blank(cls) -> "NamedArtifactSource": - return cls() - - @classmethod - def get_example(cls) -> "NamedArtifactSource": - return cls( - name="RNA_percentMito", - artifact=ArtifactReferenceModel( - assay="RNA", - kind="quality_metric", - artifactId="1" * 64, - ), - ) - - -class QcMetricSourceEvidence(AgentDataModel): - """One source-specific quality metric on the exact active cells.""" - - sourceId: str = "" - metricName: str = "" - metricRole: QcMetricRole = "diagnostic" - assay: str | None = None - sourceType: Literal["metadataColumn", "artifact"] = "metadataColumn" - origin: Literal[ - "ingestionMetadata", - "derivedArtifact", - "externalArtifact", - ] = "ingestionMetadata" - executionName: str = "" - metadataColumn: str | None = None - artifact: ArtifactReferenceModel | None = None - cellSelection: ArtifactReferenceModel | None = None - inputArtifacts: list[ArtifactReferenceModel] = Field(default_factory=list) - provenanceOperation: str | None = None - valuesFingerprint: str = "" - activeCells: int = 0 - missingCells: int = 0 - missingCellsByCapture: dict[str, int] = Field(default_factory=dict) - usableForFiltering: bool = False - notes: list[str] = Field(default_factory=list) - - @model_validator(mode="after") - def validate_source(self) -> "QcMetricSourceEvidence": - if ( - not self.sourceId - and not self.metricName - and self.metadataColumn is None - and self.artifact is None - ): - return self - if self.sourceType == "metadataColumn": - if self.metadataColumn is None or self.artifact is not None: - raise ValueError( - "A metadata QC source requires only metadataColumn provenance" - ) - if self.origin != "ingestionMetadata": - raise ValueError( - "Metadata QC sources must use ingestionMetadata origin" - ) - elif self.artifact is None or self.metadataColumn is not None: - raise ValueError("An artifact QC source requires only artifact provenance") - if self.activeCells < 0 or not 0 <= self.missingCells <= self.activeCells: - raise ValueError("QC source active and missing counts are inconsistent") - if self.usableForFiltering and self.missingCells: - raise ValueError("A QC source with missing values cannot drive filtering") - return self - - -class QcSourceConcordance(AgentDataModel): - """Observed agreement between imported and derived forms of one metric.""" - - metricRole: QcMetricRole = "diagnostic" - leftSourceId: str = "" - rightSourceId: str = "" - comparedCells: int = 0 - missingCells: int = 0 - meanAbsoluteDifference: float | None = None - maximumAbsoluteDifference: float | None = None - pearsonCorrelation: float | None = None - exactlyEqual: bool = False - numericallyClose: bool = False - evidenceId: str = "" - - -class CaptureFailureEvidence(AgentDataModel): - """Multi-axis capture anomaly plus exclusion-safety inputs.""" - - capture: str = "" - activeCells: int = 0 - retainedCells: int = 0 - retainedFraction: float = 0.0 - adverseAxes: list[QcMetricRole] = Field(default_factory=list) - independentAdverseAxes: int = 0 - metricMissingFractions: dict[str, float] = Field(default_factory=dict) - reasons: list[str] = Field(default_factory=list) - wholeCaptureFailure: bool = False - conditionAndUnitSafety: list[dict[str, Any]] = Field(default_factory=list) - preservesConditionCoverage: bool = False - preservesIndependentUnitCoverage: bool = False - exclusionEligible: bool = False - doubletEvidenceIds: list[str] = Field(default_factory=list) - evidenceId: str = "" - - @model_validator(mode="after") - def validate_failure(self) -> "CaptureFailureEvidence": - if self.independentAdverseAxes != len(set(self.adverseAxes)): - raise ValueError("Capture failure axis count must match its unique axes") - if self.wholeCaptureFailure != (self.independentAdverseAxes >= 2): - raise ValueError( - "Whole-capture failure requires at least two independent QC axes" - ) - if self.exclusionEligible and ( - not self.wholeCaptureFailure - or not self.preservesConditionCoverage - or not self.preservesIndependentUnitCoverage - ): - raise ValueError( - "Capture exclusion requires failure and preserved design coverage" - ) - return self - - -type ContrastTest = Literal["mann_whitney", "kruskal_wallis", "wilcoxon"] -type ContrastSampleStatistic = Literal["mean", "median", "fraction"] -type ContrastStatus = Literal["licensed", "blocked", "needsInput"] - - -class ContrastPlan(AgentDataModel): - """One deterministic sample-aware statistical-testing license.""" - - coefficient: str = "" - groupOrder: list[str | int | float | bool] = Field(default_factory=list) - sampleBy: str | None = None - pairBy: str | None = None - test: ContrastTest | None = None - sampleStatistic: ContrastSampleStatistic = "mean" - expressionCutoff: float = 0.0 - status: ContrastStatus = "blocked" - betweenUnitDesign: bool = False - replicationPassed: bool = False - estimabilityPassed: bool = False - pairedCoveragePassed: bool | None = None - replication: dict[str, Any] = Field(default_factory=dict) - estimability: dict[str, Any] = Field(default_factory=dict) - pairedCoverage: dict[str, Any] = Field(default_factory=dict) - blockedReasons: list[str] = Field(default_factory=list) - evidenceId: str = "" - evidenceIds: list[str] = Field(default_factory=list) - - @model_validator(mode="after") - def validate_contrast(self) -> "ContrastPlan": - if self.coefficient != self.coefficient.strip(): - raise ValueError( - "Contrast coefficient cannot contain surrounding whitespace" - ) - if self.sampleBy is not None and ( - not self.sampleBy.strip() or self.sampleBy != self.sampleBy.strip() - ): - raise ValueError("Contrast sampleBy must be a non-empty trimmed name") - if self.pairBy is not None and ( - not self.pairBy.strip() or self.pairBy != self.pairBy.strip() - ): - raise ValueError("Contrast pairBy must be a non-empty trimmed name") - group_keys = [(type(value).__name__, repr(value)) for value in self.groupOrder] - if len(group_keys) != len(set(group_keys)): - raise ValueError("Contrast groupOrder must contain unique values") - if any( - isinstance(value, float) and not math.isfinite(value) - for value in self.groupOrder - ): - raise ValueError("Contrast groupOrder cannot contain non-finite values") - if not math.isfinite(self.expressionCutoff): - raise ValueError("Contrast expressionCutoff must be finite") - if self.sampleStatistic != "fraction" and self.expressionCutoff != 0.0: - raise ValueError( - "Contrast expressionCutoff is only used with fraction summaries" - ) - if self.test == "mann_whitney" and len(self.groupOrder) != 2: - raise ValueError("mann_whitney requires exactly two ordered groups") - if self.test == "kruskal_wallis" and len(self.groupOrder) < 3: - raise ValueError("kruskal_wallis requires at least three ordered groups") - if self.test == "wilcoxon": - if len(self.groupOrder) != 2 or self.pairBy is None: - raise ValueError( - "wilcoxon requires exactly two groups and an explicit pairBy" - ) - elif self.pairBy is not None and self.test is not None: - raise ValueError("A paired contrast must use the wilcoxon test") - if self.status == "licensed": - if ( - not self.coefficient - or self.sampleBy is None - or self.test is None - or self.blockedReasons - or not self.betweenUnitDesign - or not self.replicationPassed - or not self.estimabilityPassed - or (self.pairBy is not None and self.pairedCoveragePassed is not True) - ): - raise ValueError( - "A licensed contrast requires resolved design, replication, " - "estimability, and paired coverage" - ) - elif not self.blockedReasons: - raise ValueError("A non-licensed contrast requires blockedReasons") - return self - - @classmethod - def get_blank(cls) -> "ContrastPlan": - return cls(blockedReasons=["unresolvedContrast"]) - - -def _validate_qc_sources( - *, - action: CellQcAction, - attributes: list[str], - artifact_metrics: list[NamedArtifactSource], - sample_column: str | None, - sample_artifact: NamedArtifactSource | None, - registered_profile: RegisteredCellQcProfile | None = None, - allow_metric_name_collisions: bool = False, -) -> None: - if len(attributes) != len(set(attributes)): - raise ValueError("Cell-QC metadata attributes must be unique") - if any( - not attribute.strip() or attribute != attribute.strip() - for attribute in attributes - ): - raise ValueError( - "Cell-QC metadata attributes cannot be blank or have surrounding whitespace" - ) - artifact_names = [source.name for source in artifact_metrics] - if len(artifact_names) != len(set(artifact_names)): - raise ValueError("Cell-QC artifact metric names must be unique") - if not allow_metric_name_collisions and set(attributes) & set(artifact_names): - raise ValueError( - "Cell-QC metadata and artifact metric names collide; explicitly " - "validated multi-source evidence is required" - ) - if any(source.artifact.kind != "quality_metric" for source in artifact_metrics): - raise ValueError( - "Cell-QC artifactMetrics must reference quality_metric artifacts" - ) - if sample_column is not None and sample_artifact is not None: - raise ValueError( - "Cell-QC sampleColumn and sampleArtifact are mutually exclusive" - ) - if sample_column is not None and ( - not sample_column.strip() or sample_column != sample_column.strip() - ): - raise ValueError( - "Cell-QC sampleColumn cannot be blank or have surrounding whitespace" - ) - if sample_artifact is not None and sample_artifact.artifact.kind != "hto_identity": - raise ValueError( - "Cell-QC sampleArtifact must reference an hto_identity artifact" - ) - if sample_artifact is not None and sample_artifact.name in artifact_names: - raise ValueError("Cell-QC sample and metric artifact names must be distinct") - if registered_profile is not None: - if registered_profile == "retainWithFlags": - if action != "skip": - raise ValueError( - "retainWithFlags must use the non-filtering skip action" - ) - if sample_column is not None or sample_artifact is not None: - raise ValueError("retainWithFlags cannot include a capture source") - return - if action != "registeredMad": - raise ValueError(f"{registered_profile} must use the registeredMad action") - capture_profile = registered_profile in { - "captureMad5", - "captureMad3Sensitivity", - "pooledReferenceMad5", - } - has_one_capture_source = (sample_column is None) != (sample_artifact is None) - if capture_profile and not has_one_capture_source: - raise ValueError( - f"{registered_profile} requires exactly one proven capture source" - ) - if not capture_profile and ( - sample_column is not None or sample_artifact is not None - ): - raise ValueError(f"{registered_profile} cannot include a capture source") - if not attributes and not artifact_metrics: - raise ValueError("Registered MAD filtering requires at least one metric") - return - if action == "registeredMad": - raise ValueError("registeredMad requires a registeredProfile") - if action == "skip" and (attributes or artifact_metrics): - raise ValueError("skip cannot include Cell-QC metrics") - if action != "skip" and not attributes and not artifact_metrics: - raise ValueError("Cell-QC filtering requires at least one metric") - if action == "sampleMad" and (sample_column is None) == (sample_artifact is None): - raise ValueError( - "sampleMad requires exactly one sampleColumn or sampleArtifact" - ) - if action != "sampleMad" and ( - sample_column is not None or sample_artifact is not None - ): - raise ValueError("Only sampleMad can include a sample source") - - -class CellQcProfileEvidence(AgentDataModel): - """Projected retention for one registered or legacy cell-QC profile.""" - - profileId: str = "" - action: CellQcAction = "skip" - registeredProfile: RegisteredCellQcProfile | None = None - driverAssay: str | None = None - driverAssayType: CellQcDriverType | None = None - sampleColumn: str | None = None - sampleArtifact: NamedArtifactSource | None = None - captureColumn: str | None = None - captureArtifact: NamedArtifactSource | None = None - attributes: list[str] = Field(default_factory=list) - artifactMetrics: list[NamedArtifactSource] = Field(default_factory=list) - metricSources: list[QcMetricSourceEvidence] = Field(default_factory=list) - sourceConcordance: list[QcSourceConcordance] = Field(default_factory=list) - parameters: dict[str, Any] = Field(default_factory=dict) - resolvedBounds: dict[str, Any] | list[dict[str, Any]] = Field(default_factory=dict) - activeCells: int = 0 - retainedCells: int = 0 - retainedFraction: float = 0.0 - activeCellsByCapture: dict[str, int] = Field(default_factory=dict) - sampleRetainedCells: dict[str, int] = Field(default_factory=dict) - retainedCellsByColumn: dict[str, dict[str, int]] = Field(default_factory=dict) - unsafeRetentionGroups: list[str] = Field(default_factory=list) - flaggedCells: dict[str, int] = Field(default_factory=dict) - metricFlaggedCells: dict[str, dict[str, int]] = Field(default_factory=dict) - failedCaptureCandidates: list[str] = Field(default_factory=list) - captureFailureEvidence: list[CaptureFailureEvidence] = Field(default_factory=list) - excludableCaptureCandidates: list[str] = Field(default_factory=list) - notes: list[str] = Field(default_factory=list) - evidenceId: str = "" - - @model_validator(mode="after") - def validate_sources(self) -> "CellQcProfileEvidence": - _validate_qc_sources( - action=self.action, - attributes=self.attributes, - artifact_metrics=self.artifactMetrics, - sample_column=self.sampleColumn, - sample_artifact=self.sampleArtifact, - registered_profile=self.registeredProfile, - allow_metric_name_collisions=True, - ) - if self.captureColumn is not None and self.captureArtifact is not None: - raise ValueError( - "Cell-QC captureColumn and captureArtifact are mutually exclusive" - ) - if ( - self.captureArtifact is not None - and self.captureArtifact.artifact.kind != "hto_identity" - ): - raise ValueError( - "Cell-QC captureArtifact must reference an hto_identity artifact" - ) - failures = {item.capture: item for item in self.captureFailureEvidence} - if len(failures) != len(self.captureFailureEvidence): - raise ValueError("Cell-QC capture failure evidence must be unique") - expected_failed = sorted( - capture for capture, item in failures.items() if item.wholeCaptureFailure - ) - if failures and sorted(self.failedCaptureCandidates) != expected_failed: - raise ValueError( - "Cell-QC failed captures must match their multi-axis evidence" - ) - expected_excludable = sorted( - capture for capture, item in failures.items() if item.exclusionEligible - ) - if failures and sorted(self.excludableCaptureCandidates) != expected_excludable: - raise ValueError( - "Cell-QC excludable captures must match design-safety evidence" - ) - return self - - @classmethod - def get_blank(cls) -> "CellQcProfileEvidence": - return cls() - - @classmethod - def get_example(cls) -> "CellQcProfileEvidence": - return cls( - profileId="cellQc:RNA:globalMad5", - action="registeredMad", - registeredProfile="globalMad5", - driverAssay="RNA", - driverAssayType="RNA", - attributes=["RNA_nCounts", "RNA_nFeatures"], - artifactMetrics=[NamedArtifactSource.get_example()], - parameters={"nMads": 5.0}, - activeCells=100, - retainedCells=96, - retainedFraction=0.96, - evidenceId="qcProfile:cellQc:RNA:globalMad5", - ) - - -class CellQcPlan(AgentDataModel): - """A validated selection from the bounded cell-QC profiles.""" - - action: CellQcAction = "skip" - registeredProfile: RegisteredCellQcProfile | None = None - profileId: str = "" - driverAssay: str | None = None - driverAssayType: CellQcDriverType | None = None - sampleColumn: str | None = None - sampleArtifact: NamedArtifactSource | None = None - attributes: list[str] = Field(default_factory=list) - artifactMetrics: list[NamedArtifactSource] = Field(default_factory=list) - rationale: str = "" - evidenceIds: list[str] = Field(default_factory=list) - - @model_validator(mode="after") - def validate_sources(self) -> "CellQcPlan": - _validate_qc_sources( - action=self.action, - attributes=self.attributes, - artifact_metrics=self.artifactMetrics, - sample_column=self.sampleColumn, - sample_artifact=self.sampleArtifact, - registered_profile=self.registeredProfile, - allow_metric_name_collisions=True, - ) - return self - - @classmethod - def get_blank(cls) -> "CellQcPlan": - return cls() - - @classmethod - def get_example(cls) -> "CellQcPlan": - evidence = CellQcProfileEvidence.get_example() - return cls( - action=evidence.action, - registeredProfile=evidence.registeredProfile, - profileId=evidence.profileId, - driverAssay=evidence.driverAssay, - driverAssayType=evidence.driverAssayType, - sampleColumn=evidence.sampleColumn, - sampleArtifact=evidence.sampleArtifact, - attributes=evidence.attributes, - artifactMetrics=evidence.artifactMetrics, - rationale="Use the bounded global profile for the RNA assay.", - evidenceIds=[evidence.evidenceId], - ) - - -class ExperimentalContextDecision(AgentDataModel): - """Model-authored choices that are revalidated against the datastore.""" - - columnDomains: dict[str, ColumnDomain] = Field(default_factory=dict) - coefficientsOfInterest: list[str] = Field(default_factory=list) - unitsOfInference: dict[str, InferenceUnit] = Field(default_factory=dict) - batchCorrection: BatchCorrectionPlan = Field( - default_factory=BatchCorrectionPlan.get_blank - ) - cellQc: CellQcPlan = Field(default_factory=CellQcPlan.get_blank) - rationale: str = "" - evidenceIds: list[str] = Field(default_factory=list) - needsInput: list[str] = Field(default_factory=list) - - @classmethod - def get_blank(cls) -> "ExperimentalContextDecision": - return cls() - - @classmethod - def get_example(cls) -> "ExperimentalContextDecision": - return cls( - columnDomains={ - "batch": "technical", - "sample": "design", - "donor": "design", - "treatment": "biological", - }, - coefficientsOfInterest=["treatment"], - unitsOfInference={"treatment": InferenceUnit.get_example()}, - batchCorrection=BatchCorrectionPlan.get_example(), - rationale="Treatment is the primary between-sample contrast.", - evidenceIds=[ - "column:batch", - "column:donor", - "column:sample", - "column:treatment", - ], - ) - - -class RepresentationEvaluation(AgentDataModel): - """Bounded integration metrics for one exact graph representation.""" - - available: bool = False - assay: str | None = None - cellSelection: ArtifactReferenceModel | None = None - neighbors: ArtifactReferenceModel | None = None - connectivityMap: ArtifactReferenceModel | None = None - metrics: dict[str, float] = Field(default_factory=dict) - notes: list[str] = Field(default_factory=list) - evidenceIds: list[str] = Field(default_factory=list) - - @classmethod - def get_blank(cls) -> "RepresentationEvaluation": - return cls() - - @classmethod - def get_example(cls) -> "RepresentationEvaluation": - return cls( - available=True, - assay="RNA", - cellSelection=ArtifactReferenceModel( - scope="datastore", - kind="cell_selection", - artifactId="c" * 64, - ), - neighbors=ArtifactReferenceModel( - assay="RNA", - kind="neighbors", - artifactId="a" * 64, - ), - connectivityMap=ArtifactReferenceModel( - assay="RNA", - kind="connectivity_map", - artifactId="b" * 64, - ), - metrics={"iLISI:batch": 0.71, "cLISI:cell_type": 0.94}, - evidenceIds=[ - "metric:iLISI:batch:assay:RNA:neighbors:example-neighbors", - "metric:cLISI:cell_type:assay:RNA:neighbors:example-neighbors", - ], - ) - - -class CovariateEvidence(AgentDataModel): - """One deterministic covariate characterization returned by a tool.""" - - characterization: CovariateCharacterization = Field( - default_factory=lambda: CovariateCharacterization(status="needsInput") - ) - batchSafety: list[BatchSafetyEvidence] = Field(default_factory=list) - qcProfiles: list[CellQcProfileEvidence] = Field(default_factory=list) - qcMetricSources: list[QcMetricSourceEvidence] = Field(default_factory=list) - qcSourceConcordance: list[QcSourceConcordance] = Field(default_factory=list) - contrastPlans: list[ContrastPlan] = Field(default_factory=list) - htoIdentityColumns: list[str] = Field(default_factory=list) - htoIdentityArtifacts: list[NamedArtifactSource] = Field(default_factory=list) - evidenceIds: list[str] = Field(default_factory=list) - - @classmethod - def get_example(cls) -> "CovariateEvidence": - return cls( - characterization=CovariateCharacterization( - status="done", - notes=["Example deterministic covariate characterization"], - ), - qcProfiles=[CellQcProfileEvidence.get_example()], - htoIdentityColumns=["sample_id"], - htoIdentityArtifacts=[ - NamedArtifactSource( - name="HTO_htoIdentity", - artifact=ArtifactReferenceModel( - assay="HTO", - kind="hto_identity", - artifactId="2" * 64, - ), - ) - ], - evidenceIds=[ - "column:batch", - CellQcProfileEvidence.get_example().evidenceId, - "htoIdentity:sample_id", - f"htoIdentityArtifact:HTO_htoIdentity:{'2' * 64}", - ], - ) - - -class ExperimentalContextResult(AgentDataModel): - """Canonical experimental-context report returned to the caller.""" - - status: StageStatus - decision: ExperimentalContextDecision - characterization: CovariateCharacterization - cellSelection: ArtifactReferenceModel | None = None - cellQc: CellQcPlan = Field(default_factory=CellQcPlan.get_blank) - qcProfiles: list[CellQcProfileEvidence] = Field(default_factory=list) - qcMetricSources: list[QcMetricSourceEvidence] = Field(default_factory=list) - qcSourceConcordance: list[QcSourceConcordance] = Field(default_factory=list) - contrastPlans: list[ContrastPlan] = Field(default_factory=list) - qualityMetricArtifacts: list[NamedArtifactSource] = Field(default_factory=list) - htoIdentityColumns: list[str] = Field(default_factory=list) - htoIdentityArtifacts: list[NamedArtifactSource] = Field(default_factory=list) - batchSafety: list[BatchSafetyEvidence] = Field(default_factory=list) - currentRepresentation: RepresentationEvaluation = Field( - default_factory=RepresentationEvaluation.get_blank - ) - notes: list[str] = Field(default_factory=list) - runInfo: AgentRunInfo = Field(default_factory=AgentRunInfo) - - @classmethod - def get_blank(cls) -> "ExperimentalContextResult": - return cls( - status="needsInput", - decision=ExperimentalContextDecision.get_blank(), - characterization=CovariateCharacterization(status="needsInput"), - ) - - @classmethod - def get_example(cls) -> "ExperimentalContextResult": - representation = RepresentationEvaluation.get_example() - return cls( - status="done", - decision=ExperimentalContextDecision.get_example(), - characterization=CovariateCharacterization( - status="done", - notes=["Example deterministic design characterization"], - ), - cellSelection=representation.cellSelection, - qcProfiles=[CellQcProfileEvidence.get_example()], - qualityMetricArtifacts=[NamedArtifactSource.get_example()], - htoIdentityColumns=["sample_id"], - htoIdentityArtifacts=[ - NamedArtifactSource( - name="HTO_htoIdentity", - artifact=ArtifactReferenceModel( - assay="HTO", - kind="hto_identity", - artifactId="2" * 64, - ), - ) - ], - batchSafety=[BatchSafetyEvidence.get_example()], - currentRepresentation=representation, - runInfo=AgentRunInfo.get_example(), - ) - - def to_parameter_tuning_handoff(self) -> ExperimentalTuningHandoff: - """Return validated integration inputs for Parameter Tuning.""" - if self.status != "done": - raise ValueError( - "Experimental Context must be done before creating a tuning handoff" - ) - if self.cellSelection is None: - raise ValueError("Experimental Context result lacks a cell selection") - plan = self.decision.batchCorrection - batch_columns = sorted(plan.batchColumns) - safety = sorted( - ( - item - for item in self.batchSafety - if item.batchColumns == batch_columns - and item.coefficient in self.decision.coefficientsOfInterest - ), - key=lambda item: item.coefficient, - ) - if plan.action in {"evaluateHarmony", "unsafe"}: - expected = set(self.decision.coefficientsOfInterest) - if {item.coefficient for item in safety} != expected: - raise ValueError( - "Experimental Context result lacks exact batch safety evidence" - ) - if any(item.evidenceId not in plan.evidenceIds for item in safety): - raise ValueError( - "Batch-correction plan does not cite its exact safety evidence" - ) - if plan.action == "evaluateHarmony" and any( - item.status != "safe" for item in safety - ): - raise ValueError("Harmony plan contains non-safe batch evidence") - if plan.action == "unsafe" and ( - any(item.status == "notComputed" for item in safety) - or not any(item.status == "unsafe" for item in safety) - ): - raise ValueError("Unsafe plan lacks exact unsafe batch evidence") - return ExperimentalTuningHandoff( - cellSelection=self.cellSelection, - batchAction=plan.action, - batchColumns=batch_columns, - preservationColumns=list(plan.preserveColumns), - coefficientsOfInterest=list(self.decision.coefficientsOfInterest), - batchSafety=safety, - evidenceIds=sorted({*self.decision.evidenceIds, *plan.evidenceIds}), - ) - - def to_biological_handoff( - self, - coefficient: str | None = None, - ) -> ExperimentalBiologyHandoff: - """Return one explicitly resolved biological coefficient.""" - if self.status != "done": - raise ValueError( - "Experimental Context must be done before creating a biology handoff" - ) - if self.cellSelection is None: - raise ValueError("Experimental Context result lacks a cell selection") - coefficients = list(self.decision.coefficientsOfInterest) - if coefficient is None: - if len(coefficients) != 1: - raise ValueError( - "Select one coefficient explicitly for biological interpretation" - ) - coefficient = coefficients[0] - if coefficient not in coefficients: - raise ValueError(f"Unknown coefficient of interest {coefficient!r}") - records = { - record.get("name"): record - for record in self.characterization.coefficients - if isinstance(record.get("name"), str) - } - record = records.get(coefficient) - if record is None: - raise ValueError(f"Missing characterization for {coefficient!r}") - reports = { - report.get("coefficient"): report - for report in self.characterization.confounding - if isinstance(report.get("coefficient"), str) - } - report = reports.get(coefficient) - known_evidence = characterization_evidence(self.characterization) - relevant_evidence = { - f"column:{coefficient}", - f"coefficient:{coefficient}", - f"estimability:{coefficient}", - *( - evidence_id - for evidence_id in known_evidence - if evidence_id.startswith(f"confounding:{coefficient}:") - ), - } - for unit_name in ( - record.get("observationUnit"), - record.get("independentUnit"), - ): - if isinstance(unit_name, str): - relevant_evidence.add(f"column:{unit_name}") - return ExperimentalBiologyHandoff( - cellSelection=self.cellSelection, - conditionColumn=coefficient, - observationUnit=record.get("observationUnit"), - independentUnit=record.get("independentUnit"), - coefficientScope=str(record.get("scope", "")), - estimability=dict(report.get("estimability") or {}) if report else {}, - evidenceIds=sorted(relevant_evidence.intersection(known_evidence)), - ) - - -class ExperimentalContextDependencies(AgentDataModel): - """Runtime-only state shared by the agent's read-only tools.""" - - model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") - - store: Any = Field(default=None, exclude=True) - cells: Any = Field(default=None, exclude=True) - neighbors: Any = Field(default=None, exclude=True) - connectivityMap: Any = Field(default=None, exclude=True) - cellSelection: Any = Field(default=None, exclude=True) - studyContext: str = "" - studyObjective: str = "" - directions: dict[str, Any] = Field(default_factory=dict) - evidenceIds: set[str] = Field(default_factory=set) - characterization: CovariateCharacterization | None = None - batchSafety: dict[str, BatchSafetyEvidence] = Field(default_factory=dict) - qcProfiles: dict[str, CellQcProfileEvidence] = Field(default_factory=dict) - qcMetricSources: list[QcMetricSourceEvidence] = Field(default_factory=list) - qcSourceConcordance: list[QcSourceConcordance] = Field(default_factory=list) - contrastPlans: dict[str, ContrastPlan] = Field(default_factory=dict) - htoIdentityColumns: list[str] = Field(default_factory=list) - qualityMetricArtifacts: list[NamedArtifactSource] = Field(default_factory=list) - htoIdentityArtifacts: list[NamedArtifactSource] = Field(default_factory=list) - currentRepresentation: RepresentationEvaluation = Field( - default_factory=RepresentationEvaluation.get_blank - ) - toolCalls: list[str] = Field(default_factory=list) - - @classmethod - def get_blank(cls) -> "ExperimentalContextDependencies": - return cls() - - @classmethod - def get_example(cls) -> "ExperimentalContextDependencies": - return cls( - studyContext="Case-control study with samples nested in donors.", - studyObjective=( - "Discover populations while preserving the case-control contrast." - ), - directions={"columnDomains": {"batch": "technical"}}, - ) - - -def _prepare_experimental_context_tool( - ctx: RunContext[ExperimentalContextDependencies], - tool_definition: ToolDefinition, -) -> ToolDefinition | None: - """Expose each context tool once and in its required dependency order.""" - completed_calls = set(ctx.deps.toolCalls) - if tool_definition.name == "inspect_cell_covariates": - return None if tool_definition.name in completed_calls else tool_definition - if tool_definition.name == "analyze_experimental_design": - if ( - "inspect_cell_covariates" not in completed_calls - or tool_definition.name in completed_calls - ): - return None - return tool_definition - if tool_definition.name == "score_current_representation": - if ( - "analyze_experimental_design" not in completed_calls - or tool_definition.name in completed_calls - ): - return None - characterization = ctx.deps.characterization - if characterization is not None and not any( - record.get("domain") == "technical" and record.get("kind") == "categorical" - for record in characterization.columns - ): - return None - return tool_definition - return tool_definition - - -def characterization_evidence( - characterization: CovariateCharacterization, -) -> set[str]: - """Build stable evidence IDs from one deterministic characterization.""" - evidence_ids = { - f"column:{record['name']}" - for record in characterization.columns - if isinstance(record.get("name"), str) - } - for record in characterization.coefficients: - coefficient = record.get("name") - if isinstance(coefficient, str): - evidence_ids.add(f"coefficient:{coefficient}") - for report in characterization.confounding: - coefficient = report.get("coefficient") - if not isinstance(coefficient, str): - continue - evidence_ids.add(f"estimability:{coefficient}") - for pair in report.get("pairs", []): - technical = pair.get("technical") - if isinstance(technical, str): - evidence_ids.add(f"confounding:{coefficient}:{technical}") - return evidence_ids - - -def contrast_plans_from_characterization( - characterization: CovariateCharacterization, -) -> list[ContrastPlan]: - """Build deterministic test licenses from bounded coefficient evidence.""" - reports = { - report.get("coefficient"): report - for report in characterization.confounding - if isinstance(report.get("coefficient"), str) - } - plans: list[ContrastPlan] = [] - for record in characterization.coefficients: - coefficient = record.get("name") - if not isinstance(coefficient, str): - continue - report = reports.get(coefficient, {}) - raw_groups = record.get("groupOrder") - group_order = ( - list(raw_groups) - if isinstance(raw_groups, list) - and all( - isinstance(value, str | int | float | bool) - and not (isinstance(value, float) and not math.isfinite(value)) - for value in raw_groups - ) - else [] - ) - sample_by = record.get("observationUnit") - sample_by = sample_by if isinstance(sample_by, str) else None - independent_unit = record.get("independentUnit") - independent_unit = ( - independent_unit if isinstance(independent_unit, str) else None - ) - between_unit = record.get("scope") == "betweenUnit" - replication = dict(record.get("replication") or {}) - replication_passed = replication.get("sufficient") is True - estimability = dict( - record.get("estimability") or report.get("estimability") or {} - ) - estimability_passed = ( - estimability.get("status") == "ok" - and estimability.get("coefficientEstimable") is True - and estimability.get("rankDeficient") is not True - ) - paired_coverage = dict(record.get("pairedCoverage") or {}) - pair_by: str | None = None - paired_passed: bool | None = None - mixed_independent_design = False - if independent_unit is not None: - if paired_coverage.get("complete") is True: - pair_by = independent_unit - paired_passed = True - elif paired_coverage.get("betweenIndependentUnits") is True: - sample_by = independent_unit - else: - pair_by = independent_unit - paired_passed = False - mixed_independent_design = True - - reasons: list[str] = [] - needs_input = False - if record.get("kind") != "categorical": - reasons.append("coefficientRequiresExplicitCategoricalGroups") - needs_input = True - if not between_unit: - reasons.append("coefficientIsNotBetweenUnit") - if sample_by is None: - reasons.append("sampleByIsUnresolved") - needs_input = True - if len(group_order) < 2: - reasons.append("fewerThanTwoObservedGroups") - needs_input = True - if record.get("groupCountsTruncated") is True: - reasons.append("groupOrderIsTruncated") - needs_input = True - if not replication_passed: - reasons.append("insufficientIndependentReplication") - if not estimability_passed: - reasons.append("coefficientIsNotEstimable") - - test: ContrastTest | None = None - if pair_by is not None: - if len(group_order) != 2: - reasons.append("pairedTestsRequireExactlyTwoGroups") - else: - test = "wilcoxon" - if paired_passed is not True: - reasons.append("pairedCoverageIsIncomplete") - elif mixed_independent_design: - reasons.append("independentUnitStructureIsMixed") - elif len(group_order) == 2: - test = "mann_whitney" - elif len(group_order) >= 3: - test = "kruskal_wallis" - - reasons = list(dict.fromkeys(reasons)) - status: ContrastStatus = ( - "licensed" if not reasons else "needsInput" if needs_input else "blocked" - ) - evidence_ids = [ - f"column:{coefficient}", - f"coefficient:{coefficient}", - f"estimability:{coefficient}", - ] - plans.append( - ContrastPlan( - coefficient=coefficient, - groupOrder=group_order, - sampleBy=sample_by, - pairBy=pair_by, - test=test, - status=status, - betweenUnitDesign=between_unit, - replicationPassed=replication_passed, - estimabilityPassed=estimability_passed, - pairedCoveragePassed=paired_passed, - replication=replication, - estimability=estimability, - pairedCoverage=paired_coverage, - blockedReasons=reasons, - evidenceId=f"contrastPlan:{coefficient}:{status}", - evidenceIds=evidence_ids, - ) - ) - return plans - - -def _persisted_assay_type(store: Any, assay_name: str) -> str: - """Read one persisted assay type without inferring modality from features.""" - root = getattr(store, "zw", None) - attrs = getattr(root, "attrs", {}) - raw_types = attrs.get("assayTypes", {}) if isinstance(attrs, Mapping) else {} - if isinstance(raw_types, Mapping): - assay_type = raw_types.get(assay_name) - if isinstance(assay_type, str): - return assay_type - return assay_name if assay_name in {"RNA", "ATAC", "ADT", "HTO"} else "Assay" - - -def _qc_driver(store: Any) -> tuple[str, CellQcDriverType] | None: - """Choose the first RNA assay, otherwise the first ATAC assay.""" - assay_names = [str(name) for name in getattr(store, "assay_names", [])] - for assay_type in ("RNA", "ATAC"): - for assay_name in assay_names: - if _persisted_assay_type(store, assay_name) == assay_type: - return assay_name, assay_type - return None - - -def _hto_identity_columns(deps: ExperimentalContextDependencies) -> list[str]: - """Return explicitly supplied imported HTO identity metadata columns.""" - requested: list[str] = [] - directed_many = deps.directions.get("htoIdentityColumns") - if isinstance(directed_many, list | tuple): - requested.extend(str(value) for value in directed_many) - directed_one = deps.directions.get("htoIdentityColumn") - if isinstance(directed_one, str): - requested.append(directed_one) - available = set(deps.store.cells.columns) - return list(dict.fromkeys(name for name in requested if name in available)) - - -def _cell_selection_ref(deps: ExperimentalContextDependencies) -> ArtifactRef: - selection = core_artifact_reference(deps.cellSelection) - if not isinstance(selection, ArtifactRef): - raise ValueError("cellSelection must identify an exact artifact") - if selection.kind != "cell_selection" or selection.scope != "datastore": - raise ValueError("cellSelection must identify a datastore cell selection") - return selection - - -def _active_cell_count(deps: ExperimentalContextDependencies) -> int: - selection = _cell_selection_ref(deps) - active = read_stored_selection_mask( - deps.store.zw, - selection, - kind="cell_selection", - scope="datastore", - assay=None, - table_path="cellData", - ) - if active.ndim != 1 or active.shape[0] != deps.store.cells.N: - raise ValueError( - "cellSelection must contain an aligned boolean selection vector" - ) - return int(active.sum()) - - -def _source_ref( - source: NamedArtifactSource, - *, - expected_kind: str, -) -> ArtifactRef: - if not isinstance(source, NamedArtifactSource): - raise TypeError("Artifact sources must be NamedArtifactSource values") - if not source.name.strip(): - raise ValueError("Artifact sources require a non-empty semantic name") - artifact = core_artifact_reference(source.artifact) - if not isinstance(artifact, ArtifactRef) or artifact.kind != expected_kind: - raise ValueError( - f"Artifact source {source.name!r} must reference {expected_kind!r}" - ) - return artifact - - -def _artifact_evidence_id(source: NamedArtifactSource) -> str: - return f"htoIdentityArtifact:{source.name}:{source.artifact.artifactId}" - - -def _hto_artifact_map( - deps: ExperimentalContextDependencies, -) -> dict[str, ArtifactRef]: - artifacts: dict[str, ArtifactRef] = {} - for source in deps.htoIdentityArtifacts: - if source.name in artifacts: - raise ValueError("HTO identity artifact names must be unique") - artifacts[source.name] = _source_ref( - source, - expected_kind="hto_identity", - ) - return artifacts - - -def _resolved_artifact_values( - deps: ExperimentalContextDependencies, - source: NamedArtifactSource, - *, - expected_kind: str, -) -> np.ndarray: - resolved = resolve_cell_aligned_artifact( - deps.store.zw, - _source_ref(source, expected_kind=expected_kind), - cell_selection=_cell_selection_ref(deps), - expected_kind=expected_kind, - ) - return np.asarray(resolved.values) - - -def _artifact_input_references( - value: Any, - *, - limit: int = 16, -) -> list[ArtifactReferenceModel]: - refs: list[ArtifactReferenceModel] = [] - seen: set[tuple[str, str | None, str, str]] = set() - - def visit(item: Any) -> None: - if len(refs) >= limit: - return - if isinstance(item, ArtifactRef): - ref = item - elif isinstance(item, Mapping) and { - "scope", - "kind", - "artifact_id", - }.issubset(item): - try: - ref = ArtifactRef.from_dict(item) - except (KeyError, TypeError, ValueError): - ref = None - else: - ref = None - if ref is not None: - key = (ref.scope, ref.assay, ref.kind, ref.artifact_id) - if key not in seen: - seen.add(key) - refs.append(artifact_reference(ref)) - return - if isinstance(item, Mapping): - for nested in item.values(): - visit(nested) - elif isinstance(item, list | tuple): - for nested in item: - visit(nested) - - visit(value) - return refs - - -def _qc_metric_sources( - deps: ExperimentalContextDependencies, - driver: tuple[str, CellQcDriverType], -) -> tuple[ - dict[str, np.ndarray], - list[str], - list[NamedArtifactSource], - list[QcMetricSourceEvidence], - list[QcSourceConcordance], - list[str], - dict[str, np.ndarray], -]: - assay_name, assay_type = driver - del assay_type - selection = _cell_selection_ref(deps) - selection_model = artifact_reference(selection) - active_cells = _active_cell_count(deps) - metadata_names = _qc_attributes(deps.store, assay_name, driver[1]) - artifact_candidates: list[NamedArtifactSource] = [] - for source in deps.qualityMetricArtifacts: - artifact = _source_ref(source, expected_kind="quality_metric") - if artifact.assay == assay_name: - artifact_candidates.append(source) - metadata_collisions = set(metadata_names).intersection( - source.name for source in artifact_candidates - ) - - values_by_execution_name: dict[str, np.ndarray] = {} - values_by_source: dict[str, np.ndarray] = {} - sources: list[QcMetricSourceEvidence] = [] - valid_metadata: list[str] = [] - valid_artifacts: list[NamedArtifactSource] = [] - notes: list[str] = [] - - for name in metadata_names: - raw = np.asarray(deps.cells.fetch(name)) - try: - values = np.asarray(raw, dtype=float) - except (TypeError, ValueError): - fingerprint = fingerprint_strings(raw) - source_id = f"qcMetric:metadata:{assay_name}:{name}:{fingerprint}" - sources.append( - QcMetricSourceEvidence( - sourceId=source_id, - metricName=name, - metricRole=registered_qc_metric_role(name), - assay=assay_name, - sourceType="metadataColumn", - origin="ingestionMetadata", - executionName=name, - metadataColumn=name, - cellSelection=selection_model, - valuesFingerprint=fingerprint, - activeCells=active_cells, - missingCells=active_cells, - notes=["Metric is not numeric and cannot drive filtering"], - ) - ) - notes.append(f"QC metadata source {name!r} is not numeric") - continue - if values.ndim != 1 or values.shape != (active_cells,): - raise ValueError( - f"QC metadata source {name!r} does not align with cellSelection" - ) - fingerprint = fingerprint_array(values) - missing = int((~np.isfinite(values)).sum()) - source_id = f"qcMetric:metadata:{assay_name}:{name}:{fingerprint}" - usable = missing == 0 - source_notes = ( - [] if usable else [f"{missing} active cells have non-finite metric values"] - ) - sources.append( - QcMetricSourceEvidence( - sourceId=source_id, - metricName=name, - metricRole=registered_qc_metric_role(name), - assay=assay_name, - sourceType="metadataColumn", - origin="ingestionMetadata", - executionName=name, - metadataColumn=name, - cellSelection=selection_model, - valuesFingerprint=fingerprint, - activeCells=active_cells, - missingCells=missing, - usableForFiltering=usable, - notes=source_notes, - ) - ) - values_by_source[source_id] = values - if usable: - values_by_execution_name[name] = values - valid_metadata.append(name) - else: - notes.extend(source_notes) - - for source in artifact_candidates: - artifact = _source_ref(source, expected_kind="quality_metric") - values = np.asarray( - _resolved_artifact_values( - deps, - source, - expected_kind="quality_metric", - ), - dtype=float, - ) - if values.ndim != 1 or values.shape != (active_cells,): - raise ValueError( - f"QC artifact {source.name!r} does not align with cellSelection" - ) - execution_name = qc_metric_execution_name( - source.name, - artifact_id=artifact.artifact_id, - collides_with_metadata=source.name in metadata_collisions, - ) - if execution_name in values_by_execution_name: - raise ValueError( - f"QC execution metric name {execution_name!r} is not unique" - ) - fingerprint = fingerprint_array(values) - missing = int((~np.isfinite(values)).sum()) - status = inspect_artifact(deps.store.zw, artifact) - operation = status.operation - origin: Literal[ - "ingestionMetadata", - "derivedArtifact", - "externalArtifact", - ] = ( - "derivedArtifact" - if operation == "run_feature_percentage" - else "externalArtifact" - ) - source_id = ( - f"qcMetric:artifact:{artifact.assay}:{source.name}:{artifact.artifact_id}" - ) - usable = missing == 0 - source_notes = ( - [] if usable else [f"{missing} active cells have non-finite metric values"] - ) - sources.append( - QcMetricSourceEvidence( - sourceId=source_id, - metricName=source.name, - metricRole=registered_qc_metric_role(source.name), - assay=assay_name, - sourceType="artifact", - origin=origin, - executionName=execution_name, - artifact=artifact_reference(artifact), - cellSelection=selection_model, - inputArtifacts=_artifact_input_references(status.inputs or {}), - provenanceOperation=operation, - valuesFingerprint=fingerprint, - activeCells=active_cells, - missingCells=missing, - usableForFiltering=usable, - notes=source_notes, - ) - ) - values_by_source[source_id] = values - if usable: - values_by_execution_name[execution_name] = values - valid_artifacts.append(source) - else: - notes.extend(source_notes) - - concordance: list[QcSourceConcordance] = [] - metadata_sources = [ - source for source in sources if source.sourceType == "metadataColumn" - ] - artifact_sources = [source for source in sources if source.sourceType == "artifact"] - for left in metadata_sources: - for right in artifact_sources: - if left.metricRole != right.metricRole or left.metricRole == "diagnostic": - continue - if right.artifact is None: - raise ValueError("Artifact QC source lacks its exact reference") - left_values = values_by_source.get(left.sourceId) - right_values = values_by_source.get(right.sourceId) - if left_values is None or right_values is None: - continue - finite = np.isfinite(left_values) & np.isfinite(right_values) - compared = int(finite.sum()) - missing = int(len(finite) - compared) - mean_difference: float | None = None - maximum_difference: float | None = None - pearson: float | None = None - exactly_equal = False - numerically_close = False - if compared: - left_finite = left_values[finite] - right_finite = right_values[finite] - differences = np.abs(left_finite - right_finite) - mean_difference = float(differences.mean()) - maximum_difference = float(differences.max()) - exactly_equal = missing == 0 and bool( - np.array_equal(left_finite, right_finite) - ) - numerically_close = missing == 0 and bool( - np.allclose( - left_finite, - right_finite, - rtol=1e-6, - atol=1e-8, - ) - ) - if ( - compared >= 2 - and float(np.std(left_finite)) > 0.0 - and float(np.std(right_finite)) > 0.0 - ): - correlation = float(np.corrcoef(left_finite, right_finite)[0, 1]) - if math.isfinite(correlation): - pearson = correlation - evidence_id = ( - f"qcConcordance:{left.metricRole}:" - f"{left.valuesFingerprint}:{right.artifact.artifactId}" - ) - concordance.append( - QcSourceConcordance( - metricRole=left.metricRole, - leftSourceId=left.sourceId, - rightSourceId=right.sourceId, - comparedCells=compared, - missingCells=missing, - meanAbsoluteDifference=mean_difference, - maximumAbsoluteDifference=maximum_difference, - pearsonCorrelation=pearson, - exactlyEqual=exactly_equal, - numericallyClose=numerically_close, - evidenceId=evidence_id, - ) - ) - return ( - values_by_execution_name, - valid_metadata, - valid_artifacts, - sources, - concordance, - notes, - values_by_source, - ) - - -def _qc_attributes(store: Any, assay_name: str, assay_type: str) -> list[str]: - del assay_type - suffixes = ["nCounts", "nFeatures", "percentMito", "percentRibo"] - available = set(store.cells.columns) - return [ - f"{assay_name}_{suffix}" - for suffix in suffixes - if f"{assay_name}_{suffix}" in available - ] - - -def _derive_missing_percentage_artifacts( - store: Any, - *, - cell_selection: ArtifactRef, - driver: tuple[str, CellQcDriverType] | None, - quality_sources: Sequence[NamedArtifactSource], -) -> list[NamedArtifactSource]: - """Derive missing RNA percentage metrics through public immutable APIs.""" - sources = list(quality_sources) - if driver is None or driver[1] != "RNA": - return sources - if not callable(getattr(store, "set_feature_selection", None)) or not callable( - getattr(store, "run_feature_percentage", None) - ): - return sources - assay_name = driver[0] - available_metadata = set(store.cells.columns) - supplied_roles = { - registered_qc_metric_role(source.name) - for source in sources - if source.artifact.assay == assay_name - } - assay = store.get_assay(assay_name) - feature_ids = np.asarray(assay.feats.fetch_all("ids")).astype(str) - feature_names = np.asarray(assay.feats.fetch_all("names")).astype(str) - specifications: tuple[ - tuple[QcMetricRole, str, re.Pattern[str]], - ..., - ] = ( - ("mitochondrial", "percentMito", re.compile(r"^(MT-|mt-)")), - ( - "ribosomal", - "percentRibo", - re.compile(r"^(RPS|RPL|MRPS|MRPL|Rps|Rpl|Mrps|Mrpl)"), - ), - ) - existing_names = {source.name for source in sources} - for role, suffix, pattern in specifications: - metric_name = f"{assay_name}_{suffix}" - if metric_name in available_metadata or role in supplied_roles: - continue - mask = np.fromiter( - ( - pattern.search(feature_id) is not None - or pattern.search(feature_name) is not None - for feature_id, feature_name in zip( - feature_ids, - feature_names, - strict=True, - ) - ), - dtype=bool, - count=assay.feats.N, - ) - if not mask.any(): - continue - if metric_name in existing_names: - raise ValueError(f"Derived QC metric name {metric_name!r} is not unique") - feature_selection = store.set_feature_selection( - from_assay=assay_name, - mask=mask, - invalidate_cache=False, - ) - metric = store.run_feature_percentage( - cell_selection, - feature_selection, - invalidate_cache=False, - ) - sources.append( - NamedArtifactSource( - name=metric_name, - artifact=artifact_reference(metric), - ) - ) - existing_names.add(metric_name) - supplied_roles.add(role) - return sources - - -def _qc_sample_columns( - deps: ExperimentalContextDependencies, - characterization: CovariateCharacterization | None, -) -> list[str]: - requested: list[str] = [] - directed = deps.directions.get("cellQc") - if isinstance(directed, Mapping): - sample_column = directed.get("sampleColumn") - if isinstance(sample_column, str): - requested.append(sample_column) - if characterization is not None: - for record in characterization.coefficients: - observation_unit = record.get("observationUnit") - if isinstance(observation_unit, str): - requested.append(observation_unit) - requested.extend(deps.htoIdentityColumns) - available = set(deps.store.cells.columns) - return list( - dict.fromkeys(name for name in requested if name in available and name != "I") - )[:_MAX_QC_SAMPLE_PROFILES] - - -def _qc_profile_id( - action: LegacyCellQcAction, - *, - driver: tuple[str, CellQcDriverType] | None, - sample_column: str | None = None, - sample_artifact: NamedArtifactSource | None = None, -) -> str: - assay_name, assay_type = driver or ("none", "none") - suffix = { - "skip": "skip", - "globalGaussian": "globalGaussian:0.01:0.99", - "sampleMad": ( - f"sampleMad:metadata:{sample_column}:3:20" - if sample_artifact is None - else ( - f"sampleMad:artifact:{sample_artifact.name}:" - f"{sample_artifact.artifact.artifactId}:3:20" - ) - ), - }[action] - return f"cellQc:{assay_type}:{assay_name}:{suffix}" - - -def _registered_qc_profile_id( - profile: RegisteredCellQcProfile, - *, - driver: tuple[str, CellQcDriverType], - sample_column: str | None, - sample_artifact: NamedArtifactSource | None, -) -> str: - if sample_column is not None: - source = f"metadata:{sample_column}" - elif sample_artifact is not None: - source = ( - f"artifact:{sample_artifact.name}:{sample_artifact.artifact.artifactId}" - ) - else: - source = "global" - return f"cellQc:{driver[1]}:{driver[0]}:registered:{profile}:{source}" - - -def _directed_capture_source( - deps: ExperimentalContextDependencies, -) -> tuple[str | None, NamedArtifactSource | None, np.ndarray] | None: - directed_qc = deps.directions.get("cellQc") - qc_directions = dict(directed_qc) if isinstance(directed_qc, Mapping) else {} - candidates = [ - deps.directions.get("physicalCaptureColumn"), - qc_directions.get("physicalCaptureColumn"), - qc_directions.get("captureColumn"), - ] - specified = [value for value in candidates if value is not None] - if not specified: - return None - if any(not isinstance(value, str) or not value.strip() for value in specified): - raise ValueError("physicalCaptureColumn must be a non-empty string") - names = list(dict.fromkeys(str(value) for value in specified)) - if len(names) != 1: - raise ValueError("Conflicting physical capture columns were supplied") - name = names[0] - matching_artifacts = [ - source for source in deps.htoIdentityArtifacts if source.name == name - ] - if len(matching_artifacts) > 1: - raise ValueError(f"Physical capture artifact {name!r} is not unique") - if matching_artifacts: - source = matching_artifacts[0] - labels = _resolved_artifact_values( - deps, - source, - expected_kind="hto_identity", - ) - return None, source, np.asarray(labels) - if name not in deps.cells.columns: - raise ValueError( - f"physicalCaptureColumn {name!r} is not observed metadata or an " - "exact HTO identity artifact" - ) - return name, None, np.asarray(deps.cells.fetch(name)) - - -def _directed_pooled_reference_captures( - deps: ExperimentalContextDependencies, -) -> tuple[str, ...] | None: - directed_qc = deps.directions.get("cellQc") - qc_directions = dict(directed_qc) if isinstance(directed_qc, Mapping) else {} - raw = qc_directions.get( - "pooledReferenceCaptures", - deps.directions.get("pooledReferenceCaptures"), - ) - if raw is None: - return None - if not isinstance(raw, list | tuple) or any( - not isinstance(value, str) or not value.strip() for value in raw - ): - raise ValueError("pooledReferenceCaptures must contain non-empty strings") - references = tuple(str(value) for value in raw) - if len(references) < 2 or len(references) != len(set(references)): - raise ValueError( - "pooledReferenceCaptures must contain at least two unique captures" - ) - return references - - -def _provenance_label(value: Any) -> str | None: - if isinstance(value, np.generic): - value = value.item() - if value is None: - return None - if isinstance(value, float) and not math.isfinite(value): - return None - if isinstance(value, bytes): - try: - value = value.decode("utf-8") - except UnicodeDecodeError: - return None - if isinstance(value, str) and not value.strip(): - return None - return str(value) - - -def _ordered_labels(values: np.ndarray, mask: np.ndarray) -> list[str]: - output: list[str] = [] - seen: set[str] = set() - for raw in values[mask]: - label = _provenance_label(raw) - if label is None or label in seen: - continue - seen.add(label) - output.append(label) - return output - - -def _capture_design_safety( - deps: ExperimentalContextDependencies, - characterization: CovariateCharacterization | None, - capture_labels: np.ndarray, - capture: str, -) -> tuple[list[dict[str, Any]], bool, bool]: - if characterization is None: - return [], False, False - active = np.ones(len(capture_labels), dtype=bool) - normalized = _validated_sample_labels( - capture_labels, - active, - label_name="physical capture labels", - ) - encoded = np.asarray( - [ - value.decode("utf-8") if isinstance(value, bytes) else str(value) - for value in normalized - ], - dtype=object, - ) - after = encoded != capture - safety: list[dict[str, Any]] = [] - for record in characterization.coefficients: - coefficient = record.get("name") - observation = record.get("observationUnit") - independent = record.get("independentUnit") - if ( - not isinstance(coefficient, str) - or not isinstance(observation, str) - or record.get("scope") != "betweenUnit" - or coefficient not in deps.cells.columns - or observation not in deps.cells.columns - ): - continue - condition_values = np.asarray(deps.cells.fetch(coefficient), dtype=object) - observation_values = np.asarray(deps.cells.fetch(observation), dtype=object) - if ( - condition_values.shape != after.shape - or observation_values.shape != after.shape - ): - raise ValueError("Capture safety columns do not align with cellSelection") - required_groups = _ordered_labels(condition_values, active) - remaining_groups = _ordered_labels(condition_values, after) - preserves_conditions = set(remaining_groups) == set(required_groups) - - observation_counts: list[dict[str, Any]] = [] - independent_counts: list[dict[str, Any]] = [] - independent_values: np.ndarray | None = None - if isinstance(independent, str): - if independent not in deps.cells.columns: - continue - independent_values = np.asarray( - deps.cells.fetch(independent), - dtype=object, - ) - if independent_values.shape != after.shape: - raise ValueError( - "Capture independent-unit column does not align with cellSelection" - ) - for group in required_groups: - group_mask = np.asarray( - [_provenance_label(value) == group for value in condition_values], - dtype=bool, - ) - observation_levels = set( - _ordered_labels(observation_values, after & group_mask) - ) - observation_counts.append( - {"group": group, "count": len(observation_levels)} - ) - if independent_values is not None: - independent_levels = set( - _ordered_labels(independent_values, after & group_mask) - ) - independent_counts.append( - {"group": group, "count": len(independent_levels)} - ) - - replication_counts = ( - independent_counts if independent_values is not None else observation_counts - ) - minimum_units = min( - (int(item["count"]) for item in replication_counts), - default=0, - ) - complete_pairs = 0 - incomplete_pairs = 0 - duplicate_pair_groups = 0 - single_group_pairs = 0 - if independent_values is not None: - pair_groups: dict[str, dict[str, set[str]]] = {} - for index in np.flatnonzero(after): - pair = _provenance_label(independent_values[index]) - pair_group = _provenance_label(condition_values[index]) - observation_value = _provenance_label(observation_values[index]) - if pair is None or pair_group is None or observation_value is None: - continue - pair_groups.setdefault(pair, {}).setdefault(pair_group, set()).add( - observation_value - ) - required_set = set(required_groups) - for groups in pair_groups.values(): - if len(groups) == 1: - single_group_pairs += 1 - duplicate_pair_groups += sum( - len(observations) > 1 for observations in groups.values() - ) - if set(groups) == required_set and all( - len(observations) == 1 for observations in groups.values() - ): - complete_pairs += 1 - else: - incomplete_pairs += 1 - original_pair_design = dict(record.get("pairedCoverage") or {}).get("design") - pair_structure_safe = ( - True - if independent_values is None - else ( - complete_pairs >= 2 - and incomplete_pairs == 0 - and duplicate_pair_groups == 0 - ) - if original_pair_design == "paired" - else (len(pair_groups) >= 2 and single_group_pairs == len(pair_groups)) - if original_pair_design == "betweenIndependentUnits" - else False - ) - preserves_units = ( - preserves_conditions and minimum_units >= 2 and pair_structure_safe - ) - safety.append( - { - "coefficient": coefficient, - "conditionColumn": coefficient, - "observationUnit": observation, - "independentUnit": independent, - "requiredGroups": required_groups, - "remainingGroups": remaining_groups, - "observationUnitsByGroup": observation_counts, - "independentUnitsByGroup": independent_counts, - "minimumIndependentUnitsAfterExclusion": minimum_units, - "completePairsAfterExclusion": complete_pairs, - "incompletePairsAfterExclusion": incomplete_pairs, - "duplicatePairGroupsAfterExclusion": duplicate_pair_groups, - "independentUnitDesign": original_pair_design, - "preservesConditionCoverage": preserves_conditions, - "preservesIndependentUnitCoverage": preserves_units, - } - ) - return ( - safety, - bool(safety) and all(item["preservesConditionCoverage"] for item in safety), - bool(safety) - and all(item["preservesIndependentUnitCoverage"] for item in safety), - ) - - -def _capture_source_missingness( - sources: Sequence[QcMetricSourceEvidence], - values_by_source: Mapping[str, np.ndarray], - capture_labels: np.ndarray | None, -) -> list[QcMetricSourceEvidence]: - if capture_labels is None: - return list(sources) - active = np.ones(len(capture_labels), dtype=bool) - normalized = _validated_sample_labels( - capture_labels, - active, - label_name="physical capture labels", - ) - captures: list[tuple[str, np.ndarray]] = [] - seen: set[str] = set() - for raw in normalized: - value = raw.item() if isinstance(raw, np.generic) else raw - key = value.decode("utf-8") if isinstance(value, bytes) else str(value) - if key in seen: - continue - seen.add(key) - captures.append((key, normalized == value)) - output: list[QcMetricSourceEvidence] = [] - for source in sources: - values = values_by_source.get(source.sourceId) - missing_by_capture: dict[str, int] = {} - if values is not None: - for capture, mask in captures: - missing_by_capture[capture] = int((~np.isfinite(values[mask])).sum()) - elif source.missingCells == source.activeCells: - missing_by_capture = { - capture: int(mask.sum()) for capture, mask in captures - } - output.append( - source.model_copy(update={"missingCellsByCapture": missing_by_capture}) - ) - return output - - -def _capture_failure_models( - projection: RegisteredQcProjection | AutoFilterProjection, - *, - deps: ExperimentalContextDependencies, - characterization: CovariateCharacterization | None, - capture_labels: np.ndarray | None, - metric_sources: Sequence[QcMetricSourceEvidence], -) -> list[CaptureFailureEvidence]: - if capture_labels is None: - return [] - output: list[CaptureFailureEvidence] = [] - source_by_id = {source.sourceId: source for source in metric_sources} - for comparison in projection.captureComparisons: - missing_fractions = { - source_id: ( - source.missingCellsByCapture.get(comparison.capture, 0) - / comparison.cells - if comparison.cells - else 0.0 - ) - for source_id, source in source_by_id.items() - } - safety, condition_safe, unit_safe = _capture_design_safety( - deps, - characterization, - capture_labels, - comparison.capture, - ) - failure = CaptureFailureEvidence( - capture=comparison.capture, - activeCells=comparison.cells, - retainedCells=comparison.retainedCells or 0, - retainedFraction=comparison.retainedFraction or 0.0, - adverseAxes=list(comparison.adverseAxes), - independentAdverseAxes=comparison.independentAdverseAxes, - metricMissingFractions=missing_fractions, - reasons=list(comparison.reasons), - wholeCaptureFailure=comparison.wholeCaptureFailure, - conditionAndUnitSafety=safety, - preservesConditionCoverage=condition_safe, - preservesIndependentUnitCoverage=unit_safe, - exclusionEligible=( - comparison.wholeCaptureFailure and condition_safe and unit_safe - ), - evidenceId=( - f"qcCapture:{comparison.capture}:" - f"{comparison.independentAdverseAxes}axes" - ), - ) - output.append(failure) - return output - - -def _registered_profile_evidence( - projection: RegisteredQcProjection, - *, - deps: ExperimentalContextDependencies, - characterization: CovariateCharacterization | None, - driver: tuple[str, CellQcDriverType], - active: np.ndarray, - values_by_attr: dict[str, np.ndarray], - metadata_attributes: list[str], - artifact_metrics: list[NamedArtifactSource], - metric_sources: list[QcMetricSourceEvidence], - source_concordance: list[QcSourceConcordance], - sample_column: str | None, - sample_artifact: NamedArtifactSource | None, - capture_column: str | None, - capture_artifact: NamedArtifactSource | None, - capture_labels: np.ndarray | None, - pooled_reference_captures: tuple[str, ...] | None, - active_cells: int, - comparison_source: str | None, -) -> CellQcProfileEvidence: - attributes = list(metadata_attributes) - metric_artifacts = list(artifact_metrics) - profile_id = _registered_qc_profile_id( - projection.profile, - driver=driver, - sample_column=sample_column, - sample_artifact=sample_artifact, - ) - n_mads = 3.0 if projection.profile == "captureMad3Sensitivity" else 5.0 - action: CellQcAction = ( - "skip" if projection.profile == "retainWithFlags" else "registeredMad" - ) - parameters: dict[str, Any] = { - "policyVersion": 1, - "profile": projection.profile, - "nMads": n_mads, - "boundPolicy": { - "count": {"remove": "lower", "flag": "upper"}, - "feature": {"remove": "lower", "flag": "upper"}, - "mitochondrial": {"remove": "upper", "fixedCutoff": None}, - "diagnostic": {"remove": "none"}, - }, - "resolvedBounds": [threshold.to_dict() for threshold in projection.thresholds], - "captureSizes": projection.captureSizes, - "captureComparisons": [ - comparison.to_dict() for comparison in projection.captureComparisons - ], - "captureComparisonSource": comparison_source, - "pooledReferenceCaptures": list(pooled_reference_captures or ()), - } - failure_evidence = _capture_failure_models( - projection, - deps=deps, - characterization=characterization, - capture_labels=capture_labels, - metric_sources=metric_sources, - ) - cells = deps.cells if deps.cells is not None else deps.store.cells - retention_columns: list[str] = [] - if characterization is not None: - for coefficient in characterization.coefficients: - for value in ( - coefficient.get("name"), - coefficient.get("observationUnit"), - coefficient.get("independentUnit"), - ): - if isinstance(value, str) and value in cells.columns: - retention_columns.append(value) - retained_by_column: dict[str, dict[str, int]] = {} - unsafe_groups: list[str] = [] - retained = np.asarray(projection.keep, dtype=bool) & np.asarray(active, dtype=bool) - for column in dict.fromkeys(retention_columns): - labels = np.asarray(cells.fetch(column)) - if labels.shape != retained.shape: - raise ValueError( - f"QC retention column {column!r} does not align with cellSelection" - ) - counts: dict[str, int] = {} - for raw_label in np.unique(labels[np.asarray(active, dtype=bool)]): - label = raw_label.item() if isinstance(raw_label, np.generic) else raw_label - key = label.decode("utf-8") if isinstance(label, bytes) else str(label) - count = int((retained & (labels == raw_label)).sum()) - counts[key] = count - if count == 0: - unsafe_groups.append(f"{column}={key}") - retained_by_column[column] = counts - return CellQcProfileEvidence( - profileId=profile_id, - action=action, - registeredProfile=projection.profile, - driverAssay=driver[0], - driverAssayType=driver[1], - sampleColumn=sample_column, - sampleArtifact=sample_artifact, - captureColumn=capture_column, - captureArtifact=capture_artifact, - attributes=attributes, - artifactMetrics=metric_artifacts, - metricSources=metric_sources, - sourceConcordance=source_concordance, - parameters=parameters, - resolvedBounds=parameters["resolvedBounds"], - activeCells=active_cells, - retainedCells=projection.retainedCells, - retainedFraction=( - projection.retainedCells / active_cells if active_cells else 0.0 - ), - activeCellsByCapture=projection.captureSizes, - sampleRetainedCells=projection.retainedByCapture, - retainedCellsByColumn=retained_by_column, - unsafeRetentionGroups=sorted(unsafe_groups), - flaggedCells=projection.flagCounts, - metricFlaggedCells=projection.metricFlagCounts, - failedCaptureCandidates=list(projection.failedCaptureCandidates), - captureFailureEvidence=failure_evidence, - excludableCaptureCandidates=[ - item.capture for item in failure_evidence if item.exclusionEligible - ], - notes=list(projection.warnings), - evidenceId=f"qcProfile:{profile_id}", - ) - - -def _registered_qc_profiles( - deps: ExperimentalContextDependencies, - *, - characterization: CovariateCharacterization | None, - driver: tuple[str, CellQcDriverType], - active: np.ndarray, - values_by_attr: dict[str, np.ndarray], - metadata_attributes: list[str], - artifact_metrics: list[NamedArtifactSource], - metric_sources: list[QcMetricSourceEvidence], - source_concordance: list[QcSourceConcordance], - capture: tuple[str | None, NamedArtifactSource | None, np.ndarray] | None = None, -) -> list[CellQcProfileEvidence]: - if capture is None: - capture = _directed_capture_source(deps) - sample_column: str | None = None - sample_artifact: NamedArtifactSource | None = None - capture_labels: np.ndarray | None = None - if capture is not None: - sample_column, sample_artifact, capture_labels = capture - if sample_column is not None: - comparison_source = f"metadata:{sample_column}" - elif sample_artifact is not None: - comparison_source = ( - f"artifact:{sample_artifact.name}:{sample_artifact.artifact.artifactId}" - ) - else: - comparison_source = None - pooled_references = _directed_pooled_reference_captures(deps) - if pooled_references is not None and capture is None: - raise ValueError( - "pooledReferenceCaptures requires an explicit physicalCaptureColumn" - ) - projections = offered_registered_qc_profiles( - values_by_metric=values_by_attr, - active=active, - capture_labels=capture_labels, - grouping_proven=capture is not None, - min_cells_per_capture=20, - pooled_reference_captures=pooled_references, - ) - profiles: list[CellQcProfileEvidence] = [] - for projection in projections: - uses_capture = projection.profile in { - "captureMad5", - "captureMad3Sensitivity", - "pooledReferenceMad5", - } - profiles.append( - _registered_profile_evidence( - projection, - deps=deps, - characterization=characterization, - driver=driver, - active=active, - values_by_attr=values_by_attr, - metadata_attributes=metadata_attributes, - artifact_metrics=artifact_metrics, - metric_sources=metric_sources, - source_concordance=source_concordance, - sample_column=sample_column if uses_capture else None, - sample_artifact=sample_artifact if uses_capture else None, - capture_column=sample_column, - capture_artifact=sample_artifact, - capture_labels=capture_labels, - pooled_reference_captures=( - pooled_references - if projection.profile == "pooledReferenceMad5" - else None - ), - active_cells=int(active.sum()), - comparison_source=comparison_source, - ) - ) - return profiles - - -def _global_qc_profile( - deps: ExperimentalContextDependencies, - driver: tuple[str, CellQcDriverType], - active: np.ndarray, - active_cells: int, - values_by_attr: dict[str, np.ndarray], - metadata_attributes: list[str], - artifact_metrics: list[NamedArtifactSource], - attribute_notes: list[str], - *, - characterization: CovariateCharacterization | None = None, - metric_sources: list[QcMetricSourceEvidence] | None = None, - source_concordance: list[QcSourceConcordance] | None = None, - capture: tuple[str | None, NamedArtifactSource | None, np.ndarray] | None = None, -) -> CellQcProfileEvidence | None: - """Build an execution-exact projection of core global auto-filtering.""" - if not values_by_attr: - return None - metric_sources = list(metric_sources or []) - source_concordance = list(source_concordance or []) - executable_values: dict[str, np.ndarray] = {} - for name, values in values_by_attr.items(): - selected = np.asarray(values)[active] - if selected.size and np.all(selected == selected[0]): - attribute_notes.append(f"Ignored constant QC metric {name!r}") - continue - low, high = gaussian_quantile_bounds(selected, 0.01, 0.99) - if not np.isfinite([low, high]).all(): - attribute_notes.append( - f"Ignored QC metric {name!r} with non-finite Gaussian bounds" - ) - continue - executable_values[name] = values - if not executable_values: - return None - executable_names = set(executable_values) - metadata_names = set(metadata_attributes) - metadata_attributes = [ - name for name in metadata_attributes if name in executable_names - ] - artifact_metrics = [ - source - for source in artifact_metrics - if qc_metric_execution_name( - source.name, - artifact_id=source.artifact.artifactId, - collides_with_metadata=source.name in metadata_names, - ) - in executable_names - ] - metric_sources = [ - source for source in metric_sources if source.executionName in executable_names - ] - retained_source_ids = {source.sourceId for source in metric_sources} - source_concordance = [ - comparison - for comparison in source_concordance - if comparison.leftSourceId in retained_source_ids - and comparison.rightSourceId in retained_source_ids - ] - capture_column: str | None = None - capture_artifact: NamedArtifactSource | None = None - capture_labels: np.ndarray | None = None - if capture is not None: - capture_column, capture_artifact, capture_labels = capture - try: - projection = project_auto_filter_profile( - "globalGaussian", - values_by_metric=executable_values, - active=active, - sample_labels=capture_labels, - grouping_proven=capture is not None, - ) - except ValueError as exc: - attribute_notes.append(f"Global Gaussian QC is not executable: {exc}") - return None - profile_id = _qc_profile_id( - "globalGaussian", - driver=driver, - ) - failures = _capture_failure_models( - projection, - deps=deps, - characterization=characterization, - capture_labels=capture_labels, - metric_sources=metric_sources, - ) - return CellQcProfileEvidence( - profileId=profile_id, - action="globalGaussian", - driverAssay=driver[0], - driverAssayType=driver[1], - captureColumn=capture_column, - captureArtifact=capture_artifact, - attributes=list(metadata_attributes), - artifactMetrics=list(artifact_metrics), - metricSources=metric_sources, - sourceConcordance=source_concordance, - parameters=projection.parameters, - resolvedBounds=cast(dict[str, Any], projection.parameters["resolvedBounds"]), - activeCells=active_cells, - retainedCells=projection.retainedCells, - retainedFraction=projection.retainedCells / active_cells, - activeCellsByCapture=projection.captureSizes, - sampleRetainedCells=projection.retainedByCapture, - flaggedCells=projection.flagCounts, - metricFlaggedCells=projection.metricFlagCounts, - failedCaptureCandidates=list(projection.failedCaptureCandidates), - captureFailureEvidence=failures, - excludableCaptureCandidates=[ - item.capture for item in failures if item.exclusionEligible - ], - notes=[*attribute_notes, *projection.warnings], - evidenceId=f"qcProfile:{profile_id}", - ) - - -def _sample_qc_profiles( - deps: ExperimentalContextDependencies, - characterization: CovariateCharacterization | None, - driver: tuple[str, CellQcDriverType], - active: np.ndarray, - active_cells: int, - values_by_attr: dict[str, np.ndarray], - metadata_attributes: list[str], - artifact_metrics: list[NamedArtifactSource], - metric_sources: list[QcMetricSourceEvidence], - source_concordance: list[QcSourceConcordance], - capture: tuple[str | None, NamedArtifactSource | None, np.ndarray] | None, -) -> list[CellQcProfileEvidence]: - """Build core-parity sample MAD profiles from exact grouping sources.""" - attributes = list(values_by_attr) - profiles: list[CellQcProfileEvidence] = [] - sample_sources: list[ - tuple[str | None, NamedArtifactSource | None, np.ndarray | None, bool] - ] = [] - if capture is not None: - sample_sources.append((*capture[:2], capture[2], True)) - sample_sources.extend( - (None, source, None, False) for source in deps.htoIdentityArtifacts - ) - sample_sources.extend( - (column, None, None, False) - for column in _qc_sample_columns(deps, characterization) - ) - seen_sources: set[str] = set() - for ( - sample_column, - sample_artifact, - supplied_labels, - is_physical_capture, - ) in sample_sources: - source_key = ( - f"metadata:{sample_column}" - if sample_column is not None - else ( - f"artifact:{sample_artifact.artifact.artifactId}" - if sample_artifact is not None - else "" - ) - ) - if not source_key or source_key in seen_sources: - continue - seen_sources.add(source_key) - if len(seen_sources) > _MAX_QC_SAMPLE_PROFILES: - break - if not attributes: - break - artifact_labels = ( - supplied_labels - if supplied_labels is not None - else None - if sample_artifact is None - else _resolved_artifact_values( - deps, - sample_artifact, - expected_kind="hto_identity", - ) - ) - try: - sample_labels = ( - np.asarray(supplied_labels) - if supplied_labels is not None - else np.asarray(deps.cells.fetch(sample_column)) - if sample_column is not None - else np.asarray(artifact_labels) - ) - projection = project_auto_filter_profile( - "sampleMad", - values_by_metric=values_by_attr, - sample_labels=sample_labels, - active=active, - grouping_proven=True, - n_mads=3.0, - min_cells_per_sample=20, - ) - except (TypeError, ValueError): - continue - profile_id = _qc_profile_id( - "sampleMad", - driver=driver, - sample_column=sample_column, - sample_artifact=sample_artifact, - ) - failures = ( - _capture_failure_models( - projection, - deps=deps, - characterization=characterization, - capture_labels=sample_labels, - metric_sources=metric_sources, - ) - if is_physical_capture - else [] - ) - skip_reasons = cast( - dict[str, object], - projection.parameters["skipReasons"], - ) - profiles.append( - CellQcProfileEvidence( - profileId=profile_id, - action="sampleMad", - driverAssay=driver[0], - driverAssayType=driver[1], - sampleColumn=sample_column, - sampleArtifact=sample_artifact, - captureColumn=sample_column if is_physical_capture else None, - captureArtifact=sample_artifact if is_physical_capture else None, - attributes=list(metadata_attributes), - artifactMetrics=list(artifact_metrics), - metricSources=metric_sources, - sourceConcordance=source_concordance, - parameters={ - "nMads": 3.0, - "minCellsPerSample": 20, - "nSamples": len(projection.captureSizes), - "nSkippedSamples": len(skip_reasons), - }, - resolvedBounds=cast( - dict[str, Any], - projection.parameters["resolvedBounds"], - ), - activeCells=active_cells, - retainedCells=projection.retainedCells, - retainedFraction=projection.retainedCells / active_cells, - activeCellsByCapture=projection.captureSizes, - sampleRetainedCells=projection.retainedByCapture, - flaggedCells=projection.flagCounts, - metricFlaggedCells=projection.metricFlagCounts, - failedCaptureCandidates=( - list(projection.failedCaptureCandidates) - if is_physical_capture - else [] - ), - captureFailureEvidence=failures, - excludableCaptureCandidates=[ - item.capture for item in failures if item.exclusionEligible - ], - notes=list(projection.warnings), - evidenceId=f"qcProfile:{profile_id}", - ) - ) - return profiles - - -def _offered_qc_profiles( - deps: ExperimentalContextDependencies, - characterization: CovariateCharacterization | None = None, -) -> list[CellQcProfileEvidence]: - """Project bounded QC profiles against the exact shared cell selection.""" - active_cells = _active_cell_count(deps) - active = np.ones(active_cells, dtype=bool) - driver = _qc_driver(deps.store) - driver_assay = driver[0] if driver is not None else None - driver_type = driver[1] if driver is not None else None - skip_id = _qc_profile_id( - "skip", - driver=driver, - ) - skip_notes = ( - [] - if driver is not None - else ["No RNA or ATAC assay is eligible to drive automatic cell QC"] - ) - registered_only = deps.directions.get("registeredQcOnly") is True - profiles = ( - [] - if registered_only - else [ - CellQcProfileEvidence( - profileId=skip_id, - action="skip", - driverAssay=driver_assay, - driverAssayType=driver_type, - activeCells=active_cells, - retainedCells=active_cells, - retainedFraction=1.0 if active_cells else 0.0, - notes=skip_notes, - evidenceId=f"qcProfile:{skip_id}", - ) - ] - ) - if driver is None or active_cells == 0: - if registered_only: - profiles.append( - CellQcProfileEvidence( - profileId=skip_id, - action="skip", - driverAssay=driver_assay, - driverAssayType=driver_type, - activeCells=active_cells, - retainedCells=active_cells, - retainedFraction=1.0 if active_cells else 0.0, - notes=skip_notes, - evidenceId=f"qcProfile:{skip_id}", - ) - ) - deps.qcProfiles = {profile.profileId: profile for profile in profiles} - return profiles - - ( - values_by_attr, - valid_metadata_attributes, - artifact_metrics, - metric_sources, - source_concordance, - attribute_notes, - values_by_source, - ) = _qc_metric_sources(deps, driver) - capture = _directed_capture_source(deps) - capture_column: str | None = None - capture_artifact: NamedArtifactSource | None = None - capture_labels: np.ndarray | None = None - capture_sizes: dict[str, int] = {} - if capture is not None: - capture_column, capture_artifact, capture_labels = capture - normalized = _validated_sample_labels( - capture_labels, - active, - label_name="physical capture labels", - ) - for raw in normalized: - value = raw.item() if isinstance(raw, np.generic) else raw - key = value.decode("utf-8") if isinstance(value, bytes) else str(value) - capture_sizes[key] = capture_sizes.get(key, 0) + 1 - metric_sources = _capture_source_missingness( - metric_sources, - values_by_source, - capture_labels, - ) - deps.qcMetricSources = metric_sources - deps.qcSourceConcordance = source_concordance - if not registered_only: - profiles = [ - CellQcProfileEvidence( - profileId=skip_id, - action="skip", - driverAssay=driver_assay, - driverAssayType=driver_type, - captureColumn=capture_column, - captureArtifact=capture_artifact, - metricSources=metric_sources, - sourceConcordance=source_concordance, - activeCells=active_cells, - retainedCells=active_cells, - retainedFraction=1.0, - activeCellsByCapture=capture_sizes, - sampleRetainedCells=capture_sizes, - notes=[*skip_notes, *attribute_notes], - evidenceId=f"qcProfile:{skip_id}", - ) - ] - - if not registered_only: - global_profile = _global_qc_profile( - deps, - driver, - active, - active_cells, - values_by_attr, - valid_metadata_attributes, - artifact_metrics, - attribute_notes, - characterization=characterization, - metric_sources=metric_sources, - source_concordance=source_concordance, - capture=capture, - ) - if global_profile is not None: - profiles.append(global_profile) - profiles.extend( - _sample_qc_profiles( - deps, - characterization, - driver, - active, - active_cells, - values_by_attr, - valid_metadata_attributes, - artifact_metrics, - metric_sources, - source_concordance, - capture, - ) - ) - profiles.extend( - _registered_qc_profiles( - deps, - characterization=characterization, - driver=driver, - active=active, - values_by_attr=values_by_attr, - metadata_attributes=valid_metadata_attributes, - artifact_metrics=artifact_metrics, - metric_sources=metric_sources, - source_concordance=source_concordance, - capture=capture, - ) - ) - - deps.qcProfiles = {profile.profileId: profile for profile in profiles} - return profiles - - -async def inspect_cell_covariates( - ctx: RunContext[ExperimentalContextDependencies], -) -> CovariateEvidence: - """Inspect cell metadata without making model-driven choices or writing data.""" - logger.info( - "Experimental Context covariate inspection started: " - f"cellSelection={ctx.deps.cellSelection.artifact_id}" - ) - ctx.deps.htoIdentityColumns = _hto_identity_columns(ctx.deps) - characterization = characterize_covariates( - ctx.deps.store, - cellSelection=ctx.deps.cellSelection, - studyContext=( - f"{ctx.deps.studyContext}\nStudy objective: {ctx.deps.studyObjective}" - ), - model=None, - directions=ctx.deps.directions, - groupingArtifacts=_hto_artifact_map(ctx.deps), - ) - ctx.deps.characterization = characterization - qc_profiles = _offered_qc_profiles(ctx.deps) - contrast_plans = contrast_plans_from_characterization(characterization) - ctx.deps.contrastPlans = {plan.coefficient: plan for plan in contrast_plans} - evidence_ids = characterization_evidence(characterization) - evidence_ids.update(profile.evidenceId for profile in qc_profiles) - evidence_ids.update(source.sourceId for source in ctx.deps.qcMetricSources) - evidence_ids.update(item.evidenceId for item in ctx.deps.qcSourceConcordance) - evidence_ids.update(plan.evidenceId for plan in contrast_plans) - evidence_ids.update( - failure.evidenceId - for profile in qc_profiles - for failure in profile.captureFailureEvidence - ) - evidence_ids.update( - f"htoIdentity:{column}" for column in ctx.deps.htoIdentityColumns - ) - evidence_ids.update( - _artifact_evidence_id(source) for source in ctx.deps.htoIdentityArtifacts - ) - ctx.deps.evidenceIds.update(evidence_ids) - ctx.deps.toolCalls.append("inspect_cell_covariates") - logger.info( - "Experimental Context covariate inspection completed: " - f"status={characterization.status}, " - f"columns={len(characterization.columns)}, " - f"coefficients={len(characterization.coefficients)}, " - f"qcProfiles={len(qc_profiles)}, " - f"htoIdentities={len(ctx.deps.htoIdentityColumns)}, " - f"evidence={len(evidence_ids)}" - ) - return CovariateEvidence( - characterization=characterization, - qcProfiles=qc_profiles, - qcMetricSources=ctx.deps.qcMetricSources, - qcSourceConcordance=ctx.deps.qcSourceConcordance, - contrastPlans=contrast_plans, - htoIdentityColumns=ctx.deps.htoIdentityColumns, - htoIdentityArtifacts=ctx.deps.htoIdentityArtifacts, - evidenceIds=sorted(evidence_ids), - ) - - -def _batch_safety_evidence( - deps: ExperimentalContextDependencies, - characterization: CovariateCharacterization, - *, - coefficients: Sequence[str], - batch_columns: Sequence[str], -) -> list[BatchSafetyEvidence]: - column_records = { - record.get("name"): record - for record in characterization.columns - if isinstance(record.get("name"), str) - } - coefficient_records = { - record.get("name"): record - for record in characterization.coefficients - if isinstance(record.get("name"), str) - } - confounding_reports = { - report.get("coefficient"): report - for report in characterization.confounding - if isinstance(report.get("coefficient"), str) - } - canonical_batch_columns = sorted(batch_columns) - batch_safety: list[BatchSafetyEvidence] = [] - for coefficient in coefficients: - if not canonical_batch_columns: - break - coefficient_record = coefficient_records.get(coefficient) - report = confounding_reports.get(coefficient) - coefficient_kind = ( - coefficient_record.get("kind") if coefficient_record is not None else None - ) - if coefficient_kind not in {"categorical", "continuous"}: - coefficient_kind = None - observation_unit = ( - report.get("observationUnit") - if report is not None - else ( - coefficient_record.get("observationUnit") - if coefficient_record is not None - else None - ) - ) - unit_constant = { - pair.get("technical") - for pair in (report.get("pairs", []) if report is not None else []) - if isinstance(pair.get("technical"), str) - } - effective_batch_columns = [ - name for name in canonical_batch_columns if name in unit_constant - ] - estimability: dict[str, Any] - if ( - coefficient_record is None - or coefficient_record.get("scope") != "betweenUnit" - or report is None - or not isinstance(observation_unit, str) - or coefficient_kind is None - ): - estimability = { - "status": "notComputed", - "reason": "unresolvedCoefficientDesign", - } - else: - try: - design = reduce_observation_units( - deps.cells, - observation_unit, - [coefficient, *effective_batch_columns], - cell_key="I", - ) - estimability = coefficient_estimability( - design[coefficient].to_numpy(), - coefficientKind=coefficient_kind, - technicals={ - name: design[name].to_numpy() - for name in effective_batch_columns - }, - technicalKinds={ - name: column_records[name]["kind"] - for name in effective_batch_columns - }, - ) - except (KeyError, TypeError, ValueError) as exc: - logger.debug( - "Experimental Context batch estimability was not computed: " - f"errorType={type(exc).__name__}" - ) - estimability = { - "status": "notComputed", - "reason": type(exc).__name__, - } - if estimability.get("status") != "ok": - safety_status: BatchSafetyStatus = "notComputed" - elif estimability.get("coefficientEstimable") is True and not bool( - estimability.get("rankDeficient") - ): - safety_status = "safe" - else: - safety_status = "unsafe" - batch_token = ",".join(canonical_batch_columns) - safety = BatchSafetyEvidence( - coefficient=coefficient, - coefficientKind=coefficient_kind, - observationUnit=( - observation_unit if isinstance(observation_unit, str) else None - ), - batchColumns=canonical_batch_columns, - unitConstantBatchColumns=effective_batch_columns, - status=safety_status, - estimability=estimability, - evidenceId=f"batchEstimability:{coefficient}:{batch_token}", - ) - batch_safety.append(safety) - deps.batchSafety[safety.evidenceId] = safety - return batch_safety - - -async def analyze_experimental_design( - ctx: RunContext[ExperimentalContextDependencies], - column_domains: dict[str, ColumnDomain], - coefficients_of_interest: list[str], - units_of_inference: dict[str, InferenceUnit], - batch_columns: list[str], -) -> CovariateEvidence: - """Validate proposed domains and inference units and compute confounding. - - Args: - ctx: Pydantic AI run context containing the existing datastore. - column_domains: Domain assignment for each metadata column under review. - coefficients_of_interest: Biological columns representing study contrasts. - units_of_inference: Observation and independent units for each coefficient. - batch_columns: Exact technical columns proposed for Harmony evaluation. - """ - logger.info( - "Experimental Context design analysis started: " - f"domains={len(column_domains)}, " - f"coefficients={len(coefficients_of_interest)}, " - f"inferenceUnits={len(units_of_inference)}, " - f"batchColumns={len(batch_columns)}" - ) - directions = dict(ctx.deps.directions) - directed_domains = dict(column_domains) - directed_domains.update(dict(directions.get("columnDomains") or {})) - directions["columnDomains"] = directed_domains - directed_coefficients = list( - dict.fromkeys( - [ - *coefficients_of_interest, - *(directions.get("coefficientsOfInterest") or []), - ] - ) - ) - directions["coefficientsOfInterest"] = directed_coefficients - directed_units = { - name: unit.model_dump(exclude_none=True) - for name, unit in units_of_inference.items() - } - directed_units.update(dict(directions.get("unitsOfInference") or {})) - directions["unitsOfInference"] = directed_units - - proposed_batch_columns = list(batch_columns) - directed_batch_columns = directions.get("batchColumns") - if directed_batch_columns is not None: - if not isinstance(directed_batch_columns, list) or any( - not isinstance(value, str) or not value.strip() - for value in directed_batch_columns - ): - raise ModelRetry( - "directions.batchColumns must be a list of exact metadata columns" - ) - if len(set(directed_batch_columns)) != len(directed_batch_columns): - raise ModelRetry("directions.batchColumns must be unique") - if proposed_batch_columns != directed_batch_columns: - logger.info( - "Experimental Context replaced model-proposed batch columns with " - "the exact directed columns" - ) - proposed_batch_columns = list(directed_batch_columns) - canonical_batch_columns = sorted(set(proposed_batch_columns)) - if len(canonical_batch_columns) != len(proposed_batch_columns): - logger.warning( - "Experimental Context rejected duplicate proposed batch columns: " - f"{proposed_batch_columns[:20]}" - ) - raise ModelRetry("Proposed batch columns must be unique") - inspected_records = { - record.get("name"): record - for record in ( - ctx.deps.characterization.columns - if ctx.deps.characterization is not None - else [] - ) - if isinstance(record.get("name"), str) - } - if ctx.deps.characterization is not None: - for batch_column in canonical_batch_columns: - inspected = inspected_records.get(batch_column) - if inspected is None: - logger.warning( - "Experimental Context rejected unknown proposed batch column " - f"before design recomputation: {batch_column!r}" - ) - raise ModelRetry(f"Unknown batch column {batch_column!r}") - proposed_domain = directed_domains.get( - batch_column, - inspected.get("domain"), - ) - if proposed_domain != "technical": - logger.warning( - "Experimental Context rejected proposed batch column before " - f"design recomputation: {batch_column!r}, " - f"domain={proposed_domain!r}, required='technical'" - ) - raise ModelRetry( - f"Batch column {batch_column!r} must be classified as technical" - ) - if inspected.get("kind") != "categorical": - logger.warning( - "Experimental Context rejected proposed batch column before " - f"design recomputation: {batch_column!r}, " - f"kind={inspected.get('kind')!r}, required='categorical'" - ) - raise ModelRetry( - f"Batch column {batch_column!r} must be categorical for Harmony" - ) - - characterization = characterize_covariates( - ctx.deps.store, - cellSelection=ctx.deps.cellSelection, - studyContext=( - f"{ctx.deps.studyContext}\nStudy objective: {ctx.deps.studyObjective}" - ), - model=None, - directions=directions, - groupingArtifacts=_hto_artifact_map(ctx.deps), - ) - if characterization.status == "failed": - rejection = "; ".join(characterization.notes).strip() - logger.warning( - "Experimental Context design characterization rejected the proposed " - f"directions: {rejection[:1000]}; " - f"domainColumns={sorted(column_domains)[:50]}, " - f"coefficients={coefficients_of_interest[:50]}, " - f"inferenceUnits={sorted(units_of_inference)[:50]}" - ) - raise ModelRetry("; ".join(characterization.notes)) - - # Retain the validated deterministic work even when the proposed Harmony - # columns below are rejected. A bounded retry or resumed decision can reuse - # the evidence without rescanning metadata or accepting an unsafe choice. - ctx.deps.characterization = characterization - if not ctx.deps.htoIdentityColumns: - ctx.deps.htoIdentityColumns = _hto_identity_columns(ctx.deps) - qc_profiles = _offered_qc_profiles(ctx.deps, characterization) - contrast_plans = contrast_plans_from_characterization(characterization) - ctx.deps.contrastPlans = {plan.coefficient: plan for plan in contrast_plans} - evidence_ids = characterization_evidence(characterization) - evidence_ids.update(profile.evidenceId for profile in qc_profiles) - evidence_ids.update(source.sourceId for source in ctx.deps.qcMetricSources) - evidence_ids.update(item.evidenceId for item in ctx.deps.qcSourceConcordance) - evidence_ids.update(plan.evidenceId for plan in contrast_plans) - evidence_ids.update( - failure.evidenceId - for profile in qc_profiles - for failure in profile.captureFailureEvidence - ) - evidence_ids.update( - f"htoIdentity:{column}" for column in ctx.deps.htoIdentityColumns - ) - evidence_ids.update( - _artifact_evidence_id(source) for source in ctx.deps.htoIdentityArtifacts - ) - ctx.deps.evidenceIds.update(evidence_ids) - - column_records = { - record.get("name"): record - for record in characterization.columns - if isinstance(record.get("name"), str) - } - for batch_column in canonical_batch_columns: - record = column_records.get(batch_column) - if record is None: - logger.warning( - "Experimental Context rejected unknown proposed batch column: " - f"{batch_column!r}" - ) - raise ModelRetry(f"Unknown batch column {batch_column!r}") - if record.get("domain") != "technical": - logger.warning( - "Experimental Context rejected proposed batch column " - f"{batch_column!r}: domain={record.get('domain')!r}, " - "required='technical'" - ) - raise ModelRetry( - f"Batch column {batch_column!r} must be classified as technical" - ) - if record.get("kind") != "categorical": - logger.warning( - "Experimental Context rejected proposed batch column " - f"{batch_column!r}: kind={record.get('kind')!r}, " - "required='categorical'" - ) - raise ModelRetry( - f"Batch column {batch_column!r} must be categorical for Harmony" - ) - - batch_safety = _batch_safety_evidence( - ctx.deps, - characterization, - coefficients=directed_coefficients, - batch_columns=canonical_batch_columns, - ) - - evidence_ids.update(item.evidenceId for item in batch_safety) - ctx.deps.evidenceIds.update(evidence_ids) - ctx.deps.toolCalls.append("analyze_experimental_design") - safety_counts = { - status: sum(item.status == status for item in batch_safety) - for status in ("safe", "unsafe", "notComputed") - } - logger.info( - "Experimental Context design analysis completed: " - f"status={characterization.status}, " - f"batchSafetySafe={safety_counts['safe']}, " - f"batchSafetyUnsafe={safety_counts['unsafe']}, " - f"batchSafetyNotComputed={safety_counts['notComputed']}, " - f"qcProfiles={len(qc_profiles)}, evidence={len(evidence_ids)}" - ) - return CovariateEvidence( - characterization=characterization, - batchSafety=batch_safety, - qcProfiles=qc_profiles, - qcMetricSources=ctx.deps.qcMetricSources, - qcSourceConcordance=ctx.deps.qcSourceConcordance, - contrastPlans=contrast_plans, - htoIdentityColumns=ctx.deps.htoIdentityColumns, - htoIdentityArtifacts=ctx.deps.htoIdentityArtifacts, - evidenceIds=sorted(evidence_ids), - ) - - -async def score_current_representation( - ctx: RunContext[ExperimentalContextDependencies], - batch_column: str, - biological_column: str | None = None, -) -> RepresentationEvaluation: - """Score one explicitly supplied graph without changing datastore state. - - Args: - ctx: Pydantic AI run context containing the existing datastore. - batch_column: Categorical technical column used to assess batch mixing. - biological_column: Optional biological label used to assess preservation. - """ - logger.info( - "Experimental Context representation scoring started: " - f"graphSupplied={ctx.deps.neighbors is not None}, " - f"biologicalLabelSpecified={biological_column is not None}" - ) - store = ctx.deps.store - available_columns = set(store.cells.columns) - if batch_column not in available_columns: - raise ModelRetry(f"Unknown batch column {batch_column!r}") - if biological_column is not None and biological_column not in available_columns: - raise ModelRetry(f"Unknown biological column {biological_column!r}") - characterization = ctx.deps.characterization - if characterization is not None: - batch_record = next( - ( - record - for record in characterization.columns - if record.get("name") == batch_column - ), - None, - ) - if ( - batch_record is None - or batch_record.get("domain") != "technical" - or batch_record.get("kind") != "categorical" - ): - raise ModelRetry( - "Representation scoring requires a characterized categorical " - "technical batch column" - ) - - neighbors = core_artifact_reference(ctx.deps.neighbors) - connectivity = core_artifact_reference(ctx.deps.connectivityMap) - if neighbors is None: - evaluation = RepresentationEvaluation( - cellSelection=( - artifact_reference(ctx.deps.cellSelection) - if ctx.deps.cellSelection is not None - else None - ), - notes=["No exact neighbors artifact was supplied"], - ) - ctx.deps.currentRepresentation = evaluation - ctx.deps.toolCalls.append("score_current_representation") - logger.info( - "Experimental Context representation scoring skipped: " - "no current neighbors artifact" - ) - return evaluation - if not isinstance(neighbors, ArtifactRef) or neighbors.kind != "neighbors": - raise ModelRetry("neighbors must identify an exact neighbors artifact") - if connectivity is not None and ( - not isinstance(connectivity, ArtifactRef) - or connectivity.kind not in {"connectivity_map", "integrated_graph"} - ): - raise ModelRetry( - "connectivity_map must identify an exact connectivity graph artifact" - ) - - metrics: dict[str, float] = {} - notes: list[str] = [] - evidence_ids: list[str] = [] - neighbor_route = f"assay:{neighbors.assay}:neighbors:{neighbors.artifact_id}" - try: - value = float(store.metric_ilisi(batch_column, neighbors)) - if math.isfinite(value): - metrics[f"iLISI:{batch_column}"] = value - evidence_ids.append(f"metric:iLISI:{batch_column}:{neighbor_route}") - except (KeyError, TypeError, ValueError, RuntimeError) as exc: - logger.debug( - "Experimental Context iLISI scoring was unavailable: " - f"errorType={type(exc).__name__}" - ) - notes.append(f"iLISI could not be scored: {exc}") - try: - value = float(store.metric_proportional_batch_mixing(batch_column, neighbors)) - if math.isfinite(value): - metrics[f"proportionalBatchMixing:{batch_column}"] = value - evidence_ids.append( - f"metric:proportionalBatchMixing:{batch_column}:{neighbor_route}" - ) - except (KeyError, TypeError, ValueError, RuntimeError) as exc: - logger.debug( - "Experimental Context batch-mixing scoring was unavailable: " - f"errorType={type(exc).__name__}" - ) - notes.append(f"Proportional batch mixing could not be scored: {exc}") - if biological_column is not None: - try: - value = float(store.metric_clisi(biological_column, neighbors)) - if math.isfinite(value): - metrics[f"cLISI:{biological_column}"] = value - evidence_ids.append( - f"metric:cLISI:{biological_column}:{neighbor_route}" - ) - except (KeyError, TypeError, ValueError, RuntimeError) as exc: - logger.debug( - "Experimental Context cLISI scoring was unavailable: " - f"errorType={type(exc).__name__}" - ) - notes.append(f"cLISI could not be scored: {exc}") - if connectivity is not None: - try: - value = float( - store.metric_graph_connectivity(biological_column, connectivity) - ) - if math.isfinite(value): - metrics[f"graphConnectivity:{biological_column}"] = value - evidence_ids.append( - "metric:graphConnectivity:" - f"{biological_column}:assay:{connectivity.assay}:connectivity:" - f"{connectivity.artifact_id}" - ) - except (KeyError, TypeError, ValueError, RuntimeError) as exc: - logger.debug( - "Experimental Context connectivity scoring was unavailable: " - f"errorType={type(exc).__name__}" - ) - notes.append(f"Graph connectivity could not be scored: {exc}") - - evaluation = RepresentationEvaluation( - available=bool(metrics), - assay=neighbors.assay, - cellSelection=( - artifact_reference(ctx.deps.cellSelection) - if ctx.deps.cellSelection is not None - else None - ), - neighbors=artifact_reference(neighbors), - connectivityMap=( - artifact_reference(connectivity) if connectivity is not None else None - ), - metrics=metrics, - notes=notes, - evidenceIds=evidence_ids, - ) - ctx.deps.currentRepresentation = evaluation - ctx.deps.evidenceIds.update(evidence_ids) - ctx.deps.toolCalls.append("score_current_representation") - logger.info( - "Experimental Context representation scoring completed: " - f"available={evaluation.available}, metrics={len(metrics)}, " - f"notes={len(notes)}, evidence={len(evidence_ids)}" - ) - return evaluation - - -def _canonical_cell_qc_plan( - plan: CellQcPlan, - deps: ExperimentalContextDependencies, - characterization: CovariateCharacterization, -) -> CellQcPlan: - """Resolve one exact offered profile and reject model-authored parameters.""" - if not deps.qcProfiles: - _offered_qc_profiles(deps, characterization) - directed = deps.directions.get("cellQc") - direction_map = dict(directed) if isinstance(directed, Mapping) else {} - directed_profile_id = direction_map.get("profileId") - if directed_profile_id is not None and not isinstance(directed_profile_id, str): - raise ModelRetry("cellQc.profileId direction must be a string") - - has_directed_selector = any( - key in direction_map - for key in ( - "profileId", - "registeredProfile", - "action", - "sampleColumn", - "sampleArtifactName", - ) - ) - selected_id = directed_profile_id or ( - "" if has_directed_selector else plan.profileId - ) - if not selected_id: - requested_action = direction_map.get("action") - requested_registered_profile = direction_map.get("registeredProfile") - requested_sample = direction_map.get("sampleColumn") - requested_sample_artifact = direction_map.get("sampleArtifactName") - if requested_sample is not None and requested_sample_artifact is not None: - raise ModelRetry( - "cellQc directions cannot select both sampleColumn and " - "sampleArtifactName" - ) - if requested_sample_artifact is not None and not isinstance( - requested_sample_artifact, str - ): - raise ModelRetry("cellQc.sampleArtifactName must be a string") - if requested_action is not None and requested_action not in { - "skip", - "globalGaussian", - "sampleMad", - "registeredMad", - }: - raise ModelRetry(f"Unsupported cellQc.action {requested_action!r}") - if requested_registered_profile is not None and not isinstance( - requested_registered_profile, str - ): - raise ModelRetry("cellQc.registeredProfile must be a string") - if ( - requested_registered_profile is not None - and requested_registered_profile - not in { - "retainWithFlags", - "globalMad5", - "captureMad5", - "captureMad3Sensitivity", - "pooledReferenceMad5", - } - ): - raise ModelRetry( - f"Unsupported cellQc.registeredProfile {requested_registered_profile!r}" - ) - matches = [ - profile - for profile in deps.qcProfiles.values() - if (requested_action is None or profile.action == requested_action) - and ( - requested_registered_profile is None - or profile.registeredProfile == requested_registered_profile - ) - and (requested_sample is None or profile.sampleColumn == requested_sample) - and ( - requested_sample_artifact is None - or ( - profile.sampleArtifact is not None - and profile.sampleArtifact.name == requested_sample_artifact - ) - ) - ] - if requested_action is not None or requested_registered_profile is not None: - if len(matches) != 1: - raise ModelRetry( - "cellQc directions must identify exactly one offered profile" - ) - selected_id = matches[0].profileId - else: - global_profiles = [ - profile - for profile in deps.qcProfiles.values() - if profile.action == "globalGaussian" - ] - if global_profiles: - selected_id = global_profiles[0].profileId - else: - selected_id = next( - profile.profileId - for profile in deps.qcProfiles.values() - if profile.action == "skip" - ) - - profile = deps.qcProfiles.get(selected_id) - if profile is None: - raise ModelRetry( - f"Cell-QC profile {selected_id!r} was not offered by the evidence tool" - ) - model_selected = bool(plan.profileId) and not has_directed_selector - if model_selected: - expected_fields = { - "action": profile.action, - "registeredProfile": profile.registeredProfile, - "driverAssay": profile.driverAssay, - "driverAssayType": profile.driverAssayType, - "sampleColumn": profile.sampleColumn, - "sampleArtifact": profile.sampleArtifact, - "attributes": profile.attributes, - "artifactMetrics": profile.artifactMetrics, - } - mismatches = [ - name - for name, expected in expected_fields.items() - if getattr(plan, name) != expected - ] - if mismatches: - raise ModelRetry( - "Cell-QC plan must copy the selected offered profile exactly: " - f"{mismatches}" - ) - if profile.evidenceId not in plan.evidenceIds: - raise ModelRetry( - "Cell-QC plan must cite its exact profile retention evidence" - ) - rationale = plan.rationale.strip() - if not rationale: - rationale = ( - "Selected the caller-directed bounded cell-QC profile." - if direction_map - else "Selected the bounded default cell-QC profile." - ) - cited_evidence = plan.evidenceIds if model_selected else [] - return CellQcPlan( - action=profile.action, - registeredProfile=profile.registeredProfile, - profileId=profile.profileId, - driverAssay=profile.driverAssay, - driverAssayType=profile.driverAssayType, - sampleColumn=profile.sampleColumn, - sampleArtifact=profile.sampleArtifact, - attributes=profile.attributes, - artifactMetrics=profile.artifactMetrics, - rationale=rationale, - evidenceIds=sorted({*cited_evidence, profile.evidenceId}), - ) - - -def _validate_batch_correction_plan( - decision: ExperimentalContextDecision, - deps: ExperimentalContextDependencies, - characterization: CovariateCharacterization, - requested_coefficients: set[str], - units_of_inference: dict[str, dict[str, Any]], - records: dict[str, dict[str, Any]], - coefficient_records: dict[str, dict[str, Any]], -) -> None: - """Validate one batch plan against exact design, safety, and metric evidence.""" - confounding_reports = { - report.get("coefficient"): report - for report in characterization.confounding - if isinstance(report.get("coefficient"), str) - } - plan = decision.batchCorrection - directed_batch_columns = deps.directions.get("batchColumns") - if directed_batch_columns is not None: - if not isinstance(directed_batch_columns, list) or any( - not isinstance(value, str) or not value.strip() - for value in directed_batch_columns - ): - raise ModelRetry( - "directions.batchColumns must be a list of exact metadata columns" - ) - canonical_directed_batch = sorted(directed_batch_columns) - directed_plan_mismatch = ( - ( - plan.action not in {"evaluateHarmony", "unsafe"} - or sorted(plan.batchColumns) != canonical_directed_batch - ) - if canonical_directed_batch - else plan.action != "skip" or bool(plan.batchColumns) - ) - if directed_plan_mismatch: - raise ModelRetry( - "The batch-correction plan must assess the exact directed batch " - f"columns: {canonical_directed_batch}" - ) - unknown_columns = sorted(set(decision.columnDomains) - set(records)) - if unknown_columns: - raise ModelRetry(f"Unknown column domain assignments: {unknown_columns}") - unit_columns = { - unit_name - for unit in units_of_inference.values() - for unit_name in ( - unit.get("observationUnit"), - unit.get("independentUnit"), - ) - if isinstance(unit_name, str) - } - if plan.action == "evaluateHarmony" and not plan.batchColumns: - raise ModelRetry("evaluateHarmony requires at least one batch column") - if plan.action == "unsafe" and not plan.batchColumns: - raise ModelRetry("unsafe requires the exact batch columns that were assessed") - if plan.action == "skip" and plan.batchColumns: - raise ModelRetry("skip must not include batch columns") - if plan.action == "needsInput" and not decision.needsInput: - raise ModelRetry("needsInput action requires at least one concrete question") - if len(set(plan.batchColumns)) != len(plan.batchColumns): - raise ModelRetry("Batch columns must be unique") - - for batch_column in plan.batchColumns: - record = records.get(batch_column) - if record is None: - raise ModelRetry(f"Unknown batch column {batch_column!r}") - if record.get("domain") != "technical": - raise ModelRetry( - f"Batch column {batch_column!r} must be classified as technical" - ) - if record.get("kind") != "categorical": - raise ModelRetry( - f"Batch column {batch_column!r} must be categorical for Harmony" - ) - if batch_column in requested_coefficients or batch_column in unit_columns: - raise ModelRetry( - f"Batch column {batch_column!r} cannot be a coefficient or unit of inference" - ) - - if plan.action == "evaluateHarmony": - mixing_metrics = {"iLISI", "proportionalBatchMixing"} - preservation_metrics = {"cLISI", "graphConnectivity"} - if not mixing_metrics.intersection(plan.metricsRequired): - raise ModelRetry( - "evaluateHarmony requires iLISI or proportionalBatchMixing" - ) - if plan.preserveColumns and not preservation_metrics.intersection( - plan.metricsRequired - ): - raise ModelRetry( - "evaluateHarmony requires cLISI or graphConnectivity for preservation" - ) - missing_preserve = sorted(requested_coefficients - set(plan.preserveColumns)) - if missing_preserve: - raise ModelRetry( - "preserveColumns must include every coefficient of interest: " - f"{missing_preserve}" - ) - unresolved_coefficients = sorted( - coefficient - for coefficient in requested_coefficients - if coefficient_records[coefficient].get("scope") != "betweenUnit" - or coefficient not in confounding_reports - ) - if unresolved_coefficients: - raise ModelRetry( - "evaluateHarmony requires a between-unit coefficient with a " - "matching estimability report; use needsInput or unsafe for: " - f"{unresolved_coefficients}" - ) - for preserve_column in plan.preserveColumns: - record = records.get(preserve_column) - if record is None: - raise ModelRetry(f"Unknown preservation column {preserve_column!r}") - if record.get("domain") != "biological": - raise ModelRetry( - f"Preservation column {preserve_column!r} must be biological" - ) - if record.get("kind") != "categorical": - raise ModelRetry( - f"Preservation column {preserve_column!r} must be categorical" - ) - - matched_safety: list[BatchSafetyEvidence] = [] - if plan.action in {"evaluateHarmony", "unsafe"}: - canonical_batch_columns = sorted(plan.batchColumns) - for coefficient in sorted(requested_coefficients): - coefficient_record = coefficient_records[coefficient] - report = confounding_reports.get(coefficient) - observation_unit = ( - report.get("observationUnit") - if report is not None - else coefficient_record.get("observationUnit") - ) - unit_constant = { - pair.get("technical") - for pair in (report.get("pairs", []) if report is not None else []) - if isinstance(pair.get("technical"), str) - } - expected_effective = [ - name for name in canonical_batch_columns if name in unit_constant - ] - candidates = [ - item - for item in deps.batchSafety.values() - if item.coefficient == coefficient - and item.coefficientKind == coefficient_record.get("kind") - and item.observationUnit == observation_unit - and item.batchColumns == canonical_batch_columns - and item.unitConstantBatchColumns == expected_effective - ] - if len(candidates) != 1: - raise ModelRetry( - "Call analyze_experimental_design with the exact proposed batch " - f"columns before returning a recommendation for {coefficient!r}" - ) - matched_safety.append(candidates[0]) - missing_safety_evidence = sorted( - item.evidenceId - for item in matched_safety - if item.evidenceId not in plan.evidenceIds - ) - if missing_safety_evidence: - raise ModelRetry( - "Batch-correction recommendations must cite exact batch " - f"estimability evidence: {missing_safety_evidence}" - ) - not_computed = [ - item.coefficient for item in matched_safety if item.status == "notComputed" - ] - if not_computed: - raise ModelRetry( - "Batch estimability could not be computed; use action='needsInput' " - f"for: {sorted(not_computed)}" - ) - unsafe_coefficients = [ - item.coefficient for item in matched_safety if item.status == "unsafe" - ] - if plan.action == "evaluateHarmony" and unsafe_coefficients: - raise ModelRetry( - "Batch correction is unsafe because the biological coefficient is " - "not estimable after the exact proposed batch columns; use " - f"action='unsafe' for: {sorted(unsafe_coefficients)}" - ) - if plan.action == "unsafe" and not unsafe_coefficients: - raise ModelRetry( - "The exact proposed batch columns were estimable for every " - "coefficient; use action='evaluateHarmony' or 'skip'" - ) - - cited_ids = [ - *decision.evidenceIds, - *plan.evidenceIds, - ] - unknown_evidence = sorted(set(cited_ids) - deps.evidenceIds) - if unknown_evidence: - raise ModelRetry(f"Unknown evidence IDs: {unknown_evidence}") - if plan.action in {"evaluateHarmony", "skip", "unsafe"} and not plan.evidenceIds: - raise ModelRetry("Batch-correction recommendations require evidence IDs") - current_metric_evidence = set(deps.currentRepresentation.evidenceIds) - stale_metric_evidence = sorted( - evidence_id - for evidence_id in cited_ids - if evidence_id.startswith("metric:") - and evidence_id not in current_metric_evidence - ) - if stale_metric_evidence: - raise ModelRetry( - "Metric evidence must come from the returned exact representation: " - f"{stale_metric_evidence}" - ) - - -def validate_experimental_context( - decision: ExperimentalContextDecision, - deps: ExperimentalContextDependencies, -) -> ExperimentalContextDecision: - """Recompute and validate every model-authored design choice.""" - narrative_fields = { - "rationale": decision.rationale, - "batchCorrection.rationale": decision.batchCorrection.rationale, - **{ - f"needsInput[{index}]": question - for index, question in enumerate(decision.needsInput) - }, - } - serialized_field_markers = ( - '"evidenceIds":', - '"needsInput":', - '"runInfo":', - '"batchCorrection":', - '"cellQc":', - ) - invalid_narratives = [ - name - for name, value in narrative_fields.items() - if any( - marker in value.replace('\\"', '"') for marker in serialized_field_markers - ) - ] - if invalid_narratives: - raise ModelRetry( - "Narrative fields must contain plain prose without serialized sibling " - f"fields: {invalid_narratives}" - ) - directions = dict(deps.directions) - column_domains = dict(decision.columnDomains) - column_domains.update(dict(directions.get("columnDomains") or {})) - directions["columnDomains"] = column_domains - directions["coefficientsOfInterest"] = list( - dict.fromkeys( - [ - *decision.coefficientsOfInterest, - *(directions.get("coefficientsOfInterest") or []), - ] - ) - ) - units_of_inference = { - name: unit.model_dump(exclude_none=True) - for name, unit in decision.unitsOfInference.items() - } - units_of_inference.update(dict(directions.get("unitsOfInference") or {})) - directions["unitsOfInference"] = units_of_inference - - characterization = characterize_covariates( - deps.store, - cellSelection=deps.cellSelection, - studyContext=f"{deps.studyContext}\nStudy objective: {deps.studyObjective}", - model=None, - directions=directions, - groupingArtifacts=_hto_artifact_map(deps), - ) - if characterization.status == "failed": - raise ModelRetry("; ".join(characterization.notes)) - deps.characterization = characterization - deps.evidenceIds.update(characterization_evidence(characterization)) - contrast_plans = contrast_plans_from_characterization(characterization) - deps.contrastPlans = {plan.coefficient: plan for plan in contrast_plans} - deps.evidenceIds.update(plan.evidenceId for plan in contrast_plans) - - if "inspect_cell_covariates" not in deps.toolCalls: - raise ModelRetry("Call inspect_cell_covariates before returning a decision") - if "analyze_experimental_design" not in deps.toolCalls: - raise ModelRetry("Call analyze_experimental_design before returning a decision") - - if decision.cellQc != CellQcPlan.get_blank(): - raise ModelRetry( - "Experimental Context must leave cellQc blank; the audited filtering " - "checkpoint selects from qcProfiles" - ) - if not deps.qcProfiles: - _offered_qc_profiles(deps, characterization) - deps.evidenceIds.update(profile.evidenceId for profile in deps.qcProfiles.values()) - deps.evidenceIds.update(source.sourceId for source in deps.qcMetricSources) - deps.evidenceIds.update(item.evidenceId for item in deps.qcSourceConcordance) - - requested_coefficients = set(directions["coefficientsOfInterest"]) - characterized_coefficients = { - record.get("name") for record in characterization.coefficients - } - missing_coefficients = sorted( - name - for name in requested_coefficients - if name not in characterized_coefficients - ) - if missing_coefficients: - raise ModelRetry( - "Coefficients of interest must be classified as biological: " - f"{missing_coefficients}" - ) - - coefficient_records: dict[str, dict[str, Any]] = {} - for record in characterization.coefficients: - name = record.get("name") - if isinstance(name, str): - coefficient_records[name] = record - records: dict[str, dict[str, Any]] = {} - for record in characterization.columns: - name = record.get("name") - if isinstance(name, str): - records[name] = record - _validate_batch_correction_plan( - decision, - deps, - characterization, - requested_coefficients, - units_of_inference, - records, - coefficient_records, - ) - canonical_domains = { - name: records[name]["domain"] - for name in column_domains - if name in records - and records[name].get("domain") - in { - "biological", - "technical", - "design", - "ignore", - "unknown", - } - } - canonical_units = { - coefficient: InferenceUnit( - observationUnit=coefficient_records[coefficient].get("observationUnit"), - independentUnit=coefficient_records[coefficient].get("independentUnit"), - ) - for coefficient in directions["coefficientsOfInterest"] - if coefficient in coefficient_records - } - validated = decision.model_copy( - update={ - "columnDomains": canonical_domains, - "coefficientsOfInterest": list(directions["coefficientsOfInterest"]), - "unitsOfInference": canonical_units, - "cellQc": CellQcPlan.get_blank(), - } - ) - logger.debug( - "Experimental Context decision validated: " - f"domains={len(validated.columnDomains)}, " - f"coefficients={len(validated.coefficientsOfInterest)}, " - f"qcProfiles={len(deps.qcProfiles)}, " - f"batchCorrection={validated.batchCorrection.action}, " - f"needsInput={len(validated.needsInput)}" - ) - return validated - - -def _deterministic_experimental_context_decision( - deps: ExperimentalContextDependencies, -) -> ExperimentalContextDecision: - characterization = deps.characterization - if characterization is None or characterization.status == "failed": - raise ValueError("Deterministic covariate characterization is unavailable") - records: dict[str, dict[str, Any]] = {} - for record in characterization.columns: - name = record.get("name") - if isinstance(name, str): - records[name] = record - coefficient_records: dict[str, dict[str, Any]] = {} - for record in characterization.coefficients: - name = record.get("name") - if isinstance(name, str): - coefficient_records[name] = record - directions = dict(deps.directions) - raw_batch_columns = directions.get("batchColumns") - if raw_batch_columns is not None: - if not isinstance(raw_batch_columns, list) or any( - not isinstance(value, str) or not value.strip() - for value in raw_batch_columns - ): - raise ValueError( - "directions.batchColumns must be a list of exact metadata columns" - ) - if len(raw_batch_columns) != len(set(raw_batch_columns)): - raise ValueError("directions.batchColumns must be unique") - batch_columns = list(raw_batch_columns) - else: - candidates = sorted( - name - for name, record in records.items() - if record.get("domain") == "technical" - and record.get("kind") == "categorical" - ) - if "batch" in candidates: - batch_columns = ["batch"] - elif len(candidates) <= 1: - batch_columns = candidates - else: - raise ValueError( - "Multiple categorical technical columns remain without one exact " - "batch condition" - ) - - coefficients = [ - str(record["name"]) - for record in characterization.coefficients - if isinstance(record.get("name"), str) - ] - units = { - coefficient: InferenceUnit( - observationUnit=coefficient_records[coefficient].get("observationUnit"), - independentUnit=coefficient_records[coefficient].get("independentUnit"), - ) - for coefficient in coefficients - if coefficient in coefficient_records - } - batch_safety = _batch_safety_evidence( - deps, - characterization, - coefficients=coefficients, - batch_columns=batch_columns, - ) - unresolved_safety = [ - item.coefficient for item in batch_safety if item.status == "notComputed" - ] - if unresolved_safety: - raise ValueError( - "Batch estimability is unavailable for coefficients: " - f"{sorted(unresolved_safety)}" - ) - if batch_columns and any(item.status == "unsafe" for item in batch_safety): - action: BatchCorrectionAction = "unsafe" - elif batch_columns: - action = "evaluateHarmony" - else: - action = "skip" - categorical_coefficients = [ - coefficient - for coefficient in coefficients - if records[coefficient].get("kind") == "categorical" - ] - if action == "evaluateHarmony" and set(categorical_coefficients) != set( - coefficients - ): - raise ValueError( - "Harmony preservation requires categorical coefficients of interest" - ) - - known_evidence = sorted(characterization_evidence(characterization)) - batch_evidence = [ - *(f"column:{column}" for column in batch_columns), - *(item.evidenceId for item in batch_safety), - ] - if not batch_evidence: - batch_evidence = known_evidence[:1] - if not batch_evidence: - raise ValueError("No deterministic evidence supports a batch decision") - deps.evidenceIds.update(known_evidence) - deps.evidenceIds.update(batch_evidence) - if "analyze_experimental_design" not in deps.toolCalls: - deps.toolCalls.append("analyze_experimental_design") - column_domains = { - name: cast(ColumnDomain, record["domain"]) - for name, record in records.items() - if record.get("domain") - in {"biological", "technical", "design", "ignore", "unknown"} - } - metrics_required: list[IntegrationMetric] = [] - if action == "evaluateHarmony": - metrics_required = ["iLISI", "proportionalBatchMixing"] - if categorical_coefficients: - metrics_required.extend(["cLISI", "graphConnectivity"]) - plan = BatchCorrectionPlan( - action=action, - batchColumns=batch_columns if action != "skip" else [], - preserveColumns=( - categorical_coefficients if action == "evaluateHarmony" else [] - ), - metricsRequired=metrics_required, - rationale=( - "Evaluate the exact declared categorical technical batch condition " - "against the uncorrected representation." - if action == "evaluateHarmony" - else "The exact batch condition is confounded with the study design." - if action == "unsafe" - else "No exact categorical technical batch condition was available." - ), - evidenceIds=sorted(set(batch_evidence)), - ) - decision = ExperimentalContextDecision( - columnDomains=column_domains, - coefficientsOfInterest=coefficients, - unitsOfInference=units, - batchCorrection=plan, - rationale=( - "Deterministic covariate characterization resolved the study design " - "after the model tool call failed." - ), - evidenceIds=known_evidence, - ) - return validate_experimental_context(decision, deps) - - -def failed_experimental_context_result( - deps: ExperimentalContextDependencies, - *, - error: Exception, - fallback_error: Exception, - model_name: str, -) -> ExperimentalContextResult: - """Fail unattended execution when deterministic design evidence is insufficient.""" - characterization = deps.characterization or CovariateCharacterization( - status="failed", - notes=["Deterministic covariate characterization is unavailable."], - ) - model_detail = str(error).replace("\n", " ").strip()[:500] - fallback_detail = str(fallback_error).replace("\n", " ").strip()[:500] - return ExperimentalContextResult( - status="failed", - decision=ExperimentalContextDecision( - rationale="No validated experimental-context decision was available.", - evidenceIds=sorted(deps.evidenceIds), - ), - characterization=characterization, - cellSelection=artifact_reference(deps.cellSelection), - cellQc=CellQcPlan.get_blank(), - qcProfiles=list(deps.qcProfiles.values()), - qcMetricSources=deps.qcMetricSources, - qcSourceConcordance=deps.qcSourceConcordance, - contrastPlans=list(deps.contrastPlans.values()), - qualityMetricArtifacts=deps.qualityMetricArtifacts, - htoIdentityColumns=deps.htoIdentityColumns, - htoIdentityArtifacts=deps.htoIdentityArtifacts, - batchSafety=list(deps.batchSafety.values()), - currentRepresentation=deps.currentRepresentation, - notes=[ - "The model did not produce a validated experimental-context decision.", - f"Model failure: {model_detail}", - f"Deterministic recovery failure: {fallback_detail}", - ], - runInfo=AgentRunInfo( - agentName="experimental_context_failed", - modelName=model_name, - ), - ) - - -def pending_experimental_context_result( - deps: ExperimentalContextDependencies, - *, - error: UnexpectedModelBehavior, - model_name: str, -) -> ExperimentalContextResult: - """Pause when the model exhausts its bounded decision budget.""" - characterization = deps.characterization - if characterization is None: - characterization = characterize_covariates( - deps.store, - cellSelection=deps.cellSelection, - studyContext=( - f"{deps.studyContext}\nStudy objective: {deps.studyObjective}" - ), - model=None, - directions=deps.directions, - groupingArtifacts=_hto_artifact_map(deps), - ) - deps.characterization = characterization - if not deps.htoIdentityColumns: - deps.htoIdentityColumns = _hto_identity_columns(deps) - qc_profiles = list(deps.qcProfiles.values()) - if not qc_profiles: - qc_profiles = _offered_qc_profiles(deps, characterization) - contrast_plans = contrast_plans_from_characterization(characterization) - deps.contrastPlans = {plan.coefficient: plan for plan in contrast_plans} - evidence_ids = characterization_evidence(characterization) - evidence_ids.update(profile.evidenceId for profile in qc_profiles) - evidence_ids.update(source.sourceId for source in deps.qcMetricSources) - evidence_ids.update(item.evidenceId for item in deps.qcSourceConcordance) - evidence_ids.update(plan.evidenceId for plan in contrast_plans) - evidence_ids.update(f"htoIdentity:{column}" for column in deps.htoIdentityColumns) - evidence_ids.update( - _artifact_evidence_id(source) for source in deps.htoIdentityArtifacts - ) - deps.evidenceIds.update(evidence_ids) - question = ( - "The Experimental Context agent could not produce a validated scientific " - "decision. Provide explicit metadata roles, units of inference, cell-QC " - "profile, and batch-correction intent before continuing." - ) - decision = ExperimentalContextDecision( - batchCorrection=BatchCorrectionPlan(action="needsInput"), - cellQc=CellQcPlan.get_blank(), - rationale="No scientific decision was selected.", - evidenceIds=sorted(evidence_ids), - needsInput=[question], - ) - error_detail = str(error).replace("\n", " ").strip()[:500] - logger.warning( - "Experimental Context paused without a scientific decision: " - f"reason={error_detail}" - ) - return ExperimentalContextResult( - status=("failed" if characterization.status == "failed" else "needsInput"), - decision=decision, - characterization=characterization, - cellSelection=artifact_reference(deps.cellSelection), - cellQc=CellQcPlan.get_blank(), - qcProfiles=qc_profiles, - qcMetricSources=deps.qcMetricSources, - qcSourceConcordance=deps.qcSourceConcordance, - contrastPlans=contrast_plans, - qualityMetricArtifacts=deps.qualityMetricArtifacts, - htoIdentityColumns=deps.htoIdentityColumns, - htoIdentityArtifacts=deps.htoIdentityArtifacts, - batchSafety=list(deps.batchSafety.values()), - currentRepresentation=deps.currentRepresentation, - notes=[*characterization.notes, question, error_detail], - runInfo=AgentRunInfo( - agentName="experimental_context_needs_input", - modelName=model_name, - ), - ) - - -class ExperimentalContextAgent: - """A narrow agent for study design and batch-correction planning.""" - - def __init__( - self, - model: Any, - *, - config: AgentRunConfig | None = None, - unattended: bool = False, - ) -> None: - self.model = model - self.unattended = unattended - self.config = (config or AgentRunConfig()).with_limits( - request_limit=9, - tool_call_limit=5, - output_token_limit=32768, - timeout_seconds=600.0, - ) - self.system_prompt = ( - dedent( - """ - You are Scarf's Experimental Context Agent. Work only through the - provided read-only tools and return the structured decision schema. - - Call inspect_cell_covariates exactly once. Then call - analyze_experimental_design exactly once with all explicit domains, - all biological coefficients, every unit of inference, and the complete - exact batch-column set being considered. You may call - score_current_representation at most once when an exact supplied graph - can add evidence. Do not split metadata, coefficients, or batch columns across - calls, and do not repeat a tool call. Pass batch_columns as a JSON array, - including when the array contains exactly one column. Each tool is - removed after it succeeds, so include the complete decision context in - its single call. - - The tools return bounded cell-QC profiles projected against the exact - shared cell selection. Do not choose a profile and leave cellQc blank. - A later audited checkpoint selects one registered profile. Never author - or alter numeric quality bounds. RNA is the preferred QC driver and - ATAC is the fallback. ADT and HTO never drive automatic cell filtering. - An exact HTO identity artifact may be used as grouping evidence. It is - not a live metadata column and does not make HTO a QC driver. - - A batch column must be categorical and technical. Never use donor, - sample, observation-unit, independent-unit, biological, cluster, or - embedding columns as Harmony batch columns. A biological coefficient - that is not estimable with the exact proposed batch columns makes - correction unsafe. A sample or library identifier is not automatically - technical. When no exact observed column is both categorical and - technical, pass batch_columns=[] and recommend skipping Harmony. Every - observation and independent unit must be an exact observed column name - or null. - LISI evaluates a representation; it does not identify which metadata - column is a batch. Recommend evaluateHarmony, not application, because - Parameter Tuning must compare exact uncorrected and corrected artifacts. - - Cite only evidenceIds returned by tools. Ask for input when study - design cannot be resolved. The study objective is authoritative: use - it to identify protected biological variables and the intended unit - of inference, but do not broaden it or claim to test a hypothesis. - Never propose Python, shell commands, - direct Zarr access, or any datastore mutation. Every rationale and - question must be plain prose. Never place serialized JSON, schema - field names, or sibling output fields inside a narrative string. - Return only fields defined by the structured output schema. - """ - ) - .strip() - .format() - ) - - def run( - self, - store: Any, - *, - study_context: str | None = None, - study_objective: str | None = None, - cell_selection: ArtifactRef | None = None, - directions: Mapping[str, Any] | None = None, - run: "PipelineRun | None" = None, - neighbors: ArtifactRef | None = None, - connectivity_map: ArtifactRef | None = None, - quality_metric_artifacts: Sequence[NamedArtifactSource] = (), - hto_identity_artifacts: Sequence[NamedArtifactSource] = (), - ) -> ExperimentalContextResult: - """Inspect one datastore and return a validated experimental-context report.""" - study_context = (study_context or "").strip() - study_objective = (study_objective or "").strip() - if len(study_context) > _CONTEXT_LIMIT: - study_context = study_context[: _CONTEXT_LIMIT - 3] + "..." - if len(study_objective) > _CONTEXT_LIMIT: - study_objective = study_objective[: _CONTEXT_LIMIT - 3] + "..." - direction_map = dict(directions or {}) - if run is not None: - if ( - cell_selection is not None - or neighbors is not None - or connectivity_map is not None - ): - raise ValueError( - "run is mutually exclusive with explicit artifact inputs" - ) - if getattr(run, "_owner", store) is not store: - raise ValueError("run must be opened from this datastore") - neighbors = run["neighbors"] - cell_selection = run["analysis_cell_selection"] - connectivity_map = ( - run["connectivity_map"] if "connectivity_map" in run else None - ) - cell_selection = core_artifact_reference(cell_selection) - neighbors = core_artifact_reference(neighbors) - connectivity_map = core_artifact_reference(connectivity_map) - if not isinstance(cell_selection, ArtifactRef) or ( - cell_selection.kind != "cell_selection" - or cell_selection.scope != "datastore" - ): - raise TypeError( - "cell_selection must be a datastore cell_selection ArtifactRef" - ) - if neighbors is not None: - if not isinstance(neighbors, ArtifactRef) or neighbors.kind != "neighbors": - raise TypeError("neighbors must be a neighbors ArtifactRef") - if graph_cell_selection(store.zw, neighbors) != cell_selection: - raise ValueError( - "neighbors and metadata must use the same cell selection" - ) - if connectivity_map is not None: - if not isinstance( - connectivity_map, ArtifactRef - ) or connectivity_map.kind not in { - "connectivity_map", - "integrated_graph", - }: - raise TypeError( - "connectivity_map must be a connectivity graph ArtifactRef" - ) - if graph_cell_selection(store.zw, connectivity_map) != cell_selection: - raise ValueError( - "neighbors and connectivity_map must use the same cell selection" - ) - quality_sources = _derive_missing_percentage_artifacts( - store, - cell_selection=cell_selection, - driver=_qc_driver(store), - quality_sources=quality_metric_artifacts, - ) - hto_sources = list(hto_identity_artifacts) - source_names: set[str] = set() - for sources, expected_kind in ( - (quality_sources, "quality_metric"), - (hto_sources, "hto_identity"), - ): - for source in sources: - artifact = _source_ref(source, expected_kind=expected_kind) - if source.name in source_names: - raise ValueError( - "Experimental Context artifact source names must be unique" - ) - source_names.add(source.name) - resolve_cell_aligned_artifact( - store.zw, - artifact, - cell_selection=cell_selection, - expected_kind=expected_kind, - ) - directed_qc = direction_map.get("cellQc") - directed_qc_map = dict(directed_qc) if isinstance(directed_qc, Mapping) else {} - if "cellKey" in directed_qc_map: - raise ValueError( - "cellQc.cellKey is unsupported; use the exact cell_selection input" - ) - logger.info( - "Experimental Context Agent started: " - f"cellSelection={cell_selection.artifact_id}, " - f"directions={len(direction_map)}, " - f"qualityMetrics={len(quality_sources)}, " - f"htoIdentities={len(hto_sources)}, " - f"studyContextProvided={bool(study_context)}, " - f"studyObjectiveProvided={bool(study_objective)}" - ) - deps = ExperimentalContextDependencies( - store=store, - cells=_SelectionBoundCells( - store.zw, - store.cells, - cell_selection, - artifacts={ - source.name: _source_ref( - source, - expected_kind="hto_identity", - ) - for source in hto_sources - }, - ), - neighbors=neighbors, - connectivityMap=connectivity_map, - cellSelection=cell_selection, - studyContext=study_context, - studyObjective=study_objective, - directions=direction_map, - qualityMetricArtifacts=quality_sources, - htoIdentityArtifacts=hto_sources, - ) - user_prompt = ( - dedent( - """ - Characterize this experiment's metadata and decide whether Harmony - should be evaluated. Return cell-QC candidates as tool evidence; - leave cellQc blank for the later audited filtering checkpoint. - - Study context: {study_context} - Study objective: {study_objective} - Exact cell-selection artifact: {cell_selection} - Exact quality-metric artifacts: {quality_metrics} - Exact HTO identity artifacts: {hto_identities} - Caller directions: {directions} - """ - ) - .strip() - .format( - study_context=study_context or "not provided", - study_objective=study_objective or "not provided", - cell_selection=cell_selection.artifact_id, - quality_metrics=json.dumps( - [source.model_dump(mode="json") for source in quality_sources], - sort_keys=True, - ), - hto_identities=json.dumps( - [source.model_dump(mode="json") for source in hto_sources], - sort_keys=True, - ), - directions=json.dumps(direction_map, sort_keys=True, default=str), - ) - ) - try: - execution = run_agent_sync( - model=self.model, - output_type=ExperimentalContextDecision, - system_prompt=self.system_prompt, - user_prompt=user_prompt, - tools=( - Tool( - inspect_cell_covariates, - prepare=_prepare_experimental_context_tool, - sequential=self.config.sequentialTools, - timeout=self.config.timeoutSeconds, - ), - Tool( - analyze_experimental_design, - max_retries=3, - prepare=_prepare_experimental_context_tool, - sequential=self.config.sequentialTools, - timeout=self.config.timeoutSeconds, - ), - Tool( - score_current_representation, - prepare=_prepare_experimental_context_tool, - sequential=self.config.sequentialTools, - timeout=self.config.timeoutSeconds, - ), - ), - deps_type=ExperimentalContextDependencies, - deps=deps, - config=self.config, - name="experimental_context", - output_validator=lambda decision: validate_experimental_context( - decision, - deps, - ), - ) - except UnexpectedModelBehavior as exc: - model_name = getattr(self.model, "model_name", type(self.model).__name__) - if self.unattended: - try: - decision = _deterministic_experimental_context_decision(deps) - except ( - ModelRetry, - RuntimeError, - TypeError, - ValueError, - ) as fallback_exc: - return failed_experimental_context_result( - deps, - error=exc, - fallback_error=fallback_exc, - model_name=str(model_name), - ) - run_info = AgentRunInfo( - agentName="experimental_context_deterministic", - modelName=str(model_name), - ) - else: - return pending_experimental_context_result( - deps, - error=exc, - model_name=str(model_name), - ) - else: - decision = ExperimentalContextDecision.model_validate(execution.output) - run_info = execution.runInfo - if self.unattended and ( - decision.needsInput or decision.batchCorrection.action == "needsInput" - ): - try: - decision = _deterministic_experimental_context_decision(deps) - except (ModelRetry, RuntimeError, TypeError, ValueError) as fallback_exc: - model_name = getattr( - self.model, "model_name", type(self.model).__name__ - ) - return failed_experimental_context_result( - deps, - error=RuntimeError( - "The model returned an unresolved experimental-context decision" - ), - fallback_error=fallback_exc, - model_name=str(model_name), - ) - run_info = AgentRunInfo( - agentName="experimental_context_deterministic", - modelName=getattr( - self.model, - "model_name", - type(self.model).__name__, - ), - ) - characterization = deps.characterization - if characterization is None: - characterization = characterize_covariates( - store, - cellSelection=cell_selection, - studyContext=(f"{study_context}\nStudy objective: {study_objective}"), - model=None, - directions=direction_map, - groupingArtifacts=_hto_artifact_map(deps), - ) - if characterization.status == "failed": - status: StageStatus = "failed" - elif decision.needsInput or decision.batchCorrection.action == "needsInput": - status = "needsInput" - else: - status = "done" - logger.info( - "Experimental Context Agent completed: " - f"status={status}, qcProfiles={len(deps.qcProfiles)}, " - f"batchCorrection={decision.batchCorrection.action}, " - f"coefficients={len(decision.coefficientsOfInterest)}, " - f"toolCalls={len(deps.toolCalls)}, evidence={len(deps.evidenceIds)}" - ) - contrast_plans = list(deps.contrastPlans.values()) - if not contrast_plans: - contrast_plans = contrast_plans_from_characterization(characterization) - return ExperimentalContextResult( - status=status, - decision=decision, - characterization=characterization, - cellSelection=artifact_reference(cell_selection), - cellQc=CellQcPlan.get_blank(), - qcProfiles=list(deps.qcProfiles.values()), - qcMetricSources=deps.qcMetricSources, - qcSourceConcordance=deps.qcSourceConcordance, - contrastPlans=contrast_plans, - qualityMetricArtifacts=deps.qualityMetricArtifacts, - htoIdentityColumns=deps.htoIdentityColumns, - htoIdentityArtifacts=deps.htoIdentityArtifacts, - batchSafety=list(deps.batchSafety.values()), - currentRepresentation=deps.currentRepresentation, - notes=[*characterization.notes, *decision.needsInput], - runInfo=run_info, - ) diff --git a/scarf/agent/experimental_context/__init__.py b/scarf/agent/experimental_context/__init__.py new file mode 100644 index 00000000..e5275bd9 --- /dev/null +++ b/scarf/agent/experimental_context/__init__.py @@ -0,0 +1,53 @@ +"""Tool-driven experimental-design and batch-correction assessment.""" + +from ..cell_quality.profiles import RegisteredCellQcProfile +from ..types import BatchSafetyEvidence +from .agent import ExperimentalContextAgent +from .contracts import ( + BatchCorrectionPlan, + CaptureFailureEvidence, + CellQcPlan, + CellQcProfileEvidence, + ContrastPlan, + CovariateEvidence, + ExperimentalContextDecision, + ExperimentalContextDependencies, + ExperimentalContextResult, + InferenceUnit, + NamedArtifactSource, + QcMetricSourceEvidence, + QcSourceConcordance, + RepresentationEvaluation, +) +from .tools import ( + analyze_experimental_design, + contrast_plans_from_characterization, + inspect_cell_covariates, + score_current_representation, +) +from .validation import validate_experimental_context + +__all__ = [ + "BatchCorrectionPlan", + "BatchSafetyEvidence", + "CellQcPlan", + "CellQcProfileEvidence", + "CaptureFailureEvidence", + "ContrastPlan", + "CovariateEvidence", + "ExperimentalContextAgent", + "ExperimentalContextDecision", + "ExperimentalContextDependencies", + "ExperimentalContextResult", + "InferenceUnit", + "NamedArtifactSource", + "QcMetricSourceEvidence", + "QcSourceConcordance", + "RepresentationEvaluation", + "RegisteredCellQcProfile", + "analyze_experimental_design", + "contrast_plans_from_characterization", + "inspect_cell_covariates", + "score_current_representation", + "validate_experimental_context", +] diff --git a/scarf/agent/experimental_context/agent.py b/scarf/agent/experimental_context/agent.py new file mode 100644 index 00000000..5e393ac3 --- /dev/null +++ b/scarf/agent/experimental_context/agent.py @@ -0,0 +1,424 @@ +"""Experimental-context agent prompts and execution.""" + +import json +from collections.abc import Mapping, Sequence +from textwrap import dedent +from typing import TYPE_CHECKING, Any + +from ...graph.feature_projection import graph_cell_selection +from ...metadata.selection import resolve_cell_aligned_artifact +from ...storage.refs import ArtifactRef +from ...utils.logging import logger +from .._deps import AGENT_INSTALL_HINT +from ..config import AgentRunConfig +from ..config.agent_exec import run_agent_sync +from ..tools import artifact_reference, core_artifact_reference +from ..types import AgentRunInfo, StageStatus +from .characterization import _SelectionBoundCells, characterize_covariates +from .contracts import ( + CellQcPlan, + ExperimentalContextDecision, + ExperimentalContextDependencies, + ExperimentalContextResult, + NamedArtifactSource, +) +from .qc_evidence import ( + _derive_missing_percentage_artifacts, + _hto_artifact_map, + _qc_driver, + _source_ref, +) +from .tools import ( + _prepare_experimental_context_tool, + analyze_experimental_design, + contrast_plans_from_characterization, + inspect_cell_covariates, + score_current_representation, +) +from .validation import ( + _deterministic_experimental_context_decision, + failed_experimental_context_result, + pending_experimental_context_result, + validate_experimental_context, +) + +if TYPE_CHECKING: + from ...datastore.pipeline_run import PipelineRun + +try: + from pydantic_ai import ModelRetry, Tool, UnexpectedModelBehavior +except ImportError as exc: + raise ImportError(AGENT_INSTALL_HINT) from exc + +_CONTEXT_LIMIT = 1200 + + +class ExperimentalContextAgent: + """A narrow agent for study design and batch-correction planning.""" + + def __init__( + self, + model: Any, + *, + config: AgentRunConfig | None = None, + unattended: bool = False, + ) -> None: + self.model = model + self.unattended = unattended + self.config = (config or AgentRunConfig()).with_limits( + request_limit=9, + tool_call_limit=5, + output_token_limit=32768, + timeout_seconds=600.0, + ) + self.system_prompt = ( + dedent( + """ + You are Scarf's Experimental Context Agent. Work only through the + provided read-only tools and return the structured decision schema. + + Call inspect_cell_covariates exactly once. Then call + analyze_experimental_design exactly once with all explicit domains, + all biological coefficients, every unit of inference, and the complete + exact batch-column set being considered. You may call + score_current_representation at most once when an exact supplied graph + can add evidence. Do not split metadata, coefficients, or batch columns across + calls, and do not repeat a tool call. Pass batch_columns as a JSON array, + including when the array contains exactly one column. Each tool is + removed after it succeeds, so include the complete decision context in + its single call. + + The tools return bounded cell-QC profiles projected against the exact + shared cell selection. Do not choose a profile and leave cellQc blank. + A later audited checkpoint selects one registered profile. Never author + or alter numeric quality bounds. RNA is the preferred QC driver and + ATAC is the fallback. ADT and HTO never drive automatic cell filtering. + An exact HTO identity artifact may be used as grouping evidence. It is + not a live metadata column and does not make HTO a QC driver. + + A batch column must be categorical and technical. Never use donor, + sample, observation-unit, independent-unit, biological, cluster, or + embedding columns as Harmony batch columns. A biological coefficient + that is not estimable with the exact proposed batch columns makes + correction unsafe. A sample or library identifier is not automatically + technical. When no exact observed column is both categorical and + technical, pass batch_columns=[] and recommend skipping Harmony. Every + observation and independent unit must be an exact observed column name + or null. + LISI evaluates a representation; it does not identify which metadata + column is a batch. Recommend evaluateHarmony, not application, because + Parameter Tuning must compare exact uncorrected and corrected artifacts. + + Cite only evidenceIds returned by tools. Ask for input when study + design cannot be resolved. The study objective is authoritative: use + it to identify protected biological variables and the intended unit + of inference, but do not broaden it or claim to test a hypothesis. + Never propose Python, shell commands, + direct Zarr access, or any datastore mutation. Every rationale and + question must be plain prose. Never place serialized JSON, schema + field names, or sibling output fields inside a narrative string. + Return only fields defined by the structured output schema. + """ + ) + .strip() + .format() + ) + + def run( + self, + store: Any, + *, + study_context: str | None = None, + study_objective: str | None = None, + cell_selection: ArtifactRef | None = None, + directions: Mapping[str, Any] | None = None, + run: "PipelineRun | None" = None, + neighbors: ArtifactRef | None = None, + connectivity_map: ArtifactRef | None = None, + quality_metric_artifacts: Sequence[NamedArtifactSource] = (), + hto_identity_artifacts: Sequence[NamedArtifactSource] = (), + ) -> ExperimentalContextResult: + """Inspect one datastore and return a validated experimental-context report.""" + study_context = (study_context or "").strip() + study_objective = (study_objective or "").strip() + if len(study_context) > _CONTEXT_LIMIT: + study_context = study_context[: _CONTEXT_LIMIT - 3] + "..." + if len(study_objective) > _CONTEXT_LIMIT: + study_objective = study_objective[: _CONTEXT_LIMIT - 3] + "..." + direction_map = dict(directions or {}) + if run is not None: + if ( + cell_selection is not None + or neighbors is not None + or connectivity_map is not None + ): + raise ValueError( + "run is mutually exclusive with explicit artifact inputs" + ) + if getattr(run, "_owner", store) is not store: + raise ValueError("run must be opened from this datastore") + neighbors = run["neighbors"] + cell_selection = run["analysis_cell_selection"] + connectivity_map = ( + run["connectivity_map"] if "connectivity_map" in run else None + ) + cell_selection = core_artifact_reference(cell_selection) + neighbors = core_artifact_reference(neighbors) + connectivity_map = core_artifact_reference(connectivity_map) + if not isinstance(cell_selection, ArtifactRef) or ( + cell_selection.kind != "cell_selection" + or cell_selection.scope != "datastore" + ): + raise TypeError( + "cell_selection must be a datastore cell_selection ArtifactRef" + ) + if neighbors is not None: + if not isinstance(neighbors, ArtifactRef) or neighbors.kind != "neighbors": + raise TypeError("neighbors must be a neighbors ArtifactRef") + if graph_cell_selection(store.zw, neighbors) != cell_selection: + raise ValueError( + "neighbors and metadata must use the same cell selection" + ) + if connectivity_map is not None: + if not isinstance( + connectivity_map, ArtifactRef + ) or connectivity_map.kind not in { + "connectivity_map", + "integrated_graph", + }: + raise TypeError( + "connectivity_map must be a connectivity graph ArtifactRef" + ) + if graph_cell_selection(store.zw, connectivity_map) != cell_selection: + raise ValueError( + "neighbors and connectivity_map must use the same cell selection" + ) + quality_sources = _derive_missing_percentage_artifacts( + store, + cell_selection=cell_selection, + driver=_qc_driver(store), + quality_sources=quality_metric_artifacts, + ) + hto_sources = list(hto_identity_artifacts) + source_names: set[str] = set() + for sources, expected_kind in ( + (quality_sources, "quality_metric"), + (hto_sources, "hto_identity"), + ): + for source in sources: + artifact = _source_ref(source, expected_kind=expected_kind) + if source.name in source_names: + raise ValueError( + "Experimental Context artifact source names must be unique" + ) + source_names.add(source.name) + resolve_cell_aligned_artifact( + store.zw, + artifact, + cell_selection=cell_selection, + expected_kind=expected_kind, + ) + directed_qc = direction_map.get("cellQc") + directed_qc_map = dict(directed_qc) if isinstance(directed_qc, Mapping) else {} + if "cellKey" in directed_qc_map: + raise ValueError( + "cellQc.cellKey is unsupported; use the exact cell_selection input" + ) + logger.info( + "Experimental Context Agent started: " + f"cellSelection={cell_selection.artifact_id}, " + f"directions={len(direction_map)}, " + f"qualityMetrics={len(quality_sources)}, " + f"htoIdentities={len(hto_sources)}, " + f"studyContextProvided={bool(study_context)}, " + f"studyObjectiveProvided={bool(study_objective)}" + ) + deps = ExperimentalContextDependencies( + store=store, + cells=_SelectionBoundCells( + store.zw, + store.cells, + cell_selection, + artifacts={ + source.name: _source_ref( + source, + expected_kind="hto_identity", + ) + for source in hto_sources + }, + ), + neighbors=neighbors, + connectivityMap=connectivity_map, + cellSelection=cell_selection, + studyContext=study_context, + studyObjective=study_objective, + directions=direction_map, + qualityMetricArtifacts=quality_sources, + htoIdentityArtifacts=hto_sources, + ) + user_prompt = ( + dedent( + """ + Characterize this experiment's metadata and decide whether Harmony + should be evaluated. Return cell-QC candidates as tool evidence; + leave cellQc blank for the later audited filtering checkpoint. + + Study context: {study_context} + Study objective: {study_objective} + Exact cell-selection artifact: {cell_selection} + Exact quality-metric artifacts: {quality_metrics} + Exact HTO identity artifacts: {hto_identities} + Caller directions: {directions} + """ + ) + .strip() + .format( + study_context=study_context or "not provided", + study_objective=study_objective or "not provided", + cell_selection=cell_selection.artifact_id, + quality_metrics=json.dumps( + [source.model_dump(mode="json") for source in quality_sources], + sort_keys=True, + ), + hto_identities=json.dumps( + [source.model_dump(mode="json") for source in hto_sources], + sort_keys=True, + ), + directions=json.dumps(direction_map, sort_keys=True, default=str), + ) + ) + try: + execution = run_agent_sync( + model=self.model, + output_type=ExperimentalContextDecision, + system_prompt=self.system_prompt, + user_prompt=user_prompt, + tools=( + Tool( + inspect_cell_covariates, + prepare=_prepare_experimental_context_tool, + sequential=self.config.sequentialTools, + timeout=self.config.timeoutSeconds, + ), + Tool( + analyze_experimental_design, + max_retries=3, + prepare=_prepare_experimental_context_tool, + sequential=self.config.sequentialTools, + timeout=self.config.timeoutSeconds, + ), + Tool( + score_current_representation, + prepare=_prepare_experimental_context_tool, + sequential=self.config.sequentialTools, + timeout=self.config.timeoutSeconds, + ), + ), + deps_type=ExperimentalContextDependencies, + deps=deps, + config=self.config, + name="experimental_context", + output_validator=lambda decision: validate_experimental_context( + decision, + deps, + ), + ) + except UnexpectedModelBehavior as exc: + model_name = getattr(self.model, "model_name", type(self.model).__name__) + if self.unattended: + try: + decision = _deterministic_experimental_context_decision(deps) + except ( + ModelRetry, + RuntimeError, + TypeError, + ValueError, + ) as fallback_exc: + return failed_experimental_context_result( + deps, + error=exc, + fallback_error=fallback_exc, + model_name=str(model_name), + ) + run_info = AgentRunInfo( + agentName="experimental_context_deterministic", + modelName=str(model_name), + ) + else: + return pending_experimental_context_result( + deps, + error=exc, + model_name=str(model_name), + ) + else: + decision = ExperimentalContextDecision.model_validate(execution.output) + run_info = execution.runInfo + if self.unattended and ( + decision.needsInput or decision.batchCorrection.action == "needsInput" + ): + try: + decision = _deterministic_experimental_context_decision(deps) + except (ModelRetry, RuntimeError, TypeError, ValueError) as fallback_exc: + model_name = getattr( + self.model, "model_name", type(self.model).__name__ + ) + return failed_experimental_context_result( + deps, + error=RuntimeError( + "The model returned an unresolved experimental-context decision" + ), + fallback_error=fallback_exc, + model_name=str(model_name), + ) + run_info = AgentRunInfo( + agentName="experimental_context_deterministic", + modelName=getattr( + self.model, + "model_name", + type(self.model).__name__, + ), + ) + characterization = deps.characterization + if characterization is None: + characterization = characterize_covariates( + store, + cellSelection=cell_selection, + studyContext=(f"{study_context}\nStudy objective: {study_objective}"), + model=None, + directions=direction_map, + groupingArtifacts=_hto_artifact_map(deps), + ) + if characterization.status == "failed": + status: StageStatus = "failed" + elif decision.needsInput or decision.batchCorrection.action == "needsInput": + status = "needsInput" + else: + status = "done" + logger.info( + "Experimental Context Agent completed: " + f"status={status}, qcProfiles={len(deps.qcProfiles)}, " + f"batchCorrection={decision.batchCorrection.action}, " + f"coefficients={len(decision.coefficientsOfInterest)}, " + f"toolCalls={len(deps.toolCalls)}, evidence={len(deps.evidenceIds)}" + ) + contrast_plans = list(deps.contrastPlans.values()) + if not contrast_plans: + contrast_plans = contrast_plans_from_characterization(characterization) + return ExperimentalContextResult( + status=status, + decision=decision, + characterization=characterization, + cellSelection=artifact_reference(cell_selection), + cellQc=CellQcPlan.get_blank(), + qcProfiles=list(deps.qcProfiles.values()), + qcMetricSources=deps.qcMetricSources, + qcSourceConcordance=deps.qcSourceConcordance, + contrastPlans=contrast_plans, + qualityMetricArtifacts=deps.qualityMetricArtifacts, + htoIdentityColumns=deps.htoIdentityColumns, + htoIdentityArtifacts=deps.htoIdentityArtifacts, + batchSafety=list(deps.batchSafety.values()), + currentRepresentation=deps.currentRepresentation, + notes=[*characterization.notes, *decision.needsInput], + runInfo=run_info, + ) diff --git a/scarf/agent/characterize_covariates.py b/scarf/agent/experimental_context/characterization.py similarity index 94% rename from scarf/agent/characterize_covariates.py rename to scarf/agent/experimental_context/characterization.py index fcf5a7e5..de1ba014 100644 --- a/scarf/agent/characterize_covariates.py +++ b/scarf/agent/experimental_context/characterization.py @@ -8,38 +8,26 @@ import numpy as np import pandas as pd -from ..metadata.queries import ( +from ...metadata.queries import ( PartitionDigest, column_constant_within, column_partition_digest, columns_same_partition, reduce_observation_units, ) -from ..metadata.rows import ( +from ...metadata.rows import ( MetaDataRowBlock, read_metadata_missing_rows_chunkwise, read_metadata_rows_chunkwise, ) -from ..metadata.selection import resolve_cell_aligned_artifact -from ..metrics.association import directional_mapping, report_confounding -from ..storage.refs import ArtifactRef -from ..storage.selections import read_stored_selection_indices -from .config import CONFIG -from .config._deps import AGENT_INSTALL_HINT -from .decide import DecisionValidationError, decide -from .tools import artifact_reference -from .types import ( - AgentDataModel, - ArtifactReferenceModel, - Decision, - EvidenceItem, - StageStatus, -) - -try: - from pydantic import Field -except ImportError as exc: - raise ImportError(AGENT_INSTALL_HINT) from exc +from ...metadata.selection import resolve_cell_aligned_artifact +from ...metrics.association import directional_mapping, report_confounding +from ...storage.refs import ArtifactRef +from ...storage.selections import read_stored_selection_indices +from ..decisions.selection import DecisionValidationError, decide +from ..tools import artifact_reference +from ..types import Decision, EvidenceItem +from .contracts import CovariateCharacterization __all__ = [ "CovariateCharacterization", @@ -49,6 +37,36 @@ Domain = Literal["biological", "technical", "design", "ignore", "unknown"] ColumnKind = Literal["categorical", "continuous"] +_CATEGORICAL_MAX_LEVELS = 50 +_EMBEDDING_TOKENS = ( + "umap", + "pca", + "tsne", + "scvi", + "latent", + "phate", + "forceatlas", + "diffmap", + "diffusionmap", + "diffusion", +) +_DOMAINS = frozenset({"biological", "technical", "design", "ignore", "unknown"}) +_ANALYSED = frozenset({"biological", "technical", "design"}) +_KINDS = frozenset({"categorical", "continuous"}) +_RESERVED_COLUMNS = frozenset({"I", "ids", "names"}) +_SHORT_EMBEDDING_PARTS = frozenset({"fa", "dm", "pc"}) +_INDEXED_NAME = re.compile(r"(?P.+?)[-_]?(?P\d+)") +_ONTOLOGY_SUFFIX = "_ontology_term_id" +_SAMPLE_LEVELS = 8 +_ASSOCIATION_FLOOR = 0.1 +_CONTEXT_LIMIT = 1200 +_DROP_REASONS = { + "dropAssayStat": "Scarf assay statistic column", + "dropProvenance": "analysis-linked column", + "dropEmbedding": "embedding-style column", + "dropConstant": "single-level column", +} + _DOMAIN_EVIDENCE = [ EvidenceItem( @@ -91,42 +109,6 @@ ] -class CovariateCharacterization(AgentDataModel): - status: StageStatus - cellSelection: ArtifactReferenceModel | None = None - auditLog: list[dict[str, Any]] = Field(default_factory=list) - actions: list[str] = Field(default_factory=list) - notes: list[str] = Field(default_factory=list) - decisions: list[dict[str, Any]] = Field(default_factory=list) - columns: list[dict[str, Any]] = Field(default_factory=list) - coefficients: list[dict[str, Any]] = Field(default_factory=list) - technicalNesting: list[dict[str, Any]] = Field(default_factory=list) - confounding: list[dict[str, Any]] = Field(default_factory=list) - unitLevelCounts: list[dict[str, Any]] = Field(default_factory=list) - groupImbalance: list[dict[str, Any]] = Field(default_factory=list) - missingness: list[dict[str, Any]] = Field(default_factory=list) - designStructures: list[dict[str, Any]] = Field(default_factory=list) - pairedCoverage: list[dict[str, Any]] = Field(default_factory=list) - coefficientEstimability: list[dict[str, Any]] = Field(default_factory=list) - - @classmethod - def get_blank(cls) -> "CovariateCharacterization": - return cls(status="failed") - - @classmethod - def get_example(cls) -> "CovariateCharacterization": - return cls( - status="done", - cellSelection=ArtifactReferenceModel( - scope="datastore", - kind="cell_selection", - artifactId="c" * 64, - ), - notes=["Cell covariates and confounding were characterized."], - columns=[{"name": "batch", "domain": "technical"}], - ) - - @dataclass(frozen=True, slots=True) class _ColumnProfile: kind: ColumnKind @@ -366,14 +348,14 @@ def ask( def _is_embedding_column(name: str) -> bool: - match = CONFIG._INDEXED_NAME.fullmatch(name) + match = _INDEXED_NAME.fullmatch(name) if match is None: return False parts = [part for part in re.split(r"[-_]+", match.group("stem").lower()) if part] compact = "".join(parts) - if any(token in compact for token in CONFIG._EMBEDDING_TOKENS): + if any(token in compact for token in _EMBEDDING_TOKENS): return True - return any(part in CONFIG._SHORT_EMBEDDING_PARTS for part in parts) + return any(part in _SHORT_EMBEDDING_PARTS for part in parts) def _infer_kind(values: np.ndarray) -> ColumnKind: @@ -390,7 +372,7 @@ def _infer_kind(values: np.ndarray) -> ColumnKind: finite = numeric[np.isfinite(numeric)] if finite.size == 0 or not bool(np.all(np.mod(finite, 1) == 0)): return "continuous" - limit = min(CONFIG._CATEGORICAL_MAX_LEVELS, max(2, len(values) // 20)) + limit = min(_CATEGORICAL_MAX_LEVELS, max(2, len(values) // 20)) return "categorical" if int(np.unique(finite).size) <= limit else "continuous" @@ -401,7 +383,7 @@ def _summarize(values: np.ndarray, kind: ColumnKind) -> str: levels = series.dropna().astype(str).value_counts() top = ", ".join( f"{level}={int(count)}" - for level, count in levels.head(CONFIG._SAMPLE_LEVELS).items() + for level, count in levels.head(_SAMPLE_LEVELS).items() ) return f"categorical levels={levels.shape[0]} missing={missing} top=[{top}]" numeric = pd.to_numeric(series, errors="coerce").to_numpy(dtype=float, copy=False) @@ -456,7 +438,7 @@ def _triage_columns( candidates: list[str] = [] dropped: list[tuple[str, str]] = [] for name in store.cells.columns: - if name in CONFIG._RESERVED_COLUMNS or name == cell_key or name in exclude: + if name in _RESERVED_COLUMNS or name == cell_key or name in exclude: continue if name in artifact_columns: candidates.append(name) @@ -486,9 +468,9 @@ def _collapse_ontology_aliases( dropped: set[str] = set() notes: list[dict[str, Any]] = [] for name in columns: - if not name.endswith(CONFIG._ONTOLOGY_SUFFIX): + if not name.endswith(_ONTOLOGY_SUFFIX): continue - base = name[: -len(CONFIG._ONTOLOGY_SUFFIX)] + base = name[: -len(_ONTOLOGY_SUFFIX)] if base not in present or {name, base} & dropped: continue if _digest_key(profiles[name].digest) != _digest_key(profiles[base].digest): @@ -517,11 +499,7 @@ def _collapse_ontology_aliases( def _bounded_context(study_context: str | None) -> str: text = (study_context or "").strip() - return ( - text - if len(text) <= CONFIG._CONTEXT_LIMIT - else text[: CONFIG._CONTEXT_LIMIT - 3] + "..." - ) + return text if len(text) <= _CONTEXT_LIMIT else text[: _CONTEXT_LIMIT - 3] + "..." def _validate_directions( @@ -540,8 +518,8 @@ def check_names(key: str, names: Any) -> list[str] | None: return list(names) for key, allowed in ( - ("columnKinds", CONFIG._KINDS), - ("columnDomains", CONFIG._DOMAINS), + ("columnKinds", _KINDS), + ("columnDomains", _DOMAINS), ): mapping = directions.get(key) if mapping is None: @@ -642,7 +620,7 @@ class is only eligible when every member carries the same analysis domain, """ classes: dict[tuple[bytes, int, int], list[str]] = {} for name in candidates: - if run.kind(name) != "categorical" or run.domains[name] not in CONFIG._ANALYSED: + if run.kind(name) != "categorical" or run.domains[name] not in _ANALYSED: continue classes.setdefault(_digest_key(run.digest(name)), []).append(name) @@ -721,7 +699,7 @@ def _assign_domain(run: _Run, name: str, directed: Mapping[str, Domain]) -> Doma ) return "unknown" selected = decision.selectedId.removeprefix("domain:") - if selected not in CONFIG._DOMAINS: + if selected not in _DOMAINS: run.note( kind="domainUnknown", detail=f"Unsupported domain {selected!r} returned for {name}", @@ -1414,7 +1392,7 @@ def _characterize_coefficient( coefficient: run.kind(coefficient), **{name: run.kind(name) for name in unit_constant}, }, - associationFloor=CONFIG._ASSOCIATION_FLOOR, + associationFloor=_ASSOCIATION_FLOOR, ) report["observationUnit"] = observation_unit report["independentUnit"] = independent_unit @@ -1539,7 +1517,7 @@ def _column_records( "name": name, "kind": "continuous", "domain": "ignore", - "summary": f"dropped before triage ({CONFIG._DROP_REASONS[reason]})", + "summary": f"dropped before triage ({_DROP_REASONS[reason]})", "aliases": [], "nRows": ( dropped_profile.digest.nRows if dropped_profile is not None else 0 @@ -1633,9 +1611,7 @@ def characterize_covariates( bound_store, name, cell_key=cell_key, - kind=cast(ColumnKind, directed_kind) - if directed_kind in CONFIG._KINDS - else None, + kind=cast(ColumnKind, directed_kind) if directed_kind in _KINDS else None, ) profiles[name] = profile n_rows = profile.digest.nRows @@ -1660,7 +1636,7 @@ def characterize_covariates( profiles=profiles, ) for name, reason in dropped: - detail = f"Dropped {CONFIG._DROP_REASONS[reason]} {name}" + detail = f"Dropped {_DROP_REASONS[reason]} {name}" if reason == "dropConstant" and name in directed_coefficients: detail = ( f"{detail}; also listed in coefficientsOfInterest but has no variation" diff --git a/scarf/agent/experimental_context/contracts.py b/scarf/agent/experimental_context/contracts.py new file mode 100644 index 00000000..8d31ee80 --- /dev/null +++ b/scarf/agent/experimental_context/contracts.py @@ -0,0 +1,954 @@ +"""Experimental-context contracts and handoffs.""" + +import math +from typing import Any, Literal + +from .._deps import AGENT_INSTALL_HINT +from ..cell_quality.profiles import QcMetricRole, RegisteredCellQcProfile +from ..types import ( + AgentDataModel, + AgentRunInfo, + ArtifactReferenceModel, + BatchCorrectionAction, + BatchSafetyEvidence, + ExperimentalBiologyHandoff, + ExperimentalTuningHandoff, + StageStatus, +) + +try: + from pydantic import ConfigDict, Field, model_validator +except ImportError as exc: + raise ImportError(AGENT_INSTALL_HINT) from exc + +type ColumnDomain = Literal["biological", "technical", "design", "ignore", "unknown"] +type IntegrationMetric = Literal[ + "iLISI", + "cLISI", + "graphConnectivity", + "proportionalBatchMixing", +] +type CellQcAction = Literal[ + "skip", + "globalGaussian", + "sampleMad", + "registeredMad", +] +type LegacyCellQcAction = Literal["skip", "globalGaussian", "sampleMad"] +type CellQcDriverType = Literal["RNA", "ATAC"] + + +type ContrastTest = Literal["mann_whitney", "kruskal_wallis", "wilcoxon"] +type ContrastSampleStatistic = Literal["mean", "median", "fraction"] +type ContrastStatus = Literal["licensed", "blocked", "needsInput"] + + +class CovariateCharacterization(AgentDataModel): + status: StageStatus + cellSelection: ArtifactReferenceModel | None = None + auditLog: list[dict[str, Any]] = Field(default_factory=list) + actions: list[str] = Field(default_factory=list) + notes: list[str] = Field(default_factory=list) + decisions: list[dict[str, Any]] = Field(default_factory=list) + columns: list[dict[str, Any]] = Field(default_factory=list) + coefficients: list[dict[str, Any]] = Field(default_factory=list) + technicalNesting: list[dict[str, Any]] = Field(default_factory=list) + confounding: list[dict[str, Any]] = Field(default_factory=list) + unitLevelCounts: list[dict[str, Any]] = Field(default_factory=list) + groupImbalance: list[dict[str, Any]] = Field(default_factory=list) + missingness: list[dict[str, Any]] = Field(default_factory=list) + designStructures: list[dict[str, Any]] = Field(default_factory=list) + pairedCoverage: list[dict[str, Any]] = Field(default_factory=list) + coefficientEstimability: list[dict[str, Any]] = Field(default_factory=list) + + @classmethod + def get_blank(cls) -> "CovariateCharacterization": + return cls(status="failed") + + @classmethod + def get_example(cls) -> "CovariateCharacterization": + return cls( + status="done", + cellSelection=ArtifactReferenceModel( + scope="datastore", + kind="cell_selection", + artifactId="c" * 64, + ), + notes=["Cell covariates and confounding were characterized."], + columns=[{"name": "batch", "domain": "technical"}], + ) + + +class InferenceUnit(AgentDataModel): + """Observation and independent units for one biological coefficient.""" + + observationUnit: str | None = None + independentUnit: str | None = None + + @classmethod + def get_blank(cls) -> "InferenceUnit": + return cls() + + @classmethod + def get_example(cls) -> "InferenceUnit": + return cls(observationUnit="sample", independentUnit="donor") + + +class BatchCorrectionPlan(AgentDataModel): + """A grounded recommendation about whether Harmony should be evaluated.""" + + action: BatchCorrectionAction + batchColumns: list[str] = Field(default_factory=list) + preserveColumns: list[str] = Field(default_factory=list) + metricsRequired: list[IntegrationMetric] = Field(default_factory=list) + rationale: str = "" + evidenceIds: list[str] = Field(default_factory=list) + + @classmethod + def get_blank(cls) -> "BatchCorrectionPlan": + return cls(action="needsInput") + + @classmethod + def get_example(cls) -> "BatchCorrectionPlan": + return cls( + action="evaluateHarmony", + batchColumns=["batch"], + preserveColumns=["cell_type", "treatment"], + metricsRequired=[ + "iLISI", + "cLISI", + "graphConnectivity", + ], + rationale=( + "Batch is technical and crossed with treatment, so compare an exact " + "Harmony candidate while protecting biological labels." + ), + evidenceIds=[ + "column:batch", + "estimability:treatment", + "batchEstimability:treatment:batch", + ], + ) + + +class NamedArtifactSource(AgentDataModel): + """One semantic name bound to an exact immutable artifact.""" + + name: str = "" + artifact: ArtifactReferenceModel = Field(default_factory=ArtifactReferenceModel) + + @model_validator(mode="after") + def validate_source(self) -> "NamedArtifactSource": + if self.name != self.name.strip(): + raise ValueError("Artifact source names cannot have surrounding whitespace") + if bool(self.name.strip()) != bool(self.artifact.artifactId): + raise ValueError("A named artifact source requires both name and artifact") + return self + + @classmethod + def get_blank(cls) -> "NamedArtifactSource": + return cls() + + @classmethod + def get_example(cls) -> "NamedArtifactSource": + return cls( + name="RNA_percentMito", + artifact=ArtifactReferenceModel( + assay="RNA", + kind="quality_metric", + artifactId="1" * 64, + ), + ) + + +class QcMetricSourceEvidence(AgentDataModel): + """One source-specific quality metric on the exact active cells.""" + + sourceId: str = "" + metricName: str = "" + metricRole: QcMetricRole = "diagnostic" + assay: str | None = None + sourceType: Literal["metadataColumn", "artifact"] = "metadataColumn" + origin: Literal[ + "ingestionMetadata", + "derivedArtifact", + "externalArtifact", + ] = "ingestionMetadata" + executionName: str = "" + metadataColumn: str | None = None + artifact: ArtifactReferenceModel | None = None + cellSelection: ArtifactReferenceModel | None = None + inputArtifacts: list[ArtifactReferenceModel] = Field(default_factory=list) + provenanceOperation: str | None = None + valuesFingerprint: str = "" + activeCells: int = 0 + missingCells: int = 0 + missingCellsByCapture: dict[str, int] = Field(default_factory=dict) + usableForFiltering: bool = False + notes: list[str] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_source(self) -> "QcMetricSourceEvidence": + if ( + not self.sourceId + and not self.metricName + and self.metadataColumn is None + and self.artifact is None + ): + return self + if self.sourceType == "metadataColumn": + if self.metadataColumn is None or self.artifact is not None: + raise ValueError( + "A metadata QC source requires only metadataColumn provenance" + ) + if self.origin != "ingestionMetadata": + raise ValueError( + "Metadata QC sources must use ingestionMetadata origin" + ) + elif self.artifact is None or self.metadataColumn is not None: + raise ValueError("An artifact QC source requires only artifact provenance") + if self.activeCells < 0 or not 0 <= self.missingCells <= self.activeCells: + raise ValueError("QC source active and missing counts are inconsistent") + if self.usableForFiltering and self.missingCells: + raise ValueError("A QC source with missing values cannot drive filtering") + return self + + +class QcSourceConcordance(AgentDataModel): + """Observed agreement between imported and derived forms of one metric.""" + + metricRole: QcMetricRole = "diagnostic" + leftSourceId: str = "" + rightSourceId: str = "" + comparedCells: int = 0 + missingCells: int = 0 + meanAbsoluteDifference: float | None = None + maximumAbsoluteDifference: float | None = None + pearsonCorrelation: float | None = None + exactlyEqual: bool = False + numericallyClose: bool = False + evidenceId: str = "" + + +class CaptureFailureEvidence(AgentDataModel): + """Multi-axis capture anomaly plus exclusion-safety inputs.""" + + capture: str = "" + activeCells: int = 0 + retainedCells: int = 0 + retainedFraction: float = 0.0 + adverseAxes: list[QcMetricRole] = Field(default_factory=list) + independentAdverseAxes: int = 0 + metricMissingFractions: dict[str, float] = Field(default_factory=dict) + reasons: list[str] = Field(default_factory=list) + wholeCaptureFailure: bool = False + conditionAndUnitSafety: list[dict[str, Any]] = Field(default_factory=list) + preservesConditionCoverage: bool = False + preservesIndependentUnitCoverage: bool = False + exclusionEligible: bool = False + doubletEvidenceIds: list[str] = Field(default_factory=list) + evidenceId: str = "" + + @model_validator(mode="after") + def validate_failure(self) -> "CaptureFailureEvidence": + if self.independentAdverseAxes != len(set(self.adverseAxes)): + raise ValueError("Capture failure axis count must match its unique axes") + if self.wholeCaptureFailure != (self.independentAdverseAxes >= 2): + raise ValueError( + "Whole-capture failure requires at least two independent QC axes" + ) + if self.exclusionEligible and ( + not self.wholeCaptureFailure + or not self.preservesConditionCoverage + or not self.preservesIndependentUnitCoverage + ): + raise ValueError( + "Capture exclusion requires failure and preserved design coverage" + ) + return self + + +class ContrastPlan(AgentDataModel): + """One deterministic sample-aware statistical-testing license.""" + + coefficient: str = "" + groupOrder: list[str | int | float | bool] = Field(default_factory=list) + sampleBy: str | None = None + pairBy: str | None = None + test: ContrastTest | None = None + sampleStatistic: ContrastSampleStatistic = "mean" + expressionCutoff: float = 0.0 + status: ContrastStatus = "blocked" + betweenUnitDesign: bool = False + replicationPassed: bool = False + estimabilityPassed: bool = False + pairedCoveragePassed: bool | None = None + replication: dict[str, Any] = Field(default_factory=dict) + estimability: dict[str, Any] = Field(default_factory=dict) + pairedCoverage: dict[str, Any] = Field(default_factory=dict) + blockedReasons: list[str] = Field(default_factory=list) + evidenceId: str = "" + evidenceIds: list[str] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_contrast(self) -> "ContrastPlan": + if self.coefficient != self.coefficient.strip(): + raise ValueError( + "Contrast coefficient cannot contain surrounding whitespace" + ) + if self.sampleBy is not None and ( + not self.sampleBy.strip() or self.sampleBy != self.sampleBy.strip() + ): + raise ValueError("Contrast sampleBy must be a non-empty trimmed name") + if self.pairBy is not None and ( + not self.pairBy.strip() or self.pairBy != self.pairBy.strip() + ): + raise ValueError("Contrast pairBy must be a non-empty trimmed name") + group_keys = [(type(value).__name__, repr(value)) for value in self.groupOrder] + if len(group_keys) != len(set(group_keys)): + raise ValueError("Contrast groupOrder must contain unique values") + if any( + isinstance(value, float) and not math.isfinite(value) + for value in self.groupOrder + ): + raise ValueError("Contrast groupOrder cannot contain non-finite values") + if not math.isfinite(self.expressionCutoff): + raise ValueError("Contrast expressionCutoff must be finite") + if self.sampleStatistic != "fraction" and self.expressionCutoff != 0.0: + raise ValueError( + "Contrast expressionCutoff is only used with fraction summaries" + ) + if self.test == "mann_whitney" and len(self.groupOrder) != 2: + raise ValueError("mann_whitney requires exactly two ordered groups") + if self.test == "kruskal_wallis" and len(self.groupOrder) < 3: + raise ValueError("kruskal_wallis requires at least three ordered groups") + if self.test == "wilcoxon": + if len(self.groupOrder) != 2 or self.pairBy is None: + raise ValueError( + "wilcoxon requires exactly two groups and an explicit pairBy" + ) + elif self.pairBy is not None and self.test is not None: + raise ValueError("A paired contrast must use the wilcoxon test") + if self.status == "licensed": + if ( + not self.coefficient + or self.sampleBy is None + or self.test is None + or self.blockedReasons + or not self.betweenUnitDesign + or not self.replicationPassed + or not self.estimabilityPassed + or (self.pairBy is not None and self.pairedCoveragePassed is not True) + ): + raise ValueError( + "A licensed contrast requires resolved design, replication, " + "estimability, and paired coverage" + ) + elif not self.blockedReasons: + raise ValueError("A non-licensed contrast requires blockedReasons") + return self + + @classmethod + def get_blank(cls) -> "ContrastPlan": + return cls(blockedReasons=["unresolvedContrast"]) + + +def _validate_qc_sources( + *, + action: CellQcAction, + attributes: list[str], + artifact_metrics: list[NamedArtifactSource], + sample_column: str | None, + sample_artifact: NamedArtifactSource | None, + registered_profile: RegisteredCellQcProfile | None = None, + allow_metric_name_collisions: bool = False, +) -> None: + if len(attributes) != len(set(attributes)): + raise ValueError("Cell-QC metadata attributes must be unique") + if any( + not attribute.strip() or attribute != attribute.strip() + for attribute in attributes + ): + raise ValueError( + "Cell-QC metadata attributes cannot be blank or have surrounding whitespace" + ) + artifact_names = [source.name for source in artifact_metrics] + if len(artifact_names) != len(set(artifact_names)): + raise ValueError("Cell-QC artifact metric names must be unique") + if not allow_metric_name_collisions and set(attributes) & set(artifact_names): + raise ValueError( + "Cell-QC metadata and artifact metric names collide; explicitly " + "validated multi-source evidence is required" + ) + if any(source.artifact.kind != "quality_metric" for source in artifact_metrics): + raise ValueError( + "Cell-QC artifactMetrics must reference quality_metric artifacts" + ) + if sample_column is not None and sample_artifact is not None: + raise ValueError( + "Cell-QC sampleColumn and sampleArtifact are mutually exclusive" + ) + if sample_column is not None and ( + not sample_column.strip() or sample_column != sample_column.strip() + ): + raise ValueError( + "Cell-QC sampleColumn cannot be blank or have surrounding whitespace" + ) + if sample_artifact is not None and sample_artifact.artifact.kind != "hto_identity": + raise ValueError( + "Cell-QC sampleArtifact must reference an hto_identity artifact" + ) + if sample_artifact is not None and sample_artifact.name in artifact_names: + raise ValueError("Cell-QC sample and metric artifact names must be distinct") + if registered_profile is not None: + if registered_profile == "retainWithFlags": + if action != "skip": + raise ValueError( + "retainWithFlags must use the non-filtering skip action" + ) + if sample_column is not None or sample_artifact is not None: + raise ValueError("retainWithFlags cannot include a capture source") + return + if action != "registeredMad": + raise ValueError(f"{registered_profile} must use the registeredMad action") + capture_profile = registered_profile in { + "captureMad5", + "captureMad3Sensitivity", + "pooledReferenceMad5", + } + has_one_capture_source = (sample_column is None) != (sample_artifact is None) + if capture_profile and not has_one_capture_source: + raise ValueError( + f"{registered_profile} requires exactly one proven capture source" + ) + if not capture_profile and ( + sample_column is not None or sample_artifact is not None + ): + raise ValueError(f"{registered_profile} cannot include a capture source") + if not attributes and not artifact_metrics: + raise ValueError("Registered MAD filtering requires at least one metric") + return + if action == "registeredMad": + raise ValueError("registeredMad requires a registeredProfile") + if action == "skip" and (attributes or artifact_metrics): + raise ValueError("skip cannot include Cell-QC metrics") + if action != "skip" and not attributes and not artifact_metrics: + raise ValueError("Cell-QC filtering requires at least one metric") + if action == "sampleMad" and (sample_column is None) == (sample_artifact is None): + raise ValueError( + "sampleMad requires exactly one sampleColumn or sampleArtifact" + ) + if action != "sampleMad" and ( + sample_column is not None or sample_artifact is not None + ): + raise ValueError("Only sampleMad can include a sample source") + + +class CellQcProfileEvidence(AgentDataModel): + """Projected retention for one registered or legacy cell-QC profile.""" + + profileId: str = "" + action: CellQcAction = "skip" + registeredProfile: RegisteredCellQcProfile | None = None + driverAssay: str | None = None + driverAssayType: CellQcDriverType | None = None + sampleColumn: str | None = None + sampleArtifact: NamedArtifactSource | None = None + captureColumn: str | None = None + captureArtifact: NamedArtifactSource | None = None + attributes: list[str] = Field(default_factory=list) + artifactMetrics: list[NamedArtifactSource] = Field(default_factory=list) + metricSources: list[QcMetricSourceEvidence] = Field(default_factory=list) + sourceConcordance: list[QcSourceConcordance] = Field(default_factory=list) + parameters: dict[str, Any] = Field(default_factory=dict) + resolvedBounds: dict[str, Any] | list[dict[str, Any]] = Field(default_factory=dict) + activeCells: int = 0 + retainedCells: int = 0 + retainedFraction: float = 0.0 + activeCellsByCapture: dict[str, int] = Field(default_factory=dict) + sampleRetainedCells: dict[str, int] = Field(default_factory=dict) + retainedCellsByColumn: dict[str, dict[str, int]] = Field(default_factory=dict) + unsafeRetentionGroups: list[str] = Field(default_factory=list) + flaggedCells: dict[str, int] = Field(default_factory=dict) + metricFlaggedCells: dict[str, dict[str, int]] = Field(default_factory=dict) + failedCaptureCandidates: list[str] = Field(default_factory=list) + captureFailureEvidence: list[CaptureFailureEvidence] = Field(default_factory=list) + excludableCaptureCandidates: list[str] = Field(default_factory=list) + notes: list[str] = Field(default_factory=list) + evidenceId: str = "" + + @model_validator(mode="after") + def validate_sources(self) -> "CellQcProfileEvidence": + _validate_qc_sources( + action=self.action, + attributes=self.attributes, + artifact_metrics=self.artifactMetrics, + sample_column=self.sampleColumn, + sample_artifact=self.sampleArtifact, + registered_profile=self.registeredProfile, + allow_metric_name_collisions=True, + ) + if self.captureColumn is not None and self.captureArtifact is not None: + raise ValueError( + "Cell-QC captureColumn and captureArtifact are mutually exclusive" + ) + if ( + self.captureArtifact is not None + and self.captureArtifact.artifact.kind != "hto_identity" + ): + raise ValueError( + "Cell-QC captureArtifact must reference an hto_identity artifact" + ) + failures = {item.capture: item for item in self.captureFailureEvidence} + if len(failures) != len(self.captureFailureEvidence): + raise ValueError("Cell-QC capture failure evidence must be unique") + expected_failed = sorted( + capture for capture, item in failures.items() if item.wholeCaptureFailure + ) + if failures and sorted(self.failedCaptureCandidates) != expected_failed: + raise ValueError( + "Cell-QC failed captures must match their multi-axis evidence" + ) + expected_excludable = sorted( + capture for capture, item in failures.items() if item.exclusionEligible + ) + if failures and sorted(self.excludableCaptureCandidates) != expected_excludable: + raise ValueError( + "Cell-QC excludable captures must match design-safety evidence" + ) + return self + + @classmethod + def get_blank(cls) -> "CellQcProfileEvidence": + return cls() + + @classmethod + def get_example(cls) -> "CellQcProfileEvidence": + return cls( + profileId="cellQc:RNA:globalMad5", + action="registeredMad", + registeredProfile="globalMad5", + driverAssay="RNA", + driverAssayType="RNA", + attributes=["RNA_nCounts", "RNA_nFeatures"], + artifactMetrics=[NamedArtifactSource.get_example()], + parameters={"nMads": 5.0}, + activeCells=100, + retainedCells=96, + retainedFraction=0.96, + evidenceId="qcProfile:cellQc:RNA:globalMad5", + ) + + +class CellQcPlan(AgentDataModel): + """A validated selection from the bounded cell-QC profiles.""" + + action: CellQcAction = "skip" + registeredProfile: RegisteredCellQcProfile | None = None + profileId: str = "" + driverAssay: str | None = None + driverAssayType: CellQcDriverType | None = None + sampleColumn: str | None = None + sampleArtifact: NamedArtifactSource | None = None + attributes: list[str] = Field(default_factory=list) + artifactMetrics: list[NamedArtifactSource] = Field(default_factory=list) + rationale: str = "" + evidenceIds: list[str] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_sources(self) -> "CellQcPlan": + _validate_qc_sources( + action=self.action, + attributes=self.attributes, + artifact_metrics=self.artifactMetrics, + sample_column=self.sampleColumn, + sample_artifact=self.sampleArtifact, + registered_profile=self.registeredProfile, + allow_metric_name_collisions=True, + ) + return self + + @classmethod + def get_blank(cls) -> "CellQcPlan": + return cls() + + @classmethod + def get_example(cls) -> "CellQcPlan": + evidence = CellQcProfileEvidence.get_example() + return cls( + action=evidence.action, + registeredProfile=evidence.registeredProfile, + profileId=evidence.profileId, + driverAssay=evidence.driverAssay, + driverAssayType=evidence.driverAssayType, + sampleColumn=evidence.sampleColumn, + sampleArtifact=evidence.sampleArtifact, + attributes=evidence.attributes, + artifactMetrics=evidence.artifactMetrics, + rationale="Use the bounded global profile for the RNA assay.", + evidenceIds=[evidence.evidenceId], + ) + + +class ExperimentalContextDecision(AgentDataModel): + """Model-authored choices that are revalidated against the datastore.""" + + columnDomains: dict[str, ColumnDomain] = Field(default_factory=dict) + coefficientsOfInterest: list[str] = Field(default_factory=list) + unitsOfInference: dict[str, InferenceUnit] = Field(default_factory=dict) + batchCorrection: BatchCorrectionPlan = Field( + default_factory=BatchCorrectionPlan.get_blank + ) + cellQc: CellQcPlan = Field(default_factory=CellQcPlan.get_blank) + rationale: str = "" + evidenceIds: list[str] = Field(default_factory=list) + needsInput: list[str] = Field(default_factory=list) + + @classmethod + def get_blank(cls) -> "ExperimentalContextDecision": + return cls() + + @classmethod + def get_example(cls) -> "ExperimentalContextDecision": + return cls( + columnDomains={ + "batch": "technical", + "sample": "design", + "donor": "design", + "treatment": "biological", + }, + coefficientsOfInterest=["treatment"], + unitsOfInference={"treatment": InferenceUnit.get_example()}, + batchCorrection=BatchCorrectionPlan.get_example(), + rationale="Treatment is the primary between-sample contrast.", + evidenceIds=[ + "column:batch", + "column:donor", + "column:sample", + "column:treatment", + ], + ) + + +class RepresentationEvaluation(AgentDataModel): + """Bounded integration metrics for one exact graph representation.""" + + available: bool = False + assay: str | None = None + cellSelection: ArtifactReferenceModel | None = None + neighbors: ArtifactReferenceModel | None = None + connectivityMap: ArtifactReferenceModel | None = None + metrics: dict[str, float] = Field(default_factory=dict) + notes: list[str] = Field(default_factory=list) + evidenceIds: list[str] = Field(default_factory=list) + + @classmethod + def get_blank(cls) -> "RepresentationEvaluation": + return cls() + + @classmethod + def get_example(cls) -> "RepresentationEvaluation": + return cls( + available=True, + assay="RNA", + cellSelection=ArtifactReferenceModel( + scope="datastore", + kind="cell_selection", + artifactId="c" * 64, + ), + neighbors=ArtifactReferenceModel( + assay="RNA", + kind="neighbors", + artifactId="a" * 64, + ), + connectivityMap=ArtifactReferenceModel( + assay="RNA", + kind="connectivity_map", + artifactId="b" * 64, + ), + metrics={"iLISI:batch": 0.71, "cLISI:cell_type": 0.94}, + evidenceIds=[ + "metric:iLISI:batch:assay:RNA:neighbors:example-neighbors", + "metric:cLISI:cell_type:assay:RNA:neighbors:example-neighbors", + ], + ) + + +class CovariateEvidence(AgentDataModel): + """One deterministic covariate characterization returned by a tool.""" + + characterization: CovariateCharacterization = Field( + default_factory=lambda: CovariateCharacterization(status="needsInput") + ) + batchSafety: list[BatchSafetyEvidence] = Field(default_factory=list) + qcProfiles: list[CellQcProfileEvidence] = Field(default_factory=list) + qcMetricSources: list[QcMetricSourceEvidence] = Field(default_factory=list) + qcSourceConcordance: list[QcSourceConcordance] = Field(default_factory=list) + contrastPlans: list[ContrastPlan] = Field(default_factory=list) + htoIdentityColumns: list[str] = Field(default_factory=list) + htoIdentityArtifacts: list[NamedArtifactSource] = Field(default_factory=list) + evidenceIds: list[str] = Field(default_factory=list) + + @classmethod + def get_example(cls) -> "CovariateEvidence": + return cls( + characterization=CovariateCharacterization( + status="done", + notes=["Example deterministic covariate characterization"], + ), + qcProfiles=[CellQcProfileEvidence.get_example()], + htoIdentityColumns=["sample_id"], + htoIdentityArtifacts=[ + NamedArtifactSource( + name="HTO_htoIdentity", + artifact=ArtifactReferenceModel( + assay="HTO", + kind="hto_identity", + artifactId="2" * 64, + ), + ) + ], + evidenceIds=[ + "column:batch", + CellQcProfileEvidence.get_example().evidenceId, + "htoIdentity:sample_id", + f"htoIdentityArtifact:HTO_htoIdentity:{'2' * 64}", + ], + ) + + +class ExperimentalContextResult(AgentDataModel): + """Canonical experimental-context report returned to the caller.""" + + status: StageStatus + decision: ExperimentalContextDecision + characterization: CovariateCharacterization + cellSelection: ArtifactReferenceModel | None = None + cellQc: CellQcPlan = Field(default_factory=CellQcPlan.get_blank) + qcProfiles: list[CellQcProfileEvidence] = Field(default_factory=list) + qcMetricSources: list[QcMetricSourceEvidence] = Field(default_factory=list) + qcSourceConcordance: list[QcSourceConcordance] = Field(default_factory=list) + contrastPlans: list[ContrastPlan] = Field(default_factory=list) + qualityMetricArtifacts: list[NamedArtifactSource] = Field(default_factory=list) + htoIdentityColumns: list[str] = Field(default_factory=list) + htoIdentityArtifacts: list[NamedArtifactSource] = Field(default_factory=list) + batchSafety: list[BatchSafetyEvidence] = Field(default_factory=list) + currentRepresentation: RepresentationEvaluation = Field( + default_factory=RepresentationEvaluation.get_blank + ) + notes: list[str] = Field(default_factory=list) + runInfo: AgentRunInfo = Field(default_factory=AgentRunInfo) + + @classmethod + def get_blank(cls) -> "ExperimentalContextResult": + return cls( + status="needsInput", + decision=ExperimentalContextDecision.get_blank(), + characterization=CovariateCharacterization(status="needsInput"), + ) + + @classmethod + def get_example(cls) -> "ExperimentalContextResult": + representation = RepresentationEvaluation.get_example() + return cls( + status="done", + decision=ExperimentalContextDecision.get_example(), + characterization=CovariateCharacterization( + status="done", + notes=["Example deterministic design characterization"], + ), + cellSelection=representation.cellSelection, + qcProfiles=[CellQcProfileEvidence.get_example()], + qualityMetricArtifacts=[NamedArtifactSource.get_example()], + htoIdentityColumns=["sample_id"], + htoIdentityArtifacts=[ + NamedArtifactSource( + name="HTO_htoIdentity", + artifact=ArtifactReferenceModel( + assay="HTO", + kind="hto_identity", + artifactId="2" * 64, + ), + ) + ], + batchSafety=[BatchSafetyEvidence.get_example()], + currentRepresentation=representation, + runInfo=AgentRunInfo.get_example(), + ) + + def to_parameter_tuning_handoff(self) -> ExperimentalTuningHandoff: + """Return validated integration inputs for Parameter Tuning.""" + if self.status != "done": + raise ValueError( + "Experimental Context must be done before creating a tuning handoff" + ) + if self.cellSelection is None: + raise ValueError("Experimental Context result lacks a cell selection") + plan = self.decision.batchCorrection + batch_columns = sorted(plan.batchColumns) + safety = sorted( + ( + item + for item in self.batchSafety + if item.batchColumns == batch_columns + and item.coefficient in self.decision.coefficientsOfInterest + ), + key=lambda item: item.coefficient, + ) + if plan.action in {"evaluateHarmony", "unsafe"}: + expected = set(self.decision.coefficientsOfInterest) + if {item.coefficient for item in safety} != expected: + raise ValueError( + "Experimental Context result lacks exact batch safety evidence" + ) + if any(item.evidenceId not in plan.evidenceIds for item in safety): + raise ValueError( + "Batch-correction plan does not cite its exact safety evidence" + ) + if plan.action == "evaluateHarmony" and any( + item.status != "safe" for item in safety + ): + raise ValueError("Harmony plan contains non-safe batch evidence") + if plan.action == "unsafe" and ( + any(item.status == "notComputed" for item in safety) + or not any(item.status == "unsafe" for item in safety) + ): + raise ValueError("Unsafe plan lacks exact unsafe batch evidence") + return ExperimentalTuningHandoff( + cellSelection=self.cellSelection, + batchAction=plan.action, + batchColumns=batch_columns, + preservationColumns=list(plan.preserveColumns), + coefficientsOfInterest=list(self.decision.coefficientsOfInterest), + batchSafety=safety, + evidenceIds=sorted({*self.decision.evidenceIds, *plan.evidenceIds}), + ) + + def to_biological_handoff( + self, + coefficient: str | None = None, + ) -> ExperimentalBiologyHandoff: + """Return one explicitly resolved biological coefficient.""" + if self.status != "done": + raise ValueError( + "Experimental Context must be done before creating a biology handoff" + ) + if self.cellSelection is None: + raise ValueError("Experimental Context result lacks a cell selection") + coefficients = list(self.decision.coefficientsOfInterest) + if coefficient is None: + if len(coefficients) != 1: + raise ValueError( + "Select one coefficient explicitly for biological interpretation" + ) + coefficient = coefficients[0] + if coefficient not in coefficients: + raise ValueError(f"Unknown coefficient of interest {coefficient!r}") + records = { + record.get("name"): record + for record in self.characterization.coefficients + if isinstance(record.get("name"), str) + } + record = records.get(coefficient) + if record is None: + raise ValueError(f"Missing characterization for {coefficient!r}") + reports = { + report.get("coefficient"): report + for report in self.characterization.confounding + if isinstance(report.get("coefficient"), str) + } + report = reports.get(coefficient) + known_evidence = characterization_evidence(self.characterization) + relevant_evidence = { + f"column:{coefficient}", + f"coefficient:{coefficient}", + f"estimability:{coefficient}", + *( + evidence_id + for evidence_id in known_evidence + if evidence_id.startswith(f"confounding:{coefficient}:") + ), + } + for unit_name in ( + record.get("observationUnit"), + record.get("independentUnit"), + ): + if isinstance(unit_name, str): + relevant_evidence.add(f"column:{unit_name}") + return ExperimentalBiologyHandoff( + cellSelection=self.cellSelection, + conditionColumn=coefficient, + observationUnit=record.get("observationUnit"), + independentUnit=record.get("independentUnit"), + coefficientScope=str(record.get("scope", "")), + estimability=dict(report.get("estimability") or {}) if report else {}, + evidenceIds=sorted(relevant_evidence.intersection(known_evidence)), + ) + + +class ExperimentalContextDependencies(AgentDataModel): + """Runtime-only state shared by the agent's read-only tools.""" + + model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") + + store: Any = Field(default=None, exclude=True) + cells: Any = Field(default=None, exclude=True) + neighbors: Any = Field(default=None, exclude=True) + connectivityMap: Any = Field(default=None, exclude=True) + cellSelection: Any = Field(default=None, exclude=True) + studyContext: str = "" + studyObjective: str = "" + directions: dict[str, Any] = Field(default_factory=dict) + evidenceIds: set[str] = Field(default_factory=set) + characterization: CovariateCharacterization | None = None + batchSafety: dict[str, BatchSafetyEvidence] = Field(default_factory=dict) + qcProfiles: dict[str, CellQcProfileEvidence] = Field(default_factory=dict) + qcMetricSources: list[QcMetricSourceEvidence] = Field(default_factory=list) + qcSourceConcordance: list[QcSourceConcordance] = Field(default_factory=list) + contrastPlans: dict[str, ContrastPlan] = Field(default_factory=dict) + htoIdentityColumns: list[str] = Field(default_factory=list) + qualityMetricArtifacts: list[NamedArtifactSource] = Field(default_factory=list) + htoIdentityArtifacts: list[NamedArtifactSource] = Field(default_factory=list) + currentRepresentation: RepresentationEvaluation = Field( + default_factory=RepresentationEvaluation.get_blank + ) + toolCalls: list[str] = Field(default_factory=list) + + @classmethod + def get_blank(cls) -> "ExperimentalContextDependencies": + return cls() + + @classmethod + def get_example(cls) -> "ExperimentalContextDependencies": + return cls( + studyContext="Case-control study with samples nested in donors.", + studyObjective=( + "Discover populations while preserving the case-control contrast." + ), + directions={"columnDomains": {"batch": "technical"}}, + ) + + +def characterization_evidence( + characterization: CovariateCharacterization, +) -> set[str]: + """Build stable evidence IDs from one deterministic characterization.""" + evidence_ids = { + f"column:{record['name']}" + for record in characterization.columns + if isinstance(record.get("name"), str) + } + for record in characterization.coefficients: + coefficient = record.get("name") + if isinstance(coefficient, str): + evidence_ids.add(f"coefficient:{coefficient}") + for report in characterization.confounding: + coefficient = report.get("coefficient") + if not isinstance(coefficient, str): + continue + evidence_ids.add(f"estimability:{coefficient}") + for pair in report.get("pairs", []): + technical = pair.get("technical") + if isinstance(technical, str): + evidence_ids.add(f"confounding:{coefficient}:{technical}") + return evidence_ids diff --git a/scarf/agent/experimental_context/qc_evidence.py b/scarf/agent/experimental_context/qc_evidence.py new file mode 100644 index 00000000..719ca489 --- /dev/null +++ b/scarf/agent/experimental_context/qc_evidence.py @@ -0,0 +1,1564 @@ +"""Experimental-context quality-control evidence assembly.""" + +import math +import re +from collections.abc import Mapping, Sequence +from typing import Any, Literal, cast + +import numpy as np + +from ...metadata.selection import resolve_cell_aligned_artifact +from ...quality_control.filtering import ( + _validated_sample_labels, + gaussian_quantile_bounds, +) +from ...storage.artifacts import ( + fingerprint_array, + fingerprint_strings, + inspect_artifact, +) +from ...storage.refs import ArtifactRef +from ...storage.selections import read_stored_selection_mask +from ..cell_quality.profiles import ( + AutoFilterProjection, + QcMetricRole, + RegisteredCellQcProfile, + RegisteredQcProjection, + offered_registered_qc_profiles, + project_auto_filter_profile, + qc_metric_execution_name, + registered_qc_metric_role, +) +from ..tools import artifact_reference, core_artifact_reference +from ..types import ArtifactReferenceModel +from .contracts import ( + CaptureFailureEvidence, + CellQcAction, + CellQcDriverType, + CellQcProfileEvidence, + CovariateCharacterization, + ExperimentalContextDependencies, + LegacyCellQcAction, + NamedArtifactSource, + QcMetricSourceEvidence, + QcSourceConcordance, +) + +_MAX_QC_SAMPLE_PROFILES = 4 + + +def _persisted_assay_type(store: Any, assay_name: str) -> str: + """Read one persisted assay type without inferring modality from features.""" + root = getattr(store, "zw", None) + attrs = getattr(root, "attrs", {}) + raw_types = attrs.get("assayTypes", {}) if isinstance(attrs, Mapping) else {} + if isinstance(raw_types, Mapping): + assay_type = raw_types.get(assay_name) + if isinstance(assay_type, str): + return assay_type + return assay_name if assay_name in {"RNA", "ATAC", "ADT", "HTO"} else "Assay" + + +def _qc_driver(store: Any) -> tuple[str, CellQcDriverType] | None: + """Choose the first RNA assay, otherwise the first ATAC assay.""" + assay_names = [str(name) for name in getattr(store, "assay_names", [])] + for assay_type in ("RNA", "ATAC"): + for assay_name in assay_names: + if _persisted_assay_type(store, assay_name) == assay_type: + return assay_name, assay_type + return None + + +def _hto_identity_columns(deps: ExperimentalContextDependencies) -> list[str]: + """Return explicitly supplied imported HTO identity metadata columns.""" + requested: list[str] = [] + directed_many = deps.directions.get("htoIdentityColumns") + if isinstance(directed_many, list | tuple): + requested.extend(str(value) for value in directed_many) + directed_one = deps.directions.get("htoIdentityColumn") + if isinstance(directed_one, str): + requested.append(directed_one) + available = set(deps.store.cells.columns) + return list(dict.fromkeys(name for name in requested if name in available)) + + +def _cell_selection_ref(deps: ExperimentalContextDependencies) -> ArtifactRef: + selection = core_artifact_reference(deps.cellSelection) + if not isinstance(selection, ArtifactRef): + raise ValueError("cellSelection must identify an exact artifact") + if selection.kind != "cell_selection" or selection.scope != "datastore": + raise ValueError("cellSelection must identify a datastore cell selection") + return selection + + +def _active_cell_count(deps: ExperimentalContextDependencies) -> int: + selection = _cell_selection_ref(deps) + active = read_stored_selection_mask( + deps.store.zw, + selection, + kind="cell_selection", + scope="datastore", + assay=None, + table_path="cellData", + ) + if active.ndim != 1 or active.shape[0] != deps.store.cells.N: + raise ValueError( + "cellSelection must contain an aligned boolean selection vector" + ) + return int(active.sum()) + + +def _source_ref( + source: NamedArtifactSource, + *, + expected_kind: str, +) -> ArtifactRef: + if not isinstance(source, NamedArtifactSource): + raise TypeError("Artifact sources must be NamedArtifactSource values") + if not source.name.strip(): + raise ValueError("Artifact sources require a non-empty semantic name") + artifact = core_artifact_reference(source.artifact) + if not isinstance(artifact, ArtifactRef) or artifact.kind != expected_kind: + raise ValueError( + f"Artifact source {source.name!r} must reference {expected_kind!r}" + ) + return artifact + + +def _artifact_evidence_id(source: NamedArtifactSource) -> str: + return f"htoIdentityArtifact:{source.name}:{source.artifact.artifactId}" + + +def _hto_artifact_map( + deps: ExperimentalContextDependencies, +) -> dict[str, ArtifactRef]: + artifacts: dict[str, ArtifactRef] = {} + for source in deps.htoIdentityArtifacts: + if source.name in artifacts: + raise ValueError("HTO identity artifact names must be unique") + artifacts[source.name] = _source_ref( + source, + expected_kind="hto_identity", + ) + return artifacts + + +def _resolved_artifact_values( + deps: ExperimentalContextDependencies, + source: NamedArtifactSource, + *, + expected_kind: str, +) -> np.ndarray: + resolved = resolve_cell_aligned_artifact( + deps.store.zw, + _source_ref(source, expected_kind=expected_kind), + cell_selection=_cell_selection_ref(deps), + expected_kind=expected_kind, + ) + return np.asarray(resolved.values) + + +def _artifact_input_references( + value: Any, + *, + limit: int = 16, +) -> list[ArtifactReferenceModel]: + refs: list[ArtifactReferenceModel] = [] + seen: set[tuple[str, str | None, str, str]] = set() + + def visit(item: Any) -> None: + if len(refs) >= limit: + return + if isinstance(item, ArtifactRef): + ref = item + elif isinstance(item, Mapping) and { + "scope", + "kind", + "artifact_id", + }.issubset(item): + try: + ref = ArtifactRef.from_dict(item) + except (KeyError, TypeError, ValueError): + ref = None + else: + ref = None + if ref is not None: + key = (ref.scope, ref.assay, ref.kind, ref.artifact_id) + if key not in seen: + seen.add(key) + refs.append(artifact_reference(ref)) + return + if isinstance(item, Mapping): + for nested in item.values(): + visit(nested) + elif isinstance(item, list | tuple): + for nested in item: + visit(nested) + + visit(value) + return refs + + +def _qc_metric_sources( + deps: ExperimentalContextDependencies, + driver: tuple[str, CellQcDriverType], +) -> tuple[ + dict[str, np.ndarray], + list[str], + list[NamedArtifactSource], + list[QcMetricSourceEvidence], + list[QcSourceConcordance], + list[str], + dict[str, np.ndarray], +]: + assay_name, assay_type = driver + del assay_type + selection = _cell_selection_ref(deps) + selection_model = artifact_reference(selection) + active_cells = _active_cell_count(deps) + metadata_names = _qc_attributes(deps.store, assay_name, driver[1]) + artifact_candidates: list[NamedArtifactSource] = [] + for source in deps.qualityMetricArtifacts: + artifact = _source_ref(source, expected_kind="quality_metric") + if artifact.assay == assay_name: + artifact_candidates.append(source) + metadata_collisions = set(metadata_names).intersection( + source.name for source in artifact_candidates + ) + + values_by_execution_name: dict[str, np.ndarray] = {} + values_by_source: dict[str, np.ndarray] = {} + sources: list[QcMetricSourceEvidence] = [] + valid_metadata: list[str] = [] + valid_artifacts: list[NamedArtifactSource] = [] + notes: list[str] = [] + + for name in metadata_names: + raw = np.asarray(deps.cells.fetch(name)) + try: + values = np.asarray(raw, dtype=float) + except (TypeError, ValueError): + fingerprint = fingerprint_strings(raw) + source_id = f"qcMetric:metadata:{assay_name}:{name}:{fingerprint}" + sources.append( + QcMetricSourceEvidence( + sourceId=source_id, + metricName=name, + metricRole=registered_qc_metric_role(name), + assay=assay_name, + sourceType="metadataColumn", + origin="ingestionMetadata", + executionName=name, + metadataColumn=name, + cellSelection=selection_model, + valuesFingerprint=fingerprint, + activeCells=active_cells, + missingCells=active_cells, + notes=["Metric is not numeric and cannot drive filtering"], + ) + ) + notes.append(f"QC metadata source {name!r} is not numeric") + continue + if values.ndim != 1 or values.shape != (active_cells,): + raise ValueError( + f"QC metadata source {name!r} does not align with cellSelection" + ) + fingerprint = fingerprint_array(values) + missing = int((~np.isfinite(values)).sum()) + source_id = f"qcMetric:metadata:{assay_name}:{name}:{fingerprint}" + usable = missing == 0 + source_notes = ( + [] if usable else [f"{missing} active cells have non-finite metric values"] + ) + sources.append( + QcMetricSourceEvidence( + sourceId=source_id, + metricName=name, + metricRole=registered_qc_metric_role(name), + assay=assay_name, + sourceType="metadataColumn", + origin="ingestionMetadata", + executionName=name, + metadataColumn=name, + cellSelection=selection_model, + valuesFingerprint=fingerprint, + activeCells=active_cells, + missingCells=missing, + usableForFiltering=usable, + notes=source_notes, + ) + ) + values_by_source[source_id] = values + if usable: + values_by_execution_name[name] = values + valid_metadata.append(name) + else: + notes.extend(source_notes) + + for source in artifact_candidates: + artifact = _source_ref(source, expected_kind="quality_metric") + values = np.asarray( + _resolved_artifact_values( + deps, + source, + expected_kind="quality_metric", + ), + dtype=float, + ) + if values.ndim != 1 or values.shape != (active_cells,): + raise ValueError( + f"QC artifact {source.name!r} does not align with cellSelection" + ) + execution_name = qc_metric_execution_name( + source.name, + artifact_id=artifact.artifact_id, + collides_with_metadata=source.name in metadata_collisions, + ) + if execution_name in values_by_execution_name: + raise ValueError( + f"QC execution metric name {execution_name!r} is not unique" + ) + fingerprint = fingerprint_array(values) + missing = int((~np.isfinite(values)).sum()) + status = inspect_artifact(deps.store.zw, artifact) + operation = status.operation + origin: Literal[ + "ingestionMetadata", + "derivedArtifact", + "externalArtifact", + ] = ( + "derivedArtifact" + if operation == "run_feature_percentage" + else "externalArtifact" + ) + source_id = ( + f"qcMetric:artifact:{artifact.assay}:{source.name}:{artifact.artifact_id}" + ) + usable = missing == 0 + source_notes = ( + [] if usable else [f"{missing} active cells have non-finite metric values"] + ) + sources.append( + QcMetricSourceEvidence( + sourceId=source_id, + metricName=source.name, + metricRole=registered_qc_metric_role(source.name), + assay=assay_name, + sourceType="artifact", + origin=origin, + executionName=execution_name, + artifact=artifact_reference(artifact), + cellSelection=selection_model, + inputArtifacts=_artifact_input_references(status.inputs or {}), + provenanceOperation=operation, + valuesFingerprint=fingerprint, + activeCells=active_cells, + missingCells=missing, + usableForFiltering=usable, + notes=source_notes, + ) + ) + values_by_source[source_id] = values + if usable: + values_by_execution_name[execution_name] = values + valid_artifacts.append(source) + else: + notes.extend(source_notes) + + concordance: list[QcSourceConcordance] = [] + metadata_sources = [ + source for source in sources if source.sourceType == "metadataColumn" + ] + artifact_sources = [source for source in sources if source.sourceType == "artifact"] + for left in metadata_sources: + for right in artifact_sources: + if left.metricRole != right.metricRole or left.metricRole == "diagnostic": + continue + if right.artifact is None: + raise ValueError("Artifact QC source lacks its exact reference") + left_values = values_by_source.get(left.sourceId) + right_values = values_by_source.get(right.sourceId) + if left_values is None or right_values is None: + continue + finite = np.isfinite(left_values) & np.isfinite(right_values) + compared = int(finite.sum()) + missing = int(len(finite) - compared) + mean_difference: float | None = None + maximum_difference: float | None = None + pearson: float | None = None + exactly_equal = False + numerically_close = False + if compared: + left_finite = left_values[finite] + right_finite = right_values[finite] + differences = np.abs(left_finite - right_finite) + mean_difference = float(differences.mean()) + maximum_difference = float(differences.max()) + exactly_equal = missing == 0 and bool( + np.array_equal(left_finite, right_finite) + ) + numerically_close = missing == 0 and bool( + np.allclose( + left_finite, + right_finite, + rtol=1e-6, + atol=1e-8, + ) + ) + if ( + compared >= 2 + and float(np.std(left_finite)) > 0.0 + and float(np.std(right_finite)) > 0.0 + ): + correlation = float(np.corrcoef(left_finite, right_finite)[0, 1]) + if math.isfinite(correlation): + pearson = correlation + evidence_id = ( + f"qcConcordance:{left.metricRole}:" + f"{left.valuesFingerprint}:{right.artifact.artifactId}" + ) + concordance.append( + QcSourceConcordance( + metricRole=left.metricRole, + leftSourceId=left.sourceId, + rightSourceId=right.sourceId, + comparedCells=compared, + missingCells=missing, + meanAbsoluteDifference=mean_difference, + maximumAbsoluteDifference=maximum_difference, + pearsonCorrelation=pearson, + exactlyEqual=exactly_equal, + numericallyClose=numerically_close, + evidenceId=evidence_id, + ) + ) + return ( + values_by_execution_name, + valid_metadata, + valid_artifacts, + sources, + concordance, + notes, + values_by_source, + ) + + +def _qc_attributes(store: Any, assay_name: str, assay_type: str) -> list[str]: + del assay_type + suffixes = ["nCounts", "nFeatures", "percentMito", "percentRibo"] + available = set(store.cells.columns) + return [ + f"{assay_name}_{suffix}" + for suffix in suffixes + if f"{assay_name}_{suffix}" in available + ] + + +def _derive_missing_percentage_artifacts( + store: Any, + *, + cell_selection: ArtifactRef, + driver: tuple[str, CellQcDriverType] | None, + quality_sources: Sequence[NamedArtifactSource], +) -> list[NamedArtifactSource]: + """Derive missing RNA percentage metrics through public immutable APIs.""" + sources = list(quality_sources) + if driver is None or driver[1] != "RNA": + return sources + if not callable(getattr(store, "set_feature_selection", None)) or not callable( + getattr(store, "run_feature_percentage", None) + ): + return sources + assay_name = driver[0] + available_metadata = set(store.cells.columns) + supplied_roles = { + registered_qc_metric_role(source.name) + for source in sources + if source.artifact.assay == assay_name + } + assay = store.get_assay(assay_name) + feature_ids = np.asarray(assay.feats.fetch_all("ids")).astype(str) + feature_names = np.asarray(assay.feats.fetch_all("names")).astype(str) + specifications: tuple[ + tuple[QcMetricRole, str, re.Pattern[str]], + ..., + ] = ( + ("mitochondrial", "percentMito", re.compile(r"^(MT-|mt-)")), + ( + "ribosomal", + "percentRibo", + re.compile(r"^(RPS|RPL|MRPS|MRPL|Rps|Rpl|Mrps|Mrpl)"), + ), + ) + existing_names = {source.name for source in sources} + for role, suffix, pattern in specifications: + metric_name = f"{assay_name}_{suffix}" + if metric_name in available_metadata or role in supplied_roles: + continue + mask = np.fromiter( + ( + pattern.search(feature_id) is not None + or pattern.search(feature_name) is not None + for feature_id, feature_name in zip( + feature_ids, + feature_names, + strict=True, + ) + ), + dtype=bool, + count=assay.feats.N, + ) + if not mask.any(): + continue + if metric_name in existing_names: + raise ValueError(f"Derived QC metric name {metric_name!r} is not unique") + feature_selection = store.set_feature_selection( + from_assay=assay_name, + mask=mask, + invalidate_cache=False, + ) + metric = store.run_feature_percentage( + cell_selection, + feature_selection, + invalidate_cache=False, + ) + sources.append( + NamedArtifactSource( + name=metric_name, + artifact=artifact_reference(metric), + ) + ) + existing_names.add(metric_name) + supplied_roles.add(role) + return sources + + +def _qc_sample_columns( + deps: ExperimentalContextDependencies, + characterization: CovariateCharacterization | None, +) -> list[str]: + requested: list[str] = [] + directed = deps.directions.get("cellQc") + if isinstance(directed, Mapping): + sample_column = directed.get("sampleColumn") + if isinstance(sample_column, str): + requested.append(sample_column) + if characterization is not None: + for record in characterization.coefficients: + observation_unit = record.get("observationUnit") + if isinstance(observation_unit, str): + requested.append(observation_unit) + requested.extend(deps.htoIdentityColumns) + available = set(deps.store.cells.columns) + return list( + dict.fromkeys(name for name in requested if name in available and name != "I") + )[:_MAX_QC_SAMPLE_PROFILES] + + +def _qc_profile_id( + action: LegacyCellQcAction, + *, + driver: tuple[str, CellQcDriverType] | None, + sample_column: str | None = None, + sample_artifact: NamedArtifactSource | None = None, +) -> str: + assay_name, assay_type = driver or ("none", "none") + suffix = { + "skip": "skip", + "globalGaussian": "globalGaussian:0.01:0.99", + "sampleMad": ( + f"sampleMad:metadata:{sample_column}:3:20" + if sample_artifact is None + else ( + f"sampleMad:artifact:{sample_artifact.name}:" + f"{sample_artifact.artifact.artifactId}:3:20" + ) + ), + }[action] + return f"cellQc:{assay_type}:{assay_name}:{suffix}" + + +def _registered_qc_profile_id( + profile: RegisteredCellQcProfile, + *, + driver: tuple[str, CellQcDriverType], + sample_column: str | None, + sample_artifact: NamedArtifactSource | None, +) -> str: + if sample_column is not None: + source = f"metadata:{sample_column}" + elif sample_artifact is not None: + source = ( + f"artifact:{sample_artifact.name}:{sample_artifact.artifact.artifactId}" + ) + else: + source = "global" + return f"cellQc:{driver[1]}:{driver[0]}:registered:{profile}:{source}" + + +def _directed_capture_source( + deps: ExperimentalContextDependencies, +) -> tuple[str | None, NamedArtifactSource | None, np.ndarray] | None: + directed_qc = deps.directions.get("cellQc") + qc_directions = dict(directed_qc) if isinstance(directed_qc, Mapping) else {} + candidates = [ + deps.directions.get("physicalCaptureColumn"), + qc_directions.get("physicalCaptureColumn"), + qc_directions.get("captureColumn"), + ] + specified = [value for value in candidates if value is not None] + if not specified: + return None + if any(not isinstance(value, str) or not value.strip() for value in specified): + raise ValueError("physicalCaptureColumn must be a non-empty string") + names = list(dict.fromkeys(str(value) for value in specified)) + if len(names) != 1: + raise ValueError("Conflicting physical capture columns were supplied") + name = names[0] + matching_artifacts = [ + source for source in deps.htoIdentityArtifacts if source.name == name + ] + if len(matching_artifacts) > 1: + raise ValueError(f"Physical capture artifact {name!r} is not unique") + if matching_artifacts: + source = matching_artifacts[0] + labels = _resolved_artifact_values( + deps, + source, + expected_kind="hto_identity", + ) + return None, source, np.asarray(labels) + if name not in deps.cells.columns: + raise ValueError( + f"physicalCaptureColumn {name!r} is not observed metadata or an " + "exact HTO identity artifact" + ) + return name, None, np.asarray(deps.cells.fetch(name)) + + +def _directed_pooled_reference_captures( + deps: ExperimentalContextDependencies, +) -> tuple[str, ...] | None: + directed_qc = deps.directions.get("cellQc") + qc_directions = dict(directed_qc) if isinstance(directed_qc, Mapping) else {} + raw = qc_directions.get( + "pooledReferenceCaptures", + deps.directions.get("pooledReferenceCaptures"), + ) + if raw is None: + return None + if not isinstance(raw, list | tuple) or any( + not isinstance(value, str) or not value.strip() for value in raw + ): + raise ValueError("pooledReferenceCaptures must contain non-empty strings") + references = tuple(str(value) for value in raw) + if len(references) < 2 or len(references) != len(set(references)): + raise ValueError( + "pooledReferenceCaptures must contain at least two unique captures" + ) + return references + + +def _provenance_label(value: Any) -> str | None: + if isinstance(value, np.generic): + value = value.item() + if value is None: + return None + if isinstance(value, float) and not math.isfinite(value): + return None + if isinstance(value, bytes): + try: + value = value.decode("utf-8") + except UnicodeDecodeError: + return None + if isinstance(value, str) and not value.strip(): + return None + return str(value) + + +def _ordered_labels(values: np.ndarray, mask: np.ndarray) -> list[str]: + output: list[str] = [] + seen: set[str] = set() + for raw in values[mask]: + label = _provenance_label(raw) + if label is None or label in seen: + continue + seen.add(label) + output.append(label) + return output + + +def _capture_design_safety( + deps: ExperimentalContextDependencies, + characterization: CovariateCharacterization | None, + capture_labels: np.ndarray, + capture: str, +) -> tuple[list[dict[str, Any]], bool, bool]: + if characterization is None: + return [], False, False + active = np.ones(len(capture_labels), dtype=bool) + normalized = _validated_sample_labels( + capture_labels, + active, + label_name="physical capture labels", + ) + encoded = np.asarray( + [ + value.decode("utf-8") if isinstance(value, bytes) else str(value) + for value in normalized + ], + dtype=object, + ) + after = encoded != capture + safety: list[dict[str, Any]] = [] + for record in characterization.coefficients: + coefficient = record.get("name") + observation = record.get("observationUnit") + independent = record.get("independentUnit") + if ( + not isinstance(coefficient, str) + or not isinstance(observation, str) + or record.get("scope") != "betweenUnit" + or coefficient not in deps.cells.columns + or observation not in deps.cells.columns + ): + continue + condition_values = np.asarray(deps.cells.fetch(coefficient), dtype=object) + observation_values = np.asarray(deps.cells.fetch(observation), dtype=object) + if ( + condition_values.shape != after.shape + or observation_values.shape != after.shape + ): + raise ValueError("Capture safety columns do not align with cellSelection") + required_groups = _ordered_labels(condition_values, active) + remaining_groups = _ordered_labels(condition_values, after) + preserves_conditions = set(remaining_groups) == set(required_groups) + + observation_counts: list[dict[str, Any]] = [] + independent_counts: list[dict[str, Any]] = [] + independent_values: np.ndarray | None = None + if isinstance(independent, str): + if independent not in deps.cells.columns: + continue + independent_values = np.asarray( + deps.cells.fetch(independent), + dtype=object, + ) + if independent_values.shape != after.shape: + raise ValueError( + "Capture independent-unit column does not align with cellSelection" + ) + for group in required_groups: + group_mask = np.asarray( + [_provenance_label(value) == group for value in condition_values], + dtype=bool, + ) + observation_levels = set( + _ordered_labels(observation_values, after & group_mask) + ) + observation_counts.append( + {"group": group, "count": len(observation_levels)} + ) + if independent_values is not None: + independent_levels = set( + _ordered_labels(independent_values, after & group_mask) + ) + independent_counts.append( + {"group": group, "count": len(independent_levels)} + ) + + replication_counts = ( + independent_counts if independent_values is not None else observation_counts + ) + minimum_units = min( + (int(item["count"]) for item in replication_counts), + default=0, + ) + complete_pairs = 0 + incomplete_pairs = 0 + duplicate_pair_groups = 0 + single_group_pairs = 0 + if independent_values is not None: + pair_groups: dict[str, dict[str, set[str]]] = {} + for index in np.flatnonzero(after): + pair = _provenance_label(independent_values[index]) + pair_group = _provenance_label(condition_values[index]) + observation_value = _provenance_label(observation_values[index]) + if pair is None or pair_group is None or observation_value is None: + continue + pair_groups.setdefault(pair, {}).setdefault(pair_group, set()).add( + observation_value + ) + required_set = set(required_groups) + for groups in pair_groups.values(): + if len(groups) == 1: + single_group_pairs += 1 + duplicate_pair_groups += sum( + len(observations) > 1 for observations in groups.values() + ) + if set(groups) == required_set and all( + len(observations) == 1 for observations in groups.values() + ): + complete_pairs += 1 + else: + incomplete_pairs += 1 + original_pair_design = dict(record.get("pairedCoverage") or {}).get("design") + pair_structure_safe = ( + True + if independent_values is None + else ( + complete_pairs >= 2 + and incomplete_pairs == 0 + and duplicate_pair_groups == 0 + ) + if original_pair_design == "paired" + else (len(pair_groups) >= 2 and single_group_pairs == len(pair_groups)) + if original_pair_design == "betweenIndependentUnits" + else False + ) + preserves_units = ( + preserves_conditions and minimum_units >= 2 and pair_structure_safe + ) + safety.append( + { + "coefficient": coefficient, + "conditionColumn": coefficient, + "observationUnit": observation, + "independentUnit": independent, + "requiredGroups": required_groups, + "remainingGroups": remaining_groups, + "observationUnitsByGroup": observation_counts, + "independentUnitsByGroup": independent_counts, + "minimumIndependentUnitsAfterExclusion": minimum_units, + "completePairsAfterExclusion": complete_pairs, + "incompletePairsAfterExclusion": incomplete_pairs, + "duplicatePairGroupsAfterExclusion": duplicate_pair_groups, + "independentUnitDesign": original_pair_design, + "preservesConditionCoverage": preserves_conditions, + "preservesIndependentUnitCoverage": preserves_units, + } + ) + return ( + safety, + bool(safety) and all(item["preservesConditionCoverage"] for item in safety), + bool(safety) + and all(item["preservesIndependentUnitCoverage"] for item in safety), + ) + + +def _capture_source_missingness( + sources: Sequence[QcMetricSourceEvidence], + values_by_source: Mapping[str, np.ndarray], + capture_labels: np.ndarray | None, +) -> list[QcMetricSourceEvidence]: + if capture_labels is None: + return list(sources) + active = np.ones(len(capture_labels), dtype=bool) + normalized = _validated_sample_labels( + capture_labels, + active, + label_name="physical capture labels", + ) + captures: list[tuple[str, np.ndarray]] = [] + seen: set[str] = set() + for raw in normalized: + value = raw.item() if isinstance(raw, np.generic) else raw + key = value.decode("utf-8") if isinstance(value, bytes) else str(value) + if key in seen: + continue + seen.add(key) + captures.append((key, normalized == value)) + output: list[QcMetricSourceEvidence] = [] + for source in sources: + values = values_by_source.get(source.sourceId) + missing_by_capture: dict[str, int] = {} + if values is not None: + for capture, mask in captures: + missing_by_capture[capture] = int((~np.isfinite(values[mask])).sum()) + elif source.missingCells == source.activeCells: + missing_by_capture = { + capture: int(mask.sum()) for capture, mask in captures + } + output.append( + source.model_copy(update={"missingCellsByCapture": missing_by_capture}) + ) + return output + + +def _capture_failure_models( + projection: RegisteredQcProjection | AutoFilterProjection, + *, + deps: ExperimentalContextDependencies, + characterization: CovariateCharacterization | None, + capture_labels: np.ndarray | None, + metric_sources: Sequence[QcMetricSourceEvidence], +) -> list[CaptureFailureEvidence]: + if capture_labels is None: + return [] + output: list[CaptureFailureEvidence] = [] + source_by_id = {source.sourceId: source for source in metric_sources} + for comparison in projection.captureComparisons: + missing_fractions = { + source_id: ( + source.missingCellsByCapture.get(comparison.capture, 0) + / comparison.cells + if comparison.cells + else 0.0 + ) + for source_id, source in source_by_id.items() + } + safety, condition_safe, unit_safe = _capture_design_safety( + deps, + characterization, + capture_labels, + comparison.capture, + ) + failure = CaptureFailureEvidence( + capture=comparison.capture, + activeCells=comparison.cells, + retainedCells=comparison.retainedCells or 0, + retainedFraction=comparison.retainedFraction or 0.0, + adverseAxes=list(comparison.adverseAxes), + independentAdverseAxes=comparison.independentAdverseAxes, + metricMissingFractions=missing_fractions, + reasons=list(comparison.reasons), + wholeCaptureFailure=comparison.wholeCaptureFailure, + conditionAndUnitSafety=safety, + preservesConditionCoverage=condition_safe, + preservesIndependentUnitCoverage=unit_safe, + exclusionEligible=( + comparison.wholeCaptureFailure and condition_safe and unit_safe + ), + evidenceId=( + f"qcCapture:{comparison.capture}:" + f"{comparison.independentAdverseAxes}axes" + ), + ) + output.append(failure) + return output + + +def _registered_profile_evidence( + projection: RegisteredQcProjection, + *, + deps: ExperimentalContextDependencies, + characterization: CovariateCharacterization | None, + driver: tuple[str, CellQcDriverType], + active: np.ndarray, + values_by_attr: dict[str, np.ndarray], + metadata_attributes: list[str], + artifact_metrics: list[NamedArtifactSource], + metric_sources: list[QcMetricSourceEvidence], + source_concordance: list[QcSourceConcordance], + sample_column: str | None, + sample_artifact: NamedArtifactSource | None, + capture_column: str | None, + capture_artifact: NamedArtifactSource | None, + capture_labels: np.ndarray | None, + pooled_reference_captures: tuple[str, ...] | None, + active_cells: int, + comparison_source: str | None, +) -> CellQcProfileEvidence: + attributes = list(metadata_attributes) + metric_artifacts = list(artifact_metrics) + profile_id = _registered_qc_profile_id( + projection.profile, + driver=driver, + sample_column=sample_column, + sample_artifact=sample_artifact, + ) + n_mads = 3.0 if projection.profile == "captureMad3Sensitivity" else 5.0 + action: CellQcAction = ( + "skip" if projection.profile == "retainWithFlags" else "registeredMad" + ) + parameters: dict[str, Any] = { + "policyVersion": 1, + "profile": projection.profile, + "nMads": n_mads, + "boundPolicy": { + "count": {"remove": "lower", "flag": "upper"}, + "feature": {"remove": "lower", "flag": "upper"}, + "mitochondrial": {"remove": "upper", "fixedCutoff": None}, + "diagnostic": {"remove": "none"}, + }, + "resolvedBounds": [threshold.to_dict() for threshold in projection.thresholds], + "captureSizes": projection.captureSizes, + "captureComparisons": [ + comparison.to_dict() for comparison in projection.captureComparisons + ], + "captureComparisonSource": comparison_source, + "pooledReferenceCaptures": list(pooled_reference_captures or ()), + } + failure_evidence = _capture_failure_models( + projection, + deps=deps, + characterization=characterization, + capture_labels=capture_labels, + metric_sources=metric_sources, + ) + cells = deps.cells if deps.cells is not None else deps.store.cells + retention_columns: list[str] = [] + if characterization is not None: + for coefficient in characterization.coefficients: + for value in ( + coefficient.get("name"), + coefficient.get("observationUnit"), + coefficient.get("independentUnit"), + ): + if isinstance(value, str) and value in cells.columns: + retention_columns.append(value) + retained_by_column: dict[str, dict[str, int]] = {} + unsafe_groups: list[str] = [] + retained = np.asarray(projection.keep, dtype=bool) & np.asarray(active, dtype=bool) + for column in dict.fromkeys(retention_columns): + labels = np.asarray(cells.fetch(column)) + if labels.shape != retained.shape: + raise ValueError( + f"QC retention column {column!r} does not align with cellSelection" + ) + counts: dict[str, int] = {} + for raw_label in np.unique(labels[np.asarray(active, dtype=bool)]): + label = raw_label.item() if isinstance(raw_label, np.generic) else raw_label + key = label.decode("utf-8") if isinstance(label, bytes) else str(label) + count = int((retained & (labels == raw_label)).sum()) + counts[key] = count + if count == 0: + unsafe_groups.append(f"{column}={key}") + retained_by_column[column] = counts + return CellQcProfileEvidence( + profileId=profile_id, + action=action, + registeredProfile=projection.profile, + driverAssay=driver[0], + driverAssayType=driver[1], + sampleColumn=sample_column, + sampleArtifact=sample_artifact, + captureColumn=capture_column, + captureArtifact=capture_artifact, + attributes=attributes, + artifactMetrics=metric_artifacts, + metricSources=metric_sources, + sourceConcordance=source_concordance, + parameters=parameters, + resolvedBounds=parameters["resolvedBounds"], + activeCells=active_cells, + retainedCells=projection.retainedCells, + retainedFraction=( + projection.retainedCells / active_cells if active_cells else 0.0 + ), + activeCellsByCapture=projection.captureSizes, + sampleRetainedCells=projection.retainedByCapture, + retainedCellsByColumn=retained_by_column, + unsafeRetentionGroups=sorted(unsafe_groups), + flaggedCells=projection.flagCounts, + metricFlaggedCells=projection.metricFlagCounts, + failedCaptureCandidates=list(projection.failedCaptureCandidates), + captureFailureEvidence=failure_evidence, + excludableCaptureCandidates=[ + item.capture for item in failure_evidence if item.exclusionEligible + ], + notes=list(projection.warnings), + evidenceId=f"qcProfile:{profile_id}", + ) + + +def _registered_qc_profiles( + deps: ExperimentalContextDependencies, + *, + characterization: CovariateCharacterization | None, + driver: tuple[str, CellQcDriverType], + active: np.ndarray, + values_by_attr: dict[str, np.ndarray], + metadata_attributes: list[str], + artifact_metrics: list[NamedArtifactSource], + metric_sources: list[QcMetricSourceEvidence], + source_concordance: list[QcSourceConcordance], + capture: tuple[str | None, NamedArtifactSource | None, np.ndarray] | None = None, +) -> list[CellQcProfileEvidence]: + if capture is None: + capture = _directed_capture_source(deps) + sample_column: str | None = None + sample_artifact: NamedArtifactSource | None = None + capture_labels: np.ndarray | None = None + if capture is not None: + sample_column, sample_artifact, capture_labels = capture + if sample_column is not None: + comparison_source = f"metadata:{sample_column}" + elif sample_artifact is not None: + comparison_source = ( + f"artifact:{sample_artifact.name}:{sample_artifact.artifact.artifactId}" + ) + else: + comparison_source = None + pooled_references = _directed_pooled_reference_captures(deps) + if pooled_references is not None and capture is None: + raise ValueError( + "pooledReferenceCaptures requires an explicit physicalCaptureColumn" + ) + projections = offered_registered_qc_profiles( + values_by_metric=values_by_attr, + active=active, + capture_labels=capture_labels, + grouping_proven=capture is not None, + min_cells_per_capture=20, + pooled_reference_captures=pooled_references, + ) + profiles: list[CellQcProfileEvidence] = [] + for projection in projections: + uses_capture = projection.profile in { + "captureMad5", + "captureMad3Sensitivity", + "pooledReferenceMad5", + } + profiles.append( + _registered_profile_evidence( + projection, + deps=deps, + characterization=characterization, + driver=driver, + active=active, + values_by_attr=values_by_attr, + metadata_attributes=metadata_attributes, + artifact_metrics=artifact_metrics, + metric_sources=metric_sources, + source_concordance=source_concordance, + sample_column=sample_column if uses_capture else None, + sample_artifact=sample_artifact if uses_capture else None, + capture_column=sample_column, + capture_artifact=sample_artifact, + capture_labels=capture_labels, + pooled_reference_captures=( + pooled_references + if projection.profile == "pooledReferenceMad5" + else None + ), + active_cells=int(active.sum()), + comparison_source=comparison_source, + ) + ) + return profiles + + +def _global_qc_profile( + deps: ExperimentalContextDependencies, + driver: tuple[str, CellQcDriverType], + active: np.ndarray, + active_cells: int, + values_by_attr: dict[str, np.ndarray], + metadata_attributes: list[str], + artifact_metrics: list[NamedArtifactSource], + attribute_notes: list[str], + *, + characterization: CovariateCharacterization | None = None, + metric_sources: list[QcMetricSourceEvidence] | None = None, + source_concordance: list[QcSourceConcordance] | None = None, + capture: tuple[str | None, NamedArtifactSource | None, np.ndarray] | None = None, +) -> CellQcProfileEvidence | None: + """Build an execution-exact projection of core global auto-filtering.""" + if not values_by_attr: + return None + metric_sources = list(metric_sources or []) + source_concordance = list(source_concordance or []) + executable_values: dict[str, np.ndarray] = {} + for name, values in values_by_attr.items(): + selected = np.asarray(values)[active] + if selected.size and np.all(selected == selected[0]): + attribute_notes.append(f"Ignored constant QC metric {name!r}") + continue + low, high = gaussian_quantile_bounds(selected, 0.01, 0.99) + if not np.isfinite([low, high]).all(): + attribute_notes.append( + f"Ignored QC metric {name!r} with non-finite Gaussian bounds" + ) + continue + executable_values[name] = values + if not executable_values: + return None + executable_names = set(executable_values) + metadata_names = set(metadata_attributes) + metadata_attributes = [ + name for name in metadata_attributes if name in executable_names + ] + artifact_metrics = [ + source + for source in artifact_metrics + if qc_metric_execution_name( + source.name, + artifact_id=source.artifact.artifactId, + collides_with_metadata=source.name in metadata_names, + ) + in executable_names + ] + metric_sources = [ + source for source in metric_sources if source.executionName in executable_names + ] + retained_source_ids = {source.sourceId for source in metric_sources} + source_concordance = [ + comparison + for comparison in source_concordance + if comparison.leftSourceId in retained_source_ids + and comparison.rightSourceId in retained_source_ids + ] + capture_column: str | None = None + capture_artifact: NamedArtifactSource | None = None + capture_labels: np.ndarray | None = None + if capture is not None: + capture_column, capture_artifact, capture_labels = capture + try: + projection = project_auto_filter_profile( + "globalGaussian", + values_by_metric=executable_values, + active=active, + sample_labels=capture_labels, + grouping_proven=capture is not None, + ) + except ValueError as exc: + attribute_notes.append(f"Global Gaussian QC is not executable: {exc}") + return None + profile_id = _qc_profile_id( + "globalGaussian", + driver=driver, + ) + failures = _capture_failure_models( + projection, + deps=deps, + characterization=characterization, + capture_labels=capture_labels, + metric_sources=metric_sources, + ) + return CellQcProfileEvidence( + profileId=profile_id, + action="globalGaussian", + driverAssay=driver[0], + driverAssayType=driver[1], + captureColumn=capture_column, + captureArtifact=capture_artifact, + attributes=list(metadata_attributes), + artifactMetrics=list(artifact_metrics), + metricSources=metric_sources, + sourceConcordance=source_concordance, + parameters=projection.parameters, + resolvedBounds=cast(dict[str, Any], projection.parameters["resolvedBounds"]), + activeCells=active_cells, + retainedCells=projection.retainedCells, + retainedFraction=projection.retainedCells / active_cells, + activeCellsByCapture=projection.captureSizes, + sampleRetainedCells=projection.retainedByCapture, + flaggedCells=projection.flagCounts, + metricFlaggedCells=projection.metricFlagCounts, + failedCaptureCandidates=list(projection.failedCaptureCandidates), + captureFailureEvidence=failures, + excludableCaptureCandidates=[ + item.capture for item in failures if item.exclusionEligible + ], + notes=[*attribute_notes, *projection.warnings], + evidenceId=f"qcProfile:{profile_id}", + ) + + +def _sample_qc_profiles( + deps: ExperimentalContextDependencies, + characterization: CovariateCharacterization | None, + driver: tuple[str, CellQcDriverType], + active: np.ndarray, + active_cells: int, + values_by_attr: dict[str, np.ndarray], + metadata_attributes: list[str], + artifact_metrics: list[NamedArtifactSource], + metric_sources: list[QcMetricSourceEvidence], + source_concordance: list[QcSourceConcordance], + capture: tuple[str | None, NamedArtifactSource | None, np.ndarray] | None, +) -> list[CellQcProfileEvidence]: + """Build core-parity sample MAD profiles from exact grouping sources.""" + attributes = list(values_by_attr) + profiles: list[CellQcProfileEvidence] = [] + sample_sources: list[ + tuple[str | None, NamedArtifactSource | None, np.ndarray | None, bool] + ] = [] + if capture is not None: + sample_sources.append((*capture[:2], capture[2], True)) + sample_sources.extend( + (None, source, None, False) for source in deps.htoIdentityArtifacts + ) + sample_sources.extend( + (column, None, None, False) + for column in _qc_sample_columns(deps, characterization) + ) + seen_sources: set[str] = set() + for ( + sample_column, + sample_artifact, + supplied_labels, + is_physical_capture, + ) in sample_sources: + source_key = ( + f"metadata:{sample_column}" + if sample_column is not None + else ( + f"artifact:{sample_artifact.artifact.artifactId}" + if sample_artifact is not None + else "" + ) + ) + if not source_key or source_key in seen_sources: + continue + seen_sources.add(source_key) + if len(seen_sources) > _MAX_QC_SAMPLE_PROFILES: + break + if not attributes: + break + artifact_labels = ( + supplied_labels + if supplied_labels is not None + else None + if sample_artifact is None + else _resolved_artifact_values( + deps, + sample_artifact, + expected_kind="hto_identity", + ) + ) + try: + sample_labels = ( + np.asarray(supplied_labels) + if supplied_labels is not None + else np.asarray(deps.cells.fetch(sample_column)) + if sample_column is not None + else np.asarray(artifact_labels) + ) + projection = project_auto_filter_profile( + "sampleMad", + values_by_metric=values_by_attr, + sample_labels=sample_labels, + active=active, + grouping_proven=True, + n_mads=3.0, + min_cells_per_sample=20, + ) + except (TypeError, ValueError): + continue + profile_id = _qc_profile_id( + "sampleMad", + driver=driver, + sample_column=sample_column, + sample_artifact=sample_artifact, + ) + failures = ( + _capture_failure_models( + projection, + deps=deps, + characterization=characterization, + capture_labels=sample_labels, + metric_sources=metric_sources, + ) + if is_physical_capture + else [] + ) + skip_reasons = cast( + dict[str, object], + projection.parameters["skipReasons"], + ) + profiles.append( + CellQcProfileEvidence( + profileId=profile_id, + action="sampleMad", + driverAssay=driver[0], + driverAssayType=driver[1], + sampleColumn=sample_column, + sampleArtifact=sample_artifact, + captureColumn=sample_column if is_physical_capture else None, + captureArtifact=sample_artifact if is_physical_capture else None, + attributes=list(metadata_attributes), + artifactMetrics=list(artifact_metrics), + metricSources=metric_sources, + sourceConcordance=source_concordance, + parameters={ + "nMads": 3.0, + "minCellsPerSample": 20, + "nSamples": len(projection.captureSizes), + "nSkippedSamples": len(skip_reasons), + }, + resolvedBounds=cast( + dict[str, Any], + projection.parameters["resolvedBounds"], + ), + activeCells=active_cells, + retainedCells=projection.retainedCells, + retainedFraction=projection.retainedCells / active_cells, + activeCellsByCapture=projection.captureSizes, + sampleRetainedCells=projection.retainedByCapture, + flaggedCells=projection.flagCounts, + metricFlaggedCells=projection.metricFlagCounts, + failedCaptureCandidates=( + list(projection.failedCaptureCandidates) + if is_physical_capture + else [] + ), + captureFailureEvidence=failures, + excludableCaptureCandidates=[ + item.capture for item in failures if item.exclusionEligible + ], + notes=list(projection.warnings), + evidenceId=f"qcProfile:{profile_id}", + ) + ) + return profiles + + +def _offered_qc_profiles( + deps: ExperimentalContextDependencies, + characterization: CovariateCharacterization | None = None, +) -> list[CellQcProfileEvidence]: + """Project bounded QC profiles against the exact shared cell selection.""" + active_cells = _active_cell_count(deps) + active = np.ones(active_cells, dtype=bool) + driver = _qc_driver(deps.store) + driver_assay = driver[0] if driver is not None else None + driver_type = driver[1] if driver is not None else None + skip_id = _qc_profile_id( + "skip", + driver=driver, + ) + skip_notes = ( + [] + if driver is not None + else ["No RNA or ATAC assay is eligible to drive automatic cell QC"] + ) + registered_only = deps.directions.get("registeredQcOnly") is True + profiles = ( + [] + if registered_only + else [ + CellQcProfileEvidence( + profileId=skip_id, + action="skip", + driverAssay=driver_assay, + driverAssayType=driver_type, + activeCells=active_cells, + retainedCells=active_cells, + retainedFraction=1.0 if active_cells else 0.0, + notes=skip_notes, + evidenceId=f"qcProfile:{skip_id}", + ) + ] + ) + if driver is None or active_cells == 0: + if registered_only: + profiles.append( + CellQcProfileEvidence( + profileId=skip_id, + action="skip", + driverAssay=driver_assay, + driverAssayType=driver_type, + activeCells=active_cells, + retainedCells=active_cells, + retainedFraction=1.0 if active_cells else 0.0, + notes=skip_notes, + evidenceId=f"qcProfile:{skip_id}", + ) + ) + deps.qcProfiles = {profile.profileId: profile for profile in profiles} + return profiles + + ( + values_by_attr, + valid_metadata_attributes, + artifact_metrics, + metric_sources, + source_concordance, + attribute_notes, + values_by_source, + ) = _qc_metric_sources(deps, driver) + capture = _directed_capture_source(deps) + capture_column: str | None = None + capture_artifact: NamedArtifactSource | None = None + capture_labels: np.ndarray | None = None + capture_sizes: dict[str, int] = {} + if capture is not None: + capture_column, capture_artifact, capture_labels = capture + normalized = _validated_sample_labels( + capture_labels, + active, + label_name="physical capture labels", + ) + for raw in normalized: + value = raw.item() if isinstance(raw, np.generic) else raw + key = value.decode("utf-8") if isinstance(value, bytes) else str(value) + capture_sizes[key] = capture_sizes.get(key, 0) + 1 + metric_sources = _capture_source_missingness( + metric_sources, + values_by_source, + capture_labels, + ) + deps.qcMetricSources = metric_sources + deps.qcSourceConcordance = source_concordance + if not registered_only: + profiles = [ + CellQcProfileEvidence( + profileId=skip_id, + action="skip", + driverAssay=driver_assay, + driverAssayType=driver_type, + captureColumn=capture_column, + captureArtifact=capture_artifact, + metricSources=metric_sources, + sourceConcordance=source_concordance, + activeCells=active_cells, + retainedCells=active_cells, + retainedFraction=1.0, + activeCellsByCapture=capture_sizes, + sampleRetainedCells=capture_sizes, + notes=[*skip_notes, *attribute_notes], + evidenceId=f"qcProfile:{skip_id}", + ) + ] + + if not registered_only: + global_profile = _global_qc_profile( + deps, + driver, + active, + active_cells, + values_by_attr, + valid_metadata_attributes, + artifact_metrics, + attribute_notes, + characterization=characterization, + metric_sources=metric_sources, + source_concordance=source_concordance, + capture=capture, + ) + if global_profile is not None: + profiles.append(global_profile) + profiles.extend( + _sample_qc_profiles( + deps, + characterization, + driver, + active, + active_cells, + values_by_attr, + valid_metadata_attributes, + artifact_metrics, + metric_sources, + source_concordance, + capture, + ) + ) + profiles.extend( + _registered_qc_profiles( + deps, + characterization=characterization, + driver=driver, + active=active, + values_by_attr=values_by_attr, + metadata_attributes=valid_metadata_attributes, + artifact_metrics=artifact_metrics, + metric_sources=metric_sources, + source_concordance=source_concordance, + capture=capture, + ) + ) + + deps.qcProfiles = {profile.profileId: profile for profile in profiles} + return profiles diff --git a/scarf/agent/study_contract.py b/scarf/agent/experimental_context/study.py similarity index 99% rename from scarf/agent/study_contract.py rename to scarf/agent/experimental_context/study.py index c3e82af8..1512d1a4 100644 --- a/scarf/agent/study_contract.py +++ b/scarf/agent/experimental_context/study.py @@ -5,7 +5,7 @@ from pydantic import Field, model_validator -from .types import AgentDataModel +from ..types import AgentDataModel type AuthorLabelPolicy = Literal["holdout", "preservation"] type ProcessingGoal = Literal[ diff --git a/scarf/agent/experimental_context/tools.py b/scarf/agent/experimental_context/tools.py new file mode 100644 index 00000000..20776094 --- /dev/null +++ b/scarf/agent/experimental_context/tools.py @@ -0,0 +1,758 @@ +"""Read-only Pydantic AI tools for experimental context.""" + +import math +from collections.abc import Sequence +from typing import Any + +from ...metadata.queries import reduce_observation_units +from ...metrics.association import coefficient_estimability +from ...storage.refs import ArtifactRef +from ...utils.logging import logger +from .._deps import AGENT_INSTALL_HINT +from ..tools import artifact_reference, core_artifact_reference +from ..types import BatchSafetyEvidence, BatchSafetyStatus +from .characterization import characterize_covariates +from .contracts import ( + ColumnDomain, + ContrastPlan, + ContrastStatus, + ContrastTest, + CovariateCharacterization, + CovariateEvidence, + ExperimentalContextDependencies, + InferenceUnit, + RepresentationEvaluation, + characterization_evidence, +) +from .qc_evidence import ( + _artifact_evidence_id, + _hto_artifact_map, + _hto_identity_columns, + _offered_qc_profiles, +) + +try: + from pydantic_ai import ModelRetry, RunContext + from pydantic_ai.tools import ToolDefinition +except ImportError as exc: + raise ImportError(AGENT_INSTALL_HINT) from exc + + +def _prepare_experimental_context_tool( + ctx: RunContext[ExperimentalContextDependencies], + tool_definition: ToolDefinition, +) -> ToolDefinition | None: + """Expose each context tool once and in its required dependency order.""" + completed_calls = set(ctx.deps.toolCalls) + if tool_definition.name == "inspect_cell_covariates": + return None if tool_definition.name in completed_calls else tool_definition + if tool_definition.name == "analyze_experimental_design": + if ( + "inspect_cell_covariates" not in completed_calls + or tool_definition.name in completed_calls + ): + return None + return tool_definition + if tool_definition.name == "score_current_representation": + if ( + "analyze_experimental_design" not in completed_calls + or tool_definition.name in completed_calls + ): + return None + characterization = ctx.deps.characterization + if characterization is not None and not any( + record.get("domain") == "technical" and record.get("kind") == "categorical" + for record in characterization.columns + ): + return None + return tool_definition + return tool_definition + + +def contrast_plans_from_characterization( + characterization: CovariateCharacterization, +) -> list[ContrastPlan]: + """Build deterministic test licenses from bounded coefficient evidence.""" + reports = { + report.get("coefficient"): report + for report in characterization.confounding + if isinstance(report.get("coefficient"), str) + } + plans: list[ContrastPlan] = [] + for record in characterization.coefficients: + coefficient = record.get("name") + if not isinstance(coefficient, str): + continue + report = reports.get(coefficient, {}) + raw_groups = record.get("groupOrder") + group_order = ( + list(raw_groups) + if isinstance(raw_groups, list) + and all( + isinstance(value, str | int | float | bool) + and not (isinstance(value, float) and not math.isfinite(value)) + for value in raw_groups + ) + else [] + ) + sample_by = record.get("observationUnit") + sample_by = sample_by if isinstance(sample_by, str) else None + independent_unit = record.get("independentUnit") + independent_unit = ( + independent_unit if isinstance(independent_unit, str) else None + ) + between_unit = record.get("scope") == "betweenUnit" + replication = dict(record.get("replication") or {}) + replication_passed = replication.get("sufficient") is True + estimability = dict( + record.get("estimability") or report.get("estimability") or {} + ) + estimability_passed = ( + estimability.get("status") == "ok" + and estimability.get("coefficientEstimable") is True + and estimability.get("rankDeficient") is not True + ) + paired_coverage = dict(record.get("pairedCoverage") or {}) + pair_by: str | None = None + paired_passed: bool | None = None + mixed_independent_design = False + if independent_unit is not None: + if paired_coverage.get("complete") is True: + pair_by = independent_unit + paired_passed = True + elif paired_coverage.get("betweenIndependentUnits") is True: + sample_by = independent_unit + else: + pair_by = independent_unit + paired_passed = False + mixed_independent_design = True + + reasons: list[str] = [] + needs_input = False + if record.get("kind") != "categorical": + reasons.append("coefficientRequiresExplicitCategoricalGroups") + needs_input = True + if not between_unit: + reasons.append("coefficientIsNotBetweenUnit") + if sample_by is None: + reasons.append("sampleByIsUnresolved") + needs_input = True + if len(group_order) < 2: + reasons.append("fewerThanTwoObservedGroups") + needs_input = True + if record.get("groupCountsTruncated") is True: + reasons.append("groupOrderIsTruncated") + needs_input = True + if not replication_passed: + reasons.append("insufficientIndependentReplication") + if not estimability_passed: + reasons.append("coefficientIsNotEstimable") + + test: ContrastTest | None = None + if pair_by is not None: + if len(group_order) != 2: + reasons.append("pairedTestsRequireExactlyTwoGroups") + else: + test = "wilcoxon" + if paired_passed is not True: + reasons.append("pairedCoverageIsIncomplete") + elif mixed_independent_design: + reasons.append("independentUnitStructureIsMixed") + elif len(group_order) == 2: + test = "mann_whitney" + elif len(group_order) >= 3: + test = "kruskal_wallis" + + reasons = list(dict.fromkeys(reasons)) + status: ContrastStatus = ( + "licensed" if not reasons else "needsInput" if needs_input else "blocked" + ) + evidence_ids = [ + f"column:{coefficient}", + f"coefficient:{coefficient}", + f"estimability:{coefficient}", + ] + plans.append( + ContrastPlan( + coefficient=coefficient, + groupOrder=group_order, + sampleBy=sample_by, + pairBy=pair_by, + test=test, + status=status, + betweenUnitDesign=between_unit, + replicationPassed=replication_passed, + estimabilityPassed=estimability_passed, + pairedCoveragePassed=paired_passed, + replication=replication, + estimability=estimability, + pairedCoverage=paired_coverage, + blockedReasons=reasons, + evidenceId=f"contrastPlan:{coefficient}:{status}", + evidenceIds=evidence_ids, + ) + ) + return plans + + +async def inspect_cell_covariates( + ctx: RunContext[ExperimentalContextDependencies], +) -> CovariateEvidence: + """Inspect cell metadata without making model-driven choices or writing data.""" + logger.info( + "Experimental Context covariate inspection started: " + f"cellSelection={ctx.deps.cellSelection.artifact_id}" + ) + ctx.deps.htoIdentityColumns = _hto_identity_columns(ctx.deps) + characterization = characterize_covariates( + ctx.deps.store, + cellSelection=ctx.deps.cellSelection, + studyContext=( + f"{ctx.deps.studyContext}\nStudy objective: {ctx.deps.studyObjective}" + ), + model=None, + directions=ctx.deps.directions, + groupingArtifacts=_hto_artifact_map(ctx.deps), + ) + ctx.deps.characterization = characterization + qc_profiles = _offered_qc_profiles(ctx.deps) + contrast_plans = contrast_plans_from_characterization(characterization) + ctx.deps.contrastPlans = {plan.coefficient: plan for plan in contrast_plans} + evidence_ids = characterization_evidence(characterization) + evidence_ids.update(profile.evidenceId for profile in qc_profiles) + evidence_ids.update(source.sourceId for source in ctx.deps.qcMetricSources) + evidence_ids.update(item.evidenceId for item in ctx.deps.qcSourceConcordance) + evidence_ids.update(plan.evidenceId for plan in contrast_plans) + evidence_ids.update( + failure.evidenceId + for profile in qc_profiles + for failure in profile.captureFailureEvidence + ) + evidence_ids.update( + f"htoIdentity:{column}" for column in ctx.deps.htoIdentityColumns + ) + evidence_ids.update( + _artifact_evidence_id(source) for source in ctx.deps.htoIdentityArtifacts + ) + ctx.deps.evidenceIds.update(evidence_ids) + ctx.deps.toolCalls.append("inspect_cell_covariates") + logger.info( + "Experimental Context covariate inspection completed: " + f"status={characterization.status}, " + f"columns={len(characterization.columns)}, " + f"coefficients={len(characterization.coefficients)}, " + f"qcProfiles={len(qc_profiles)}, " + f"htoIdentities={len(ctx.deps.htoIdentityColumns)}, " + f"evidence={len(evidence_ids)}" + ) + return CovariateEvidence( + characterization=characterization, + qcProfiles=qc_profiles, + qcMetricSources=ctx.deps.qcMetricSources, + qcSourceConcordance=ctx.deps.qcSourceConcordance, + contrastPlans=contrast_plans, + htoIdentityColumns=ctx.deps.htoIdentityColumns, + htoIdentityArtifacts=ctx.deps.htoIdentityArtifacts, + evidenceIds=sorted(evidence_ids), + ) + + +def _batch_safety_evidence( + deps: ExperimentalContextDependencies, + characterization: CovariateCharacterization, + *, + coefficients: Sequence[str], + batch_columns: Sequence[str], +) -> list[BatchSafetyEvidence]: + column_records = { + record.get("name"): record + for record in characterization.columns + if isinstance(record.get("name"), str) + } + coefficient_records = { + record.get("name"): record + for record in characterization.coefficients + if isinstance(record.get("name"), str) + } + confounding_reports = { + report.get("coefficient"): report + for report in characterization.confounding + if isinstance(report.get("coefficient"), str) + } + canonical_batch_columns = sorted(batch_columns) + batch_safety: list[BatchSafetyEvidence] = [] + for coefficient in coefficients: + if not canonical_batch_columns: + break + coefficient_record = coefficient_records.get(coefficient) + report = confounding_reports.get(coefficient) + coefficient_kind = ( + coefficient_record.get("kind") if coefficient_record is not None else None + ) + if coefficient_kind not in {"categorical", "continuous"}: + coefficient_kind = None + observation_unit = ( + report.get("observationUnit") + if report is not None + else ( + coefficient_record.get("observationUnit") + if coefficient_record is not None + else None + ) + ) + unit_constant = { + pair.get("technical") + for pair in (report.get("pairs", []) if report is not None else []) + if isinstance(pair.get("technical"), str) + } + effective_batch_columns = [ + name for name in canonical_batch_columns if name in unit_constant + ] + estimability: dict[str, Any] + if ( + coefficient_record is None + or coefficient_record.get("scope") != "betweenUnit" + or report is None + or not isinstance(observation_unit, str) + or coefficient_kind is None + ): + estimability = { + "status": "notComputed", + "reason": "unresolvedCoefficientDesign", + } + else: + try: + design = reduce_observation_units( + deps.cells, + observation_unit, + [coefficient, *effective_batch_columns], + cell_key="I", + ) + estimability = coefficient_estimability( + design[coefficient].to_numpy(), + coefficientKind=coefficient_kind, + technicals={ + name: design[name].to_numpy() + for name in effective_batch_columns + }, + technicalKinds={ + name: column_records[name]["kind"] + for name in effective_batch_columns + }, + ) + except (KeyError, TypeError, ValueError) as exc: + logger.debug( + "Experimental Context batch estimability was not computed: " + f"errorType={type(exc).__name__}" + ) + estimability = { + "status": "notComputed", + "reason": type(exc).__name__, + } + if estimability.get("status") != "ok": + safety_status: BatchSafetyStatus = "notComputed" + elif estimability.get("coefficientEstimable") is True and not bool( + estimability.get("rankDeficient") + ): + safety_status = "safe" + else: + safety_status = "unsafe" + batch_token = ",".join(canonical_batch_columns) + safety = BatchSafetyEvidence( + coefficient=coefficient, + coefficientKind=coefficient_kind, + observationUnit=( + observation_unit if isinstance(observation_unit, str) else None + ), + batchColumns=canonical_batch_columns, + unitConstantBatchColumns=effective_batch_columns, + status=safety_status, + estimability=estimability, + evidenceId=f"batchEstimability:{coefficient}:{batch_token}", + ) + batch_safety.append(safety) + deps.batchSafety[safety.evidenceId] = safety + return batch_safety + + +async def analyze_experimental_design( + ctx: RunContext[ExperimentalContextDependencies], + column_domains: dict[str, ColumnDomain], + coefficients_of_interest: list[str], + units_of_inference: dict[str, InferenceUnit], + batch_columns: list[str], +) -> CovariateEvidence: + """Validate proposed domains and inference units and compute confounding. + + Args: + ctx: Pydantic AI run context containing the existing datastore. + column_domains: Domain assignment for each metadata column under review. + coefficients_of_interest: Biological columns representing study contrasts. + units_of_inference: Observation and independent units for each coefficient. + batch_columns: Exact technical columns proposed for Harmony evaluation. + """ + logger.info( + "Experimental Context design analysis started: " + f"domains={len(column_domains)}, " + f"coefficients={len(coefficients_of_interest)}, " + f"inferenceUnits={len(units_of_inference)}, " + f"batchColumns={len(batch_columns)}" + ) + directions = dict(ctx.deps.directions) + directed_domains = dict(column_domains) + directed_domains.update(dict(directions.get("columnDomains") or {})) + directions["columnDomains"] = directed_domains + directed_coefficients = list( + dict.fromkeys( + [ + *coefficients_of_interest, + *(directions.get("coefficientsOfInterest") or []), + ] + ) + ) + directions["coefficientsOfInterest"] = directed_coefficients + directed_units = { + name: unit.model_dump(exclude_none=True) + for name, unit in units_of_inference.items() + } + directed_units.update(dict(directions.get("unitsOfInference") or {})) + directions["unitsOfInference"] = directed_units + + proposed_batch_columns = list(batch_columns) + directed_batch_columns = directions.get("batchColumns") + if directed_batch_columns is not None: + if not isinstance(directed_batch_columns, list) or any( + not isinstance(value, str) or not value.strip() + for value in directed_batch_columns + ): + raise ModelRetry( + "directions.batchColumns must be a list of exact metadata columns" + ) + if len(set(directed_batch_columns)) != len(directed_batch_columns): + raise ModelRetry("directions.batchColumns must be unique") + if proposed_batch_columns != directed_batch_columns: + logger.info( + "Experimental Context replaced model-proposed batch columns with " + "the exact directed columns" + ) + proposed_batch_columns = list(directed_batch_columns) + canonical_batch_columns = sorted(set(proposed_batch_columns)) + if len(canonical_batch_columns) != len(proposed_batch_columns): + logger.warning( + "Experimental Context rejected duplicate proposed batch columns: " + f"{proposed_batch_columns[:20]}" + ) + raise ModelRetry("Proposed batch columns must be unique") + inspected_records = { + record.get("name"): record + for record in ( + ctx.deps.characterization.columns + if ctx.deps.characterization is not None + else [] + ) + if isinstance(record.get("name"), str) + } + if ctx.deps.characterization is not None: + for batch_column in canonical_batch_columns: + inspected = inspected_records.get(batch_column) + if inspected is None: + logger.warning( + "Experimental Context rejected unknown proposed batch column " + f"before design recomputation: {batch_column!r}" + ) + raise ModelRetry(f"Unknown batch column {batch_column!r}") + proposed_domain = directed_domains.get( + batch_column, + inspected.get("domain"), + ) + if proposed_domain != "technical": + logger.warning( + "Experimental Context rejected proposed batch column before " + f"design recomputation: {batch_column!r}, " + f"domain={proposed_domain!r}, required='technical'" + ) + raise ModelRetry( + f"Batch column {batch_column!r} must be classified as technical" + ) + if inspected.get("kind") != "categorical": + logger.warning( + "Experimental Context rejected proposed batch column before " + f"design recomputation: {batch_column!r}, " + f"kind={inspected.get('kind')!r}, required='categorical'" + ) + raise ModelRetry( + f"Batch column {batch_column!r} must be categorical for Harmony" + ) + + characterization = characterize_covariates( + ctx.deps.store, + cellSelection=ctx.deps.cellSelection, + studyContext=( + f"{ctx.deps.studyContext}\nStudy objective: {ctx.deps.studyObjective}" + ), + model=None, + directions=directions, + groupingArtifacts=_hto_artifact_map(ctx.deps), + ) + if characterization.status == "failed": + rejection = "; ".join(characterization.notes).strip() + logger.warning( + "Experimental Context design characterization rejected the proposed " + f"directions: {rejection[:1000]}; " + f"domainColumns={sorted(column_domains)[:50]}, " + f"coefficients={coefficients_of_interest[:50]}, " + f"inferenceUnits={sorted(units_of_inference)[:50]}" + ) + raise ModelRetry("; ".join(characterization.notes)) + + # Retain the validated deterministic work even when the proposed Harmony + # columns below are rejected. A bounded retry or resumed decision can reuse + # the evidence without rescanning metadata or accepting an unsafe choice. + ctx.deps.characterization = characterization + if not ctx.deps.htoIdentityColumns: + ctx.deps.htoIdentityColumns = _hto_identity_columns(ctx.deps) + qc_profiles = _offered_qc_profiles(ctx.deps, characterization) + contrast_plans = contrast_plans_from_characterization(characterization) + ctx.deps.contrastPlans = {plan.coefficient: plan for plan in contrast_plans} + evidence_ids = characterization_evidence(characterization) + evidence_ids.update(profile.evidenceId for profile in qc_profiles) + evidence_ids.update(source.sourceId for source in ctx.deps.qcMetricSources) + evidence_ids.update(item.evidenceId for item in ctx.deps.qcSourceConcordance) + evidence_ids.update(plan.evidenceId for plan in contrast_plans) + evidence_ids.update( + failure.evidenceId + for profile in qc_profiles + for failure in profile.captureFailureEvidence + ) + evidence_ids.update( + f"htoIdentity:{column}" for column in ctx.deps.htoIdentityColumns + ) + evidence_ids.update( + _artifact_evidence_id(source) for source in ctx.deps.htoIdentityArtifacts + ) + ctx.deps.evidenceIds.update(evidence_ids) + + column_records = { + record.get("name"): record + for record in characterization.columns + if isinstance(record.get("name"), str) + } + for batch_column in canonical_batch_columns: + record = column_records.get(batch_column) + if record is None: + logger.warning( + "Experimental Context rejected unknown proposed batch column: " + f"{batch_column!r}" + ) + raise ModelRetry(f"Unknown batch column {batch_column!r}") + if record.get("domain") != "technical": + logger.warning( + "Experimental Context rejected proposed batch column " + f"{batch_column!r}: domain={record.get('domain')!r}, " + "required='technical'" + ) + raise ModelRetry( + f"Batch column {batch_column!r} must be classified as technical" + ) + if record.get("kind") != "categorical": + logger.warning( + "Experimental Context rejected proposed batch column " + f"{batch_column!r}: kind={record.get('kind')!r}, " + "required='categorical'" + ) + raise ModelRetry( + f"Batch column {batch_column!r} must be categorical for Harmony" + ) + + batch_safety = _batch_safety_evidence( + ctx.deps, + characterization, + coefficients=directed_coefficients, + batch_columns=canonical_batch_columns, + ) + + evidence_ids.update(item.evidenceId for item in batch_safety) + ctx.deps.evidenceIds.update(evidence_ids) + ctx.deps.toolCalls.append("analyze_experimental_design") + safety_counts = { + status: sum(item.status == status for item in batch_safety) + for status in ("safe", "unsafe", "notComputed") + } + logger.info( + "Experimental Context design analysis completed: " + f"status={characterization.status}, " + f"batchSafetySafe={safety_counts['safe']}, " + f"batchSafetyUnsafe={safety_counts['unsafe']}, " + f"batchSafetyNotComputed={safety_counts['notComputed']}, " + f"qcProfiles={len(qc_profiles)}, evidence={len(evidence_ids)}" + ) + return CovariateEvidence( + characterization=characterization, + batchSafety=batch_safety, + qcProfiles=qc_profiles, + qcMetricSources=ctx.deps.qcMetricSources, + qcSourceConcordance=ctx.deps.qcSourceConcordance, + contrastPlans=contrast_plans, + htoIdentityColumns=ctx.deps.htoIdentityColumns, + htoIdentityArtifacts=ctx.deps.htoIdentityArtifacts, + evidenceIds=sorted(evidence_ids), + ) + + +async def score_current_representation( + ctx: RunContext[ExperimentalContextDependencies], + batch_column: str, + biological_column: str | None = None, +) -> RepresentationEvaluation: + """Score one explicitly supplied graph without changing datastore state. + + Args: + ctx: Pydantic AI run context containing the existing datastore. + batch_column: Categorical technical column used to assess batch mixing. + biological_column: Optional biological label used to assess preservation. + """ + logger.info( + "Experimental Context representation scoring started: " + f"graphSupplied={ctx.deps.neighbors is not None}, " + f"biologicalLabelSpecified={biological_column is not None}" + ) + store = ctx.deps.store + available_columns = set(store.cells.columns) + if batch_column not in available_columns: + raise ModelRetry(f"Unknown batch column {batch_column!r}") + if biological_column is not None and biological_column not in available_columns: + raise ModelRetry(f"Unknown biological column {biological_column!r}") + characterization = ctx.deps.characterization + if characterization is not None: + batch_record = next( + ( + record + for record in characterization.columns + if record.get("name") == batch_column + ), + None, + ) + if ( + batch_record is None + or batch_record.get("domain") != "technical" + or batch_record.get("kind") != "categorical" + ): + raise ModelRetry( + "Representation scoring requires a characterized categorical " + "technical batch column" + ) + + neighbors = core_artifact_reference(ctx.deps.neighbors) + connectivity = core_artifact_reference(ctx.deps.connectivityMap) + if neighbors is None: + evaluation = RepresentationEvaluation( + cellSelection=( + artifact_reference(ctx.deps.cellSelection) + if ctx.deps.cellSelection is not None + else None + ), + notes=["No exact neighbors artifact was supplied"], + ) + ctx.deps.currentRepresentation = evaluation + ctx.deps.toolCalls.append("score_current_representation") + logger.info( + "Experimental Context representation scoring skipped: " + "no current neighbors artifact" + ) + return evaluation + if not isinstance(neighbors, ArtifactRef) or neighbors.kind != "neighbors": + raise ModelRetry("neighbors must identify an exact neighbors artifact") + if connectivity is not None and ( + not isinstance(connectivity, ArtifactRef) + or connectivity.kind not in {"connectivity_map", "integrated_graph"} + ): + raise ModelRetry( + "connectivity_map must identify an exact connectivity graph artifact" + ) + + metrics: dict[str, float] = {} + notes: list[str] = [] + evidence_ids: list[str] = [] + neighbor_route = f"assay:{neighbors.assay}:neighbors:{neighbors.artifact_id}" + try: + value = float(store.metric_ilisi(batch_column, neighbors)) + if math.isfinite(value): + metrics[f"iLISI:{batch_column}"] = value + evidence_ids.append(f"metric:iLISI:{batch_column}:{neighbor_route}") + except (KeyError, TypeError, ValueError, RuntimeError) as exc: + logger.debug( + "Experimental Context iLISI scoring was unavailable: " + f"errorType={type(exc).__name__}" + ) + notes.append(f"iLISI could not be scored: {exc}") + try: + value = float(store.metric_proportional_batch_mixing(batch_column, neighbors)) + if math.isfinite(value): + metrics[f"proportionalBatchMixing:{batch_column}"] = value + evidence_ids.append( + f"metric:proportionalBatchMixing:{batch_column}:{neighbor_route}" + ) + except (KeyError, TypeError, ValueError, RuntimeError) as exc: + logger.debug( + "Experimental Context batch-mixing scoring was unavailable: " + f"errorType={type(exc).__name__}" + ) + notes.append(f"Proportional batch mixing could not be scored: {exc}") + if biological_column is not None: + try: + value = float(store.metric_clisi(biological_column, neighbors)) + if math.isfinite(value): + metrics[f"cLISI:{biological_column}"] = value + evidence_ids.append( + f"metric:cLISI:{biological_column}:{neighbor_route}" + ) + except (KeyError, TypeError, ValueError, RuntimeError) as exc: + logger.debug( + "Experimental Context cLISI scoring was unavailable: " + f"errorType={type(exc).__name__}" + ) + notes.append(f"cLISI could not be scored: {exc}") + if connectivity is not None: + try: + value = float( + store.metric_graph_connectivity(biological_column, connectivity) + ) + if math.isfinite(value): + metrics[f"graphConnectivity:{biological_column}"] = value + evidence_ids.append( + "metric:graphConnectivity:" + f"{biological_column}:assay:{connectivity.assay}:connectivity:" + f"{connectivity.artifact_id}" + ) + except (KeyError, TypeError, ValueError, RuntimeError) as exc: + logger.debug( + "Experimental Context connectivity scoring was unavailable: " + f"errorType={type(exc).__name__}" + ) + notes.append(f"Graph connectivity could not be scored: {exc}") + + evaluation = RepresentationEvaluation( + available=bool(metrics), + assay=neighbors.assay, + cellSelection=( + artifact_reference(ctx.deps.cellSelection) + if ctx.deps.cellSelection is not None + else None + ), + neighbors=artifact_reference(neighbors), + connectivityMap=( + artifact_reference(connectivity) if connectivity is not None else None + ), + metrics=metrics, + notes=notes, + evidenceIds=evidence_ids, + ) + ctx.deps.currentRepresentation = evaluation + ctx.deps.evidenceIds.update(evidence_ids) + ctx.deps.toolCalls.append("score_current_representation") + logger.info( + "Experimental Context representation scoring completed: " + f"available={evaluation.available}, metrics={len(metrics)}, " + f"notes={len(notes)}, evidence={len(evidence_ids)}" + ) + return evaluation diff --git a/scarf/agent/experimental_context/validation.py b/scarf/agent/experimental_context/validation.py new file mode 100644 index 00000000..8a0a4567 --- /dev/null +++ b/scarf/agent/experimental_context/validation.py @@ -0,0 +1,829 @@ +"""Experimental-context canonicalization and fallbacks.""" + +from collections.abc import Mapping +from typing import Any, cast + +from ...utils.logging import logger +from .._deps import AGENT_INSTALL_HINT +from ..tools import artifact_reference +from ..types import AgentRunInfo, BatchCorrectionAction, BatchSafetyEvidence +from .characterization import characterize_covariates +from .contracts import ( + BatchCorrectionPlan, + CellQcPlan, + ColumnDomain, + CovariateCharacterization, + ExperimentalContextDecision, + ExperimentalContextDependencies, + ExperimentalContextResult, + InferenceUnit, + IntegrationMetric, + characterization_evidence, +) +from .qc_evidence import ( + _artifact_evidence_id, + _hto_artifact_map, + _hto_identity_columns, + _offered_qc_profiles, +) +from .tools import _batch_safety_evidence, contrast_plans_from_characterization + +try: + from pydantic_ai import ModelRetry, UnexpectedModelBehavior +except ImportError as exc: + raise ImportError(AGENT_INSTALL_HINT) from exc + + +def _canonical_cell_qc_plan( + plan: CellQcPlan, + deps: ExperimentalContextDependencies, + characterization: CovariateCharacterization, +) -> CellQcPlan: + """Resolve one exact offered profile and reject model-authored parameters.""" + if not deps.qcProfiles: + _offered_qc_profiles(deps, characterization) + directed = deps.directions.get("cellQc") + direction_map = dict(directed) if isinstance(directed, Mapping) else {} + directed_profile_id = direction_map.get("profileId") + if directed_profile_id is not None and not isinstance(directed_profile_id, str): + raise ModelRetry("cellQc.profileId direction must be a string") + + has_directed_selector = any( + key in direction_map + for key in ( + "profileId", + "registeredProfile", + "action", + "sampleColumn", + "sampleArtifactName", + ) + ) + selected_id = directed_profile_id or ( + "" if has_directed_selector else plan.profileId + ) + if not selected_id: + requested_action = direction_map.get("action") + requested_registered_profile = direction_map.get("registeredProfile") + requested_sample = direction_map.get("sampleColumn") + requested_sample_artifact = direction_map.get("sampleArtifactName") + if requested_sample is not None and requested_sample_artifact is not None: + raise ModelRetry( + "cellQc directions cannot select both sampleColumn and " + "sampleArtifactName" + ) + if requested_sample_artifact is not None and not isinstance( + requested_sample_artifact, str + ): + raise ModelRetry("cellQc.sampleArtifactName must be a string") + if requested_action is not None and requested_action not in { + "skip", + "globalGaussian", + "sampleMad", + "registeredMad", + }: + raise ModelRetry(f"Unsupported cellQc.action {requested_action!r}") + if requested_registered_profile is not None and not isinstance( + requested_registered_profile, str + ): + raise ModelRetry("cellQc.registeredProfile must be a string") + if ( + requested_registered_profile is not None + and requested_registered_profile + not in { + "retainWithFlags", + "globalMad5", + "captureMad5", + "captureMad3Sensitivity", + "pooledReferenceMad5", + } + ): + raise ModelRetry( + f"Unsupported cellQc.registeredProfile {requested_registered_profile!r}" + ) + matches = [ + profile + for profile in deps.qcProfiles.values() + if (requested_action is None or profile.action == requested_action) + and ( + requested_registered_profile is None + or profile.registeredProfile == requested_registered_profile + ) + and (requested_sample is None or profile.sampleColumn == requested_sample) + and ( + requested_sample_artifact is None + or ( + profile.sampleArtifact is not None + and profile.sampleArtifact.name == requested_sample_artifact + ) + ) + ] + if requested_action is not None or requested_registered_profile is not None: + if len(matches) != 1: + raise ModelRetry( + "cellQc directions must identify exactly one offered profile" + ) + selected_id = matches[0].profileId + else: + global_profiles = [ + profile + for profile in deps.qcProfiles.values() + if profile.action == "globalGaussian" + ] + if global_profiles: + selected_id = global_profiles[0].profileId + else: + selected_id = next( + profile.profileId + for profile in deps.qcProfiles.values() + if profile.action == "skip" + ) + + profile = deps.qcProfiles.get(selected_id) + if profile is None: + raise ModelRetry( + f"Cell-QC profile {selected_id!r} was not offered by the evidence tool" + ) + model_selected = bool(plan.profileId) and not has_directed_selector + if model_selected: + expected_fields = { + "action": profile.action, + "registeredProfile": profile.registeredProfile, + "driverAssay": profile.driverAssay, + "driverAssayType": profile.driverAssayType, + "sampleColumn": profile.sampleColumn, + "sampleArtifact": profile.sampleArtifact, + "attributes": profile.attributes, + "artifactMetrics": profile.artifactMetrics, + } + mismatches = [ + name + for name, expected in expected_fields.items() + if getattr(plan, name) != expected + ] + if mismatches: + raise ModelRetry( + "Cell-QC plan must copy the selected offered profile exactly: " + f"{mismatches}" + ) + if profile.evidenceId not in plan.evidenceIds: + raise ModelRetry( + "Cell-QC plan must cite its exact profile retention evidence" + ) + rationale = plan.rationale.strip() + if not rationale: + rationale = ( + "Selected the caller-directed bounded cell-QC profile." + if direction_map + else "Selected the bounded default cell-QC profile." + ) + cited_evidence = plan.evidenceIds if model_selected else [] + return CellQcPlan( + action=profile.action, + registeredProfile=profile.registeredProfile, + profileId=profile.profileId, + driverAssay=profile.driverAssay, + driverAssayType=profile.driverAssayType, + sampleColumn=profile.sampleColumn, + sampleArtifact=profile.sampleArtifact, + attributes=profile.attributes, + artifactMetrics=profile.artifactMetrics, + rationale=rationale, + evidenceIds=sorted({*cited_evidence, profile.evidenceId}), + ) + + +def _validate_batch_correction_plan( + decision: ExperimentalContextDecision, + deps: ExperimentalContextDependencies, + characterization: CovariateCharacterization, + requested_coefficients: set[str], + units_of_inference: dict[str, dict[str, Any]], + records: dict[str, dict[str, Any]], + coefficient_records: dict[str, dict[str, Any]], +) -> None: + """Validate one batch plan against exact design, safety, and metric evidence.""" + confounding_reports = { + report.get("coefficient"): report + for report in characterization.confounding + if isinstance(report.get("coefficient"), str) + } + plan = decision.batchCorrection + directed_batch_columns = deps.directions.get("batchColumns") + if directed_batch_columns is not None: + if not isinstance(directed_batch_columns, list) or any( + not isinstance(value, str) or not value.strip() + for value in directed_batch_columns + ): + raise ModelRetry( + "directions.batchColumns must be a list of exact metadata columns" + ) + canonical_directed_batch = sorted(directed_batch_columns) + directed_plan_mismatch = ( + ( + plan.action not in {"evaluateHarmony", "unsafe"} + or sorted(plan.batchColumns) != canonical_directed_batch + ) + if canonical_directed_batch + else plan.action != "skip" or bool(plan.batchColumns) + ) + if directed_plan_mismatch: + raise ModelRetry( + "The batch-correction plan must assess the exact directed batch " + f"columns: {canonical_directed_batch}" + ) + unknown_columns = sorted(set(decision.columnDomains) - set(records)) + if unknown_columns: + raise ModelRetry(f"Unknown column domain assignments: {unknown_columns}") + unit_columns = { + unit_name + for unit in units_of_inference.values() + for unit_name in ( + unit.get("observationUnit"), + unit.get("independentUnit"), + ) + if isinstance(unit_name, str) + } + if plan.action == "evaluateHarmony" and not plan.batchColumns: + raise ModelRetry("evaluateHarmony requires at least one batch column") + if plan.action == "unsafe" and not plan.batchColumns: + raise ModelRetry("unsafe requires the exact batch columns that were assessed") + if plan.action == "skip" and plan.batchColumns: + raise ModelRetry("skip must not include batch columns") + if plan.action == "needsInput" and not decision.needsInput: + raise ModelRetry("needsInput action requires at least one concrete question") + if len(set(plan.batchColumns)) != len(plan.batchColumns): + raise ModelRetry("Batch columns must be unique") + + for batch_column in plan.batchColumns: + record = records.get(batch_column) + if record is None: + raise ModelRetry(f"Unknown batch column {batch_column!r}") + if record.get("domain") != "technical": + raise ModelRetry( + f"Batch column {batch_column!r} must be classified as technical" + ) + if record.get("kind") != "categorical": + raise ModelRetry( + f"Batch column {batch_column!r} must be categorical for Harmony" + ) + if batch_column in requested_coefficients or batch_column in unit_columns: + raise ModelRetry( + f"Batch column {batch_column!r} cannot be a coefficient or unit of inference" + ) + + if plan.action == "evaluateHarmony": + mixing_metrics = {"iLISI", "proportionalBatchMixing"} + preservation_metrics = {"cLISI", "graphConnectivity"} + if not mixing_metrics.intersection(plan.metricsRequired): + raise ModelRetry( + "evaluateHarmony requires iLISI or proportionalBatchMixing" + ) + if plan.preserveColumns and not preservation_metrics.intersection( + plan.metricsRequired + ): + raise ModelRetry( + "evaluateHarmony requires cLISI or graphConnectivity for preservation" + ) + missing_preserve = sorted(requested_coefficients - set(plan.preserveColumns)) + if missing_preserve: + raise ModelRetry( + "preserveColumns must include every coefficient of interest: " + f"{missing_preserve}" + ) + unresolved_coefficients = sorted( + coefficient + for coefficient in requested_coefficients + if coefficient_records[coefficient].get("scope") != "betweenUnit" + or coefficient not in confounding_reports + ) + if unresolved_coefficients: + raise ModelRetry( + "evaluateHarmony requires a between-unit coefficient with a " + "matching estimability report; use needsInput or unsafe for: " + f"{unresolved_coefficients}" + ) + for preserve_column in plan.preserveColumns: + record = records.get(preserve_column) + if record is None: + raise ModelRetry(f"Unknown preservation column {preserve_column!r}") + if record.get("domain") != "biological": + raise ModelRetry( + f"Preservation column {preserve_column!r} must be biological" + ) + if record.get("kind") != "categorical": + raise ModelRetry( + f"Preservation column {preserve_column!r} must be categorical" + ) + + matched_safety: list[BatchSafetyEvidence] = [] + if plan.action in {"evaluateHarmony", "unsafe"}: + canonical_batch_columns = sorted(plan.batchColumns) + for coefficient in sorted(requested_coefficients): + coefficient_record = coefficient_records[coefficient] + report = confounding_reports.get(coefficient) + observation_unit = ( + report.get("observationUnit") + if report is not None + else coefficient_record.get("observationUnit") + ) + unit_constant = { + pair.get("technical") + for pair in (report.get("pairs", []) if report is not None else []) + if isinstance(pair.get("technical"), str) + } + expected_effective = [ + name for name in canonical_batch_columns if name in unit_constant + ] + candidates = [ + item + for item in deps.batchSafety.values() + if item.coefficient == coefficient + and item.coefficientKind == coefficient_record.get("kind") + and item.observationUnit == observation_unit + and item.batchColumns == canonical_batch_columns + and item.unitConstantBatchColumns == expected_effective + ] + if len(candidates) != 1: + raise ModelRetry( + "Call analyze_experimental_design with the exact proposed batch " + f"columns before returning a recommendation for {coefficient!r}" + ) + matched_safety.append(candidates[0]) + missing_safety_evidence = sorted( + item.evidenceId + for item in matched_safety + if item.evidenceId not in plan.evidenceIds + ) + if missing_safety_evidence: + raise ModelRetry( + "Batch-correction recommendations must cite exact batch " + f"estimability evidence: {missing_safety_evidence}" + ) + not_computed = [ + item.coefficient for item in matched_safety if item.status == "notComputed" + ] + if not_computed: + raise ModelRetry( + "Batch estimability could not be computed; use action='needsInput' " + f"for: {sorted(not_computed)}" + ) + unsafe_coefficients = [ + item.coefficient for item in matched_safety if item.status == "unsafe" + ] + if plan.action == "evaluateHarmony" and unsafe_coefficients: + raise ModelRetry( + "Batch correction is unsafe because the biological coefficient is " + "not estimable after the exact proposed batch columns; use " + f"action='unsafe' for: {sorted(unsafe_coefficients)}" + ) + if plan.action == "unsafe" and not unsafe_coefficients: + raise ModelRetry( + "The exact proposed batch columns were estimable for every " + "coefficient; use action='evaluateHarmony' or 'skip'" + ) + + cited_ids = [ + *decision.evidenceIds, + *plan.evidenceIds, + ] + unknown_evidence = sorted(set(cited_ids) - deps.evidenceIds) + if unknown_evidence: + raise ModelRetry(f"Unknown evidence IDs: {unknown_evidence}") + if plan.action in {"evaluateHarmony", "skip", "unsafe"} and not plan.evidenceIds: + raise ModelRetry("Batch-correction recommendations require evidence IDs") + current_metric_evidence = set(deps.currentRepresentation.evidenceIds) + stale_metric_evidence = sorted( + evidence_id + for evidence_id in cited_ids + if evidence_id.startswith("metric:") + and evidence_id not in current_metric_evidence + ) + if stale_metric_evidence: + raise ModelRetry( + "Metric evidence must come from the returned exact representation: " + f"{stale_metric_evidence}" + ) + + +def validate_experimental_context( + decision: ExperimentalContextDecision, + deps: ExperimentalContextDependencies, +) -> ExperimentalContextDecision: + """Recompute and validate every model-authored design choice.""" + narrative_fields = { + "rationale": decision.rationale, + "batchCorrection.rationale": decision.batchCorrection.rationale, + **{ + f"needsInput[{index}]": question + for index, question in enumerate(decision.needsInput) + }, + } + serialized_field_markers = ( + '"evidenceIds":', + '"needsInput":', + '"runInfo":', + '"batchCorrection":', + '"cellQc":', + ) + invalid_narratives = [ + name + for name, value in narrative_fields.items() + if any( + marker in value.replace('\\"', '"') for marker in serialized_field_markers + ) + ] + if invalid_narratives: + raise ModelRetry( + "Narrative fields must contain plain prose without serialized sibling " + f"fields: {invalid_narratives}" + ) + directions = dict(deps.directions) + column_domains = dict(decision.columnDomains) + column_domains.update(dict(directions.get("columnDomains") or {})) + directions["columnDomains"] = column_domains + directions["coefficientsOfInterest"] = list( + dict.fromkeys( + [ + *decision.coefficientsOfInterest, + *(directions.get("coefficientsOfInterest") or []), + ] + ) + ) + units_of_inference = { + name: unit.model_dump(exclude_none=True) + for name, unit in decision.unitsOfInference.items() + } + units_of_inference.update(dict(directions.get("unitsOfInference") or {})) + directions["unitsOfInference"] = units_of_inference + + characterization = characterize_covariates( + deps.store, + cellSelection=deps.cellSelection, + studyContext=f"{deps.studyContext}\nStudy objective: {deps.studyObjective}", + model=None, + directions=directions, + groupingArtifacts=_hto_artifact_map(deps), + ) + if characterization.status == "failed": + raise ModelRetry("; ".join(characterization.notes)) + deps.characterization = characterization + deps.evidenceIds.update(characterization_evidence(characterization)) + contrast_plans = contrast_plans_from_characterization(characterization) + deps.contrastPlans = {plan.coefficient: plan for plan in contrast_plans} + deps.evidenceIds.update(plan.evidenceId for plan in contrast_plans) + + if "inspect_cell_covariates" not in deps.toolCalls: + raise ModelRetry("Call inspect_cell_covariates before returning a decision") + if "analyze_experimental_design" not in deps.toolCalls: + raise ModelRetry("Call analyze_experimental_design before returning a decision") + + if decision.cellQc != CellQcPlan.get_blank(): + raise ModelRetry( + "Experimental Context must leave cellQc blank; the audited filtering " + "checkpoint selects from qcProfiles" + ) + if not deps.qcProfiles: + _offered_qc_profiles(deps, characterization) + deps.evidenceIds.update(profile.evidenceId for profile in deps.qcProfiles.values()) + deps.evidenceIds.update(source.sourceId for source in deps.qcMetricSources) + deps.evidenceIds.update(item.evidenceId for item in deps.qcSourceConcordance) + + requested_coefficients = set(directions["coefficientsOfInterest"]) + characterized_coefficients = { + record.get("name") for record in characterization.coefficients + } + missing_coefficients = sorted( + name + for name in requested_coefficients + if name not in characterized_coefficients + ) + if missing_coefficients: + raise ModelRetry( + "Coefficients of interest must be classified as biological: " + f"{missing_coefficients}" + ) + + coefficient_records: dict[str, dict[str, Any]] = {} + for record in characterization.coefficients: + name = record.get("name") + if isinstance(name, str): + coefficient_records[name] = record + records: dict[str, dict[str, Any]] = {} + for record in characterization.columns: + name = record.get("name") + if isinstance(name, str): + records[name] = record + _validate_batch_correction_plan( + decision, + deps, + characterization, + requested_coefficients, + units_of_inference, + records, + coefficient_records, + ) + canonical_domains = { + name: records[name]["domain"] + for name in column_domains + if name in records + and records[name].get("domain") + in { + "biological", + "technical", + "design", + "ignore", + "unknown", + } + } + canonical_units = { + coefficient: InferenceUnit( + observationUnit=coefficient_records[coefficient].get("observationUnit"), + independentUnit=coefficient_records[coefficient].get("independentUnit"), + ) + for coefficient in directions["coefficientsOfInterest"] + if coefficient in coefficient_records + } + validated = decision.model_copy( + update={ + "columnDomains": canonical_domains, + "coefficientsOfInterest": list(directions["coefficientsOfInterest"]), + "unitsOfInference": canonical_units, + "cellQc": CellQcPlan.get_blank(), + } + ) + logger.debug( + "Experimental Context decision validated: " + f"domains={len(validated.columnDomains)}, " + f"coefficients={len(validated.coefficientsOfInterest)}, " + f"qcProfiles={len(deps.qcProfiles)}, " + f"batchCorrection={validated.batchCorrection.action}, " + f"needsInput={len(validated.needsInput)}" + ) + return validated + + +def _deterministic_experimental_context_decision( + deps: ExperimentalContextDependencies, +) -> ExperimentalContextDecision: + characterization = deps.characterization + if characterization is None or characterization.status == "failed": + raise ValueError("Deterministic covariate characterization is unavailable") + records: dict[str, dict[str, Any]] = {} + for record in characterization.columns: + name = record.get("name") + if isinstance(name, str): + records[name] = record + coefficient_records: dict[str, dict[str, Any]] = {} + for record in characterization.coefficients: + name = record.get("name") + if isinstance(name, str): + coefficient_records[name] = record + directions = dict(deps.directions) + raw_batch_columns = directions.get("batchColumns") + if raw_batch_columns is not None: + if not isinstance(raw_batch_columns, list) or any( + not isinstance(value, str) or not value.strip() + for value in raw_batch_columns + ): + raise ValueError( + "directions.batchColumns must be a list of exact metadata columns" + ) + if len(raw_batch_columns) != len(set(raw_batch_columns)): + raise ValueError("directions.batchColumns must be unique") + batch_columns = list(raw_batch_columns) + else: + candidates = sorted( + name + for name, record in records.items() + if record.get("domain") == "technical" + and record.get("kind") == "categorical" + ) + if "batch" in candidates: + batch_columns = ["batch"] + elif len(candidates) <= 1: + batch_columns = candidates + else: + raise ValueError( + "Multiple categorical technical columns remain without one exact " + "batch condition" + ) + + coefficients = [ + str(record["name"]) + for record in characterization.coefficients + if isinstance(record.get("name"), str) + ] + units = { + coefficient: InferenceUnit( + observationUnit=coefficient_records[coefficient].get("observationUnit"), + independentUnit=coefficient_records[coefficient].get("independentUnit"), + ) + for coefficient in coefficients + if coefficient in coefficient_records + } + batch_safety = _batch_safety_evidence( + deps, + characterization, + coefficients=coefficients, + batch_columns=batch_columns, + ) + unresolved_safety = [ + item.coefficient for item in batch_safety if item.status == "notComputed" + ] + if unresolved_safety: + raise ValueError( + "Batch estimability is unavailable for coefficients: " + f"{sorted(unresolved_safety)}" + ) + if batch_columns and any(item.status == "unsafe" for item in batch_safety): + action: BatchCorrectionAction = "unsafe" + elif batch_columns: + action = "evaluateHarmony" + else: + action = "skip" + categorical_coefficients = [ + coefficient + for coefficient in coefficients + if records[coefficient].get("kind") == "categorical" + ] + if action == "evaluateHarmony" and set(categorical_coefficients) != set( + coefficients + ): + raise ValueError( + "Harmony preservation requires categorical coefficients of interest" + ) + + known_evidence = sorted(characterization_evidence(characterization)) + batch_evidence = [ + *(f"column:{column}" for column in batch_columns), + *(item.evidenceId for item in batch_safety), + ] + if not batch_evidence: + batch_evidence = known_evidence[:1] + if not batch_evidence: + raise ValueError("No deterministic evidence supports a batch decision") + deps.evidenceIds.update(known_evidence) + deps.evidenceIds.update(batch_evidence) + if "analyze_experimental_design" not in deps.toolCalls: + deps.toolCalls.append("analyze_experimental_design") + column_domains = { + name: cast(ColumnDomain, record["domain"]) + for name, record in records.items() + if record.get("domain") + in {"biological", "technical", "design", "ignore", "unknown"} + } + metrics_required: list[IntegrationMetric] = [] + if action == "evaluateHarmony": + metrics_required = ["iLISI", "proportionalBatchMixing"] + if categorical_coefficients: + metrics_required.extend(["cLISI", "graphConnectivity"]) + plan = BatchCorrectionPlan( + action=action, + batchColumns=batch_columns if action != "skip" else [], + preserveColumns=( + categorical_coefficients if action == "evaluateHarmony" else [] + ), + metricsRequired=metrics_required, + rationale=( + "Evaluate the exact declared categorical technical batch condition " + "against the uncorrected representation." + if action == "evaluateHarmony" + else "The exact batch condition is confounded with the study design." + if action == "unsafe" + else "No exact categorical technical batch condition was available." + ), + evidenceIds=sorted(set(batch_evidence)), + ) + decision = ExperimentalContextDecision( + columnDomains=column_domains, + coefficientsOfInterest=coefficients, + unitsOfInference=units, + batchCorrection=plan, + rationale=( + "Deterministic covariate characterization resolved the study design " + "after the model tool call failed." + ), + evidenceIds=known_evidence, + ) + return validate_experimental_context(decision, deps) + + +def failed_experimental_context_result( + deps: ExperimentalContextDependencies, + *, + error: Exception, + fallback_error: Exception, + model_name: str, +) -> ExperimentalContextResult: + """Fail unattended execution when deterministic design evidence is insufficient.""" + characterization = deps.characterization or CovariateCharacterization( + status="failed", + notes=["Deterministic covariate characterization is unavailable."], + ) + model_detail = str(error).replace("\n", " ").strip()[:500] + fallback_detail = str(fallback_error).replace("\n", " ").strip()[:500] + return ExperimentalContextResult( + status="failed", + decision=ExperimentalContextDecision( + rationale="No validated experimental-context decision was available.", + evidenceIds=sorted(deps.evidenceIds), + ), + characterization=characterization, + cellSelection=artifact_reference(deps.cellSelection), + cellQc=CellQcPlan.get_blank(), + qcProfiles=list(deps.qcProfiles.values()), + qcMetricSources=deps.qcMetricSources, + qcSourceConcordance=deps.qcSourceConcordance, + contrastPlans=list(deps.contrastPlans.values()), + qualityMetricArtifacts=deps.qualityMetricArtifacts, + htoIdentityColumns=deps.htoIdentityColumns, + htoIdentityArtifacts=deps.htoIdentityArtifacts, + batchSafety=list(deps.batchSafety.values()), + currentRepresentation=deps.currentRepresentation, + notes=[ + "The model did not produce a validated experimental-context decision.", + f"Model failure: {model_detail}", + f"Deterministic recovery failure: {fallback_detail}", + ], + runInfo=AgentRunInfo( + agentName="experimental_context_failed", + modelName=model_name, + ), + ) + + +def pending_experimental_context_result( + deps: ExperimentalContextDependencies, + *, + error: UnexpectedModelBehavior, + model_name: str, +) -> ExperimentalContextResult: + """Pause when the model exhausts its bounded decision budget.""" + characterization = deps.characterization + if characterization is None: + characterization = characterize_covariates( + deps.store, + cellSelection=deps.cellSelection, + studyContext=( + f"{deps.studyContext}\nStudy objective: {deps.studyObjective}" + ), + model=None, + directions=deps.directions, + groupingArtifacts=_hto_artifact_map(deps), + ) + deps.characterization = characterization + if not deps.htoIdentityColumns: + deps.htoIdentityColumns = _hto_identity_columns(deps) + qc_profiles = list(deps.qcProfiles.values()) + if not qc_profiles: + qc_profiles = _offered_qc_profiles(deps, characterization) + contrast_plans = contrast_plans_from_characterization(characterization) + deps.contrastPlans = {plan.coefficient: plan for plan in contrast_plans} + evidence_ids = characterization_evidence(characterization) + evidence_ids.update(profile.evidenceId for profile in qc_profiles) + evidence_ids.update(source.sourceId for source in deps.qcMetricSources) + evidence_ids.update(item.evidenceId for item in deps.qcSourceConcordance) + evidence_ids.update(plan.evidenceId for plan in contrast_plans) + evidence_ids.update(f"htoIdentity:{column}" for column in deps.htoIdentityColumns) + evidence_ids.update( + _artifact_evidence_id(source) for source in deps.htoIdentityArtifacts + ) + deps.evidenceIds.update(evidence_ids) + question = ( + "The Experimental Context agent could not produce a validated scientific " + "decision. Provide explicit metadata roles, units of inference, cell-QC " + "profile, and batch-correction intent before continuing." + ) + decision = ExperimentalContextDecision( + batchCorrection=BatchCorrectionPlan(action="needsInput"), + cellQc=CellQcPlan.get_blank(), + rationale="No scientific decision was selected.", + evidenceIds=sorted(evidence_ids), + needsInput=[question], + ) + error_detail = str(error).replace("\n", " ").strip()[:500] + logger.warning( + "Experimental Context paused without a scientific decision: " + f"reason={error_detail}" + ) + return ExperimentalContextResult( + status=("failed" if characterization.status == "failed" else "needsInput"), + decision=decision, + characterization=characterization, + cellSelection=artifact_reference(deps.cellSelection), + cellQc=CellQcPlan.get_blank(), + qcProfiles=qc_profiles, + qcMetricSources=deps.qcMetricSources, + qcSourceConcordance=deps.qcSourceConcordance, + contrastPlans=contrast_plans, + qualityMetricArtifacts=deps.qualityMetricArtifacts, + htoIdentityColumns=deps.htoIdentityColumns, + htoIdentityArtifacts=deps.htoIdentityArtifacts, + batchSafety=list(deps.batchSafety.values()), + currentRepresentation=deps.currentRepresentation, + notes=[*characterization.notes, question, error_detail], + runInfo=AgentRunInfo( + agentName="experimental_context_needs_input", + modelName=model_name, + ), + ) diff --git a/scarf/agent/hypotheses/__init__.py b/scarf/agent/hypotheses/__init__.py new file mode 100644 index 00000000..bbb42bb5 --- /dev/null +++ b/scarf/agent/hypotheses/__init__.py @@ -0,0 +1,21 @@ +"""Evidence-gated hypothesis contracts and execution.""" + +from .contracts import ( + ClusterSelectionContract, + FeaturePanelPurpose, + HypothesisContract, + HypothesisExecutionStatus, + HypothesisFeaturePanel, + HypothesisTestExecution, +) +from .execution import execute_hypothesis_contract + +__all__ = [ + "ClusterSelectionContract", + "FeaturePanelPurpose", + "HypothesisContract", + "HypothesisExecutionStatus", + "HypothesisFeaturePanel", + "HypothesisTestExecution", + "execute_hypothesis_contract", +] diff --git a/scarf/agent/hypotheses/contracts.py b/scarf/agent/hypotheses/contracts.py new file mode 100644 index 00000000..41e15ded --- /dev/null +++ b/scarf/agent/hypotheses/contracts.py @@ -0,0 +1,141 @@ +"""Serializable contracts for evidence-gated hypothesis tests.""" + +from typing import Literal + +from .._deps import AGENT_INSTALL_HINT +from ..experimental_context.contracts import ContrastPlan +from ..types import AgentDataModel, ArtifactReferenceModel + +try: + from pydantic import Field, model_validator +except ImportError as exc: + raise ImportError(AGENT_INSTALL_HINT) from exc + + +type FeaturePanelPurpose = Literal["explicit", "exploratoryMarkers"] +type HypothesisExecutionStatus = Literal["executed", "blocked", "needsInput"] + +_NORMALIZED_EXPRESSION_SCOPE = ( + "Sample-level normalized-expression distribution testing. This is not a " + "raw-count pseudobulk differential-expression model." +) + + +class HypothesisFeaturePanel(AgentDataModel): + """Features kept under one explicit or exploratory provenance label.""" + + panelId: str = "" + purpose: FeaturePanelPurpose = "explicit" + features: list[str] = Field(default_factory=list) + sourceArtifact: ArtifactReferenceModel | None = None + evidenceIds: list[str] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_panel(self) -> "HypothesisFeaturePanel": + if self.panelId != self.panelId.strip(): + raise ValueError("Feature panel ids cannot contain surrounding whitespace") + if any( + not feature.strip() or feature != feature.strip() + for feature in self.features + ): + raise ValueError("Feature names must be non-empty trimmed strings") + if len(self.features) != len(set(self.features)): + raise ValueError("Feature names must be unique within a panel") + if self.sourceArtifact is not None and not self.sourceArtifact.artifactId: + raise ValueError("Feature panel source artifacts must be exact") + if self.purpose == "exploratoryMarkers" and self.sourceArtifact is None: + raise ValueError( + "Exploratory marker panels require their exact source artifact" + ) + return self + + +class ClusterSelectionContract(AgentDataModel): + """An exact cluster artifact and labels used for a within-cluster test.""" + + clusterArtifact: ArtifactReferenceModel + include: list[str | int | float | bool] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_selection(self) -> "ClusterSelectionContract": + if not self.clusterArtifact.artifactId: + raise ValueError("Cluster selection requires an exact artifact") + if not self.include: + raise ValueError("Cluster selection requires at least one label") + keys = [(type(value).__name__, repr(value)) for value in self.include] + if len(keys) != len(set(keys)): + raise ValueError("Cluster labels must be unique") + return self + + +class HypothesisContract(AgentDataModel): + """One immutable-input hypothesis family licensed by a contrast plan.""" + + contractId: str = "" + familyId: str = "" + contrast: ContrastPlan = Field(default_factory=ContrastPlan.get_blank) + cellSelection: ArtifactReferenceModel | None = None + groupingArtifact: ArtifactReferenceModel | None = None + clusterSelection: ClusterSelectionContract | None = None + featurePanels: list[HypothesisFeaturePanel] = Field(default_factory=list) + fromAssay: str | None = None + adjustment: Literal["fdr_bh"] = "fdr_bh" + evidenceIds: list[str] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_contract(self) -> "HypothesisContract": + for name, value in ( + ("contractId", self.contractId), + ("familyId", self.familyId), + ): + if value != value.strip(): + raise ValueError(f"{name} cannot contain surrounding whitespace") + panel_ids = [panel.panelId for panel in self.featurePanels] + if len(panel_ids) != len(set(panel_ids)): + raise ValueError("Hypothesis feature panel ids must be unique") + if self.cellSelection is not None and ( + self.cellSelection.scope != "datastore" + or self.cellSelection.kind != "cell_selection" + or not self.cellSelection.artifactId + ): + raise ValueError( + "Hypothesis cellSelection must be an exact datastore selection" + ) + if self.groupingArtifact is not None and not self.groupingArtifact.artifactId: + raise ValueError("Hypothesis groupingArtifact must be exact") + return self + + +class HypothesisTestExecution(AgentDataModel): + """Executed artifact references or explicit reasons no test was run.""" + + contractId: str = "" + familyId: str = "" + status: HypothesisExecutionStatus = "blocked" + contrast: ContrastPlan = Field(default_factory=ContrastPlan.get_blank) + featurePanels: list[HypothesisFeaturePanel] = Field(default_factory=list) + testedFeatures: list[str] = Field(default_factory=list) + inputCellSelection: ArtifactReferenceModel | None = None + effectiveCellSelection: ArtifactReferenceModel | None = None + groupingArtifact: ArtifactReferenceModel | None = None + clusterArtifact: ArtifactReferenceModel | None = None + statisticalTestArtifact: ArtifactReferenceModel | None = None + adjustment: Literal["fdr_bh"] = "fdr_bh" + blockedReasons: list[str] = Field(default_factory=list) + claimScope: str = _NORMALIZED_EXPRESSION_SCOPE + evidenceIds: list[str] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_execution(self) -> "HypothesisTestExecution": + if self.status == "executed": + if self.statisticalTestArtifact is None or self.blockedReasons: + raise ValueError( + "Executed hypothesis tests require an artifact and no block" + ) + elif not self.blockedReasons: + raise ValueError("Blocked hypothesis tests require explicit reasons") + return self + + @classmethod + def get_blank(cls) -> "HypothesisTestExecution": + return cls(blockedReasons=["hypothesisContractIsUnresolved"]) diff --git a/scarf/agent/hypothesis_testing.py b/scarf/agent/hypotheses/execution.py similarity index 54% rename from scarf/agent/hypothesis_testing.py rename to scarf/agent/hypotheses/execution.py index eea70ba5..f6d9d133 100644 --- a/scarf/agent/hypothesis_testing.py +++ b/scarf/agent/hypotheses/execution.py @@ -1,148 +1,17 @@ -"""Evidence-gated execution of existing Scarf statistical tests.""" +"""Execute licensed hypothesis contracts through Scarf statistical tests.""" -from typing import Any, Literal +from typing import Any -from ..metadata.selection import CellField -from ..storage.refs import ArtifactRef -from .config._deps import AGENT_INSTALL_HINT -from .experimental_context import ContrastPlan -from .tools import artifact_reference, core_artifact_reference -from .types import AgentDataModel, ArtifactReferenceModel - -try: - from pydantic import Field, model_validator -except ImportError as exc: - raise ImportError(AGENT_INSTALL_HINT) from exc - -type FeaturePanelPurpose = Literal["explicit", "exploratoryMarkers"] -type HypothesisExecutionStatus = Literal["executed", "blocked", "needsInput"] - -_NORMALIZED_EXPRESSION_SCOPE = ( - "Sample-level normalized-expression distribution testing. This is not a " - "raw-count pseudobulk differential-expression model." +from ...metadata.selection import CellField +from ...storage.refs import ArtifactRef +from ..tools import artifact_reference, core_artifact_reference +from .contracts import ( + HypothesisContract, + HypothesisExecutionStatus, + HypothesisTestExecution, ) -class HypothesisFeaturePanel(AgentDataModel): - """Features kept under one explicit or exploratory provenance label.""" - - panelId: str = "" - purpose: FeaturePanelPurpose = "explicit" - features: list[str] = Field(default_factory=list) - sourceArtifact: ArtifactReferenceModel | None = None - evidenceIds: list[str] = Field(default_factory=list) - - @model_validator(mode="after") - def validate_panel(self) -> "HypothesisFeaturePanel": - if self.panelId != self.panelId.strip(): - raise ValueError("Feature panel ids cannot contain surrounding whitespace") - if any( - not feature.strip() or feature != feature.strip() - for feature in self.features - ): - raise ValueError("Feature names must be non-empty trimmed strings") - if len(self.features) != len(set(self.features)): - raise ValueError("Feature names must be unique within a panel") - if self.sourceArtifact is not None and not self.sourceArtifact.artifactId: - raise ValueError("Feature panel source artifacts must be exact") - if self.purpose == "exploratoryMarkers" and self.sourceArtifact is None: - raise ValueError( - "Exploratory marker panels require their exact source artifact" - ) - return self - - -class ClusterSelectionContract(AgentDataModel): - """An exact cluster artifact and labels used for a within-cluster test.""" - - clusterArtifact: ArtifactReferenceModel - include: list[str | int | float | bool] = Field(default_factory=list) - - @model_validator(mode="after") - def validate_selection(self) -> "ClusterSelectionContract": - if not self.clusterArtifact.artifactId: - raise ValueError("Cluster selection requires an exact artifact") - if not self.include: - raise ValueError("Cluster selection requires at least one label") - keys = [(type(value).__name__, repr(value)) for value in self.include] - if len(keys) != len(set(keys)): - raise ValueError("Cluster labels must be unique") - return self - - -class HypothesisContract(AgentDataModel): - """One immutable-input hypothesis family licensed by a contrast plan.""" - - contractId: str = "" - familyId: str = "" - contrast: ContrastPlan = Field(default_factory=ContrastPlan.get_blank) - cellSelection: ArtifactReferenceModel | None = None - groupingArtifact: ArtifactReferenceModel | None = None - clusterSelection: ClusterSelectionContract | None = None - featurePanels: list[HypothesisFeaturePanel] = Field(default_factory=list) - fromAssay: str | None = None - adjustment: Literal["fdr_bh"] = "fdr_bh" - evidenceIds: list[str] = Field(default_factory=list) - - @model_validator(mode="after") - def validate_contract(self) -> "HypothesisContract": - for name, value in ( - ("contractId", self.contractId), - ("familyId", self.familyId), - ): - if value != value.strip(): - raise ValueError(f"{name} cannot contain surrounding whitespace") - panel_ids = [panel.panelId for panel in self.featurePanels] - if len(panel_ids) != len(set(panel_ids)): - raise ValueError("Hypothesis feature panel ids must be unique") - if self.cellSelection is not None and ( - self.cellSelection.scope != "datastore" - or self.cellSelection.kind != "cell_selection" - or not self.cellSelection.artifactId - ): - raise ValueError( - "Hypothesis cellSelection must be an exact datastore selection" - ) - if self.groupingArtifact is not None and not self.groupingArtifact.artifactId: - raise ValueError("Hypothesis groupingArtifact must be exact") - return self - - -class HypothesisTestExecution(AgentDataModel): - """Executed artifact references or explicit reasons no test was run.""" - - contractId: str = "" - familyId: str = "" - status: HypothesisExecutionStatus = "blocked" - contrast: ContrastPlan = Field(default_factory=ContrastPlan.get_blank) - featurePanels: list[HypothesisFeaturePanel] = Field(default_factory=list) - testedFeatures: list[str] = Field(default_factory=list) - inputCellSelection: ArtifactReferenceModel | None = None - effectiveCellSelection: ArtifactReferenceModel | None = None - groupingArtifact: ArtifactReferenceModel | None = None - clusterArtifact: ArtifactReferenceModel | None = None - statisticalTestArtifact: ArtifactReferenceModel | None = None - adjustment: Literal["fdr_bh"] = "fdr_bh" - blockedReasons: list[str] = Field(default_factory=list) - claimScope: str = _NORMALIZED_EXPRESSION_SCOPE - evidenceIds: list[str] = Field(default_factory=list) - - @model_validator(mode="after") - def validate_execution(self) -> "HypothesisTestExecution": - if self.status == "executed": - if self.statisticalTestArtifact is None or self.blockedReasons: - raise ValueError( - "Executed hypothesis tests require an artifact and no block" - ) - elif not self.blockedReasons: - raise ValueError("Blocked hypothesis tests require explicit reasons") - return self - - @classmethod - def get_blank(cls) -> "HypothesisTestExecution": - return cls(blockedReasons=["hypothesisContractIsUnresolved"]) - - def _blocked_execution( contract: HypothesisContract, *, @@ -349,12 +218,4 @@ def execute_hypothesis_contract( ) -__all__ = [ - "ClusterSelectionContract", - "FeaturePanelPurpose", - "HypothesisContract", - "HypothesisExecutionStatus", - "HypothesisFeaturePanel", - "HypothesisTestExecution", - "execute_hypothesis_contract", -] +__all__ = ["execute_hypothesis_contract"] diff --git a/scarf/agent/ingest/common.py b/scarf/agent/ingest/common.py index 2df7f0b4..3cc47da2 100644 --- a/scarf/agent/ingest/common.py +++ b/scarf/agent/ingest/common.py @@ -7,7 +7,7 @@ from ...storage.profiles import is_local_zarr_path from ...storage.stores import zarr_location_has_content -from ..decide import DecisionValidationError, decide +from ..decisions.selection import DecisionValidationError, decide from ..types import Decision, EvidenceItem from .result import ( IngestResult, @@ -252,7 +252,7 @@ def finish( resolved_action_labels = list(action_labels) workflow_run = None if summary_mode != "r": - from ..persistence import create_agent_workflow + from ..persistence.reports import create_agent_workflow try: workflow_run = create_agent_workflow(zarr_path) diff --git a/scarf/agent/ingest/manifest.py b/scarf/agent/ingest/manifest.py index 65b90abb..7c6f84e5 100644 --- a/scarf/agent/ingest/manifest.py +++ b/scarf/agent/ingest/manifest.py @@ -9,15 +9,15 @@ import numpy as np from ...readers._h5ad_inspect import ( - _MatrixCandidate, _as_text, _column_names, _matrix_candidates, + _MatrixCandidate, _node_length, _select_matrix, inspect_h5ad, ) -from ..config._deps import AGENT_INSTALL_HINT +from .._deps import AGENT_INSTALL_HINT from ..types import AgentDataModel try: diff --git a/scarf/agent/ingest/result.py b/scarf/agent/ingest/result.py index d44f6db9..27c9a2a6 100644 --- a/scarf/agent/ingest/result.py +++ b/scarf/agent/ingest/result.py @@ -3,8 +3,8 @@ from collections.abc import Sequence from typing import Any -from ..config._deps import AGENT_INSTALL_HINT -from ..persistence import AgentWorkflowRun +from .._deps import AGENT_INSTALL_HINT +from ..persistence.contracts import AgentWorkflowRun from ..types import AgentDataModel, Decision, NeedsInput, StageStatus try: diff --git a/scarf/agent/orchestrator/context.py b/scarf/agent/orchestrator/context.py index 931cd7c6..a8c354d6 100644 --- a/scarf/agent/orchestrator/context.py +++ b/scarf/agent/orchestrator/context.py @@ -8,24 +8,24 @@ from ...datastore.datastore import DataStore from ...utils.logging import logger -from ..data_enrichment import ( - DataEnrichmentAgent, +from ..data_enrichment.agent import DataEnrichmentAgent +from ..data_enrichment.contracts import ( DataEnrichmentContext, DataEnrichmentReport, ) -from ..experimental_context import ( - ExperimentalContextAgent, +from ..experimental_context.agent import ExperimentalContextAgent +from ..experimental_context.contracts import ( ExperimentalContextResult, NamedArtifactSource, ) +from ..experimental_context.study import build_study_contract from ..ingest import IngestResult from ..ingest.manifest import DatasetManifest, is_author_label_column -from ..persistence import ( +from ..persistence.contracts import ( AgentInvocation, AgentReportReference, AgentWorkflowRun, ) -from ..study_contract import build_study_contract from ..types import AgentRunInfo, ArtifactReferenceModel from . import journal from .models import ( diff --git a/scarf/agent/orchestrator/decisions.py b/scarf/agent/orchestrator/decisions.py index 0c9b8f31..1832d62b 100644 --- a/scarf/agent/orchestrator/decisions.py +++ b/scarf/agent/orchestrator/decisions.py @@ -11,7 +11,7 @@ from .. import record_io from ..config.agent_exec import run_agent_sync -from ..decision_kernel import ( +from ..decisions.kernel import ( DecisionRecord, DecisionSelection, DecisionSource, @@ -20,17 +20,17 @@ PendingDecision, RevisionRequest, ) -from ..decision_persistence import ( +from ..decisions.rna import ( + CompiledRnaDecision, + RnaDecisionDefinition, + compile_rna_decision, +) +from ..persistence.decisions import ( attach_audited_rna_decision, load_latest_decision_workflow_snapshot, pause_decision_workflow, save_decision_workflow_snapshot, ) -from ..rna_decisions import ( - CompiledRnaDecision, - RnaDecisionDefinition, - compile_rna_decision, -) from .models import OrchestrationRequestRecord, WorkflowQuestion diff --git a/scarf/agent/orchestrator/finalization.py b/scarf/agent/orchestrator/finalization.py index ae4c2da1..7acd3c29 100644 --- a/scarf/agent/orchestrator/finalization.py +++ b/scarf/agent/orchestrator/finalization.py @@ -7,35 +7,33 @@ from ...datastore.datastore import DataStore from ...utils.logging import logger -from ..biological_interpretation import ( +from ..biological_interpretation.agent import BiologicalInterpretationAgent +from ..biological_interpretation.contracts import ( BiologicalContext, - BiologicalInterpretationAgent, BiologicalInterpretationReport, ) -from ..data_enrichment import DataEnrichmentReport -from ..decision_persistence import ( - complete_decision_workflow, - load_latest_decision_workflow_snapshot, - save_decision_workflow_snapshot, -) -from ..experimental_context import ExperimentalContextResult -from ..hypothesis_testing import ( +from ..data_enrichment.contracts import DataEnrichmentReport +from ..experimental_context.contracts import ExperimentalContextResult +from ..experimental_context.study import StudyContract +from ..hypotheses.contracts import ( ClusterSelectionContract, HypothesisContract, HypothesisFeaturePanel, HypothesisTestExecution, - execute_hypothesis_contract, -) -from ..parameter_tuning import ( - ParameterTuningAgent, - ParameterTuningReport, ) -from ..persistence import ( +from ..hypotheses.execution import execute_hypothesis_contract +from ..parameter_tuning.agent import ParameterTuningAgent +from ..parameter_tuning.contracts import ParameterTuningReport +from ..persistence.contracts import ( AgentInvocation, AgentReportReference, AgentWorkflowRun, ) -from ..study_contract import StudyContract +from ..persistence.decisions import ( + complete_decision_workflow, + load_latest_decision_workflow_snapshot, + save_decision_workflow_snapshot, +) from ..types import ArtifactReferenceModel, ExperimentalBiologyHandoff from . import journal from .models import ( diff --git a/scarf/agent/orchestrator/journal.py b/scarf/agent/orchestrator/journal.py index fcac3cae..78ff31ad 100644 --- a/scarf/agent/orchestrator/journal.py +++ b/scarf/agent/orchestrator/journal.py @@ -14,14 +14,17 @@ from ...datastore.datastore import DataStore from ...utils.logging import logger from .. import record_io +from ..experimental_context.study import StudyContract from ..ingest.manifest import DatasetManifest -from ..persistence import ( +from ..persistence.contracts import ( AgentInvocation, AgentName, - AgentReport, AgentReportLink, AgentReportReference, AgentWorkflowRun, +) +from ..persistence.reports import ( + AgentReport, finalize_agent_workflow, list_agent_reports, load_agent_record, @@ -30,7 +33,6 @@ save_agent_report, ) from ..types import AgentDataModel, ArtifactReferenceModel -from ..study_contract import StudyContract from .models import ( _ORCHESTRATION_FORMAT, _ORCHESTRATION_VERSION, diff --git a/scarf/agent/orchestrator/main.py b/scarf/agent/orchestrator/main.py index a26eab49..a2090e33 100644 --- a/scarf/agent/orchestrator/main.py +++ b/scarf/agent/orchestrator/main.py @@ -13,17 +13,17 @@ from ...storage.stores import zarr_root_path from ...utils.logging import logger from .. import record_io -from ..decision_persistence import load_latest_decision_workflow_snapshot +from ..experimental_context.study import StudyContract from ..ingest import IngestResult, detect_format, ingest from ..ingest.manifest import DatasetManifest, inspect_h5ad_manifest -from ..persistence import ( - AgentWorkflowRun, +from ..persistence.contracts import AgentWorkflowRun +from ..persistence.decisions import load_latest_decision_workflow_snapshot +from ..persistence.reports import ( create_agent_workflow, finalize_agent_workflow, load_agent_report, load_agent_workflow, ) -from ..study_contract import StudyContract from . import journal from .context import ContextStagesMixin from .finalization import FinalizationStagesMixin @@ -58,7 +58,7 @@ def _generate_completed_report( try: if zarr_root_path(store.z) is None: return - from ..report import generate_agent_report + from ..report.generator import generate_agent_report report_path = generate_agent_report(store, workflow.workflowRunId) relative_path = os.path.relpath(report_path, start=Path.cwd()) diff --git a/scarf/agent/orchestrator/models.py b/scarf/agent/orchestrator/models.py index c864dbe6..315ac6f2 100644 --- a/scarf/agent/orchestrator/models.py +++ b/scarf/agent/orchestrator/models.py @@ -9,11 +9,11 @@ from ...storage.refs import ArtifactRef from .. import record_io from ..config import AgentRunConfig -from ..experimental_context import CellQcPlan +from ..decisions.rna import CellQualityExecutorPayload +from ..experimental_context.contracts import CellQcPlan +from ..experimental_context.study import AuthorLabelPolicy, StudyContract from ..ingest.manifest import DatasetManifest -from ..persistence import AgentReportReference, AgentWorkflowRun -from ..rna_decisions import CellQualityExecutorPayload -from ..study_contract import AuthorLabelPolicy, StudyContract +from ..persistence.contracts import AgentReportReference, AgentWorkflowRun from ..types import AgentDataModel, ArtifactReferenceModel type AutomatedWorkflowStatus = Literal[ diff --git a/scarf/agent/orchestrator/preprocessing.py b/scarf/agent/orchestrator/preprocessing.py index 6c4357e7..2f385789 100644 --- a/scarf/agent/orchestrator/preprocessing.py +++ b/scarf/agent/orchestrator/preprocessing.py @@ -24,31 +24,14 @@ from ...storage.types import as_zarr_array from ...utils.logging import logger from .. import record_io -from ..data_enrichment import ( +from ..cell_quality.execution import execute_registered_cell_qc +from ..data_enrichment.contracts import ( AssayFeatureInspection, DataEnrichmentReport, FeatureSelectionPolicy, ) -from ..decision_kernel import DecisionEvidence, DecisionSelection, EvidenceBundle -from ..experimental_context import ( - CellQcPlan, - CellQcProfileEvidence, - ExperimentalContextResult, -) -from ..hvg_diagnostics import ( - HvgRanking, - compare_hvg_ranking_to_default, - run_hvg_diagnostic_artifacts, -) -from ..persistence import AgentWorkflowRun -from ..parameter_tuning import ( - ParameterCandidate, - ParameterCandidateEvaluation, - execute_parameter_candidate, - prepare_parameter_tuning_dependencies, -) -from ..qc_execution import execute_registered_cell_qc -from ..rna_decisions import ( +from ..decisions.kernel import DecisionEvidence, DecisionSelection, EvidenceBundle +from ..decisions.rna import ( CellQualityExecutorPayload, CellQualityProfile, FeaturePolicyExecutorPayload, @@ -62,12 +45,29 @@ build_qc_grouping_decision, require_option_evidence, ) -from ..study_contract import StudyContract -from ..tuning_diagnostics import ( +from ..experimental_context.contracts import ( + CellQcPlan, + CellQcProfileEvidence, + ExperimentalContextResult, +) +from ..experimental_context.study import StudyContract +from ..parameter_tuning.agent import prepare_parameter_tuning_dependencies +from ..parameter_tuning.contracts import ( + ParameterCandidate, + ParameterCandidateEvaluation, +) +from ..parameter_tuning.diagnostics import ( SCARF_DEFAULT_DIAGNOSTIC_FAMILIES, augment_cluster_evaluations, augment_pca_evaluations, ) +from ..parameter_tuning.execution import execute_parameter_candidate +from ..parameter_tuning.hvg import ( + HvgRanking, + compare_hvg_ranking_to_default, + run_hvg_diagnostic_artifacts, +) +from ..persistence.contracts import AgentWorkflowRun from ..types import ArtifactReferenceModel from . import journal from .decisions import DecisionStagesMixin diff --git a/scarf/agent/orchestrator/tuning.py b/scarf/agent/orchestrator/tuning.py index 93271527..eccd2773 100644 --- a/scarf/agent/orchestrator/tuning.py +++ b/scarf/agent/orchestrator/tuning.py @@ -24,9 +24,29 @@ build_visual_evidence_prompt, run_agent_sync, ) -from ..decision_kernel import DecisionEvidence, DecisionSelection, EvidenceBundle -from ..experimental_context import ExperimentalContextResult -from ..parameter_tuning import ( +from ..decisions.kernel import DecisionEvidence, DecisionSelection, EvidenceBundle +from ..decisions.rna import ( + ClusterExecutorPayload, + ConditionalGeneFamily, + CorrectionLicensePayload, + CorrectionNeedPayload, + CorrectionOutcomeExecutorPayload, + FeaturePolicyExecutorPayload, + GraphExecutorPayload, + PcaPrefixExecutorPayload, + build_cluster_partition_decision, + build_correction_license_decision, + build_correction_need_decision, + build_correction_outcome_decision, + build_feature_policy_decision, + build_graph_k_decision, + build_pca_prefix_decision, + require_option_evidence, +) +from ..experimental_context.contracts import ExperimentalContextResult +from ..experimental_context.study import StudyContract +from ..parameter_tuning.agent import ParameterTuningAgent +from ..parameter_tuning.contracts import ( ArtifactRecord, FinalGraphComparison, FinalGraphSelection, @@ -35,31 +55,30 @@ ParameterCandidate, ParameterCandidateEvaluation, ParameterSearchPlan, - ParameterTuningDependencies, - ParameterTuningAgent, ParameterTuningAssayInput, + ParameterTuningDependencies, ParameterTuningReport, - final_graph_options, - finalize_parameter_tuning_selection, +) +from ..parameter_tuning.diagnostics import ( + SCARF_DEFAULT_DIAGNOSTIC_FAMILIES, + augment_cluster_evaluations, + augment_pca_evaluations, + score_advisory_doublets, +) +from ..parameter_tuning.prompts import ( parameter_search_prompt, parameter_search_system_prompt, parameter_tuning_prompt, parameter_tuning_system_prompt, +) +from ..parameter_tuning.selection import ( + final_graph_options, + finalize_parameter_tuning_selection, pending_parameter_tuning_report, - validate_parameter_tuning_report, validate_final_graph_selection, + validate_parameter_tuning_report, ) -from ..persistence import ( - AgentInvocation, - AgentReportLink, - AgentReportReference, - AgentWorkflowRun, - list_agent_reports, - load_agent_record, - load_agent_report, - save_agent_report, -) -from ..sequential_tuning import ( +from ..parameter_tuning.sequential import ( CorrectionNeedSelection, ParameterPhaseEvidence, ParameterPhasePlan, @@ -70,33 +89,20 @@ execute_sequential_refinement, prepare_sequential_refinement_dependencies, sequential_evidence_to_report, - validate_sequential_refinement_plan, validate_parameter_phase_selection, + validate_sequential_refinement_plan, ) -from ..rna_decisions import ( - ClusterExecutorPayload, - ConditionalGeneFamily, - CorrectionLicensePayload, - CorrectionNeedPayload, - CorrectionOutcomeExecutorPayload, - FeaturePolicyExecutorPayload, - GraphExecutorPayload, - PcaPrefixExecutorPayload, - build_cluster_partition_decision, - build_correction_license_decision, - build_correction_need_decision, - build_correction_outcome_decision, - build_feature_policy_decision, - build_graph_k_decision, - build_pca_prefix_decision, - require_option_evidence, +from ..persistence.contracts import ( + AgentInvocation, + AgentReportLink, + AgentReportReference, + AgentWorkflowRun, ) -from ..study_contract import StudyContract -from ..tuning_diagnostics import ( - SCARF_DEFAULT_DIAGNOSTIC_FAMILIES, - augment_cluster_evaluations, - augment_pca_evaluations, - score_advisory_doublets, +from ..persistence.reports import ( + list_agent_reports, + load_agent_record, + load_agent_report, + save_agent_report, ) from ..types import AgentDataModel, ArtifactReferenceModel, ExperimentalTuningHandoff from . import journal diff --git a/scarf/agent/parameter_tuning.py b/scarf/agent/parameter_tuning.py deleted file mode 100644 index 42c205f7..00000000 --- a/scarf/agent/parameter_tuning.py +++ /dev/null @@ -1,4286 +0,0 @@ -"""Bounded parameter tuning over explicit Scarf analysis candidates.""" - -import json -from collections.abc import Mapping, Sequence -from textwrap import dedent -from threading import Lock -from typing import Any, Literal - -import numpy as np - -from ..metrics import graph_connectivity -from ..storage.refs import ArtifactRef -from ..storage.types import as_zarr_array -from .config import CONFIG, AgentRunConfig -from .config._deps import AGENT_INSTALL_HINT -from .config.agent_exec import run_agent_sync -from .tools import artifact_reference, core_artifact_reference -from .types import ( - AgentDataModel, - AgentRunInfo, - ArtifactReferenceModel, - ExperimentalTuningHandoff, - StageStatus, - TuningBiologyHandoff, -) -from ..utils.logging import logger - -try: - from pydantic import Field -except ImportError as exc: - raise ImportError(AGENT_INSTALL_HINT) from exc - -try: - from pydantic_ai import RunContext, UnexpectedModelBehavior, UsageLimitExceeded -except ImportError as exc: - raise ImportError(AGENT_INSTALL_HINT) from exc - - -type CandidateStatus = Literal["done", "failed"] -type CandidatePhase = Literal["initial", "refined"] -type ParameterSearchStatus = Literal["complete", "refine"] -type TuningConfidence = Literal["low", "medium", "high"] -type ReductionMethod = Literal["pca", "lsi", "identity"] -type IntegrationMethod = Literal["snn", "wnn"] - - -class ArtifactRecord(ArtifactReferenceModel): - """JSON-safe identity for one artifact returned by candidate execution.""" - - @classmethod - def from_ref(cls, ref: Any) -> "ArtifactRecord": - return cls( - scope=getattr(ref, "scope", "assay"), - kind=str(getattr(ref, "kind", "")), - artifactId=str(getattr(ref, "artifact_id", ref)), - assay=getattr(ref, "assay", None), - ) - - @classmethod - def get_blank(cls) -> "ArtifactRecord": - return cls() - - @classmethod - def get_example(cls) -> "ArtifactRecord": - return cls( - scope="assay", - kind="connectivity_map", - artifactId="a" * 64, - assay="RNA", - ) - - -class ParameterCandidate(AgentDataModel): - """One exact, caller-authorized parameter candidate.""" - - candidateId: str = Field( - default="", - description="Exact candidate id supplied to the evaluation tool", - ) - reductionMethod: ReductionMethod = "pca" - dimensions: int = Field(default=21, ge=2) - leidenResolution: float = Field(default=1.0, gt=0) - neighborsK: int = Field(default=11, ge=2) - useHarmony: bool = False - - @classmethod - def get_blank(cls) -> "ParameterCandidate": - return cls() - - @classmethod - def get_example(cls) -> "ParameterCandidate": - return cls( - candidateId="baseline", - reductionMethod="pca", - dimensions=21, - leidenResolution=1.0, - neighborsK=11, - useHarmony=False, - ) - - -class ParameterMetrics(AgentDataModel): - """Bounded quality metrics for one candidate branch.""" - - nClusters: int | None = None - minClusterCells: int | None = None - minClusterFraction: float | None = None - graphSilhouetteMedian: float | None = None - pcaSilhouette: float | None = None - macroF1: float | None = None - weightedF1: float | None = None - membershipStrengthMean: float | None = None - membershipStrengthMedian: float | None = None - membershipStrengthP10: float | None = None - membershipStrengthByCluster: dict[str, float] = Field(default_factory=dict) - membershipStrengthSampleSize: int | None = None - clusterConnectivity: float | None = None - seedStability: float | None = None - subsampleStability: float | None = None - markerCoherence: float | None = None - markerSpecificityMedian: float | None = None - markerSpecificityByCluster: dict[str, float] = Field(default_factory=dict) - markerAucByCluster: dict[str, float] = Field(default_factory=dict) - topMarkerGenes: dict[str, list[str]] = Field(default_factory=dict) - crossUnitSupport: float | None = None - technicalAssociation: dict[str, float] = Field(default_factory=dict) - componentVariance: list[float] = Field(default_factory=list) - pcaExplainedVarianceRatio: list[float] = Field(default_factory=list) - pcaCumulativeExplainedVarianceRatio: list[float] = Field(default_factory=list) - topLoadingGenes: dict[str, list[str]] = Field(default_factory=dict) - loadingFamilyEnrichment: dict[str, float] = Field(default_factory=dict) - loadingFamilyEnrichmentByComponent: dict[str, dict[str, float]] = Field( - default_factory=dict - ) - pcaComponentAssociations: dict[str, dict[str, list[float]]] = Field( - default_factory=dict - ) - batchPcaAssociation: dict[str, float] = Field(default_factory=dict) - technicalPcaAssociation: dict[str, float] = Field(default_factory=dict) - protectedPcaAssociation: dict[str, float] = Field(default_factory=dict) - qcPcaAssociation: dict[str, float] = Field(default_factory=dict) - neighborPrefixOverlap: float | None = None - markerFamilyEnrichment: dict[str, float] = Field(default_factory=dict) - protectedMarkerFamilies: list[str] = Field(default_factory=list) - doubletHighScoreConcentration: float | None = None - doubletScoreQuantiles: dict[str, float] = Field(default_factory=dict) - doubletScoreByCapture: dict[str, dict[str, float]] = Field(default_factory=dict) - doubletCaptureCoverage: float | None = None - batchMixing: dict[str, float] = Field(default_factory=dict) - biologicalPreservation: dict[str, dict[str, float]] = Field(default_factory=dict) - paretoOptimal: bool | None = None - dominatedByCandidateIds: list[str] = Field(default_factory=list) - dominatesCandidateIds: list[str] = Field(default_factory=list) - dominanceMetrics: dict[str, list[str]] = Field(default_factory=dict) - - @classmethod - def get_blank(cls) -> "ParameterMetrics": - return cls() - - @classmethod - def get_example(cls) -> "ParameterMetrics": - return cls( - nClusters=8, - minClusterCells=42, - minClusterFraction=0.021, - graphSilhouetteMedian=0.41, - pcaSilhouette=0.36, - macroF1=0.82, - weightedF1=0.86, - batchMixing={"batch": 0.73}, - biologicalPreservation={ - "cell_type": {"clisi": 0.88, "graphConnectivity": 0.91} - }, - ) - - -class ParameterCandidateEvaluation(AgentDataModel): - """Execution record returned to the model for one candidate.""" - - candidateId: str = "" - phase: CandidatePhase = "initial" - harmonyBatchColumns: list[str] = Field(default_factory=list) - status: CandidateStatus = "failed" - eligible: bool = False - parameters: ParameterCandidate = Field(default_factory=ParameterCandidate.get_blank) - artifacts: dict[str, ArtifactRecord] = Field(default_factory=dict) - cellSelection: ArtifactReferenceModel | None = None - clusterColumn: str | None = None - clusterLabel: str | None = None - effectiveDimensions: int | None = None - metrics: ParameterMetrics = Field(default_factory=ParameterMetrics.get_blank) - evidenceIds: list[str] = Field(default_factory=list) - eligibilityReasons: list[str] = Field(default_factory=list) - warnings: list[str] = Field(default_factory=list) - error: str | None = None - - @classmethod - def get_blank(cls) -> "ParameterCandidateEvaluation": - return cls() - - @classmethod - def get_example(cls) -> "ParameterCandidateEvaluation": - candidate = ParameterCandidate.get_example() - return cls( - candidateId=candidate.candidateId, - status="done", - eligible=True, - parameters=candidate, - artifacts={ - "connectivityMap": ArtifactRecord.get_example(), - "clusters": ArtifactRecord( - assay="RNA", - kind="cluster_labels", - artifactId="b" * 64, - ), - }, - cellSelection=ArtifactReferenceModel( - scope="datastore", - assay=None, - kind="cell_selection", - artifactId="c" * 64, - ), - clusterColumn="RNA_agent_tuning_baseline", - clusterLabel="agent_tuning_baseline", - effectiveDimensions=21, - metrics=ParameterMetrics.get_example(), - evidenceIds=["candidate:baseline:clusters"], - ) - - -class IntegrationMetrics(AgentDataModel): - """Metrics that are valid for an integrated graph comparison.""" - - nClusters: int | None = None - minClusterCells: int | None = None - minClusterFraction: float | None = None - adjustedRandByAssay: dict[str, float] = Field(default_factory=dict) - normalizedMutualInformationByAssay: dict[str, float] = Field(default_factory=dict) - biologicalConnectivity: dict[str, float] = Field(default_factory=dict) - modalityWeightsValid: bool | None = None - - @classmethod - def get_blank(cls) -> "IntegrationMetrics": - return cls() - - @classmethod - def get_example(cls) -> "IntegrationMetrics": - return cls( - nClusters=8, - minClusterCells=37, - minClusterFraction=0.0185, - adjustedRandByAssay={"RNA": 0.71, "ADT": 0.63}, - normalizedMutualInformationByAssay={"RNA": 0.76, "ADT": 0.69}, - modalityWeightsValid=True, - ) - - -class IntegrationCandidateEvaluation(AgentDataModel): - """One executor-produced SNN or WNN graph and cluster evaluation.""" - - integrationId: str = "" - method: IntegrationMethod = "wnn" - assays: list[str] = Field(default_factory=list) - status: CandidateStatus = "failed" - eligible: bool = False - cellSelection: ArtifactReferenceModel | None = None - resolution: float = Field(default=1.0, gt=0) - graphArtifact: ArtifactRecord | None = None - clusterArtifact: ArtifactRecord | None = None - clusterColumn: str | None = None - metrics: IntegrationMetrics = Field(default_factory=IntegrationMetrics.get_blank) - evidenceIds: list[str] = Field(default_factory=list) - eligibilityReasons: list[str] = Field(default_factory=list) - warnings: list[str] = Field(default_factory=list) - error: str | None = None - - @classmethod - def get_blank(cls) -> "IntegrationCandidateEvaluation": - return cls() - - @classmethod - def get_example(cls) -> "IntegrationCandidateEvaluation": - return cls( - integrationId="wnn_resolution_1", - method="wnn", - assays=["RNA", "ADT"], - status="done", - eligible=True, - cellSelection=ArtifactReferenceModel( - scope="datastore", - assay=None, - kind="cell_selection", - artifactId="c" * 64, - ), - graphArtifact=ArtifactRecord( - scope="datastore", - kind="integrated_graph", - artifactId="2" * 64, - ), - clusterArtifact=ArtifactRecord( - scope="datastore", - kind="cluster_labels", - artifactId="3" * 64, - ), - clusterColumn="agent_wnn_cluster", - metrics=IntegrationMetrics.get_example(), - evidenceIds=["integration:wnn_resolution_1:clusters"], - ) - - -class FinalGraphComparison(AgentDataModel): - """Evidence-backed comparison against one eligible final graph option.""" - - optionId: str = "" - summary: str = "" - evidenceIds: list[str] = Field(default_factory=list) - - @classmethod - def get_blank(cls) -> "FinalGraphComparison": - return cls() - - @classmethod - def get_example(cls) -> "FinalGraphComparison": - return cls( - optionId="native:ADT:baseline", - summary="The RNA-native option better preserves the requested labels.", - evidenceIds=[ - "native:RNA:candidate:baseline:clusters", - "native:ADT:candidate:baseline:clusters", - ], - ) - - -class FinalGraphNeedsInput(AgentDataModel): - """Concrete input needed before a final graph can be selected.""" - - question: str = "" - options: list[str] = Field(default_factory=list) - evidenceIds: list[str] = Field(default_factory=list) - - @classmethod - def get_blank(cls) -> "FinalGraphNeedsInput": - return cls() - - @classmethod - def get_example(cls) -> "FinalGraphNeedsInput": - return cls( - question="Which biological signal must the final graph preserve?", - options=["cell_type", "condition"], - ) - - -class FinalGraphSelection(AgentDataModel): - """Grounded choice among selected native, SNN, and WNN graph options.""" - - status: StageStatus = "needsInput" - selectedOptionId: str | None = None - graphMethod: Literal["native", "snn", "wnn"] | None = None - nativeAssay: str | None = None - nativeCandidateId: str | None = None - integrationId: str | None = None - markerAssay: str = "" - confidence: TuningConfidence = "low" - rationale: str = "" - evidenceIds: list[str] = Field(default_factory=list) - comparisons: list[FinalGraphComparison] = Field(default_factory=list) - tradeoffs: list[str] = Field(default_factory=list) - limitations: list[str] = Field(default_factory=list) - needsInput: FinalGraphNeedsInput | None = None - runInfo: AgentRunInfo = Field(default_factory=AgentRunInfo) - - @classmethod - def get_blank(cls) -> "FinalGraphSelection": - return cls() - - @classmethod - def get_example(cls) -> "FinalGraphSelection": - return cls( - status="done", - selectedOptionId="native:RNA:baseline", - graphMethod="native", - nativeAssay="RNA", - nativeCandidateId="baseline", - markerAssay="RNA", - confidence="medium", - rationale="The selected native graph has the strongest supported balance.", - evidenceIds=["native:RNA:candidate:baseline:clusters"], - runInfo=AgentRunInfo.get_example(), - ) - - -class CandidateComparison(AgentDataModel): - """Evidence-backed comparison against one executed non-selected candidate.""" - - candidateId: str = "" - summary: str = "" - evidenceIds: list[str] = Field(default_factory=list) - - @classmethod - def get_blank(cls) -> "CandidateComparison": - return cls() - - @classmethod - def get_example(cls) -> "CandidateComparison": - return cls( - candidateId="pca_15", - summary="The selected baseline retains larger minimum clusters.", - evidenceIds=[ - "candidate:baseline:clusters", - "candidate:pca_15:clusters", - ], - ) - - -class ParameterSearchPlan(AgentDataModel): - """Validated proposal for one bounded refinement pass.""" - - status: ParameterSearchStatus = Field( - default="complete", - description=( - "Summary derived from candidates: refine when candidates is non-empty " - "and complete when it is empty" - ), - ) - candidates: list[ParameterCandidate] = Field( - default_factory=list, - description=( - "Bounded unexecuted refinement candidates, or an empty list when the " - "initial screen is complete" - ), - ) - basedOnCandidateIds: list[str] = Field(default_factory=list) - harmonyBatchColumns: list[str] = Field(default_factory=list) - objectives: list[str] = Field(default_factory=list) - rationale: str = "" - evidenceIds: list[str] = Field(default_factory=list) - stoppingCriteria: list[str] = Field(default_factory=list) - runInfo: AgentRunInfo = Field(default_factory=AgentRunInfo) - - @classmethod - def get_blank(cls) -> "ParameterSearchPlan": - return cls() - - @classmethod - def get_example(cls) -> "ParameterSearchPlan": - return cls( - status="refine", - candidates=[ - ParameterCandidate( - candidateId="refined_pca_18", - dimensions=18, - leidenResolution=1.0, - neighborsK=11, - useHarmony=False, - ) - ], - basedOnCandidateIds=["baseline", "pca_15"], - harmonyBatchColumns=[], - objectives=["Resolve the dimension tradeoff."], - rationale="The initial screen brackets a narrower dimension range.", - evidenceIds=[ - "candidate:baseline:clusters", - "candidate:pca_15:clusters", - ], - stoppingCriteria=["Run the proposed candidate once."], - runInfo=AgentRunInfo.get_example(), - ) - - -class ParameterTuningBatchSearchPlan(AgentDataModel): - """One bounded refinement plan for every assay in a batched screen.""" - - assayPlans: dict[str, ParameterSearchPlan] = Field(default_factory=dict) - runInfo: AgentRunInfo = Field(default_factory=AgentRunInfo) - - @classmethod - def get_blank(cls) -> "ParameterTuningBatchSearchPlan": - return cls() - - @classmethod - def get_example(cls) -> "ParameterTuningBatchSearchPlan": - return cls(assayPlans={"RNA": ParameterSearchPlan.get_example()}) - - -class ParameterTuningNeedsInput(AgentDataModel): - """User input required before tuning can produce a recommendation.""" - - question: str = "" - options: list[str] = Field(default_factory=list) - evidenceIds: list[str] = Field(default_factory=list) - - @classmethod - def get_blank(cls) -> "ParameterTuningNeedsInput": - return cls() - - @classmethod - def get_example(cls) -> "ParameterTuningNeedsInput": - return cls( - question="Which trusted biological label should be preserved?", - options=["cell_type", "none"], - evidenceIds=["candidate:baseline:batchMixing:batch"], - ) - - -class ParameterTuningReport(AgentDataModel): - """Grounded recommendation over candidate branches actually executed.""" - - status: StageStatus = "failed" - fromAssay: str = "" - cellSelection: ArtifactReferenceModel | None = None - evaluations: list[ParameterCandidateEvaluation] = Field(default_factory=list) - recommendedCandidateId: str | None = None - selectedArtifacts: dict[str, ArtifactRecord] = Field(default_factory=dict) - confidence: TuningConfidence = "low" - rationale: str = "" - evidenceIds: list[str] = Field(default_factory=list) - comparisons: list[CandidateComparison] = Field(default_factory=list) - tradeoffs: list[str] = Field(default_factory=list) - limitations: list[str] = Field(default_factory=list) - stopReason: str = "" - needsInput: ParameterTuningNeedsInput | None = None - searchPlan: ParameterSearchPlan | None = None - assayReports: dict[str, "ParameterTuningReport"] = Field(default_factory=dict) - recommendedByAssay: dict[str, str] = Field(default_factory=dict) - totalCandidates: int = 0 - integrationEvaluations: list[IntegrationCandidateEvaluation] = Field( - default_factory=list - ) - recommendedIntegrationId: str | None = None - finalClusterColumn: str | None = None - finalClusterArtifact: ArtifactRecord | None = None - graphAssay: str | None = None - markerAssay: str | None = None - finalSelection: FinalGraphSelection | None = None - runInfo: AgentRunInfo = Field(default_factory=AgentRunInfo) - - @classmethod - def get_blank(cls) -> "ParameterTuningReport": - return cls() - - @classmethod - def get_example(cls) -> "ParameterTuningReport": - evaluation = ParameterCandidateEvaluation.get_example() - return cls( - status="done", - fromAssay="RNA", - cellSelection=evaluation.cellSelection, - evaluations=[evaluation], - recommendedCandidateId=evaluation.candidateId, - selectedArtifacts=dict(evaluation.artifacts), - confidence="medium", - rationale="The baseline balances separation and cluster size.", - evidenceIds=["candidate:baseline:clusters"], - tradeoffs=["Higher resolutions produced smaller clusters."], - limitations=["No trusted biological preservation label was supplied."], - stopReason="All authorized candidates were evaluated.", - recommendedByAssay={"RNA": evaluation.candidateId}, - totalCandidates=1, - graphAssay="RNA", - markerAssay="RNA", - finalSelection=FinalGraphSelection.get_example(), - runInfo=AgentRunInfo.get_example(), - ) - - def to_biological_handoff( - self, - *, - marker_assay: str | None = None, - ) -> TuningBiologyHandoff: - """Return the exact selected clustering branch for interpretation.""" - if self.status != "done": - raise ValueError( - "Parameter Tuning must be done before creating a biology handoff" - ) - if self.finalClusterArtifact is not None: - if self.cellSelection is None: - raise ValueError("Final branch lacks an exact cell selection") - resolved_marker_assay = marker_assay or self.markerAssay - if not resolved_marker_assay: - raise ValueError( - "A marker assay is required for an integrated biology handoff" - ) - if self.finalClusterArtifact.scope == "datastore": - if self.finalClusterArtifact.assay is not None: - raise ValueError( - "A datastore-scoped cluster artifact must not name an assay" - ) - elif ( - self.graphAssay is not None - and self.finalClusterArtifact.assay != self.graphAssay - ): - raise ValueError("Final cluster artifact does not match graphAssay") - integration = next( - ( - item - for item in self.integrationEvaluations - if item.integrationId == self.recommendedIntegrationId - ), - None, - ) - if self.finalSelection is not None: - evidence_ids = self.finalSelection.evidenceIds - elif integration is not None: - evidence_ids = integration.evidenceIds - else: - prefix = f"candidate:{self.recommendedCandidateId}:" - evidence_ids = [ - evidence_id - for evidence_id in self.evidenceIds - if evidence_id.startswith(prefix) - ] - return TuningBiologyHandoff( - cellSelection=self.cellSelection, - fromAssay=self.fromAssay, - graphAssay=self.graphAssay, - markerAssay=resolved_marker_assay, - recommendedCandidateId=( - self.recommendedIntegrationId - or ( - self.finalSelection.nativeCandidateId - if self.finalSelection is not None - else None - ) - or self.recommendedCandidateId - or "final" - ), - clusterArtifact=ArtifactReferenceModel.model_validate( - self.finalClusterArtifact.model_dump() - ), - evidenceIds=sorted(evidence_ids), - ) - if self.recommendedCandidateId is None: - raise ValueError( - "Parameter Tuning must recommend a candidate before creating a " - "biology handoff" - ) - selected = next( - ( - item - for item in self.evaluations - if item.candidateId == self.recommendedCandidateId - ), - None, - ) - if selected is None or selected.status != "done" or not selected.eligible: - raise ValueError("Recommended candidate is not an eligible execution") - cluster_artifact = selected.artifacts.get("clusters") - if cluster_artifact is None or selected.cellSelection is None: - raise ValueError("Recommended candidate lacks an exact cluster artifact") - if not self.fromAssay or cluster_artifact.assay != self.fromAssay: - raise ValueError("Recommended cluster artifact does not match the assay") - prefix = f"candidate:{selected.candidateId}:" - return TuningBiologyHandoff( - cellSelection=selected.cellSelection, - fromAssay=self.fromAssay, - graphAssay=self.fromAssay, - markerAssay=marker_assay or self.markerAssay or self.fromAssay, - recommendedCandidateId=selected.candidateId, - clusterArtifact=ArtifactReferenceModel.model_validate( - cluster_artifact.model_dump() - ), - evidenceIds=sorted( - evidence_id - for evidence_id in self.evidenceIds - if evidence_id.startswith(prefix) - ), - ) - - -class ParameterTuningDependencies(AgentDataModel): - """Runtime-only state hidden from the model and shared by tuning tools.""" - - store: Any = Field(default=None, exclude=True) - normalized: Any = Field(default=None, exclude=True) - cellSelection: Any = Field(default=None, exclude=True) - normalizedShape: tuple[int, int] | None = None - fromAssay: str = "" - candidates: dict[str, ParameterCandidate] = Field(default_factory=dict) - candidatePhases: dict[str, CandidatePhase] = Field(default_factory=dict) - batchColumns: tuple[str, ...] = () - preservationColumns: tuple[str, ...] = () - harmonyAuthorized: bool = False - maxCandidates: int = 5 - minClusterCells: int = 20 - identityFeatureLimit: int = 64 - evaluations: dict[str, ParameterCandidateEvaluation] = Field(default_factory=dict) - executionOrder: list[str] = Field(default_factory=list) - executionLock: Any = Field(default_factory=Lock, exclude=True, repr=False) - - @classmethod - def get_blank(cls) -> "ParameterTuningDependencies": - return cls() - - @classmethod - def get_example(cls) -> "ParameterTuningDependencies": - candidate = ParameterCandidate.get_example() - return cls( - fromAssay="RNA", - normalizedShape=(1000, 2000), - candidates={candidate.candidateId: candidate}, - batchColumns=("batch",), - preservationColumns=("cell_type",), - ) - - -class ParameterTuningAssayInput(AgentDataModel): - """One assay branch supplied to batched parameter tuning.""" - - normalized: Any = Field(default=None, exclude=True) - candidates: list[ParameterCandidate] = Field(default_factory=list) - batchColumns: list[str] = Field(default_factory=list) - preservationColumns: list[str] = Field(default_factory=list) - experimentalHandoff: ExperimentalTuningHandoff | None = None - maxCandidates: int = Field(default=5, ge=1) - maxRefinedCandidates: int = Field(default=0, ge=0) - allowHarmonyRefinement: bool = True - minClusterCells: int = Field(default=20, ge=1) - identityFeatureLimit: int = Field(default=64, ge=2) - - @classmethod - def get_blank(cls) -> "ParameterTuningAssayInput": - return cls() - - @classmethod - def get_example(cls) -> "ParameterTuningAssayInput": - return cls( - normalized=ArtifactRecord( - assay="RNA", - kind="normalized", - artifactId="4" * 64, - ), - candidates=get_default_parameter_candidates(), - experimentalHandoff=ExperimentalTuningHandoff(batchAction="skip"), - ) - - -def get_default_parameter_candidates() -> list[ParameterCandidate]: - """Return a small one-factor candidate set around Scarf defaults.""" - - return [ - ParameterCandidate( - candidateId="baseline", - dimensions=21, - leidenResolution=1.0, - ), - ParameterCandidate( - candidateId="pca_15", - dimensions=15, - leidenResolution=1.0, - ), - ParameterCandidate( - candidateId="pca_30", - dimensions=30, - leidenResolution=1.0, - ), - ParameterCandidate( - candidateId="leiden_0_5", - dimensions=21, - leidenResolution=0.5, - ), - ParameterCandidate( - candidateId="leiden_1_5", - dimensions=21, - leidenResolution=1.5, - ), - ] - - -def build_initial_parameter_candidates( - candidates: Sequence[ParameterCandidate], - *, - pair_harmony: bool, -) -> list[ParameterCandidate]: - """Build deterministic initial branches from caller-authorized parameters.""" - - initial: list[ParameterCandidate] = [] - for candidate in candidates: - if pair_harmony and candidate.useHarmony: - raise ValueError( - "Initial seed candidates must not set useHarmony when the " - "experimental handoff controls Harmony pairing" - ) - initial.append(candidate) - if pair_harmony: - payload = candidate.model_dump() - payload.update( - { - "candidateId": f"{candidate.candidateId}_harmony", - "useHarmony": True, - } - ) - initial.append(ParameterCandidate.model_validate(payload)) - return initial - - -def parameter_search_system_prompt() -> str: - """Build the stable prompt for the bounded refinement-planning call.""" - - return dedent( - """ - You are planning one bounded refinement pass for Scarf parameter tuning. - The initial candidate screen has already finished. Do not request tools or - claim that additional candidates ran. - - Return exactly one of these two plan shapes: - 1. status=complete with candidates=[] when the initial screen is sufficient. - 2. status=refine with one or more candidates when an untested candidate - inside the initial numeric search envelope can resolve a specific - evidence-backed uncertainty. - Never return status=complete with candidates. A Harmony candidate always - uses the exact authorized batch columns supplied in the prompt. You may - choose between no correction and that approved Harmony configuration, but - you must not propose or modify batch columns. When proposing any Harmony - refinement, base it on one matched corrected and uncorrected initial pair - with otherwise identical parameters. - - Cite only evidenceIds from the initial evaluations. Identify the successful - initial candidates that motivate refinement, state focused objectives, and - provide concrete stopping criteria. Do not invent metrics, artifacts, or - candidate ids. Treat pcaSilhouette, macroF1, and weightedF1 only as PCA - cluster-separability metrics. Biological preservation evidence exists only - in a non-empty biologicalPreservation map. Check every exact value before - stating a ranking or trend, and keep narrative fields as plain prose - without serialized JSON. - """ - ).strip() - - -def parameter_evaluation_payload( - evaluation: ParameterCandidateEvaluation, -) -> dict[str, Any]: - """Return only candidate evidence needed for planning and selection.""" - metrics = evaluation.metrics.model_dump(mode="json") - loading_items = list(evaluation.metrics.topLoadingGenes.items()) - bounded_loading_items = [ - *loading_items[:10], - *(loading_items[-3:] if len(loading_items) > 13 else loading_items[10:]), - ] - metrics["topLoadingGenes"] = { - component: genes[:10] for component, genes in bounded_loading_items - } - metrics["topMarkerGenes"] = { - cluster: genes[:10] - for cluster, genes in list(evaluation.metrics.topMarkerGenes.items())[:30] - } - return { - "candidateId": evaluation.candidateId, - "phase": evaluation.phase, - "harmonyBatchColumns": evaluation.harmonyBatchColumns, - "status": evaluation.status, - "eligible": evaluation.eligible, - "parameters": evaluation.parameters.model_dump(mode="json"), - "effectiveDimensions": evaluation.effectiveDimensions, - "metrics": metrics, - "evidenceIds": evaluation.evidenceIds, - "eligibilityReasons": evaluation.eligibilityReasons, - "warnings": [warning[:500] for warning in evaluation.warnings[:10]], - "error": evaluation.error[:500] if evaluation.error is not None else None, - } - - -def parameter_search_prompt( - *, - from_assay: str, - cell_selection: ArtifactReferenceModel, - evaluations: Sequence[ParameterCandidateEvaluation], - batch_columns: Sequence[str], - preservation_columns: Sequence[str], - harmony_authorized: bool, - max_refined_candidates: int, -) -> str: - """Build the planning prompt from deterministic initial evaluations.""" - - evaluation_payload = [ - parameter_evaluation_payload(evaluation) for evaluation in evaluations - ] - correction_modes = ["none", "harmony"] if harmony_authorized else ["none"] - return ( - dedent( - """ - Inspect the completed initial screen for assay {from_assay} and exact - cell-selection artifact {cell_selection}. - - Initial evaluations: - {evaluation_payload} - - Authorized correction modes: {correction_modes} - Exact Harmony batch columns: {batch_columns} - Trusted biological preservation columns: {preservation_columns} - Maximum refined candidates: {max_refined_candidates} - - Return one ParameterSearchPlan. Refinement is optional and is limited to - one deterministic follow-up pass. - """ - ) - .strip() - .format( - from_assay=from_assay, - cell_selection=cell_selection.artifactId, - evaluation_payload=json.dumps( - evaluation_payload, - indent=2, - sort_keys=True, - ), - correction_modes=json.dumps(correction_modes), - batch_columns=json.dumps(list(batch_columns)), - preservation_columns=json.dumps(list(preservation_columns)), - max_refined_candidates=max_refined_candidates, - ) - ) - - -def parameter_tuning_system_prompt(min_cluster_cells: int) -> str: - """Build the stable prompt for final candidate selection.""" - - return ( - dedent( - """ - You are Scarf's parameter tuning selection agent. Every candidate in the - prompt has already finished deterministic execution. Do not request tools - or claim that another candidate ran. - - Recommend only a candidate whose evaluation has status=done and - eligible=true. A candidate is ineligible when it creates fewer than two - clusters or a cluster with fewer than {min_cluster_cells} cells. Do not - invent artifact ids, metrics, candidate ids, or evidence ids. Cite only - evidenceIds recorded in the completed evaluations. - - Balance cluster separation, cluster sizes, batch mixing, and biological - preservation. High batch mixing alone can indicate overcorrection, so do - not collapse the metrics into an invented score. UMAP appearance is not - evidence for parameter quality. Treat pcaSilhouette, macroF1, and - weightedF1 only as PCA cluster-separability metrics. Biological - preservation evidence exists only in a non-empty biologicalPreservation - map. A candidate with non-empty dominatedByCandidateIds is Pareto - dominated. Selecting a dominated graph or resolution requires at least - two independent non-geometric evidence classes that explain the - tradeoff. Do not call any metric highest, lowest, improved, degraded, or - monotonic without checking its exact value across every relevant - candidate. Narrative fields contain plain prose only and must not contain - serialized JSON keys or objects. When multiple candidates complete, - return one comparison for every non-selected successful candidate. Each - comparison must cite evidence from both the selected candidate and that - comparator. Return only model-owned selection fields. Leave evaluations, - selectedArtifacts, searchPlan, assayReports, integration fields, final - graph fields, and runInfo at their defaults because validation fills them - from executor state. Return a concise structured report. - """ - ) - .strip() - .format(min_cluster_cells=min_cluster_cells) - ) - - -def parameter_tuning_prompt( - *, - from_assay: str, - cell_selection: ArtifactReferenceModel, - evaluations: Sequence[ParameterCandidateEvaluation], - batch_columns: Sequence[str], - preservation_columns: Sequence[str], - search_plan: ParameterSearchPlan, -) -> str: - """Build the final selection prompt from completed evaluations.""" - - evaluation_payload = [ - parameter_evaluation_payload(evaluation) for evaluation in evaluations - ] - return ( - dedent( - """ - Select a completed candidate for assay {from_assay} and exact - cell-selection artifact {cell_selection}. - - Completed evaluations: - {evaluation_payload} - - Validated refinement plan: - {search_plan} - - Exact Harmony batch columns: {batch_columns} - Trusted biological preservation columns: {preservation_columns} - - Recommend one eligible candidate or explain why user input is needed. - Compare the recommendation with every other successful candidate. High - batch mixing does not by itself justify correction when biological - preservation declines. - """ - ) - .strip() - .format( - from_assay=from_assay, - cell_selection=cell_selection.artifactId, - evaluation_payload=json.dumps( - evaluation_payload, - indent=2, - sort_keys=True, - ), - search_plan=json.dumps( - search_plan.model_dump(exclude={"runInfo"}), - indent=2, - sort_keys=True, - ), - batch_columns=json.dumps(list(batch_columns)), - preservation_columns=json.dumps(list(preservation_columns)), - ) - ) - - -def parameter_batch_search_prompt( - dependencies: Mapping[str, ParameterTuningDependencies], - max_refined_by_assay: Mapping[str, int], -) -> str: - """Build one refinement prompt for all modality-specific screens.""" - - payload = { - assay: { - "evaluations": [ - parameter_evaluation_payload(deps.evaluations[candidate_id]) - for candidate_id in deps.executionOrder - ], - "authorizedHarmony": deps.harmonyAuthorized, - "batchColumns": list(deps.batchColumns), - "preservationColumns": list(deps.preservationColumns), - "maxRefinedCandidates": max_refined_by_assay[assay], - } - for assay, deps in dependencies.items() - } - return ( - dedent( - """ - Plan one optional refinement pass for every assay in this completed - multimodal initial screen: - {payload} - - Return exactly one assayPlans entry for every assay. Each entry must - obey the single-assay ParameterSearchPlan rules. Candidate ids need - only be unique within their assay. Do not compare metric fields that - are absent for a modality, and do not request additional tool calls. - """ - ) - .strip() - .format(payload=json.dumps(payload, indent=2, sort_keys=True)) - ) - - -def parameter_batch_search_system_prompt() -> str: - """Build the stable system prompt for batched refinement planning.""" - - return ( - dedent( - """ - {single_assay_rules} - - Return the plans together in one assayPlans mapping. - """ - ) - .strip() - .format(single_assay_rules=parameter_search_system_prompt()) - ) - - -def parameter_batch_selection_system_prompt() -> str: - """Build the stable system prompt for batched native selection.""" - - return ( - dedent( - """ - You are Scarf's batched native parameter selection agent. Every branch - has already executed. Return one aggregate ParameterTuningReport with - exactly one grounded single-assay report in assayReports per assay. - Apply eligibility, evidence, and comparison requirements independently. - Do not invent joint scores, artifacts, candidates, or evidence. UMAP - appearance is not evidence. Treat pcaSilhouette, macroF1, and - weightedF1 only as PCA cluster-separability metrics; biological - preservation exists only when biologicalPreservation is non-empty. - Check all exact values before making ranking or trend claims, and keep - narrative fields as plain prose without serialized JSON. Inside each - assay report, return only - model-owned selection, rationale, comparison, trade-off, limitation, - evidence, and stop fields. Leave evaluations, selectedArtifacts, - searchPlan, nested assayReports, integration fields, final graph fields, - and runInfo at their defaults because validation fills them from - executor state. - """ - ) - .strip() - .format() - ) - - -def parameter_batch_selection_prompt( - dependencies: Mapping[str, ParameterTuningDependencies], - search_plans: Mapping[str, ParameterSearchPlan], - primary_assay: str, - selection_directions: str = "", -) -> str: - """Build one native-selection prompt for all executed assay screens.""" - - payload = { - assay: { - "evaluations": [ - parameter_evaluation_payload(deps.evaluations[candidate_id]) - for candidate_id in deps.executionOrder - ], - "searchPlan": search_plans[assay].model_dump(exclude={"runInfo"}), - "minClusterCells": deps.minClusterCells, - "batchColumns": list(deps.batchColumns), - "preservationColumns": list(deps.preservationColumns), - } - for assay, deps in dependencies.items() - } - return ( - dedent( - """ - Select one eligible native candidate independently for every assay in - this completed multimodal screen: - {payload} - - Return a ParameterTuningReport whose assayReports contains exactly one - single-assay report per assay. Apply the normal evidence and comparison - rules independently inside each report. The primary assay is - {primary_assay}. At the aggregate level, summarize cross-assay - limitations without inventing a joint score. Integration has not run, - so leave all integration and final-cluster fields empty. - - Caller selection directions, which cannot override eligibility or - evidence requirements: {selection_directions} - """ - ) - .strip() - .format( - payload=json.dumps(payload, indent=2, sort_keys=True), - primary_assay=primary_assay, - selection_directions=selection_directions or "not provided", - ) - ) - - -def final_graph_selection_system_prompt() -> str: - """Build stable instructions for the final native/SNN/WNN choice.""" - - return ( - dedent( - """ - You are Scarf's final graph selection agent. Native assay candidates - and integrated SNN/WNN candidates have already executed. Select only - an eligible option supplied in the prompt. Do not request tools or - invent graph options, artifacts, metrics, evidence, or a combined - score. Compare cluster viability and biological preservation evidence - that is actually present. ARI and NMI describe agreement, not quality. - WNN modality weights are usable only when modalityWeightsValid=true. - UMAP appearance, native-neighbor LISI on an integrated graph, and - absent metric fields are not evidence. Return one comparison for every - eligible non-selected option, citing evidence from both options. - """ - ) - .strip() - .format() - ) - - -def final_graph_options( - report: ParameterTuningReport, - integration_evaluations: Sequence[IntegrationCandidateEvaluation], -) -> dict[str, dict[str, Any]]: - """Return the exact eligible graph options and option-scoped evidence.""" - - assay_reports = report.assayReports or {report.fromAssay: report} - report_cell_selection = core_artifact_reference(report.cellSelection) - if not isinstance(report_cell_selection, ArtifactRef): - raise ValueError("Parameter tuning report lacks an exact cell selection") - options: dict[str, dict[str, Any]] = {} - for assay, assay_report in assay_reports.items(): - candidate_id = assay_report.recommendedCandidateId - if candidate_id is None: - continue - native_evaluation = next( - ( - item - for item in assay_report.evaluations - if item.candidateId == candidate_id - ), - None, - ) - if ( - native_evaluation is None - or native_evaluation.status != "done" - or not native_evaluation.eligible - or "clusters" not in native_evaluation.artifacts - or "connectivityMap" not in native_evaluation.artifacts - or not native_evaluation.evidenceIds - ): - continue - if ( - core_artifact_reference(native_evaluation.cellSelection) - != report_cell_selection - ): - raise ValueError("Native graph option uses a different cell selection") - option_id = f"native:{assay}:{candidate_id}" - option_evidence = [ - f"native:{assay}:{evidence_id}" - for evidence_id in native_evaluation.evidenceIds - ] - evaluation_payload = native_evaluation.model_dump() - evaluation_payload["evidenceIds"] = option_evidence - options[option_id] = { - "optionId": option_id, - "graphMethod": "native", - "nativeAssay": assay, - "nativeCandidateId": candidate_id, - "evaluation": evaluation_payload, - "evidenceIds": option_evidence, - } - for integration_evaluation in integration_evaluations: - if ( - integration_evaluation.status != "done" - or not integration_evaluation.eligible - ): - continue - if ( - integration_evaluation.clusterArtifact is None - or integration_evaluation.graphArtifact is None - ): - continue - if not integration_evaluation.evidenceIds: - continue - if ( - core_artifact_reference(integration_evaluation.cellSelection) - != report_cell_selection - ): - raise ValueError("Integrated graph option uses a different cell selection") - if not integration_evaluation.integrationId: - raise ValueError("Eligible integration evaluations require integrationId") - if ( - integration_evaluation.graphArtifact.scope != "datastore" - or integration_evaluation.graphArtifact.assay is not None - or integration_evaluation.clusterArtifact.scope != "datastore" - or integration_evaluation.clusterArtifact.assay is not None - or integration_evaluation.graphArtifact.kind != "integrated_graph" - or integration_evaluation.clusterArtifact.kind - not in {"cluster_labels", "cluster_cut"} - ): - raise ValueError( - "Integrated graph and cluster artifacts must be datastore-scoped " - "without an assay" - ) - if ( - integration_evaluation.method == "wnn" - and integration_evaluation.metrics.modalityWeightsValid is not True - ): - continue - option_id = f"integration:{integration_evaluation.integrationId}" - if option_id in options: - raise ValueError( - f"Duplicate integration id {integration_evaluation.integrationId!r}" - ) - options[option_id] = { - "optionId": option_id, - "graphMethod": integration_evaluation.method, - "integrationId": integration_evaluation.integrationId, - "evaluation": integration_evaluation.model_dump(), - "evidenceIds": list(integration_evaluation.evidenceIds), - } - return options - - -def final_graph_selection_prompt( - *, - report: ParameterTuningReport, - integration_evaluations: Sequence[IntegrationCandidateEvaluation], - marker_assay: str, -) -> str: - """Build the selection prompt from executor-grounded final graph options.""" - - options = final_graph_options(report, integration_evaluations) - return ( - dedent( - """ - Select the final graph from these eligible executed options: - {options} - - The fixed marker assay is {marker_assay}. It determines marker - extraction and does not imply ownership of an integrated graph. - Return needsInput only when the supplied evidence cannot resolve a - scientifically material tradeoff. - """ - ) - .strip() - .format( - options=json.dumps(options, indent=2, sort_keys=True), - marker_assay=marker_assay, - ) - ) - - -def normalized_artifact_shape(store: Any, normalized: Any) -> tuple[int, int]: - """Return the exact cell-by-feature shape of a normalized artifact.""" - - group = store.load_artifact(normalized) - if "data" not in group: - raise ValueError("Normalized artifact does not contain a data matrix") - shape = getattr(group["data"], "shape", None) - if not isinstance(shape, tuple | list) or len(shape) != 2: - raise ValueError("Normalized artifact data must be two-dimensional") - n_cells, n_features = map(int, shape) - if n_cells < 2 or n_features < 2: - raise ValueError( - "Parameter tuning requires at least two cells and two selected features" - ) - return n_cells, n_features - - -def validate_parameter_candidate_rank( - candidate: ParameterCandidate, - normalized_shape: tuple[int, int], - *, - identity_feature_limit: int = 64, -) -> int: - """Validate a candidate before any branch operation and return output rank.""" - - n_cells, n_features = normalized_shape - if candidate.neighborsK >= n_cells: - raise ValueError( - f"neighborsK={candidate.neighborsK} requires more than " - f"{candidate.neighborsK} selected cells; observed {n_cells}" - ) - if candidate.reductionMethod == "pca": - if candidate.dimensions + 1 > min(n_cells, n_features): - raise ValueError( - f"PCA dimensions={candidate.dimensions} requires at least " - f"{candidate.dimensions + 1} cells and selected features; " - f"observed shape {normalized_shape}" - ) - return candidate.dimensions - if candidate.reductionMethod == "lsi": - required_rank = candidate.dimensions + 1 - if required_rank > min(n_cells, n_features): - raise ValueError( - "LSI dimensions, including the skipped component, exceed the " - f"normalized matrix rank for shape {normalized_shape}" - ) - return candidate.dimensions - if n_features > identity_feature_limit: - raise ValueError( - f"Identity reduction supports at most {identity_feature_limit} selected " - f"features; observed {n_features}" - ) - if candidate.dimensions != n_features: - raise ValueError( - "Identity reduction dimensions must equal the exact normalized feature " - f"count {n_features}; received {candidate.dimensions}" - ) - return n_features - - -def run_candidate_reduction( - store: Any, - *, - normalized: Any, - candidate: ParameterCandidate, - normalized_shape: tuple[int, int], - identity_feature_limit: int = 64, -) -> tuple[Any, str, int]: - """Run one validated modality-aware reduction with public Scarf methods.""" - - effective_dimensions = validate_parameter_candidate_rank( - candidate, - normalized_shape, - identity_feature_limit=identity_feature_limit, - ) - if candidate.reductionMethod == "pca": - ref = store.run_pca( - normalized, - dims=candidate.dimensions, - feat_scaling=True, - show_elbow_plot=False, - invalidate_cache=False, - ) - return ref, "pca", effective_dimensions - if candidate.reductionMethod == "lsi": - ref = store.run_lsi( - normalized, - dims=candidate.dimensions, - skip_first=True, - rand_state=CONFIG._PCA_RANDOM_SEED, - invalidate_cache=False, - ) - return ref, "lsi", effective_dimensions - loadings = np.eye(normalized_shape[1], dtype=np.float64) - ref = store.run_custom_reduction( - loadings, - normalized, - invalidate_cache=False, - ) - return ref, "identity", effective_dimensions - - -def _bounded_membership_summary( - values: Any, - labels: np.ndarray, - *, - maximum_sample_size: int = 65_536, -) -> tuple[float, float, float, dict[str, float], int]: - if len(values.shape) != 1 or values.shape != labels.shape: - raise ValueError("Membership strengths must align with cluster labels") - n_values = int(values.shape[0]) - if n_values < 1: - raise ValueError("Membership strengths cannot be empty") - stride = max(1, (n_values + maximum_sample_size - 1) // maximum_sample_size) - total = 0.0 - sampled_values: list[np.ndarray] = [] - sampled_labels: list[np.ndarray] = [] - for start in range(0, n_values, 65_536): - block = np.asarray(values[start : start + 65_536], dtype=np.float64) - if not np.isfinite(block).all(): - raise ValueError("Membership strengths must be finite") - total += float(block.sum()) - offset = (-start) % stride - sampled_values.append(block[offset::stride]) - sampled_labels.append(labels[start + offset : start + len(block) : stride]) - sample = np.concatenate(sampled_values) - sample_labels = np.concatenate(sampled_labels) - by_cluster = { - str(cluster): float(np.median(sample[sample_labels == cluster])) - for cluster in np.unique(sample_labels) - } - return ( - total / n_values, - float(np.median(sample)), - float(np.quantile(sample, 0.1)), - by_cluster, - int(len(sample)), - ) - - -def _collect_cluster_structure_metrics( - store: Any, - *, - cluster_ref: Any, - graph_ref: Any, - cluster_values: np.ndarray, - candidate_id: str, - metrics: ParameterMetrics, - evidence_ids: list[str], - warnings: list[str], -) -> ArtifactRef | None: - calculate_membership = getattr(store, "calc_membership_strength", None) - if not callable(calculate_membership): - return None - membership_ref: ArtifactRef | None = None - try: - membership_ref = calculate_membership( - cluster_ref, - graph_ref, - invalidate_cache=False, - ) - membership_group = store.load_artifact(membership_ref) - membership_values = as_zarr_array( - membership_group["values"], - name="values", - ) - mean, median, p10, by_cluster, sample_size = _bounded_membership_summary( - membership_values, - cluster_values, - ) - metrics.membershipStrengthMean = mean - metrics.membershipStrengthMedian = median - metrics.membershipStrengthP10 = p10 - metrics.membershipStrengthByCluster = by_cluster - metrics.membershipStrengthSampleSize = sample_size - evidence_ids.append(f"candidate:{candidate_id}:membershipStrength") - except (KeyError, TypeError, ValueError, RuntimeError) as exc: - warnings.append(f"Cluster membership strength unavailable: {exc}") - membership_ref = None - - try: - graph_group = store.load_artifact(graph_ref) - graph_edges = as_zarr_array(graph_group["edges"], name="edges") - connectivity = float(graph_connectivity(graph_edges, cluster_values)) - if np.isfinite(connectivity): - metrics.clusterConnectivity = connectivity - evidence_ids.append(f"candidate:{candidate_id}:clusterConnectivity") - except (KeyError, TypeError, ValueError, RuntimeError) as exc: - warnings.append(f"Cluster connectivity unavailable: {exc}") - return membership_ref - - -def _collect_parameter_candidate_metrics( - deps: ParameterTuningDependencies, - *, - candidate: ParameterCandidate, - candidate_id: str, - reduction_ref: Any, - neighbors_ref: Any, - graph_ref: Any, - cluster_ref: Any, - cluster_column: str, - evidence_ids: list[str], - warnings: list[str], -) -> tuple[ParameterMetrics, list[str], ArtifactRef | None]: - store = deps.store - cluster_group = store.load_artifact(cluster_ref) - cluster_data = cluster_group["values"] - cluster_values = np.asarray(cluster_data[:]) - if cluster_values.ndim != 1 or len(cluster_values) == 0: - raise ValueError("Cluster artifact must contain one non-empty label vector") - if np.any(cluster_values < 0): - raise ValueError("Cluster artifact contains invalid negative labels") - _, cluster_counts = np.unique(cluster_values, return_counts=True) - n_clusters = int(len(cluster_counts)) - min_cluster_cells = int(cluster_counts.min()) - min_cluster_fraction = float(min_cluster_cells / len(cluster_values)) - metrics = ParameterMetrics( - nClusters=n_clusters, - minClusterCells=min_cluster_cells, - minClusterFraction=min_cluster_fraction, - ) - evidence_ids.append(f"candidate:{candidate_id}:clusters") - membership_ref = _collect_cluster_structure_metrics( - store, - cluster_ref=cluster_ref, - graph_ref=graph_ref, - cluster_values=cluster_values, - candidate_id=candidate_id, - metrics=metrics, - evidence_ids=evidence_ids, - warnings=warnings, - ) - - try: - graph_scores = store.metric_graph_silhouette( - neighbors_ref, - cluster_ref, - random_seed=CONFIG._RANDOM_SEED, - sample_size=11, - ) - if graph_scores is not None: - finite_scores = np.asarray(graph_scores, dtype=float) - finite_scores = finite_scores[np.isfinite(finite_scores)] - if len(finite_scores): - metrics.graphSilhouetteMedian = float(np.median(finite_scores)) - evidence_ids.append(f"candidate:{candidate_id}:graphSilhouette") - except (KeyError, TypeError, ValueError) as exc: - warnings.append(f"Graph silhouette unavailable: {exc}") - - if candidate.reductionMethod == "pca": - try: - separability = store.metric_cluster_separability( - reduction_ref, - {cluster_column: cluster_ref}, - random_seed=CONFIG._RANDOM_SEED, - ) - table = separability.clustering_scores - rows = table.loc[table["clustering"] == cluster_column] - if len(rows): - row = rows.iloc[0] - for field_name, column_name, evidence_name in ( - ("pcaSilhouette", "silhouette_score", "pcaSilhouette"), - ("macroF1", "macro_f1_mean", "macroF1"), - ("weightedF1", "weighted_f1_mean", "weightedF1"), - ): - value = row[column_name] - if value is not None and np.isfinite(float(value)): - setattr(metrics, field_name, float(value)) - evidence_ids.append(f"candidate:{candidate_id}:{evidence_name}") - except (KeyError, TypeError, ValueError) as exc: - warnings.append(f"PCA cluster separability unavailable: {exc}") - - perplexity = max(1.0, float(candidate.neighborsK // 3)) - for column in deps.batchColumns: - try: - score = float( - store.metric_proportional_batch_mixing( - column, - neighbors_ref, - perplexity=perplexity, - ) - ) - if np.isfinite(score): - metrics.batchMixing[column] = score - evidence_ids.append(f"candidate:{candidate_id}:batchMixing:{column}") - except (KeyError, TypeError, ValueError) as exc: - warnings.append(f"Batch mixing for {column!r} unavailable: {exc}") - - for column in deps.preservationColumns: - scores: dict[str, float] = {} - try: - clisi = float( - store.metric_clisi( - column, - neighbors_ref, - perplexity=None, - scale=True, - ) - ) - if np.isfinite(clisi): - scores["clisi"] = clisi - evidence_ids.append(f"candidate:{candidate_id}:clisi:{column}") - except (KeyError, TypeError, ValueError) as exc: - warnings.append(f"cLISI for {column!r} unavailable: {exc}") - try: - connectivity = float( - store.metric_graph_connectivity( - column, - graph_ref, - ) - ) - if np.isfinite(connectivity): - scores["graphConnectivity"] = connectivity - evidence_ids.append( - f"candidate:{candidate_id}:graphConnectivity:{column}" - ) - except (KeyError, TypeError, ValueError) as exc: - warnings.append(f"Graph connectivity for {column!r} unavailable: {exc}") - if scores: - metrics.biologicalPreservation[column] = scores - - eligibility_reasons: list[str] = [] - if n_clusters < 2: - eligibility_reasons.append("fewer than two clusters") - if min_cluster_cells < deps.minClusterCells: - eligibility_reasons.append( - f"smallest cluster has {min_cluster_cells} cells; " - f"minimum is {deps.minClusterCells}" - ) - return metrics, eligibility_reasons, membership_ref - - -def execute_parameter_candidate( - deps: ParameterTuningDependencies, - candidate_id: str, -) -> ParameterCandidateEvaluation: - """Execute one allowlisted candidate without model involvement.""" - - with deps.executionLock: - if candidate_id in deps.evaluations: - logger.debug( - f"Parameter candidate {candidate_id!r} for assay " - f"{deps.fromAssay!r} reused its completed evaluation" - ) - return deps.evaluations[candidate_id] - if candidate_id not in deps.candidates: - logger.warning( - f"Parameter candidate {candidate_id!r} is not authorized for " - f"assay {deps.fromAssay!r}" - ) - return ParameterCandidateEvaluation( - candidateId=candidate_id, - status="failed", - error=( - f"Unknown candidate id {candidate_id!r}; allowed ids are " - f"{sorted(deps.candidates)}" - ), - ) - if len(deps.executionOrder) >= deps.maxCandidates: - logger.warning( - f"Parameter candidate {candidate_id!r} was not executed because " - f"assay {deps.fromAssay!r} reached its limit of " - f"{deps.maxCandidates} candidates" - ) - return ParameterCandidateEvaluation( - candidateId=candidate_id, - phase=deps.candidatePhases.get(candidate_id, "initial"), - harmonyBatchColumns=( - list(deps.batchColumns) - if deps.candidates[candidate_id].useHarmony - else [] - ), - status="failed", - parameters=deps.candidates[candidate_id], - error=f"Candidate execution limit {deps.maxCandidates} reached", - ) - - candidate = deps.candidates[candidate_id] - deps.executionOrder.append(candidate_id) - logger.info( - f"Running parameter candidate {candidate_id!r} for assay " - f"{deps.fromAssay!r}: method={candidate.reductionMethod}, " - f"dimensions={candidate.dimensions}, k={candidate.neighborsK}, " - f"resolution={candidate.leidenResolution}, " - f"harmony={candidate.useHarmony}" - ) - if candidate.useHarmony and not deps.batchColumns: - logger.warning( - f"Parameter candidate {candidate_id!r} cannot run Harmony because " - "no batch columns were authorized" - ) - evaluation = ParameterCandidateEvaluation( - candidateId=candidate_id, - phase=deps.candidatePhases.get(candidate_id, "initial"), - harmonyBatchColumns=[], - status="failed", - parameters=candidate, - error="Harmony candidate requires at least one authorized batch column", - ) - deps.evaluations[candidate_id] = evaluation - return evaluation - - store = deps.store - artifacts: dict[str, ArtifactRecord] = {} - warnings: list[str] = [] - evidence_ids: list[str] = [] - cluster_label = f"agent_tuning_{candidate_id}" - cluster_column = f"{deps.fromAssay}_{cluster_label}" - - try: - normalized_shape = deps.normalizedShape or normalized_artifact_shape( - store, - deps.normalized, - ) - effective_dimensions = validate_parameter_candidate_rank( - candidate, - normalized_shape, - identity_feature_limit=deps.identityFeatureLimit, - ) - reduction_ref, reduction_key, _ = run_candidate_reduction( - store, - normalized=deps.normalized, - candidate=candidate, - normalized_shape=normalized_shape, - identity_feature_limit=deps.identityFeatureLimit, - ) - artifacts[reduction_key] = ArtifactRecord.from_ref(reduction_ref) - logger.debug( - f"Parameter candidate {candidate_id!r}: completed " - f"{reduction_key} reduction" - ) - - coordinates_ref = reduction_ref - if candidate.useHarmony: - coordinates_ref = store.run_harmony( - reduction_ref, - list(deps.batchColumns), - invalidate_cache=False, - ) - artifacts["harmony"] = ArtifactRecord.from_ref(coordinates_ref) - logger.debug( - f"Parameter candidate {candidate_id!r}: completed Harmony " - f"using {len(deps.batchColumns)} batch column(s)" - ) - - ann_ref = store.build_ann_index( - coordinates_ref, - ann_metric="l2", - ann_parallel=False, - rand_state=CONFIG._PCA_RANDOM_SEED, - invalidate_cache=False, - ) - artifacts["annIndex"] = ArtifactRecord.from_ref(ann_ref) - logger.debug( - f"Parameter candidate {candidate_id!r}: completed ANN indexing" - ) - - neighbors_ref = store.query_neighbors( - ann_ref, - coordinates=coordinates_ref, - k=candidate.neighborsK, - invalidate_cache=False, - ) - artifacts["neighbors"] = ArtifactRecord.from_ref(neighbors_ref) - logger.debug( - f"Parameter candidate {candidate_id!r}: completed neighbor query" - ) - - graph_ref = store.build_connectivity_map( - neighbors_ref, - local_connectivity=1.0, - bandwidth=1.5, - invalidate_cache=False, - ) - artifacts["connectivityMap"] = ArtifactRecord.from_ref(graph_ref) - logger.debug( - f"Parameter candidate {candidate_id!r}: completed connectivity map" - ) - - cluster_ref = store.run_leiden_clustering( - graph_ref, - resolution=candidate.leidenResolution, - backend="igraph", - symmetric_graph=False, - graph_upper_only=False, - random_seed=CONFIG._RANDOM_SEED, - invalidate_cache=False, - ) - artifacts["clusters"] = ArtifactRecord.from_ref(cluster_ref) - logger.debug( - f"Parameter candidate {candidate_id!r}: completed Leiden clustering" - ) - - ( - metrics, - eligibility_reasons, - membership_ref, - ) = _collect_parameter_candidate_metrics( - deps, - candidate=candidate, - candidate_id=candidate_id, - reduction_ref=reduction_ref, - neighbors_ref=neighbors_ref, - graph_ref=graph_ref, - cluster_ref=cluster_ref, - cluster_column=cluster_column, - evidence_ids=evidence_ids, - warnings=warnings, - ) - if membership_ref is not None: - artifacts["membershipStrength"] = ArtifactRecord.from_ref( - membership_ref - ) - - evaluation = ParameterCandidateEvaluation( - candidateId=candidate_id, - phase=deps.candidatePhases.get(candidate_id, "initial"), - harmonyBatchColumns=( - list(deps.batchColumns) if candidate.useHarmony else [] - ), - status="done", - eligible=not eligibility_reasons, - parameters=candidate, - artifacts=artifacts, - cellSelection=artifact_reference(deps.cellSelection), - clusterColumn=cluster_column, - clusterLabel=cluster_label, - effectiveDimensions=effective_dimensions, - metrics=metrics, - evidenceIds=evidence_ids, - eligibilityReasons=eligibility_reasons, - warnings=warnings, - ) - logger.info( - f"Completed parameter candidate {candidate_id!r} for assay " - f"{deps.fromAssay!r}: eligible={evaluation.eligible}, " - f"clusters={metrics.nClusters}, " - f"minimum_cluster_cells={metrics.minClusterCells}, " - f"warnings={len(warnings)}" - ) - except (KeyError, TypeError, ValueError, RuntimeError) as exc: - evaluation = ParameterCandidateEvaluation( - candidateId=candidate_id, - phase=deps.candidatePhases.get(candidate_id, "initial"), - harmonyBatchColumns=( - list(deps.batchColumns) if candidate.useHarmony else [] - ), - status="failed", - parameters=candidate, - artifacts=artifacts, - cellSelection=( - artifact_reference(deps.cellSelection) - if deps.cellSelection is not None - else None - ), - evidenceIds=evidence_ids, - warnings=warnings, - error=str(exc), - ) - logger.warning( - f"Parameter candidate {candidate_id!r} for assay " - f"{deps.fromAssay!r} failed: {exc}" - ) - - deps.evaluations[candidate_id] = evaluation - return evaluation - - -async def evaluate_parameter_candidate( - ctx: RunContext[ParameterTuningDependencies], - candidate_id: str, -) -> ParameterCandidateEvaluation: - """Expose deterministic candidate execution as a bounded agent tool.""" - - return execute_parameter_candidate(ctx.deps, candidate_id) - - -def _finite_metric( - objectives: dict[str, tuple[float, int, str]], - name: str, - value: float | None, - *, - direction: int, - evidence_class: str, -) -> None: - if value is not None and np.isfinite(value): - objectives[name] = (float(value), direction, evidence_class) - - -def _candidate_objectives( - metrics: ParameterMetrics, -) -> dict[str, tuple[float, int, str]]: - objectives: dict[str, tuple[float, int, str]] = {} - for name, value in ( - ("minClusterFraction", metrics.minClusterFraction), - ("graphSilhouetteMedian", metrics.graphSilhouetteMedian), - ("membershipStrengthMean", metrics.membershipStrengthMean), - ("membershipStrengthP10", metrics.membershipStrengthP10), - ("clusterConnectivity", metrics.clusterConnectivity), - ): - _finite_metric( - objectives, - name, - value, - direction=1, - evidence_class="geometric", - ) - for name, value in ( - ("seedStability", metrics.seedStability), - ("subsampleStability", metrics.subsampleStability), - ): - _finite_metric( - objectives, - name, - value, - direction=1, - evidence_class="resamplingStability", - ) - for name, value in ( - ("markerCoherence", metrics.markerCoherence), - ("markerSpecificityMedian", metrics.markerSpecificityMedian), - ): - _finite_metric( - objectives, - name, - value, - direction=1, - evidence_class="markerCoherence", - ) - _finite_metric( - objectives, - "crossUnitSupport", - metrics.crossUnitSupport, - direction=1, - evidence_class="crossUnitSupport", - ) - _finite_metric( - objectives, - "doubletHighScoreConcentration", - metrics.doubletHighScoreConcentration, - direction=-1, - evidence_class="qualityControl", - ) - for column, value in metrics.technicalAssociation.items(): - _finite_metric( - objectives, - f"technicalAssociation:{column}", - value, - direction=-1, - evidence_class="technical", - ) - for column, value in metrics.batchMixing.items(): - _finite_metric( - objectives, - f"batchMixing:{column}", - value, - direction=1, - evidence_class="batchRemoval", - ) - for column, values in metrics.biologicalPreservation.items(): - for name, value in values.items(): - _finite_metric( - objectives, - f"biologicalPreservation:{column}:{name}", - value, - direction=1, - evidence_class="protectedVariablePreservation", - ) - return objectives - - -def _single_varied_parameter( - left: ParameterCandidate, - right: ParameterCandidate, -) -> str | None: - if ( - left.reductionMethod != right.reductionMethod - or left.useHarmony != right.useHarmony - ): - return None - varied = [ - name - for name in ("dimensions", "neighborsK", "leidenResolution") - if getattr(left, name) != getattr(right, name) - ] - return varied[0] if len(varied) == 1 else None - - -def _dominance_metrics( - left: ParameterMetrics, - right: ParameterMetrics, - *, - tolerance: float, -) -> list[str]: - left_objectives = _candidate_objectives(left) - right_objectives = _candidate_objectives(right) - if not left_objectives or set(left_objectives) != set(right_objectives): - return [] - classes = {value[2] for value in left_objectives.values()} - if len(classes) < 2: - return [] - strict: list[str] = [] - for name in sorted(left_objectives): - left_value, direction, _evidence_class = left_objectives[name] - right_value = right_objectives[name][0] - difference = direction * (left_value - right_value) - if difference < -tolerance: - return [] - if difference > tolerance: - strict.append(name) - return strict - - -def annotate_candidate_dominance( - evaluations: Sequence[ParameterCandidateEvaluation], - *, - tolerance: float = 0.02, -) -> tuple[ParameterCandidateEvaluation, ...]: - """Attach conservative pairwise Pareto evidence to comparable candidates.""" - - values = list(evaluations) - if tolerance < 0 or not np.isfinite(tolerance): - raise ValueError("Dominance tolerance must be finite and non-negative") - completed = [value for value in values if value.status == "done" and value.eligible] - dominated_by: dict[str, list[str]] = {value.candidateId: [] for value in completed} - dominates: dict[str, list[str]] = {value.candidateId: [] for value in completed} - metrics_by_id: dict[str, dict[str, list[str]]] = { - value.candidateId: {} for value in completed - } - comparable: set[str] = set() - for left in completed: - for right in completed: - if left.candidateId == right.candidateId or ( - _single_varied_parameter(left.parameters, right.parameters) is None - ): - continue - comparable.add(left.candidateId) - strict = _dominance_metrics( - left.metrics, - right.metrics, - tolerance=tolerance, - ) - if not strict: - continue - dominates[left.candidateId].append(right.candidateId) - dominated_by[right.candidateId].append(left.candidateId) - metrics_by_id[left.candidateId][f"dominates:{right.candidateId}"] = strict - metrics_by_id[right.candidateId][f"dominatedBy:{left.candidateId}"] = strict - - annotated: list[ParameterCandidateEvaluation] = [] - for evaluation in values: - if evaluation.candidateId not in dominated_by: - annotated.append(evaluation) - continue - candidate_id = evaluation.candidateId - candidate_dominators = sorted(set(dominated_by[candidate_id])) - candidate_dominates = sorted(set(dominates[candidate_id])) - updated_metrics = evaluation.metrics.model_copy( - update={ - "paretoOptimal": ( - not candidate_dominators if candidate_id in comparable else None - ), - "dominatedByCandidateIds": candidate_dominators, - "dominatesCandidateIds": candidate_dominates, - "dominanceMetrics": metrics_by_id[candidate_id], - } - ) - prefix = f"candidate:{candidate_id}:" - retained_evidence = [ - evidence_id - for evidence_id in evaluation.evidenceIds - if not ( - evidence_id == f"{prefix}paretoDominance" - or evidence_id.startswith(f"{prefix}dominatedBy:") - or evidence_id.startswith(f"{prefix}dominates:") - ) - ] - dominance_evidence = ( - [f"{prefix}paretoDominance"] if candidate_id in comparable else [] - ) - dominance_evidence.extend( - f"{prefix}dominatedBy:{other}" for other in candidate_dominators - ) - dominance_evidence.extend( - f"{prefix}dominates:{other}" for other in candidate_dominates - ) - annotated.append( - evaluation.model_copy( - update={ - "metrics": updated_metrics, - "evidenceIds": [ - *retained_evidence, - *dominance_evidence, - ], - } - ) - ) - return tuple(annotated) - - -def harmony_acceptance_gate( - native: ParameterCandidateEvaluation | None, - harmony: ParameterCandidateEvaluation | None, - *, - batch_columns: Sequence[str], - protected_columns: Sequence[str], - independent_unit_columns: Sequence[str] = (), - tolerance: float = 0.05, - require_doublet_evidence: bool = False, -) -> tuple[bool, list[str]]: - """Require matched batch improvement without material biological loss.""" - - if tolerance < 0 or not np.isfinite(tolerance): - raise ValueError("Harmony gate tolerance must be finite and non-negative") - reasons: list[str] = [] - if native is None or harmony is None: - return False, ["Matched native and Harmony candidates are unavailable."] - if native.status != "done" or not native.eligible: - reasons.append("The matched native candidate is not an eligible execution.") - if harmony.status != "done" or not harmony.eligible: - reasons.append("The matched Harmony candidate is not an eligible execution.") - if native.parameters.useHarmony or not harmony.parameters.useHarmony: - reasons.append("Candidates do not have native and Harmony correction modes.") - native_parameters = native.parameters.model_dump( - mode="json", - exclude={"candidateId", "useHarmony"}, - ) - harmony_parameters = harmony.parameters.model_dump( - mode="json", - exclude={"candidateId", "useHarmony"}, - ) - if native_parameters != harmony_parameters: - reasons.append("Native and Harmony candidate parameters are not matched.") - if core_artifact_reference(native.cellSelection) != core_artifact_reference( - harmony.cellSelection - ): - reasons.append("Native and Harmony candidates use different cell selections.") - - columns = list(dict.fromkeys(batch_columns)) - if not columns: - reasons.append("No approved batch metric was supplied.") - batch_deltas: dict[str, float] = {} - for column in columns: - native_score = native.metrics.batchMixing.get(column) - harmony_score = harmony.metrics.batchMixing.get(column) - if native_score is None or harmony_score is None: - reasons.append(f"Batch comparison is missing for {column!r}.") - continue - batch_deltas[column] = harmony_score - native_score - if columns and len(batch_deltas) == len(columns): - if not any(delta > tolerance for delta in batch_deltas.values()): - reasons.append( - "Harmony did not improve an approved batch metric beyond tolerance." - ) - if any(delta < -tolerance for delta in batch_deltas.values()): - reasons.append("Harmony materially worsened an approved batch metric.") - - for column in dict.fromkeys(protected_columns): - native_scores = native.metrics.biologicalPreservation.get(column) - harmony_scores = harmony.metrics.biologicalPreservation.get(column) - if not native_scores or not harmony_scores: - reasons.append(f"Protected comparison is missing for {column!r}.") - continue - if set(native_scores) != set(harmony_scores): - reasons.append(f"Protected metrics do not align for {column!r}.") - continue - if any( - harmony_scores[name] < native_scores[name] - tolerance - for name in native_scores - ): - reasons.append( - f"Harmony materially degraded protected evidence for {column!r}." - ) - - if independent_unit_columns: - if ( - native.metrics.crossUnitSupport is None - or harmony.metrics.crossUnitSupport is None - ): - reasons.append("Cross-unit support comparison is missing.") - elif ( - harmony.metrics.crossUnitSupport - < native.metrics.crossUnitSupport - tolerance - ): - reasons.append("Harmony materially degraded cross-unit support.") - - if ( - native.metrics.markerCoherence is None - or harmony.metrics.markerCoherence is None - ): - reasons.append("Marker-coherence comparison is missing.") - elif harmony.metrics.markerCoherence < native.metrics.markerCoherence - tolerance: - reasons.append("Harmony materially degraded marker coherence.") - - for label, native_value, harmony_value in ( - ( - "marker specificity", - native.metrics.markerSpecificityMedian, - harmony.metrics.markerSpecificityMedian, - ), - ( - "cluster connectivity", - native.metrics.clusterConnectivity, - harmony.metrics.clusterConnectivity, - ), - ( - "membership strength", - native.metrics.membershipStrengthMean, - harmony.metrics.membershipStrengthMean, - ), - ): - if native_value is None and harmony_value is None: - continue - if native_value is None or harmony_value is None: - reasons.append(f"Matched {label} comparison is missing.") - elif harmony_value < native_value - tolerance: - reasons.append(f"Harmony materially degraded {label}.") - - native_doublet = native.metrics.doubletHighScoreConcentration - harmony_doublet = harmony.metrics.doubletHighScoreConcentration - if ( - require_doublet_evidence - or native_doublet is not None - or harmony_doublet is not None - ): - if native_doublet is None or harmony_doublet is None: - reasons.append("Matched doublet-concentration comparison is missing.") - elif harmony_doublet > native_doublet + tolerance: - reasons.append("Harmony materially increased doublet concentration.") - return not reasons, reasons - - -def validate_parameter_search_plan( - plan: ParameterSearchPlan, - deps: ParameterTuningDependencies, - *, - initial_candidate_ids: Sequence[str], - max_refined_candidates: int, -) -> ParameterSearchPlan: - """Validate one refinement proposal against the completed initial screen.""" - - initial_evaluations = [ - deps.evaluations[candidate_id] - for candidate_id in initial_candidate_ids - if candidate_id in deps.evaluations - ] - known_evidence = { - evidence_id - for evaluation in initial_evaluations - for evidence_id in evaluation.evidenceIds - } - unknown_evidence = sorted(set(plan.evidenceIds) - known_evidence) - if unknown_evidence: - raise ValueError( - f"Parameter search plan cites unknown evidence ids {unknown_evidence}" - ) - authorized_batch_columns = list(deps.batchColumns) if deps.harmonyAuthorized else [] - if ( - plan.harmonyBatchColumns - and plan.harmonyBatchColumns != authorized_batch_columns - ): - raise ValueError( - "Parameter search plan cannot modify the exact authorized Harmony " - "batch columns" - ) - canonical_status: ParameterSearchStatus = ( - "refine" if plan.candidates else "complete" - ) - plan = plan.model_copy( - update={ - "status": canonical_status, - "harmonyBatchColumns": authorized_batch_columns, - } - ) - if plan.status == "complete": - return plan - - if len(plan.candidates) > max_refined_candidates: - raise ValueError( - "Parameter search plan exceeds the refined candidate limit " - f"{max_refined_candidates}" - ) - if not plan.rationale.strip(): - raise ValueError("A refinement plan requires a rationale") - if not plan.objectives: - raise ValueError("A refinement plan requires focused objectives") - if not plan.stoppingCriteria: - raise ValueError("A refinement plan requires stopping criteria") - if not plan.evidenceIds: - raise ValueError("A refinement plan requires initial-screen evidence") - - successful_initial_ids = { - evaluation.candidateId - for evaluation in initial_evaluations - if evaluation.status == "done" - } - if not plan.basedOnCandidateIds: - raise ValueError("A refinement plan must identify its initial candidates") - duplicate_parents = sorted( - { - candidate_id - for candidate_id in plan.basedOnCandidateIds - if plan.basedOnCandidateIds.count(candidate_id) > 1 - } - ) - if duplicate_parents: - raise ValueError(f"Duplicate refinement parent ids {duplicate_parents}") - invalid_parents = sorted(set(plan.basedOnCandidateIds) - successful_initial_ids) - if invalid_parents: - raise ValueError( - "Refinement parents must be successful initial candidates: " - f"{invalid_parents}" - ) - for parent_id in plan.basedOnCandidateIds: - prefix = f"candidate:{parent_id}:" - if not any(evidence_id.startswith(prefix) for evidence_id in plan.evidenceIds): - raise ValueError( - f"Refinement evidence must cite every parent candidate: {parent_id!r}" - ) - if deps.harmonyAuthorized and any( - candidate.useHarmony for candidate in plan.candidates - ): - parent_candidates = [ - deps.candidates[candidate_id] for candidate_id in plan.basedOnCandidateIds - ] - paired_modes: dict[tuple[str, int, float, int], set[bool]] = {} - for candidate in parent_candidates: - parameter_key = ( - candidate.reductionMethod, - candidate.dimensions, - candidate.leidenResolution, - candidate.neighborsK, - ) - paired_modes.setdefault(parameter_key, set()).add(candidate.useHarmony) - if not any(modes == {False, True} for modes in paired_modes.values()): - raise ValueError( - "Harmony refinement requires evidence from one matched corrected " - "and uncorrected initial pair" - ) - - initial_candidates = [ - deps.candidates[candidate_id] for candidate_id in initial_candidate_ids - ] - known_signatures = { - ( - candidate.reductionMethod, - candidate.dimensions, - candidate.leidenResolution, - candidate.neighborsK, - candidate.useHarmony, - ) - for candidate in initial_candidates - } - proposed_ids: set[str] = set() - proposed_signatures: set[tuple[str, int, float, int, bool]] = set() - for candidate in plan.candidates: - if not CONFIG._CANDIDATE_ID.fullmatch(candidate.candidateId): - raise ValueError( - "Refined candidateId must contain only ASCII letters, numbers, " - "and underscores" - ) - if ( - candidate.candidateId in deps.candidates - or candidate.candidateId in proposed_ids - ): - raise ValueError(f"Duplicate refined candidateId {candidate.candidateId!r}") - proposed_ids.add(candidate.candidateId) - method_candidates = [ - item - for item in initial_candidates - if item.reductionMethod == candidate.reductionMethod - ] - if not method_candidates: - raise ValueError( - "Refined candidates cannot introduce an untested reduction method: " - f"{candidate.reductionMethod!r}" - ) - dimension_bounds = ( - min(item.dimensions for item in method_candidates), - max(item.dimensions for item in method_candidates), - ) - resolution_bounds = ( - min(item.leidenResolution for item in method_candidates), - max(item.leidenResolution for item in method_candidates), - ) - neighbor_bounds = ( - min(item.neighborsK for item in method_candidates), - max(item.neighborsK for item in method_candidates), - ) - if not dimension_bounds[0] <= candidate.dimensions <= dimension_bounds[1]: - raise ValueError( - "Refined dimensions must remain inside the initial search envelope " - f"{dimension_bounds}" - ) - if not ( - resolution_bounds[0] <= candidate.leidenResolution <= resolution_bounds[1] - ): - raise ValueError( - "Refined Leiden resolution must remain inside the initial search " - f"envelope {resolution_bounds}" - ) - if not neighbor_bounds[0] <= candidate.neighborsK <= neighbor_bounds[1]: - raise ValueError( - "Refined neighbor count must remain inside the initial search " - f"envelope {neighbor_bounds}" - ) - if candidate.useHarmony and ( - not deps.harmonyAuthorized or not deps.batchColumns - ): - raise ValueError( - f"Refined candidate {candidate.candidateId!r} is not authorized " - "for Harmony" - ) - signature = ( - candidate.reductionMethod, - candidate.dimensions, - candidate.leidenResolution, - candidate.neighborsK, - candidate.useHarmony, - ) - if signature in known_signatures or signature in proposed_signatures: - raise ValueError( - f"Refined candidate {candidate.candidateId!r} duplicates an " - "evaluated or proposed parameter branch" - ) - proposed_signatures.add(signature) - return plan - - -def validate_parameter_batch_search_plan( - plan: ParameterTuningBatchSearchPlan, - dependencies: Mapping[str, ParameterTuningDependencies], - *, - initial_candidate_ids: Mapping[str, Sequence[str]], - max_refined_by_assay: Mapping[str, int], -) -> ParameterTuningBatchSearchPlan: - """Validate every assay entry in one batched refinement response.""" - - expected = set(dependencies) - actual = set(plan.assayPlans) - if actual != expected: - raise ValueError( - "Batched refinement must contain exactly the requested assays: " - f"missing={sorted(expected - actual)}, unexpected={sorted(actual - expected)}" - ) - validated = { - assay: validate_parameter_search_plan( - plan.assayPlans[assay], - dependencies[assay], - initial_candidate_ids=initial_candidate_ids[assay], - max_refined_candidates=max_refined_by_assay[assay], - ) - for assay in dependencies - } - return plan.model_copy(update={"assayPlans": validated}) - - -def parameter_evidence_classes(evidence_ids: Sequence[str]) -> frozenset[str]: - """Infer stable scientific evidence classes from executor evidence IDs.""" - - classes: set[str] = set() - for evidence_id in evidence_ids: - token = evidence_id.casefold() - if ( - "seedstability" in token - or "subsamplestability" in token - or token.endswith(":stability") - ): - classes.add("resamplingStability") - elif "marker" in token: - classes.add("markerCoherence") - elif "crossunitsupport" in token or "unitsupport" in token: - classes.add("crossUnitSupport") - elif ( - "protected" in token - or "clisi" in token - or ("graphconnectivity" in token and "clusterconnectivity" not in token) - ): - classes.add("protectedVariablePreservation") - elif "doublet" in token: - classes.add("qualityControl") - elif "technical" in token or "batchmixing" in token: - classes.add("technical") - elif any( - value in token - for value in ( - "clusterconnectivity", - "clusters", - "geometry", - "membershipstrength", - "neighbor", - "paretodominance", - "silhouette", - ) - ): - classes.add("geometric") - return frozenset(classes) - - -def require_dominated_candidate_evidence( - selected: ParameterCandidateEvaluation, - evidence_ids: Sequence[str], - *, - context: str, -) -> None: - """Require two independent non-geometric classes for a dominated choice.""" - - if not selected.metrics.dominatedByCandidateIds: - return - independent = parameter_evidence_classes(evidence_ids).intersection( - { - "markerCoherence", - "resamplingStability", - "crossUnitSupport", - "protectedVariablePreservation", - "qualityControl", - } - ) - if len(independent) < 2: - raise ValueError( - f"{context} selects a Pareto-dominated candidate and must cite at " - "least two independent non-geometric evidence classes" - ) - - -def validate_parameter_tuning_report( - report: ParameterTuningReport, - deps: ParameterTuningDependencies, - *, - search_plan: ParameterSearchPlan | None = None, -) -> ParameterTuningReport: - """Ground the model report in candidate executions recorded by the tool.""" - - evaluations = list( - annotate_candidate_dominance( - [ - deps.evaluations[candidate_id] - for candidate_id in deps.executionOrder - if candidate_id in deps.evaluations - ] - ) - ) - evaluations_by_id = { - evaluation.candidateId: evaluation for evaluation in evaluations - } - known_evidence = { - evidence_id - for evaluation in evaluations - for evidence_id in evaluation.evidenceIds - } - cited_evidence = set(report.evidenceIds) - for comparison in report.comparisons: - cited_evidence.update(comparison.evidenceIds) - if report.needsInput is not None: - cited_evidence.update(report.needsInput.evidenceIds) - unknown_evidence = sorted(cited_evidence - known_evidence) - if unknown_evidence: - raise ValueError( - f"Parameter tuning report cites unknown evidence ids {unknown_evidence}" - ) - if report.status == "done" and report.recommendedCandidateId is None: - raise ValueError("A done tuning report must recommend an executed candidate") - if report.status == "needsInput" and report.needsInput is None: - raise ValueError("A needsInput tuning report must include a concrete question") - successful = [ - evaluation for evaluation in evaluations if evaluation.status == "done" - ] - comparison_required = len(deps.candidates) > 1 and deps.maxCandidates > 1 - if report.status == "done": - if not report.evidenceIds: - raise ValueError("A done tuning report requires recommendation evidence") - if comparison_required and len(successful) < 2: - raise ValueError( - "A completed tuning recommendation requires at least two successful " - "candidate executions" - ) - if ( - comparison_required - and "baseline" in deps.candidates - and not any(item.candidateId == "baseline" for item in successful) - ): - raise ValueError( - "Evaluate the baseline before completing a multi-candidate comparison" - ) - - selected_artifacts: dict[str, ArtifactRecord] = {} - selected_evaluation: ParameterCandidateEvaluation | None = None - if report.recommendedCandidateId is not None: - selected = evaluations_by_id.get(report.recommendedCandidateId) - if selected is None: - raise ValueError("Recommended candidate was not executed") - if selected.status != "done": - raise ValueError("Recommended candidate execution failed") - if not selected.eligible: - raise ValueError("Recommended candidate is not eligible") - recommendation_prefix = f"candidate:{selected.candidateId}:" - if not any( - evidence_id.startswith(recommendation_prefix) - for evidence_id in report.evidenceIds - ): - raise ValueError( - "Recommendation evidence must include the selected candidate" - ) - selected_evaluation = selected - selected_artifacts = dict(selected.artifacts) - - if report.status == "done" and not comparison_required and report.comparisons: - raise ValueError( - "Candidate comparisons require a completed multi-candidate evaluation" - ) - if report.status == "done" and comparison_required: - assert report.recommendedCandidateId is not None - successful_ids = {item.candidateId for item in successful} - expected_comparators = successful_ids - {report.recommendedCandidateId} - comparison_ids = [item.candidateId for item in report.comparisons] - duplicate_comparators = sorted( - { - candidate_id - for candidate_id in comparison_ids - if comparison_ids.count(candidate_id) > 1 - } - ) - if duplicate_comparators: - raise ValueError(f"Duplicate candidate comparisons {duplicate_comparators}") - actual_comparators = set(comparison_ids) - missing_comparators = sorted(expected_comparators - actual_comparators) - invalid_comparators = sorted(actual_comparators - expected_comparators) - if missing_comparators: - raise ValueError( - "Completed tuning reports require comparisons for every successful " - f"non-selected candidate: {missing_comparators}" - ) - if invalid_comparators: - raise ValueError( - "Candidate comparisons must identify successful non-selected " - f"candidates: {invalid_comparators}" - ) - selected_prefix = f"candidate:{report.recommendedCandidateId}:" - for comparison in report.comparisons: - comparator_prefix = f"candidate:{comparison.candidateId}:" - if not any( - evidence_id.startswith(selected_prefix) - for evidence_id in comparison.evidenceIds - ): - raise ValueError( - "Each candidate comparison must cite evidence from the " - "selected candidate" - ) - if not any( - evidence_id.startswith(comparator_prefix) - for evidence_id in comparison.evidenceIds - ): - raise ValueError( - "Each candidate comparison must cite evidence from its comparator" - ) - if not comparison.summary.strip(): - raise ValueError( - "Each candidate comparison requires a concise grounded summary" - ) - if ( - selected_evaluation is not None - and comparison.candidateId - in selected_evaluation.metrics.dominatedByCandidateIds - and _single_varied_parameter( - selected_evaluation.parameters, - evaluations_by_id[comparison.candidateId].parameters, - ) - in {"neighborsK", "leidenResolution"} - ): - require_dominated_candidate_evidence( - selected_evaluation, - comparison.evidenceIds, - context=(f"The comparison with {comparison.candidateId!r}"), - ) - - graph_partition_dominators = ( - [ - candidate_id - for candidate_id in selected_evaluation.metrics.dominatedByCandidateIds - if candidate_id in evaluations_by_id - and _single_varied_parameter( - selected_evaluation.parameters, - evaluations_by_id[candidate_id].parameters, - ) - in {"neighborsK", "leidenResolution"} - ] - if selected_evaluation is not None - else [] - ) - if ( - report.status == "done" - and selected_evaluation is not None - and graph_partition_dominators - ): - selection_evidence = [ - *report.evidenceIds, - *( - evidence_id - for comparison in report.comparisons - for evidence_id in comparison.evidenceIds - ), - ] - require_dominated_candidate_evidence( - selected_evaluation, - selection_evidence, - context="The tuning recommendation", - ) - - return report.model_copy( - update={ - "fromAssay": deps.fromAssay, - "cellSelection": artifact_reference(deps.cellSelection), - "evaluations": evaluations, - "selectedArtifacts": selected_artifacts, - "searchPlan": search_plan, - "assayReports": {}, - "recommendedByAssay": ( - {deps.fromAssay: report.recommendedCandidateId} - if report.recommendedCandidateId is not None - else {} - ), - "totalCandidates": len(evaluations), - "integrationEvaluations": [], - "recommendedIntegrationId": None, - "finalClusterColumn": None, - "finalClusterArtifact": None, - "graphAssay": deps.fromAssay, - "markerAssay": deps.fromAssay, - "finalSelection": None, - } - ) - - -def validate_parameter_tuning_batch_report( - report: ParameterTuningReport, - dependencies: Mapping[str, ParameterTuningDependencies], - *, - search_plans: Mapping[str, ParameterSearchPlan], - primary_assay: str, -) -> ParameterTuningReport: - """Ground one aggregate response in every assay's executed branches.""" - - expected = set(dependencies) - actual = set(report.assayReports) - if actual != expected: - raise ValueError( - "Batched selection must contain exactly the requested assays: " - f"missing={sorted(expected - actual)}, unexpected={sorted(actual - expected)}" - ) - if primary_assay not in dependencies: - raise ValueError(f"Unknown primary assay {primary_assay!r}") - validated_reports = { - assay: validate_parameter_tuning_report( - report.assayReports[assay], - dependencies[assay], - search_plan=search_plans[assay], - ) - for assay in dependencies - } - known_evidence = { - evidence_id - for assay_report in validated_reports.values() - for evaluation in assay_report.evaluations - for evidence_id in evaluation.evidenceIds - } - unknown_evidence = sorted(set(report.evidenceIds) - known_evidence) - if unknown_evidence: - raise ValueError( - f"Batched tuning report cites unknown evidence ids {unknown_evidence}" - ) - statuses = {assay_report.status for assay_report in validated_reports.values()} - if statuses == {"done"}: - status: StageStatus = "done" - elif "needsInput" in statuses: - status = "needsInput" - else: - status = "failed" - primary = validated_reports[primary_assay] - recommended = { - assay: assay_report.recommendedCandidateId - for assay, assay_report in validated_reports.items() - if assay_report.recommendedCandidateId is not None - } - if status == "done" and len(recommended) != len(validated_reports): - raise ValueError("Every completed assay report must recommend a candidate") - cell_selections = [ - core_artifact_reference(assay_report.cellSelection) - for assay_report in validated_reports.values() - ] - if ( - not cell_selections - or not isinstance(cell_selections[0], ArtifactRef) - or any(selection != cell_selections[0] for selection in cell_selections[1:]) - ): - raise ValueError("Every assay report must use the same exact cell selection") - return report.model_copy( - update={ - "status": status, - "fromAssay": primary_assay, - "cellSelection": primary.cellSelection, - "evaluations": primary.evaluations, - "recommendedCandidateId": primary.recommendedCandidateId, - "selectedArtifacts": primary.selectedArtifacts, - "needsInput": primary.needsInput if status != "done" else None, - "searchPlan": primary.searchPlan, - "assayReports": validated_reports, - "recommendedByAssay": recommended, - "totalCandidates": sum( - len(item.evaluations) for item in validated_reports.values() - ), - "integrationEvaluations": [], - "recommendedIntegrationId": None, - "finalClusterColumn": None, - "finalClusterArtifact": None, - "graphAssay": primary_assay, - "markerAssay": primary_assay, - "finalSelection": None, - } - ) - - -def pending_parameter_tuning_report( - deps: ParameterTuningDependencies, - *, - search_plan: ParameterSearchPlan, - agent_name: str, -) -> ParameterTuningReport: - """Pause when structured selection is unavailable. - - Completed executor evidence is retained for an exact human resume, but it is - never converted into an implicit scientific recommendation. - """ - - evaluations = [ - deps.evaluations[candidate_id] - for candidate_id in deps.executionOrder - if candidate_id in deps.evaluations - ] - successful = [item for item in evaluations if item.status == "done"] - eligible = [item for item in successful if item.eligible] - logger.warning( - f"Parameter tuning for assay {deps.fromAssay!r} requires input after " - f"model exhaustion: completed={len(successful)}, eligible={len(eligible)}" - ) - known_evidence = sorted( - { - evidence_id - for evaluation in evaluations - for evidence_id in evaluation.evidenceIds - } - ) - report = ParameterTuningReport( - status="needsInput", - confidence="low", - rationale=( - "The bounded structured selection was unavailable. Executor evidence " - "is complete enough to resume, but it cannot choose a scientific " - "alternative by itself." - ), - evidenceIds=known_evidence, - limitations=["No candidate was selected merely to complete the workflow."], - stopReason="The bounded screen completed without a valid decision.", - needsInput=ParameterTuningNeedsInput( - question=( - "Select one eligible executed candidate and provide a scientific " - "rationale tied to the cited evidence." - ), - options=[item.candidateId for item in eligible], - evidenceIds=known_evidence, - ), - runInfo=AgentRunInfo(agentName=agent_name), - ) - return validate_parameter_tuning_report( - report, - deps, - search_plan=search_plan, - ) - - -def pending_parameter_tuning_batch_report( - dependencies: Mapping[str, ParameterTuningDependencies], - *, - search_plans: Mapping[str, ParameterSearchPlan], - primary_assay: str, -) -> ParameterTuningReport: - """Build one grounded pause over completed assay screens.""" - - logger.warning( - f"Pausing parameter tuning after model exhaustion for " - f"{len(dependencies)} assay(s)" - ) - assay_reports = { - assay: pending_parameter_tuning_report( - deps, - search_plan=search_plans[assay], - agent_name="parameter_tuning_batch_needs_input", - ) - for assay, deps in dependencies.items() - } - aggregate = ParameterTuningReport( - status="needsInput", - assayReports=assay_reports, - rationale=( - "Structured model selection was unavailable. All completed evidence " - "was retained without choosing a branch." - ), - evidenceIds=list( - dict.fromkeys( - evidence_id - for assay_report in assay_reports.values() - for evidence_id in assay_report.evidenceIds - ) - ), - limitations=["No assay candidate was selected merely to finish the workflow."], - stopReason="The bounded native screens completed without valid decisions.", - runInfo=AgentRunInfo(agentName="parameter_tuning_batch_needs_input"), - ) - logger.warning( - f"Parameter tuning batch pause status={aggregate.status}; " - f"completed_assays={sum(item.status == 'done' for item in assay_reports.values())}" - ) - return validate_parameter_tuning_batch_report( - aggregate, - dependencies, - search_plans=search_plans, - primary_assay=primary_assay, - ) - - -def validate_final_graph_selection( - selection: FinalGraphSelection, - report: ParameterTuningReport, - *, - integration_evaluations: Sequence[IntegrationCandidateEvaluation], - marker_assay: str, -) -> FinalGraphSelection: - """Ground a final graph choice in the exact eligible executor outputs.""" - - if report.status != "done": - raise ValueError("Native parameter tuning must finish before graph selection") - options = final_graph_options(report, integration_evaluations) - if not options: - raise ValueError("No eligible native or integrated graph options are available") - known_evidence = { - evidence_id - for option in options.values() - for evidence_id in option["evidenceIds"] - } - cited = set(selection.evidenceIds) - for comparison in selection.comparisons: - cited.update(comparison.evidenceIds) - if selection.needsInput is not None: - cited.update(selection.needsInput.evidenceIds) - unknown = sorted(cited - known_evidence) - if unknown: - raise ValueError(f"Final graph selection cites unknown evidence ids {unknown}") - if selection.status == "needsInput": - if selection.needsInput is None or not selection.needsInput.question.strip(): - raise ValueError( - "A needsInput graph selection requires a concrete question" - ) - return selection.model_copy( - update={ - "selectedOptionId": None, - "graphMethod": None, - "nativeAssay": None, - "nativeCandidateId": None, - "integrationId": None, - "markerAssay": marker_assay, - } - ) - if selection.status != "done": - raise ValueError("Final graph selection must be done or needsInput") - if selection.selectedOptionId not in options: - raise ValueError("Selected final graph option is not eligible") - assert selection.selectedOptionId is not None - selected = options[selection.selectedOptionId] - selected_evidence = set(selected["evidenceIds"]) - if not selected_evidence.intersection(selection.evidenceIds): - raise ValueError( - "Final graph recommendation must cite selected-option evidence" - ) - expected_comparators = set(options) - {selection.selectedOptionId} - comparison_ids = [item.optionId for item in selection.comparisons] - if len(set(comparison_ids)) != len(comparison_ids): - raise ValueError("Final graph comparisons must not contain duplicates") - if set(comparison_ids) != expected_comparators: - raise ValueError( - "Final graph selection requires one comparison for every eligible " - "non-selected option" - ) - for comparison in selection.comparisons: - comparator_evidence = set(options[comparison.optionId]["evidenceIds"]) - if not selected_evidence.intersection(comparison.evidenceIds): - raise ValueError( - "Every final graph comparison must cite selected-option evidence" - ) - if not comparator_evidence.intersection(comparison.evidenceIds): - raise ValueError( - "Every final graph comparison must cite comparator evidence" - ) - if not comparison.summary.strip(): - raise ValueError("Every final graph comparison requires a summary") - return selection.model_copy( - update={ - "graphMethod": selected["graphMethod"], - "nativeAssay": selected.get("nativeAssay"), - "nativeCandidateId": selected.get("nativeCandidateId"), - "integrationId": selected.get("integrationId"), - "markerAssay": marker_assay, - "needsInput": None, - } - ) - - -def finalize_parameter_tuning_selection( - report: ParameterTuningReport, - *, - marker_assay: str, - integration_evaluations: Sequence[IntegrationCandidateEvaluation] = (), - recommended_integration_id: str | None = None, - native_assay: str | None = None, - final_selection: FinalGraphSelection | None = None, -) -> ParameterTuningReport: - """Attach an executor-selected native or integrated final cluster branch.""" - - logger.debug( - f"Finalizing parameter graph selection: marker_assay={marker_assay!r}, " - f"integration_candidates={len(integration_evaluations)}" - ) - if report.status != "done": - raise ValueError("Parameter tuning must be done before final graph selection") - if not marker_assay: - raise ValueError("marker_assay must be non-empty") - report_cell_selection = core_artifact_reference(report.cellSelection) - if not isinstance(report_cell_selection, ArtifactRef): - raise ValueError("Parameter tuning report lacks an exact cell selection") - assay_reports = report.assayReports or {report.fromAssay: report} - if marker_assay not in assay_reports: - raise ValueError(f"Unknown marker assay {marker_assay!r}") - evaluations = list(integration_evaluations) - integration_ids = [item.integrationId for item in evaluations] - if len(set(integration_ids)) != len(integration_ids): - raise ValueError("Integration evaluation ids must be unique") - if recommended_integration_id is not None and native_assay is not None: - raise ValueError("Choose either an integrated graph or one native assay") - if recommended_integration_id is not None: - selected = next( - ( - item - for item in evaluations - if item.integrationId == recommended_integration_id - ), - None, - ) - if selected is None: - raise ValueError("Recommended integration candidate was not evaluated") - if selected.status != "done" or not selected.eligible: - raise ValueError("Recommended integration candidate is not eligible") - if selected.clusterArtifact is None: - raise ValueError("Recommended integration lacks an exact cluster artifact") - if core_artifact_reference(selected.cellSelection) != report_cell_selection: - raise ValueError("Recommended integration uses a different cell selection") - if ( - selected.clusterArtifact.scope != "datastore" - or selected.clusterArtifact.assay is not None - ): - raise ValueError( - "Integrated cluster artifacts must be datastore-scoped without assay" - ) - if ( - selected.graphArtifact is None - or selected.graphArtifact.scope != "datastore" - ): - raise ValueError("Integrated graph artifact must be datastore-scoped") - cluster_artifact = selected.clusterArtifact - cluster_column = selected.clusterColumn - graph_assay = None - else: - selected_assay = native_assay or report.fromAssay - primary = assay_reports.get(selected_assay) - if primary is None or primary.recommendedCandidateId is None: - raise ValueError("Selected assay lacks a native tuning recommendation") - selected_native = next( - ( - item - for item in primary.evaluations - if item.candidateId == primary.recommendedCandidateId - ), - None, - ) - if ( - selected_native is None - or selected_native.status != "done" - or not selected_native.eligible - or "clusters" not in selected_native.artifacts - ): - raise ValueError("Primary native recommendation lacks exact clusters") - if ( - core_artifact_reference(selected_native.cellSelection) - != report_cell_selection - ): - raise ValueError( - "Recommended native candidate uses a different cell selection" - ) - cluster_artifact = selected_native.artifacts["clusters"] - cluster_column = selected_native.clusterColumn - graph_assay = selected_assay - finalized = report.model_copy( - update={ - "totalCandidates": ( - sum(len(value.evaluations) for value in assay_reports.values()) - + len(evaluations) - ), - "integrationEvaluations": evaluations, - "recommendedIntegrationId": recommended_integration_id, - "finalClusterColumn": cluster_column, - "finalClusterArtifact": cluster_artifact, - "graphAssay": graph_assay, - "markerAssay": marker_assay, - "finalSelection": final_selection, - } - ) - selected_graph = recommended_integration_id or graph_assay - logger.info( - f"Finalized parameter graph selection: graph={selected_graph!r}, " - f"marker_assay={marker_assay!r}, cluster_column={cluster_column!r}" - ) - return finalized - - -def select_final_parameter_graph( - *, - model: Any, - report: ParameterTuningReport, - integration_evaluations: Sequence[IntegrationCandidateEvaluation], - marker_assay: str, - config: AgentRunConfig | None = None, -) -> ParameterTuningReport: - """Use one bounded provider call to select and attach the final graph.""" - - evaluations = list(integration_evaluations) - if not marker_assay: - raise ValueError("marker_assay must be non-empty") - assay_reports = report.assayReports or {report.fromAssay: report} - if marker_assay not in assay_reports: - raise ValueError(f"Unknown marker assay {marker_assay!r}") - options = final_graph_options(report, evaluations) - if not options: - raise ValueError("No eligible native or integrated graph options are available") - logger.info( - f"Selecting final parameter graph from {len(options)} eligible option(s); " - f"marker_assay={marker_assay!r}" - ) - if len(options) == 1: - option_id, option = next(iter(options.items())) - logger.info( - f"Selecting sole eligible final graph option {option_id!r} " - "without a provider request" - ) - selection = validate_final_graph_selection( - FinalGraphSelection( - status="done", - selectedOptionId=option_id, - markerAssay=marker_assay, - confidence="high", - rationale="The executor produced exactly one eligible graph option.", - evidenceIds=list(option["evidenceIds"]), - limitations=["No alternative eligible final graph required ranking."], - runInfo=AgentRunInfo( - agentName="parameter_tuning_final_graph_deterministic" - ), - ), - report, - integration_evaluations=evaluations, - marker_assay=marker_assay, - ) - else: - run_config = (config or AgentRunConfig()).with_limits( - request_limit=6, - tool_call_limit=5, - output_token_limit=32768, - timeout_seconds=600.0, - ) - try: - logger.info( - f"Requesting final graph selection across {len(options)} " - "eligible options" - ) - execution = run_agent_sync( - model=model, - output_type=FinalGraphSelection, - system_prompt=final_graph_selection_system_prompt(), - user_prompt=final_graph_selection_prompt( - report=report, - integration_evaluations=evaluations, - marker_assay=marker_assay, - ), - deps_type=ParameterTuningDependencies, - deps=ParameterTuningDependencies.get_blank(), - config=run_config, - name="parameter_tuning_final_graph", - output_validator=lambda proposed: validate_final_graph_selection( - proposed, - report, - integration_evaluations=evaluations, - marker_assay=marker_assay, - ), - ) - except (UnexpectedModelBehavior, UsageLimitExceeded) as exc: - option_ids = sorted(options) - logger.warning( - "Final graph selection model run failed within its bounds " - f"({type(exc).__name__}); " - f"requesting input for {len(option_ids)} eligible options" - ) - selection = validate_final_graph_selection( - FinalGraphSelection( - status="needsInput", - markerAssay=marker_assay, - confidence="low", - rationale=( - "The bounded structured final-graph selection was unavailable." - ), - limitations=[ - "No ranking was invented across multiple eligible graphs." - ], - needsInput=FinalGraphNeedsInput( - question="Select one eligible final graph option.", - options=option_ids, - evidenceIds=sorted( - { - evidence_id - for option in options.values() - for evidence_id in option["evidenceIds"] - } - ), - ), - runInfo=AgentRunInfo( - agentName="parameter_tuning_final_graph_needs_input" - ), - ), - report, - integration_evaluations=evaluations, - marker_assay=marker_assay, - ) - else: - if not isinstance(execution.output, FinalGraphSelection): - raise TypeError( - "Final graph selector returned an unexpected output type" - ) - selection = validate_final_graph_selection( - execution.output, - report, - integration_evaluations=evaluations, - marker_assay=marker_assay, - ).model_copy(update={"runInfo": execution.runInfo}) - logger.info( - f"Provider selected final graph option {selection.selectedOptionId!r}" - ) - if selection.status == "needsInput": - needs_input = selection.needsInput or FinalGraphNeedsInput.get_blank() - option_ids = sorted(options) - canonical_question = "Select one eligible final graph option." - canonical_needs_input = needs_input.model_copy( - update={"question": canonical_question, "options": option_ids} - ) - logger.warning( - f"Final parameter graph selection needs input; options={len(option_ids)}" - ) - return report.model_copy( - update={ - "status": "needsInput", - "totalCandidates": ( - sum(len(value.evaluations) for value in assay_reports.values()) - + len(evaluations) - ), - "integrationEvaluations": evaluations, - "markerAssay": marker_assay, - "finalSelection": selection.model_copy( - update={"needsInput": canonical_needs_input} - ), - "needsInput": ParameterTuningNeedsInput( - question=canonical_question, - options=option_ids, - evidenceIds=needs_input.evidenceIds, - ), - } - ) - return finalize_parameter_tuning_selection( - report, - marker_assay=marker_assay, - integration_evaluations=evaluations, - recommended_integration_id=selection.integrationId, - native_assay=selection.nativeAssay, - final_selection=selection, - ) - - -def promote_parameter_candidate( - store: Any, - *, - report: ParameterTuningReport, - normalized: Any, - identity_feature_limit: int = 64, -) -> ParameterCandidateEvaluation: - """Resolve and verify the exact selected native branch without replaying it.""" - - if report.status != "done" or report.recommendedCandidateId is None: - raise ValueError("A completed native tuning recommendation is required") - evaluation = next( - ( - item - for item in report.evaluations - if item.candidateId == report.recommendedCandidateId - ), - None, - ) - if evaluation is None or evaluation.status != "done" or not evaluation.eligible: - raise ValueError("Recommended candidate is not an eligible execution") - if "clusters" not in evaluation.artifacts: - raise ValueError("Recommended candidate lacks an exact cluster artifact") - normalized_ref = core_artifact_reference(normalized) - if ( - not isinstance(normalized_ref, ArtifactRef) - or normalized_ref.kind != "normalized" - or normalized_ref.assay != report.fromAssay - ): - raise ValueError( - "normalized must identify the report's exact normalized assay artifact" - ) - status = store.inspect_artifact(normalized_ref) - if not getattr(status, "exists", True) or not getattr(status, "complete", False): - raise ValueError("normalized artifact is unavailable or incomplete") - raw_selection = (getattr(status, "inputs", None) or {}).get("cell_selection") - if not isinstance(raw_selection, Mapping): - raise ValueError("normalized artifact has no cell-selection input") - normalized_selection = ArtifactRef.from_dict(dict(raw_selection)) - if normalized_selection != core_artifact_reference(evaluation.cellSelection): - raise ValueError( - "Recommended candidate does not match normalized artifact lineage" - ) - if normalized_selection != core_artifact_reference(report.cellSelection): - raise ValueError( - "Parameter tuning report does not match normalized artifact lineage" - ) - if identity_feature_limit < 2: - raise ValueError("identity_feature_limit must be at least two") - logger.info( - f"Resolved parameter candidate {evaluation.candidateId!r} for assay " - f"{report.fromAssay!r} without replay" - ) - return evaluation - - -def _resolve_experimental_tuning_handoff( - *, - normalized_cell_selection: ArtifactRef, - batch_columns: Sequence[str], - preservation_columns: Sequence[str], - experimental_handoff: ExperimentalTuningHandoff | None, -) -> tuple[ArtifactRef, list[str], list[str]]: - resolved_batch_columns = list(batch_columns) - resolved_preservation_columns = list(preservation_columns) - if experimental_handoff is None: - return ( - normalized_cell_selection, - resolved_batch_columns, - resolved_preservation_columns, - ) - - handoff_batch_columns = list(experimental_handoff.batchColumns) - canonical_batch_columns = sorted(set(handoff_batch_columns)) - if len(canonical_batch_columns) != len(handoff_batch_columns): - raise ValueError("experimental_handoff batch columns must be unique") - handoff_cell_selection = core_artifact_reference(experimental_handoff.cellSelection) - if not isinstance(handoff_cell_selection, ArtifactRef): - raise ValueError("experimental_handoff lacks an exact cell selection") - if handoff_cell_selection != normalized_cell_selection: - raise ValueError("normalized selection conflicts with experimental_handoff") - if resolved_batch_columns and sorted(resolved_batch_columns) != ( - canonical_batch_columns - ): - raise ValueError("batch_columns conflict with experimental_handoff") - if resolved_preservation_columns and resolved_preservation_columns != list( - experimental_handoff.preservationColumns - ): - raise ValueError("preservation_columns conflict with experimental_handoff") - if experimental_handoff.batchAction == "needsInput": - raise ValueError("Experimental Context requires input before tuning") - if experimental_handoff.batchAction == "skip" and experimental_handoff.batchColumns: - raise ValueError("A skip handoff must not contain batch columns") - if experimental_handoff.batchAction == "evaluateHarmony": - expected_coefficients = set(experimental_handoff.coefficientsOfInterest) - safe_coefficients = { - item.coefficient - for item in experimental_handoff.batchSafety - if item.status == "safe" and item.batchColumns == canonical_batch_columns - } - if ( - not expected_coefficients - or not canonical_batch_columns - or safe_coefficients != expected_coefficients - ): - raise ValueError( - "Harmony handoff lacks safe evidence for every coefficient" - ) - if experimental_handoff.batchAction == "unsafe": - expected_coefficients = set(experimental_handoff.coefficientsOfInterest) - exact_safety = [ - item - for item in experimental_handoff.batchSafety - if item.batchColumns == canonical_batch_columns - and item.coefficient in expected_coefficients - ] - if ( - not expected_coefficients - or {item.coefficient for item in exact_safety} != expected_coefficients - or any(item.status == "notComputed" for item in exact_safety) - or not any(item.status == "unsafe" for item in exact_safety) - ): - raise ValueError("Unsafe handoff lacks exact unsafe batch evidence") - if any( - item.evidenceId not in experimental_handoff.evidenceIds - for item in experimental_handoff.batchSafety - ): - raise ValueError("Experimental handoff does not cite its batch evidence") - return ( - normalized_cell_selection, - canonical_batch_columns, - list(experimental_handoff.preservationColumns), - ) - - -def prepare_parameter_tuning_dependencies( - store: Any, - *, - normalized: ArtifactRef, - candidates: Sequence[ParameterCandidate] | None = None, - batch_columns: Sequence[str] = (), - preservation_columns: Sequence[str] = (), - experimental_handoff: ExperimentalTuningHandoff | None = None, - max_candidates: int = 5, - max_refined_candidates: int = 0, - allow_harmony_refinement: bool = True, - pair_harmony_candidates: bool | None = None, - min_cluster_cells: int = 20, - identity_feature_limit: int = 64, -) -> tuple[ParameterTuningDependencies, list[str]]: - """Validate one assay request and construct branch-safe dependencies.""" - - if max_candidates < 1: - raise ValueError("max_candidates must be at least one") - if max_refined_candidates < 0: - raise ValueError("max_refined_candidates must be non-negative") - if pair_harmony_candidates is not None and not isinstance( - pair_harmony_candidates, - bool, - ): - raise TypeError("pair_harmony_candidates must be a boolean or None") - if min_cluster_cells < 1: - raise ValueError("min_cluster_cells must be at least one") - if identity_feature_limit < 2: - raise ValueError("identity_feature_limit must be at least two") - normalized = core_artifact_reference(normalized) - if not isinstance(normalized, ArtifactRef) or normalized.kind != "normalized": - raise TypeError("normalized must be a normalized ArtifactRef") - if normalized.assay is None: - raise ValueError("normalized artifact has no assay") - normalized_status = store.inspect_artifact(normalized) - if not getattr(normalized_status, "exists", True): - raise ValueError("normalized artifact does not exist") - if not getattr(normalized_status, "complete", False): - raise ValueError("normalized artifact is incomplete") - raw_cell_selection = (getattr(normalized_status, "inputs", None) or {}).get( - "cell_selection" - ) - if not isinstance(raw_cell_selection, Mapping): - raise ValueError("normalized artifact has no cell-selection input") - normalized_cell_selection = ArtifactRef.from_dict(dict(raw_cell_selection)) - if ( - normalized_cell_selection.scope != "datastore" - or normalized_cell_selection.kind != "cell_selection" - or normalized_cell_selection.assay is not None - ): - raise ValueError("normalized artifact has an invalid cell-selection input") - from_assay = normalized.assay - ( - resolved_cell_selection, - resolved_batch_columns, - resolved_preservation_columns, - ) = _resolve_experimental_tuning_handoff( - normalized_cell_selection=normalized_cell_selection, - batch_columns=batch_columns, - preservation_columns=preservation_columns, - experimental_handoff=experimental_handoff, - ) - if len(set(resolved_batch_columns)) != len(resolved_batch_columns): - raise ValueError("batch_columns must be unique") - seed_candidates = ( - get_default_parameter_candidates() if candidates is None else list(candidates) - ) - if not seed_candidates: - raise ValueError("candidates must be non-empty") - if len(seed_candidates) > max_candidates: - raise ValueError( - f"Initial candidate count exceeds max_candidates={max_candidates}" - ) - pair_harmony = ( - ( - experimental_handoff is not None - and experimental_handoff.batchAction == "evaluateHarmony" - ) - if pair_harmony_candidates is None - else pair_harmony_candidates - ) - candidate_values = build_initial_parameter_candidates( - seed_candidates, - pair_harmony=pair_harmony, - ) - if len(candidate_values) + max_refined_candidates > CONFIG._MAX_CANDIDATES_OFFERED: - raise ValueError( - "Initial and refined candidates may contain at most " - f"{CONFIG._MAX_CANDIDATES_OFFERED} values" - ) - candidate_map: dict[str, ParameterCandidate] = {} - for candidate in candidate_values: - if not CONFIG._CANDIDATE_ID.fullmatch(candidate.candidateId): - raise ValueError( - "candidateId must contain only ASCII letters, numbers, and underscores" - ) - if candidate.candidateId in candidate_map: - raise ValueError(f"Duplicate candidateId {candidate.candidateId!r}") - if candidate.useHarmony and not resolved_batch_columns: - raise ValueError( - f"Candidate {candidate.candidateId!r} requires batch_columns" - ) - if ( - candidate.useHarmony - and experimental_handoff is not None - and experimental_handoff.batchAction != "evaluateHarmony" - ): - raise ValueError( - f"Candidate {candidate.candidateId!r} is not authorized for Harmony" - ) - candidate_map[candidate.candidateId] = candidate - harmony_authorized = ( - allow_harmony_refinement - and bool(resolved_batch_columns) - and ( - experimental_handoff is None - or experimental_handoff.batchAction == "evaluateHarmony" - ) - ) - normalized_shape = normalized_artifact_shape(store, normalized) - deps = ParameterTuningDependencies( - store=store, - normalized=normalized, - cellSelection=resolved_cell_selection, - normalizedShape=normalized_shape, - fromAssay=from_assay, - candidates=candidate_map, - candidatePhases={candidate_id: "initial" for candidate_id in candidate_map}, - batchColumns=tuple(resolved_batch_columns), - preservationColumns=tuple(resolved_preservation_columns), - harmonyAuthorized=harmony_authorized, - maxCandidates=len(candidate_values) + max_refined_candidates, - minClusterCells=min_cluster_cells, - identityFeatureLimit=identity_feature_limit, - ) - return deps, list(candidate_map) - - -class ParameterTuningAgent: - """Run bounded tuning over caller-authorized Scarf candidates.""" - - def __init__( - self, - model: Any, - *, - config: AgentRunConfig | None = None, - ) -> None: - self.model = model - self.config = (config or AgentRunConfig()).with_limits( - request_limit=6, - tool_call_limit=5, - output_token_limit=32768, - timeout_seconds=600.0, - ) - - def run( - self, - store: Any, - *, - normalized: Any, - candidates: Sequence[ParameterCandidate] | None = None, - batch_columns: Sequence[str] = (), - preservation_columns: Sequence[str] = (), - experimental_handoff: ExperimentalTuningHandoff | None = None, - max_candidates: int = 5, - max_refined_candidates: int = 0, - min_cluster_cells: int = 20, - identity_feature_limit: int = 64, - ) -> ParameterTuningReport: - """Run deterministic screening, optional refinement, and final selection.""" - return tune_parameters( - store, - model=self.model, - normalized=normalized, - candidates=candidates, - batch_columns=batch_columns, - preservation_columns=preservation_columns, - experimental_handoff=experimental_handoff, - max_candidates=max_candidates, - max_refined_candidates=max_refined_candidates, - min_cluster_cells=min_cluster_cells, - identity_feature_limit=identity_feature_limit, - config=self.config, - ) - - def promote( - self, - store: Any, - *, - report: ParameterTuningReport, - normalized: Any, - identity_feature_limit: int = 64, - ) -> ParameterCandidateEvaluation: - """Resolve the exact selected native branch without mutating state.""" - - return promote_parameter_candidate( - store, - report=report, - normalized=normalized, - identity_feature_limit=identity_feature_limit, - ) - - def run_batch( - self, - store: Any, - *, - assays: Sequence[ParameterTuningAssayInput], - primary_assay: str | None = None, - max_total_candidates: int = 24, - selection_directions: str = "", - ) -> ParameterTuningReport: - """Tune several assays with one planning and one selection request.""" - - return tune_parameters_batch( - store, - model=self.model, - assays=assays, - primary_assay=primary_assay, - max_total_candidates=max_total_candidates, - selection_directions=selection_directions, - config=self.config, - ) - - def select_final( - self, - *, - report: ParameterTuningReport, - integration_evaluations: Sequence[IntegrationCandidateEvaluation], - marker_assay: str, - ) -> ParameterTuningReport: - """Select native, SNN, or WNN once and attach the final branch.""" - - return select_final_parameter_graph( - model=self.model, - report=report, - integration_evaluations=integration_evaluations, - marker_assay=marker_assay, - config=self.config, - ) - - -def _execute_parameter_candidates( - deps: ParameterTuningDependencies, - candidate_ids: Sequence[str], -) -> None: - logger.info( - f"Executing {len(candidate_ids)} parameter candidate(s) for assay " - f"{deps.fromAssay!r}" - ) - for candidate_id in candidate_ids: - execute_parameter_candidate(deps, candidate_id) - _refresh_candidate_dominance(deps) - - -def _refresh_candidate_dominance(deps: ParameterTuningDependencies) -> None: - ordered = [ - deps.evaluations[candidate_id] - for candidate_id in deps.executionOrder - if candidate_id in deps.evaluations - ] - deps.evaluations.update( - { - evaluation.candidateId: evaluation - for evaluation in annotate_candidate_dominance(ordered) - } - ) - - -def _register_refined_parameter_candidates( - deps: ParameterTuningDependencies, - candidates: Sequence[ParameterCandidate], -) -> None: - if candidates: - logger.info( - f"Executing {len(candidates)} refined parameter candidate(s) for " - f"assay {deps.fromAssay!r}" - ) - for candidate in candidates: - deps.candidates[candidate.candidateId] = candidate - deps.candidatePhases[candidate.candidateId] = "refined" - execute_parameter_candidate(deps, candidate.candidateId) - _refresh_candidate_dominance(deps) - - -def execute_parameter_search_plan( - deps: ParameterTuningDependencies, - plan: ParameterSearchPlan, - *, - initial_candidate_ids: Sequence[str], - max_refined_candidates: int, -) -> tuple[ParameterSearchPlan, tuple[ParameterCandidateEvaluation, ...]]: - """Validate and execute one already-proposed bounded refinement plan.""" - - validated = validate_parameter_search_plan( - plan, - deps, - initial_candidate_ids=initial_candidate_ids, - max_refined_candidates=max_refined_candidates, - ) - _register_refined_parameter_candidates(deps, validated.candidates) - return ( - validated, - tuple( - deps.evaluations[candidate.candidateId] - for candidate in validated.candidates - ), - ) - - -def tune_parameters_batch( - store: Any, - *, - model: Any, - assays: Sequence[ParameterTuningAssayInput], - primary_assay: str | None = None, - max_total_candidates: int = 24, - selection_directions: str = "", - config: AgentRunConfig | None = None, -) -> ParameterTuningReport: - """Execute and select modality-specific native branches in two model calls.""" - - assay_inputs = list(assays) - if not assay_inputs: - raise ValueError("assays must contain at least one tuning input") - if max_total_candidates < 1: - raise ValueError("max_total_candidates must be at least one") - planned_total = sum(item.maxCandidates for item in assay_inputs) - if planned_total > max_total_candidates: - raise ValueError( - f"Batched tuning requests {planned_total} candidate branches; " - f"the global limit is {max_total_candidates}" - ) - normalized_refs = [ - core_artifact_reference(item.normalized) for item in assay_inputs - ] - if any( - not isinstance(ref, ArtifactRef) - or ref.kind != "normalized" - or ref.assay is None - for ref in normalized_refs - ): - raise TypeError( - "Every batched tuning input requires an assay-scoped normalized artifact" - ) - assay_names = [ref.assay for ref in normalized_refs] - if len(set(assay_names)) != len(assay_names): - raise ValueError("Batched tuning assay names must be unique") - resolved_primary = primary_assay or assay_names[0] - if resolved_primary not in assay_names: - raise ValueError(f"Unknown primary assay {resolved_primary!r}") - logger.info( - f"Starting batched parameter tuning for {len(assay_names)} assay(s); " - f"primary_assay={resolved_primary!r}, " - f"candidate_limit={max_total_candidates}" - ) - - dependencies: dict[str, ParameterTuningDependencies] = {} - initial_ids: dict[str, list[str]] = {} - max_refined_by_assay: dict[str, int] = {} - for item, assay_name in zip(assay_inputs, assay_names, strict=True): - deps, candidate_ids = prepare_parameter_tuning_dependencies( - store, - normalized=item.normalized, - candidates=item.candidates or None, - batch_columns=item.batchColumns, - preservation_columns=item.preservationColumns, - experimental_handoff=item.experimentalHandoff, - max_candidates=item.maxCandidates, - max_refined_candidates=item.maxRefinedCandidates, - allow_harmony_refinement=item.allowHarmonyRefinement, - min_cluster_cells=item.minClusterCells, - identity_feature_limit=item.identityFeatureLimit, - ) - dependencies[assay_name] = deps - initial_ids[assay_name] = candidate_ids - max_refined_by_assay[assay_name] = item.maxRefinedCandidates - cell_selections = {deps.cellSelection for deps in dependencies.values()} - if len(cell_selections) != 1: - raise ValueError("Batched tuning inputs must use the same cell selection") - for assay in assay_names: - deps = dependencies[assay] - _execute_parameter_candidates(deps, initial_ids[assay]) - logger.info( - "Completed batched initial parameter screen: " - + ", ".join( - f"{assay}={len(dependencies[assay].evaluations)}" for assay in assay_names - ) - ) - - run_config = (config or AgentRunConfig()).with_limits( - request_limit=6, - tool_call_limit=5, - output_token_limit=32768, - timeout_seconds=600.0, - ) - refinement_planning_failed = False - if any(max_refined_by_assay.values()): - try: - logger.info( - "Requesting one batched parameter refinement plan for " - f"{len(assay_names)} assay(s)" - ) - planning_execution = run_agent_sync( - model=model, - output_type=ParameterTuningBatchSearchPlan, - system_prompt=parameter_batch_search_system_prompt(), - user_prompt=parameter_batch_search_prompt( - dependencies, - max_refined_by_assay, - ), - deps_type=ParameterTuningDependencies, - deps=dependencies[resolved_primary], - config=run_config, - name="parameter_batch_search_planning", - output_validator=( - lambda proposed: validate_parameter_batch_search_plan( - proposed, - dependencies, - initial_candidate_ids=initial_ids, - max_refined_by_assay=max_refined_by_assay, - ) - ), - ) - except (UnexpectedModelBehavior, UsageLimitExceeded) as exc: - logger.warning( - "Batched parameter refinement model run failed within its bounds " - f"({type(exc).__name__}); pausing without a refinement decision" - ) - refinement_planning_failed = True - failed_plans = { - assay: ParameterSearchPlan( - status="complete", - basedOnCandidateIds=[ - next( - ( - candidate_id - for candidate_id in initial_ids[assay] - if dependencies[assay].evaluations[candidate_id].status - == "done" - and dependencies[assay] - .evaluations[candidate_id] - .eligible - ), - initial_ids[assay][0], - ) - ], - rationale=( - "The required bounded refinement review was unavailable." - ), - evidenceIds=sorted( - { - evidence_id - for candidate_id in initial_ids[assay] - for evidence_id in dependencies[assay] - .evaluations[candidate_id] - .evidenceIds - } - ), - stoppingCriteria=[ - "Obtain a grounded refinement disposition before selection." - ], - runInfo=AgentRunInfo( - agentName="parameter_batch_search_planning_needs_input" - ), - ) - for assay in assay_names - } - batch_plan = ParameterTuningBatchSearchPlan( - assayPlans=failed_plans, - runInfo=AgentRunInfo( - agentName="parameter_batch_search_planning_needs_input" - ), - ) - else: - if not isinstance( - planning_execution.output, ParameterTuningBatchSearchPlan - ): - raise TypeError("Batched parameter planner returned an unexpected type") - validated_batch_plan = validate_parameter_batch_search_plan( - planning_execution.output, - dependencies, - initial_candidate_ids=initial_ids, - max_refined_by_assay=max_refined_by_assay, - ) - batch_plan = validated_batch_plan.model_copy( - update={ - "assayPlans": { - assay: plan.model_copy( - update={"runInfo": planning_execution.runInfo} - ) - for assay, plan in validated_batch_plan.assayPlans.items() - }, - "runInfo": planning_execution.runInfo, - } - ) - logger.info( - "Completed batched parameter refinement plan: " - + ", ".join( - f"{assay}={len(plan.candidates)}" - for assay, plan in batch_plan.assayPlans.items() - ) - ) - else: - logger.info( - "Skipping batched parameter refinement because it is not authorized" - ) - batch_plan = ParameterTuningBatchSearchPlan( - assayPlans={ - assay: ParameterSearchPlan( - status="complete", - rationale=( - "Refinement was not authorized because " - "maxRefinedCandidates is zero." - ), - stoppingCriteria=[ - "Use the completed initial screen without refinement." - ], - ) - for assay in assay_names - } - ) - for assay, plan in batch_plan.assayPlans.items(): - deps = dependencies[assay] - _register_refined_parameter_candidates(deps, plan.candidates) - - try: - logger.info( - f"Requesting batched parameter selection across " - f"{sum(len(deps.evaluations) for deps in dependencies.values())} " - "executed candidates" - ) - selection_execution = run_agent_sync( - model=model, - output_type=ParameterTuningReport, - system_prompt=parameter_batch_selection_system_prompt(), - user_prompt=parameter_batch_selection_prompt( - dependencies, - batch_plan.assayPlans, - resolved_primary, - selection_directions, - ), - deps_type=ParameterTuningDependencies, - deps=dependencies[resolved_primary], - config=run_config, - name="parameter_tuning_batch", - output_validator=lambda proposed: validate_parameter_tuning_batch_report( - proposed, - dependencies, - search_plans=batch_plan.assayPlans, - primary_assay=resolved_primary, - ), - ) - except (UnexpectedModelBehavior, UsageLimitExceeded) as exc: - logger.warning( - "Batched parameter selection model run failed within its bounds " - f"({type(exc).__name__}); " - "returning needsInput with the completed executor evidence" - ) - return pending_parameter_tuning_batch_report( - dependencies, - search_plans=batch_plan.assayPlans, - primary_assay=resolved_primary, - ) - if not isinstance(selection_execution.output, ParameterTuningReport): - raise TypeError("Batched parameter tuning returned an unexpected type") - if refinement_planning_failed: - return pending_parameter_tuning_batch_report( - dependencies, - search_plans=batch_plan.assayPlans, - primary_assay=resolved_primary, - ) - report = validate_parameter_tuning_batch_report( - selection_execution.output, - dependencies, - search_plans=batch_plan.assayPlans, - primary_assay=resolved_primary, - ) - completed_report = report.model_copy( - update={"runInfo": selection_execution.runInfo} - ) - logger.info( - f"Completed batched parameter tuning: status={completed_report.status}, " - f"assays={len(completed_report.assayReports)}, " - f"candidates={completed_report.totalCandidates}" - ) - return completed_report - - -def tune_parameters( - store: Any, - *, - model: Any, - normalized: Any, - candidates: Sequence[ParameterCandidate] | None = None, - batch_columns: Sequence[str] = (), - preservation_columns: Sequence[str] = (), - experimental_handoff: ExperimentalTuningHandoff | None = None, - max_candidates: int = 5, - max_refined_candidates: int = 0, - min_cluster_cells: int = 20, - identity_feature_limit: int = 64, - config: AgentRunConfig | None = None, -) -> ParameterTuningReport: - """Run the bounded parameter tuning agent against an existing DataStore.""" - - deps, initial_candidate_ids = prepare_parameter_tuning_dependencies( - store, - normalized=normalized, - candidates=candidates, - batch_columns=batch_columns, - preservation_columns=preservation_columns, - experimental_handoff=experimental_handoff, - max_candidates=max_candidates, - max_refined_candidates=max_refined_candidates, - min_cluster_cells=min_cluster_cells, - identity_feature_limit=identity_feature_limit, - ) - from_assay = deps.fromAssay - cell_selection = artifact_reference(deps.cellSelection) - logger.info( - f"Starting parameter tuning for assay {from_assay!r}; " - f"candidate_limit={max_candidates}, " - f"refinement_limit={max_refined_candidates}" - ) - run_config = (config or AgentRunConfig()).with_limits( - request_limit=6, - tool_call_limit=5, - output_token_limit=32768, - timeout_seconds=600.0, - ) - _execute_parameter_candidates(deps, initial_candidate_ids) - initial_evaluations = [ - deps.evaluations[candidate_id] for candidate_id in initial_candidate_ids - ] - - refinement_planning_failed = False - if max_refined_candidates == 0: - logger.info( - f"Skipping parameter refinement for assay {from_assay!r} because it " - "is not authorized" - ) - plan = ParameterSearchPlan( - status="complete", - rationale=( - "Refinement was not authorized because max_refined_candidates is zero." - ), - stoppingCriteria=[ - "Use the completed initial screen without a refinement pass." - ], - ) - else: - try: - logger.info( - f"Requesting parameter refinement plan for assay {from_assay!r} " - f"from {len(initial_evaluations)} initial evaluations" - ) - planning_execution = run_agent_sync( - model=model, - output_type=ParameterSearchPlan, - system_prompt=parameter_search_system_prompt(), - user_prompt=parameter_search_prompt( - from_assay=from_assay, - cell_selection=cell_selection, - evaluations=initial_evaluations, - batch_columns=deps.batchColumns, - preservation_columns=deps.preservationColumns, - harmony_authorized=deps.harmonyAuthorized, - max_refined_candidates=max_refined_candidates, - ), - deps_type=ParameterTuningDependencies, - deps=deps, - config=run_config, - name="parameter_search_planning", - output_validator=( - lambda proposed_plan: validate_parameter_search_plan( - proposed_plan, - deps, - initial_candidate_ids=initial_candidate_ids, - max_refined_candidates=max_refined_candidates, - ) - ), - ) - except (UnexpectedModelBehavior, UsageLimitExceeded) as exc: - logger.warning( - f"Parameter refinement planning for assay {from_assay!r} " - f"failed within its model-run bounds ({type(exc).__name__}); " - "pausing without a refinement decision" - ) - successful_parent = next( - ( - evaluation - for evaluation in initial_evaluations - if evaluation.status == "done" and evaluation.eligible - ), - initial_evaluations[0], - ) - failed_plan = ParameterSearchPlan( - status="complete", - basedOnCandidateIds=[successful_parent.candidateId], - rationale=("The required bounded refinement review was unavailable."), - evidenceIds=sorted( - { - evidence_id - for evaluation in initial_evaluations - for evidence_id in evaluation.evidenceIds - } - ), - stoppingCriteria=[ - "Obtain a grounded refinement disposition before selection." - ], - runInfo=AgentRunInfo(agentName="parameter_search_planning_needs_input"), - ) - refinement_planning_failed = True - plan = failed_plan - else: - if not isinstance(planning_execution.output, ParameterSearchPlan): - raise TypeError( - "Parameter search planner returned an unexpected output type" - ) - plan = validate_parameter_search_plan( - planning_execution.output, - deps, - initial_candidate_ids=initial_candidate_ids, - max_refined_candidates=max_refined_candidates, - ).model_copy(update={"runInfo": planning_execution.runInfo}) - logger.info( - f"Completed parameter refinement plan for assay " - f"{from_assay!r}: status={plan.status}, " - f"candidates={len(plan.candidates)}" - ) - - _register_refined_parameter_candidates(deps, plan.candidates) - - evaluations = [ - deps.evaluations[candidate_id] - for candidate_id in deps.executionOrder - if candidate_id in deps.evaluations - ] - try: - logger.info( - f"Requesting parameter selection for assay {from_assay!r} across " - f"{len(evaluations)} executed candidates" - ) - selection_execution = run_agent_sync( - model=model, - output_type=ParameterTuningReport, - system_prompt=parameter_tuning_system_prompt(min_cluster_cells), - user_prompt=parameter_tuning_prompt( - from_assay=from_assay, - cell_selection=cell_selection, - evaluations=evaluations, - batch_columns=deps.batchColumns, - preservation_columns=deps.preservationColumns, - search_plan=plan, - ), - deps_type=ParameterTuningDependencies, - deps=deps, - config=run_config, - name="parameter_tuning", - output_validator=lambda report: validate_parameter_tuning_report( - report, - deps, - search_plan=plan, - ), - ) - except (UnexpectedModelBehavior, UsageLimitExceeded) as exc: - logger.warning( - f"Parameter selection for assay {from_assay!r} failed within its " - f"model-run bounds ({type(exc).__name__}); returning needsInput " - "with the completed executor evidence" - ) - return pending_parameter_tuning_report( - deps, - search_plan=plan, - agent_name="parameter_tuning_needs_input", - ) - if not isinstance(selection_execution.output, ParameterTuningReport): - raise TypeError("Parameter tuning agent returned an unexpected output type") - if refinement_planning_failed: - return pending_parameter_tuning_report( - deps, - search_plan=plan, - agent_name="parameter_tuning_needs_input", - ) - report = validate_parameter_tuning_report( - selection_execution.output, - deps, - search_plan=plan, - ) - completed_report = report.model_copy( - update={"runInfo": selection_execution.runInfo} - ) - logger.info( - f"Completed parameter tuning for assay {from_assay!r}: " - f"status={completed_report.status}, " - f"selected={completed_report.recommendedCandidateId!r}, " - f"candidates={len(completed_report.evaluations)}" - ) - return completed_report - - -__all__ = [ - "annotate_candidate_dominance", - "ArtifactRecord", - "build_initial_parameter_candidates", - "CandidateComparison", - "execute_parameter_candidate", - "execute_parameter_search_plan", - "final_graph_options", - "final_graph_selection_prompt", - "final_graph_selection_system_prompt", - "FinalGraphComparison", - "FinalGraphNeedsInput", - "FinalGraphSelection", - "finalize_parameter_tuning_selection", - "IntegrationCandidateEvaluation", - "IntegrationMetrics", - "normalized_artifact_shape", - "ParameterCandidate", - "ParameterCandidateEvaluation", - "ParameterMetrics", - "ParameterSearchPlan", - "ParameterTuningAssayInput", - "ParameterTuningAgent", - "ParameterTuningBatchSearchPlan", - "ParameterTuningDependencies", - "ParameterTuningNeedsInput", - "ParameterTuningReport", - "evaluate_parameter_candidate", - "get_default_parameter_candidates", - "harmony_acceptance_gate", - "parameter_batch_search_prompt", - "parameter_batch_search_system_prompt", - "parameter_batch_selection_prompt", - "parameter_batch_selection_system_prompt", - "parameter_search_prompt", - "parameter_search_system_prompt", - "parameter_evidence_classes", - "parameter_tuning_prompt", - "parameter_tuning_system_prompt", - "prepare_parameter_tuning_dependencies", - "promote_parameter_candidate", - "run_candidate_reduction", - "require_dominated_candidate_evidence", - "select_final_parameter_graph", - "tune_parameters", - "tune_parameters_batch", - "validate_parameter_batch_search_plan", - "validate_parameter_candidate_rank", - "validate_final_graph_selection", - "validate_parameter_search_plan", - "validate_parameter_tuning_batch_report", - "validate_parameter_tuning_report", -] diff --git a/scarf/agent/parameter_tuning/__init__.py b/scarf/agent/parameter_tuning/__init__.py new file mode 100644 index 00000000..921713d6 --- /dev/null +++ b/scarf/agent/parameter_tuning/__init__.py @@ -0,0 +1,117 @@ +"""Bounded parameter tuning over explicit Scarf analysis candidates.""" + +from .agent import ( + ParameterTuningAgent, + execute_parameter_search_plan, + prepare_parameter_tuning_dependencies, + tune_parameters, + tune_parameters_batch, +) +from .contracts import ( + ArtifactRecord, + CandidateComparison, + FinalGraphComparison, + FinalGraphNeedsInput, + FinalGraphSelection, + IntegrationCandidateEvaluation, + IntegrationMetrics, + ParameterCandidate, + ParameterCandidateEvaluation, + ParameterMetrics, + ParameterSearchPlan, + ParameterTuningAssayInput, + ParameterTuningBatchSearchPlan, + ParameterTuningDependencies, + ParameterTuningNeedsInput, + ParameterTuningReport, +) +from .execution import ( + evaluate_parameter_candidate, + execute_parameter_candidate, + normalized_artifact_shape, + run_candidate_reduction, + validate_parameter_candidate_rank, +) +from .prompts import ( + build_initial_parameter_candidates, + final_graph_selection_prompt, + final_graph_selection_system_prompt, + get_default_parameter_candidates, + parameter_batch_search_prompt, + parameter_batch_search_system_prompt, + parameter_batch_selection_prompt, + parameter_batch_selection_system_prompt, + parameter_search_prompt, + parameter_search_system_prompt, + parameter_tuning_prompt, + parameter_tuning_system_prompt, +) +from .selection import ( + annotate_candidate_dominance, + final_graph_options, + finalize_parameter_tuning_selection, + harmony_acceptance_gate, + parameter_evidence_classes, + promote_parameter_candidate, + require_dominated_candidate_evidence, + select_final_parameter_graph, + validate_final_graph_selection, + validate_parameter_batch_search_plan, + validate_parameter_search_plan, + validate_parameter_tuning_batch_report, + validate_parameter_tuning_report, +) + +__all__ = [ + "annotate_candidate_dominance", + "ArtifactRecord", + "build_initial_parameter_candidates", + "CandidateComparison", + "execute_parameter_candidate", + "execute_parameter_search_plan", + "final_graph_options", + "final_graph_selection_prompt", + "final_graph_selection_system_prompt", + "FinalGraphComparison", + "FinalGraphNeedsInput", + "FinalGraphSelection", + "finalize_parameter_tuning_selection", + "IntegrationCandidateEvaluation", + "IntegrationMetrics", + "normalized_artifact_shape", + "ParameterCandidate", + "ParameterCandidateEvaluation", + "ParameterMetrics", + "ParameterSearchPlan", + "ParameterTuningAssayInput", + "ParameterTuningAgent", + "ParameterTuningBatchSearchPlan", + "ParameterTuningDependencies", + "ParameterTuningNeedsInput", + "ParameterTuningReport", + "evaluate_parameter_candidate", + "get_default_parameter_candidates", + "harmony_acceptance_gate", + "parameter_batch_search_prompt", + "parameter_batch_search_system_prompt", + "parameter_batch_selection_prompt", + "parameter_batch_selection_system_prompt", + "parameter_search_prompt", + "parameter_search_system_prompt", + "parameter_evidence_classes", + "parameter_tuning_prompt", + "parameter_tuning_system_prompt", + "prepare_parameter_tuning_dependencies", + "promote_parameter_candidate", + "run_candidate_reduction", + "require_dominated_candidate_evidence", + "select_final_parameter_graph", + "tune_parameters", + "tune_parameters_batch", + "validate_parameter_batch_search_plan", + "validate_parameter_candidate_rank", + "validate_final_graph_selection", + "validate_parameter_search_plan", + "validate_parameter_tuning_batch_report", + "validate_parameter_tuning_report", +] diff --git a/scarf/agent/parameter_tuning/agent.py b/scarf/agent/parameter_tuning/agent.py new file mode 100644 index 00000000..f80cd137 --- /dev/null +++ b/scarf/agent/parameter_tuning/agent.py @@ -0,0 +1,934 @@ +from collections.abc import Mapping, Sequence +from typing import Any + +from ...storage.refs import ArtifactRef +from ...utils.logging import logger +from .._deps import AGENT_INSTALL_HINT +from ..config import AgentRunConfig +from ..config.agent_exec import run_agent_sync +from ..tools import artifact_reference, core_artifact_reference +from ..types import AgentRunInfo, ExperimentalTuningHandoff +from .contracts import ( + _CANDIDATE_ID, + IntegrationCandidateEvaluation, + ParameterCandidate, + ParameterCandidateEvaluation, + ParameterSearchPlan, + ParameterTuningAssayInput, + ParameterTuningBatchSearchPlan, + ParameterTuningDependencies, + ParameterTuningReport, +) +from .execution import execute_parameter_candidate, normalized_artifact_shape +from .prompts import ( + build_initial_parameter_candidates, + get_default_parameter_candidates, + parameter_batch_search_prompt, + parameter_batch_search_system_prompt, + parameter_batch_selection_prompt, + parameter_batch_selection_system_prompt, + parameter_search_prompt, + parameter_search_system_prompt, + parameter_tuning_prompt, + parameter_tuning_system_prompt, +) +from .selection import ( + annotate_candidate_dominance, + pending_parameter_tuning_batch_report, + pending_parameter_tuning_report, + promote_parameter_candidate, + select_final_parameter_graph, + validate_parameter_batch_search_plan, + validate_parameter_search_plan, + validate_parameter_tuning_batch_report, + validate_parameter_tuning_report, +) + +try: + from pydantic_ai import UnexpectedModelBehavior, UsageLimitExceeded +except ImportError as exc: + raise ImportError(AGENT_INSTALL_HINT) from exc + + +_MAX_CANDIDATES_OFFERED = 25 + + +def _resolve_experimental_tuning_handoff( + *, + normalized_cell_selection: ArtifactRef, + batch_columns: Sequence[str], + preservation_columns: Sequence[str], + experimental_handoff: ExperimentalTuningHandoff | None, +) -> tuple[ArtifactRef, list[str], list[str]]: + resolved_batch_columns = list(batch_columns) + resolved_preservation_columns = list(preservation_columns) + if experimental_handoff is None: + return ( + normalized_cell_selection, + resolved_batch_columns, + resolved_preservation_columns, + ) + + handoff_batch_columns = list(experimental_handoff.batchColumns) + canonical_batch_columns = sorted(set(handoff_batch_columns)) + if len(canonical_batch_columns) != len(handoff_batch_columns): + raise ValueError("experimental_handoff batch columns must be unique") + handoff_cell_selection = core_artifact_reference(experimental_handoff.cellSelection) + if not isinstance(handoff_cell_selection, ArtifactRef): + raise ValueError("experimental_handoff lacks an exact cell selection") + if handoff_cell_selection != normalized_cell_selection: + raise ValueError("normalized selection conflicts with experimental_handoff") + if resolved_batch_columns and sorted(resolved_batch_columns) != ( + canonical_batch_columns + ): + raise ValueError("batch_columns conflict with experimental_handoff") + if resolved_preservation_columns and resolved_preservation_columns != list( + experimental_handoff.preservationColumns + ): + raise ValueError("preservation_columns conflict with experimental_handoff") + if experimental_handoff.batchAction == "needsInput": + raise ValueError("Experimental Context requires input before tuning") + if experimental_handoff.batchAction == "skip" and experimental_handoff.batchColumns: + raise ValueError("A skip handoff must not contain batch columns") + if experimental_handoff.batchAction == "evaluateHarmony": + expected_coefficients = set(experimental_handoff.coefficientsOfInterest) + safe_coefficients = { + item.coefficient + for item in experimental_handoff.batchSafety + if item.status == "safe" and item.batchColumns == canonical_batch_columns + } + if ( + not expected_coefficients + or not canonical_batch_columns + or safe_coefficients != expected_coefficients + ): + raise ValueError( + "Harmony handoff lacks safe evidence for every coefficient" + ) + if experimental_handoff.batchAction == "unsafe": + expected_coefficients = set(experimental_handoff.coefficientsOfInterest) + exact_safety = [ + item + for item in experimental_handoff.batchSafety + if item.batchColumns == canonical_batch_columns + and item.coefficient in expected_coefficients + ] + if ( + not expected_coefficients + or {item.coefficient for item in exact_safety} != expected_coefficients + or any(item.status == "notComputed" for item in exact_safety) + or not any(item.status == "unsafe" for item in exact_safety) + ): + raise ValueError("Unsafe handoff lacks exact unsafe batch evidence") + if any( + item.evidenceId not in experimental_handoff.evidenceIds + for item in experimental_handoff.batchSafety + ): + raise ValueError("Experimental handoff does not cite its batch evidence") + return ( + normalized_cell_selection, + canonical_batch_columns, + list(experimental_handoff.preservationColumns), + ) + + +def prepare_parameter_tuning_dependencies( + store: Any, + *, + normalized: ArtifactRef, + candidates: Sequence[ParameterCandidate] | None = None, + batch_columns: Sequence[str] = (), + preservation_columns: Sequence[str] = (), + experimental_handoff: ExperimentalTuningHandoff | None = None, + max_candidates: int = 5, + max_refined_candidates: int = 0, + allow_harmony_refinement: bool = True, + pair_harmony_candidates: bool | None = None, + min_cluster_cells: int = 20, + identity_feature_limit: int = 64, +) -> tuple[ParameterTuningDependencies, list[str]]: + """Validate one assay request and construct branch-safe dependencies.""" + + if max_candidates < 1: + raise ValueError("max_candidates must be at least one") + if max_refined_candidates < 0: + raise ValueError("max_refined_candidates must be non-negative") + if pair_harmony_candidates is not None and not isinstance( + pair_harmony_candidates, + bool, + ): + raise TypeError("pair_harmony_candidates must be a boolean or None") + if min_cluster_cells < 1: + raise ValueError("min_cluster_cells must be at least one") + if identity_feature_limit < 2: + raise ValueError("identity_feature_limit must be at least two") + normalized = core_artifact_reference(normalized) + if not isinstance(normalized, ArtifactRef) or normalized.kind != "normalized": + raise TypeError("normalized must be a normalized ArtifactRef") + if normalized.assay is None: + raise ValueError("normalized artifact has no assay") + normalized_status = store.inspect_artifact(normalized) + if not getattr(normalized_status, "exists", True): + raise ValueError("normalized artifact does not exist") + if not getattr(normalized_status, "complete", False): + raise ValueError("normalized artifact is incomplete") + raw_cell_selection = (getattr(normalized_status, "inputs", None) or {}).get( + "cell_selection" + ) + if not isinstance(raw_cell_selection, Mapping): + raise ValueError("normalized artifact has no cell-selection input") + normalized_cell_selection = ArtifactRef.from_dict(dict(raw_cell_selection)) + if ( + normalized_cell_selection.scope != "datastore" + or normalized_cell_selection.kind != "cell_selection" + or normalized_cell_selection.assay is not None + ): + raise ValueError("normalized artifact has an invalid cell-selection input") + from_assay = normalized.assay + ( + resolved_cell_selection, + resolved_batch_columns, + resolved_preservation_columns, + ) = _resolve_experimental_tuning_handoff( + normalized_cell_selection=normalized_cell_selection, + batch_columns=batch_columns, + preservation_columns=preservation_columns, + experimental_handoff=experimental_handoff, + ) + if len(set(resolved_batch_columns)) != len(resolved_batch_columns): + raise ValueError("batch_columns must be unique") + seed_candidates = ( + get_default_parameter_candidates() if candidates is None else list(candidates) + ) + if not seed_candidates: + raise ValueError("candidates must be non-empty") + if len(seed_candidates) > max_candidates: + raise ValueError( + f"Initial candidate count exceeds max_candidates={max_candidates}" + ) + pair_harmony = ( + ( + experimental_handoff is not None + and experimental_handoff.batchAction == "evaluateHarmony" + ) + if pair_harmony_candidates is None + else pair_harmony_candidates + ) + candidate_values = build_initial_parameter_candidates( + seed_candidates, + pair_harmony=pair_harmony, + ) + if len(candidate_values) + max_refined_candidates > _MAX_CANDIDATES_OFFERED: + raise ValueError( + "Initial and refined candidates may contain at most " + f"{_MAX_CANDIDATES_OFFERED} values" + ) + candidate_map: dict[str, ParameterCandidate] = {} + for candidate in candidate_values: + if not _CANDIDATE_ID.fullmatch(candidate.candidateId): + raise ValueError( + "candidateId must contain only ASCII letters, numbers, and underscores" + ) + if candidate.candidateId in candidate_map: + raise ValueError(f"Duplicate candidateId {candidate.candidateId!r}") + if candidate.useHarmony and not resolved_batch_columns: + raise ValueError( + f"Candidate {candidate.candidateId!r} requires batch_columns" + ) + if ( + candidate.useHarmony + and experimental_handoff is not None + and experimental_handoff.batchAction != "evaluateHarmony" + ): + raise ValueError( + f"Candidate {candidate.candidateId!r} is not authorized for Harmony" + ) + candidate_map[candidate.candidateId] = candidate + harmony_authorized = ( + allow_harmony_refinement + and bool(resolved_batch_columns) + and ( + experimental_handoff is None + or experimental_handoff.batchAction == "evaluateHarmony" + ) + ) + normalized_shape = normalized_artifact_shape(store, normalized) + deps = ParameterTuningDependencies( + store=store, + normalized=normalized, + cellSelection=resolved_cell_selection, + normalizedShape=normalized_shape, + fromAssay=from_assay, + candidates=candidate_map, + candidatePhases={candidate_id: "initial" for candidate_id in candidate_map}, + batchColumns=tuple(resolved_batch_columns), + preservationColumns=tuple(resolved_preservation_columns), + harmonyAuthorized=harmony_authorized, + maxCandidates=len(candidate_values) + max_refined_candidates, + minClusterCells=min_cluster_cells, + identityFeatureLimit=identity_feature_limit, + ) + return deps, list(candidate_map) + + +class ParameterTuningAgent: + """Run bounded tuning over caller-authorized Scarf candidates.""" + + def __init__( + self, + model: Any, + *, + config: AgentRunConfig | None = None, + ) -> None: + self.model = model + self.config = (config or AgentRunConfig()).with_limits( + request_limit=6, + tool_call_limit=5, + output_token_limit=32768, + timeout_seconds=600.0, + ) + + def run( + self, + store: Any, + *, + normalized: Any, + candidates: Sequence[ParameterCandidate] | None = None, + batch_columns: Sequence[str] = (), + preservation_columns: Sequence[str] = (), + experimental_handoff: ExperimentalTuningHandoff | None = None, + max_candidates: int = 5, + max_refined_candidates: int = 0, + min_cluster_cells: int = 20, + identity_feature_limit: int = 64, + ) -> ParameterTuningReport: + """Run deterministic screening, optional refinement, and final selection.""" + return tune_parameters( + store, + model=self.model, + normalized=normalized, + candidates=candidates, + batch_columns=batch_columns, + preservation_columns=preservation_columns, + experimental_handoff=experimental_handoff, + max_candidates=max_candidates, + max_refined_candidates=max_refined_candidates, + min_cluster_cells=min_cluster_cells, + identity_feature_limit=identity_feature_limit, + config=self.config, + ) + + def promote( + self, + store: Any, + *, + report: ParameterTuningReport, + normalized: Any, + identity_feature_limit: int = 64, + ) -> ParameterCandidateEvaluation: + """Resolve the exact selected native branch without mutating state.""" + + return promote_parameter_candidate( + store, + report=report, + normalized=normalized, + identity_feature_limit=identity_feature_limit, + ) + + def run_batch( + self, + store: Any, + *, + assays: Sequence[ParameterTuningAssayInput], + primary_assay: str | None = None, + max_total_candidates: int = 24, + selection_directions: str = "", + ) -> ParameterTuningReport: + """Tune several assays with one planning and one selection request.""" + + return tune_parameters_batch( + store, + model=self.model, + assays=assays, + primary_assay=primary_assay, + max_total_candidates=max_total_candidates, + selection_directions=selection_directions, + config=self.config, + ) + + def select_final( + self, + *, + report: ParameterTuningReport, + integration_evaluations: Sequence[IntegrationCandidateEvaluation], + marker_assay: str, + ) -> ParameterTuningReport: + """Select native, SNN, or WNN once and attach the final branch.""" + + return select_final_parameter_graph( + model=self.model, + report=report, + integration_evaluations=integration_evaluations, + marker_assay=marker_assay, + config=self.config, + ) + + +def _execute_parameter_candidates( + deps: ParameterTuningDependencies, + candidate_ids: Sequence[str], +) -> None: + logger.info( + f"Executing {len(candidate_ids)} parameter candidate(s) for assay " + f"{deps.fromAssay!r}" + ) + for candidate_id in candidate_ids: + execute_parameter_candidate(deps, candidate_id) + _refresh_candidate_dominance(deps) + + +def _refresh_candidate_dominance(deps: ParameterTuningDependencies) -> None: + ordered = [ + deps.evaluations[candidate_id] + for candidate_id in deps.executionOrder + if candidate_id in deps.evaluations + ] + deps.evaluations.update( + { + evaluation.candidateId: evaluation + for evaluation in annotate_candidate_dominance(ordered) + } + ) + + +def _register_refined_parameter_candidates( + deps: ParameterTuningDependencies, + candidates: Sequence[ParameterCandidate], +) -> None: + if candidates: + logger.info( + f"Executing {len(candidates)} refined parameter candidate(s) for " + f"assay {deps.fromAssay!r}" + ) + for candidate in candidates: + deps.candidates[candidate.candidateId] = candidate + deps.candidatePhases[candidate.candidateId] = "refined" + execute_parameter_candidate(deps, candidate.candidateId) + _refresh_candidate_dominance(deps) + + +def execute_parameter_search_plan( + deps: ParameterTuningDependencies, + plan: ParameterSearchPlan, + *, + initial_candidate_ids: Sequence[str], + max_refined_candidates: int, +) -> tuple[ParameterSearchPlan, tuple[ParameterCandidateEvaluation, ...]]: + """Validate and execute one already-proposed bounded refinement plan.""" + + validated = validate_parameter_search_plan( + plan, + deps, + initial_candidate_ids=initial_candidate_ids, + max_refined_candidates=max_refined_candidates, + ) + _register_refined_parameter_candidates(deps, validated.candidates) + return ( + validated, + tuple( + deps.evaluations[candidate.candidateId] + for candidate in validated.candidates + ), + ) + + +def tune_parameters_batch( + store: Any, + *, + model: Any, + assays: Sequence[ParameterTuningAssayInput], + primary_assay: str | None = None, + max_total_candidates: int = 24, + selection_directions: str = "", + config: AgentRunConfig | None = None, +) -> ParameterTuningReport: + """Execute and select modality-specific native branches in two model calls.""" + + assay_inputs = list(assays) + if not assay_inputs: + raise ValueError("assays must contain at least one tuning input") + if max_total_candidates < 1: + raise ValueError("max_total_candidates must be at least one") + planned_total = sum(item.maxCandidates for item in assay_inputs) + if planned_total > max_total_candidates: + raise ValueError( + f"Batched tuning requests {planned_total} candidate branches; " + f"the global limit is {max_total_candidates}" + ) + normalized_refs = [ + core_artifact_reference(item.normalized) for item in assay_inputs + ] + if any( + not isinstance(ref, ArtifactRef) + or ref.kind != "normalized" + or ref.assay is None + for ref in normalized_refs + ): + raise TypeError( + "Every batched tuning input requires an assay-scoped normalized artifact" + ) + assay_names = [ref.assay for ref in normalized_refs] + if len(set(assay_names)) != len(assay_names): + raise ValueError("Batched tuning assay names must be unique") + resolved_primary = primary_assay or assay_names[0] + if resolved_primary not in assay_names: + raise ValueError(f"Unknown primary assay {resolved_primary!r}") + logger.info( + f"Starting batched parameter tuning for {len(assay_names)} assay(s); " + f"primary_assay={resolved_primary!r}, " + f"candidate_limit={max_total_candidates}" + ) + + dependencies: dict[str, ParameterTuningDependencies] = {} + initial_ids: dict[str, list[str]] = {} + max_refined_by_assay: dict[str, int] = {} + for item, assay_name in zip(assay_inputs, assay_names, strict=True): + deps, candidate_ids = prepare_parameter_tuning_dependencies( + store, + normalized=item.normalized, + candidates=item.candidates or None, + batch_columns=item.batchColumns, + preservation_columns=item.preservationColumns, + experimental_handoff=item.experimentalHandoff, + max_candidates=item.maxCandidates, + max_refined_candidates=item.maxRefinedCandidates, + allow_harmony_refinement=item.allowHarmonyRefinement, + min_cluster_cells=item.minClusterCells, + identity_feature_limit=item.identityFeatureLimit, + ) + dependencies[assay_name] = deps + initial_ids[assay_name] = candidate_ids + max_refined_by_assay[assay_name] = item.maxRefinedCandidates + cell_selections = {deps.cellSelection for deps in dependencies.values()} + if len(cell_selections) != 1: + raise ValueError("Batched tuning inputs must use the same cell selection") + for assay in assay_names: + deps = dependencies[assay] + _execute_parameter_candidates(deps, initial_ids[assay]) + logger.info( + "Completed batched initial parameter screen: " + + ", ".join( + f"{assay}={len(dependencies[assay].evaluations)}" for assay in assay_names + ) + ) + + run_config = (config or AgentRunConfig()).with_limits( + request_limit=6, + tool_call_limit=5, + output_token_limit=32768, + timeout_seconds=600.0, + ) + refinement_planning_failed = False + if any(max_refined_by_assay.values()): + try: + logger.info( + "Requesting one batched parameter refinement plan for " + f"{len(assay_names)} assay(s)" + ) + planning_execution = run_agent_sync( + model=model, + output_type=ParameterTuningBatchSearchPlan, + system_prompt=parameter_batch_search_system_prompt(), + user_prompt=parameter_batch_search_prompt( + dependencies, + max_refined_by_assay, + ), + deps_type=ParameterTuningDependencies, + deps=dependencies[resolved_primary], + config=run_config, + name="parameter_batch_search_planning", + output_validator=( + lambda proposed: validate_parameter_batch_search_plan( + proposed, + dependencies, + initial_candidate_ids=initial_ids, + max_refined_by_assay=max_refined_by_assay, + ) + ), + ) + except (UnexpectedModelBehavior, UsageLimitExceeded) as exc: + logger.warning( + "Batched parameter refinement model run failed within its bounds " + f"({type(exc).__name__}); pausing without a refinement decision" + ) + refinement_planning_failed = True + failed_plans = { + assay: ParameterSearchPlan( + status="complete", + basedOnCandidateIds=[ + next( + ( + candidate_id + for candidate_id in initial_ids[assay] + if dependencies[assay].evaluations[candidate_id].status + == "done" + and dependencies[assay] + .evaluations[candidate_id] + .eligible + ), + initial_ids[assay][0], + ) + ], + rationale=( + "The required bounded refinement review was unavailable." + ), + evidenceIds=sorted( + { + evidence_id + for candidate_id in initial_ids[assay] + for evidence_id in dependencies[assay] + .evaluations[candidate_id] + .evidenceIds + } + ), + stoppingCriteria=[ + "Obtain a grounded refinement disposition before selection." + ], + runInfo=AgentRunInfo( + agentName="parameter_batch_search_planning_needs_input" + ), + ) + for assay in assay_names + } + batch_plan = ParameterTuningBatchSearchPlan( + assayPlans=failed_plans, + runInfo=AgentRunInfo( + agentName="parameter_batch_search_planning_needs_input" + ), + ) + else: + if not isinstance( + planning_execution.output, ParameterTuningBatchSearchPlan + ): + raise TypeError("Batched parameter planner returned an unexpected type") + validated_batch_plan = validate_parameter_batch_search_plan( + planning_execution.output, + dependencies, + initial_candidate_ids=initial_ids, + max_refined_by_assay=max_refined_by_assay, + ) + batch_plan = validated_batch_plan.model_copy( + update={ + "assayPlans": { + assay: plan.model_copy( + update={"runInfo": planning_execution.runInfo} + ) + for assay, plan in validated_batch_plan.assayPlans.items() + }, + "runInfo": planning_execution.runInfo, + } + ) + logger.info( + "Completed batched parameter refinement plan: " + + ", ".join( + f"{assay}={len(plan.candidates)}" + for assay, plan in batch_plan.assayPlans.items() + ) + ) + else: + logger.info( + "Skipping batched parameter refinement because it is not authorized" + ) + batch_plan = ParameterTuningBatchSearchPlan( + assayPlans={ + assay: ParameterSearchPlan( + status="complete", + rationale=( + "Refinement was not authorized because " + "maxRefinedCandidates is zero." + ), + stoppingCriteria=[ + "Use the completed initial screen without refinement." + ], + ) + for assay in assay_names + } + ) + for assay, plan in batch_plan.assayPlans.items(): + deps = dependencies[assay] + _register_refined_parameter_candidates(deps, plan.candidates) + + try: + logger.info( + f"Requesting batched parameter selection across " + f"{sum(len(deps.evaluations) for deps in dependencies.values())} " + "executed candidates" + ) + selection_execution = run_agent_sync( + model=model, + output_type=ParameterTuningReport, + system_prompt=parameter_batch_selection_system_prompt(), + user_prompt=parameter_batch_selection_prompt( + dependencies, + batch_plan.assayPlans, + resolved_primary, + selection_directions, + ), + deps_type=ParameterTuningDependencies, + deps=dependencies[resolved_primary], + config=run_config, + name="parameter_tuning_batch", + output_validator=lambda proposed: validate_parameter_tuning_batch_report( + proposed, + dependencies, + search_plans=batch_plan.assayPlans, + primary_assay=resolved_primary, + ), + ) + except (UnexpectedModelBehavior, UsageLimitExceeded) as exc: + logger.warning( + "Batched parameter selection model run failed within its bounds " + f"({type(exc).__name__}); " + "returning needsInput with the completed executor evidence" + ) + return pending_parameter_tuning_batch_report( + dependencies, + search_plans=batch_plan.assayPlans, + primary_assay=resolved_primary, + ) + if not isinstance(selection_execution.output, ParameterTuningReport): + raise TypeError("Batched parameter tuning returned an unexpected type") + if refinement_planning_failed: + return pending_parameter_tuning_batch_report( + dependencies, + search_plans=batch_plan.assayPlans, + primary_assay=resolved_primary, + ) + report = validate_parameter_tuning_batch_report( + selection_execution.output, + dependencies, + search_plans=batch_plan.assayPlans, + primary_assay=resolved_primary, + ) + completed_report = report.model_copy( + update={"runInfo": selection_execution.runInfo} + ) + logger.info( + f"Completed batched parameter tuning: status={completed_report.status}, " + f"assays={len(completed_report.assayReports)}, " + f"candidates={completed_report.totalCandidates}" + ) + return completed_report + + +def tune_parameters( + store: Any, + *, + model: Any, + normalized: Any, + candidates: Sequence[ParameterCandidate] | None = None, + batch_columns: Sequence[str] = (), + preservation_columns: Sequence[str] = (), + experimental_handoff: ExperimentalTuningHandoff | None = None, + max_candidates: int = 5, + max_refined_candidates: int = 0, + min_cluster_cells: int = 20, + identity_feature_limit: int = 64, + config: AgentRunConfig | None = None, +) -> ParameterTuningReport: + """Run the bounded parameter tuning agent against an existing DataStore.""" + + deps, initial_candidate_ids = prepare_parameter_tuning_dependencies( + store, + normalized=normalized, + candidates=candidates, + batch_columns=batch_columns, + preservation_columns=preservation_columns, + experimental_handoff=experimental_handoff, + max_candidates=max_candidates, + max_refined_candidates=max_refined_candidates, + min_cluster_cells=min_cluster_cells, + identity_feature_limit=identity_feature_limit, + ) + from_assay = deps.fromAssay + cell_selection = artifact_reference(deps.cellSelection) + logger.info( + f"Starting parameter tuning for assay {from_assay!r}; " + f"candidate_limit={max_candidates}, " + f"refinement_limit={max_refined_candidates}" + ) + run_config = (config or AgentRunConfig()).with_limits( + request_limit=6, + tool_call_limit=5, + output_token_limit=32768, + timeout_seconds=600.0, + ) + _execute_parameter_candidates(deps, initial_candidate_ids) + initial_evaluations = [ + deps.evaluations[candidate_id] for candidate_id in initial_candidate_ids + ] + + refinement_planning_failed = False + if max_refined_candidates == 0: + logger.info( + f"Skipping parameter refinement for assay {from_assay!r} because it " + "is not authorized" + ) + plan = ParameterSearchPlan( + status="complete", + rationale=( + "Refinement was not authorized because max_refined_candidates is zero." + ), + stoppingCriteria=[ + "Use the completed initial screen without a refinement pass." + ], + ) + else: + try: + logger.info( + f"Requesting parameter refinement plan for assay {from_assay!r} " + f"from {len(initial_evaluations)} initial evaluations" + ) + planning_execution = run_agent_sync( + model=model, + output_type=ParameterSearchPlan, + system_prompt=parameter_search_system_prompt(), + user_prompt=parameter_search_prompt( + from_assay=from_assay, + cell_selection=cell_selection, + evaluations=initial_evaluations, + batch_columns=deps.batchColumns, + preservation_columns=deps.preservationColumns, + harmony_authorized=deps.harmonyAuthorized, + max_refined_candidates=max_refined_candidates, + ), + deps_type=ParameterTuningDependencies, + deps=deps, + config=run_config, + name="parameter_search_planning", + output_validator=( + lambda proposed_plan: validate_parameter_search_plan( + proposed_plan, + deps, + initial_candidate_ids=initial_candidate_ids, + max_refined_candidates=max_refined_candidates, + ) + ), + ) + except (UnexpectedModelBehavior, UsageLimitExceeded) as exc: + logger.warning( + f"Parameter refinement planning for assay {from_assay!r} " + f"failed within its model-run bounds ({type(exc).__name__}); " + "pausing without a refinement decision" + ) + successful_parent = next( + ( + evaluation + for evaluation in initial_evaluations + if evaluation.status == "done" and evaluation.eligible + ), + initial_evaluations[0], + ) + failed_plan = ParameterSearchPlan( + status="complete", + basedOnCandidateIds=[successful_parent.candidateId], + rationale=("The required bounded refinement review was unavailable."), + evidenceIds=sorted( + { + evidence_id + for evaluation in initial_evaluations + for evidence_id in evaluation.evidenceIds + } + ), + stoppingCriteria=[ + "Obtain a grounded refinement disposition before selection." + ], + runInfo=AgentRunInfo(agentName="parameter_search_planning_needs_input"), + ) + refinement_planning_failed = True + plan = failed_plan + else: + if not isinstance(planning_execution.output, ParameterSearchPlan): + raise TypeError( + "Parameter search planner returned an unexpected output type" + ) + plan = validate_parameter_search_plan( + planning_execution.output, + deps, + initial_candidate_ids=initial_candidate_ids, + max_refined_candidates=max_refined_candidates, + ).model_copy(update={"runInfo": planning_execution.runInfo}) + logger.info( + f"Completed parameter refinement plan for assay " + f"{from_assay!r}: status={plan.status}, " + f"candidates={len(plan.candidates)}" + ) + + _register_refined_parameter_candidates(deps, plan.candidates) + + evaluations = [ + deps.evaluations[candidate_id] + for candidate_id in deps.executionOrder + if candidate_id in deps.evaluations + ] + try: + logger.info( + f"Requesting parameter selection for assay {from_assay!r} across " + f"{len(evaluations)} executed candidates" + ) + selection_execution = run_agent_sync( + model=model, + output_type=ParameterTuningReport, + system_prompt=parameter_tuning_system_prompt(min_cluster_cells), + user_prompt=parameter_tuning_prompt( + from_assay=from_assay, + cell_selection=cell_selection, + evaluations=evaluations, + batch_columns=deps.batchColumns, + preservation_columns=deps.preservationColumns, + search_plan=plan, + ), + deps_type=ParameterTuningDependencies, + deps=deps, + config=run_config, + name="parameter_tuning", + output_validator=lambda report: validate_parameter_tuning_report( + report, + deps, + search_plan=plan, + ), + ) + except (UnexpectedModelBehavior, UsageLimitExceeded) as exc: + logger.warning( + f"Parameter selection for assay {from_assay!r} failed within its " + f"model-run bounds ({type(exc).__name__}); returning needsInput " + "with the completed executor evidence" + ) + return pending_parameter_tuning_report( + deps, + search_plan=plan, + agent_name="parameter_tuning_needs_input", + ) + if not isinstance(selection_execution.output, ParameterTuningReport): + raise TypeError("Parameter tuning agent returned an unexpected output type") + if refinement_planning_failed: + return pending_parameter_tuning_report( + deps, + search_plan=plan, + agent_name="parameter_tuning_needs_input", + ) + report = validate_parameter_tuning_report( + selection_execution.output, + deps, + search_plan=plan, + ) + completed_report = report.model_copy( + update={"runInfo": selection_execution.runInfo} + ) + logger.info( + f"Completed parameter tuning for assay {from_assay!r}: " + f"status={completed_report.status}, " + f"selected={completed_report.recommendedCandidateId!r}, " + f"candidates={len(completed_report.evaluations)}" + ) + return completed_report diff --git a/scarf/agent/parameter_tuning/contracts.py b/scarf/agent/parameter_tuning/contracts.py new file mode 100644 index 00000000..96bf2155 --- /dev/null +++ b/scarf/agent/parameter_tuning/contracts.py @@ -0,0 +1,750 @@ +import re +from threading import Lock +from typing import Any, Literal + +from .._deps import AGENT_INSTALL_HINT +from ..types import ( + AgentDataModel, + AgentRunInfo, + ArtifactReferenceModel, + ExperimentalTuningHandoff, + StageStatus, + TuningBiologyHandoff, +) + +try: + from pydantic import Field +except ImportError as exc: + raise ImportError(AGENT_INSTALL_HINT) from exc + + +type CandidateStatus = Literal["done", "failed"] +type CandidatePhase = Literal["initial", "refined"] +type ParameterSearchStatus = Literal["complete", "refine"] +type TuningConfidence = Literal["low", "medium", "high"] +type ReductionMethod = Literal["pca", "lsi", "identity"] +type IntegrationMethod = Literal["snn", "wnn"] + +_CANDIDATE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_]{0,63}$") + + +class ArtifactRecord(ArtifactReferenceModel): + """JSON-safe identity for one artifact returned by candidate execution.""" + + @classmethod + def from_ref(cls, ref: Any) -> "ArtifactRecord": + return cls( + scope=getattr(ref, "scope", "assay"), + kind=str(getattr(ref, "kind", "")), + artifactId=str(getattr(ref, "artifact_id", ref)), + assay=getattr(ref, "assay", None), + ) + + @classmethod + def get_blank(cls) -> "ArtifactRecord": + return cls() + + @classmethod + def get_example(cls) -> "ArtifactRecord": + return cls( + scope="assay", + kind="connectivity_map", + artifactId="a" * 64, + assay="RNA", + ) + + +class ParameterCandidate(AgentDataModel): + """One exact, caller-authorized parameter candidate.""" + + candidateId: str = Field( + default="", + description="Exact candidate id supplied to the evaluation tool", + ) + reductionMethod: ReductionMethod = "pca" + dimensions: int = Field(default=21, ge=2) + leidenResolution: float = Field(default=1.0, gt=0) + neighborsK: int = Field(default=11, ge=2) + useHarmony: bool = False + + @classmethod + def get_blank(cls) -> "ParameterCandidate": + return cls() + + @classmethod + def get_example(cls) -> "ParameterCandidate": + return cls( + candidateId="baseline", + reductionMethod="pca", + dimensions=21, + leidenResolution=1.0, + neighborsK=11, + useHarmony=False, + ) + + +class ParameterMetrics(AgentDataModel): + """Bounded quality metrics for one candidate branch.""" + + nClusters: int | None = None + minClusterCells: int | None = None + minClusterFraction: float | None = None + graphSilhouetteMedian: float | None = None + pcaSilhouette: float | None = None + macroF1: float | None = None + weightedF1: float | None = None + membershipStrengthMean: float | None = None + membershipStrengthMedian: float | None = None + membershipStrengthP10: float | None = None + membershipStrengthByCluster: dict[str, float] = Field(default_factory=dict) + membershipStrengthSampleSize: int | None = None + clusterConnectivity: float | None = None + seedStability: float | None = None + subsampleStability: float | None = None + markerCoherence: float | None = None + markerSpecificityMedian: float | None = None + markerSpecificityByCluster: dict[str, float] = Field(default_factory=dict) + markerAucByCluster: dict[str, float] = Field(default_factory=dict) + topMarkerGenes: dict[str, list[str]] = Field(default_factory=dict) + crossUnitSupport: float | None = None + technicalAssociation: dict[str, float] = Field(default_factory=dict) + componentVariance: list[float] = Field(default_factory=list) + pcaExplainedVarianceRatio: list[float] = Field(default_factory=list) + pcaCumulativeExplainedVarianceRatio: list[float] = Field(default_factory=list) + topLoadingGenes: dict[str, list[str]] = Field(default_factory=dict) + loadingFamilyEnrichment: dict[str, float] = Field(default_factory=dict) + loadingFamilyEnrichmentByComponent: dict[str, dict[str, float]] = Field( + default_factory=dict + ) + pcaComponentAssociations: dict[str, dict[str, list[float]]] = Field( + default_factory=dict + ) + batchPcaAssociation: dict[str, float] = Field(default_factory=dict) + technicalPcaAssociation: dict[str, float] = Field(default_factory=dict) + protectedPcaAssociation: dict[str, float] = Field(default_factory=dict) + qcPcaAssociation: dict[str, float] = Field(default_factory=dict) + neighborPrefixOverlap: float | None = None + markerFamilyEnrichment: dict[str, float] = Field(default_factory=dict) + protectedMarkerFamilies: list[str] = Field(default_factory=list) + doubletHighScoreConcentration: float | None = None + doubletScoreQuantiles: dict[str, float] = Field(default_factory=dict) + doubletScoreByCapture: dict[str, dict[str, float]] = Field(default_factory=dict) + doubletCaptureCoverage: float | None = None + batchMixing: dict[str, float] = Field(default_factory=dict) + biologicalPreservation: dict[str, dict[str, float]] = Field(default_factory=dict) + paretoOptimal: bool | None = None + dominatedByCandidateIds: list[str] = Field(default_factory=list) + dominatesCandidateIds: list[str] = Field(default_factory=list) + dominanceMetrics: dict[str, list[str]] = Field(default_factory=dict) + + @classmethod + def get_blank(cls) -> "ParameterMetrics": + return cls() + + @classmethod + def get_example(cls) -> "ParameterMetrics": + return cls( + nClusters=8, + minClusterCells=42, + minClusterFraction=0.021, + graphSilhouetteMedian=0.41, + pcaSilhouette=0.36, + macroF1=0.82, + weightedF1=0.86, + batchMixing={"batch": 0.73}, + biologicalPreservation={ + "cell_type": {"clisi": 0.88, "graphConnectivity": 0.91} + }, + ) + + +class ParameterCandidateEvaluation(AgentDataModel): + """Execution record returned to the model for one candidate.""" + + candidateId: str = "" + phase: CandidatePhase = "initial" + harmonyBatchColumns: list[str] = Field(default_factory=list) + status: CandidateStatus = "failed" + eligible: bool = False + parameters: ParameterCandidate = Field(default_factory=ParameterCandidate.get_blank) + artifacts: dict[str, ArtifactRecord] = Field(default_factory=dict) + cellSelection: ArtifactReferenceModel | None = None + clusterColumn: str | None = None + clusterLabel: str | None = None + effectiveDimensions: int | None = None + metrics: ParameterMetrics = Field(default_factory=ParameterMetrics.get_blank) + evidenceIds: list[str] = Field(default_factory=list) + eligibilityReasons: list[str] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) + error: str | None = None + + @classmethod + def get_blank(cls) -> "ParameterCandidateEvaluation": + return cls() + + @classmethod + def get_example(cls) -> "ParameterCandidateEvaluation": + candidate = ParameterCandidate.get_example() + return cls( + candidateId=candidate.candidateId, + status="done", + eligible=True, + parameters=candidate, + artifacts={ + "connectivityMap": ArtifactRecord.get_example(), + "clusters": ArtifactRecord( + assay="RNA", + kind="cluster_labels", + artifactId="b" * 64, + ), + }, + cellSelection=ArtifactReferenceModel( + scope="datastore", + assay=None, + kind="cell_selection", + artifactId="c" * 64, + ), + clusterColumn="RNA_agent_tuning_baseline", + clusterLabel="agent_tuning_baseline", + effectiveDimensions=21, + metrics=ParameterMetrics.get_example(), + evidenceIds=["candidate:baseline:clusters"], + ) + + +class IntegrationMetrics(AgentDataModel): + """Metrics that are valid for an integrated graph comparison.""" + + nClusters: int | None = None + minClusterCells: int | None = None + minClusterFraction: float | None = None + adjustedRandByAssay: dict[str, float] = Field(default_factory=dict) + normalizedMutualInformationByAssay: dict[str, float] = Field(default_factory=dict) + biologicalConnectivity: dict[str, float] = Field(default_factory=dict) + modalityWeightsValid: bool | None = None + + @classmethod + def get_blank(cls) -> "IntegrationMetrics": + return cls() + + @classmethod + def get_example(cls) -> "IntegrationMetrics": + return cls( + nClusters=8, + minClusterCells=37, + minClusterFraction=0.0185, + adjustedRandByAssay={"RNA": 0.71, "ADT": 0.63}, + normalizedMutualInformationByAssay={"RNA": 0.76, "ADT": 0.69}, + modalityWeightsValid=True, + ) + + +class IntegrationCandidateEvaluation(AgentDataModel): + """One executor-produced SNN or WNN graph and cluster evaluation.""" + + integrationId: str = "" + method: IntegrationMethod = "wnn" + assays: list[str] = Field(default_factory=list) + status: CandidateStatus = "failed" + eligible: bool = False + cellSelection: ArtifactReferenceModel | None = None + resolution: float = Field(default=1.0, gt=0) + graphArtifact: ArtifactRecord | None = None + clusterArtifact: ArtifactRecord | None = None + clusterColumn: str | None = None + metrics: IntegrationMetrics = Field(default_factory=IntegrationMetrics.get_blank) + evidenceIds: list[str] = Field(default_factory=list) + eligibilityReasons: list[str] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) + error: str | None = None + + @classmethod + def get_blank(cls) -> "IntegrationCandidateEvaluation": + return cls() + + @classmethod + def get_example(cls) -> "IntegrationCandidateEvaluation": + return cls( + integrationId="wnn_resolution_1", + method="wnn", + assays=["RNA", "ADT"], + status="done", + eligible=True, + cellSelection=ArtifactReferenceModel( + scope="datastore", + assay=None, + kind="cell_selection", + artifactId="c" * 64, + ), + graphArtifact=ArtifactRecord( + scope="datastore", + kind="integrated_graph", + artifactId="2" * 64, + ), + clusterArtifact=ArtifactRecord( + scope="datastore", + kind="cluster_labels", + artifactId="3" * 64, + ), + clusterColumn="agent_wnn_cluster", + metrics=IntegrationMetrics.get_example(), + evidenceIds=["integration:wnn_resolution_1:clusters"], + ) + + +class FinalGraphComparison(AgentDataModel): + """Evidence-backed comparison against one eligible final graph option.""" + + optionId: str = "" + summary: str = "" + evidenceIds: list[str] = Field(default_factory=list) + + @classmethod + def get_blank(cls) -> "FinalGraphComparison": + return cls() + + @classmethod + def get_example(cls) -> "FinalGraphComparison": + return cls( + optionId="native:ADT:baseline", + summary="The RNA-native option better preserves the requested labels.", + evidenceIds=[ + "native:RNA:candidate:baseline:clusters", + "native:ADT:candidate:baseline:clusters", + ], + ) + + +class FinalGraphNeedsInput(AgentDataModel): + """Concrete input needed before a final graph can be selected.""" + + question: str = "" + options: list[str] = Field(default_factory=list) + evidenceIds: list[str] = Field(default_factory=list) + + @classmethod + def get_blank(cls) -> "FinalGraphNeedsInput": + return cls() + + @classmethod + def get_example(cls) -> "FinalGraphNeedsInput": + return cls( + question="Which biological signal must the final graph preserve?", + options=["cell_type", "condition"], + ) + + +class FinalGraphSelection(AgentDataModel): + """Grounded choice among selected native, SNN, and WNN graph options.""" + + status: StageStatus = "needsInput" + selectedOptionId: str | None = None + graphMethod: Literal["native", "snn", "wnn"] | None = None + nativeAssay: str | None = None + nativeCandidateId: str | None = None + integrationId: str | None = None + markerAssay: str = "" + confidence: TuningConfidence = "low" + rationale: str = "" + evidenceIds: list[str] = Field(default_factory=list) + comparisons: list[FinalGraphComparison] = Field(default_factory=list) + tradeoffs: list[str] = Field(default_factory=list) + limitations: list[str] = Field(default_factory=list) + needsInput: FinalGraphNeedsInput | None = None + runInfo: AgentRunInfo = Field(default_factory=AgentRunInfo) + + @classmethod + def get_blank(cls) -> "FinalGraphSelection": + return cls() + + @classmethod + def get_example(cls) -> "FinalGraphSelection": + return cls( + status="done", + selectedOptionId="native:RNA:baseline", + graphMethod="native", + nativeAssay="RNA", + nativeCandidateId="baseline", + markerAssay="RNA", + confidence="medium", + rationale="The selected native graph has the strongest supported balance.", + evidenceIds=["native:RNA:candidate:baseline:clusters"], + runInfo=AgentRunInfo.get_example(), + ) + + +class CandidateComparison(AgentDataModel): + """Evidence-backed comparison against one executed non-selected candidate.""" + + candidateId: str = "" + summary: str = "" + evidenceIds: list[str] = Field(default_factory=list) + + @classmethod + def get_blank(cls) -> "CandidateComparison": + return cls() + + @classmethod + def get_example(cls) -> "CandidateComparison": + return cls( + candidateId="pca_15", + summary="The selected baseline retains larger minimum clusters.", + evidenceIds=[ + "candidate:baseline:clusters", + "candidate:pca_15:clusters", + ], + ) + + +class ParameterSearchPlan(AgentDataModel): + """Validated proposal for one bounded refinement pass.""" + + status: ParameterSearchStatus = Field( + default="complete", + description=( + "Summary derived from candidates: refine when candidates is non-empty " + "and complete when it is empty" + ), + ) + candidates: list[ParameterCandidate] = Field( + default_factory=list, + description=( + "Bounded unexecuted refinement candidates, or an empty list when the " + "initial screen is complete" + ), + ) + basedOnCandidateIds: list[str] = Field(default_factory=list) + harmonyBatchColumns: list[str] = Field(default_factory=list) + objectives: list[str] = Field(default_factory=list) + rationale: str = "" + evidenceIds: list[str] = Field(default_factory=list) + stoppingCriteria: list[str] = Field(default_factory=list) + runInfo: AgentRunInfo = Field(default_factory=AgentRunInfo) + + @classmethod + def get_blank(cls) -> "ParameterSearchPlan": + return cls() + + @classmethod + def get_example(cls) -> "ParameterSearchPlan": + return cls( + status="refine", + candidates=[ + ParameterCandidate( + candidateId="refined_pca_18", + dimensions=18, + leidenResolution=1.0, + neighborsK=11, + useHarmony=False, + ) + ], + basedOnCandidateIds=["baseline", "pca_15"], + harmonyBatchColumns=[], + objectives=["Resolve the dimension tradeoff."], + rationale="The initial screen brackets a narrower dimension range.", + evidenceIds=[ + "candidate:baseline:clusters", + "candidate:pca_15:clusters", + ], + stoppingCriteria=["Run the proposed candidate once."], + runInfo=AgentRunInfo.get_example(), + ) + + +class ParameterTuningBatchSearchPlan(AgentDataModel): + """One bounded refinement plan for every assay in a batched screen.""" + + assayPlans: dict[str, ParameterSearchPlan] = Field(default_factory=dict) + runInfo: AgentRunInfo = Field(default_factory=AgentRunInfo) + + @classmethod + def get_blank(cls) -> "ParameterTuningBatchSearchPlan": + return cls() + + @classmethod + def get_example(cls) -> "ParameterTuningBatchSearchPlan": + return cls(assayPlans={"RNA": ParameterSearchPlan.get_example()}) + + +class ParameterTuningNeedsInput(AgentDataModel): + """User input required before tuning can produce a recommendation.""" + + question: str = "" + options: list[str] = Field(default_factory=list) + evidenceIds: list[str] = Field(default_factory=list) + + @classmethod + def get_blank(cls) -> "ParameterTuningNeedsInput": + return cls() + + @classmethod + def get_example(cls) -> "ParameterTuningNeedsInput": + return cls( + question="Which trusted biological label should be preserved?", + options=["cell_type", "none"], + evidenceIds=["candidate:baseline:batchMixing:batch"], + ) + + +class ParameterTuningReport(AgentDataModel): + """Grounded recommendation over candidate branches actually executed.""" + + status: StageStatus = "failed" + fromAssay: str = "" + cellSelection: ArtifactReferenceModel | None = None + evaluations: list[ParameterCandidateEvaluation] = Field(default_factory=list) + recommendedCandidateId: str | None = None + selectedArtifacts: dict[str, ArtifactRecord] = Field(default_factory=dict) + confidence: TuningConfidence = "low" + rationale: str = "" + evidenceIds: list[str] = Field(default_factory=list) + comparisons: list[CandidateComparison] = Field(default_factory=list) + tradeoffs: list[str] = Field(default_factory=list) + limitations: list[str] = Field(default_factory=list) + stopReason: str = "" + needsInput: ParameterTuningNeedsInput | None = None + searchPlan: ParameterSearchPlan | None = None + assayReports: dict[str, "ParameterTuningReport"] = Field(default_factory=dict) + recommendedByAssay: dict[str, str] = Field(default_factory=dict) + totalCandidates: int = 0 + integrationEvaluations: list[IntegrationCandidateEvaluation] = Field( + default_factory=list + ) + recommendedIntegrationId: str | None = None + finalClusterColumn: str | None = None + finalClusterArtifact: ArtifactRecord | None = None + graphAssay: str | None = None + markerAssay: str | None = None + finalSelection: FinalGraphSelection | None = None + runInfo: AgentRunInfo = Field(default_factory=AgentRunInfo) + + @classmethod + def get_blank(cls) -> "ParameterTuningReport": + return cls() + + @classmethod + def get_example(cls) -> "ParameterTuningReport": + evaluation = ParameterCandidateEvaluation.get_example() + return cls( + status="done", + fromAssay="RNA", + cellSelection=evaluation.cellSelection, + evaluations=[evaluation], + recommendedCandidateId=evaluation.candidateId, + selectedArtifacts=dict(evaluation.artifacts), + confidence="medium", + rationale="The baseline balances separation and cluster size.", + evidenceIds=["candidate:baseline:clusters"], + tradeoffs=["Higher resolutions produced smaller clusters."], + limitations=["No trusted biological preservation label was supplied."], + stopReason="All authorized candidates were evaluated.", + recommendedByAssay={"RNA": evaluation.candidateId}, + totalCandidates=1, + graphAssay="RNA", + markerAssay="RNA", + finalSelection=FinalGraphSelection.get_example(), + runInfo=AgentRunInfo.get_example(), + ) + + def to_biological_handoff( + self, + *, + marker_assay: str | None = None, + ) -> TuningBiologyHandoff: + """Return the exact selected clustering branch for interpretation.""" + if self.status != "done": + raise ValueError( + "Parameter Tuning must be done before creating a biology handoff" + ) + if self.finalClusterArtifact is not None: + if self.cellSelection is None: + raise ValueError("Final branch lacks an exact cell selection") + resolved_marker_assay = marker_assay or self.markerAssay + if not resolved_marker_assay: + raise ValueError( + "A marker assay is required for an integrated biology handoff" + ) + if self.finalClusterArtifact.scope == "datastore": + if self.finalClusterArtifact.assay is not None: + raise ValueError( + "A datastore-scoped cluster artifact must not name an assay" + ) + elif ( + self.graphAssay is not None + and self.finalClusterArtifact.assay != self.graphAssay + ): + raise ValueError("Final cluster artifact does not match graphAssay") + integration = next( + ( + item + for item in self.integrationEvaluations + if item.integrationId == self.recommendedIntegrationId + ), + None, + ) + if self.finalSelection is not None: + evidence_ids = self.finalSelection.evidenceIds + elif integration is not None: + evidence_ids = integration.evidenceIds + else: + prefix = f"candidate:{self.recommendedCandidateId}:" + evidence_ids = [ + evidence_id + for evidence_id in self.evidenceIds + if evidence_id.startswith(prefix) + ] + return TuningBiologyHandoff( + cellSelection=self.cellSelection, + fromAssay=self.fromAssay, + graphAssay=self.graphAssay, + markerAssay=resolved_marker_assay, + recommendedCandidateId=( + self.recommendedIntegrationId + or ( + self.finalSelection.nativeCandidateId + if self.finalSelection is not None + else None + ) + or self.recommendedCandidateId + or "final" + ), + clusterArtifact=ArtifactReferenceModel.model_validate( + self.finalClusterArtifact.model_dump() + ), + evidenceIds=sorted(evidence_ids), + ) + if self.recommendedCandidateId is None: + raise ValueError( + "Parameter Tuning must recommend a candidate before creating a " + "biology handoff" + ) + selected = next( + ( + item + for item in self.evaluations + if item.candidateId == self.recommendedCandidateId + ), + None, + ) + if selected is None or selected.status != "done" or not selected.eligible: + raise ValueError("Recommended candidate is not an eligible execution") + cluster_artifact = selected.artifacts.get("clusters") + if cluster_artifact is None or selected.cellSelection is None: + raise ValueError("Recommended candidate lacks an exact cluster artifact") + if not self.fromAssay or cluster_artifact.assay != self.fromAssay: + raise ValueError("Recommended cluster artifact does not match the assay") + prefix = f"candidate:{selected.candidateId}:" + return TuningBiologyHandoff( + cellSelection=selected.cellSelection, + fromAssay=self.fromAssay, + graphAssay=self.fromAssay, + markerAssay=marker_assay or self.markerAssay or self.fromAssay, + recommendedCandidateId=selected.candidateId, + clusterArtifact=ArtifactReferenceModel.model_validate( + cluster_artifact.model_dump() + ), + evidenceIds=sorted( + evidence_id + for evidence_id in self.evidenceIds + if evidence_id.startswith(prefix) + ), + ) + + +class ParameterTuningDependencies(AgentDataModel): + """Runtime-only state hidden from the model and shared by tuning tools.""" + + store: Any = Field(default=None, exclude=True) + normalized: Any = Field(default=None, exclude=True) + cellSelection: Any = Field(default=None, exclude=True) + normalizedShape: tuple[int, int] | None = None + fromAssay: str = "" + candidates: dict[str, ParameterCandidate] = Field(default_factory=dict) + candidatePhases: dict[str, CandidatePhase] = Field(default_factory=dict) + batchColumns: tuple[str, ...] = () + preservationColumns: tuple[str, ...] = () + harmonyAuthorized: bool = False + maxCandidates: int = 5 + minClusterCells: int = 20 + identityFeatureLimit: int = 64 + evaluations: dict[str, ParameterCandidateEvaluation] = Field(default_factory=dict) + executionOrder: list[str] = Field(default_factory=list) + executionLock: Any = Field(default_factory=Lock, exclude=True, repr=False) + + @classmethod + def get_blank(cls) -> "ParameterTuningDependencies": + return cls() + + @classmethod + def get_example(cls) -> "ParameterTuningDependencies": + candidate = ParameterCandidate.get_example() + return cls( + fromAssay="RNA", + normalizedShape=(1000, 2000), + candidates={candidate.candidateId: candidate}, + batchColumns=("batch",), + preservationColumns=("cell_type",), + ) + + +class ParameterTuningAssayInput(AgentDataModel): + """One assay branch supplied to batched parameter tuning.""" + + normalized: Any = Field(default=None, exclude=True) + candidates: list[ParameterCandidate] = Field(default_factory=list) + batchColumns: list[str] = Field(default_factory=list) + preservationColumns: list[str] = Field(default_factory=list) + experimentalHandoff: ExperimentalTuningHandoff | None = None + maxCandidates: int = Field(default=5, ge=1) + maxRefinedCandidates: int = Field(default=0, ge=0) + allowHarmonyRefinement: bool = True + minClusterCells: int = Field(default=20, ge=1) + identityFeatureLimit: int = Field(default=64, ge=2) + + @classmethod + def get_blank(cls) -> "ParameterTuningAssayInput": + return cls() + + @classmethod + def get_example(cls) -> "ParameterTuningAssayInput": + return cls( + normalized=ArtifactRecord( + assay="RNA", + kind="normalized", + artifactId="4" * 64, + ), + candidates=_default_parameter_candidates(), + experimentalHandoff=ExperimentalTuningHandoff(batchAction="skip"), + ) + + +def _default_parameter_candidates() -> list[ParameterCandidate]: + """Return a small one-factor candidate set around Scarf defaults.""" + + return [ + ParameterCandidate( + candidateId="baseline", + dimensions=21, + leidenResolution=1.0, + ), + ParameterCandidate( + candidateId="pca_15", + dimensions=15, + leidenResolution=1.0, + ), + ParameterCandidate( + candidateId="pca_30", + dimensions=30, + leidenResolution=1.0, + ), + ParameterCandidate( + candidateId="leiden_0_5", + dimensions=21, + leidenResolution=0.5, + ), + ParameterCandidate( + candidateId="leiden_1_5", + dimensions=21, + leidenResolution=1.5, + ), + ] diff --git a/scarf/agent/tuning_diagnostics.py b/scarf/agent/parameter_tuning/diagnostics.py similarity index 98% rename from scarf/agent/tuning_diagnostics.py rename to scarf/agent/parameter_tuning/diagnostics.py index 02637cd7..0988e413 100644 --- a/scarf/agent/tuning_diagnostics.py +++ b/scarf/agent/parameter_tuning/diagnostics.py @@ -7,32 +7,29 @@ import numpy as np from sklearn.metrics import adjusted_rand_score, normalized_mutual_info_score -from ..clustering.leiden import leiden_membership -from ..metadata.rows import read_metadata_rows_chunkwise -from ..quality_control.cell_cycle_genes import ( +from ...clustering.leiden import leiden_membership +from ...metadata.rows import read_metadata_rows_chunkwise +from ...quality_control.cell_cycle_genes import ( g2m_phase_genes, g2m_phase_genes_mouse, s_phase_genes, s_phase_genes_mouse, ) -from ..storage.arrays import create_zarr_dataset -from ..storage.artifact_writer import ( +from ...storage.arrays import create_zarr_dataset +from ...storage.artifact_writer import ( ArrayRequirement, AttributeRequirement, finish_artifact, plan_artifact, start_artifact, ) -from ..storage.artifacts import fingerprint_stored_arrays -from ..storage.feature_selection import read_feature_selection_indices -from ..storage.refs import ArtifactRef -from ..storage.selections import read_stored_selection_indices -from ..storage.types import as_zarr_array -from .parameter_tuning import ( - ArtifactRecord, - ParameterCandidateEvaluation, - annotate_candidate_dominance, -) +from ...storage.artifacts import fingerprint_stored_arrays +from ...storage.feature_selection import read_feature_selection_indices +from ...storage.refs import ArtifactRef +from ...storage.selections import read_stored_selection_indices +from ...storage.types import as_zarr_array +from .contracts import ArtifactRecord, ParameterCandidateEvaluation +from .selection import annotate_candidate_dominance _PCA_DIAGNOSTIC_ARRAYS = ( "component_variance", diff --git a/scarf/agent/parameter_tuning/execution.py b/scarf/agent/parameter_tuning/execution.py new file mode 100644 index 00000000..cb8ba692 --- /dev/null +++ b/scarf/agent/parameter_tuning/execution.py @@ -0,0 +1,712 @@ +from collections.abc import Sequence +from typing import Any + +import numpy as np + +from ...metrics import graph_connectivity +from ...storage.refs import ArtifactRef +from ...storage.types import as_zarr_array +from ...utils.logging import logger +from .._deps import AGENT_INSTALL_HINT +from ..tools import artifact_reference, core_artifact_reference +from .contracts import ( + ArtifactRecord, + IntegrationCandidateEvaluation, + ParameterCandidate, + ParameterCandidateEvaluation, + ParameterMetrics, + ParameterTuningDependencies, + ParameterTuningReport, +) + +try: + from pydantic_ai import RunContext +except ImportError as exc: + raise ImportError(AGENT_INSTALL_HINT) from exc + + +_RANDOM_SEED = 4444 +_PCA_RANDOM_SEED = 4466 + + +def _final_graph_options( + report: ParameterTuningReport, + integration_evaluations: Sequence[IntegrationCandidateEvaluation], +) -> dict[str, dict[str, Any]]: + """Return the exact eligible graph options and option-scoped evidence.""" + + assay_reports = report.assayReports or {report.fromAssay: report} + report_cell_selection = core_artifact_reference(report.cellSelection) + if not isinstance(report_cell_selection, ArtifactRef): + raise ValueError("Parameter tuning report lacks an exact cell selection") + options: dict[str, dict[str, Any]] = {} + for assay, assay_report in assay_reports.items(): + candidate_id = assay_report.recommendedCandidateId + if candidate_id is None: + continue + native_evaluation = next( + ( + item + for item in assay_report.evaluations + if item.candidateId == candidate_id + ), + None, + ) + if ( + native_evaluation is None + or native_evaluation.status != "done" + or not native_evaluation.eligible + or "clusters" not in native_evaluation.artifacts + or "connectivityMap" not in native_evaluation.artifacts + or not native_evaluation.evidenceIds + ): + continue + if ( + core_artifact_reference(native_evaluation.cellSelection) + != report_cell_selection + ): + raise ValueError("Native graph option uses a different cell selection") + option_id = f"native:{assay}:{candidate_id}" + option_evidence = [ + f"native:{assay}:{evidence_id}" + for evidence_id in native_evaluation.evidenceIds + ] + evaluation_payload = native_evaluation.model_dump() + evaluation_payload["evidenceIds"] = option_evidence + options[option_id] = { + "optionId": option_id, + "graphMethod": "native", + "nativeAssay": assay, + "nativeCandidateId": candidate_id, + "evaluation": evaluation_payload, + "evidenceIds": option_evidence, + } + for integration_evaluation in integration_evaluations: + if ( + integration_evaluation.status != "done" + or not integration_evaluation.eligible + ): + continue + if ( + integration_evaluation.clusterArtifact is None + or integration_evaluation.graphArtifact is None + ): + continue + if not integration_evaluation.evidenceIds: + continue + if ( + core_artifact_reference(integration_evaluation.cellSelection) + != report_cell_selection + ): + raise ValueError("Integrated graph option uses a different cell selection") + if not integration_evaluation.integrationId: + raise ValueError("Eligible integration evaluations require integrationId") + if ( + integration_evaluation.graphArtifact.scope != "datastore" + or integration_evaluation.graphArtifact.assay is not None + or integration_evaluation.clusterArtifact.scope != "datastore" + or integration_evaluation.clusterArtifact.assay is not None + or integration_evaluation.graphArtifact.kind != "integrated_graph" + or integration_evaluation.clusterArtifact.kind + not in {"cluster_labels", "cluster_cut"} + ): + raise ValueError( + "Integrated graph and cluster artifacts must be datastore-scoped " + "without an assay" + ) + if ( + integration_evaluation.method == "wnn" + and integration_evaluation.metrics.modalityWeightsValid is not True + ): + continue + option_id = f"integration:{integration_evaluation.integrationId}" + if option_id in options: + raise ValueError( + f"Duplicate integration id {integration_evaluation.integrationId!r}" + ) + options[option_id] = { + "optionId": option_id, + "graphMethod": integration_evaluation.method, + "integrationId": integration_evaluation.integrationId, + "evaluation": integration_evaluation.model_dump(), + "evidenceIds": list(integration_evaluation.evidenceIds), + } + return options + + +def normalized_artifact_shape(store: Any, normalized: Any) -> tuple[int, int]: + """Return the exact cell-by-feature shape of a normalized artifact.""" + + group = store.load_artifact(normalized) + if "data" not in group: + raise ValueError("Normalized artifact does not contain a data matrix") + shape = getattr(group["data"], "shape", None) + if not isinstance(shape, tuple | list) or len(shape) != 2: + raise ValueError("Normalized artifact data must be two-dimensional") + n_cells, n_features = map(int, shape) + if n_cells < 2 or n_features < 2: + raise ValueError( + "Parameter tuning requires at least two cells and two selected features" + ) + return n_cells, n_features + + +def validate_parameter_candidate_rank( + candidate: ParameterCandidate, + normalized_shape: tuple[int, int], + *, + identity_feature_limit: int = 64, +) -> int: + """Validate a candidate before any branch operation and return output rank.""" + + n_cells, n_features = normalized_shape + if candidate.neighborsK >= n_cells: + raise ValueError( + f"neighborsK={candidate.neighborsK} requires more than " + f"{candidate.neighborsK} selected cells; observed {n_cells}" + ) + if candidate.reductionMethod == "pca": + if candidate.dimensions + 1 > min(n_cells, n_features): + raise ValueError( + f"PCA dimensions={candidate.dimensions} requires at least " + f"{candidate.dimensions + 1} cells and selected features; " + f"observed shape {normalized_shape}" + ) + return candidate.dimensions + if candidate.reductionMethod == "lsi": + required_rank = candidate.dimensions + 1 + if required_rank > min(n_cells, n_features): + raise ValueError( + "LSI dimensions, including the skipped component, exceed the " + f"normalized matrix rank for shape {normalized_shape}" + ) + return candidate.dimensions + if n_features > identity_feature_limit: + raise ValueError( + f"Identity reduction supports at most {identity_feature_limit} selected " + f"features; observed {n_features}" + ) + if candidate.dimensions != n_features: + raise ValueError( + "Identity reduction dimensions must equal the exact normalized feature " + f"count {n_features}; received {candidate.dimensions}" + ) + return n_features + + +def run_candidate_reduction( + store: Any, + *, + normalized: Any, + candidate: ParameterCandidate, + normalized_shape: tuple[int, int], + identity_feature_limit: int = 64, +) -> tuple[Any, str, int]: + """Run one validated modality-aware reduction with public Scarf methods.""" + + effective_dimensions = validate_parameter_candidate_rank( + candidate, + normalized_shape, + identity_feature_limit=identity_feature_limit, + ) + if candidate.reductionMethod == "pca": + ref = store.run_pca( + normalized, + dims=candidate.dimensions, + feat_scaling=True, + show_elbow_plot=False, + invalidate_cache=False, + ) + return ref, "pca", effective_dimensions + if candidate.reductionMethod == "lsi": + ref = store.run_lsi( + normalized, + dims=candidate.dimensions, + skip_first=True, + rand_state=_PCA_RANDOM_SEED, + invalidate_cache=False, + ) + return ref, "lsi", effective_dimensions + loadings = np.eye(normalized_shape[1], dtype=np.float64) + ref = store.run_custom_reduction( + loadings, + normalized, + invalidate_cache=False, + ) + return ref, "identity", effective_dimensions + + +def _bounded_membership_summary( + values: Any, + labels: np.ndarray, + *, + maximum_sample_size: int = 65_536, +) -> tuple[float, float, float, dict[str, float], int]: + if len(values.shape) != 1 or values.shape != labels.shape: + raise ValueError("Membership strengths must align with cluster labels") + n_values = int(values.shape[0]) + if n_values < 1: + raise ValueError("Membership strengths cannot be empty") + stride = max(1, (n_values + maximum_sample_size - 1) // maximum_sample_size) + total = 0.0 + sampled_values: list[np.ndarray] = [] + sampled_labels: list[np.ndarray] = [] + for start in range(0, n_values, 65_536): + block = np.asarray(values[start : start + 65_536], dtype=np.float64) + if not np.isfinite(block).all(): + raise ValueError("Membership strengths must be finite") + total += float(block.sum()) + offset = (-start) % stride + sampled_values.append(block[offset::stride]) + sampled_labels.append(labels[start + offset : start + len(block) : stride]) + sample = np.concatenate(sampled_values) + sample_labels = np.concatenate(sampled_labels) + by_cluster = { + str(cluster): float(np.median(sample[sample_labels == cluster])) + for cluster in np.unique(sample_labels) + } + return ( + total / n_values, + float(np.median(sample)), + float(np.quantile(sample, 0.1)), + by_cluster, + int(len(sample)), + ) + + +def _collect_cluster_structure_metrics( + store: Any, + *, + cluster_ref: Any, + graph_ref: Any, + cluster_values: np.ndarray, + candidate_id: str, + metrics: ParameterMetrics, + evidence_ids: list[str], + warnings: list[str], +) -> ArtifactRef | None: + calculate_membership = getattr(store, "calc_membership_strength", None) + if not callable(calculate_membership): + return None + membership_ref: ArtifactRef | None = None + try: + membership_ref = calculate_membership( + cluster_ref, + graph_ref, + invalidate_cache=False, + ) + membership_group = store.load_artifact(membership_ref) + membership_values = as_zarr_array( + membership_group["values"], + name="values", + ) + mean, median, p10, by_cluster, sample_size = _bounded_membership_summary( + membership_values, + cluster_values, + ) + metrics.membershipStrengthMean = mean + metrics.membershipStrengthMedian = median + metrics.membershipStrengthP10 = p10 + metrics.membershipStrengthByCluster = by_cluster + metrics.membershipStrengthSampleSize = sample_size + evidence_ids.append(f"candidate:{candidate_id}:membershipStrength") + except (KeyError, TypeError, ValueError, RuntimeError) as exc: + warnings.append(f"Cluster membership strength unavailable: {exc}") + membership_ref = None + + try: + graph_group = store.load_artifact(graph_ref) + graph_edges = as_zarr_array(graph_group["edges"], name="edges") + connectivity = float(graph_connectivity(graph_edges, cluster_values)) + if np.isfinite(connectivity): + metrics.clusterConnectivity = connectivity + evidence_ids.append(f"candidate:{candidate_id}:clusterConnectivity") + except (KeyError, TypeError, ValueError, RuntimeError) as exc: + warnings.append(f"Cluster connectivity unavailable: {exc}") + return membership_ref + + +def _collect_parameter_candidate_metrics( + deps: ParameterTuningDependencies, + *, + candidate: ParameterCandidate, + candidate_id: str, + reduction_ref: Any, + neighbors_ref: Any, + graph_ref: Any, + cluster_ref: Any, + cluster_column: str, + evidence_ids: list[str], + warnings: list[str], +) -> tuple[ParameterMetrics, list[str], ArtifactRef | None]: + store = deps.store + cluster_group = store.load_artifact(cluster_ref) + cluster_data = cluster_group["values"] + cluster_values = np.asarray(cluster_data[:]) + if cluster_values.ndim != 1 or len(cluster_values) == 0: + raise ValueError("Cluster artifact must contain one non-empty label vector") + if np.any(cluster_values < 0): + raise ValueError("Cluster artifact contains invalid negative labels") + _, cluster_counts = np.unique(cluster_values, return_counts=True) + n_clusters = int(len(cluster_counts)) + min_cluster_cells = int(cluster_counts.min()) + min_cluster_fraction = float(min_cluster_cells / len(cluster_values)) + metrics = ParameterMetrics( + nClusters=n_clusters, + minClusterCells=min_cluster_cells, + minClusterFraction=min_cluster_fraction, + ) + evidence_ids.append(f"candidate:{candidate_id}:clusters") + membership_ref = _collect_cluster_structure_metrics( + store, + cluster_ref=cluster_ref, + graph_ref=graph_ref, + cluster_values=cluster_values, + candidate_id=candidate_id, + metrics=metrics, + evidence_ids=evidence_ids, + warnings=warnings, + ) + + try: + graph_scores = store.metric_graph_silhouette( + neighbors_ref, + cluster_ref, + random_seed=_RANDOM_SEED, + sample_size=11, + ) + if graph_scores is not None: + finite_scores = np.asarray(graph_scores, dtype=float) + finite_scores = finite_scores[np.isfinite(finite_scores)] + if len(finite_scores): + metrics.graphSilhouetteMedian = float(np.median(finite_scores)) + evidence_ids.append(f"candidate:{candidate_id}:graphSilhouette") + except (KeyError, TypeError, ValueError) as exc: + warnings.append(f"Graph silhouette unavailable: {exc}") + + if candidate.reductionMethod == "pca": + try: + separability = store.metric_cluster_separability( + reduction_ref, + {cluster_column: cluster_ref}, + random_seed=_RANDOM_SEED, + ) + table = separability.clustering_scores + rows = table.loc[table["clustering"] == cluster_column] + if len(rows): + row = rows.iloc[0] + for field_name, column_name, evidence_name in ( + ("pcaSilhouette", "silhouette_score", "pcaSilhouette"), + ("macroF1", "macro_f1_mean", "macroF1"), + ("weightedF1", "weighted_f1_mean", "weightedF1"), + ): + value = row[column_name] + if value is not None and np.isfinite(float(value)): + setattr(metrics, field_name, float(value)) + evidence_ids.append(f"candidate:{candidate_id}:{evidence_name}") + except (KeyError, TypeError, ValueError) as exc: + warnings.append(f"PCA cluster separability unavailable: {exc}") + + perplexity = max(1.0, float(candidate.neighborsK // 3)) + for column in deps.batchColumns: + try: + score = float( + store.metric_proportional_batch_mixing( + column, + neighbors_ref, + perplexity=perplexity, + ) + ) + if np.isfinite(score): + metrics.batchMixing[column] = score + evidence_ids.append(f"candidate:{candidate_id}:batchMixing:{column}") + except (KeyError, TypeError, ValueError) as exc: + warnings.append(f"Batch mixing for {column!r} unavailable: {exc}") + + for column in deps.preservationColumns: + scores: dict[str, float] = {} + try: + clisi = float( + store.metric_clisi( + column, + neighbors_ref, + perplexity=None, + scale=True, + ) + ) + if np.isfinite(clisi): + scores["clisi"] = clisi + evidence_ids.append(f"candidate:{candidate_id}:clisi:{column}") + except (KeyError, TypeError, ValueError) as exc: + warnings.append(f"cLISI for {column!r} unavailable: {exc}") + try: + connectivity = float( + store.metric_graph_connectivity( + column, + graph_ref, + ) + ) + if np.isfinite(connectivity): + scores["graphConnectivity"] = connectivity + evidence_ids.append( + f"candidate:{candidate_id}:graphConnectivity:{column}" + ) + except (KeyError, TypeError, ValueError) as exc: + warnings.append(f"Graph connectivity for {column!r} unavailable: {exc}") + if scores: + metrics.biologicalPreservation[column] = scores + + eligibility_reasons: list[str] = [] + if n_clusters < 2: + eligibility_reasons.append("fewer than two clusters") + if min_cluster_cells < deps.minClusterCells: + eligibility_reasons.append( + f"smallest cluster has {min_cluster_cells} cells; " + f"minimum is {deps.minClusterCells}" + ) + return metrics, eligibility_reasons, membership_ref + + +def execute_parameter_candidate( + deps: ParameterTuningDependencies, + candidate_id: str, +) -> ParameterCandidateEvaluation: + """Execute one allowlisted candidate without model involvement.""" + + with deps.executionLock: + if candidate_id in deps.evaluations: + logger.debug( + f"Parameter candidate {candidate_id!r} for assay " + f"{deps.fromAssay!r} reused its completed evaluation" + ) + return deps.evaluations[candidate_id] + if candidate_id not in deps.candidates: + logger.warning( + f"Parameter candidate {candidate_id!r} is not authorized for " + f"assay {deps.fromAssay!r}" + ) + return ParameterCandidateEvaluation( + candidateId=candidate_id, + status="failed", + error=( + f"Unknown candidate id {candidate_id!r}; allowed ids are " + f"{sorted(deps.candidates)}" + ), + ) + if len(deps.executionOrder) >= deps.maxCandidates: + logger.warning( + f"Parameter candidate {candidate_id!r} was not executed because " + f"assay {deps.fromAssay!r} reached its limit of " + f"{deps.maxCandidates} candidates" + ) + return ParameterCandidateEvaluation( + candidateId=candidate_id, + phase=deps.candidatePhases.get(candidate_id, "initial"), + harmonyBatchColumns=( + list(deps.batchColumns) + if deps.candidates[candidate_id].useHarmony + else [] + ), + status="failed", + parameters=deps.candidates[candidate_id], + error=f"Candidate execution limit {deps.maxCandidates} reached", + ) + + candidate = deps.candidates[candidate_id] + deps.executionOrder.append(candidate_id) + logger.info( + f"Running parameter candidate {candidate_id!r} for assay " + f"{deps.fromAssay!r}: method={candidate.reductionMethod}, " + f"dimensions={candidate.dimensions}, k={candidate.neighborsK}, " + f"resolution={candidate.leidenResolution}, " + f"harmony={candidate.useHarmony}" + ) + if candidate.useHarmony and not deps.batchColumns: + logger.warning( + f"Parameter candidate {candidate_id!r} cannot run Harmony because " + "no batch columns were authorized" + ) + evaluation = ParameterCandidateEvaluation( + candidateId=candidate_id, + phase=deps.candidatePhases.get(candidate_id, "initial"), + harmonyBatchColumns=[], + status="failed", + parameters=candidate, + error="Harmony candidate requires at least one authorized batch column", + ) + deps.evaluations[candidate_id] = evaluation + return evaluation + + store = deps.store + artifacts: dict[str, ArtifactRecord] = {} + warnings: list[str] = [] + evidence_ids: list[str] = [] + cluster_label = f"agent_tuning_{candidate_id}" + cluster_column = f"{deps.fromAssay}_{cluster_label}" + + try: + normalized_shape = deps.normalizedShape or normalized_artifact_shape( + store, + deps.normalized, + ) + effective_dimensions = validate_parameter_candidate_rank( + candidate, + normalized_shape, + identity_feature_limit=deps.identityFeatureLimit, + ) + reduction_ref, reduction_key, _ = run_candidate_reduction( + store, + normalized=deps.normalized, + candidate=candidate, + normalized_shape=normalized_shape, + identity_feature_limit=deps.identityFeatureLimit, + ) + artifacts[reduction_key] = ArtifactRecord.from_ref(reduction_ref) + logger.debug( + f"Parameter candidate {candidate_id!r}: completed " + f"{reduction_key} reduction" + ) + + coordinates_ref = reduction_ref + if candidate.useHarmony: + coordinates_ref = store.run_harmony( + reduction_ref, + list(deps.batchColumns), + invalidate_cache=False, + ) + artifacts["harmony"] = ArtifactRecord.from_ref(coordinates_ref) + logger.debug( + f"Parameter candidate {candidate_id!r}: completed Harmony " + f"using {len(deps.batchColumns)} batch column(s)" + ) + + ann_ref = store.build_ann_index( + coordinates_ref, + ann_metric="l2", + ann_parallel=False, + rand_state=_PCA_RANDOM_SEED, + invalidate_cache=False, + ) + artifacts["annIndex"] = ArtifactRecord.from_ref(ann_ref) + logger.debug( + f"Parameter candidate {candidate_id!r}: completed ANN indexing" + ) + + neighbors_ref = store.query_neighbors( + ann_ref, + coordinates=coordinates_ref, + k=candidate.neighborsK, + invalidate_cache=False, + ) + artifacts["neighbors"] = ArtifactRecord.from_ref(neighbors_ref) + logger.debug( + f"Parameter candidate {candidate_id!r}: completed neighbor query" + ) + + graph_ref = store.build_connectivity_map( + neighbors_ref, + local_connectivity=1.0, + bandwidth=1.5, + invalidate_cache=False, + ) + artifacts["connectivityMap"] = ArtifactRecord.from_ref(graph_ref) + logger.debug( + f"Parameter candidate {candidate_id!r}: completed connectivity map" + ) + + cluster_ref = store.run_leiden_clustering( + graph_ref, + resolution=candidate.leidenResolution, + backend="igraph", + symmetric_graph=False, + graph_upper_only=False, + random_seed=_RANDOM_SEED, + invalidate_cache=False, + ) + artifacts["clusters"] = ArtifactRecord.from_ref(cluster_ref) + logger.debug( + f"Parameter candidate {candidate_id!r}: completed Leiden clustering" + ) + + ( + metrics, + eligibility_reasons, + membership_ref, + ) = _collect_parameter_candidate_metrics( + deps, + candidate=candidate, + candidate_id=candidate_id, + reduction_ref=reduction_ref, + neighbors_ref=neighbors_ref, + graph_ref=graph_ref, + cluster_ref=cluster_ref, + cluster_column=cluster_column, + evidence_ids=evidence_ids, + warnings=warnings, + ) + if membership_ref is not None: + artifacts["membershipStrength"] = ArtifactRecord.from_ref( + membership_ref + ) + + evaluation = ParameterCandidateEvaluation( + candidateId=candidate_id, + phase=deps.candidatePhases.get(candidate_id, "initial"), + harmonyBatchColumns=( + list(deps.batchColumns) if candidate.useHarmony else [] + ), + status="done", + eligible=not eligibility_reasons, + parameters=candidate, + artifacts=artifacts, + cellSelection=artifact_reference(deps.cellSelection), + clusterColumn=cluster_column, + clusterLabel=cluster_label, + effectiveDimensions=effective_dimensions, + metrics=metrics, + evidenceIds=evidence_ids, + eligibilityReasons=eligibility_reasons, + warnings=warnings, + ) + logger.info( + f"Completed parameter candidate {candidate_id!r} for assay " + f"{deps.fromAssay!r}: eligible={evaluation.eligible}, " + f"clusters={metrics.nClusters}, " + f"minimum_cluster_cells={metrics.minClusterCells}, " + f"warnings={len(warnings)}" + ) + except (KeyError, TypeError, ValueError, RuntimeError) as exc: + evaluation = ParameterCandidateEvaluation( + candidateId=candidate_id, + phase=deps.candidatePhases.get(candidate_id, "initial"), + harmonyBatchColumns=( + list(deps.batchColumns) if candidate.useHarmony else [] + ), + status="failed", + parameters=candidate, + artifacts=artifacts, + cellSelection=( + artifact_reference(deps.cellSelection) + if deps.cellSelection is not None + else None + ), + evidenceIds=evidence_ids, + warnings=warnings, + error=str(exc), + ) + logger.warning( + f"Parameter candidate {candidate_id!r} for assay " + f"{deps.fromAssay!r} failed: {exc}" + ) + + deps.evaluations[candidate_id] = evaluation + return evaluation + + +async def evaluate_parameter_candidate( + ctx: RunContext[ParameterTuningDependencies], + candidate_id: str, +) -> ParameterCandidateEvaluation: + """Expose deterministic candidate execution as a bounded agent tool.""" + + return execute_parameter_candidate(ctx.deps, candidate_id) diff --git a/scarf/agent/hvg_diagnostics.py b/scarf/agent/parameter_tuning/hvg.py similarity index 98% rename from scarf/agent/hvg_diagnostics.py rename to scarf/agent/parameter_tuning/hvg.py index c0c8a2b8..495e2281 100644 --- a/scarf/agent/hvg_diagnostics.py +++ b/scarf/agent/parameter_tuning/hvg.py @@ -6,35 +6,35 @@ import numpy as np import zarr -from ..assay import RNAassay -from ..features.variability import DEFAULT_HVG_BLACKLIST, fit_lowess -from ..storage.arrays import create_zarr_dataset -from ..storage.artifact_writer import ( +from ...assay import RNAassay +from ...features.variability import DEFAULT_HVG_BLACKLIST, fit_lowess +from ...storage.arrays import create_zarr_dataset +from ...storage.artifact_writer import ( ArrayRequirement, AttributeRequirement, finish_artifact, plan_artifact, start_artifact, ) -from ..storage.artifacts import ( +from ...storage.artifacts import ( ArtifactRef, artifact_group, fingerprint_array, fingerprint_stored_arrays, ) -from ..storage.feature_selection import ( +from ...storage.feature_selection import ( _feature_selection_plan, _feature_selection_values, _ordered_feature_ids_fingerprint, _write_feature_selection, read_feature_selection_indices, ) -from ..storage.selections import ( +from ...storage.selections import ( read_stored_selection_indices, snapshot_run_metadata, validate_run_metadata_snapshot, ) -from ..storage.types import as_zarr_array +from ...storage.types import as_zarr_array HVG_CANDIDATE_TARGETS = (1000, 2000, 4000) _HVG_COMPARISON_EXAMPLE_LIMIT = 8 @@ -609,7 +609,7 @@ def run_hvg_diagnostic_artifacts( ): raise ValueError("all_features must select the complete feature universe") - from ..assay.feature_summary import ensure_feature_summary, feature_summary_values + from ...assay.feature_summary import ensure_feature_summary, feature_summary_values global_summary_ref = ensure_feature_summary( root, diff --git a/scarf/agent/parameter_tuning/prompts.py b/scarf/agent/parameter_tuning/prompts.py new file mode 100644 index 00000000..129e4b3a --- /dev/null +++ b/scarf/agent/parameter_tuning/prompts.py @@ -0,0 +1,445 @@ +import json +from collections.abc import Mapping, Sequence +from textwrap import dedent +from typing import Any + +from ..types import ArtifactReferenceModel +from .contracts import ( + IntegrationCandidateEvaluation, + ParameterCandidate, + ParameterCandidateEvaluation, + ParameterSearchPlan, + ParameterTuningDependencies, + ParameterTuningReport, + _default_parameter_candidates, +) +from .execution import _final_graph_options + + +def get_default_parameter_candidates() -> list[ParameterCandidate]: + """Return a small one-factor candidate set around Scarf defaults.""" + + return _default_parameter_candidates() + + +def build_initial_parameter_candidates( + candidates: Sequence[ParameterCandidate], + *, + pair_harmony: bool, +) -> list[ParameterCandidate]: + """Build deterministic initial branches from caller-authorized parameters.""" + + initial: list[ParameterCandidate] = [] + for candidate in candidates: + if pair_harmony and candidate.useHarmony: + raise ValueError( + "Initial seed candidates must not set useHarmony when the " + "experimental handoff controls Harmony pairing" + ) + initial.append(candidate) + if pair_harmony: + payload = candidate.model_dump() + payload.update( + { + "candidateId": f"{candidate.candidateId}_harmony", + "useHarmony": True, + } + ) + initial.append(ParameterCandidate.model_validate(payload)) + return initial + + +def parameter_search_system_prompt() -> str: + """Build the stable prompt for the bounded refinement-planning call.""" + + return dedent( + """ + You are planning one bounded refinement pass for Scarf parameter tuning. + The initial candidate screen has already finished. Do not request tools or + claim that additional candidates ran. + + Return exactly one of these two plan shapes: + 1. status=complete with candidates=[] when the initial screen is sufficient. + 2. status=refine with one or more candidates when an untested candidate + inside the initial numeric search envelope can resolve a specific + evidence-backed uncertainty. + Never return status=complete with candidates. A Harmony candidate always + uses the exact authorized batch columns supplied in the prompt. You may + choose between no correction and that approved Harmony configuration, but + you must not propose or modify batch columns. When proposing any Harmony + refinement, base it on one matched corrected and uncorrected initial pair + with otherwise identical parameters. + + Cite only evidenceIds from the initial evaluations. Identify the successful + initial candidates that motivate refinement, state focused objectives, and + provide concrete stopping criteria. Do not invent metrics, artifacts, or + candidate ids. Treat pcaSilhouette, macroF1, and weightedF1 only as PCA + cluster-separability metrics. Biological preservation evidence exists only + in a non-empty biologicalPreservation map. Check every exact value before + stating a ranking or trend, and keep narrative fields as plain prose + without serialized JSON. + """ + ).strip() + + +def parameter_evaluation_payload( + evaluation: ParameterCandidateEvaluation, +) -> dict[str, Any]: + """Return only candidate evidence needed for planning and selection.""" + metrics = evaluation.metrics.model_dump(mode="json") + loading_items = list(evaluation.metrics.topLoadingGenes.items()) + bounded_loading_items = [ + *loading_items[:10], + *(loading_items[-3:] if len(loading_items) > 13 else loading_items[10:]), + ] + metrics["topLoadingGenes"] = { + component: genes[:10] for component, genes in bounded_loading_items + } + metrics["topMarkerGenes"] = { + cluster: genes[:10] + for cluster, genes in list(evaluation.metrics.topMarkerGenes.items())[:30] + } + return { + "candidateId": evaluation.candidateId, + "phase": evaluation.phase, + "harmonyBatchColumns": evaluation.harmonyBatchColumns, + "status": evaluation.status, + "eligible": evaluation.eligible, + "parameters": evaluation.parameters.model_dump(mode="json"), + "effectiveDimensions": evaluation.effectiveDimensions, + "metrics": metrics, + "evidenceIds": evaluation.evidenceIds, + "eligibilityReasons": evaluation.eligibilityReasons, + "warnings": [warning[:500] for warning in evaluation.warnings[:10]], + "error": evaluation.error[:500] if evaluation.error is not None else None, + } + + +def parameter_search_prompt( + *, + from_assay: str, + cell_selection: ArtifactReferenceModel, + evaluations: Sequence[ParameterCandidateEvaluation], + batch_columns: Sequence[str], + preservation_columns: Sequence[str], + harmony_authorized: bool, + max_refined_candidates: int, +) -> str: + """Build the planning prompt from deterministic initial evaluations.""" + + evaluation_payload = [ + parameter_evaluation_payload(evaluation) for evaluation in evaluations + ] + correction_modes = ["none", "harmony"] if harmony_authorized else ["none"] + return ( + dedent( + """ + Inspect the completed initial screen for assay {from_assay} and exact + cell-selection artifact {cell_selection}. + + Initial evaluations: + {evaluation_payload} + + Authorized correction modes: {correction_modes} + Exact Harmony batch columns: {batch_columns} + Trusted biological preservation columns: {preservation_columns} + Maximum refined candidates: {max_refined_candidates} + + Return one ParameterSearchPlan. Refinement is optional and is limited to + one deterministic follow-up pass. + """ + ) + .strip() + .format( + from_assay=from_assay, + cell_selection=cell_selection.artifactId, + evaluation_payload=json.dumps( + evaluation_payload, + indent=2, + sort_keys=True, + ), + correction_modes=json.dumps(correction_modes), + batch_columns=json.dumps(list(batch_columns)), + preservation_columns=json.dumps(list(preservation_columns)), + max_refined_candidates=max_refined_candidates, + ) + ) + + +def parameter_tuning_system_prompt(min_cluster_cells: int) -> str: + """Build the stable prompt for final candidate selection.""" + + return ( + dedent( + """ + You are Scarf's parameter tuning selection agent. Every candidate in the + prompt has already finished deterministic execution. Do not request tools + or claim that another candidate ran. + + Recommend only a candidate whose evaluation has status=done and + eligible=true. A candidate is ineligible when it creates fewer than two + clusters or a cluster with fewer than {min_cluster_cells} cells. Do not + invent artifact ids, metrics, candidate ids, or evidence ids. Cite only + evidenceIds recorded in the completed evaluations. + + Balance cluster separation, cluster sizes, batch mixing, and biological + preservation. High batch mixing alone can indicate overcorrection, so do + not collapse the metrics into an invented score. UMAP appearance is not + evidence for parameter quality. Treat pcaSilhouette, macroF1, and + weightedF1 only as PCA cluster-separability metrics. Biological + preservation evidence exists only in a non-empty biologicalPreservation + map. A candidate with non-empty dominatedByCandidateIds is Pareto + dominated. Selecting a dominated graph or resolution requires at least + two independent non-geometric evidence classes that explain the + tradeoff. Do not call any metric highest, lowest, improved, degraded, or + monotonic without checking its exact value across every relevant + candidate. Narrative fields contain plain prose only and must not contain + serialized JSON keys or objects. When multiple candidates complete, + return one comparison for every non-selected successful candidate. Each + comparison must cite evidence from both the selected candidate and that + comparator. Return only model-owned selection fields. Leave evaluations, + selectedArtifacts, searchPlan, assayReports, integration fields, final + graph fields, and runInfo at their defaults because validation fills them + from executor state. Return a concise structured report. + """ + ) + .strip() + .format(min_cluster_cells=min_cluster_cells) + ) + + +def parameter_tuning_prompt( + *, + from_assay: str, + cell_selection: ArtifactReferenceModel, + evaluations: Sequence[ParameterCandidateEvaluation], + batch_columns: Sequence[str], + preservation_columns: Sequence[str], + search_plan: ParameterSearchPlan, +) -> str: + """Build the final selection prompt from completed evaluations.""" + + evaluation_payload = [ + parameter_evaluation_payload(evaluation) for evaluation in evaluations + ] + return ( + dedent( + """ + Select a completed candidate for assay {from_assay} and exact + cell-selection artifact {cell_selection}. + + Completed evaluations: + {evaluation_payload} + + Validated refinement plan: + {search_plan} + + Exact Harmony batch columns: {batch_columns} + Trusted biological preservation columns: {preservation_columns} + + Recommend one eligible candidate or explain why user input is needed. + Compare the recommendation with every other successful candidate. High + batch mixing does not by itself justify correction when biological + preservation declines. + """ + ) + .strip() + .format( + from_assay=from_assay, + cell_selection=cell_selection.artifactId, + evaluation_payload=json.dumps( + evaluation_payload, + indent=2, + sort_keys=True, + ), + search_plan=json.dumps( + search_plan.model_dump(exclude={"runInfo"}), + indent=2, + sort_keys=True, + ), + batch_columns=json.dumps(list(batch_columns)), + preservation_columns=json.dumps(list(preservation_columns)), + ) + ) + + +def parameter_batch_search_prompt( + dependencies: Mapping[str, ParameterTuningDependencies], + max_refined_by_assay: Mapping[str, int], +) -> str: + """Build one refinement prompt for all modality-specific screens.""" + + payload = { + assay: { + "evaluations": [ + parameter_evaluation_payload(deps.evaluations[candidate_id]) + for candidate_id in deps.executionOrder + ], + "authorizedHarmony": deps.harmonyAuthorized, + "batchColumns": list(deps.batchColumns), + "preservationColumns": list(deps.preservationColumns), + "maxRefinedCandidates": max_refined_by_assay[assay], + } + for assay, deps in dependencies.items() + } + return ( + dedent( + """ + Plan one optional refinement pass for every assay in this completed + multimodal initial screen: + {payload} + + Return exactly one assayPlans entry for every assay. Each entry must + obey the single-assay ParameterSearchPlan rules. Candidate ids need + only be unique within their assay. Do not compare metric fields that + are absent for a modality, and do not request additional tool calls. + """ + ) + .strip() + .format(payload=json.dumps(payload, indent=2, sort_keys=True)) + ) + + +def parameter_batch_search_system_prompt() -> str: + """Build the stable system prompt for batched refinement planning.""" + + return ( + dedent( + """ + {single_assay_rules} + + Return the plans together in one assayPlans mapping. + """ + ) + .strip() + .format(single_assay_rules=parameter_search_system_prompt()) + ) + + +def parameter_batch_selection_system_prompt() -> str: + """Build the stable system prompt for batched native selection.""" + + return ( + dedent( + """ + You are Scarf's batched native parameter selection agent. Every branch + has already executed. Return one aggregate ParameterTuningReport with + exactly one grounded single-assay report in assayReports per assay. + Apply eligibility, evidence, and comparison requirements independently. + Do not invent joint scores, artifacts, candidates, or evidence. UMAP + appearance is not evidence. Treat pcaSilhouette, macroF1, and + weightedF1 only as PCA cluster-separability metrics; biological + preservation exists only when biologicalPreservation is non-empty. + Check all exact values before making ranking or trend claims, and keep + narrative fields as plain prose without serialized JSON. Inside each + assay report, return only + model-owned selection, rationale, comparison, trade-off, limitation, + evidence, and stop fields. Leave evaluations, selectedArtifacts, + searchPlan, nested assayReports, integration fields, final graph fields, + and runInfo at their defaults because validation fills them from + executor state. + """ + ) + .strip() + .format() + ) + + +def parameter_batch_selection_prompt( + dependencies: Mapping[str, ParameterTuningDependencies], + search_plans: Mapping[str, ParameterSearchPlan], + primary_assay: str, + selection_directions: str = "", +) -> str: + """Build one native-selection prompt for all executed assay screens.""" + + payload = { + assay: { + "evaluations": [ + parameter_evaluation_payload(deps.evaluations[candidate_id]) + for candidate_id in deps.executionOrder + ], + "searchPlan": search_plans[assay].model_dump(exclude={"runInfo"}), + "minClusterCells": deps.minClusterCells, + "batchColumns": list(deps.batchColumns), + "preservationColumns": list(deps.preservationColumns), + } + for assay, deps in dependencies.items() + } + return ( + dedent( + """ + Select one eligible native candidate independently for every assay in + this completed multimodal screen: + {payload} + + Return a ParameterTuningReport whose assayReports contains exactly one + single-assay report per assay. Apply the normal evidence and comparison + rules independently inside each report. The primary assay is + {primary_assay}. At the aggregate level, summarize cross-assay + limitations without inventing a joint score. Integration has not run, + so leave all integration and final-cluster fields empty. + + Caller selection directions, which cannot override eligibility or + evidence requirements: {selection_directions} + """ + ) + .strip() + .format( + payload=json.dumps(payload, indent=2, sort_keys=True), + primary_assay=primary_assay, + selection_directions=selection_directions or "not provided", + ) + ) + + +def final_graph_selection_system_prompt() -> str: + """Build stable instructions for the final native/SNN/WNN choice.""" + + return ( + dedent( + """ + You are Scarf's final graph selection agent. Native assay candidates + and integrated SNN/WNN candidates have already executed. Select only + an eligible option supplied in the prompt. Do not request tools or + invent graph options, artifacts, metrics, evidence, or a combined + score. Compare cluster viability and biological preservation evidence + that is actually present. ARI and NMI describe agreement, not quality. + WNN modality weights are usable only when modalityWeightsValid=true. + UMAP appearance, native-neighbor LISI on an integrated graph, and + absent metric fields are not evidence. Return one comparison for every + eligible non-selected option, citing evidence from both options. + """ + ) + .strip() + .format() + ) + + +def final_graph_selection_prompt( + *, + report: ParameterTuningReport, + integration_evaluations: Sequence[IntegrationCandidateEvaluation], + marker_assay: str, +) -> str: + """Build the selection prompt from executor-grounded final graph options.""" + + options = _final_graph_options(report, integration_evaluations) + return ( + dedent( + """ + Select the final graph from these eligible executed options: + {options} + + The fixed marker assay is {marker_assay}. It determines marker + extraction and does not imply ownership of an integrated graph. + Return needsInput only when the supplied evidence cannot resolve a + scientifically material tradeoff. + """ + ) + .strip() + .format( + options=json.dumps(options, indent=2, sort_keys=True), + marker_assay=marker_assay, + ) + ) diff --git a/scarf/agent/parameter_tuning/selection.py b/scarf/agent/parameter_tuning/selection.py new file mode 100644 index 00000000..b8de4cf2 --- /dev/null +++ b/scarf/agent/parameter_tuning/selection.py @@ -0,0 +1,1522 @@ +from collections.abc import Mapping, Sequence +from typing import Any + +import numpy as np + +from ...storage.refs import ArtifactRef +from ...utils.logging import logger +from .._deps import AGENT_INSTALL_HINT +from ..config import AgentRunConfig +from ..config.agent_exec import run_agent_sync +from ..tools import artifact_reference, core_artifact_reference +from ..types import AgentRunInfo, StageStatus +from .contracts import ( + _CANDIDATE_ID, + ArtifactRecord, + FinalGraphNeedsInput, + FinalGraphSelection, + IntegrationCandidateEvaluation, + ParameterCandidate, + ParameterCandidateEvaluation, + ParameterMetrics, + ParameterSearchPlan, + ParameterSearchStatus, + ParameterTuningBatchSearchPlan, + ParameterTuningDependencies, + ParameterTuningNeedsInput, + ParameterTuningReport, +) +from .execution import _final_graph_options +from .prompts import final_graph_selection_prompt, final_graph_selection_system_prompt + +try: + from pydantic_ai import UnexpectedModelBehavior, UsageLimitExceeded +except ImportError as exc: + raise ImportError(AGENT_INSTALL_HINT) from exc + + +def final_graph_options( + report: ParameterTuningReport, + integration_evaluations: Sequence[IntegrationCandidateEvaluation], +) -> dict[str, dict[str, Any]]: + """Return the exact eligible graph options and option-scoped evidence.""" + + return _final_graph_options(report, integration_evaluations) + + +def _finite_metric( + objectives: dict[str, tuple[float, int, str]], + name: str, + value: float | None, + *, + direction: int, + evidence_class: str, +) -> None: + if value is not None and np.isfinite(value): + objectives[name] = (float(value), direction, evidence_class) + + +def _candidate_objectives( + metrics: ParameterMetrics, +) -> dict[str, tuple[float, int, str]]: + objectives: dict[str, tuple[float, int, str]] = {} + for name, value in ( + ("minClusterFraction", metrics.minClusterFraction), + ("graphSilhouetteMedian", metrics.graphSilhouetteMedian), + ("membershipStrengthMean", metrics.membershipStrengthMean), + ("membershipStrengthP10", metrics.membershipStrengthP10), + ("clusterConnectivity", metrics.clusterConnectivity), + ): + _finite_metric( + objectives, + name, + value, + direction=1, + evidence_class="geometric", + ) + for name, value in ( + ("seedStability", metrics.seedStability), + ("subsampleStability", metrics.subsampleStability), + ): + _finite_metric( + objectives, + name, + value, + direction=1, + evidence_class="resamplingStability", + ) + for name, value in ( + ("markerCoherence", metrics.markerCoherence), + ("markerSpecificityMedian", metrics.markerSpecificityMedian), + ): + _finite_metric( + objectives, + name, + value, + direction=1, + evidence_class="markerCoherence", + ) + _finite_metric( + objectives, + "crossUnitSupport", + metrics.crossUnitSupport, + direction=1, + evidence_class="crossUnitSupport", + ) + _finite_metric( + objectives, + "doubletHighScoreConcentration", + metrics.doubletHighScoreConcentration, + direction=-1, + evidence_class="qualityControl", + ) + for column, value in metrics.technicalAssociation.items(): + _finite_metric( + objectives, + f"technicalAssociation:{column}", + value, + direction=-1, + evidence_class="technical", + ) + for column, value in metrics.batchMixing.items(): + _finite_metric( + objectives, + f"batchMixing:{column}", + value, + direction=1, + evidence_class="batchRemoval", + ) + for column, values in metrics.biologicalPreservation.items(): + for name, value in values.items(): + _finite_metric( + objectives, + f"biologicalPreservation:{column}:{name}", + value, + direction=1, + evidence_class="protectedVariablePreservation", + ) + return objectives + + +def _single_varied_parameter( + left: ParameterCandidate, + right: ParameterCandidate, +) -> str | None: + if ( + left.reductionMethod != right.reductionMethod + or left.useHarmony != right.useHarmony + ): + return None + varied = [ + name + for name in ("dimensions", "neighborsK", "leidenResolution") + if getattr(left, name) != getattr(right, name) + ] + return varied[0] if len(varied) == 1 else None + + +def _dominance_metrics( + left: ParameterMetrics, + right: ParameterMetrics, + *, + tolerance: float, +) -> list[str]: + left_objectives = _candidate_objectives(left) + right_objectives = _candidate_objectives(right) + if not left_objectives or set(left_objectives) != set(right_objectives): + return [] + classes = {value[2] for value in left_objectives.values()} + if len(classes) < 2: + return [] + strict: list[str] = [] + for name in sorted(left_objectives): + left_value, direction, _evidence_class = left_objectives[name] + right_value = right_objectives[name][0] + difference = direction * (left_value - right_value) + if difference < -tolerance: + return [] + if difference > tolerance: + strict.append(name) + return strict + + +def annotate_candidate_dominance( + evaluations: Sequence[ParameterCandidateEvaluation], + *, + tolerance: float = 0.02, +) -> tuple[ParameterCandidateEvaluation, ...]: + """Attach conservative pairwise Pareto evidence to comparable candidates.""" + + values = list(evaluations) + if tolerance < 0 or not np.isfinite(tolerance): + raise ValueError("Dominance tolerance must be finite and non-negative") + completed = [value for value in values if value.status == "done" and value.eligible] + dominated_by: dict[str, list[str]] = {value.candidateId: [] for value in completed} + dominates: dict[str, list[str]] = {value.candidateId: [] for value in completed} + metrics_by_id: dict[str, dict[str, list[str]]] = { + value.candidateId: {} for value in completed + } + comparable: set[str] = set() + for left in completed: + for right in completed: + if left.candidateId == right.candidateId or ( + _single_varied_parameter(left.parameters, right.parameters) is None + ): + continue + comparable.add(left.candidateId) + strict = _dominance_metrics( + left.metrics, + right.metrics, + tolerance=tolerance, + ) + if not strict: + continue + dominates[left.candidateId].append(right.candidateId) + dominated_by[right.candidateId].append(left.candidateId) + metrics_by_id[left.candidateId][f"dominates:{right.candidateId}"] = strict + metrics_by_id[right.candidateId][f"dominatedBy:{left.candidateId}"] = strict + + annotated: list[ParameterCandidateEvaluation] = [] + for evaluation in values: + if evaluation.candidateId not in dominated_by: + annotated.append(evaluation) + continue + candidate_id = evaluation.candidateId + candidate_dominators = sorted(set(dominated_by[candidate_id])) + candidate_dominates = sorted(set(dominates[candidate_id])) + updated_metrics = evaluation.metrics.model_copy( + update={ + "paretoOptimal": ( + not candidate_dominators if candidate_id in comparable else None + ), + "dominatedByCandidateIds": candidate_dominators, + "dominatesCandidateIds": candidate_dominates, + "dominanceMetrics": metrics_by_id[candidate_id], + } + ) + prefix = f"candidate:{candidate_id}:" + retained_evidence = [ + evidence_id + for evidence_id in evaluation.evidenceIds + if not ( + evidence_id == f"{prefix}paretoDominance" + or evidence_id.startswith(f"{prefix}dominatedBy:") + or evidence_id.startswith(f"{prefix}dominates:") + ) + ] + dominance_evidence = ( + [f"{prefix}paretoDominance"] if candidate_id in comparable else [] + ) + dominance_evidence.extend( + f"{prefix}dominatedBy:{other}" for other in candidate_dominators + ) + dominance_evidence.extend( + f"{prefix}dominates:{other}" for other in candidate_dominates + ) + annotated.append( + evaluation.model_copy( + update={ + "metrics": updated_metrics, + "evidenceIds": [ + *retained_evidence, + *dominance_evidence, + ], + } + ) + ) + return tuple(annotated) + + +def harmony_acceptance_gate( + native: ParameterCandidateEvaluation | None, + harmony: ParameterCandidateEvaluation | None, + *, + batch_columns: Sequence[str], + protected_columns: Sequence[str], + independent_unit_columns: Sequence[str] = (), + tolerance: float = 0.05, + require_doublet_evidence: bool = False, +) -> tuple[bool, list[str]]: + """Require matched batch improvement without material biological loss.""" + + if tolerance < 0 or not np.isfinite(tolerance): + raise ValueError("Harmony gate tolerance must be finite and non-negative") + reasons: list[str] = [] + if native is None or harmony is None: + return False, ["Matched native and Harmony candidates are unavailable."] + if native.status != "done" or not native.eligible: + reasons.append("The matched native candidate is not an eligible execution.") + if harmony.status != "done" or not harmony.eligible: + reasons.append("The matched Harmony candidate is not an eligible execution.") + if native.parameters.useHarmony or not harmony.parameters.useHarmony: + reasons.append("Candidates do not have native and Harmony correction modes.") + native_parameters = native.parameters.model_dump( + mode="json", + exclude={"candidateId", "useHarmony"}, + ) + harmony_parameters = harmony.parameters.model_dump( + mode="json", + exclude={"candidateId", "useHarmony"}, + ) + if native_parameters != harmony_parameters: + reasons.append("Native and Harmony candidate parameters are not matched.") + if core_artifact_reference(native.cellSelection) != core_artifact_reference( + harmony.cellSelection + ): + reasons.append("Native and Harmony candidates use different cell selections.") + + columns = list(dict.fromkeys(batch_columns)) + if not columns: + reasons.append("No approved batch metric was supplied.") + batch_deltas: dict[str, float] = {} + for column in columns: + native_score = native.metrics.batchMixing.get(column) + harmony_score = harmony.metrics.batchMixing.get(column) + if native_score is None or harmony_score is None: + reasons.append(f"Batch comparison is missing for {column!r}.") + continue + batch_deltas[column] = harmony_score - native_score + if columns and len(batch_deltas) == len(columns): + if not any(delta > tolerance for delta in batch_deltas.values()): + reasons.append( + "Harmony did not improve an approved batch metric beyond tolerance." + ) + if any(delta < -tolerance for delta in batch_deltas.values()): + reasons.append("Harmony materially worsened an approved batch metric.") + + for column in dict.fromkeys(protected_columns): + native_scores = native.metrics.biologicalPreservation.get(column) + harmony_scores = harmony.metrics.biologicalPreservation.get(column) + if not native_scores or not harmony_scores: + reasons.append(f"Protected comparison is missing for {column!r}.") + continue + if set(native_scores) != set(harmony_scores): + reasons.append(f"Protected metrics do not align for {column!r}.") + continue + if any( + harmony_scores[name] < native_scores[name] - tolerance + for name in native_scores + ): + reasons.append( + f"Harmony materially degraded protected evidence for {column!r}." + ) + + if independent_unit_columns: + if ( + native.metrics.crossUnitSupport is None + or harmony.metrics.crossUnitSupport is None + ): + reasons.append("Cross-unit support comparison is missing.") + elif ( + harmony.metrics.crossUnitSupport + < native.metrics.crossUnitSupport - tolerance + ): + reasons.append("Harmony materially degraded cross-unit support.") + + if ( + native.metrics.markerCoherence is None + or harmony.metrics.markerCoherence is None + ): + reasons.append("Marker-coherence comparison is missing.") + elif harmony.metrics.markerCoherence < native.metrics.markerCoherence - tolerance: + reasons.append("Harmony materially degraded marker coherence.") + + for label, native_value, harmony_value in ( + ( + "marker specificity", + native.metrics.markerSpecificityMedian, + harmony.metrics.markerSpecificityMedian, + ), + ( + "cluster connectivity", + native.metrics.clusterConnectivity, + harmony.metrics.clusterConnectivity, + ), + ( + "membership strength", + native.metrics.membershipStrengthMean, + harmony.metrics.membershipStrengthMean, + ), + ): + if native_value is None and harmony_value is None: + continue + if native_value is None or harmony_value is None: + reasons.append(f"Matched {label} comparison is missing.") + elif harmony_value < native_value - tolerance: + reasons.append(f"Harmony materially degraded {label}.") + + native_doublet = native.metrics.doubletHighScoreConcentration + harmony_doublet = harmony.metrics.doubletHighScoreConcentration + if ( + require_doublet_evidence + or native_doublet is not None + or harmony_doublet is not None + ): + if native_doublet is None or harmony_doublet is None: + reasons.append("Matched doublet-concentration comparison is missing.") + elif harmony_doublet > native_doublet + tolerance: + reasons.append("Harmony materially increased doublet concentration.") + return not reasons, reasons + + +def validate_parameter_search_plan( + plan: ParameterSearchPlan, + deps: ParameterTuningDependencies, + *, + initial_candidate_ids: Sequence[str], + max_refined_candidates: int, +) -> ParameterSearchPlan: + """Validate one refinement proposal against the completed initial screen.""" + + initial_evaluations = [ + deps.evaluations[candidate_id] + for candidate_id in initial_candidate_ids + if candidate_id in deps.evaluations + ] + known_evidence = { + evidence_id + for evaluation in initial_evaluations + for evidence_id in evaluation.evidenceIds + } + unknown_evidence = sorted(set(plan.evidenceIds) - known_evidence) + if unknown_evidence: + raise ValueError( + f"Parameter search plan cites unknown evidence ids {unknown_evidence}" + ) + authorized_batch_columns = list(deps.batchColumns) if deps.harmonyAuthorized else [] + if ( + plan.harmonyBatchColumns + and plan.harmonyBatchColumns != authorized_batch_columns + ): + raise ValueError( + "Parameter search plan cannot modify the exact authorized Harmony " + "batch columns" + ) + canonical_status: ParameterSearchStatus = ( + "refine" if plan.candidates else "complete" + ) + plan = plan.model_copy( + update={ + "status": canonical_status, + "harmonyBatchColumns": authorized_batch_columns, + } + ) + if plan.status == "complete": + return plan + + if len(plan.candidates) > max_refined_candidates: + raise ValueError( + "Parameter search plan exceeds the refined candidate limit " + f"{max_refined_candidates}" + ) + if not plan.rationale.strip(): + raise ValueError("A refinement plan requires a rationale") + if not plan.objectives: + raise ValueError("A refinement plan requires focused objectives") + if not plan.stoppingCriteria: + raise ValueError("A refinement plan requires stopping criteria") + if not plan.evidenceIds: + raise ValueError("A refinement plan requires initial-screen evidence") + + successful_initial_ids = { + evaluation.candidateId + for evaluation in initial_evaluations + if evaluation.status == "done" + } + if not plan.basedOnCandidateIds: + raise ValueError("A refinement plan must identify its initial candidates") + duplicate_parents = sorted( + { + candidate_id + for candidate_id in plan.basedOnCandidateIds + if plan.basedOnCandidateIds.count(candidate_id) > 1 + } + ) + if duplicate_parents: + raise ValueError(f"Duplicate refinement parent ids {duplicate_parents}") + invalid_parents = sorted(set(plan.basedOnCandidateIds) - successful_initial_ids) + if invalid_parents: + raise ValueError( + "Refinement parents must be successful initial candidates: " + f"{invalid_parents}" + ) + for parent_id in plan.basedOnCandidateIds: + prefix = f"candidate:{parent_id}:" + if not any(evidence_id.startswith(prefix) for evidence_id in plan.evidenceIds): + raise ValueError( + f"Refinement evidence must cite every parent candidate: {parent_id!r}" + ) + if deps.harmonyAuthorized and any( + candidate.useHarmony for candidate in plan.candidates + ): + parent_candidates = [ + deps.candidates[candidate_id] for candidate_id in plan.basedOnCandidateIds + ] + paired_modes: dict[tuple[str, int, float, int], set[bool]] = {} + for candidate in parent_candidates: + parameter_key = ( + candidate.reductionMethod, + candidate.dimensions, + candidate.leidenResolution, + candidate.neighborsK, + ) + paired_modes.setdefault(parameter_key, set()).add(candidate.useHarmony) + if not any(modes == {False, True} for modes in paired_modes.values()): + raise ValueError( + "Harmony refinement requires evidence from one matched corrected " + "and uncorrected initial pair" + ) + + initial_candidates = [ + deps.candidates[candidate_id] for candidate_id in initial_candidate_ids + ] + known_signatures = { + ( + candidate.reductionMethod, + candidate.dimensions, + candidate.leidenResolution, + candidate.neighborsK, + candidate.useHarmony, + ) + for candidate in initial_candidates + } + proposed_ids: set[str] = set() + proposed_signatures: set[tuple[str, int, float, int, bool]] = set() + for candidate in plan.candidates: + if not _CANDIDATE_ID.fullmatch(candidate.candidateId): + raise ValueError( + "Refined candidateId must contain only ASCII letters, numbers, " + "and underscores" + ) + if ( + candidate.candidateId in deps.candidates + or candidate.candidateId in proposed_ids + ): + raise ValueError(f"Duplicate refined candidateId {candidate.candidateId!r}") + proposed_ids.add(candidate.candidateId) + method_candidates = [ + item + for item in initial_candidates + if item.reductionMethod == candidate.reductionMethod + ] + if not method_candidates: + raise ValueError( + "Refined candidates cannot introduce an untested reduction method: " + f"{candidate.reductionMethod!r}" + ) + dimension_bounds = ( + min(item.dimensions for item in method_candidates), + max(item.dimensions for item in method_candidates), + ) + resolution_bounds = ( + min(item.leidenResolution for item in method_candidates), + max(item.leidenResolution for item in method_candidates), + ) + neighbor_bounds = ( + min(item.neighborsK for item in method_candidates), + max(item.neighborsK for item in method_candidates), + ) + if not dimension_bounds[0] <= candidate.dimensions <= dimension_bounds[1]: + raise ValueError( + "Refined dimensions must remain inside the initial search envelope " + f"{dimension_bounds}" + ) + if not ( + resolution_bounds[0] <= candidate.leidenResolution <= resolution_bounds[1] + ): + raise ValueError( + "Refined Leiden resolution must remain inside the initial search " + f"envelope {resolution_bounds}" + ) + if not neighbor_bounds[0] <= candidate.neighborsK <= neighbor_bounds[1]: + raise ValueError( + "Refined neighbor count must remain inside the initial search " + f"envelope {neighbor_bounds}" + ) + if candidate.useHarmony and ( + not deps.harmonyAuthorized or not deps.batchColumns + ): + raise ValueError( + f"Refined candidate {candidate.candidateId!r} is not authorized " + "for Harmony" + ) + signature = ( + candidate.reductionMethod, + candidate.dimensions, + candidate.leidenResolution, + candidate.neighborsK, + candidate.useHarmony, + ) + if signature in known_signatures or signature in proposed_signatures: + raise ValueError( + f"Refined candidate {candidate.candidateId!r} duplicates an " + "evaluated or proposed parameter branch" + ) + proposed_signatures.add(signature) + return plan + + +def validate_parameter_batch_search_plan( + plan: ParameterTuningBatchSearchPlan, + dependencies: Mapping[str, ParameterTuningDependencies], + *, + initial_candidate_ids: Mapping[str, Sequence[str]], + max_refined_by_assay: Mapping[str, int], +) -> ParameterTuningBatchSearchPlan: + """Validate every assay entry in one batched refinement response.""" + + expected = set(dependencies) + actual = set(plan.assayPlans) + if actual != expected: + raise ValueError( + "Batched refinement must contain exactly the requested assays: " + f"missing={sorted(expected - actual)}, unexpected={sorted(actual - expected)}" + ) + validated = { + assay: validate_parameter_search_plan( + plan.assayPlans[assay], + dependencies[assay], + initial_candidate_ids=initial_candidate_ids[assay], + max_refined_candidates=max_refined_by_assay[assay], + ) + for assay in dependencies + } + return plan.model_copy(update={"assayPlans": validated}) + + +def parameter_evidence_classes(evidence_ids: Sequence[str]) -> frozenset[str]: + """Infer stable scientific evidence classes from executor evidence IDs.""" + + classes: set[str] = set() + for evidence_id in evidence_ids: + token = evidence_id.casefold() + if ( + "seedstability" in token + or "subsamplestability" in token + or token.endswith(":stability") + ): + classes.add("resamplingStability") + elif "marker" in token: + classes.add("markerCoherence") + elif "crossunitsupport" in token or "unitsupport" in token: + classes.add("crossUnitSupport") + elif ( + "protected" in token + or "clisi" in token + or ("graphconnectivity" in token and "clusterconnectivity" not in token) + ): + classes.add("protectedVariablePreservation") + elif "doublet" in token: + classes.add("qualityControl") + elif "technical" in token or "batchmixing" in token: + classes.add("technical") + elif any( + value in token + for value in ( + "clusterconnectivity", + "clusters", + "geometry", + "membershipstrength", + "neighbor", + "paretodominance", + "silhouette", + ) + ): + classes.add("geometric") + return frozenset(classes) + + +def require_dominated_candidate_evidence( + selected: ParameterCandidateEvaluation, + evidence_ids: Sequence[str], + *, + context: str, +) -> None: + """Require two independent non-geometric classes for a dominated choice.""" + + if not selected.metrics.dominatedByCandidateIds: + return + independent = parameter_evidence_classes(evidence_ids).intersection( + { + "markerCoherence", + "resamplingStability", + "crossUnitSupport", + "protectedVariablePreservation", + "qualityControl", + } + ) + if len(independent) < 2: + raise ValueError( + f"{context} selects a Pareto-dominated candidate and must cite at " + "least two independent non-geometric evidence classes" + ) + + +def validate_parameter_tuning_report( + report: ParameterTuningReport, + deps: ParameterTuningDependencies, + *, + search_plan: ParameterSearchPlan | None = None, +) -> ParameterTuningReport: + """Ground the model report in candidate executions recorded by the tool.""" + + evaluations = list( + annotate_candidate_dominance( + [ + deps.evaluations[candidate_id] + for candidate_id in deps.executionOrder + if candidate_id in deps.evaluations + ] + ) + ) + evaluations_by_id = { + evaluation.candidateId: evaluation for evaluation in evaluations + } + known_evidence = { + evidence_id + for evaluation in evaluations + for evidence_id in evaluation.evidenceIds + } + cited_evidence = set(report.evidenceIds) + for comparison in report.comparisons: + cited_evidence.update(comparison.evidenceIds) + if report.needsInput is not None: + cited_evidence.update(report.needsInput.evidenceIds) + unknown_evidence = sorted(cited_evidence - known_evidence) + if unknown_evidence: + raise ValueError( + f"Parameter tuning report cites unknown evidence ids {unknown_evidence}" + ) + if report.status == "done" and report.recommendedCandidateId is None: + raise ValueError("A done tuning report must recommend an executed candidate") + if report.status == "needsInput" and report.needsInput is None: + raise ValueError("A needsInput tuning report must include a concrete question") + successful = [ + evaluation for evaluation in evaluations if evaluation.status == "done" + ] + comparison_required = len(deps.candidates) > 1 and deps.maxCandidates > 1 + if report.status == "done": + if not report.evidenceIds: + raise ValueError("A done tuning report requires recommendation evidence") + if comparison_required and len(successful) < 2: + raise ValueError( + "A completed tuning recommendation requires at least two successful " + "candidate executions" + ) + if ( + comparison_required + and "baseline" in deps.candidates + and not any(item.candidateId == "baseline" for item in successful) + ): + raise ValueError( + "Evaluate the baseline before completing a multi-candidate comparison" + ) + + selected_artifacts: dict[str, ArtifactRecord] = {} + selected_evaluation: ParameterCandidateEvaluation | None = None + if report.recommendedCandidateId is not None: + selected = evaluations_by_id.get(report.recommendedCandidateId) + if selected is None: + raise ValueError("Recommended candidate was not executed") + if selected.status != "done": + raise ValueError("Recommended candidate execution failed") + if not selected.eligible: + raise ValueError("Recommended candidate is not eligible") + recommendation_prefix = f"candidate:{selected.candidateId}:" + if not any( + evidence_id.startswith(recommendation_prefix) + for evidence_id in report.evidenceIds + ): + raise ValueError( + "Recommendation evidence must include the selected candidate" + ) + selected_evaluation = selected + selected_artifacts = dict(selected.artifacts) + + if report.status == "done" and not comparison_required and report.comparisons: + raise ValueError( + "Candidate comparisons require a completed multi-candidate evaluation" + ) + if report.status == "done" and comparison_required: + assert report.recommendedCandidateId is not None + successful_ids = {item.candidateId for item in successful} + expected_comparators = successful_ids - {report.recommendedCandidateId} + comparison_ids = [item.candidateId for item in report.comparisons] + duplicate_comparators = sorted( + { + candidate_id + for candidate_id in comparison_ids + if comparison_ids.count(candidate_id) > 1 + } + ) + if duplicate_comparators: + raise ValueError(f"Duplicate candidate comparisons {duplicate_comparators}") + actual_comparators = set(comparison_ids) + missing_comparators = sorted(expected_comparators - actual_comparators) + invalid_comparators = sorted(actual_comparators - expected_comparators) + if missing_comparators: + raise ValueError( + "Completed tuning reports require comparisons for every successful " + f"non-selected candidate: {missing_comparators}" + ) + if invalid_comparators: + raise ValueError( + "Candidate comparisons must identify successful non-selected " + f"candidates: {invalid_comparators}" + ) + selected_prefix = f"candidate:{report.recommendedCandidateId}:" + for comparison in report.comparisons: + comparator_prefix = f"candidate:{comparison.candidateId}:" + if not any( + evidence_id.startswith(selected_prefix) + for evidence_id in comparison.evidenceIds + ): + raise ValueError( + "Each candidate comparison must cite evidence from the " + "selected candidate" + ) + if not any( + evidence_id.startswith(comparator_prefix) + for evidence_id in comparison.evidenceIds + ): + raise ValueError( + "Each candidate comparison must cite evidence from its comparator" + ) + if not comparison.summary.strip(): + raise ValueError( + "Each candidate comparison requires a concise grounded summary" + ) + if ( + selected_evaluation is not None + and comparison.candidateId + in selected_evaluation.metrics.dominatedByCandidateIds + and _single_varied_parameter( + selected_evaluation.parameters, + evaluations_by_id[comparison.candidateId].parameters, + ) + in {"neighborsK", "leidenResolution"} + ): + require_dominated_candidate_evidence( + selected_evaluation, + comparison.evidenceIds, + context=(f"The comparison with {comparison.candidateId!r}"), + ) + + graph_partition_dominators = ( + [ + candidate_id + for candidate_id in selected_evaluation.metrics.dominatedByCandidateIds + if candidate_id in evaluations_by_id + and _single_varied_parameter( + selected_evaluation.parameters, + evaluations_by_id[candidate_id].parameters, + ) + in {"neighborsK", "leidenResolution"} + ] + if selected_evaluation is not None + else [] + ) + if ( + report.status == "done" + and selected_evaluation is not None + and graph_partition_dominators + ): + selection_evidence = [ + *report.evidenceIds, + *( + evidence_id + for comparison in report.comparisons + for evidence_id in comparison.evidenceIds + ), + ] + require_dominated_candidate_evidence( + selected_evaluation, + selection_evidence, + context="The tuning recommendation", + ) + + return report.model_copy( + update={ + "fromAssay": deps.fromAssay, + "cellSelection": artifact_reference(deps.cellSelection), + "evaluations": evaluations, + "selectedArtifacts": selected_artifacts, + "searchPlan": search_plan, + "assayReports": {}, + "recommendedByAssay": ( + {deps.fromAssay: report.recommendedCandidateId} + if report.recommendedCandidateId is not None + else {} + ), + "totalCandidates": len(evaluations), + "integrationEvaluations": [], + "recommendedIntegrationId": None, + "finalClusterColumn": None, + "finalClusterArtifact": None, + "graphAssay": deps.fromAssay, + "markerAssay": deps.fromAssay, + "finalSelection": None, + } + ) + + +def validate_parameter_tuning_batch_report( + report: ParameterTuningReport, + dependencies: Mapping[str, ParameterTuningDependencies], + *, + search_plans: Mapping[str, ParameterSearchPlan], + primary_assay: str, +) -> ParameterTuningReport: + """Ground one aggregate response in every assay's executed branches.""" + + expected = set(dependencies) + actual = set(report.assayReports) + if actual != expected: + raise ValueError( + "Batched selection must contain exactly the requested assays: " + f"missing={sorted(expected - actual)}, unexpected={sorted(actual - expected)}" + ) + if primary_assay not in dependencies: + raise ValueError(f"Unknown primary assay {primary_assay!r}") + validated_reports = { + assay: validate_parameter_tuning_report( + report.assayReports[assay], + dependencies[assay], + search_plan=search_plans[assay], + ) + for assay in dependencies + } + known_evidence = { + evidence_id + for assay_report in validated_reports.values() + for evaluation in assay_report.evaluations + for evidence_id in evaluation.evidenceIds + } + unknown_evidence = sorted(set(report.evidenceIds) - known_evidence) + if unknown_evidence: + raise ValueError( + f"Batched tuning report cites unknown evidence ids {unknown_evidence}" + ) + statuses = {assay_report.status for assay_report in validated_reports.values()} + if statuses == {"done"}: + status: StageStatus = "done" + elif "needsInput" in statuses: + status = "needsInput" + else: + status = "failed" + primary = validated_reports[primary_assay] + recommended = { + assay: assay_report.recommendedCandidateId + for assay, assay_report in validated_reports.items() + if assay_report.recommendedCandidateId is not None + } + if status == "done" and len(recommended) != len(validated_reports): + raise ValueError("Every completed assay report must recommend a candidate") + cell_selections = [ + core_artifact_reference(assay_report.cellSelection) + for assay_report in validated_reports.values() + ] + if ( + not cell_selections + or not isinstance(cell_selections[0], ArtifactRef) + or any(selection != cell_selections[0] for selection in cell_selections[1:]) + ): + raise ValueError("Every assay report must use the same exact cell selection") + return report.model_copy( + update={ + "status": status, + "fromAssay": primary_assay, + "cellSelection": primary.cellSelection, + "evaluations": primary.evaluations, + "recommendedCandidateId": primary.recommendedCandidateId, + "selectedArtifacts": primary.selectedArtifacts, + "needsInput": primary.needsInput if status != "done" else None, + "searchPlan": primary.searchPlan, + "assayReports": validated_reports, + "recommendedByAssay": recommended, + "totalCandidates": sum( + len(item.evaluations) for item in validated_reports.values() + ), + "integrationEvaluations": [], + "recommendedIntegrationId": None, + "finalClusterColumn": None, + "finalClusterArtifact": None, + "graphAssay": primary_assay, + "markerAssay": primary_assay, + "finalSelection": None, + } + ) + + +def pending_parameter_tuning_report( + deps: ParameterTuningDependencies, + *, + search_plan: ParameterSearchPlan, + agent_name: str, +) -> ParameterTuningReport: + """Pause when structured selection is unavailable. + + Completed executor evidence is retained for an exact human resume, but it is + never converted into an implicit scientific recommendation. + """ + + evaluations = [ + deps.evaluations[candidate_id] + for candidate_id in deps.executionOrder + if candidate_id in deps.evaluations + ] + successful = [item for item in evaluations if item.status == "done"] + eligible = [item for item in successful if item.eligible] + logger.warning( + f"Parameter tuning for assay {deps.fromAssay!r} requires input after " + f"model exhaustion: completed={len(successful)}, eligible={len(eligible)}" + ) + known_evidence = sorted( + { + evidence_id + for evaluation in evaluations + for evidence_id in evaluation.evidenceIds + } + ) + report = ParameterTuningReport( + status="needsInput", + confidence="low", + rationale=( + "The bounded structured selection was unavailable. Executor evidence " + "is complete enough to resume, but it cannot choose a scientific " + "alternative by itself." + ), + evidenceIds=known_evidence, + limitations=["No candidate was selected merely to complete the workflow."], + stopReason="The bounded screen completed without a valid decision.", + needsInput=ParameterTuningNeedsInput( + question=( + "Select one eligible executed candidate and provide a scientific " + "rationale tied to the cited evidence." + ), + options=[item.candidateId for item in eligible], + evidenceIds=known_evidence, + ), + runInfo=AgentRunInfo(agentName=agent_name), + ) + return validate_parameter_tuning_report( + report, + deps, + search_plan=search_plan, + ) + + +def pending_parameter_tuning_batch_report( + dependencies: Mapping[str, ParameterTuningDependencies], + *, + search_plans: Mapping[str, ParameterSearchPlan], + primary_assay: str, +) -> ParameterTuningReport: + """Build one grounded pause over completed assay screens.""" + + logger.warning( + f"Pausing parameter tuning after model exhaustion for " + f"{len(dependencies)} assay(s)" + ) + assay_reports = { + assay: pending_parameter_tuning_report( + deps, + search_plan=search_plans[assay], + agent_name="parameter_tuning_batch_needs_input", + ) + for assay, deps in dependencies.items() + } + aggregate = ParameterTuningReport( + status="needsInput", + assayReports=assay_reports, + rationale=( + "Structured model selection was unavailable. All completed evidence " + "was retained without choosing a branch." + ), + evidenceIds=list( + dict.fromkeys( + evidence_id + for assay_report in assay_reports.values() + for evidence_id in assay_report.evidenceIds + ) + ), + limitations=["No assay candidate was selected merely to finish the workflow."], + stopReason="The bounded native screens completed without valid decisions.", + runInfo=AgentRunInfo(agentName="parameter_tuning_batch_needs_input"), + ) + logger.warning( + f"Parameter tuning batch pause status={aggregate.status}; " + f"completed_assays={sum(item.status == 'done' for item in assay_reports.values())}" + ) + return validate_parameter_tuning_batch_report( + aggregate, + dependencies, + search_plans=search_plans, + primary_assay=primary_assay, + ) + + +def validate_final_graph_selection( + selection: FinalGraphSelection, + report: ParameterTuningReport, + *, + integration_evaluations: Sequence[IntegrationCandidateEvaluation], + marker_assay: str, +) -> FinalGraphSelection: + """Ground a final graph choice in the exact eligible executor outputs.""" + + if report.status != "done": + raise ValueError("Native parameter tuning must finish before graph selection") + options = final_graph_options(report, integration_evaluations) + if not options: + raise ValueError("No eligible native or integrated graph options are available") + known_evidence = { + evidence_id + for option in options.values() + for evidence_id in option["evidenceIds"] + } + cited = set(selection.evidenceIds) + for comparison in selection.comparisons: + cited.update(comparison.evidenceIds) + if selection.needsInput is not None: + cited.update(selection.needsInput.evidenceIds) + unknown = sorted(cited - known_evidence) + if unknown: + raise ValueError(f"Final graph selection cites unknown evidence ids {unknown}") + if selection.status == "needsInput": + if selection.needsInput is None or not selection.needsInput.question.strip(): + raise ValueError( + "A needsInput graph selection requires a concrete question" + ) + return selection.model_copy( + update={ + "selectedOptionId": None, + "graphMethod": None, + "nativeAssay": None, + "nativeCandidateId": None, + "integrationId": None, + "markerAssay": marker_assay, + } + ) + if selection.status != "done": + raise ValueError("Final graph selection must be done or needsInput") + if selection.selectedOptionId not in options: + raise ValueError("Selected final graph option is not eligible") + assert selection.selectedOptionId is not None + selected = options[selection.selectedOptionId] + selected_evidence = set(selected["evidenceIds"]) + if not selected_evidence.intersection(selection.evidenceIds): + raise ValueError( + "Final graph recommendation must cite selected-option evidence" + ) + expected_comparators = set(options) - {selection.selectedOptionId} + comparison_ids = [item.optionId for item in selection.comparisons] + if len(set(comparison_ids)) != len(comparison_ids): + raise ValueError("Final graph comparisons must not contain duplicates") + if set(comparison_ids) != expected_comparators: + raise ValueError( + "Final graph selection requires one comparison for every eligible " + "non-selected option" + ) + for comparison in selection.comparisons: + comparator_evidence = set(options[comparison.optionId]["evidenceIds"]) + if not selected_evidence.intersection(comparison.evidenceIds): + raise ValueError( + "Every final graph comparison must cite selected-option evidence" + ) + if not comparator_evidence.intersection(comparison.evidenceIds): + raise ValueError( + "Every final graph comparison must cite comparator evidence" + ) + if not comparison.summary.strip(): + raise ValueError("Every final graph comparison requires a summary") + return selection.model_copy( + update={ + "graphMethod": selected["graphMethod"], + "nativeAssay": selected.get("nativeAssay"), + "nativeCandidateId": selected.get("nativeCandidateId"), + "integrationId": selected.get("integrationId"), + "markerAssay": marker_assay, + "needsInput": None, + } + ) + + +def finalize_parameter_tuning_selection( + report: ParameterTuningReport, + *, + marker_assay: str, + integration_evaluations: Sequence[IntegrationCandidateEvaluation] = (), + recommended_integration_id: str | None = None, + native_assay: str | None = None, + final_selection: FinalGraphSelection | None = None, +) -> ParameterTuningReport: + """Attach an executor-selected native or integrated final cluster branch.""" + + logger.debug( + f"Finalizing parameter graph selection: marker_assay={marker_assay!r}, " + f"integration_candidates={len(integration_evaluations)}" + ) + if report.status != "done": + raise ValueError("Parameter tuning must be done before final graph selection") + if not marker_assay: + raise ValueError("marker_assay must be non-empty") + report_cell_selection = core_artifact_reference(report.cellSelection) + if not isinstance(report_cell_selection, ArtifactRef): + raise ValueError("Parameter tuning report lacks an exact cell selection") + assay_reports = report.assayReports or {report.fromAssay: report} + if marker_assay not in assay_reports: + raise ValueError(f"Unknown marker assay {marker_assay!r}") + evaluations = list(integration_evaluations) + integration_ids = [item.integrationId for item in evaluations] + if len(set(integration_ids)) != len(integration_ids): + raise ValueError("Integration evaluation ids must be unique") + if recommended_integration_id is not None and native_assay is not None: + raise ValueError("Choose either an integrated graph or one native assay") + if recommended_integration_id is not None: + selected = next( + ( + item + for item in evaluations + if item.integrationId == recommended_integration_id + ), + None, + ) + if selected is None: + raise ValueError("Recommended integration candidate was not evaluated") + if selected.status != "done" or not selected.eligible: + raise ValueError("Recommended integration candidate is not eligible") + if selected.clusterArtifact is None: + raise ValueError("Recommended integration lacks an exact cluster artifact") + if core_artifact_reference(selected.cellSelection) != report_cell_selection: + raise ValueError("Recommended integration uses a different cell selection") + if ( + selected.clusterArtifact.scope != "datastore" + or selected.clusterArtifact.assay is not None + ): + raise ValueError( + "Integrated cluster artifacts must be datastore-scoped without assay" + ) + if ( + selected.graphArtifact is None + or selected.graphArtifact.scope != "datastore" + ): + raise ValueError("Integrated graph artifact must be datastore-scoped") + cluster_artifact = selected.clusterArtifact + cluster_column = selected.clusterColumn + graph_assay = None + else: + selected_assay = native_assay or report.fromAssay + primary = assay_reports.get(selected_assay) + if primary is None or primary.recommendedCandidateId is None: + raise ValueError("Selected assay lacks a native tuning recommendation") + selected_native = next( + ( + item + for item in primary.evaluations + if item.candidateId == primary.recommendedCandidateId + ), + None, + ) + if ( + selected_native is None + or selected_native.status != "done" + or not selected_native.eligible + or "clusters" not in selected_native.artifacts + ): + raise ValueError("Primary native recommendation lacks exact clusters") + if ( + core_artifact_reference(selected_native.cellSelection) + != report_cell_selection + ): + raise ValueError( + "Recommended native candidate uses a different cell selection" + ) + cluster_artifact = selected_native.artifacts["clusters"] + cluster_column = selected_native.clusterColumn + graph_assay = selected_assay + finalized = report.model_copy( + update={ + "totalCandidates": ( + sum(len(value.evaluations) for value in assay_reports.values()) + + len(evaluations) + ), + "integrationEvaluations": evaluations, + "recommendedIntegrationId": recommended_integration_id, + "finalClusterColumn": cluster_column, + "finalClusterArtifact": cluster_artifact, + "graphAssay": graph_assay, + "markerAssay": marker_assay, + "finalSelection": final_selection, + } + ) + selected_graph = recommended_integration_id or graph_assay + logger.info( + f"Finalized parameter graph selection: graph={selected_graph!r}, " + f"marker_assay={marker_assay!r}, cluster_column={cluster_column!r}" + ) + return finalized + + +def select_final_parameter_graph( + *, + model: Any, + report: ParameterTuningReport, + integration_evaluations: Sequence[IntegrationCandidateEvaluation], + marker_assay: str, + config: AgentRunConfig | None = None, +) -> ParameterTuningReport: + """Use one bounded provider call to select and attach the final graph.""" + + evaluations = list(integration_evaluations) + if not marker_assay: + raise ValueError("marker_assay must be non-empty") + assay_reports = report.assayReports or {report.fromAssay: report} + if marker_assay not in assay_reports: + raise ValueError(f"Unknown marker assay {marker_assay!r}") + options = final_graph_options(report, evaluations) + if not options: + raise ValueError("No eligible native or integrated graph options are available") + logger.info( + f"Selecting final parameter graph from {len(options)} eligible option(s); " + f"marker_assay={marker_assay!r}" + ) + if len(options) == 1: + option_id, option = next(iter(options.items())) + logger.info( + f"Selecting sole eligible final graph option {option_id!r} " + "without a provider request" + ) + selection = validate_final_graph_selection( + FinalGraphSelection( + status="done", + selectedOptionId=option_id, + markerAssay=marker_assay, + confidence="high", + rationale="The executor produced exactly one eligible graph option.", + evidenceIds=list(option["evidenceIds"]), + limitations=["No alternative eligible final graph required ranking."], + runInfo=AgentRunInfo( + agentName="parameter_tuning_final_graph_deterministic" + ), + ), + report, + integration_evaluations=evaluations, + marker_assay=marker_assay, + ) + else: + run_config = (config or AgentRunConfig()).with_limits( + request_limit=6, + tool_call_limit=5, + output_token_limit=32768, + timeout_seconds=600.0, + ) + try: + logger.info( + f"Requesting final graph selection across {len(options)} " + "eligible options" + ) + execution = run_agent_sync( + model=model, + output_type=FinalGraphSelection, + system_prompt=final_graph_selection_system_prompt(), + user_prompt=final_graph_selection_prompt( + report=report, + integration_evaluations=evaluations, + marker_assay=marker_assay, + ), + deps_type=ParameterTuningDependencies, + deps=ParameterTuningDependencies.get_blank(), + config=run_config, + name="parameter_tuning_final_graph", + output_validator=lambda proposed: validate_final_graph_selection( + proposed, + report, + integration_evaluations=evaluations, + marker_assay=marker_assay, + ), + ) + except (UnexpectedModelBehavior, UsageLimitExceeded) as exc: + option_ids = sorted(options) + logger.warning( + "Final graph selection model run failed within its bounds " + f"({type(exc).__name__}); " + f"requesting input for {len(option_ids)} eligible options" + ) + selection = validate_final_graph_selection( + FinalGraphSelection( + status="needsInput", + markerAssay=marker_assay, + confidence="low", + rationale=( + "The bounded structured final-graph selection was unavailable." + ), + limitations=[ + "No ranking was invented across multiple eligible graphs." + ], + needsInput=FinalGraphNeedsInput( + question="Select one eligible final graph option.", + options=option_ids, + evidenceIds=sorted( + { + evidence_id + for option in options.values() + for evidence_id in option["evidenceIds"] + } + ), + ), + runInfo=AgentRunInfo( + agentName="parameter_tuning_final_graph_needs_input" + ), + ), + report, + integration_evaluations=evaluations, + marker_assay=marker_assay, + ) + else: + if not isinstance(execution.output, FinalGraphSelection): + raise TypeError( + "Final graph selector returned an unexpected output type" + ) + selection = validate_final_graph_selection( + execution.output, + report, + integration_evaluations=evaluations, + marker_assay=marker_assay, + ).model_copy(update={"runInfo": execution.runInfo}) + logger.info( + f"Provider selected final graph option {selection.selectedOptionId!r}" + ) + if selection.status == "needsInput": + needs_input = selection.needsInput or FinalGraphNeedsInput.get_blank() + option_ids = sorted(options) + canonical_question = "Select one eligible final graph option." + canonical_needs_input = needs_input.model_copy( + update={"question": canonical_question, "options": option_ids} + ) + logger.warning( + f"Final parameter graph selection needs input; options={len(option_ids)}" + ) + return report.model_copy( + update={ + "status": "needsInput", + "totalCandidates": ( + sum(len(value.evaluations) for value in assay_reports.values()) + + len(evaluations) + ), + "integrationEvaluations": evaluations, + "markerAssay": marker_assay, + "finalSelection": selection.model_copy( + update={"needsInput": canonical_needs_input} + ), + "needsInput": ParameterTuningNeedsInput( + question=canonical_question, + options=option_ids, + evidenceIds=needs_input.evidenceIds, + ), + } + ) + return finalize_parameter_tuning_selection( + report, + marker_assay=marker_assay, + integration_evaluations=evaluations, + recommended_integration_id=selection.integrationId, + native_assay=selection.nativeAssay, + final_selection=selection, + ) + + +def promote_parameter_candidate( + store: Any, + *, + report: ParameterTuningReport, + normalized: Any, + identity_feature_limit: int = 64, +) -> ParameterCandidateEvaluation: + """Resolve and verify the exact selected native branch without replaying it.""" + + if report.status != "done" or report.recommendedCandidateId is None: + raise ValueError("A completed native tuning recommendation is required") + evaluation = next( + ( + item + for item in report.evaluations + if item.candidateId == report.recommendedCandidateId + ), + None, + ) + if evaluation is None or evaluation.status != "done" or not evaluation.eligible: + raise ValueError("Recommended candidate is not an eligible execution") + if "clusters" not in evaluation.artifacts: + raise ValueError("Recommended candidate lacks an exact cluster artifact") + normalized_ref = core_artifact_reference(normalized) + if ( + not isinstance(normalized_ref, ArtifactRef) + or normalized_ref.kind != "normalized" + or normalized_ref.assay != report.fromAssay + ): + raise ValueError( + "normalized must identify the report's exact normalized assay artifact" + ) + status = store.inspect_artifact(normalized_ref) + if not getattr(status, "exists", True) or not getattr(status, "complete", False): + raise ValueError("normalized artifact is unavailable or incomplete") + raw_selection = (getattr(status, "inputs", None) or {}).get("cell_selection") + if not isinstance(raw_selection, Mapping): + raise ValueError("normalized artifact has no cell-selection input") + normalized_selection = ArtifactRef.from_dict(dict(raw_selection)) + if normalized_selection != core_artifact_reference(evaluation.cellSelection): + raise ValueError( + "Recommended candidate does not match normalized artifact lineage" + ) + if normalized_selection != core_artifact_reference(report.cellSelection): + raise ValueError( + "Parameter tuning report does not match normalized artifact lineage" + ) + if identity_feature_limit < 2: + raise ValueError("identity_feature_limit must be at least two") + logger.info( + f"Resolved parameter candidate {evaluation.candidateId!r} for assay " + f"{report.fromAssay!r} without replay" + ) + return evaluation diff --git a/scarf/agent/sequential_tuning.py b/scarf/agent/parameter_tuning/sequential.py similarity index 99% rename from scarf/agent/sequential_tuning.py rename to scarf/agent/parameter_tuning/sequential.py index 40e0e112..bbce357e 100644 --- a/scarf/agent/sequential_tuning.py +++ b/scarf/agent/parameter_tuning/sequential.py @@ -13,24 +13,24 @@ from pydantic import ConfigDict, Field, model_validator -from .parameter_tuning import ( +from ..tools import core_artifact_reference +from ..types import AgentDataModel, ExperimentalTuningHandoff +from .agent import execute_parameter_search_plan, prepare_parameter_tuning_dependencies +from .contracts import ( ParameterCandidate, ParameterCandidateEvaluation, ParameterSearchPlan, ParameterTuningDependencies, ParameterTuningNeedsInput, ParameterTuningReport, +) +from .execution import execute_parameter_candidate +from .selection import ( annotate_candidate_dominance, - execute_parameter_candidate, - execute_parameter_search_plan, finalize_parameter_tuning_selection, - prepare_parameter_tuning_dependencies, require_dominated_candidate_evidence, validate_parameter_search_plan, ) -from .tools import core_artifact_reference -from .types import AgentDataModel, ExperimentalTuningHandoff - type ParameterPhase = Literal[ "pcaPrefix", diff --git a/scarf/agent/persistence/__init__.py b/scarf/agent/persistence/__init__.py new file mode 100644 index 00000000..cfcac4dd --- /dev/null +++ b/scarf/agent/persistence/__init__.py @@ -0,0 +1,47 @@ +"""Immutable agent workflow, report, and decision persistence.""" + +from .contracts import ( + AgentInvocation, + AgentName, + AgentPersistenceTarget, + AgentReportLink, + AgentReportRecord, + AgentReportReference, + AgentReportType, + AgentTerminalStatus, + AgentWorkflowRun, + AgentWorkflowStatus, +) +from .reports import ( + AgentReport, + create_agent_workflow, + finalize_agent_workflow, + list_agent_reports, + list_agent_workflows, + load_agent_record, + load_agent_report, + load_agent_workflow, + save_agent_report, +) + +__all__ = [ + "AgentInvocation", + "AgentName", + "AgentPersistenceTarget", + "AgentReport", + "AgentReportLink", + "AgentReportRecord", + "AgentReportReference", + "AgentReportType", + "AgentTerminalStatus", + "AgentWorkflowRun", + "AgentWorkflowStatus", + "create_agent_workflow", + "finalize_agent_workflow", + "list_agent_reports", + "list_agent_workflows", + "load_agent_record", + "load_agent_report", + "load_agent_workflow", + "save_agent_report", +] diff --git a/scarf/agent/persistence/contracts.py b/scarf/agent/persistence/contracts.py new file mode 100644 index 00000000..2f71e93c --- /dev/null +++ b/scarf/agent/persistence/contracts.py @@ -0,0 +1,415 @@ +"""Serializable contracts for immutable Scarf agent records.""" + +import re +from pathlib import Path +from typing import Any, Literal + +import zarr +from pydantic import Field, field_validator, model_validator + +from ...datastore.datastore import DataStore +from ...storage.schema import validate_workspace_name +from ..config import AgentRunConfig +from ..types import ( + AgentDataModel, + ArtifactReferenceModel, + ExperimentalBiologyHandoff, + ExperimentalTuningHandoff, + TuningBiologyHandoff, +) + +type AgentName = Literal[ + "data_enrichment", + "experimental_context", + "parameter_tuning", + "biological_interpretation", +] +type AgentReportType = Literal[ + "", + "DataEnrichmentReport", + "ExperimentalContextResult", + "ParameterTuningReport", + "BiologicalInterpretationReport", +] +type AgentPersistenceTarget = str | Path | zarr.Group | DataStore +type AgentWorkflowStatus = Literal[ + "running", + "completed", + "abstained", + "failed", + "abandoned", +] +type AgentTerminalStatus = Literal["completed", "abstained", "failed", "abandoned"] + +_FORMAT = "scarf_agent_reports" +_RUN_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,127}$") +_SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") + + +class AgentReportLink(AgentDataModel): + """Immutable identity of one report used as an invocation parent.""" + + type: Literal["agentReportLink"] = "agentReportLink" + workflowRunId: str = "" + workspace: str | None = None + agentName: AgentName = "data_enrichment" + agentRunId: str = "" + contentSha256: str = "" + + @field_validator("workflowRunId", "agentRunId") + @classmethod + def validate_run_ids(cls, value: str) -> str: + if value: + _validate_run_id(value, "run ID") + return value + + @field_validator("workspace") + @classmethod + def validate_workspace(cls, value: str | None) -> str | None: + validate_workspace_name(value) + return value + + @field_validator("contentSha256") + @classmethod + def validate_content_sha256(cls, value: str) -> str: + if value and _SHA256_PATTERN.fullmatch(value) is None: + raise ValueError("contentSha256 must be a lowercase SHA-256 digest") + return value + + @model_validator(mode="after") + def validate_complete_identity(self) -> "AgentReportLink": + if self.workflowRunId or self.agentRunId or self.contentSha256: + if not self.workflowRunId or not self.agentRunId or not self.contentSha256: + raise ValueError("A parent report link requires a complete identity") + return self + + @classmethod + def from_reference(cls, reference: "AgentReportReference") -> "AgentReportLink": + return cls( + workflowRunId=reference.workflowRunId, + workspace=reference.workspace, + agentName=reference.agentName, + agentRunId=reference.agentRunId, + contentSha256=reference.contentSha256, + ) + + @classmethod + def get_blank(cls) -> "AgentReportLink": + return cls() + + @classmethod + def get_example(cls) -> "AgentReportLink": + return cls( + workflowRunId="workflow-1", + agentName="experimental_context", + agentRunId="experimental-run-1", + contentSha256="0" * 64, + ) + + +class AgentInvocation(AgentDataModel): + """Replay-relevant inputs and typed handoffs for one agent invocation.""" + + agentName: AgentName = "data_enrichment" + parentReports: list[AgentReportLink] = Field(default_factory=list) + inputs: dict[str, Any] = Field(default_factory=dict) + artifacts: dict[str, ArtifactReferenceModel] = Field(default_factory=dict) + runConfig: AgentRunConfig = Field(default_factory=AgentRunConfig) + experimentalTuningHandoff: ExperimentalTuningHandoff | None = None + experimentalBiologyHandoff: ExperimentalBiologyHandoff | None = None + tuningBiologyHandoff: TuningBiologyHandoff | None = None + + @model_validator(mode="after") + def validate_parent_reports(self) -> "AgentInvocation": + identities = [ + (parent.workflowRunId, parent.agentName, parent.agentRunId) + for parent in self.parentReports + ] + if len(identities) != len(set(identities)): + raise ValueError("parentReports must not contain duplicate reports") + return self + + @classmethod + def get_blank(cls) -> "AgentInvocation": + return cls() + + @classmethod + def get_example(cls) -> "AgentInvocation": + cell_selection = ArtifactReferenceModel( + scope="datastore", + kind="cell_selection", + artifactId="c" * 64, + ) + return cls( + agentName="parameter_tuning", + parentReports=[AgentReportLink.get_example()], + inputs={ + "fromAssay": "RNA", + "cellSelection": cell_selection.model_dump(mode="json"), + }, + artifacts={"cellSelection": cell_selection}, + runConfig=AgentRunConfig.get_example(), + experimentalTuningHandoff=ExperimentalTuningHandoff( + cellSelection=cell_selection, + batchAction="skip", + ), + ) + + +class AgentReportReference(AgentDataModel): + """Stable identity for one immutable agent report.""" + + type: Literal["agentReport"] = "agentReport" + workflowRunId: str = "" + workspace: str | None = None + agentName: AgentName = "data_enrichment" + agentRunId: str = "" + reportType: AgentReportType = "" + executionRunId: str = "" + createdAtNs: int = Field(default=0, ge=0, strict=True) + complete: bool = Field(default=False, strict=True) + parentReports: list[AgentReportLink] = Field(default_factory=list) + contentSha256: str = "" + + @field_validator("workflowRunId", "agentRunId") + @classmethod + def validate_run_ids(cls, value: str) -> str: + if value: + _validate_run_id(value, "run ID") + return value + + @field_validator("workspace") + @classmethod + def validate_workspace(cls, value: str | None) -> str | None: + validate_workspace_name(value) + return value + + @field_validator("contentSha256") + @classmethod + def validate_content_sha256(cls, value: str) -> str: + if value and _SHA256_PATTERN.fullmatch(value) is None: + raise ValueError("contentSha256 must be a lowercase SHA-256 digest") + return value + + @model_validator(mode="after") + def validate_complete_identity(self) -> "AgentReportReference": + has_identity = bool( + self.workflowRunId + or self.agentRunId + or self.reportType + or self.createdAtNs + or self.complete + or self.contentSha256 + ) + if has_identity and ( + not self.workflowRunId + or not self.agentRunId + or not self.reportType + or self.createdAtNs < 1 + or not self.complete + or not self.contentSha256 + ): + raise ValueError("An agent report reference requires a complete identity") + return self + + @classmethod + def get_blank(cls) -> "AgentReportReference": + return cls() + + @classmethod + def get_example(cls) -> "AgentReportReference": + return cls( + workflowRunId="workflow-1", + agentName="data_enrichment", + agentRunId="agent-run-1", + reportType="DataEnrichmentReport", + executionRunId="provider-run-1", + createdAtNs=1, + complete=True, + contentSha256="0" * 64, + ) + + +class AgentReportRecord(AgentDataModel): + """Complete JSON envelope for one immutable report and its invocation.""" + + recordType: Literal["agentReport"] = "agentReport" + formatVersion: Literal[2] = 2 + reference: AgentReportReference = Field(default_factory=AgentReportReference) + invocation: AgentInvocation = Field(default_factory=AgentInvocation) + report: dict[str, Any] = Field(default_factory=dict) + + @model_validator(mode="after") + def validate_identity(self) -> "AgentReportRecord": + if self.reference.agentName != self.invocation.agentName: + raise ValueError("Report reference and invocation agent names differ") + if self.reference.parentReports != self.invocation.parentReports: + raise ValueError("Report reference and invocation parents differ") + if any( + parent.workflowRunId == self.reference.workflowRunId + and parent.agentName == self.reference.agentName + and parent.agentRunId == self.reference.agentRunId + for parent in self.invocation.parentReports + ): + raise ValueError("An agent report cannot cite itself as a parent") + return self + + @classmethod + def get_blank(cls) -> "AgentReportRecord": + return cls() + + @classmethod + def get_example(cls) -> "AgentReportRecord": + from ..data_enrichment.contracts import DataEnrichmentReport + + report = DataEnrichmentReport.get_example() + return cls( + reference=AgentReportReference.get_example(), + invocation=AgentInvocation(agentName="data_enrichment"), + report=report.model_dump(mode="json"), + ) + + +class AgentWorkflowRun(AgentDataModel): + """One dataset-bound workflow and its immutable report records.""" + + type: Literal["agentWorkflowRun"] = "agentWorkflowRun" + formatVersion: Literal[2] = 2 + workflowRunId: str = "" + workspace: str | None = None + createdAtNs: int = Field(default=0, ge=0, strict=True) + finalizedAtNs: int = Field(default=0, ge=0, strict=True) + status: AgentWorkflowStatus = "running" + finalizationMessage: str = "" + analysisStore: str = "" + datasetFingerprints: dict[str, str] = Field(default_factory=dict) + reports: list[AgentReportReference] = Field(default_factory=list) + + @field_validator("workflowRunId") + @classmethod + def validate_workflow_run_id(cls, value: str) -> str: + if value: + _validate_run_id(value, "workflowRunId") + return value + + @field_validator("workspace") + @classmethod + def validate_workspace(cls, value: str | None) -> str | None: + validate_workspace_name(value) + return value + + @field_validator("datasetFingerprints") + @classmethod + def validate_dataset_fingerprints(cls, value: dict[str, str]) -> dict[str, str]: + if any(not assay or not fingerprint for assay, fingerprint in value.items()): + raise ValueError("Dataset fingerprint names and values must be non-empty") + return dict(sorted(value.items())) + + @model_validator(mode="after") + def validate_lifecycle(self) -> "AgentWorkflowRun": + if self.workflowRunId and self.createdAtNs < 1: + raise ValueError("A workflow requires a positive createdAtNs") + if self.workflowRunId and not self.datasetFingerprints: + raise ValueError("A workflow requires exact dataset fingerprints") + if self.status == "running" and self.finalizedAtNs != 0: + raise ValueError("A running workflow cannot have finalizedAtNs") + if self.status == "running" and self.finalizationMessage: + raise ValueError("A running workflow cannot have a finalizationMessage") + if self.status != "running" and self.finalizedAtNs < 1: + raise ValueError("A terminal workflow requires finalizedAtNs") + if ( + self.status != "running" + and self.createdAtNs + and self.finalizedAtNs < self.createdAtNs + ): + raise ValueError("finalizedAtNs cannot precede createdAtNs") + return self + + @classmethod + def get_blank(cls) -> "AgentWorkflowRun": + return cls() + + @classmethod + def get_example(cls) -> "AgentWorkflowRun": + return cls( + workflowRunId="workflow-1", + createdAtNs=1, + analysisStore="analysis.zarr", + datasetFingerprints={"RNA": "dataset-1"}, + reports=[AgentReportReference.get_example()], + ) + + +class AgentStoreManifest(AgentDataModel): + """Identity document for one workspace-local agent JSON store.""" + + type: Literal["agentReportStore"] = "agentReportStore" + format: Literal["scarf_agent_reports"] = "scarf_agent_reports" + formatVersion: Literal[2] = 2 + workspace: str | None = None + + @field_validator("workspace") + @classmethod + def validate_workspace(cls, value: str | None) -> str | None: + validate_workspace_name(value) + return value + + @classmethod + def get_blank(cls) -> "AgentStoreManifest": + return cls() + + @classmethod + def get_example(cls) -> "AgentStoreManifest": + return cls(workspace="analysis") + + +class AgentWorkflowFinalization(AgentDataModel): + """Immutable terminal event for a workflow.""" + + recordType: Literal["agentWorkflowFinalization"] = "agentWorkflowFinalization" + formatVersion: Literal[2] = 2 + workflowRunId: str = "" + workspace: str | None = None + status: AgentTerminalStatus = "completed" + finalizedAtNs: int = Field(default=0, ge=0, strict=True) + message: str = "" + + @field_validator("workflowRunId") + @classmethod + def validate_workflow_run_id(cls, value: str) -> str: + if value: + _validate_run_id(value, "workflowRunId") + return value + + @field_validator("workspace") + @classmethod + def validate_workspace(cls, value: str | None) -> str | None: + validate_workspace_name(value) + return value + + @model_validator(mode="after") + def validate_finalization(self) -> "AgentWorkflowFinalization": + if self.workflowRunId and self.finalizedAtNs < 1: + raise ValueError("A finalization requires a positive finalizedAtNs") + return self + + @classmethod + def get_blank(cls) -> "AgentWorkflowFinalization": + return cls() + + @classmethod + def get_example(cls) -> "AgentWorkflowFinalization": + return cls( + workflowRunId="workflow-1", + status="completed", + finalizedAtNs=2, + ) + + +def _validate_run_id(value: str, label: str) -> str: + if _RUN_ID_PATTERN.fullmatch(value) is None: + raise ValueError( + f"{label} must be one safe path component containing 1-128 ASCII " + "lowercase letters, numbers, underscores, or hyphens" + ) + return value diff --git a/scarf/agent/decision_persistence.py b/scarf/agent/persistence/decisions.py similarity index 99% rename from scarf/agent/decision_persistence.py rename to scarf/agent/persistence/decisions.py index 2d5a7c41..eeabaa6e 100644 --- a/scarf/agent/decision_persistence.py +++ b/scarf/agent/persistence/decisions.py @@ -11,25 +11,26 @@ from zarr.core.buffer import default_buffer_prototype from zarr.core.sync import sync -from . import record_io -from .decision_kernel import ( +from .. import record_io +from ..decisions.kernel import ( DecisionRecord, DecisionWorkflowRun, PendingDecision, RevisionRequest, ) -from .orchestrator.models import ( +from ..decisions.rna import ( + RNA_DECISION_TRANSITION_GRAPH, + CompiledRnaDecision, + RnaDecisionCheckpoint, +) +from ..orchestrator.models import ( _ORCHESTRATION_FORMAT, _ORCHESTRATION_VERSION, OrchestrationRequestRecord, ) -from .persistence import AgentPersistenceTarget, _resolve_target -from .rna_decisions import ( - CompiledRnaDecision, - RNA_DECISION_TRANSITION_GRAPH, - RnaDecisionCheckpoint, -) -from .types import AgentDataModel +from ..types import AgentDataModel +from .contracts import AgentPersistenceTarget +from .reports import _resolve_target _RUN_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,127}$") _SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") diff --git a/scarf/agent/persistence.py b/scarf/agent/persistence/reports.py similarity index 71% rename from scarf/agent/persistence.py rename to scarf/agent/persistence/reports.py index 4df391f0..d30196a7 100644 --- a/scarf/agent/persistence.py +++ b/scarf/agent/persistence/reports.py @@ -1,76 +1,56 @@ -"""Immutable JSON persistence for structured Scarf agent workflows. - -Agent records are stored as plain JSON keys beneath an ``agents`` Zarr group in -the active Scarf data group. The namespace group contains metadata only; report -models are not stored as Zarr arrays or encoded chunks. Format version 2 is a -single-writer format with no implicit migration from earlier layouts. -""" +"""Immutable JSON storage for structured Scarf agent reports and workflows.""" import hashlib import json -import re import time import uuid from collections.abc import Mapping from pathlib import Path -from typing import Any, Literal, cast +from typing import cast import zarr -from pydantic import Field, field_validator, model_validator from zarr.core.buffer import default_buffer_prototype from zarr.core.sync import sync -from ..datastore.datastore import DataStore -from ..storage.artifacts import inspect_artifact -from ..storage.refs import ArtifactRef -from ..storage.schema import validate_workspace_name -from ..utils.logging import logger -from . import record_io -from .biological_interpretation import BiologicalInterpretationReport -from .config import AgentRunConfig -from .data_enrichment import DataEnrichmentReport -from .experimental_context import ExperimentalContextResult -from .parameter_tuning import ParameterTuningReport -from .types import ( +from ...datastore.datastore import DataStore +from ...storage.artifacts import inspect_artifact +from ...storage.refs import ArtifactRef +from ...storage.schema import validate_workspace_name +from ...utils.logging import logger +from .. import record_io +from ..biological_interpretation.contracts import BiologicalInterpretationReport +from ..data_enrichment.contracts import DataEnrichmentReport +from ..experimental_context.contracts import ExperimentalContextResult +from ..parameter_tuning.contracts import ParameterTuningReport +from ..types import ( AgentDataModel, ArtifactReferenceModel, ExperimentalBiologyHandoff, ExperimentalTuningHandoff, - TuningBiologyHandoff, +) +from .contracts import ( + _FORMAT, + AgentInvocation, + AgentName, + AgentPersistenceTarget, + AgentReportLink, + AgentReportRecord, + AgentReportReference, + AgentReportType, + AgentStoreManifest, + AgentTerminalStatus, + AgentWorkflowFinalization, + AgentWorkflowRun, + _validate_run_id, ) -type AgentName = Literal[ - "data_enrichment", - "experimental_context", - "parameter_tuning", - "biological_interpretation", -] -type AgentReportType = Literal[ - "", - "DataEnrichmentReport", - "ExperimentalContextResult", - "ParameterTuningReport", - "BiologicalInterpretationReport", -] type AgentReport = ( DataEnrichmentReport | ExperimentalContextResult | ParameterTuningReport | BiologicalInterpretationReport ) -type AgentPersistenceTarget = str | Path | zarr.Group | DataStore -type AgentWorkflowStatus = Literal[ - "running", - "completed", - "abstained", - "failed", - "abandoned", -] -type AgentTerminalStatus = Literal["completed", "abstained", "failed", "abandoned"] - -_FORMAT = "scarf_agent_reports" -_RUN_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,127}$") -_SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") + _REPORT_TYPES: dict[AgentName, type[AgentDataModel]] = { "data_enrichment": DataEnrichmentReport, "experimental_context": ExperimentalContextResult, @@ -82,373 +62,6 @@ } -class AgentReportLink(AgentDataModel): - """Immutable identity of one report used as an invocation parent.""" - - type: Literal["agentReportLink"] = "agentReportLink" - workflowRunId: str = "" - workspace: str | None = None - agentName: AgentName = "data_enrichment" - agentRunId: str = "" - contentSha256: str = "" - - @field_validator("workflowRunId", "agentRunId") - @classmethod - def validate_run_ids(cls, value: str) -> str: - if value: - _validate_run_id(value, "run ID") - return value - - @field_validator("workspace") - @classmethod - def validate_workspace(cls, value: str | None) -> str | None: - validate_workspace_name(value) - return value - - @field_validator("contentSha256") - @classmethod - def validate_content_sha256(cls, value: str) -> str: - if value and _SHA256_PATTERN.fullmatch(value) is None: - raise ValueError("contentSha256 must be a lowercase SHA-256 digest") - return value - - @model_validator(mode="after") - def validate_complete_identity(self) -> "AgentReportLink": - if self.workflowRunId or self.agentRunId or self.contentSha256: - if not self.workflowRunId or not self.agentRunId or not self.contentSha256: - raise ValueError("A parent report link requires a complete identity") - return self - - @classmethod - def from_reference(cls, reference: "AgentReportReference") -> "AgentReportLink": - return cls( - workflowRunId=reference.workflowRunId, - workspace=reference.workspace, - agentName=reference.agentName, - agentRunId=reference.agentRunId, - contentSha256=reference.contentSha256, - ) - - @classmethod - def get_blank(cls) -> "AgentReportLink": - return cls() - - @classmethod - def get_example(cls) -> "AgentReportLink": - return cls( - workflowRunId="workflow-1", - agentName="experimental_context", - agentRunId="experimental-run-1", - contentSha256="0" * 64, - ) - - -class AgentInvocation(AgentDataModel): - """Replay-relevant inputs and typed handoffs for one agent invocation.""" - - agentName: AgentName = "data_enrichment" - parentReports: list[AgentReportLink] = Field(default_factory=list) - inputs: dict[str, Any] = Field(default_factory=dict) - artifacts: dict[str, ArtifactReferenceModel] = Field(default_factory=dict) - runConfig: AgentRunConfig = Field(default_factory=AgentRunConfig) - experimentalTuningHandoff: ExperimentalTuningHandoff | None = None - experimentalBiologyHandoff: ExperimentalBiologyHandoff | None = None - tuningBiologyHandoff: TuningBiologyHandoff | None = None - - @model_validator(mode="after") - def validate_parent_reports(self) -> "AgentInvocation": - identities = [ - (parent.workflowRunId, parent.agentName, parent.agentRunId) - for parent in self.parentReports - ] - if len(identities) != len(set(identities)): - raise ValueError("parentReports must not contain duplicate reports") - return self - - @classmethod - def get_blank(cls) -> "AgentInvocation": - return cls() - - @classmethod - def get_example(cls) -> "AgentInvocation": - cell_selection = ArtifactReferenceModel( - scope="datastore", - kind="cell_selection", - artifactId="c" * 64, - ) - return cls( - agentName="parameter_tuning", - parentReports=[AgentReportLink.get_example()], - inputs={ - "fromAssay": "RNA", - "cellSelection": cell_selection.model_dump(mode="json"), - }, - artifacts={"cellSelection": cell_selection}, - runConfig=AgentRunConfig.get_example(), - experimentalTuningHandoff=ExperimentalTuningHandoff( - cellSelection=cell_selection, - batchAction="skip", - ), - ) - - -class AgentReportReference(AgentDataModel): - """Stable identity for one immutable agent report.""" - - type: Literal["agentReport"] = "agentReport" - workflowRunId: str = "" - workspace: str | None = None - agentName: AgentName = "data_enrichment" - agentRunId: str = "" - reportType: AgentReportType = "" - executionRunId: str = "" - createdAtNs: int = Field(default=0, ge=0, strict=True) - complete: bool = Field(default=False, strict=True) - parentReports: list[AgentReportLink] = Field(default_factory=list) - contentSha256: str = "" - - @field_validator("workflowRunId", "agentRunId") - @classmethod - def validate_run_ids(cls, value: str) -> str: - if value: - _validate_run_id(value, "run ID") - return value - - @field_validator("workspace") - @classmethod - def validate_workspace(cls, value: str | None) -> str | None: - validate_workspace_name(value) - return value - - @field_validator("contentSha256") - @classmethod - def validate_content_sha256(cls, value: str) -> str: - if value and _SHA256_PATTERN.fullmatch(value) is None: - raise ValueError("contentSha256 must be a lowercase SHA-256 digest") - return value - - @model_validator(mode="after") - def validate_complete_identity(self) -> "AgentReportReference": - has_identity = bool( - self.workflowRunId - or self.agentRunId - or self.reportType - or self.createdAtNs - or self.complete - or self.contentSha256 - ) - if has_identity and ( - not self.workflowRunId - or not self.agentRunId - or not self.reportType - or self.createdAtNs < 1 - or not self.complete - or not self.contentSha256 - ): - raise ValueError("An agent report reference requires a complete identity") - return self - - @classmethod - def get_blank(cls) -> "AgentReportReference": - return cls() - - @classmethod - def get_example(cls) -> "AgentReportReference": - return cls( - workflowRunId="workflow-1", - agentName="data_enrichment", - agentRunId="agent-run-1", - reportType="DataEnrichmentReport", - executionRunId="provider-run-1", - createdAtNs=1, - complete=True, - contentSha256="0" * 64, - ) - - -class AgentReportRecord(AgentDataModel): - """Complete JSON envelope for one immutable report and its invocation.""" - - recordType: Literal["agentReport"] = "agentReport" - formatVersion: Literal[2] = 2 - reference: AgentReportReference = Field(default_factory=AgentReportReference) - invocation: AgentInvocation = Field(default_factory=AgentInvocation) - report: dict[str, Any] = Field(default_factory=dict) - - @model_validator(mode="after") - def validate_identity(self) -> "AgentReportRecord": - if self.reference.agentName != self.invocation.agentName: - raise ValueError("Report reference and invocation agent names differ") - if self.reference.parentReports != self.invocation.parentReports: - raise ValueError("Report reference and invocation parents differ") - if any( - parent.workflowRunId == self.reference.workflowRunId - and parent.agentName == self.reference.agentName - and parent.agentRunId == self.reference.agentRunId - for parent in self.invocation.parentReports - ): - raise ValueError("An agent report cannot cite itself as a parent") - return self - - @classmethod - def get_blank(cls) -> "AgentReportRecord": - return cls() - - @classmethod - def get_example(cls) -> "AgentReportRecord": - report = DataEnrichmentReport.get_example() - return cls( - reference=AgentReportReference.get_example(), - invocation=AgentInvocation(agentName="data_enrichment"), - report=report.model_dump(mode="json"), - ) - - -class AgentWorkflowRun(AgentDataModel): - """One dataset-bound workflow and its immutable report records.""" - - type: Literal["agentWorkflowRun"] = "agentWorkflowRun" - formatVersion: Literal[2] = 2 - workflowRunId: str = "" - workspace: str | None = None - createdAtNs: int = Field(default=0, ge=0, strict=True) - finalizedAtNs: int = Field(default=0, ge=0, strict=True) - status: AgentWorkflowStatus = "running" - finalizationMessage: str = "" - analysisStore: str = "" - datasetFingerprints: dict[str, str] = Field(default_factory=dict) - reports: list[AgentReportReference] = Field(default_factory=list) - - @field_validator("workflowRunId") - @classmethod - def validate_workflow_run_id(cls, value: str) -> str: - if value: - _validate_run_id(value, "workflowRunId") - return value - - @field_validator("workspace") - @classmethod - def validate_workspace(cls, value: str | None) -> str | None: - validate_workspace_name(value) - return value - - @field_validator("datasetFingerprints") - @classmethod - def validate_dataset_fingerprints(cls, value: dict[str, str]) -> dict[str, str]: - if any(not assay or not fingerprint for assay, fingerprint in value.items()): - raise ValueError("Dataset fingerprint names and values must be non-empty") - return dict(sorted(value.items())) - - @model_validator(mode="after") - def validate_lifecycle(self) -> "AgentWorkflowRun": - if self.workflowRunId and self.createdAtNs < 1: - raise ValueError("A workflow requires a positive createdAtNs") - if self.workflowRunId and not self.datasetFingerprints: - raise ValueError("A workflow requires exact dataset fingerprints") - if self.status == "running" and self.finalizedAtNs != 0: - raise ValueError("A running workflow cannot have finalizedAtNs") - if self.status == "running" and self.finalizationMessage: - raise ValueError("A running workflow cannot have a finalizationMessage") - if self.status != "running" and self.finalizedAtNs < 1: - raise ValueError("A terminal workflow requires finalizedAtNs") - if ( - self.status != "running" - and self.createdAtNs - and self.finalizedAtNs < self.createdAtNs - ): - raise ValueError("finalizedAtNs cannot precede createdAtNs") - return self - - @classmethod - def get_blank(cls) -> "AgentWorkflowRun": - return cls() - - @classmethod - def get_example(cls) -> "AgentWorkflowRun": - return cls( - workflowRunId="workflow-1", - createdAtNs=1, - analysisStore="analysis.zarr", - datasetFingerprints={"RNA": "dataset-1"}, - reports=[AgentReportReference.get_example()], - ) - - -class AgentStoreManifest(AgentDataModel): - """Identity document for one workspace-local agent JSON store.""" - - type: Literal["agentReportStore"] = "agentReportStore" - format: Literal["scarf_agent_reports"] = "scarf_agent_reports" - formatVersion: Literal[2] = 2 - workspace: str | None = None - - @field_validator("workspace") - @classmethod - def validate_workspace(cls, value: str | None) -> str | None: - validate_workspace_name(value) - return value - - @classmethod - def get_blank(cls) -> "AgentStoreManifest": - return cls() - - @classmethod - def get_example(cls) -> "AgentStoreManifest": - return cls(workspace="analysis") - - -class AgentWorkflowFinalization(AgentDataModel): - """Immutable terminal event for a workflow.""" - - recordType: Literal["agentWorkflowFinalization"] = "agentWorkflowFinalization" - formatVersion: Literal[2] = 2 - workflowRunId: str = "" - workspace: str | None = None - status: AgentTerminalStatus = "completed" - finalizedAtNs: int = Field(default=0, ge=0, strict=True) - message: str = "" - - @field_validator("workflowRunId") - @classmethod - def validate_workflow_run_id(cls, value: str) -> str: - if value: - _validate_run_id(value, "workflowRunId") - return value - - @field_validator("workspace") - @classmethod - def validate_workspace(cls, value: str | None) -> str | None: - validate_workspace_name(value) - return value - - @model_validator(mode="after") - def validate_finalization(self) -> "AgentWorkflowFinalization": - if self.workflowRunId and self.finalizedAtNs < 1: - raise ValueError("A finalization requires a positive finalizedAtNs") - return self - - @classmethod - def get_blank(cls) -> "AgentWorkflowFinalization": - return cls() - - @classmethod - def get_example(cls) -> "AgentWorkflowFinalization": - return cls( - workflowRunId="workflow-1", - status="completed", - finalizedAtNs=2, - ) - - -def _validate_run_id(value: str, label: str) -> str: - if _RUN_ID_PATTERN.fullmatch(value) is None: - raise ValueError( - f"{label} must be one safe path component containing 1-128 ASCII " - "lowercase letters, numbers, underscores, or hyphens" - ) - return value - - def _key_exists(group: zarr.Group, key: str) -> bool: return record_io.read_key(group, key) is not None @@ -1573,26 +1186,3 @@ def finalize_agent_workflow( f"reports={len(finalized.reports)}" ) return finalized - - -__all__ = [ - "AgentInvocation", - "AgentName", - "AgentPersistenceTarget", - "AgentReport", - "AgentReportLink", - "AgentReportRecord", - "AgentReportReference", - "AgentReportType", - "AgentTerminalStatus", - "AgentWorkflowRun", - "AgentWorkflowStatus", - "create_agent_workflow", - "finalize_agent_workflow", - "list_agent_reports", - "list_agent_workflows", - "load_agent_record", - "load_agent_report", - "load_agent_workflow", - "save_agent_report", -] diff --git a/scarf/agent/report/__init__.py b/scarf/agent/report/__init__.py new file mode 100644 index 00000000..befa0df2 --- /dev/null +++ b/scarf/agent/report/__init__.py @@ -0,0 +1,5 @@ +"""Local HTML reports for completed automated Scarf agent workflows.""" + +from .generator import generate_agent_report + +__all__ = ["generate_agent_report"] diff --git a/scarf/agent/report/artifacts.py b/scarf/agent/report/artifacts.py new file mode 100644 index 00000000..4887b90b --- /dev/null +++ b/scarf/agent/report/artifacts.py @@ -0,0 +1,547 @@ +"""Persisted artifact and workflow-stage collection for agent reports.""" + +import re +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Any, cast + +from ...datastore.datastore import DataStore +from ...storage.stores import zarr_root_path +from .. import record_io +from ..orchestrator import journal +from ..orchestrator.models import ( + _STAGE_ORDER, + AutomatedWorkflowResult, + OrchestrationRequestRecord, + WorkflowStageAttempt, + artifact_model_to_ref, +) +from ..persistence.contracts import AgentWorkflowRun +from ..persistence.decisions import load_latest_decision_workflow_snapshot +from ..persistence.reports import load_agent_report +from ..types import ArtifactReferenceModel +from .contracts import _is_sequence, _mapping, _mappings, _text_values + + +def _local_root(target: str | Path | DataStore) -> Path: + """Resolve a local filesystem root without accepting remote stores.""" + if isinstance(target, DataStore): + location = zarr_root_path(target.z) + if location is None: + raise ValueError("Agent HTML reports require a local filesystem store") + path = Path(location) + elif isinstance(target, Path): + path = target + elif isinstance(target, str) and target.startswith("file://"): + path = Path(target.removeprefix("file://")) + elif isinstance(target, str): + if "://" in target: + raise ValueError("Agent HTML reports require a local filesystem store") + path = Path(target) + else: + raise TypeError("Agent HTML reports require a local filesystem store") + path = path.expanduser().resolve() + if not path.is_dir(): + raise FileNotFoundError(path) + return path + + +def _open_datastore( + target: str | Path | DataStore, + root: Path, + workflow: AgentWorkflowRun, +) -> DataStore: + if isinstance(target, DataStore): + if target.workspace != workflow.workspace: + raise ValueError("Workflow workspace does not match the DataStore") + return target + default_assay = next(iter(workflow.datasetFingerprints)) + return DataStore( + str(root), + default_assay=default_assay, + min_features_per_cell=-1, + mito_pattern="", + ribo_pattern="", + zarr_mode="r", + workspace=workflow.workspace, + ) + + +def _load_request( + store: DataStore, + prefix: str, + workflow_run_id: str, +) -> OrchestrationRequestRecord: + record = cast( + OrchestrationRequestRecord, + journal._read_model( + store.zw, + journal._request_key(prefix, workflow_run_id), + OrchestrationRequestRecord, + ), + ) + if record.workflowRunId != workflow_run_id: + raise ValueError("Stored orchestration request belongs to another workflow") + if record.requestSha256 != journal._sha256_model(record.request): + raise ValueError("Stored orchestration request checksum is invalid") + if record.configSha256 != journal._sha256_model(record.config): + raise ValueError("Stored orchestration configuration checksum is invalid") + if record.contentSha256 != journal._record_checksum(record): + raise ValueError("Stored orchestration request envelope is invalid") + return record + + +def _load_completed_result( + store: DataStore, + workflow: AgentWorkflowRun, +) -> tuple[str, AutomatedWorkflowResult, OrchestrationRequestRecord]: + if workflow.status != "completed": + raise RuntimeError( + "Agent HTML reports can only be generated for completed workflows" + ) + prefix = journal._ensure_orchestration_store(store) + result = journal._load_terminal_result(store, prefix, workflow) + if result is None: + raise FileNotFoundError( + f"Completed workflow {workflow.workflowRunId!r} has no terminal result" + ) + if result.status != "completed" or result.finalAnalysis is None: + raise ValueError("Completed workflow result lacks its final analysis handoff") + request = _load_request(store, prefix, workflow.workflowRunId) + if request.request.workspace != workflow.workspace: + raise ValueError("Stored request workspace does not match the workflow") + return prefix, result, request + + +def _collect_reports( + store: DataStore, + result: AutomatedWorkflowResult, +) -> dict[str, list[dict[str, Any]]]: + reports: dict[str, list[dict[str, Any]]] = {} + for reference in result.reportReferences: + report = load_agent_report(store, reference) + reports.setdefault(reference.agentName, []).append( + report.model_dump(mode="json") + ) + return reports + + +def _collect_active_decisions( + store: DataStore, + workflow_run_id: str, +) -> dict[str, dict[str, Any]]: + try: + snapshot = load_latest_decision_workflow_snapshot(store, workflow_run_id) + except KeyError: + return {} + decisions: dict[str, dict[str, Any]] = {} + for record in snapshot.workflow.active_decision_records(): + if record.decisionId in decisions: + raise ValueError( + f"Decision workflow has multiple active {record.decisionId!r} records" + ) + decisions[record.decisionId] = record.model_dump(mode="json") + return decisions + + +def _stage_summary(attempt: WorkflowStageAttempt) -> dict[str, Any]: + duration = ( + (attempt.completedAtNs - attempt.startedAtNs) / 1_000_000_000 + if attempt.completedAtNs + else None + ) + error_type = None + if attempt.error: + candidate = attempt.error.partition(":")[0].strip() + error_type = ( + candidate + if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_.]{0,127}", candidate) + else "WorkflowStageError" + ) + return { + "stage": attempt.stage, + "attemptId": attempt.attemptId, + "status": attempt.status, + "durationSeconds": duration, + "actions": list(attempt.actions), + "reportCount": len(attempt.reportReferences), + "artifactCount": len(attempt.artifacts), + "artifacts": { + name: artifact.model_dump(mode="json") + for name, artifact in attempt.artifacts.items() + }, + "parentAttempts": [ + f"{parent.stage}:{parent.attemptId}" for parent in attempt.parentAttempts + ], + "questionIds": ( + [question.questionId for question in attempt.needsInput.questions] + if attempt.needsInput is not None + else [] + ), + "noteCount": len(attempt.notes), + "notes": list(attempt.notes), + "errorType": error_type, + } + + +def _collect_history( + store: DataStore, + prefix: str, + workflow: AgentWorkflowRun, + request: OrchestrationRequestRecord, +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + attempts: dict[tuple[str, str], WorkflowStageAttempt] = {} + summaries: list[tuple[int, dict[str, Any]]] = [] + for stage in _STAGE_ORDER: + starts = { + item.attemptId: item + for item in journal._stage_starts( + store.zw, prefix, workflow.workflowRunId, stage + ) + } + outcomes = { + item.attemptId: item + for item in journal._stage_outcomes( + store.zw, prefix, workflow.workflowRunId, stage + ) + } + if not set(outcomes).issubset(starts): + raise ValueError("Workflow history contains an outcome without a start") + for attempt_id, started in starts.items(): + attempt = outcomes.get(attempt_id, started) + identity = (stage, attempt_id) + if identity in attempts: + raise ValueError("Workflow history contains duplicate stage attempts") + attempts[identity] = attempt + summaries.append((attempt.startedAtNs, _stage_summary(attempt))) + + for attempt in attempts.values(): + for parent in attempt.parentAttempts: + observed = attempts.get((parent.stage, parent.attemptId)) + if ( + observed is None + or observed.status != "done" + or observed.contentSha256 != parent.contentSha256 + ): + raise ValueError("Workflow parent-stage lineage does not resolve") + + terminal_candidates = [ + attempt + for attempt in attempts.values() + if attempt.stage == "analysis_finalization" and attempt.status == "done" + ] + if len(terminal_candidates) != 1: + raise ValueError( + "Completed workflow lacks one exact analysis finalization attempt" + ) + current = terminal_candidates[0] + terminal_chain: set[tuple[str, str]] = set() + while True: + identity = (current.stage, current.attemptId) + if identity in terminal_chain: + raise ValueError("Workflow stage lineage contains a cycle") + terminal_chain.add(identity) + if not journal._stage_outcome_resolves( + store, + prefix, + workflow.workflowRunId, + request, + current, + ): + raise ValueError("Terminal workflow stage artifacts do not resolve") + stage_index = _STAGE_ORDER.index(current.stage) + if stage_index == 0: + if current.parentAttempts: + raise ValueError("The ingest stage cannot have a parent") + break + if len(current.parentAttempts) != 1: + raise ValueError("Every terminal-chain stage must have one parent") + parent = current.parentAttempts[0] + if parent.stage != _STAGE_ORDER[stage_index - 1]: + raise ValueError("Terminal workflow lineage skips a stage") + current = attempts[(parent.stage, parent.attemptId)] + + resumes: list[dict[str, Any]] = [] + resume_prefix = record_io.join_key(prefix, workflow.workflowRunId, "resumes") + for key in record_io.list_keys(store.zw, resume_prefix): + if not key.endswith(".json"): + continue + resume_id = key.rsplit("/", 1)[-1].removesuffix(".json") + resume = journal._validated_resume_record( + store, prefix, workflow.workflowRunId, resume_id + ) + resumes.append( + { + "resumeId": resume.resumeId, + "createdAtNs": resume.createdAtNs, + "answeredStage": ( + resume.answeredAttempt.stage + if resume.answeredAttempt is not None + else None + ), + "answeredAttemptId": ( + resume.answeredAttempt.attemptId + if resume.answeredAttempt is not None + else None + ), + "questionIds": list(resume.questionIds), + } + ) + resumes.sort(key=lambda value: (value["createdAtNs"], value["resumeId"])) + ordered = sorted( + summaries, + key=lambda item: ( + item[0], + str(item[1]["stage"]), + str(item[1]["attemptId"]), + ), + ) + return [value for _, value in ordered], resumes + + +def _hvg_diagnostic_evidence( + store: DataStore, + reference: Mapping[str, Any], +) -> dict[str, Any]: + import numpy as np + + model = ArtifactReferenceModel.model_validate(reference) + group: Any = store.load_artifact(artifact_model_to_ref(model)) + provenance = _mapping(group.attrs.get("provenance")) + parameters = _mapping(provenance.get("parameters")) + ranking = np.asarray(group["ranking"][:], dtype=np.int64) + corrected_variance = np.asarray( + group["global_corrected_variance"][:], + dtype=np.float64, + ) + recurrence = np.asarray(group["recurrence"][:], dtype=np.int64) + eligible = np.asarray(group["eligible"][:], dtype=bool) + if ( + ranking.ndim != 1 + or corrected_variance.ndim != 1 + or recurrence.shape != corrected_variance.shape + or eligible.shape != corrected_variance.shape + ): + raise ValueError("HVG diagnostic arrays are malformed") + if ranking.size and ( + int(ranking.min()) < 0 or int(ranking.max()) >= corrected_variance.size + ): + raise ValueError("HVG diagnostic ranking contains out-of-range indices") + raw_counts = parameters.get("candidate_counts") + if not _is_sequence(raw_counts): + raise ValueError("HVG diagnostic is missing candidate counts") + raw_count_values = cast(Sequence[Any], raw_counts) + candidate_counts = [ + int(value) + for value in raw_count_values + if isinstance(value, int) and not isinstance(value, bool) + ] + if len(candidate_counts) != len(raw_count_values) or any( + value < 1 or value > ranking.size for value in candidate_counts + ): + raise ValueError("HVG diagnostic candidate counts are invalid") + valid_groups = group.attrs.get("valid_groups", []) + if not _is_sequence(valid_groups): + raise ValueError("HVG diagnostic valid groups are malformed") + valid_group_count = len(cast(Sequence[Any], valid_groups)) + excluded_groups = group.attrs.get("excluded_groups", []) + if not _is_sequence(excluded_groups): + raise ValueError("HVG diagnostic excluded groups are malformed") + eligible_variance = float(corrected_variance[eligible].sum()) + recurrence_threshold = max(2, (valid_group_count + 1) // 2) + candidates: list[dict[str, Any]] = [] + for count in candidate_counts: + selected = ranking[:count] + variance_fraction = ( + float(corrected_variance[selected].sum()) / eligible_variance + if eligible_variance > 0 + else 0.0 + ) + candidates.append( + { + "featureCount": count, + "varianceFraction": variance_fraction, + "recurrentFraction": ( + float((recurrence[selected] >= recurrence_threshold).mean()) + if valid_group_count + else None + ), + } + ) + broad = ranking[: max(candidate_counts)] + return { + "rankingMode": group.attrs.get("ranking_mode"), + "eligibleFeatureCount": int(eligible.sum()), + "validTechnicalGroups": valid_group_count, + "excludedTechnicalGroupCount": len(cast(Sequence[Any], excluded_groups)), + "candidateMetrics": candidates, + "meanTechnicalGroupCoverage": ( + float(recurrence[broad].mean()) / valid_group_count + if valid_group_count + else None + ), + "recurrentInTwoGroupsFraction": ( + float((recurrence[broad] >= 2).mean()) if valid_group_count else None + ), + "minimumDetectedCells": parameters.get("min_cells"), + "minimumTechnicalGroupCells": parameters.get("min_group_cells"), + } + + +def _latest_hvg_diagnostic_artifacts( + stage_attempts: Sequence[Mapping[str, Any]], +) -> tuple[str, dict[str, Any], dict[str, Any]]: + for attempt in reversed(stage_attempts): + artifacts = _mapping(attempt.get("artifacts")) + match = next( + ( + (str(name), _mapping(reference)) + for name, reference in artifacts.items() + if re.fullmatch(r".+_hvg_diagnostic", str(name)) + ), + None, + ) + if match is not None: + return match[0], match[1], artifacts + return "", {}, {} + + +def _collect_hvg_evidence( + store: DataStore, + stage_attempts: Sequence[Mapping[str, Any]], + preprocessing_plan: Mapping[str, Any], +) -> dict[str, Any]: + selected_name, selected_reference, selected_artifacts = ( + _latest_hvg_diagnostic_artifacts(stage_attempts) + ) + if not selected_reference: + return {} + assay = selected_name.removesuffix("_hvg_diagnostic") + selected = _hvg_diagnostic_evidence(store, selected_reference) + ranking_references = ( + ("global", selected_artifacts.get(f"{assay}_hvg_global_diagnostic")), + ( + "batchAware", + selected_artifacts.get(f"{assay}_hvg_batchAware_diagnostic"), + ), + ) + rankings: list[dict[str, Any]] = [] + for mode, reference in ranking_references: + if isinstance(reference, Mapping): + summary = _hvg_diagnostic_evidence(store, reference) + if summary.get("rankingMode") != mode: + raise ValueError("HVG diagnostic ranking mode does not match its role") + rankings.append(summary) + if not rankings: + rankings.append(selected) + assay_plan = next( + ( + value + for value in _mappings(preprocessing_plan.get("assays")) + if value.get("assay") == assay + ), + {}, + ) + selected_count = _mapping(assay_plan.get("featureParameters")).get("topN") + default_reference_counts = sorted( + { + int(default_match.group(1)) + for name in selected_artifacts + if ( + default_match := re.fullmatch( + rf"{re.escape(assay)}_hvg_scarf_default_([0-9]+)", + str(name), + ) + ) + } + ) + executed_branch_count = len(default_reference_counts) + sum( + len(_mappings(ranking.get("candidateMetrics"))) for ranking in rankings + ) + return { + "assay": assay, + "selectedRankingMode": selected.get("rankingMode"), + "selectedFeatureCount": selected_count, + "rankings": rankings, + "candidateMetrics": selected.get("candidateMetrics"), + "eligibleFeatureCount": selected.get("eligibleFeatureCount"), + "validTechnicalGroups": selected.get("validTechnicalGroups"), + "excludedTechnicalGroupCount": selected.get("excludedTechnicalGroupCount"), + "minimumDetectedCells": selected.get("minimumDetectedCells"), + "minimumTechnicalGroupCells": selected.get("minimumTechnicalGroupCells"), + "scarfDefaultReferenceCounts": default_reference_counts, + "executedBranchCount": executed_branch_count, + } + + +def _collect_default_feature_inventories( + store: DataStore, + preprocessing_plan: Mapping[str, Any], +) -> list[dict[str, Any]]: + inventories: list[dict[str, Any]] = [] + for assay_plan in _mappings(preprocessing_plan.get("assays")): + assay_name = str(assay_plan.get("assay") or "") + parameters = _mapping(assay_plan.get("featureParameters")) + inventory = _mapping(parameters.get("defaultFeatureInventory")) + if not inventory: + continue + feature_column = str(inventory.get("featureColumn") or "") + blacklist = str(inventory.get("blacklist") or "") + if not assay_name or not feature_column or not blacklist: + raise ValueError("Scarf default feature inventory is incomplete") + assay = store.get_assay(assay_name) + if feature_column not in assay.feats.columns: + raise ValueError( + f"Scarf default feature column {feature_column!r} is unavailable " + f"for assay {assay_name!r}" + ) + names = [str(value) for value in assay.feats.fetch_all(feature_column)] + try: + compiled = re.compile(blacklist.upper()) + except re.error as exc: + raise ValueError("Scarf default feature blacklist is invalid") from exc + matched = sorted( + (name for name in names if compiled.match(name.upper()) is not None), + key=lambda value: (value.casefold(), value), + ) + expected_total = inventory.get("totalFeatures") + expected_matches = inventory.get("matchCount") + if isinstance(expected_total, int) and expected_total != len(names): + raise ValueError( + f"Scarf default feature inventory for {assay_name!r} has stale " + "total feature evidence" + ) + if isinstance(expected_matches, int) and expected_matches != len(matched): + raise ValueError( + f"Scarf default feature inventory for {assay_name!r} has stale " + "blacklist match evidence" + ) + inventories.append( + { + **inventory, + "assay": assay_name, + "appliedToSelectedRepresentation": ( + parameters.get("useScarfDefaultBlacklist") is True + ), + "selectedExcludeFamilies": _text_values( + parameters.get("excludeFamilies") + ), + "selectedProtectFamilies": _text_values( + parameters.get("protectFamilies") + ), + "matchedFeatures": matched, + } + ) + return inventories + + +def _default_inventory_for_assay( + inventories: Sequence[Mapping[str, Any]], + assay: str, +) -> dict[str, Any]: + matches = [dict(value) for value in inventories if value.get("assay") == assay] + if len(matches) > 1: + raise ValueError( + f"Multiple Scarf default inventories found for assay {assay!r}" + ) + return matches[0] if matches else {} diff --git a/scarf/agent/report/contracts.py b/scarf/agent/report/contracts.py new file mode 100644 index 00000000..ce8e81b0 --- /dev/null +++ b/scarf/agent/report/contracts.py @@ -0,0 +1,243 @@ +"""Shared internal value contracts for agent report generation.""" + +import re +from collections.abc import Mapping, Sequence +from typing import Any + + +def _present(value: Any) -> bool: + return value is not None and value != "" and value != [] and value != {} + + +def _label(value: Any) -> str: + text = str(value).replace("_", " ").strip() + words: list[str] = [] + for index, character in enumerate(text): + if ( + index + and character.isupper() + and not text[index - 1].isupper() + and text[index - 1] != " " + ): + words.append(" ") + words.append(character) + text = "".join(words) + return text[:1].upper() + text[1:] + + +def _scalar(value: Any) -> str: + if value is None or value == "": + return "Not provided" + if isinstance(value, bool): + return "Yes" if value else "No" + if isinstance(value, int): + return f"{value:,}" + if isinstance(value, float): + if value == 0: + return "0" + if abs(value) < 0.001 or abs(value) >= 10_000: + return f"{value:.3g}" + return f"{value:.3f}".rstrip("0").rstrip(".") + return str(value) + + +def _mapping(value: Any) -> dict[str, Any]: + return dict(value) if isinstance(value, Mapping) else {} + + +def _mappings(value: Any) -> list[dict[str, Any]]: + if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)): + return [] + return [dict(item) for item in value if isinstance(item, Mapping)] + + +def _is_sequence(value: Any) -> bool: + return isinstance(value, Sequence) and not isinstance( + value, (str, bytes, bytearray) + ) + + +def _is_leaf(value: Any) -> bool: + return not isinstance(value, Mapping) and not _is_sequence(value) + + +def _is_simple(value: Any) -> bool: + if _is_leaf(value): + return True + return _is_sequence(value) and all(_is_leaf(item) for item in value) + + +def _is_mapping_sequence(value: Any) -> bool: + return ( + _is_sequence(value) + and bool(value) + and all(isinstance(item, Mapping) for item in value) + ) + + +def _latest(reports: Mapping[str, Any], agent_name: str) -> dict[str, Any]: + values = reports.get(agent_name) + if isinstance(values, Mapping): + return dict(values) + if isinstance(values, Sequence) and not isinstance(values, (str, bytes, bytearray)): + for value in reversed(values): + if isinstance(value, Mapping): + return dict(value) + return {} + + +def _text_values(value: Any) -> list[str]: + if not _is_sequence(value): + return [] + return [str(item).strip() for item in value if _is_leaf(item) and str(item).strip()] + + +def _specific_references(values: Sequence[str]) -> list[str]: + unique = list(dict.fromkeys(values)) + return [ + value + for value in unique + if not any( + value.casefold() != other.casefold() + and value.casefold() in other.casefold() + for other in unique + ) + ] + + +def _brief_text(value: Any, *, max_length: int = 240) -> str: + if not isinstance(value, str): + return "" + text = " ".join(value.split()) + text = re.sub( + r"\b[0-9a-f]{64}\b", + "recorded result", + text, + flags=re.IGNORECASE, + ) + text = re.sub( + r"\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b", + "recorded value", + text, + flags=re.IGNORECASE, + ) + text = re.sub( + r"\b([A-Za-z][A-Za-z0-9]*)_id\b", + lambda match: _label(match.group(1)).lower(), + text, + ) + if not text: + return "" + first_sentence = re.split(r"(?<=[.!?])\s+", text, maxsplit=1)[0] + if len(first_sentence) <= max_length: + return first_sentence + shortened = first_sentence[: max_length - 3].rsplit(" ", 1)[0] + return f"{shortened or first_sentence[: max_length - 3]}..." + + +def _format_text_list(values: Sequence[str]) -> str: + items = [value for value in dict.fromkeys(values) if value] + if not items: + return "" + if len(items) == 1: + return items[0] + if len(items) == 2: + return f"{items[0]} and {items[1]}" + return f"{', '.join(items[:-1])}, and {items[-1]}" + + +def _assay_label(value: Any) -> str: + labels = { + "RNA": "RNA", + "ATAC": "chromatin accessibility", + "ADT": "protein abundance", + "HTO": "sample tags", + } + text = str(value or "").strip() + return labels.get(text.upper(), _label(text).lower()) if text else "" + + +def _selected_qc_profile( + experimental: Mapping[str, Any], + cell_qc: Mapping[str, Any], +) -> dict[str, Any]: + profiles = _mappings(experimental.get("qcProfiles")) + profile_id = cell_qc.get("profileId") + if profile_id: + for profile in profiles: + if profile.get("profileId") == profile_id: + return profile + return profiles[0] if len(profiles) == 1 else {} + + +def _feature_family_label(value: Any) -> str: + labels = { + "ribosomal": "ribosomal genes", + "ribosomalProtein": "ribosomal protein genes", + "mitochondrial": "mitochondrial genes", + "mitoribosomal": "mitoribosomal genes", + "sex": "sex-linked genes", + "sexLinked": "sex-linked genes", + "cellCycle": "cell-cycle genes", + "cellCycleCcn": "CCN-prefixed genes", + "hla": "HLA genes", + "h2": "H2 genes", + "histone": "histone genes", + } + text = str(value or "").strip() + return labels.get(text, _label(text).lower()) if text else "" + + +def _public_field_label(value: Any) -> str: + labels = { + "T2D": "T2D status", + "donor_id": "donor", + "library_id": "library", + "RNA_nCounts": "RNA counts", + "RNA_nFeatures": "detected genes", + "RNA_percentMito": "mitochondrial percentage", + "RNA_percentRibo": "ribosomal percentage", + "sample_id": "sample", + "sex": "sex", + "tissue": "tissue", + } + text = str(value or "").strip() + if not text: + return "" + if text in labels: + return labels[text] + return _label(text.removesuffix("_id")).lower() + + +def _analysis_percent(value: Any) -> str: + if not isinstance(value, (int, float)) or isinstance(value, bool): + return "Not available" + return f"{float(value):.1%}" + + +def _analysis_number_range(values: Sequence[Any]) -> str: + numbers = [ + float(value) + for value in values + if isinstance(value, (int, float)) and not isinstance(value, bool) + ] + if not numbers: + return "Not available" + low = min(numbers) + high = max(numbers) + + def display(value: float) -> str: + if abs(value) >= 100: + return f"{value:,.0f}" + return f"{value:,.3f}".rstrip("0").rstrip(".") + + if low == high: + return display(low) + return f"{display(low)} to {display(high)}" + + +def _qc_resolved_bounds(profile: Mapping[str, Any]) -> list[dict[str, Any]]: + direct = _mappings(profile.get("resolvedBounds")) + if direct: + return direct + return _mappings(_mapping(profile.get("parameters")).get("resolvedBounds")) diff --git a/scarf/agent/report/decision_tree.py b/scarf/agent/report/decision_tree.py new file mode 100644 index 00000000..e19f8a7f --- /dev/null +++ b/scarf/agent/report/decision_tree.py @@ -0,0 +1,1183 @@ +"""Decision-tree construction and rendering for agent reports.""" + +import html +from collections import Counter +from collections.abc import Mapping, Sequence +from typing import Any + +from .artifacts import _default_inventory_for_assay +from .contracts import ( + _analysis_percent, + _brief_text, + _feature_family_label, + _format_text_list, + _label, + _latest, + _mapping, + _mappings, + _present, + _public_field_label, + _scalar, + _text_values, +) +from .plots import _hvg_ranking_label + + +def _tree_branch( + *, + label: str, + status: str, + state: str, + metrics: Sequence[str], + reason: str, +) -> dict[str, Any]: + return { + "label": label, + "status": status, + "state": state, + "metrics": list(metrics), + "reason": reason, + } + + +def _qc_profile_label(profile: Mapping[str, Any]) -> str: + labels = { + "retainWithFlags": "Retain cells with quality flags", + "globalMad5": "Global quality threshold", + "captureMad5": "Per-library quality threshold", + "captureMad3Sensitivity": "Stricter per-library sensitivity check", + } + registered = str(profile.get("registeredProfile") or "") + if registered in labels: + return labels[registered] + profile_id = str(profile.get("profileId") or "") + for name, label in labels.items(): + if name in profile_id: + return label + action = str(profile.get("action") or "") + return { + "skip": "Retain reviewed cells", + "globalGaussian": "Global quality threshold", + "sampleMad": "Per-sample quality threshold", + "registeredMad": "Registered quality threshold", + }.get(action, "Quality-control option") + + +def _qc_tree_stage( + experimental: Mapping[str, Any], + plan: Mapping[str, Any], + total_cells: int, +) -> dict[str, Any] | None: + decision = _mapping(experimental.get("decision")) + cell_qc = _mapping(plan.get("cellQc")) + if not cell_qc: + cell_qc = _mapping(decision.get("cellQc")) + if not cell_qc: + cell_qc = _mapping(experimental.get("cellQc")) + if not cell_qc: + return None + profiles = _mappings(experimental.get("qcProfiles")) + if not profiles: + profiles = [ + { + **cell_qc, + "activeCells": total_cells or None, + "retainedCells": total_cells or None, + } + ] + selected_id = cell_qc.get("profileId") + selected_name = cell_qc.get("registeredProfile") + branches: list[dict[str, Any]] = [] + for profile in profiles: + selected = bool( + (selected_id and profile.get("profileId") == selected_id) + or ( + not selected_id + and selected_name + and profile.get("registeredProfile") == selected_name + ) + or (len(profiles) == 1) + ) + active = profile.get("activeCells") + retained = profile.get("retainedCells") + metrics: list[str] = [] + removed: int | None = None + if isinstance(active, int) and isinstance(retained, int) and active: + retained_percent = retained / active * 100 + percent_text = "100%" if retained == active else f"{retained_percent:.2f}%" + metrics.append( + f"Retained {retained:,} of {active:,} cells ({percent_text})" + ) + removed = active - retained + if selected: + reason = ( + "Selected because it preserved the reviewed dataset without " + "unsupported filtering." + if removed == 0 + else "Selected as the best-supported balance of cell retention and " + "quality control." + ) + elif removed == 0: + reason = ( + "Not selected because it retained the same cells while adding a " + "filtering rule that was not needed." + ) + elif removed is not None: + reason = ( + f"Not selected because it removed {removed:,} additional cells " + "without stronger support." + ) + else: + reason = "Evaluated but not selected for the final cell set." + branches.append( + _tree_branch( + label=_qc_profile_label(profile), + status="Selected" if selected else "Not selected", + state="selected" if selected else "alternative", + metrics=metrics, + reason=reason, + ) + ) + branches.sort(key=lambda branch: branch["state"] != "selected") + return { + "question": "Which cells should be retained?", + "description": ( + "The workflow compared the registered quality-control choices before " + "changing the cell set." + ), + "branches": branches, + } + + +def _feature_tree_stage( + plan: Mapping[str, Any], + inventories: Sequence[Mapping[str, Any]], +) -> dict[str, Any] | None: + assay_plans = _mappings(plan.get("assays")) + selected_assay = next( + (assay for assay in assay_plans if assay.get("graphEligible") is True), + assay_plans[0] if assay_plans else {}, + ) + if not selected_assay: + return None + feature_method = str(selected_assay.get("featureMethod") or "none") + feature_labels = { + "hvg": "Most variable genes", + "prevalentPeaks": "Frequently observed chromatin regions", + "panel": "Predefined feature panel", + "none": "No feature subset", + } + parameters = _mapping(selected_assay.get("featureParameters")) + metrics: list[str] = [] + top_n = parameters.get("topN") + min_cells = parameters.get("minCells") + if isinstance(top_n, int): + metrics.append(f"Selected {top_n:,} features") + if isinstance(min_cells, int): + metrics.append(f"Required presence in at least {min_cells:,} cells") + excluded = [ + _feature_family_label(item) + for item in _text_values(parameters.get("excludeFamilies")) + ] + protected = [ + _feature_family_label(item) + for item in _text_values(parameters.get("protectFamilies")) + ] + if excluded: + metrics.append(f"Excluded {_format_text_list(excluded)}") + if protected: + metrics.append(f"Kept {_format_text_list(protected)} eligible") + inventory = _default_inventory_for_assay( + inventories, + str(selected_assay.get("assay") or ""), + ) + if inventory: + match_count = inventory.get("matchCount") + total_features = inventory.get("totalFeatures") + if isinstance(match_count, int) and isinstance(total_features, int): + metrics.append( + f"Scarf default reference matched {match_count:,} of " + f"{total_features:,} genes" + ) + metrics.append( + "Complete Scarf default blacklist applied: " + + ( + "yes" + if inventory.get("appliedToSelectedRepresentation") is True + else "no" + ) + ) + return { + "question": "Which measurements should shape the cell map?", + "description": ( + "The selected feature policy controls which biological variation can " + "influence the map." + ), + "branches": [ + _tree_branch( + label=feature_labels.get( + feature_method, + "Analysis-specific feature set", + ), + status="Selected", + state="selected", + metrics=metrics, + reason=( + "Selected to emphasize informative variation while limiting " + "known unwanted signal." + ), + ) + ], + } + + +def _batch_tree_stage( + experimental: Mapping[str, Any], + parameter: Mapping[str, Any], + final: Mapping[str, Any], + decisions: Mapping[str, Any], +) -> dict[str, Any] | None: + decision = _mapping(experimental.get("decision")) + batch_plan = _mapping(decision.get("batchCorrection")) + if not batch_plan: + return None + native_analyses = _mappings(final.get("nativeAnalyses")) + if final.get("graphMethod") == "native" and final.get("primaryAssay"): + selected_native = [ + item + for item in native_analyses + if item.get("assay") == final.get("primaryAssay") + ] + else: + selected_native = native_analyses + adjustment_applied = any( + _present(item.get("batchCorrection")) for item in selected_native + ) + native_candidate, harmony_candidate = _harmony_candidate_pair(parameter, final) + harmony_executed = _harmony_completed(native_candidate) and _harmony_completed( + harmony_candidate + ) + degraded = _degraded_protected_columns(native_candidate, harmony_candidate) + safety = _mappings(experimental.get("batchSafety")) + unsafe = [item for item in safety if item.get("status") == "unsafe"] + coefficients = [ + _public_field_label(item.get("coefficient")) + for item in unsafe + if _public_field_label(item.get("coefficient")) + ] + coefficients = list(dict.fromkeys(coefficients)) + remaining_capacity = [ + _mapping(item.get("estimability")).get("estimableDf") for item in unsafe + ] + adjustment_metrics: list[str] = [] + if coefficients: + adjustment_metrics.append( + f"Protected comparisons at risk: {_format_text_list(coefficients)}" + ) + if remaining_capacity and all(value == 0 for value in remaining_capacity): + adjustment_metrics.append("Remaining comparison capacity: 0") + if harmony_candidate: + harmony_parameters = _mapping(harmony_candidate.get("parameters")) + adjustment_metrics.append( + "Matched parameters: " + f"{_scalar(harmony_parameters.get('dimensions'))} dimensions, " + f"{_scalar(harmony_parameters.get('neighborsK'))} neighbors, " + f"resolution {_scalar(harmony_parameters.get('leidenResolution'))}" + ) + if harmony_executed: + adjustment_metrics.insert(0, "Run status: completed diagnostic") + native_metrics = _mapping(native_candidate.get("metrics")) + harmony_metrics = _mapping(harmony_candidate.get("metrics")) + native_batch = _mapping(native_metrics.get("batchMixing")) + harmony_batch = _mapping(harmony_metrics.get("batchMixing")) + for column in dict.fromkeys([*native_batch, *harmony_batch]): + adjustment_metrics.append( + f"{_public_field_label(column).capitalize()} mixing: " + f"{_score_transition(native_batch.get(column), harmony_batch.get(column))}" + ) + if degraded: + adjustment_metrics.append( + "Protected evidence degraded: " + _format_text_list(degraded) + ) + correction_license = _active_decision(decisions, "correctionLicense") + diagnostic_only = str(correction_license.get("selectedOptionId") or "").endswith( + "unsafeConfounded" + ) + if diagnostic_only: + adjustment_metrics.append("Selection license: diagnostic only") + action = str(batch_plan.get("action") or "") + if adjustment_applied: + unadjusted_state = "alternative" + adjusted_state = "selected" + unadjusted_status = "Not selected" + adjusted_status = "Selected" + unadjusted_reason = ( + "The adjusted result provided stronger supported comparability." + ) + adjusted_reason = ( + "Selected because it improved technical comparability while preserving " + "the biological structure being studied." + ) + else: + unadjusted_state = "selected" + adjusted_state = ( + "rejected" + if harmony_executed + else ("blocked" if action in {"unsafe", "skip"} else "alternative") + ) + unadjusted_status = "Selected" + adjusted_status = ( + "Run diagnostically; rejected" + if harmony_executed + else ("Not run" if adjusted_state == "blocked" else "Not selected") + ) + unadjusted_reason = ( + "Selected after the matched diagnostic retained more of the protected " + "biological structure." + if harmony_executed + else "Selected because adjustment was not shown to improve the data safely." + ) + adjusted_reason = ( + "Rejected because protected evidence degraded for " + f"{_format_text_list(degraded)}" + + ( + " and the design allowed diagnostic use only." + if diagnostic_only + else "." + ) + if harmony_executed and degraded + else ( + "Run as a matched diagnostic but not selected." + if harmony_executed + else ( + "Not run because technical and biological differences could " + "not be separated safely." + if adjusted_state == "blocked" + else "Tested but did not provide a safer improvement over the " + "unadjusted data." + ) + ) + ) + return { + "question": "Should technical variation be adjusted?", + "description": ( + "Adjustment was accepted only if it improved comparability without " + "removing protected biological differences." + ), + "branches": [ + _tree_branch( + label="Use the unadjusted representation", + status=unadjusted_status, + state=unadjusted_state, + metrics=[ + "Final representation: native", + "Protected biological comparisons retained", + ], + reason=unadjusted_reason, + ), + _tree_branch( + label="Apply Harmony batch adjustment", + status=adjusted_status, + state=adjusted_state, + metrics=adjustment_metrics, + reason=adjusted_reason, + ), + ], + } + + +def _selected_parameter_context( + parameter: Mapping[str, Any], + final: Mapping[str, Any], +) -> tuple[dict[str, Any], list[dict[str, Any]], dict[str, Any]]: + assay_reports = _mapping(parameter.get("assayReports")) + preferred_assay = str( + parameter.get("graphAssay") + or final.get("primaryAssay") + or parameter.get("fromAssay") + or "" + ) + report = _mapping(assay_reports.get(preferred_assay)) + if not report and assay_reports: + report = _mapping(next(iter(assay_reports.values()))) + if not report: + report = dict(parameter) + evaluations = _mappings(report.get("evaluations")) + recommended = _mapping(parameter.get("recommendedByAssay")) + selected_id = ( + recommended.get(preferred_assay) + or report.get("recommendedCandidateId") + or parameter.get("recommendedCandidateId") + ) + selected = next( + ( + evaluation + for evaluation in evaluations + if evaluation.get("candidateId") == selected_id + ), + {}, + ) + return report, evaluations, selected + + +def _harmony_candidate_pair( + parameter: Mapping[str, Any], + final: Mapping[str, Any], +) -> tuple[dict[str, Any], dict[str, Any]]: + _report, evaluations, _selected = _selected_parameter_context(parameter, final) + for harmony in reversed(evaluations): + harmony_parameters = _mapping(harmony.get("parameters")) + if harmony_parameters.get("useHarmony") is not True: + continue + signature = { + key: value + for key, value in harmony_parameters.items() + if key not in {"candidateId", "useHarmony"} + } + native_candidates = [ + evaluation + for evaluation in evaluations + if _mapping(evaluation.get("parameters")).get("useHarmony") is False + and { + key: value + for key, value in _mapping(evaluation.get("parameters")).items() + if key not in {"candidateId", "useHarmony"} + } + == signature + ] + if not native_candidates: + continue + expected_native_id = str(harmony.get("candidateId") or "").replace( + "_correction_harmony", + "_correction_native", + ) + native = next( + ( + evaluation + for evaluation in native_candidates + if evaluation.get("candidateId") == expected_native_id + ), + native_candidates[-1], + ) + return native, harmony + return {}, {} + + +def _harmony_completed(evaluation: Mapping[str, Any]) -> bool: + return ( + evaluation.get("status") == "done" and evaluation.get("eligible") is not False + ) + + +def _score_transition(native: Any, harmony: Any) -> str: + if not isinstance(native, (int, float)) or isinstance(native, bool): + return "Not available" + if not isinstance(harmony, (int, float)) or isinstance(harmony, bool): + return "Not available" + delta = float(harmony) - float(native) + return f"{float(native):.3f} to {float(harmony):.3f} (change {delta:+.3f})" + + +def _harmony_metric_rows( + native: Mapping[str, Any], + harmony: Mapping[str, Any], +) -> list[dict[str, Any]]: + native_metrics = _mapping(native.get("metrics")) + harmony_metrics = _mapping(harmony.get("metrics")) + rows: list[dict[str, Any]] = [] + + def add( + category: str, + metric: str, + native_value: Any, + harmony_value: Any, + interpretation: str, + ) -> None: + delta = ( + float(harmony_value) - float(native_value) + if isinstance(native_value, (int, float)) + and not isinstance(native_value, bool) + and isinstance(harmony_value, (int, float)) + and not isinstance(harmony_value, bool) + else None + ) + rows.append( + { + "category": category, + "metric": metric, + "native": native_value, + "Harmony": harmony_value, + "change": delta, + "interpretation": interpretation, + } + ) + + native_batch = _mapping(native_metrics.get("batchMixing")) + harmony_batch = _mapping(harmony_metrics.get("batchMixing")) + for column in dict.fromkeys([*native_batch, *harmony_batch]): + add( + "Batch removal", + f"{_public_field_label(column)} mixing", + native_batch.get(column), + harmony_batch.get(column), + "Higher values indicate stronger mixing across the technical group.", + ) + + native_association = _mapping(native_metrics.get("technicalAssociation")) + harmony_association = _mapping(harmony_metrics.get("technicalAssociation")) + for column in dict.fromkeys([*native_association, *harmony_association]): + add( + "Technical association", + _public_field_label(column), + native_association.get(column), + harmony_association.get(column), + "Lower values indicate less association with the technical group.", + ) + + native_biology = _mapping(native_metrics.get("biologicalPreservation")) + harmony_biology = _mapping(harmony_metrics.get("biologicalPreservation")) + for column in dict.fromkeys([*native_biology, *harmony_biology]): + native_scores = _mapping(native_biology.get(column)) + harmony_scores = _mapping(harmony_biology.get(column)) + for name in dict.fromkeys([*native_scores, *harmony_scores]): + add( + "Protected biology", + f"{_public_field_label(column)} {_label(name)}", + native_scores.get(name), + harmony_scores.get(name), + "Protected evidence should not decrease materially.", + ) + + for key, label, interpretation in ( + ( + "crossUnitSupport", + "Cross-sample support", + "Higher values indicate broader support across study units.", + ), + ( + "markerCoherence", + "Marker coherence", + "Higher values indicate more groups with coherent markers.", + ), + ( + "markerSpecificityMedian", + "Median marker specificity", + "Higher values indicate more group-specific markers.", + ), + ( + "clusterConnectivity", + "Cluster connectivity", + "Higher values indicate better connected groups.", + ), + ( + "membershipStrengthMean", + "Mean membership strength", + "Higher values indicate more stable cluster membership.", + ), + ( + "doubletHighScoreConcentration", + "Doublet-score concentration", + "Lower values indicate less concentration of high doublet scores.", + ), + ): + if key in native_metrics or key in harmony_metrics: + add( + "Supporting diagnostic", + label, + native_metrics.get(key), + harmony_metrics.get(key), + interpretation, + ) + return rows + + +def _degraded_protected_columns( + native: Mapping[str, Any], + harmony: Mapping[str, Any], + *, + tolerance: float = 0.05, +) -> list[str]: + native_biology = _mapping( + _mapping(native.get("metrics")).get("biologicalPreservation") + ) + harmony_biology = _mapping( + _mapping(harmony.get("metrics")).get("biologicalPreservation") + ) + degraded: list[str] = [] + for column, raw_native in native_biology.items(): + native_scores = _mapping(raw_native) + harmony_scores = _mapping(harmony_biology.get(column)) + if any( + isinstance(value, (int, float)) + and not isinstance(value, bool) + and isinstance(harmony_scores.get(name), (int, float)) + and not isinstance(harmony_scores.get(name), bool) + and float(harmony_scores[name]) < float(value) - tolerance + for name, value in native_scores.items() + ): + degraded.append(_public_field_label(column)) + return degraded + + +def _active_decision( + decisions: Mapping[str, Any], + decision_id: str, +) -> dict[str, Any]: + return _mapping(decisions.get(decision_id)) + + +def _common_parameter( + evaluations: Sequence[Mapping[str, Any]], + key: str, +) -> Any: + values = [ + _mapping(evaluation.get("parameters")).get(key) + for evaluation in evaluations + if _present(_mapping(evaluation.get("parameters")).get(key)) + ] + return Counter(values).most_common(1)[0][0] if values else None + + +def _parameter_options( + evaluations: Sequence[Mapping[str, Any]], + key: str, + filters: Mapping[str, Any], +) -> list[dict[str, Any]]: + by_value: dict[Any, dict[str, Any]] = {} + for evaluation in evaluations: + if evaluation.get("status") != "done" or evaluation.get("eligible") is False: + continue + parameters = _mapping(evaluation.get("parameters")) + if any(parameters.get(name) != value for name, value in filters.items()): + continue + value = parameters.get(key) + if not _present(value): + continue + current = by_value.get(value) + current_metrics = _mapping(current.get("metrics")) if current else {} + metrics = _mapping(evaluation.get("metrics")) + if current is None or len(metrics) > len(current_metrics): + by_value[value] = dict(evaluation) + return [ + by_value[value] + for value in sorted( + by_value, + key=lambda item: (not isinstance(item, (int, float)), item), + ) + ] + + +def _candidate_metrics( + evaluation: Mapping[str, Any], + *, + include_stability: bool = False, +) -> list[str]: + metrics = _mapping(evaluation.get("metrics")) + values: list[str] = [] + clusters = metrics.get("nClusters") + separation = metrics.get("graphSilhouetteMedian") + smallest = metrics.get("minClusterCells") + if isinstance(clusters, int): + values.append(f"Cell groups: {clusters:,}") + if isinstance(separation, (int, float)): + values.append(f"Separation score: {float(separation):.3f}") + if isinstance(smallest, int): + values.append(f"Smallest group: {smallest:,} cells") + if include_stability: + seed = metrics.get("seedStability") + subsample = metrics.get("subsampleStability") + marker = metrics.get("markerCoherence") + support = metrics.get("crossUnitSupport") + if isinstance(seed, (int, float)): + values.append(f"Repeat-run stability: {float(seed):.3f}") + if isinstance(subsample, (int, float)): + values.append(f"Subsample stability: {float(subsample):.3f}") + if isinstance(marker, (int, float)): + values.append(f"Marker coherence: {float(marker):.3f}") + if isinstance(support, (int, float)): + values.append(f"Cross-sample support: {float(support):.3f}") + return values + + +def _parameter_tree_stage( + *, + question: str, + description: str, + options: Sequence[Mapping[str, Any]], + parameter_name: str, + selected_value: Any, + label: Any, + selected_reason: str, + alternative_reason: Any, + include_stability: bool = False, +) -> dict[str, Any] | None: + if not options: + return None + branches: list[dict[str, Any]] = [] + for evaluation in options: + value = _mapping(evaluation.get("parameters")).get(parameter_name) + selected = value == selected_value + branches.append( + _tree_branch( + label=str(label(value)), + status="Selected" if selected else "Not selected", + state="selected" if selected else "alternative", + metrics=_candidate_metrics( + evaluation, + include_stability=include_stability and selected, + ), + reason=( + selected_reason + if selected + else str(alternative_reason(value, evaluation)) + ), + ) + ) + return { + "question": question, + "description": description, + "branches": branches, + } + + +def _parameter_tree_stages( + parameter: Mapping[str, Any], + final: Mapping[str, Any], +) -> tuple[list[dict[str, Any]], dict[str, Any]]: + _report, evaluations, selected = _selected_parameter_context(parameter, final) + if not evaluations or not selected: + return [], selected + selected_parameters = _mapping(selected.get("parameters")) + selected_dimensions = selected_parameters.get("dimensions") + selected_neighbors = selected_parameters.get("neighborsK") + selected_resolution = selected_parameters.get("leidenResolution") + selected_harmony = selected_parameters.get("useHarmony") + common_neighbors = _common_parameter(evaluations, "neighborsK") + common_resolution = _common_parameter(evaluations, "leidenResolution") + + dimension_options = _parameter_options( + evaluations, + "dimensions", + { + "neighborsK": common_neighbors, + "leidenResolution": common_resolution, + "useHarmony": selected_harmony, + }, + ) + neighbor_options = _parameter_options( + evaluations, + "neighborsK", + { + "dimensions": selected_dimensions, + "leidenResolution": common_resolution, + "useHarmony": selected_harmony, + }, + ) + resolution_options = _parameter_options( + evaluations, + "leidenResolution", + { + "dimensions": selected_dimensions, + "neighborsK": selected_neighbors, + "useHarmony": selected_harmony, + }, + ) + + def dimension_alternative(value: Any, _evaluation: Mapping[str, Any]) -> str: + if isinstance(value, (int, float)) and isinstance( + selected_dimensions, (int, float) + ): + if value > selected_dimensions: + return ( + "Not selected because the smaller representation retained " + "sufficient structure with less added noise." + ) + return "Not selected because it retained too little stable structure." + return "Evaluated but not selected." + + def neighbor_alternative(value: Any, _evaluation: Mapping[str, Any]) -> str: + if isinstance(value, (int, float)) and isinstance( + selected_neighbors, (int, float) + ): + if value < selected_neighbors: + return ( + "Provided finer local detail but produced smaller, less stable " + "groups." + ) + return "Smoothed across more cells and reduced useful local detail." + return "Evaluated but not selected." + + selected_metrics = _mapping(selected.get("metrics")) + selected_separation = selected_metrics.get("graphSilhouetteMedian") + + def resolution_alternative( + _value: Any, + evaluation: Mapping[str, Any], + ) -> str: + metrics = _mapping(evaluation.get("metrics")) + groups = metrics.get("nClusters") + separation = metrics.get("graphSilhouetteMedian") + if isinstance(groups, int) and isinstance(separation, (int, float)): + return ( + f"Produced {groups:,} groups with separation " + f"{float(separation):.3f}, weaker than the selected balance." + ) + if isinstance(selected_separation, (int, float)): + return ( + f"Did not match the selected separation score of " + f"{float(selected_separation):.3f}." + ) + return "Evaluated but not selected." + + stages = [ + stage + for stage in ( + _parameter_tree_stage( + question="How many variation patterns should be retained?", + description=( + "Dimensions are compressed patterns of gene variation used to " + "build the cell map." + ), + options=dimension_options, + parameter_name="dimensions", + selected_value=selected_dimensions, + label=lambda value: f"{int(value):,} dimensions", + selected_reason=( + "Selected as the smallest representation that retained a stable " + "cell map." + ), + alternative_reason=dimension_alternative, + ), + _parameter_tree_stage( + question="How local should each cell neighborhood be?", + description=( + "Smaller neighborhoods emphasize local detail; larger ones " + "produce broader smoothing." + ), + options=neighbor_options, + parameter_name="neighborsK", + selected_value=selected_neighbors, + label=lambda value: f"{int(value):,} nearest neighbors", + selected_reason=( + "Selected to balance local detail with stable cell-group sizes." + ), + alternative_reason=neighbor_alternative, + ), + _parameter_tree_stage( + question="How finely should cells be divided into groups?", + description=( + "Resolution controls whether the final map contains broader or " + "more finely divided cell groups." + ), + options=resolution_options, + parameter_name="leidenResolution", + selected_value=selected_resolution, + label=lambda value: f"Resolution {float(value):g}", + selected_reason=( + "Selected for the strongest supported separation, stability, " + "marker coherence, and group sizes." + ), + alternative_reason=resolution_alternative, + include_stability=True, + ), + ) + if stage is not None and len(stage["branches"]) > 1 + ] + return stages, selected + + +def _analysis_tree_stages(payload: Mapping[str, Any]) -> list[dict[str, Any]]: + reports = _mapping(payload.get("reports")) + workflow_result = _mapping(payload.get("workflowResult")) + plan = _mapping(workflow_result.get("preprocessingPlan")) + final = _mapping(workflow_result.get("finalAnalysis")) + experimental = _latest(reports, "experimental_context") + parameter = _latest(reports, "parameter_tuning") + biology = _latest(reports, "biological_interpretation") + decisions = _mapping(payload.get("activeDecisions")) + inventories = _mappings(payload.get("defaultFeatureInventories")) + cluster_counts = _mapping(payload.get("clusterCounts")) + total_cells = sum(int(value) for value in cluster_counts.values()) + stages: list[dict[str, Any]] = [] + for stage in ( + _qc_tree_stage(experimental, plan, total_cells), + _feature_tree_stage(plan, inventories), + ): + if stage is not None: + stages.append(stage) + stages.extend(_hvg_tree_stages(_mapping(payload.get("hvgEvidence")))) + batch_stage = _batch_tree_stage(experimental, parameter, final, decisions) + if batch_stage is not None: + stages.append(batch_stage) + parameter_stages, selected = _parameter_tree_stages(parameter, final) + stages.extend(parameter_stages) + + interpretations = _mappings(biology.get("clusterInterpretations")) + final_metrics = [f"Cells analyzed: {total_cells:,}"] if total_cells else [] + final_metrics.extend(_candidate_metrics(selected, include_stability=True)) + if not selected and cluster_counts: + final_metrics.append(f"Cell groups: {len(cluster_counts):,}") + stages.append( + { + "question": "Which result became the final analysis?", + "description": ( + "Only the selected branch was carried into visualization and marker " + "analysis." + ), + "branches": [ + _tree_branch( + label=( + f"{len(cluster_counts):,} cell groups" + if cluster_counts + else "Final selected cell map" + ), + status="Final result", + state="selected", + metrics=final_metrics, + reason=( + f"{len(interpretations):,} groups also received biological " + "interpretations." + if interpretations + else "No biological cell-type labels were inferred." + ), + ) + ], + } + ) + return stages + + +def _tree_connector_svg( + branch_count: int, + selected_index: int, + stage_index: int, + *, + continues: bool, +) -> tuple[str, str]: + width = 1200 + centers = [(index + 0.5) * width / branch_count for index in range(branch_count)] + branch_marker_id = f"tree-branch-arrow-{stage_index}" + if branch_count == 1: + branch_paths = ( + f'' + ) + else: + branch_paths = ( + f'' + f'' + + "".join( + f'' + for center in centers + ) + ) + branch_definitions = ( + f'' + '' + ) + branch_svg = ( + '" + ) + if not continues: + return branch_svg, "" + selected_x = centers[selected_index] + selection_marker_id = f"tree-selection-arrow-{stage_index}" + selection_path = ( + f"M {selected_x:g} 0 V 28 H {width / 2:g} V 78" + if selected_x != width / 2 + else f"M {width / 2:g} 0 V 78" + ) + selection_definitions = ( + f'' + "" + ) + selection_svg = ( + '' + ) + return branch_svg, selection_svg + + +def _render_decision_tree(stages: Sequence[Mapping[str, Any]]) -> str: + if not stages: + return '

    No completed analysis decisions were available.

    ' + rendered: list[str] = [] + for stage_index, stage in enumerate(stages, start=1): + branches = _mappings(stage.get("branches")) + if not branches: + continue + selected_index = next( + ( + index + for index, branch in enumerate(branches) + if branch.get("state") == "selected" + ), + 0, + ) + branch_svg, selection_svg = _tree_connector_svg( + len(branches), + selected_index, + stage_index, + continues=stage_index < len(stages), + ) + branch_markup = "".join( + '
    '.format( + html.escape(str(branch.get("state") or "alternative"), quote=True) + ) + + '{}'.format( + html.escape(str(branch.get("status") or "Evaluated")) + ) + + f"

    {html.escape(str(branch.get('label') or 'Option'))}

    " + + ( + '
      ' + + "".join( + f"
    • {html.escape(metric)}
    • " + for metric in _text_values(branch.get("metrics")) + ) + + "
    " + if _present(branch.get("metrics")) + else "" + ) + + ( + f"

    {html.escape(_brief_text(branch.get('reason')))}

    " + if _brief_text(branch.get("reason")) + else "" + ) + + "
    " + for branch in branches + ) + rendered.append( + '
    ' + '
    ' + f"Decision {stage_index}" + f"{html.escape(str(stage.get('question') or 'Analysis decision'))}" + "
    " + + ( + f'

    {html.escape(_brief_text(stage.get("description")))}

    ' + if _brief_text(stage.get("description")) + else "" + ) + + branch_svg + + '
    '.format( + len(branches) + ) + + branch_markup + + "
    " + + selection_svg + + "
    " + ) + return ( + '
    ' + + "".join(rendered) + + "
    " + ) + + +def _hvg_tree_stages(evidence: Mapping[str, Any]) -> list[dict[str, Any]]: + rankings = _mappings(evidence.get("rankings")) + candidates = _mappings(evidence.get("candidateMetrics")) + default_counts = [ + int(value) + for value in evidence.get("scarfDefaultReferenceCounts", []) + if isinstance(value, int) + ] + selected_mode = evidence.get("selectedRankingMode") + selected_count = evidence.get("selectedFeatureCount") + stages: list[dict[str, Any]] = [] + if rankings: + ranking_branches: list[dict[str, Any]] = [] + for ranking in rankings: + selected = ranking.get("rankingMode") == selected_mode + ranking_branches.append( + _tree_branch( + label=_hvg_ranking_label(ranking.get("rankingMode")), + status="Selected" if selected else "Not selected", + state="selected" if selected else "alternative", + metrics=[ + "Mean library coverage: " + f"{_analysis_percent(ranking.get('meanTechnicalGroupCoverage'))}", + "Recurring in at least two libraries: " + f"{_analysis_percent(ranking.get('recurrentInTwoGroupsFraction'))}", + ], + reason=( + "Selected after the combined recurrence, default-overlap, " + "technical-association, and downstream-stability comparison." + if selected + else ( + "Not selected after the combined upstream and downstream " + "comparison." + ) + ), + ) + ) + if default_counts: + ranking_branches.append( + _tree_branch( + label="Exact Scarf-default blacklist reference", + status="Reference evaluated", + state="reviewed", + metrics=[ + "Executed set sizes: " + + ", ".join(f"{value:,}" for value in default_counts) + ], + reason=( + "Used as an exact comparison reference; it was not a " + "selectable ranking mode." + ), + ) + ) + stages.append( + { + "question": "How should highly variable genes be ranked?", + "description": ( + "The workflow compared a global variability ranking with a " + "ranking that emphasized recurrence across libraries." + ), + "branches": ranking_branches, + } + ) + if candidates: + count_branches: list[dict[str, Any]] = [] + for candidate in candidates: + count = candidate.get("featureCount") + if not isinstance(count, int): + continue + selected = count == selected_count + count_branches.append( + _tree_branch( + label=f"{count:,} variable genes", + status="Selected" if selected else "Not selected", + state="selected" if selected else "alternative", + metrics=[ + "Corrected variance captured: " + f"{_analysis_percent(candidate.get('varianceFraction'))}", + "Recurring across most libraries: " + f"{_analysis_percent(candidate.get('recurrentFraction'))}", + ], + reason=( + "Selected as the supported balance of captured variation, " + "reproducibility, and downstream stability." + if selected + else "Not selected after comparison with the supported set size." + ), + ) + ) + if count_branches: + stages.append( + { + "question": "How many highly variable genes should be used?", + "description": ( + "Registered focused, standard, and broad feature-set sizes " + "were all executed and compared." + ), + "branches": count_branches, + } + ) + return stages diff --git a/scarf/agent/report/generator.py b/scarf/agent/report/generator.py new file mode 100644 index 00000000..3cfa2d2a --- /dev/null +++ b/scarf/agent/report/generator.py @@ -0,0 +1,153 @@ +"""Top-level local agent report assembly.""" + +import os +import uuid +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from ...datastore.datastore import DataStore +from ...utils.logging import logger +from ..persistence.reports import load_agent_workflow +from .artifacts import ( + _collect_active_decisions, + _collect_default_feature_inventories, + _collect_history, + _collect_hvg_evidence, + _collect_reports, + _load_completed_result, + _local_root, + _open_datastore, +) +from .contracts import _latest, _mapping, _selected_qc_profile +from .plots import _collect_final_artifacts, _collect_hvg_plots +from .rendering import ( + _render_analysis_document, + _render_index_document, + _render_technical_document, +) + + +def _write_report_page(report_dir: Path, filename: str, document: str) -> Path: + destination = report_dir / filename + temporary = report_dir / f".{filename}.{uuid.uuid4().hex}.tmp" + try: + temporary.write_text(document, encoding="utf-8") + os.replace(temporary, destination) + finally: + temporary.unlink(missing_ok=True) + return destination + + +def generate_agent_report( + target: str | Path | DataStore, + workflow_run_id: str, + *, + workspace: str | None = None, +) -> Path: + """Generate a local HTML report for one completed automated workflow. + + The report directory contains a landing page, an analysis summary, and + technical details. The returned path points to the landing ``index.html``. + Existing derived report files may be replaced; immutable agent and + orchestration records are only read. + """ + root = _local_root(target) + resolved_workspace = ( + target.workspace if isinstance(target, DataStore) else workspace + ) + if ( + isinstance(target, DataStore) + and workspace is not None + and workspace != target.workspace + ): + raise ValueError("workspace does not match the DataStore workspace") + workflow = load_agent_workflow( + target, + workflow_run_id, + workspace=resolved_workspace, + ) + store = _open_datastore(target, root, workflow) + prefix, result, request = _load_completed_result(store, workflow) + reports = _collect_reports(store, result) + stage_attempts, resumes = _collect_history(store, prefix, workflow, request) + + active_root = ( + root if workflow.workspace is None else (root / workflow.workspace).resolve() + ) + if not active_root.is_relative_to(root): + raise ValueError("Workflow workspace resolves outside the analysis store") + report_dir = ( + active_root / "agents" / "runs" / workflow_run_id / "report" + ).resolve() + if not report_dir.is_relative_to(active_root): + raise ValueError("Agent report path resolves outside the analysis store") + plot_dir = report_dir / "plots" + report_dir.mkdir(parents=True, exist_ok=True) + preprocessing_plan = ( + result.preprocessingPlan.model_dump(mode="json") + if result.preprocessingPlan is not None + else {} + ) + experimental = _latest(reports, "experimental_context") + selected_qc_profile = _selected_qc_profile( + experimental, + _mapping(preprocessing_plan.get("cellQc")), + ) + cluster_counts, top_markers, plot_files, plot_notes = _collect_final_artifacts( + store, + result, + plot_dir, + qc_profile=selected_qc_profile, + ) + hvg_evidence = _collect_hvg_evidence( + store, + stage_attempts, + preprocessing_plan, + ) + hvg_plots, hvg_plot_notes = _collect_hvg_plots( + store, + stage_attempts, + preprocessing_plan, + plot_dir, + ) + plot_files.update(hvg_plots) + plot_notes.extend(hvg_plot_notes) + active_decisions = _collect_active_decisions(store, workflow_run_id) + default_feature_inventories = _collect_default_feature_inventories( + store, + preprocessing_plan, + ) + payload: dict[str, Any] = { + "status": result.status, + "currentStage": result.currentStage, + "workflowRunId": workflow_run_id, + "generatedAt": datetime.now(UTC).isoformat(), + "request": request.request.model_dump(mode="json"), + "effectiveConfig": request.config.model_dump(mode="json"), + "workflowResult": result.model_dump(mode="json"), + "reports": reports, + "stageAttempts": stage_attempts, + "workflowResumes": resumes, + "clusterCounts": cluster_counts, + "topMarkers": top_markers, + "plotFiles": plot_files, + "plotNotes": plot_notes, + "hvgEvidence": hvg_evidence, + "activeDecisions": active_decisions, + "defaultFeatureInventories": default_feature_inventories, + } + documents = ( + ("analysis.html", _render_analysis_document(payload)), + ("technical.html", _render_technical_document(payload)), + ("index.html", _render_index_document(payload)), + ) + destination = report_dir / "index.html" + for filename, document in documents: + written = _write_report_page(report_dir, filename, document) + if filename == "index.html": + destination = written + logger.info( + f"Generated HTML report for agent workflow {workflow_run_id}: {destination}" + ) + return destination diff --git a/scarf/agent/report/plots.py b/scarf/agent/report/plots.py new file mode 100644 index 00000000..1c20cd6f --- /dev/null +++ b/scarf/agent/report/plots.py @@ -0,0 +1,812 @@ +"""Plot and bounded visual-artifact collection for agent reports.""" + +import html +import json +import os +import re +import uuid +from collections import Counter +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Any + +from ...datastore.datastore import DataStore +from ...storage.refs import ArtifactRef +from ...storage.types import as_zarr_array +from ..orchestrator.models import AutomatedWorkflowResult, artifact_model_to_ref +from ..types import ArtifactReferenceModel +from .artifacts import _latest_hvg_diagnostic_artifacts +from .contracts import ( + _analysis_number_range, + _label, + _mapping, + _mappings, + _qc_resolved_bounds, +) + +MAX_MARKER_DOTPLOT_FEATURES = 24 + + +CLUSTER_COUNT_BLOCK_SIZE = 100_000 + + +MAX_EMBEDDING_PLOT_CELLS = 250_000 + + +MAX_DOTPLOT_CELLS = 75_000 + + +MAX_CONNECTIVITY_PLOT_CELLS = 100_000 + + +MAX_COMPOSITION_PLOT_CELLS = 1_000_000 + + +def _save_plot(plot: Any, path: Path) -> None: + """Atomically save one plot and its provenance, always closing its figure.""" + token = uuid.uuid4().hex + temporary = path.with_name(f".{path.stem}.{token}{path.suffix}") + sidecar = path.with_suffix(path.suffix + ".json") + temporary_sidecar = sidecar.with_name(f".{sidecar.stem}.{token}{sidecar.suffix}") + try: + plot.save(temporary, dpi=150) + plot.save_provenance(temporary_sidecar, figure_path=path, dpi=150) + os.replace(temporary, path) + os.replace(temporary_sidecar, sidecar) + finally: + temporary.unlink(missing_ok=True) + temporary_sidecar.unlink(missing_ok=True) + plot.close() + + +def _safe_assay_name(value: str, fallback: str) -> str: + label = "_".join(part.lower() for part in re.findall(r"[A-Za-z0-9]+", value)) + return label[:64].rstrip("_") or fallback + + +def _annotate_qc_cutoffs(plot: Any, profile: Mapping[str, Any]) -> None: + bounds = _qc_resolved_bounds(profile) + if not bounds: + return + diagnostic_only = profile.get("action") == "skip" + styles = { + "lowerRemoval": ( + "lower diagnostic bound" if diagnostic_only else "lower removal cutoff", + "#d62728", + "--", + ), + "upperRemoval": ( + "upper diagnostic bound" if diagnostic_only else "upper removal cutoff", + "#d62728", + "--", + ), + "upperFlag": ("high-value diagnostic bound", "#ff7f0e", ":"), + } + recorded: list[dict[str, Any]] = [] + for metric, axis in plot.axes.items(): + metric_bounds = [ + bound for bound in bounds if str(bound.get("metric") or "") == str(metric) + ] + original_limits = axis.get_ylim() + visible_low, visible_high = sorted(float(value) for value in original_limits) + has_legend_entry = False + for field, (label, color, linestyle) in styles.items(): + values = sorted( + { + float(bound[field]) + for bound in metric_bounds + if isinstance(bound.get(field), int | float) + and not isinstance(bound.get(field), bool) + } + ) + if not values: + continue + formatted_values = _analysis_number_range(values) + if len(values) == 1: + if visible_low <= values[0] <= visible_high: + axis.axhline( + values[0], + color=color, + linestyle=linestyle, + linewidth=1.2, + label=f"{label}: {formatted_values}", + ) + else: + axis.plot( + [], + [], + color=color, + linestyle=linestyle, + linewidth=1.2, + label=f"{label}: {formatted_values} (outside plot)", + ) + else: + clipped_low = max(values[0], visible_low) + clipped_high = min(values[-1], visible_high) + if clipped_low <= clipped_high: + range_suffix = ( + " (partly outside plot)" + if values[0] < visible_low or values[-1] > visible_high + else "" + ) + axis.axhspan( + clipped_low, + clipped_high, + color=color, + alpha=0.1, + label=f"{label}: {formatted_values}{range_suffix}", + ) + for value in values: + if visible_low <= value <= visible_high: + axis.axhline( + value, + color=color, + linestyle=linestyle, + linewidth=0.8, + ) + else: + axis.plot( + [], + [], + color=color, + linestyle=linestyle, + linewidth=1.2, + label=f"{label}: {formatted_values} (outside plot)", + ) + has_legend_entry = True + recorded.extend( + { + "metric": str(metric), + "field": field, + "group": bound.get("group"), + "value": bound.get(field), + } + for bound in metric_bounds + if isinstance(bound.get(field), int | float) + and not isinstance(bound.get(field), bool) + ) + if has_legend_entry: + axis.legend(frameon=False, fontsize=6.5, loc="upper left") + axis.set_ylim(original_limits) + plot.provenance.extras["qc_cutoffs"] = recorded + plot.provenance.extras["qc_profile"] = profile.get("registeredProfile") + + +def _collect_final_artifacts( + store: DataStore, + result: AutomatedWorkflowResult, + plot_dir: Path, + *, + qc_profile: Mapping[str, Any] | None = None, +) -> tuple[ + dict[str, int], + list[dict[str, Any]], + dict[str, str], + list[str], +]: + """Validate the final handoff and derive bounded tables and plots.""" + import numpy as np + + final = result.finalAnalysis + assert final is not None + if final.cellSelection is None or final.clusters is None or final.umap is None: + raise ValueError("Final handoff lacks its selection, clusters, or UMAP") + + artifact_models = [ + final.cellSelection, + final.graph, + final.clusters, + final.embeddingInitialization, + final.umap, + final.markerFeatures, + final.markers, + ] + for native in final.nativeAnalyses: + artifact_models.extend( + [ + native.featureSelection, + native.markerFeatures, + native.normalized, + native.reduction, + native.batchCorrection, + native.annIndex, + native.embeddingInitialization, + native.neighbors, + native.graph, + native.clusters, + native.umap, + ] + ) + for artifact in artifact_models: + if artifact is not None: + store.load_artifact(artifact_model_to_ref(artifact)) + + cluster_ref = artifact_model_to_ref(final.clusters) + umap_ref = artifact_model_to_ref(final.umap) + cluster_artifact: Any = store.load_artifact(cluster_ref) + values = cluster_artifact["values"] + counts: Counter[str] = Counter() + for start in range(0, int(values.shape[0]), CLUSTER_COUNT_BLOCK_SIZE): + block = np.asarray(values[start : start + CLUSTER_COUNT_BLOCK_SIZE]).astype(str) + block_labels, frequencies = np.unique(block, return_counts=True) + counts.update( + { + str(label): int(frequency) + for label, frequency in zip(block_labels, frequencies, strict=True) + } + ) + cluster_counts = dict(sorted(counts.items())) + cluster_labels = list(cluster_counts) + n_cells = sum(cluster_counts.values()) + plots: dict[str, str] = {} + notes: list[str] = [] + plot_dir.mkdir(parents=True, exist_ok=True) + + def render_plot(name: str, filename: str, create: Any) -> None: + try: + path = plot_dir / filename + _save_plot(create(), path) + plots[name] = f"plots/{filename}" + except Exception as exc: + notes.append(f"{name}: {type(exc).__name__}: {exc}") + + if n_cells <= MAX_EMBEDDING_PLOT_CELLS: + render_plot( + "umapClusters", + "final_umap.png", + lambda: store.plots.embedding( + layout=umap_ref, + color_by=cluster_ref, + show=False, + ), + ) + else: + notes.append( + "umapClusters: skipped because the final selection has " + f"{n_cells:,} cells, above the memory-safe report limit of " + f"{MAX_EMBEDDING_PLOT_CELLS:,}" + ) + + observed_native_names: set[str] = set() + for index, native in enumerate(final.nativeAnalyses): + if native.umap is None or native.clusters is None: + continue + native_umap = artifact_model_to_ref(native.umap) + native_clusters = artifact_model_to_ref(native.clusters) + if native_umap == umap_ref and native_clusters == cluster_ref: + continue + suffix = _safe_assay_name(native.assay, f"assay_{index + 1}") + base_name = "nativeUmap" + "".join( + part.capitalize() for part in suffix.split("_") + ) + plot_name = base_name + serial = 1 + while plot_name in observed_native_names: + serial += 1 + plot_name = f"{base_name}{serial}" + observed_native_names.add(plot_name) + file_suffix = suffix if serial == 1 else f"{suffix}_{serial}" + if n_cells <= MAX_EMBEDDING_PLOT_CELLS: + render_plot( + plot_name, + f"native_umap_{file_suffix}.png", + lambda layout=native_umap, color=native_clusters: store.plots.embedding( + layout=layout, color_by=color, show=False + ), + ) + else: + notes.append( + f"{plot_name}: skipped because {n_cells:,} cells exceed the " + f"memory-safe report limit of {MAX_EMBEDDING_PLOT_CELLS:,}" + ) + + if n_cells <= MAX_COMPOSITION_PLOT_CELLS: + render_plot( + "clusterComposition", + "cluster_composition.png", + lambda: store.plots.composition( + categories=cluster_ref, + show_percent_labels=len(cluster_labels) <= 12, + show=False, + ), + ) + else: + notes.append( + "clusterComposition: skipped because the final selection has " + f"{n_cells:,} cells, above the memory-safe report limit of " + f"{MAX_COMPOSITION_PLOT_CELLS:,}" + ) + + qc_attributes = ( + list(result.preprocessingPlan.cellQc.attributes) + if result.preprocessingPlan is not None + else [] + ) + available_qc_attributes = [ + value for value in qc_attributes if value in store.cells.columns + ] + artifact_qc_metrics = ( + [ + artifact_model_to_ref(value.artifact) + for value in result.preprocessingPlan.cellQc.artifactMetrics + ] + if result.preprocessingPlan is not None + else [] + ) + available_qc_attributes = available_qc_attributes[:4] + + def qc_distribution(selection: Any) -> Any: + plot = store.plots.distribution( + keys=available_qc_attributes, + cell_selection=artifact_model_to_ref(selection), + kind="violin", + max_points=10_000, + show=False, + ) + if qc_profile: + _annotate_qc_cutoffs(plot, qc_profile) + return plot + + active_cells = qc_profile.get("activeCells") if qc_profile else None + retained_cells = qc_profile.get("retainedCells") if qc_profile else None + if ( + available_qc_attributes + and result.preprocessingPlan is not None + and result.preprocessingPlan.cellSelection is not None + and isinstance(active_cells, int) + and isinstance(retained_cells, int) + and retained_cells != active_cells + ): + render_plot( + "qcDistributionsBeforeFiltering", + "qc_distributions_before_filtering.png", + lambda: qc_distribution(result.preprocessingPlan.cellSelection), + ) + if available_qc_attributes: + render_plot( + "qcDistributions", + "qc_distributions.png", + lambda: qc_distribution(final.cellSelection), + ) + remaining_qc_plots = max(0, 4 - len(available_qc_attributes)) + for index, metric in enumerate(artifact_qc_metrics[:remaining_qc_plots]): + render_plot( + f"qcDistributionDerived{index + 1}", + f"qc_distribution_derived_{index + 1}.png", + lambda source=metric: store.plots.distribution( + keys=source, + kind="violin", + max_points=10_000, + show=False, + ), + ) + + for index, score_model in enumerate(final.doubletScores[:4]): + score_ref = artifact_model_to_ref(score_model) + render_plot( + f"doubletDistribution{index + 1}", + f"doublet_distribution_{index + 1}.png", + lambda score=score_ref: store.plots.distribution( + keys=score, + kind="hist", + bins=40, + show=False, + ), + ) + if index == 0 and n_cells <= MAX_EMBEDDING_PLOT_CELLS: + render_plot( + "doubletEmbedding", + "doublet_embedding.png", + lambda score=score_ref: store.plots.embedding( + layout=umap_ref, + color_by=score, + show=False, + ), + ) + + top_markers: list[dict[str, Any]] = [] + if final.markers is not None: + marker_ref = artifact_model_to_ref(final.markers) + marker_parameters = store.inspect_artifact(marker_ref).parameters or {} + raw_normalization = marker_parameters.get("normalization", {}) + marker_normalization = ( + dict(raw_normalization) if isinstance(raw_normalization, Mapping) else {} + ) + marker_log_transform = marker_normalization.get("log_transform", False) is True + if marker_normalization.get("renormalize_subset", False) is True: + notes.append( + "marker visualizations: the persisted marker search renormalized " + "its feature subset; current plotting APIs preserve its log " + "transform but visualize assay-wide normalized values" + ) + for label in cluster_labels: + try: + table = store.get_markers( + marker_ref, + group_id=label, + min_score=-1, + min_frac_exp=-1, + ) + if not table.empty: + if "score" in table: + table = table.sort_values( + "score", ascending=False, kind="stable" + ) + top_markers.extend( + json.loads(table.head(5).to_json(orient="records")) + ) + except Exception as exc: + notes.append( + f"marker export for cluster {label}: {type(exc).__name__}: {exc}" + ) + + render_plot( + "markerHeatmap", + "marker_heatmap.png", + lambda: store.plots.marker_heatmap( + marker=marker_ref, + log_transform=marker_log_transform, + show=False, + ), + ) + + try: + from ...plotting import FeatureRef, NormalizationSpec + + by_cluster: dict[str, list[tuple[tuple[str, str], Any]]] = { + label: [] for label in cluster_labels + } + for marker in top_markers: + group_id = str(marker.get("group_id", "")) + if group_id not in by_cluster: + continue + feature_name = marker.get("feature_name") + feature_id = marker.get("feature_id") + feature_index = marker.get("feature_index") + label = str(feature_name or feature_id or feature_index or "") + if isinstance(feature_index, (int, float)): + identity = ("index", str(int(feature_index))) + feature = FeatureRef( + value=int(feature_index), + assay=final.markerAssay, + by="index", + label=label, + ) + elif isinstance(feature_id, str) and feature_id: + identity = ("id", feature_id) + feature = FeatureRef( + value=feature_id, + assay=final.markerAssay, + by="id", + label=label, + ) + else: + continue + if all(observed != identity for observed, _ in by_cluster[group_id]): + by_cluster[group_id].append((identity, feature)) + + marker_groups: dict[str, list[Any]] = {} + selected: set[tuple[str, str]] = set() + max_rank = max(map(len, by_cluster.values()), default=0) + rank = 0 + while rank < max_rank and len(selected) < MAX_MARKER_DOTPLOT_FEATURES: + for cluster in cluster_labels: + features = by_cluster[cluster] + if rank >= len(features): + continue + identity, feature = features[rank] + if identity in selected: + continue + marker_groups.setdefault(f"Cluster {cluster}", []).append(feature) + selected.add(identity) + if len(selected) == MAX_MARKER_DOTPLOT_FEATURES: + break + rank += 1 + if marker_groups and n_cells <= MAX_DOTPLOT_CELLS: + render_plot( + "markerDotplot", + "marker_dotplot.png", + lambda: store.plots.dotplot( + features=marker_groups, + groups=cluster_ref, + from_assay=final.markerAssay, + normalization=NormalizationSpec( + source="assay", + transform=("log1p" if marker_log_transform else "none"), + ), + standardize="feature", + show=False, + ), + ) + elif marker_groups: + notes.append( + "markerDotplot: skipped because the final selection has " + f"{n_cells:,} cells, above the memory-safe report limit of " + f"{MAX_DOTPLOT_CELLS:,}" + ) + except Exception as exc: + notes.append(f"markerDotplot: {type(exc).__name__}: {exc}") + + if final.graph is not None: + graph_ref = artifact_model_to_ref(final.graph) + if n_cells <= MAX_CONNECTIVITY_PLOT_CELLS: + render_plot( + "clusterConnectivity", + "cluster_connectivity.png", + lambda: store.plots.cluster_connectivity( + groups=cluster_ref, + layout=umap_ref, + graph=graph_ref, + show=False, + ), + ) + else: + notes.append( + "clusterConnectivity: skipped because the final selection has " + f"{n_cells:,} cells, above the memory-safe report limit of " + f"{MAX_CONNECTIVITY_PLOT_CELLS:,}" + ) + return cluster_counts, top_markers, plots, notes + + +def _collect_hvg_plots( + store: DataStore, + stage_attempts: Sequence[Mapping[str, Any]], + preprocessing_plan: Mapping[str, Any], + plot_dir: Path, +) -> tuple[dict[str, str], list[str]]: + import numpy as np + + selected_name, selected_reference, artifacts = _latest_hvg_diagnostic_artifacts( + stage_attempts + ) + if not selected_reference: + return {}, [] + assay_name = selected_name.removesuffix("_hvg_diagnostic") + assay_plan = next( + ( + value + for value in _mappings(preprocessing_plan.get("assays")) + if value.get("assay") == assay_name + ), + {}, + ) + selected_count = _mapping(assay_plan.get("featureParameters")).get("topN") + if not isinstance(selected_count, int) or isinstance(selected_count, bool): + return {}, ["HVG diagnostics: selected feature count is unavailable"] + + references = ( + ("global", artifacts.get(f"{assay_name}_hvg_global_diagnostic")), + ("batchAware", artifacts.get(f"{assay_name}_hvg_batchAware_diagnostic")), + ) + plots: dict[str, str] = {} + notes: list[str] = [] + seen_artifact_ids: set[str] = set() + plot_dir.mkdir(parents=True, exist_ok=True) + for ranking_mode, raw_reference in references: + if not isinstance(raw_reference, Mapping): + continue + model = ArtifactReferenceModel.model_validate(dict(raw_reference)) + if model.artifactId in seen_artifact_ids: + continue + seen_artifact_ids.add(model.artifactId) + plot_name = "hvgGlobal" if ranking_mode == "global" else "hvgBatchAware" + filename = ( + "hvg_global.png" if ranking_mode == "global" else "hvg_batch_aware.png" + ) + try: + diagnostic_ref = artifact_model_to_ref(model) + diagnostic = store.load_artifact(diagnostic_ref) + observed_mode = diagnostic.attrs.get("ranking_mode") + if observed_mode != ranking_mode: + raise ValueError( + f"HVG diagnostic expected {ranking_mode!r}, got {observed_mode!r}" + ) + status = store.inspect_artifact(diagnostic_ref) + raw_summary = (status.inputs or {}).get("global_feature_summary") + if not isinstance(raw_summary, Mapping): + raise ValueError("HVG diagnostic lacks its global feature summary") + summary_ref = ArtifactRef.from_dict(dict(raw_summary)) + summary = store.load_artifact(summary_ref) + corrected_variance = np.asarray( + as_zarr_array( + diagnostic["global_corrected_variance"], + name="global_corrected_variance", + )[:], + dtype=np.float64, + ) + ranking = np.asarray( + as_zarr_array(diagnostic["ranking"], name="ranking")[:], + dtype=np.int64, + ) + normed_tot = np.asarray( + as_zarr_array(summary["normed_tot"], name="normed_tot")[:], + dtype=np.float64, + ) + normed_n = np.asarray( + as_zarr_array(summary["normed_n"], name="normed_n")[:], + dtype=np.float64, + ) + shape = corrected_variance.shape + if ( + corrected_variance.ndim != 1 + or normed_tot.shape != shape + or normed_n.shape != shape + or selected_count > ranking.size + or ranking.size + and (int(ranking.min()) < 0 or int(ranking.max()) >= shape[0]) + or np.unique(ranking).size != ranking.size + ): + raise ValueError("HVG plotting arrays are malformed") + selected = np.zeros(shape, dtype=bool) + selected[ranking[:selected_count]] = True + mean_nonzero = np.divide( + normed_tot, + normed_n, + out=np.zeros_like(normed_tot), + where=normed_n != 0, + ) + from ...plotting import highly_variable_features + + plot = highly_variable_features( + mean_nonzero=mean_nonzero, + corrected_variance=corrected_variance, + n_cells=normed_n, + selected=selected, + show=False, + ) + plot.axes["highly_variable_features"].set_title( + f"{_hvg_ranking_label(ranking_mode)}\n{selected_count:,} selected genes" + ) + plot.provenance.extras.update( + { + "assay": assay_name, + "diagnostic_artifact_id": model.artifactId, + "ranking_mode": ranking_mode, + "selected_feature_count": selected_count, + } + ) + _save_plot(plot, plot_dir / filename) + plots[plot_name] = f"plots/{filename}" + except Exception as exc: + notes.append(f"{plot_name}: {type(exc).__name__}: {exc}") + return plots, notes + + +def _render_plots( + plots: Mapping[str, str], + notes: Sequence[str], + *, + order: Sequence[str] | None = None, + titles: Mapping[str, tuple[str, str]] | None = None, + show_provenance: bool = True, + show_notes: bool = True, + empty_message: str | None = None, +) -> str: + plot_titles = { + "umapClusters": ( + "Final UMAP by cluster", + "The selected final representation, colored by final cluster.", + ), + "markerHeatmap": ( + "Marker heatmap", + "Marker-feature patterns across the final clusters.", + ), + "markerDotplot": ( + "Marker dot plot", + "A bounded expression summary for exact exported marker features.", + ), + "clusterComposition": ( + "Cluster composition", + "The relative size of each cluster in the final cell selection.", + ), + "clusterConnectivity": ( + "Cluster connectivity", + "Connectivity between final clusters in the selected graph.", + ), + "qcDistributions": ( + "QC distributions after the selected policy", + "Retained-cell distributions with the selected profile's cutoff annotations.", + ), + "qcDistributionsBeforeFiltering": ( + "QC distributions before filtering", + "Input-cell distributions with the selected profile's cutoff annotations.", + ), + "hvgGlobal": ( + "Global HVG diagnostic", + "Mean-variance evidence with genes selected by the global ranking highlighted.", + ), + "hvgBatchAware": ( + "Group-aware HVG diagnostic", + "Mean-variance evidence with genes selected for recurrence across technical groups highlighted.", + ), + "doubletEmbedding": ( + "Advisory doublet scores", + "The final embedding colored by non-removing doublet evidence.", + ), + } + if titles is not None: + plot_titles.update(titles) + plot_order = ( + list(order) + if order is not None + else [ + "umapClusters", + *(name for name in plots if name.startswith("nativeUmap")), + "markerHeatmap", + "markerDotplot", + "clusterComposition", + "clusterConnectivity", + "qcDistributionsBeforeFiltering", + "qcDistributions", + *(name for name in plots if name.startswith("qcDistributionDerived")), + "hvgGlobal", + "hvgBatchAware", + "doubletEmbedding", + *(name for name in plots if name.startswith("doubletDistribution")), + *plots, + ] + ) + figures: list[str] = [] + for name in dict.fromkeys(plot_order): + source = plots.get(name) + if source is None: + continue + if name.startswith("nativeUmap"): + assay = name.removeprefix("nativeUmap") or "assay" + title = f"{assay} native UMAP" + caption = f"The finalized native {assay} representation and clusters." + elif name.startswith("doubletDistribution"): + title = "Advisory doublet-score distribution" + caption = ( + "Capture-aware doublet evidence retained as flags without removal." + ) + elif name.startswith("qcDistributionDerived"): + title = "Derived QC metric distribution" + caption = "An immutable feature-family QC metric on the selected cell axis." + else: + title, caption = plot_titles.get( + name, (_label(name), "A finalized Scarf analysis plot.") + ) + escaped_source = html.escape(source, quote=True) + plot_class = ' class="primary"' if name == "umapClusters" else "" + provenance_markup = "" + if show_provenance: + provenance = html.escape(source + ".json", quote=True) + provenance_markup = f' Plot provenance' + figures.append( + f"" + f'' + f"
    {html.escape(title)}
    " + f"{html.escape(caption)}{provenance_markup}
    " + ) + if not figures: + if empty_message is None: + plot_markup = ( + '

    No plots could be rendered. The structured ' + "analysis remains available below. Install Scarf with the " + "extra dependency group to enable plotting.

    " + ) + else: + plot_markup = ( + f'

    {html.escape(empty_message)}

    ' + ) + else: + plot_markup = f'
    {"".join(figures)}
    ' + note_markup = "" + if show_notes and notes: + note_markup = ( + "
    Plot availability notes" + '
      ' + + "".join(f"
    • {html.escape(note)}
    • " for note in notes) + + "
    " + ) + return plot_markup + note_markup + + +def _hvg_ranking_label(value: Any) -> str: + return { + "global": "Global variability ranking", + "batchAware": "Group-aware variability ranking", + }.get(str(value or ""), "Variable-gene ranking") diff --git a/scarf/agent/report.py b/scarf/agent/report/rendering.py similarity index 50% rename from scarf/agent/report.py rename to scarf/agent/report/rendering.py index 1a17ba63..679c7757 100644 --- a/scarf/agent/report.py +++ b/scarf/agent/report/rendering.py @@ -1,1196 +1,57 @@ -"""Local HTML reports for completed automated Scarf agent workflows. - -Reports are derived presentation files. They are written beside the immutable -agent records, but they are not Zarr components and never participate in -workflow checksums or artifact lineage. -""" +"""HTML sections and templates for agent reports.""" import html import json -import os -import re -import uuid from collections import Counter from collections.abc import Mapping, Sequence -from datetime import UTC, datetime -from pathlib import Path -from typing import Any, cast - -from .. import __version__ -from ..datastore.datastore import DataStore -from ..storage.refs import ArtifactRef -from ..storage.stores import zarr_root_path -from ..storage.types import as_zarr_array -from ..utils.logging import logger -from . import record_io -from .decision_persistence import load_latest_decision_workflow_snapshot -from .orchestrator import journal -from .orchestrator.models import ( - _STAGE_ORDER, - AutomatedWorkflowResult, - OrchestrationRequestRecord, - WorkflowStageAttempt, - artifact_model_to_ref, +from typing import Any + +from ... import __version__ +from .artifacts import _default_inventory_for_assay +from .contracts import ( + _analysis_number_range, + _analysis_percent, + _assay_label, + _brief_text, + _feature_family_label, + _format_text_list, + _is_leaf, + _is_mapping_sequence, + _is_sequence, + _is_simple, + _label, + _latest, + _mapping, + _mappings, + _present, + _public_field_label, + _qc_resolved_bounds, + _scalar, + _selected_qc_profile, + _specific_references, + _text_values, ) -from .persistence import ( - AgentWorkflowRun, - load_agent_report, - load_agent_workflow, +from .decision_tree import ( + _active_decision, + _analysis_tree_stages, + _degraded_protected_columns, + _harmony_candidate_pair, + _harmony_completed, + _harmony_metric_rows, + _qc_profile_label, + _render_decision_tree, + _score_transition, + _selected_parameter_context, ) -from .types import ArtifactReferenceModel - -MAX_MARKER_DOTPLOT_FEATURES = 24 -CLUSTER_COUNT_BLOCK_SIZE = 100_000 -MAX_EMBEDDING_PLOT_CELLS = 250_000 -MAX_DOTPLOT_CELLS = 75_000 -MAX_CONNECTIVITY_PLOT_CELLS = 100_000 -MAX_COMPOSITION_PLOT_CELLS = 1_000_000 -MAX_CHIP_LENGTH = 56 -MAX_TABLE_COLUMNS = 7 -MAX_INLINE_LEAVES = 12 - - -def _local_root(target: str | Path | DataStore) -> Path: - """Resolve a local filesystem root without accepting remote stores.""" - if isinstance(target, DataStore): - location = zarr_root_path(target.z) - if location is None: - raise ValueError("Agent HTML reports require a local filesystem store") - path = Path(location) - elif isinstance(target, Path): - path = target - elif isinstance(target, str) and target.startswith("file://"): - path = Path(target.removeprefix("file://")) - elif isinstance(target, str): - if "://" in target: - raise ValueError("Agent HTML reports require a local filesystem store") - path = Path(target) - else: - raise TypeError("Agent HTML reports require a local filesystem store") - path = path.expanduser().resolve() - if not path.is_dir(): - raise FileNotFoundError(path) - return path - - -def _open_datastore( - target: str | Path | DataStore, - root: Path, - workflow: AgentWorkflowRun, -) -> DataStore: - if isinstance(target, DataStore): - if target.workspace != workflow.workspace: - raise ValueError("Workflow workspace does not match the DataStore") - return target - default_assay = next(iter(workflow.datasetFingerprints)) - return DataStore( - str(root), - default_assay=default_assay, - min_features_per_cell=-1, - mito_pattern="", - ribo_pattern="", - zarr_mode="r", - workspace=workflow.workspace, - ) - - -def _load_request( - store: DataStore, - prefix: str, - workflow_run_id: str, -) -> OrchestrationRequestRecord: - record = cast( - OrchestrationRequestRecord, - journal._read_model( - store.zw, - journal._request_key(prefix, workflow_run_id), - OrchestrationRequestRecord, - ), - ) - if record.workflowRunId != workflow_run_id: - raise ValueError("Stored orchestration request belongs to another workflow") - if record.requestSha256 != journal._sha256_model(record.request): - raise ValueError("Stored orchestration request checksum is invalid") - if record.configSha256 != journal._sha256_model(record.config): - raise ValueError("Stored orchestration configuration checksum is invalid") - if record.contentSha256 != journal._record_checksum(record): - raise ValueError("Stored orchestration request envelope is invalid") - return record - - -def _load_completed_result( - store: DataStore, - workflow: AgentWorkflowRun, -) -> tuple[str, AutomatedWorkflowResult, OrchestrationRequestRecord]: - if workflow.status != "completed": - raise RuntimeError( - "Agent HTML reports can only be generated for completed workflows" - ) - prefix = journal._ensure_orchestration_store(store) - result = journal._load_terminal_result(store, prefix, workflow) - if result is None: - raise FileNotFoundError( - f"Completed workflow {workflow.workflowRunId!r} has no terminal result" - ) - if result.status != "completed" or result.finalAnalysis is None: - raise ValueError("Completed workflow result lacks its final analysis handoff") - request = _load_request(store, prefix, workflow.workflowRunId) - if request.request.workspace != workflow.workspace: - raise ValueError("Stored request workspace does not match the workflow") - return prefix, result, request - - -def _collect_reports( - store: DataStore, - result: AutomatedWorkflowResult, -) -> dict[str, list[dict[str, Any]]]: - reports: dict[str, list[dict[str, Any]]] = {} - for reference in result.reportReferences: - report = load_agent_report(store, reference) - reports.setdefault(reference.agentName, []).append( - report.model_dump(mode="json") - ) - return reports - - -def _collect_active_decisions( - store: DataStore, - workflow_run_id: str, -) -> dict[str, dict[str, Any]]: - try: - snapshot = load_latest_decision_workflow_snapshot(store, workflow_run_id) - except KeyError: - return {} - decisions: dict[str, dict[str, Any]] = {} - for record in snapshot.workflow.active_decision_records(): - if record.decisionId in decisions: - raise ValueError( - f"Decision workflow has multiple active {record.decisionId!r} records" - ) - decisions[record.decisionId] = record.model_dump(mode="json") - return decisions - - -def _stage_summary(attempt: WorkflowStageAttempt) -> dict[str, Any]: - duration = ( - (attempt.completedAtNs - attempt.startedAtNs) / 1_000_000_000 - if attempt.completedAtNs - else None - ) - error_type = None - if attempt.error: - candidate = attempt.error.partition(":")[0].strip() - error_type = ( - candidate - if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_.]{0,127}", candidate) - else "WorkflowStageError" - ) - return { - "stage": attempt.stage, - "attemptId": attempt.attemptId, - "status": attempt.status, - "durationSeconds": duration, - "actions": list(attempt.actions), - "reportCount": len(attempt.reportReferences), - "artifactCount": len(attempt.artifacts), - "artifacts": { - name: artifact.model_dump(mode="json") - for name, artifact in attempt.artifacts.items() - }, - "parentAttempts": [ - f"{parent.stage}:{parent.attemptId}" for parent in attempt.parentAttempts - ], - "questionIds": ( - [question.questionId for question in attempt.needsInput.questions] - if attempt.needsInput is not None - else [] - ), - "noteCount": len(attempt.notes), - "notes": list(attempt.notes), - "errorType": error_type, - } - - -def _collect_history( - store: DataStore, - prefix: str, - workflow: AgentWorkflowRun, - request: OrchestrationRequestRecord, -) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: - attempts: dict[tuple[str, str], WorkflowStageAttempt] = {} - summaries: list[tuple[int, dict[str, Any]]] = [] - for stage in _STAGE_ORDER: - starts = { - item.attemptId: item - for item in journal._stage_starts( - store.zw, prefix, workflow.workflowRunId, stage - ) - } - outcomes = { - item.attemptId: item - for item in journal._stage_outcomes( - store.zw, prefix, workflow.workflowRunId, stage - ) - } - if not set(outcomes).issubset(starts): - raise ValueError("Workflow history contains an outcome without a start") - for attempt_id, started in starts.items(): - attempt = outcomes.get(attempt_id, started) - identity = (stage, attempt_id) - if identity in attempts: - raise ValueError("Workflow history contains duplicate stage attempts") - attempts[identity] = attempt - summaries.append((attempt.startedAtNs, _stage_summary(attempt))) - - for attempt in attempts.values(): - for parent in attempt.parentAttempts: - observed = attempts.get((parent.stage, parent.attemptId)) - if ( - observed is None - or observed.status != "done" - or observed.contentSha256 != parent.contentSha256 - ): - raise ValueError("Workflow parent-stage lineage does not resolve") - - terminal_candidates = [ - attempt - for attempt in attempts.values() - if attempt.stage == "analysis_finalization" and attempt.status == "done" - ] - if len(terminal_candidates) != 1: - raise ValueError( - "Completed workflow lacks one exact analysis finalization attempt" - ) - current = terminal_candidates[0] - terminal_chain: set[tuple[str, str]] = set() - while True: - identity = (current.stage, current.attemptId) - if identity in terminal_chain: - raise ValueError("Workflow stage lineage contains a cycle") - terminal_chain.add(identity) - if not journal._stage_outcome_resolves( - store, - prefix, - workflow.workflowRunId, - request, - current, - ): - raise ValueError("Terminal workflow stage artifacts do not resolve") - stage_index = _STAGE_ORDER.index(current.stage) - if stage_index == 0: - if current.parentAttempts: - raise ValueError("The ingest stage cannot have a parent") - break - if len(current.parentAttempts) != 1: - raise ValueError("Every terminal-chain stage must have one parent") - parent = current.parentAttempts[0] - if parent.stage != _STAGE_ORDER[stage_index - 1]: - raise ValueError("Terminal workflow lineage skips a stage") - current = attempts[(parent.stage, parent.attemptId)] - - resumes: list[dict[str, Any]] = [] - resume_prefix = record_io.join_key(prefix, workflow.workflowRunId, "resumes") - for key in record_io.list_keys(store.zw, resume_prefix): - if not key.endswith(".json"): - continue - resume_id = key.rsplit("/", 1)[-1].removesuffix(".json") - resume = journal._validated_resume_record( - store, prefix, workflow.workflowRunId, resume_id - ) - resumes.append( - { - "resumeId": resume.resumeId, - "createdAtNs": resume.createdAtNs, - "answeredStage": ( - resume.answeredAttempt.stage - if resume.answeredAttempt is not None - else None - ), - "answeredAttemptId": ( - resume.answeredAttempt.attemptId - if resume.answeredAttempt is not None - else None - ), - "questionIds": list(resume.questionIds), - } - ) - resumes.sort(key=lambda value: (value["createdAtNs"], value["resumeId"])) - ordered = sorted( - summaries, - key=lambda item: ( - item[0], - str(item[1]["stage"]), - str(item[1]["attemptId"]), - ), - ) - return [value for _, value in ordered], resumes - - -def _save_plot(plot: Any, path: Path) -> None: - """Atomically save one plot and its provenance, always closing its figure.""" - token = uuid.uuid4().hex - temporary = path.with_name(f".{path.stem}.{token}{path.suffix}") - sidecar = path.with_suffix(path.suffix + ".json") - temporary_sidecar = sidecar.with_name(f".{sidecar.stem}.{token}{sidecar.suffix}") - try: - plot.save(temporary, dpi=150) - plot.save_provenance(temporary_sidecar, figure_path=path, dpi=150) - os.replace(temporary, path) - os.replace(temporary_sidecar, sidecar) - finally: - temporary.unlink(missing_ok=True) - temporary_sidecar.unlink(missing_ok=True) - plot.close() - - -def _safe_assay_name(value: str, fallback: str) -> str: - label = "_".join(part.lower() for part in re.findall(r"[A-Za-z0-9]+", value)) - return label[:64].rstrip("_") or fallback - - -def _annotate_qc_cutoffs(plot: Any, profile: Mapping[str, Any]) -> None: - bounds = _qc_resolved_bounds(profile) - if not bounds: - return - diagnostic_only = profile.get("action") == "skip" - styles = { - "lowerRemoval": ( - "lower diagnostic bound" if diagnostic_only else "lower removal cutoff", - "#d62728", - "--", - ), - "upperRemoval": ( - "upper diagnostic bound" if diagnostic_only else "upper removal cutoff", - "#d62728", - "--", - ), - "upperFlag": ("high-value diagnostic bound", "#ff7f0e", ":"), - } - recorded: list[dict[str, Any]] = [] - for metric, axis in plot.axes.items(): - metric_bounds = [ - bound for bound in bounds if str(bound.get("metric") or "") == str(metric) - ] - original_limits = axis.get_ylim() - visible_low, visible_high = sorted(float(value) for value in original_limits) - has_legend_entry = False - for field, (label, color, linestyle) in styles.items(): - values = sorted( - { - float(bound[field]) - for bound in metric_bounds - if isinstance(bound.get(field), int | float) - and not isinstance(bound.get(field), bool) - } - ) - if not values: - continue - formatted_values = _analysis_number_range(values) - if len(values) == 1: - if visible_low <= values[0] <= visible_high: - axis.axhline( - values[0], - color=color, - linestyle=linestyle, - linewidth=1.2, - label=f"{label}: {formatted_values}", - ) - else: - axis.plot( - [], - [], - color=color, - linestyle=linestyle, - linewidth=1.2, - label=f"{label}: {formatted_values} (outside plot)", - ) - else: - clipped_low = max(values[0], visible_low) - clipped_high = min(values[-1], visible_high) - if clipped_low <= clipped_high: - range_suffix = ( - " (partly outside plot)" - if values[0] < visible_low or values[-1] > visible_high - else "" - ) - axis.axhspan( - clipped_low, - clipped_high, - color=color, - alpha=0.1, - label=f"{label}: {formatted_values}{range_suffix}", - ) - for value in values: - if visible_low <= value <= visible_high: - axis.axhline( - value, - color=color, - linestyle=linestyle, - linewidth=0.8, - ) - else: - axis.plot( - [], - [], - color=color, - linestyle=linestyle, - linewidth=1.2, - label=f"{label}: {formatted_values} (outside plot)", - ) - has_legend_entry = True - recorded.extend( - { - "metric": str(metric), - "field": field, - "group": bound.get("group"), - "value": bound.get(field), - } - for bound in metric_bounds - if isinstance(bound.get(field), int | float) - and not isinstance(bound.get(field), bool) - ) - if has_legend_entry: - axis.legend(frameon=False, fontsize=6.5, loc="upper left") - axis.set_ylim(original_limits) - plot.provenance.extras["qc_cutoffs"] = recorded - plot.provenance.extras["qc_profile"] = profile.get("registeredProfile") - - -def _collect_final_artifacts( - store: DataStore, - result: AutomatedWorkflowResult, - plot_dir: Path, - *, - qc_profile: Mapping[str, Any] | None = None, -) -> tuple[ - dict[str, int], - list[dict[str, Any]], - dict[str, str], - list[str], -]: - """Validate the final handoff and derive bounded tables and plots.""" - import numpy as np - - final = result.finalAnalysis - assert final is not None - if final.cellSelection is None or final.clusters is None or final.umap is None: - raise ValueError("Final handoff lacks its selection, clusters, or UMAP") - - artifact_models = [ - final.cellSelection, - final.graph, - final.clusters, - final.embeddingInitialization, - final.umap, - final.markerFeatures, - final.markers, - ] - for native in final.nativeAnalyses: - artifact_models.extend( - [ - native.featureSelection, - native.markerFeatures, - native.normalized, - native.reduction, - native.batchCorrection, - native.annIndex, - native.embeddingInitialization, - native.neighbors, - native.graph, - native.clusters, - native.umap, - ] - ) - for artifact in artifact_models: - if artifact is not None: - store.load_artifact(artifact_model_to_ref(artifact)) - - cluster_ref = artifact_model_to_ref(final.clusters) - umap_ref = artifact_model_to_ref(final.umap) - cluster_artifact: Any = store.load_artifact(cluster_ref) - values = cluster_artifact["values"] - counts: Counter[str] = Counter() - for start in range(0, int(values.shape[0]), CLUSTER_COUNT_BLOCK_SIZE): - block = np.asarray(values[start : start + CLUSTER_COUNT_BLOCK_SIZE]).astype(str) - block_labels, frequencies = np.unique(block, return_counts=True) - counts.update( - { - str(label): int(frequency) - for label, frequency in zip(block_labels, frequencies, strict=True) - } - ) - cluster_counts = dict(sorted(counts.items())) - cluster_labels = list(cluster_counts) - n_cells = sum(cluster_counts.values()) - plots: dict[str, str] = {} - notes: list[str] = [] - plot_dir.mkdir(parents=True, exist_ok=True) - - def render_plot(name: str, filename: str, create: Any) -> None: - try: - path = plot_dir / filename - _save_plot(create(), path) - plots[name] = f"plots/{filename}" - except Exception as exc: - notes.append(f"{name}: {type(exc).__name__}: {exc}") - - if n_cells <= MAX_EMBEDDING_PLOT_CELLS: - render_plot( - "umapClusters", - "final_umap.png", - lambda: store.plots.embedding( - layout=umap_ref, - color_by=cluster_ref, - show=False, - ), - ) - else: - notes.append( - "umapClusters: skipped because the final selection has " - f"{n_cells:,} cells, above the memory-safe report limit of " - f"{MAX_EMBEDDING_PLOT_CELLS:,}" - ) - - observed_native_names: set[str] = set() - for index, native in enumerate(final.nativeAnalyses): - if native.umap is None or native.clusters is None: - continue - native_umap = artifact_model_to_ref(native.umap) - native_clusters = artifact_model_to_ref(native.clusters) - if native_umap == umap_ref and native_clusters == cluster_ref: - continue - suffix = _safe_assay_name(native.assay, f"assay_{index + 1}") - base_name = "nativeUmap" + "".join( - part.capitalize() for part in suffix.split("_") - ) - plot_name = base_name - serial = 1 - while plot_name in observed_native_names: - serial += 1 - plot_name = f"{base_name}{serial}" - observed_native_names.add(plot_name) - file_suffix = suffix if serial == 1 else f"{suffix}_{serial}" - if n_cells <= MAX_EMBEDDING_PLOT_CELLS: - render_plot( - plot_name, - f"native_umap_{file_suffix}.png", - lambda layout=native_umap, color=native_clusters: store.plots.embedding( - layout=layout, color_by=color, show=False - ), - ) - else: - notes.append( - f"{plot_name}: skipped because {n_cells:,} cells exceed the " - f"memory-safe report limit of {MAX_EMBEDDING_PLOT_CELLS:,}" - ) - - if n_cells <= MAX_COMPOSITION_PLOT_CELLS: - render_plot( - "clusterComposition", - "cluster_composition.png", - lambda: store.plots.composition( - categories=cluster_ref, - show_percent_labels=len(cluster_labels) <= 12, - show=False, - ), - ) - else: - notes.append( - "clusterComposition: skipped because the final selection has " - f"{n_cells:,} cells, above the memory-safe report limit of " - f"{MAX_COMPOSITION_PLOT_CELLS:,}" - ) - - qc_attributes = ( - list(result.preprocessingPlan.cellQc.attributes) - if result.preprocessingPlan is not None - else [] - ) - available_qc_attributes = [ - value for value in qc_attributes if value in store.cells.columns - ] - artifact_qc_metrics = ( - [ - artifact_model_to_ref(value.artifact) - for value in result.preprocessingPlan.cellQc.artifactMetrics - ] - if result.preprocessingPlan is not None - else [] - ) - available_qc_attributes = available_qc_attributes[:4] - - def qc_distribution(selection: Any) -> Any: - plot = store.plots.distribution( - keys=available_qc_attributes, - cell_selection=artifact_model_to_ref(selection), - kind="violin", - max_points=10_000, - show=False, - ) - if qc_profile: - _annotate_qc_cutoffs(plot, qc_profile) - return plot - - active_cells = qc_profile.get("activeCells") if qc_profile else None - retained_cells = qc_profile.get("retainedCells") if qc_profile else None - if ( - available_qc_attributes - and result.preprocessingPlan is not None - and result.preprocessingPlan.cellSelection is not None - and isinstance(active_cells, int) - and isinstance(retained_cells, int) - and retained_cells != active_cells - ): - render_plot( - "qcDistributionsBeforeFiltering", - "qc_distributions_before_filtering.png", - lambda: qc_distribution(result.preprocessingPlan.cellSelection), - ) - if available_qc_attributes: - render_plot( - "qcDistributions", - "qc_distributions.png", - lambda: qc_distribution(final.cellSelection), - ) - remaining_qc_plots = max(0, 4 - len(available_qc_attributes)) - for index, metric in enumerate(artifact_qc_metrics[:remaining_qc_plots]): - render_plot( - f"qcDistributionDerived{index + 1}", - f"qc_distribution_derived_{index + 1}.png", - lambda source=metric: store.plots.distribution( - keys=source, - kind="violin", - max_points=10_000, - show=False, - ), - ) - - for index, score_model in enumerate(final.doubletScores[:4]): - score_ref = artifact_model_to_ref(score_model) - render_plot( - f"doubletDistribution{index + 1}", - f"doublet_distribution_{index + 1}.png", - lambda score=score_ref: store.plots.distribution( - keys=score, - kind="hist", - bins=40, - show=False, - ), - ) - if index == 0 and n_cells <= MAX_EMBEDDING_PLOT_CELLS: - render_plot( - "doubletEmbedding", - "doublet_embedding.png", - lambda score=score_ref: store.plots.embedding( - layout=umap_ref, - color_by=score, - show=False, - ), - ) - - top_markers: list[dict[str, Any]] = [] - if final.markers is not None: - marker_ref = artifact_model_to_ref(final.markers) - marker_parameters = store.inspect_artifact(marker_ref).parameters or {} - raw_normalization = marker_parameters.get("normalization", {}) - marker_normalization = ( - dict(raw_normalization) if isinstance(raw_normalization, Mapping) else {} - ) - marker_log_transform = marker_normalization.get("log_transform", False) is True - if marker_normalization.get("renormalize_subset", False) is True: - notes.append( - "marker visualizations: the persisted marker search renormalized " - "its feature subset; current plotting APIs preserve its log " - "transform but visualize assay-wide normalized values" - ) - for label in cluster_labels: - try: - table = store.get_markers( - marker_ref, - group_id=label, - min_score=-1, - min_frac_exp=-1, - ) - if not table.empty: - if "score" in table: - table = table.sort_values( - "score", ascending=False, kind="stable" - ) - top_markers.extend( - json.loads(table.head(5).to_json(orient="records")) - ) - except Exception as exc: - notes.append( - f"marker export for cluster {label}: {type(exc).__name__}: {exc}" - ) - - render_plot( - "markerHeatmap", - "marker_heatmap.png", - lambda: store.plots.marker_heatmap( - marker=marker_ref, - log_transform=marker_log_transform, - show=False, - ), - ) - - try: - from ..plotting import FeatureRef, NormalizationSpec - - by_cluster: dict[str, list[tuple[tuple[str, str], Any]]] = { - label: [] for label in cluster_labels - } - for marker in top_markers: - group_id = str(marker.get("group_id", "")) - if group_id not in by_cluster: - continue - feature_name = marker.get("feature_name") - feature_id = marker.get("feature_id") - feature_index = marker.get("feature_index") - label = str(feature_name or feature_id or feature_index or "") - if isinstance(feature_index, (int, float)): - identity = ("index", str(int(feature_index))) - feature = FeatureRef( - value=int(feature_index), - assay=final.markerAssay, - by="index", - label=label, - ) - elif isinstance(feature_id, str) and feature_id: - identity = ("id", feature_id) - feature = FeatureRef( - value=feature_id, - assay=final.markerAssay, - by="id", - label=label, - ) - else: - continue - if all(observed != identity for observed, _ in by_cluster[group_id]): - by_cluster[group_id].append((identity, feature)) - - marker_groups: dict[str, list[Any]] = {} - selected: set[tuple[str, str]] = set() - max_rank = max(map(len, by_cluster.values()), default=0) - rank = 0 - while rank < max_rank and len(selected) < MAX_MARKER_DOTPLOT_FEATURES: - for cluster in cluster_labels: - features = by_cluster[cluster] - if rank >= len(features): - continue - identity, feature = features[rank] - if identity in selected: - continue - marker_groups.setdefault(f"Cluster {cluster}", []).append(feature) - selected.add(identity) - if len(selected) == MAX_MARKER_DOTPLOT_FEATURES: - break - rank += 1 - if marker_groups and n_cells <= MAX_DOTPLOT_CELLS: - render_plot( - "markerDotplot", - "marker_dotplot.png", - lambda: store.plots.dotplot( - features=marker_groups, - groups=cluster_ref, - from_assay=final.markerAssay, - normalization=NormalizationSpec( - source="assay", - transform=("log1p" if marker_log_transform else "none"), - ), - standardize="feature", - show=False, - ), - ) - elif marker_groups: - notes.append( - "markerDotplot: skipped because the final selection has " - f"{n_cells:,} cells, above the memory-safe report limit of " - f"{MAX_DOTPLOT_CELLS:,}" - ) - except Exception as exc: - notes.append(f"markerDotplot: {type(exc).__name__}: {exc}") - - if final.graph is not None: - graph_ref = artifact_model_to_ref(final.graph) - if n_cells <= MAX_CONNECTIVITY_PLOT_CELLS: - render_plot( - "clusterConnectivity", - "cluster_connectivity.png", - lambda: store.plots.cluster_connectivity( - groups=cluster_ref, - layout=umap_ref, - graph=graph_ref, - show=False, - ), - ) - else: - notes.append( - "clusterConnectivity: skipped because the final selection has " - f"{n_cells:,} cells, above the memory-safe report limit of " - f"{MAX_CONNECTIVITY_PLOT_CELLS:,}" - ) - return cluster_counts, top_markers, plots, notes - - -def _hvg_diagnostic_evidence( - store: DataStore, - reference: Mapping[str, Any], -) -> dict[str, Any]: - import numpy as np - - model = ArtifactReferenceModel.model_validate(reference) - group: Any = store.load_artifact(artifact_model_to_ref(model)) - provenance = _mapping(group.attrs.get("provenance")) - parameters = _mapping(provenance.get("parameters")) - ranking = np.asarray(group["ranking"][:], dtype=np.int64) - corrected_variance = np.asarray( - group["global_corrected_variance"][:], - dtype=np.float64, - ) - recurrence = np.asarray(group["recurrence"][:], dtype=np.int64) - eligible = np.asarray(group["eligible"][:], dtype=bool) - if ( - ranking.ndim != 1 - or corrected_variance.ndim != 1 - or recurrence.shape != corrected_variance.shape - or eligible.shape != corrected_variance.shape - ): - raise ValueError("HVG diagnostic arrays are malformed") - if ranking.size and ( - int(ranking.min()) < 0 or int(ranking.max()) >= corrected_variance.size - ): - raise ValueError("HVG diagnostic ranking contains out-of-range indices") - raw_counts = parameters.get("candidate_counts") - if not _is_sequence(raw_counts): - raise ValueError("HVG diagnostic is missing candidate counts") - raw_count_values = cast(Sequence[Any], raw_counts) - candidate_counts = [ - int(value) - for value in raw_count_values - if isinstance(value, int) and not isinstance(value, bool) - ] - if len(candidate_counts) != len(raw_count_values) or any( - value < 1 or value > ranking.size for value in candidate_counts - ): - raise ValueError("HVG diagnostic candidate counts are invalid") - valid_groups = group.attrs.get("valid_groups", []) - if not _is_sequence(valid_groups): - raise ValueError("HVG diagnostic valid groups are malformed") - valid_group_count = len(cast(Sequence[Any], valid_groups)) - excluded_groups = group.attrs.get("excluded_groups", []) - if not _is_sequence(excluded_groups): - raise ValueError("HVG diagnostic excluded groups are malformed") - eligible_variance = float(corrected_variance[eligible].sum()) - recurrence_threshold = max(2, (valid_group_count + 1) // 2) - candidates: list[dict[str, Any]] = [] - for count in candidate_counts: - selected = ranking[:count] - variance_fraction = ( - float(corrected_variance[selected].sum()) / eligible_variance - if eligible_variance > 0 - else 0.0 - ) - candidates.append( - { - "featureCount": count, - "varianceFraction": variance_fraction, - "recurrentFraction": ( - float((recurrence[selected] >= recurrence_threshold).mean()) - if valid_group_count - else None - ), - } - ) - broad = ranking[: max(candidate_counts)] - return { - "rankingMode": group.attrs.get("ranking_mode"), - "eligibleFeatureCount": int(eligible.sum()), - "validTechnicalGroups": valid_group_count, - "excludedTechnicalGroupCount": len(cast(Sequence[Any], excluded_groups)), - "candidateMetrics": candidates, - "meanTechnicalGroupCoverage": ( - float(recurrence[broad].mean()) / valid_group_count - if valid_group_count - else None - ), - "recurrentInTwoGroupsFraction": ( - float((recurrence[broad] >= 2).mean()) if valid_group_count else None - ), - "minimumDetectedCells": parameters.get("min_cells"), - "minimumTechnicalGroupCells": parameters.get("min_group_cells"), - } - - -def _latest_hvg_diagnostic_artifacts( - stage_attempts: Sequence[Mapping[str, Any]], -) -> tuple[str, dict[str, Any], dict[str, Any]]: - for attempt in reversed(stage_attempts): - artifacts = _mapping(attempt.get("artifacts")) - match = next( - ( - (str(name), _mapping(reference)) - for name, reference in artifacts.items() - if re.fullmatch(r".+_hvg_diagnostic", str(name)) - ), - None, - ) - if match is not None: - return match[0], match[1], artifacts - return "", {}, {} - - -def _collect_hvg_evidence( - store: DataStore, - stage_attempts: Sequence[Mapping[str, Any]], - preprocessing_plan: Mapping[str, Any], -) -> dict[str, Any]: - selected_name, selected_reference, selected_artifacts = ( - _latest_hvg_diagnostic_artifacts(stage_attempts) - ) - if not selected_reference: - return {} - assay = selected_name.removesuffix("_hvg_diagnostic") - selected = _hvg_diagnostic_evidence(store, selected_reference) - ranking_references = ( - ("global", selected_artifacts.get(f"{assay}_hvg_global_diagnostic")), - ( - "batchAware", - selected_artifacts.get(f"{assay}_hvg_batchAware_diagnostic"), - ), - ) - rankings: list[dict[str, Any]] = [] - for mode, reference in ranking_references: - if isinstance(reference, Mapping): - summary = _hvg_diagnostic_evidence(store, reference) - if summary.get("rankingMode") != mode: - raise ValueError("HVG diagnostic ranking mode does not match its role") - rankings.append(summary) - if not rankings: - rankings.append(selected) - assay_plan = next( - ( - value - for value in _mappings(preprocessing_plan.get("assays")) - if value.get("assay") == assay - ), - {}, - ) - selected_count = _mapping(assay_plan.get("featureParameters")).get("topN") - default_reference_counts = sorted( - { - int(default_match.group(1)) - for name in selected_artifacts - if ( - default_match := re.fullmatch( - rf"{re.escape(assay)}_hvg_scarf_default_([0-9]+)", - str(name), - ) - ) - } - ) - executed_branch_count = len(default_reference_counts) + sum( - len(_mappings(ranking.get("candidateMetrics"))) for ranking in rankings - ) - return { - "assay": assay, - "selectedRankingMode": selected.get("rankingMode"), - "selectedFeatureCount": selected_count, - "rankings": rankings, - "candidateMetrics": selected.get("candidateMetrics"), - "eligibleFeatureCount": selected.get("eligibleFeatureCount"), - "validTechnicalGroups": selected.get("validTechnicalGroups"), - "excludedTechnicalGroupCount": selected.get("excludedTechnicalGroupCount"), - "minimumDetectedCells": selected.get("minimumDetectedCells"), - "minimumTechnicalGroupCells": selected.get("minimumTechnicalGroupCells"), - "scarfDefaultReferenceCounts": default_reference_counts, - "executedBranchCount": executed_branch_count, - } +from .plots import _hvg_ranking_label, _render_plots +MAX_CHIP_LENGTH = 56 -def _collect_hvg_plots( - store: DataStore, - stage_attempts: Sequence[Mapping[str, Any]], - preprocessing_plan: Mapping[str, Any], - plot_dir: Path, -) -> tuple[dict[str, str], list[str]]: - import numpy as np - - selected_name, selected_reference, artifacts = _latest_hvg_diagnostic_artifacts( - stage_attempts - ) - if not selected_reference: - return {}, [] - assay_name = selected_name.removesuffix("_hvg_diagnostic") - assay_plan = next( - ( - value - for value in _mappings(preprocessing_plan.get("assays")) - if value.get("assay") == assay_name - ), - {}, - ) - selected_count = _mapping(assay_plan.get("featureParameters")).get("topN") - if not isinstance(selected_count, int) or isinstance(selected_count, bool): - return {}, ["HVG diagnostics: selected feature count is unavailable"] - references = ( - ("global", artifacts.get(f"{assay_name}_hvg_global_diagnostic")), - ("batchAware", artifacts.get(f"{assay_name}_hvg_batchAware_diagnostic")), - ) - plots: dict[str, str] = {} - notes: list[str] = [] - seen_artifact_ids: set[str] = set() - plot_dir.mkdir(parents=True, exist_ok=True) - for ranking_mode, raw_reference in references: - if not isinstance(raw_reference, Mapping): - continue - model = ArtifactReferenceModel.model_validate(dict(raw_reference)) - if model.artifactId in seen_artifact_ids: - continue - seen_artifact_ids.add(model.artifactId) - plot_name = "hvgGlobal" if ranking_mode == "global" else "hvgBatchAware" - filename = ( - "hvg_global.png" if ranking_mode == "global" else "hvg_batch_aware.png" - ) - try: - diagnostic_ref = artifact_model_to_ref(model) - diagnostic = store.load_artifact(diagnostic_ref) - observed_mode = diagnostic.attrs.get("ranking_mode") - if observed_mode != ranking_mode: - raise ValueError( - f"HVG diagnostic expected {ranking_mode!r}, got {observed_mode!r}" - ) - status = store.inspect_artifact(diagnostic_ref) - raw_summary = (status.inputs or {}).get("global_feature_summary") - if not isinstance(raw_summary, Mapping): - raise ValueError("HVG diagnostic lacks its global feature summary") - summary_ref = ArtifactRef.from_dict(dict(raw_summary)) - summary = store.load_artifact(summary_ref) - corrected_variance = np.asarray( - as_zarr_array( - diagnostic["global_corrected_variance"], - name="global_corrected_variance", - )[:], - dtype=np.float64, - ) - ranking = np.asarray( - as_zarr_array(diagnostic["ranking"], name="ranking")[:], - dtype=np.int64, - ) - normed_tot = np.asarray( - as_zarr_array(summary["normed_tot"], name="normed_tot")[:], - dtype=np.float64, - ) - normed_n = np.asarray( - as_zarr_array(summary["normed_n"], name="normed_n")[:], - dtype=np.float64, - ) - shape = corrected_variance.shape - if ( - corrected_variance.ndim != 1 - or normed_tot.shape != shape - or normed_n.shape != shape - or selected_count > ranking.size - or ranking.size - and (int(ranking.min()) < 0 or int(ranking.max()) >= shape[0]) - or np.unique(ranking).size != ranking.size - ): - raise ValueError("HVG plotting arrays are malformed") - selected = np.zeros(shape, dtype=bool) - selected[ranking[:selected_count]] = True - mean_nonzero = np.divide( - normed_tot, - normed_n, - out=np.zeros_like(normed_tot), - where=normed_n != 0, - ) - from ..plotting import highly_variable_features - - plot = highly_variable_features( - mean_nonzero=mean_nonzero, - corrected_variance=corrected_variance, - n_cells=normed_n, - selected=selected, - show=False, - ) - plot.axes["highly_variable_features"].set_title( - f"{_hvg_ranking_label(ranking_mode)}\n{selected_count:,} selected genes" - ) - plot.provenance.extras.update( - { - "assay": assay_name, - "diagnostic_artifact_id": model.artifactId, - "ranking_mode": ranking_mode, - "selected_feature_count": selected_count, - } - ) - _save_plot(plot, plot_dir / filename) - plots[plot_name] = f"plots/{filename}" - except Exception as exc: - notes.append(f"{plot_name}: {type(exc).__name__}: {exc}") - return plots, notes +MAX_TABLE_COLUMNS = 7 -def _collect_default_feature_inventories( - store: DataStore, - preprocessing_plan: Mapping[str, Any], -) -> list[dict[str, Any]]: - inventories: list[dict[str, Any]] = [] - for assay_plan in _mappings(preprocessing_plan.get("assays")): - assay_name = str(assay_plan.get("assay") or "") - parameters = _mapping(assay_plan.get("featureParameters")) - inventory = _mapping(parameters.get("defaultFeatureInventory")) - if not inventory: - continue - feature_column = str(inventory.get("featureColumn") or "") - blacklist = str(inventory.get("blacklist") or "") - if not assay_name or not feature_column or not blacklist: - raise ValueError("Scarf default feature inventory is incomplete") - assay = store.get_assay(assay_name) - if feature_column not in assay.feats.columns: - raise ValueError( - f"Scarf default feature column {feature_column!r} is unavailable " - f"for assay {assay_name!r}" - ) - names = [str(value) for value in assay.feats.fetch_all(feature_column)] - try: - compiled = re.compile(blacklist.upper()) - except re.error as exc: - raise ValueError("Scarf default feature blacklist is invalid") from exc - matched = sorted( - (name for name in names if compiled.match(name.upper()) is not None), - key=lambda value: (value.casefold(), value), - ) - expected_total = inventory.get("totalFeatures") - expected_matches = inventory.get("matchCount") - if isinstance(expected_total, int) and expected_total != len(names): - raise ValueError( - f"Scarf default feature inventory for {assay_name!r} has stale " - "total feature evidence" - ) - if isinstance(expected_matches, int) and expected_matches != len(matched): - raise ValueError( - f"Scarf default feature inventory for {assay_name!r} has stale " - "blacklist match evidence" - ) - inventories.append( - { - **inventory, - "assay": assay_name, - "appliedToSelectedRepresentation": ( - parameters.get("useScarfDefaultBlacklist") is True - ), - "selectedExcludeFamilies": _text_values( - parameters.get("excludeFamilies") - ), - "selectedProtectFamilies": _text_values( - parameters.get("protectFamilies") - ), - "matchedFeatures": matched, - } - ) - return inventories +MAX_INLINE_LEAVES = 12 REPORT_STYLES = """ @@ -1847,76 +708,6 @@ def _collect_default_feature_inventories( """ -def _present(value: Any) -> bool: - return value is not None and value != "" and value != [] and value != {} - - -def _label(value: Any) -> str: - text = str(value).replace("_", " ").strip() - words: list[str] = [] - for index, character in enumerate(text): - if ( - index - and character.isupper() - and not text[index - 1].isupper() - and text[index - 1] != " " - ): - words.append(" ") - words.append(character) - text = "".join(words) - return text[:1].upper() + text[1:] - - -def _scalar(value: Any) -> str: - if value is None or value == "": - return "Not provided" - if isinstance(value, bool): - return "Yes" if value else "No" - if isinstance(value, int): - return f"{value:,}" - if isinstance(value, float): - if value == 0: - return "0" - if abs(value) < 0.001 or abs(value) >= 10_000: - return f"{value:.3g}" - return f"{value:.3f}".rstrip("0").rstrip(".") - return str(value) - - -def _mapping(value: Any) -> dict[str, Any]: - return dict(value) if isinstance(value, Mapping) else {} - - -def _mappings(value: Any) -> list[dict[str, Any]]: - if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)): - return [] - return [dict(item) for item in value if isinstance(item, Mapping)] - - -def _is_sequence(value: Any) -> bool: - return isinstance(value, Sequence) and not isinstance( - value, (str, bytes, bytearray) - ) - - -def _is_leaf(value: Any) -> bool: - return not isinstance(value, Mapping) and not _is_sequence(value) - - -def _is_simple(value: Any) -> bool: - if _is_leaf(value): - return True - return _is_sequence(value) and all(_is_leaf(item) for item in value) - - -def _is_mapping_sequence(value: Any) -> bool: - return ( - _is_sequence(value) - and bool(value) - and all(isinstance(item, Mapping) for item in value) - ) - - def _chip(text: str) -> str: escaped = html.escape(text) if len(text) > MAX_CHIP_LENGTH: @@ -2085,162 +876,19 @@ def _table( ) -def _latest(reports: Mapping[str, Any], agent_name: str) -> dict[str, Any]: - values = reports.get(agent_name) - if isinstance(values, Mapping): - return dict(values) - if isinstance(values, Sequence) and not isinstance(values, (str, bytes, bytearray)): - for value in reversed(values): - if isinstance(value, Mapping): - return dict(value) - return {} - - -def _render_plots( - plots: Mapping[str, str], - notes: Sequence[str], - *, - order: Sequence[str] | None = None, - titles: Mapping[str, tuple[str, str]] | None = None, - show_provenance: bool = True, - show_notes: bool = True, - empty_message: str | None = None, -) -> str: - plot_titles = { - "umapClusters": ( - "Final UMAP by cluster", - "The selected final representation, colored by final cluster.", - ), - "markerHeatmap": ( - "Marker heatmap", - "Marker-feature patterns across the final clusters.", - ), - "markerDotplot": ( - "Marker dot plot", - "A bounded expression summary for exact exported marker features.", - ), - "clusterComposition": ( - "Cluster composition", - "The relative size of each cluster in the final cell selection.", - ), - "clusterConnectivity": ( - "Cluster connectivity", - "Connectivity between final clusters in the selected graph.", - ), - "qcDistributions": ( - "QC distributions after the selected policy", - "Retained-cell distributions with the selected profile's cutoff annotations.", - ), - "qcDistributionsBeforeFiltering": ( - "QC distributions before filtering", - "Input-cell distributions with the selected profile's cutoff annotations.", - ), - "hvgGlobal": ( - "Global HVG diagnostic", - "Mean-variance evidence with genes selected by the global ranking highlighted.", - ), - "hvgBatchAware": ( - "Group-aware HVG diagnostic", - "Mean-variance evidence with genes selected for recurrence across technical groups highlighted.", - ), - "doubletEmbedding": ( - "Advisory doublet scores", - "The final embedding colored by non-removing doublet evidence.", - ), - } - if titles is not None: - plot_titles.update(titles) - plot_order = ( - list(order) - if order is not None - else [ - "umapClusters", - *(name for name in plots if name.startswith("nativeUmap")), - "markerHeatmap", - "markerDotplot", - "clusterComposition", - "clusterConnectivity", - "qcDistributionsBeforeFiltering", - "qcDistributions", - *(name for name in plots if name.startswith("qcDistributionDerived")), - "hvgGlobal", - "hvgBatchAware", - "doubletEmbedding", - *(name for name in plots if name.startswith("doubletDistribution")), - *plots, - ] - ) - figures: list[str] = [] - for name in dict.fromkeys(plot_order): - source = plots.get(name) - if source is None: - continue - if name.startswith("nativeUmap"): - assay = name.removeprefix("nativeUmap") or "assay" - title = f"{assay} native UMAP" - caption = f"The finalized native {assay} representation and clusters." - elif name.startswith("doubletDistribution"): - title = "Advisory doublet-score distribution" - caption = ( - "Capture-aware doublet evidence retained as flags without removal." - ) - elif name.startswith("qcDistributionDerived"): - title = "Derived QC metric distribution" - caption = "An immutable feature-family QC metric on the selected cell axis." - else: - title, caption = plot_titles.get( - name, (_label(name), "A finalized Scarf analysis plot.") - ) - escaped_source = html.escape(source, quote=True) - plot_class = ' class="primary"' if name == "umapClusters" else "" - provenance_markup = "" - if show_provenance: - provenance = html.escape(source + ".json", quote=True) - provenance_markup = f' Plot provenance' - figures.append( - f"" - f'' - f"
    {html.escape(title)}
    " - f"{html.escape(caption)}{provenance_markup}
    " - ) - if not figures: - if empty_message is None: - plot_markup = ( - '

    No plots could be rendered. The structured ' - "analysis remains available below. Install Scarf with the " - "extra dependency group to enable plotting.

    " - ) - else: - plot_markup = ( - f'

    {html.escape(empty_message)}

    ' - ) - else: - plot_markup = f'
    {"".join(figures)}
    ' - note_markup = "" - if show_notes and notes: - note_markup = ( - "
    Plot availability notes" - '
      ' - + "".join(f"
    • {html.escape(note)}
    • " for note in notes) - + "
    " - ) - return plot_markup + note_markup - - -def _render_clusters(cluster_counts: Mapping[str, int]) -> str: - if not cluster_counts: - return '

    No final cluster counts were available.

    ' - maximum = max(cluster_counts.values(), default=1) or 1 - return "".join( - '
    ' - f"Cluster {html.escape(str(label))}" - '' - f'' - "" - f"{count:,}
    " - for label, count in cluster_counts.items() - ) +def _render_clusters(cluster_counts: Mapping[str, int]) -> str: + if not cluster_counts: + return '

    No final cluster counts were available.

    ' + maximum = max(cluster_counts.values(), default=1) or 1 + return "".join( + '
    ' + f"Cluster {html.escape(str(label))}" + '' + f'' + "" + f"{count:,}
    " + for label, count in cluster_counts.items() + ) def _parameter_rows(parameter: Mapping[str, Any]) -> list[dict[str, Any]]: @@ -2411,1332 +1059,152 @@ def _render_timeline( "name": name, "scope": artifact.get("scope"), "assay": artifact.get("assay"), - "kind": artifact.get("kind"), - "artifact ID": artifact.get("artifactId"), - } - ) - return ( - "

    Stage attempts

    " - + _table( - attempts, - columns=( - "stage", - "status", - "durationSeconds", - "actions", - "reportCount", - "artifactCount", - "parentAttempts", - "questionIds", - "noteCount", - "errorType", - ), - ) - + '

    Stage artifact inventory

    ' - + _table(artifacts, empty="No stage artifacts were recorded.") - + "
    " - + '

    Resume lineage

    ' - + _table( - resumes, - columns=( - "resumeId", - "answeredStage", - "answeredAttemptId", - "questionIds", - ), - empty="No resume was required.", - ) - + "
    " - ) - - -def _text_values(value: Any) -> list[str]: - if not _is_sequence(value): - return [] - return [str(item).strip() for item in value if _is_leaf(item) and str(item).strip()] - - -def _specific_references(values: Sequence[str]) -> list[str]: - unique = list(dict.fromkeys(values)) - return [ - value - for value in unique - if not any( - value.casefold() != other.casefold() - and value.casefold() in other.casefold() - for other in unique - ) - ] - - -def _brief_text(value: Any, *, max_length: int = 240) -> str: - if not isinstance(value, str): - return "" - text = " ".join(value.split()) - text = re.sub( - r"\b[0-9a-f]{64}\b", - "recorded result", - text, - flags=re.IGNORECASE, - ) - text = re.sub( - r"\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b", - "recorded value", - text, - flags=re.IGNORECASE, - ) - text = re.sub( - r"\b([A-Za-z][A-Za-z0-9]*)_id\b", - lambda match: _label(match.group(1)).lower(), - text, - ) - if not text: - return "" - first_sentence = re.split(r"(?<=[.!?])\s+", text, maxsplit=1)[0] - if len(first_sentence) <= max_length: - return first_sentence - shortened = first_sentence[: max_length - 3].rsplit(" ", 1)[0] - return f"{shortened or first_sentence[: max_length - 3]}..." - - -def _format_text_list(values: Sequence[str]) -> str: - items = [value for value in dict.fromkeys(values) if value] - if not items: - return "" - if len(items) == 1: - return items[0] - if len(items) == 2: - return f"{items[0]} and {items[1]}" - return f"{', '.join(items[:-1])}, and {items[-1]}" - - -def _study_overview( - payload: Mapping[str, Any], -) -> tuple[str, list[str], list[str]]: - reports = _mapping(payload.get("reports")) - request = _mapping(payload.get("request")) - enrichment = _latest(reports, "data_enrichment") - study = _mapping(enrichment.get("studyContextSummary")) - objective = "" - for candidate in ( - study.get("studyObjective"), - request.get("studyObjective"), - study.get("studyContext"), - request.get("studyContext"), - ): - objective = _brief_text(candidate) - if objective: - break - return ( - objective or "The automated analysis completed successfully.", - _specific_references(_text_values(study.get("organismReferences"))), - _specific_references(_text_values(study.get("tissueReferences"))), - ) - - -def _biological_source( - organisms: Sequence[str], - tissues: Sequence[str], -) -> str: - organism = _format_text_list(organisms) - tissue = _format_text_list(tissues) - if organism and tissue: - return f"{organism} material from {tissue}" - if organism: - return f"{organism} biological material" - if tissue: - return f"biological material from {tissue}" - return "" - - -def _assay_label(value: Any) -> str: - labels = { - "RNA": "RNA", - "ATAC": "chromatin accessibility", - "ADT": "protein abundance", - "HTO": "sample tags", - } - text = str(value or "").strip() - return labels.get(text.upper(), _label(text).lower()) if text else "" - - -def _report_assays(plan: Mapping[str, Any]) -> list[str]: - assays: list[str] = [] - for assay in _mappings(plan.get("assays")): - label = _assay_label(assay.get("assayType") or assay.get("assay")) - if label and label not in assays: - assays.append(label) - return assays - - -def _render_metrics(metrics: Sequence[tuple[str, Any]]) -> str: - markup = "".join( - '' - f'{html.escape(label)}' - f'{html.escape(_scalar(value))}' - for label, value in metrics - if _present(value) - ) - return f'
    {markup}
    ' if markup else "" - - -def _render_report_navigation(active_page: str) -> str: - links = ( - ("index", "index.html", "Report home"), - ("analysis", "analysis.html", "Analysis summary"), - ("technical", "technical.html", "Technical details"), - ) - return ''.format( - "".join( - '{}'.format( - html.escape(path, quote=True), - ' aria-current="page"' if page == active_page else "", - html.escape(label), - ) - for page, path, label in links - ) - ) - - -def _render_report_shell( - *, - title: str, - active_page: str, - body: str, -) -> str: - return f""" - - - - - {html.escape(title)} - - - -
    - Nygen Analytics - {_render_report_navigation(active_page)} -
    -
    -{body} -
    - - - -""" - - -def _selected_qc_profile( - experimental: Mapping[str, Any], - cell_qc: Mapping[str, Any], -) -> dict[str, Any]: - profiles = _mappings(experimental.get("qcProfiles")) - profile_id = cell_qc.get("profileId") - if profile_id: - for profile in profiles: - if profile.get("profileId") == profile_id: - return profile - return profiles[0] if len(profiles) == 1 else {} - - -def _feature_family_label(value: Any) -> str: - labels = { - "ribosomal": "ribosomal genes", - "ribosomalProtein": "ribosomal protein genes", - "mitochondrial": "mitochondrial genes", - "mitoribosomal": "mitoribosomal genes", - "sex": "sex-linked genes", - "sexLinked": "sex-linked genes", - "cellCycle": "cell-cycle genes", - "cellCycleCcn": "CCN-prefixed genes", - "hla": "HLA genes", - "h2": "H2 genes", - "histone": "histone genes", - } - text = str(value or "").strip() - return labels.get(text, _label(text).lower()) if text else "" - - -def _public_field_label(value: Any) -> str: - labels = { - "T2D": "T2D status", - "donor_id": "donor", - "library_id": "library", - "RNA_nCounts": "RNA counts", - "RNA_nFeatures": "detected genes", - "RNA_percentMito": "mitochondrial percentage", - "RNA_percentRibo": "ribosomal percentage", - "sample_id": "sample", - "sex": "sex", - "tissue": "tissue", - } - text = str(value or "").strip() - if not text: - return "" - if text in labels: - return labels[text] - return _label(text.removesuffix("_id")).lower() - - -def _tree_branch( - *, - label: str, - status: str, - state: str, - metrics: Sequence[str], - reason: str, -) -> dict[str, Any]: - return { - "label": label, - "status": status, - "state": state, - "metrics": list(metrics), - "reason": reason, - } - - -def _qc_profile_label(profile: Mapping[str, Any]) -> str: - labels = { - "retainWithFlags": "Retain cells with quality flags", - "globalMad5": "Global quality threshold", - "captureMad5": "Per-library quality threshold", - "captureMad3Sensitivity": "Stricter per-library sensitivity check", - } - registered = str(profile.get("registeredProfile") or "") - if registered in labels: - return labels[registered] - profile_id = str(profile.get("profileId") or "") - for name, label in labels.items(): - if name in profile_id: - return label - action = str(profile.get("action") or "") - return { - "skip": "Retain reviewed cells", - "globalGaussian": "Global quality threshold", - "sampleMad": "Per-sample quality threshold", - "registeredMad": "Registered quality threshold", - }.get(action, "Quality-control option") - - -def _qc_tree_stage( - experimental: Mapping[str, Any], - plan: Mapping[str, Any], - total_cells: int, -) -> dict[str, Any] | None: - decision = _mapping(experimental.get("decision")) - cell_qc = _mapping(plan.get("cellQc")) - if not cell_qc: - cell_qc = _mapping(decision.get("cellQc")) - if not cell_qc: - cell_qc = _mapping(experimental.get("cellQc")) - if not cell_qc: - return None - profiles = _mappings(experimental.get("qcProfiles")) - if not profiles: - profiles = [ - { - **cell_qc, - "activeCells": total_cells or None, - "retainedCells": total_cells or None, - } - ] - selected_id = cell_qc.get("profileId") - selected_name = cell_qc.get("registeredProfile") - branches: list[dict[str, Any]] = [] - for profile in profiles: - selected = bool( - (selected_id and profile.get("profileId") == selected_id) - or ( - not selected_id - and selected_name - and profile.get("registeredProfile") == selected_name - ) - or (len(profiles) == 1) - ) - active = profile.get("activeCells") - retained = profile.get("retainedCells") - metrics: list[str] = [] - removed: int | None = None - if isinstance(active, int) and isinstance(retained, int) and active: - retained_percent = retained / active * 100 - percent_text = "100%" if retained == active else f"{retained_percent:.2f}%" - metrics.append( - f"Retained {retained:,} of {active:,} cells ({percent_text})" - ) - removed = active - retained - if selected: - reason = ( - "Selected because it preserved the reviewed dataset without " - "unsupported filtering." - if removed == 0 - else "Selected as the best-supported balance of cell retention and " - "quality control." - ) - elif removed == 0: - reason = ( - "Not selected because it retained the same cells while adding a " - "filtering rule that was not needed." - ) - elif removed is not None: - reason = ( - f"Not selected because it removed {removed:,} additional cells " - "without stronger support." - ) - else: - reason = "Evaluated but not selected for the final cell set." - branches.append( - _tree_branch( - label=_qc_profile_label(profile), - status="Selected" if selected else "Not selected", - state="selected" if selected else "alternative", - metrics=metrics, - reason=reason, - ) - ) - branches.sort(key=lambda branch: branch["state"] != "selected") - return { - "question": "Which cells should be retained?", - "description": ( - "The workflow compared the registered quality-control choices before " - "changing the cell set." - ), - "branches": branches, - } - - -def _feature_tree_stage( - plan: Mapping[str, Any], - inventories: Sequence[Mapping[str, Any]], -) -> dict[str, Any] | None: - assay_plans = _mappings(plan.get("assays")) - selected_assay = next( - (assay for assay in assay_plans if assay.get("graphEligible") is True), - assay_plans[0] if assay_plans else {}, - ) - if not selected_assay: - return None - feature_method = str(selected_assay.get("featureMethod") or "none") - feature_labels = { - "hvg": "Most variable genes", - "prevalentPeaks": "Frequently observed chromatin regions", - "panel": "Predefined feature panel", - "none": "No feature subset", - } - parameters = _mapping(selected_assay.get("featureParameters")) - metrics: list[str] = [] - top_n = parameters.get("topN") - min_cells = parameters.get("minCells") - if isinstance(top_n, int): - metrics.append(f"Selected {top_n:,} features") - if isinstance(min_cells, int): - metrics.append(f"Required presence in at least {min_cells:,} cells") - excluded = [ - _feature_family_label(item) - for item in _text_values(parameters.get("excludeFamilies")) - ] - protected = [ - _feature_family_label(item) - for item in _text_values(parameters.get("protectFamilies")) - ] - if excluded: - metrics.append(f"Excluded {_format_text_list(excluded)}") - if protected: - metrics.append(f"Kept {_format_text_list(protected)} eligible") - inventory = _default_inventory_for_assay( - inventories, - str(selected_assay.get("assay") or ""), - ) - if inventory: - match_count = inventory.get("matchCount") - total_features = inventory.get("totalFeatures") - if isinstance(match_count, int) and isinstance(total_features, int): - metrics.append( - f"Scarf default reference matched {match_count:,} of " - f"{total_features:,} genes" - ) - metrics.append( - "Complete Scarf default blacklist applied: " - + ( - "yes" - if inventory.get("appliedToSelectedRepresentation") is True - else "no" - ) - ) - return { - "question": "Which measurements should shape the cell map?", - "description": ( - "The selected feature policy controls which biological variation can " - "influence the map." - ), - "branches": [ - _tree_branch( - label=feature_labels.get( - feature_method, - "Analysis-specific feature set", - ), - status="Selected", - state="selected", - metrics=metrics, - reason=( - "Selected to emphasize informative variation while limiting " - "known unwanted signal." - ), - ) - ], - } - - -def _batch_tree_stage( - experimental: Mapping[str, Any], - parameter: Mapping[str, Any], - final: Mapping[str, Any], - decisions: Mapping[str, Any], -) -> dict[str, Any] | None: - decision = _mapping(experimental.get("decision")) - batch_plan = _mapping(decision.get("batchCorrection")) - if not batch_plan: - return None - native_analyses = _mappings(final.get("nativeAnalyses")) - if final.get("graphMethod") == "native" and final.get("primaryAssay"): - selected_native = [ - item - for item in native_analyses - if item.get("assay") == final.get("primaryAssay") - ] - else: - selected_native = native_analyses - adjustment_applied = any( - _present(item.get("batchCorrection")) for item in selected_native - ) - native_candidate, harmony_candidate = _harmony_candidate_pair(parameter, final) - harmony_executed = _harmony_completed(native_candidate) and _harmony_completed( - harmony_candidate - ) - degraded = _degraded_protected_columns(native_candidate, harmony_candidate) - safety = _mappings(experimental.get("batchSafety")) - unsafe = [item for item in safety if item.get("status") == "unsafe"] - coefficients = [ - _public_field_label(item.get("coefficient")) - for item in unsafe - if _public_field_label(item.get("coefficient")) - ] - coefficients = list(dict.fromkeys(coefficients)) - remaining_capacity = [ - _mapping(item.get("estimability")).get("estimableDf") for item in unsafe - ] - adjustment_metrics: list[str] = [] - if coefficients: - adjustment_metrics.append( - f"Protected comparisons at risk: {_format_text_list(coefficients)}" - ) - if remaining_capacity and all(value == 0 for value in remaining_capacity): - adjustment_metrics.append("Remaining comparison capacity: 0") - if harmony_candidate: - harmony_parameters = _mapping(harmony_candidate.get("parameters")) - adjustment_metrics.append( - "Matched parameters: " - f"{_scalar(harmony_parameters.get('dimensions'))} dimensions, " - f"{_scalar(harmony_parameters.get('neighborsK'))} neighbors, " - f"resolution {_scalar(harmony_parameters.get('leidenResolution'))}" - ) - if harmony_executed: - adjustment_metrics.insert(0, "Run status: completed diagnostic") - native_metrics = _mapping(native_candidate.get("metrics")) - harmony_metrics = _mapping(harmony_candidate.get("metrics")) - native_batch = _mapping(native_metrics.get("batchMixing")) - harmony_batch = _mapping(harmony_metrics.get("batchMixing")) - for column in dict.fromkeys([*native_batch, *harmony_batch]): - adjustment_metrics.append( - f"{_public_field_label(column).capitalize()} mixing: " - f"{_score_transition(native_batch.get(column), harmony_batch.get(column))}" - ) - if degraded: - adjustment_metrics.append( - "Protected evidence degraded: " + _format_text_list(degraded) - ) - correction_license = _active_decision(decisions, "correctionLicense") - diagnostic_only = str(correction_license.get("selectedOptionId") or "").endswith( - "unsafeConfounded" - ) - if diagnostic_only: - adjustment_metrics.append("Selection license: diagnostic only") - action = str(batch_plan.get("action") or "") - if adjustment_applied: - unadjusted_state = "alternative" - adjusted_state = "selected" - unadjusted_status = "Not selected" - adjusted_status = "Selected" - unadjusted_reason = ( - "The adjusted result provided stronger supported comparability." - ) - adjusted_reason = ( - "Selected because it improved technical comparability while preserving " - "the biological structure being studied." - ) - else: - unadjusted_state = "selected" - adjusted_state = ( - "rejected" - if harmony_executed - else ("blocked" if action in {"unsafe", "skip"} else "alternative") - ) - unadjusted_status = "Selected" - adjusted_status = ( - "Run diagnostically; rejected" - if harmony_executed - else ("Not run" if adjusted_state == "blocked" else "Not selected") - ) - unadjusted_reason = ( - "Selected after the matched diagnostic retained more of the protected " - "biological structure." - if harmony_executed - else "Selected because adjustment was not shown to improve the data safely." - ) - adjusted_reason = ( - "Rejected because protected evidence degraded for " - f"{_format_text_list(degraded)}" - + ( - " and the design allowed diagnostic use only." - if diagnostic_only - else "." - ) - if harmony_executed and degraded - else ( - "Run as a matched diagnostic but not selected." - if harmony_executed - else ( - "Not run because technical and biological differences could " - "not be separated safely." - if adjusted_state == "blocked" - else "Tested but did not provide a safer improvement over the " - "unadjusted data." - ) - ) - ) - return { - "question": "Should technical variation be adjusted?", - "description": ( - "Adjustment was accepted only if it improved comparability without " - "removing protected biological differences." - ), - "branches": [ - _tree_branch( - label="Use the unadjusted representation", - status=unadjusted_status, - state=unadjusted_state, - metrics=[ - "Final representation: native", - "Protected biological comparisons retained", - ], - reason=unadjusted_reason, - ), - _tree_branch( - label="Apply Harmony batch adjustment", - status=adjusted_status, - state=adjusted_state, - metrics=adjustment_metrics, - reason=adjusted_reason, - ), - ], - } - - -def _selected_parameter_context( - parameter: Mapping[str, Any], - final: Mapping[str, Any], -) -> tuple[dict[str, Any], list[dict[str, Any]], dict[str, Any]]: - assay_reports = _mapping(parameter.get("assayReports")) - preferred_assay = str( - parameter.get("graphAssay") - or final.get("primaryAssay") - or parameter.get("fromAssay") - or "" - ) - report = _mapping(assay_reports.get(preferred_assay)) - if not report and assay_reports: - report = _mapping(next(iter(assay_reports.values()))) - if not report: - report = dict(parameter) - evaluations = _mappings(report.get("evaluations")) - recommended = _mapping(parameter.get("recommendedByAssay")) - selected_id = ( - recommended.get(preferred_assay) - or report.get("recommendedCandidateId") - or parameter.get("recommendedCandidateId") - ) - selected = next( - ( - evaluation - for evaluation in evaluations - if evaluation.get("candidateId") == selected_id - ), - {}, - ) - return report, evaluations, selected - - -def _harmony_candidate_pair( - parameter: Mapping[str, Any], - final: Mapping[str, Any], -) -> tuple[dict[str, Any], dict[str, Any]]: - _report, evaluations, _selected = _selected_parameter_context(parameter, final) - for harmony in reversed(evaluations): - harmony_parameters = _mapping(harmony.get("parameters")) - if harmony_parameters.get("useHarmony") is not True: - continue - signature = { - key: value - for key, value in harmony_parameters.items() - if key not in {"candidateId", "useHarmony"} - } - native_candidates = [ - evaluation - for evaluation in evaluations - if _mapping(evaluation.get("parameters")).get("useHarmony") is False - and { - key: value - for key, value in _mapping(evaluation.get("parameters")).items() - if key not in {"candidateId", "useHarmony"} - } - == signature - ] - if not native_candidates: - continue - expected_native_id = str(harmony.get("candidateId") or "").replace( - "_correction_harmony", - "_correction_native", - ) - native = next( - ( - evaluation - for evaluation in native_candidates - if evaluation.get("candidateId") == expected_native_id - ), - native_candidates[-1], - ) - return native, harmony - return {}, {} - - -def _harmony_completed(evaluation: Mapping[str, Any]) -> bool: - return ( - evaluation.get("status") == "done" and evaluation.get("eligible") is not False - ) - - -def _score_transition(native: Any, harmony: Any) -> str: - if not isinstance(native, (int, float)) or isinstance(native, bool): - return "Not available" - if not isinstance(harmony, (int, float)) or isinstance(harmony, bool): - return "Not available" - delta = float(harmony) - float(native) - return f"{float(native):.3f} to {float(harmony):.3f} (change {delta:+.3f})" - - -def _harmony_metric_rows( - native: Mapping[str, Any], - harmony: Mapping[str, Any], -) -> list[dict[str, Any]]: - native_metrics = _mapping(native.get("metrics")) - harmony_metrics = _mapping(harmony.get("metrics")) - rows: list[dict[str, Any]] = [] - - def add( - category: str, - metric: str, - native_value: Any, - harmony_value: Any, - interpretation: str, - ) -> None: - delta = ( - float(harmony_value) - float(native_value) - if isinstance(native_value, (int, float)) - and not isinstance(native_value, bool) - and isinstance(harmony_value, (int, float)) - and not isinstance(harmony_value, bool) - else None - ) - rows.append( - { - "category": category, - "metric": metric, - "native": native_value, - "Harmony": harmony_value, - "change": delta, - "interpretation": interpretation, - } - ) - - native_batch = _mapping(native_metrics.get("batchMixing")) - harmony_batch = _mapping(harmony_metrics.get("batchMixing")) - for column in dict.fromkeys([*native_batch, *harmony_batch]): - add( - "Batch removal", - f"{_public_field_label(column)} mixing", - native_batch.get(column), - harmony_batch.get(column), - "Higher values indicate stronger mixing across the technical group.", - ) - - native_association = _mapping(native_metrics.get("technicalAssociation")) - harmony_association = _mapping(harmony_metrics.get("technicalAssociation")) - for column in dict.fromkeys([*native_association, *harmony_association]): - add( - "Technical association", - _public_field_label(column), - native_association.get(column), - harmony_association.get(column), - "Lower values indicate less association with the technical group.", - ) - - native_biology = _mapping(native_metrics.get("biologicalPreservation")) - harmony_biology = _mapping(harmony_metrics.get("biologicalPreservation")) - for column in dict.fromkeys([*native_biology, *harmony_biology]): - native_scores = _mapping(native_biology.get(column)) - harmony_scores = _mapping(harmony_biology.get(column)) - for name in dict.fromkeys([*native_scores, *harmony_scores]): - add( - "Protected biology", - f"{_public_field_label(column)} {_label(name)}", - native_scores.get(name), - harmony_scores.get(name), - "Protected evidence should not decrease materially.", - ) - - for key, label, interpretation in ( - ( - "crossUnitSupport", - "Cross-sample support", - "Higher values indicate broader support across study units.", - ), - ( - "markerCoherence", - "Marker coherence", - "Higher values indicate more groups with coherent markers.", - ), - ( - "markerSpecificityMedian", - "Median marker specificity", - "Higher values indicate more group-specific markers.", - ), - ( - "clusterConnectivity", - "Cluster connectivity", - "Higher values indicate better connected groups.", - ), - ( - "membershipStrengthMean", - "Mean membership strength", - "Higher values indicate more stable cluster membership.", - ), - ( - "doubletHighScoreConcentration", - "Doublet-score concentration", - "Lower values indicate less concentration of high doublet scores.", - ), - ): - if key in native_metrics or key in harmony_metrics: - add( - "Supporting diagnostic", - label, - native_metrics.get(key), - harmony_metrics.get(key), - interpretation, - ) - return rows - - -def _degraded_protected_columns( - native: Mapping[str, Any], - harmony: Mapping[str, Any], - *, - tolerance: float = 0.05, -) -> list[str]: - native_biology = _mapping( - _mapping(native.get("metrics")).get("biologicalPreservation") - ) - harmony_biology = _mapping( - _mapping(harmony.get("metrics")).get("biologicalPreservation") - ) - degraded: list[str] = [] - for column, raw_native in native_biology.items(): - native_scores = _mapping(raw_native) - harmony_scores = _mapping(harmony_biology.get(column)) - if any( - isinstance(value, (int, float)) - and not isinstance(value, bool) - and isinstance(harmony_scores.get(name), (int, float)) - and not isinstance(harmony_scores.get(name), bool) - and float(harmony_scores[name]) < float(value) - tolerance - for name, value in native_scores.items() - ): - degraded.append(_public_field_label(column)) - return degraded - - -def _active_decision( - decisions: Mapping[str, Any], - decision_id: str, -) -> dict[str, Any]: - return _mapping(decisions.get(decision_id)) - - -def _common_parameter( - evaluations: Sequence[Mapping[str, Any]], - key: str, -) -> Any: - values = [ - _mapping(evaluation.get("parameters")).get(key) - for evaluation in evaluations - if _present(_mapping(evaluation.get("parameters")).get(key)) - ] - return Counter(values).most_common(1)[0][0] if values else None - - -def _parameter_options( - evaluations: Sequence[Mapping[str, Any]], - key: str, - filters: Mapping[str, Any], -) -> list[dict[str, Any]]: - by_value: dict[Any, dict[str, Any]] = {} - for evaluation in evaluations: - if evaluation.get("status") != "done" or evaluation.get("eligible") is False: - continue - parameters = _mapping(evaluation.get("parameters")) - if any(parameters.get(name) != value for name, value in filters.items()): - continue - value = parameters.get(key) - if not _present(value): - continue - current = by_value.get(value) - current_metrics = _mapping(current.get("metrics")) if current else {} - metrics = _mapping(evaluation.get("metrics")) - if current is None or len(metrics) > len(current_metrics): - by_value[value] = dict(evaluation) - return [ - by_value[value] - for value in sorted( - by_value, - key=lambda item: (not isinstance(item, (int, float)), item), - ) - ] - - -def _candidate_metrics( - evaluation: Mapping[str, Any], - *, - include_stability: bool = False, -) -> list[str]: - metrics = _mapping(evaluation.get("metrics")) - values: list[str] = [] - clusters = metrics.get("nClusters") - separation = metrics.get("graphSilhouetteMedian") - smallest = metrics.get("minClusterCells") - if isinstance(clusters, int): - values.append(f"Cell groups: {clusters:,}") - if isinstance(separation, (int, float)): - values.append(f"Separation score: {float(separation):.3f}") - if isinstance(smallest, int): - values.append(f"Smallest group: {smallest:,} cells") - if include_stability: - seed = metrics.get("seedStability") - subsample = metrics.get("subsampleStability") - marker = metrics.get("markerCoherence") - support = metrics.get("crossUnitSupport") - if isinstance(seed, (int, float)): - values.append(f"Repeat-run stability: {float(seed):.3f}") - if isinstance(subsample, (int, float)): - values.append(f"Subsample stability: {float(subsample):.3f}") - if isinstance(marker, (int, float)): - values.append(f"Marker coherence: {float(marker):.3f}") - if isinstance(support, (int, float)): - values.append(f"Cross-sample support: {float(support):.3f}") - return values - - -def _parameter_tree_stage( - *, - question: str, - description: str, - options: Sequence[Mapping[str, Any]], - parameter_name: str, - selected_value: Any, - label: Any, - selected_reason: str, - alternative_reason: Any, - include_stability: bool = False, -) -> dict[str, Any] | None: - if not options: - return None - branches: list[dict[str, Any]] = [] - for evaluation in options: - value = _mapping(evaluation.get("parameters")).get(parameter_name) - selected = value == selected_value - branches.append( - _tree_branch( - label=str(label(value)), - status="Selected" if selected else "Not selected", - state="selected" if selected else "alternative", - metrics=_candidate_metrics( - evaluation, - include_stability=include_stability and selected, - ), - reason=( - selected_reason - if selected - else str(alternative_reason(value, evaluation)) - ), - ) - ) - return { - "question": question, - "description": description, - "branches": branches, - } - - -def _parameter_tree_stages( - parameter: Mapping[str, Any], - final: Mapping[str, Any], -) -> tuple[list[dict[str, Any]], dict[str, Any]]: - _report, evaluations, selected = _selected_parameter_context(parameter, final) - if not evaluations or not selected: - return [], selected - selected_parameters = _mapping(selected.get("parameters")) - selected_dimensions = selected_parameters.get("dimensions") - selected_neighbors = selected_parameters.get("neighborsK") - selected_resolution = selected_parameters.get("leidenResolution") - selected_harmony = selected_parameters.get("useHarmony") - common_neighbors = _common_parameter(evaluations, "neighborsK") - common_resolution = _common_parameter(evaluations, "leidenResolution") - - dimension_options = _parameter_options( - evaluations, - "dimensions", - { - "neighborsK": common_neighbors, - "leidenResolution": common_resolution, - "useHarmony": selected_harmony, - }, - ) - neighbor_options = _parameter_options( - evaluations, - "neighborsK", - { - "dimensions": selected_dimensions, - "leidenResolution": common_resolution, - "useHarmony": selected_harmony, - }, - ) - resolution_options = _parameter_options( - evaluations, - "leidenResolution", - { - "dimensions": selected_dimensions, - "neighborsK": selected_neighbors, - "useHarmony": selected_harmony, - }, - ) - - def dimension_alternative(value: Any, _evaluation: Mapping[str, Any]) -> str: - if isinstance(value, (int, float)) and isinstance( - selected_dimensions, (int, float) - ): - if value > selected_dimensions: - return ( - "Not selected because the smaller representation retained " - "sufficient structure with less added noise." - ) - return "Not selected because it retained too little stable structure." - return "Evaluated but not selected." - - def neighbor_alternative(value: Any, _evaluation: Mapping[str, Any]) -> str: - if isinstance(value, (int, float)) and isinstance( - selected_neighbors, (int, float) - ): - if value < selected_neighbors: - return ( - "Provided finer local detail but produced smaller, less stable " - "groups." - ) - return "Smoothed across more cells and reduced useful local detail." - return "Evaluated but not selected." - - selected_metrics = _mapping(selected.get("metrics")) - selected_separation = selected_metrics.get("graphSilhouetteMedian") - - def resolution_alternative( - _value: Any, - evaluation: Mapping[str, Any], - ) -> str: - metrics = _mapping(evaluation.get("metrics")) - groups = metrics.get("nClusters") - separation = metrics.get("graphSilhouetteMedian") - if isinstance(groups, int) and isinstance(separation, (int, float)): - return ( - f"Produced {groups:,} groups with separation " - f"{float(separation):.3f}, weaker than the selected balance." - ) - if isinstance(selected_separation, (int, float)): - return ( - f"Did not match the selected separation score of " - f"{float(selected_separation):.3f}." + "kind": artifact.get("kind"), + "artifact ID": artifact.get("artifactId"), + } ) - return "Evaluated but not selected." - - stages = [ - stage - for stage in ( - _parameter_tree_stage( - question="How many variation patterns should be retained?", - description=( - "Dimensions are compressed patterns of gene variation used to " - "build the cell map." - ), - options=dimension_options, - parameter_name="dimensions", - selected_value=selected_dimensions, - label=lambda value: f"{int(value):,} dimensions", - selected_reason=( - "Selected as the smallest representation that retained a stable " - "cell map." - ), - alternative_reason=dimension_alternative, - ), - _parameter_tree_stage( - question="How local should each cell neighborhood be?", - description=( - "Smaller neighborhoods emphasize local detail; larger ones " - "produce broader smoothing." - ), - options=neighbor_options, - parameter_name="neighborsK", - selected_value=selected_neighbors, - label=lambda value: f"{int(value):,} nearest neighbors", - selected_reason=( - "Selected to balance local detail with stable cell-group sizes." - ), - alternative_reason=neighbor_alternative, + return ( + "

    Stage attempts

    " + + _table( + attempts, + columns=( + "stage", + "status", + "durationSeconds", + "actions", + "reportCount", + "artifactCount", + "parentAttempts", + "questionIds", + "noteCount", + "errorType", ), - _parameter_tree_stage( - question="How finely should cells be divided into groups?", - description=( - "Resolution controls whether the final map contains broader or " - "more finely divided cell groups." - ), - options=resolution_options, - parameter_name="leidenResolution", - selected_value=selected_resolution, - label=lambda value: f"Resolution {float(value):g}", - selected_reason=( - "Selected for the strongest supported separation, stability, " - "marker coherence, and group sizes." - ), - alternative_reason=resolution_alternative, - include_stability=True, + ) + + '

    Stage artifact inventory

    ' + + _table(artifacts, empty="No stage artifacts were recorded.") + + "
    " + + '

    Resume lineage

    ' + + _table( + resumes, + columns=( + "resumeId", + "answeredStage", + "answeredAttemptId", + "questionIds", ), + empty="No resume was required.", ) - if stage is not None and len(stage["branches"]) > 1 - ] - return stages, selected + + "
    " + ) -def _analysis_tree_stages(payload: Mapping[str, Any]) -> list[dict[str, Any]]: +def _study_overview( + payload: Mapping[str, Any], +) -> tuple[str, list[str], list[str]]: reports = _mapping(payload.get("reports")) - workflow_result = _mapping(payload.get("workflowResult")) - plan = _mapping(workflow_result.get("preprocessingPlan")) - final = _mapping(workflow_result.get("finalAnalysis")) - experimental = _latest(reports, "experimental_context") - parameter = _latest(reports, "parameter_tuning") - biology = _latest(reports, "biological_interpretation") - decisions = _mapping(payload.get("activeDecisions")) - inventories = _mappings(payload.get("defaultFeatureInventories")) - cluster_counts = _mapping(payload.get("clusterCounts")) - total_cells = sum(int(value) for value in cluster_counts.values()) - stages: list[dict[str, Any]] = [] - for stage in ( - _qc_tree_stage(experimental, plan, total_cells), - _feature_tree_stage(plan, inventories), + request = _mapping(payload.get("request")) + enrichment = _latest(reports, "data_enrichment") + study = _mapping(enrichment.get("studyContextSummary")) + objective = "" + for candidate in ( + study.get("studyObjective"), + request.get("studyObjective"), + study.get("studyContext"), + request.get("studyContext"), ): - if stage is not None: - stages.append(stage) - stages.extend(_hvg_tree_stages(_mapping(payload.get("hvgEvidence")))) - batch_stage = _batch_tree_stage(experimental, parameter, final, decisions) - if batch_stage is not None: - stages.append(batch_stage) - parameter_stages, selected = _parameter_tree_stages(parameter, final) - stages.extend(parameter_stages) - - interpretations = _mappings(biology.get("clusterInterpretations")) - final_metrics = [f"Cells analyzed: {total_cells:,}"] if total_cells else [] - final_metrics.extend(_candidate_metrics(selected, include_stability=True)) - if not selected and cluster_counts: - final_metrics.append(f"Cell groups: {len(cluster_counts):,}") - stages.append( - { - "question": "Which result became the final analysis?", - "description": ( - "Only the selected branch was carried into visualization and marker " - "analysis." - ), - "branches": [ - _tree_branch( - label=( - f"{len(cluster_counts):,} cell groups" - if cluster_counts - else "Final selected cell map" - ), - status="Final result", - state="selected", - metrics=final_metrics, - reason=( - f"{len(interpretations):,} groups also received biological " - "interpretations." - if interpretations - else "No biological cell-type labels were inferred." - ), - ) - ], - } + objective = _brief_text(candidate) + if objective: + break + return ( + objective or "The automated analysis completed successfully.", + _specific_references(_text_values(study.get("organismReferences"))), + _specific_references(_text_values(study.get("tissueReferences"))), ) - return stages -def _tree_connector_svg( - branch_count: int, - selected_index: int, - stage_index: int, - *, - continues: bool, -) -> tuple[str, str]: - width = 1200 - centers = [(index + 0.5) * width / branch_count for index in range(branch_count)] - branch_marker_id = f"tree-branch-arrow-{stage_index}" - if branch_count == 1: - branch_paths = ( - f'' - ) - else: - branch_paths = ( - f'' - f'' - + "".join( - f'' - for center in centers - ) - ) - branch_definitions = ( - f'' - '' - ) - branch_svg = ( - '" - ) - if not continues: - return branch_svg, "" - selected_x = centers[selected_index] - selection_marker_id = f"tree-selection-arrow-{stage_index}" - selection_path = ( - f"M {selected_x:g} 0 V 28 H {width / 2:g} V 78" - if selected_x != width / 2 - else f"M {width / 2:g} 0 V 78" - ) - selection_definitions = ( - f'' - "" - ) - selection_svg = ( - '' +def _biological_source( + organisms: Sequence[str], + tissues: Sequence[str], +) -> str: + organism = _format_text_list(organisms) + tissue = _format_text_list(tissues) + if organism and tissue: + return f"{organism} material from {tissue}" + if organism: + return f"{organism} biological material" + if tissue: + return f"biological material from {tissue}" + return "" + + +def _report_assays(plan: Mapping[str, Any]) -> list[str]: + assays: list[str] = [] + for assay in _mappings(plan.get("assays")): + label = _assay_label(assay.get("assayType") or assay.get("assay")) + if label and label not in assays: + assays.append(label) + return assays + + +def _render_metrics(metrics: Sequence[tuple[str, Any]]) -> str: + markup = "".join( + '' + f'{html.escape(label)}' + f'{html.escape(_scalar(value))}' + for label, value in metrics + if _present(value) ) - return branch_svg, selection_svg + return f'
    {markup}
    ' if markup else "" -def _render_decision_tree(stages: Sequence[Mapping[str, Any]]) -> str: - if not stages: - return '

    No completed analysis decisions were available.

    ' - rendered: list[str] = [] - for stage_index, stage in enumerate(stages, start=1): - branches = _mappings(stage.get("branches")) - if not branches: - continue - selected_index = next( - ( - index - for index, branch in enumerate(branches) - if branch.get("state") == "selected" - ), - 0, - ) - branch_svg, selection_svg = _tree_connector_svg( - len(branches), - selected_index, - stage_index, - continues=stage_index < len(stages), - ) - branch_markup = "".join( - '
    '.format( - html.escape(str(branch.get("state") or "alternative"), quote=True) - ) - + '{}'.format( - html.escape(str(branch.get("status") or "Evaluated")) - ) - + f"

    {html.escape(str(branch.get('label') or 'Option'))}

    " - + ( - '
      ' - + "".join( - f"
    • {html.escape(metric)}
    • " - for metric in _text_values(branch.get("metrics")) - ) - + "
    " - if _present(branch.get("metrics")) - else "" - ) - + ( - f"

    {html.escape(_brief_text(branch.get('reason')))}

    " - if _brief_text(branch.get("reason")) - else "" - ) - + "
    " - for branch in branches - ) - rendered.append( - '
    ' - '
    ' - f"Decision {stage_index}" - f"{html.escape(str(stage.get('question') or 'Analysis decision'))}" - "
    " - + ( - f'

    {html.escape(_brief_text(stage.get("description")))}

    ' - if _brief_text(stage.get("description")) - else "" - ) - + branch_svg - + '
    '.format( - len(branches) +def _render_report_navigation(active_page: str) -> str: + links = ( + ("index", "index.html", "Report home"), + ("analysis", "analysis.html", "Analysis summary"), + ("technical", "technical.html", "Technical details"), + ) + return ''.format( + "".join( + '{}'.format( + html.escape(path, quote=True), + ' aria-current="page"' if page == active_page else "", + html.escape(label), ) - + branch_markup - + "
    " - + selection_svg - + "
    " + for page, path, label in links ) - return ( - '
    ' - + "".join(rendered) - + "
    " ) +def _render_report_shell( + *, + title: str, + active_page: str, + body: str, +) -> str: + return f""" + + + + + {html.escape(title)} + + + +
    + Nygen Analytics + {_render_report_navigation(active_page)} +
    +
    +{body} +
    + + + +""" + + def _render_selection_evidence(payload: Mapping[str, Any]) -> str: reports = _mapping(payload.get("reports")) workflow_result = _mapping(payload.get("workflowResult")) @@ -3809,33 +1277,6 @@ def _render_selection_evidence(payload: Mapping[str, Any]) -> str: ) -def _analysis_percent(value: Any) -> str: - if not isinstance(value, (int, float)) or isinstance(value, bool): - return "Not available" - return f"{float(value):.1%}" - - -def _analysis_number_range(values: Sequence[Any]) -> str: - numbers = [ - float(value) - for value in values - if isinstance(value, (int, float)) and not isinstance(value, bool) - ] - if not numbers: - return "Not available" - low = min(numbers) - high = max(numbers) - - def display(value: float) -> str: - if abs(value) >= 100: - return f"{value:,.0f}" - return f"{value:,.3f}".rstrip("0").rstrip(".") - - if low == high: - return display(low) - return f"{display(low)} to {display(high)}" - - def _render_evidence_choices(choices: Sequence[Mapping[str, Any]]) -> str: return '
    {}
    '.format( "".join( @@ -3907,13 +1348,6 @@ def _qc_profile_scope(profile: Mapping[str, Any]) -> str: return "Per-library thresholds" if len(groups) > 1 else "Global thresholds" -def _qc_resolved_bounds(profile: Mapping[str, Any]) -> list[dict[str, Any]]: - direct = _mappings(profile.get("resolvedBounds")) - if direct: - return direct - return _mappings(_mapping(profile.get("parameters")).get("resolvedBounds")) - - def _qc_flag_summary(profile: Mapping[str, Any]) -> list[str]: labels = ( ("nCounts:high", "High RNA count flags"), @@ -4366,18 +1800,6 @@ def _feature_family_counts( return counts -def _default_inventory_for_assay( - inventories: Sequence[Mapping[str, Any]], - assay: str, -) -> dict[str, Any]: - matches = [dict(value) for value in inventories if value.get("assay") == assay] - if len(matches) > 1: - raise ValueError( - f"Multiple Scarf default inventories found for assay {assay!r}" - ) - return matches[0] if matches else {} - - def _default_inventory_family_rows( inventory: Mapping[str, Any], ) -> list[dict[str, Any]]: @@ -4809,116 +2231,6 @@ def _render_batch_evidence( ) -def _hvg_ranking_label(value: Any) -> str: - return { - "global": "Global variability ranking", - "batchAware": "Group-aware variability ranking", - }.get(str(value or ""), "Variable-gene ranking") - - -def _hvg_tree_stages(evidence: Mapping[str, Any]) -> list[dict[str, Any]]: - rankings = _mappings(evidence.get("rankings")) - candidates = _mappings(evidence.get("candidateMetrics")) - default_counts = [ - int(value) - for value in evidence.get("scarfDefaultReferenceCounts", []) - if isinstance(value, int) - ] - selected_mode = evidence.get("selectedRankingMode") - selected_count = evidence.get("selectedFeatureCount") - stages: list[dict[str, Any]] = [] - if rankings: - ranking_branches: list[dict[str, Any]] = [] - for ranking in rankings: - selected = ranking.get("rankingMode") == selected_mode - ranking_branches.append( - _tree_branch( - label=_hvg_ranking_label(ranking.get("rankingMode")), - status="Selected" if selected else "Not selected", - state="selected" if selected else "alternative", - metrics=[ - "Mean library coverage: " - f"{_analysis_percent(ranking.get('meanTechnicalGroupCoverage'))}", - "Recurring in at least two libraries: " - f"{_analysis_percent(ranking.get('recurrentInTwoGroupsFraction'))}", - ], - reason=( - "Selected after the combined recurrence, default-overlap, " - "technical-association, and downstream-stability comparison." - if selected - else ( - "Not selected after the combined upstream and downstream " - "comparison." - ) - ), - ) - ) - if default_counts: - ranking_branches.append( - _tree_branch( - label="Exact Scarf-default blacklist reference", - status="Reference evaluated", - state="reviewed", - metrics=[ - "Executed set sizes: " - + ", ".join(f"{value:,}" for value in default_counts) - ], - reason=( - "Used as an exact comparison reference; it was not a " - "selectable ranking mode." - ), - ) - ) - stages.append( - { - "question": "How should highly variable genes be ranked?", - "description": ( - "The workflow compared a global variability ranking with a " - "ranking that emphasized recurrence across libraries." - ), - "branches": ranking_branches, - } - ) - if candidates: - count_branches: list[dict[str, Any]] = [] - for candidate in candidates: - count = candidate.get("featureCount") - if not isinstance(count, int): - continue - selected = count == selected_count - count_branches.append( - _tree_branch( - label=f"{count:,} variable genes", - status="Selected" if selected else "Not selected", - state="selected" if selected else "alternative", - metrics=[ - "Corrected variance captured: " - f"{_analysis_percent(candidate.get('varianceFraction'))}", - "Recurring across most libraries: " - f"{_analysis_percent(candidate.get('recurrentFraction'))}", - ], - reason=( - "Selected as the supported balance of captured variation, " - "reproducibility, and downstream stability." - if selected - else "Not selected after comparison with the supported set size." - ), - ) - ) - if count_branches: - stages.append( - { - "question": "How many highly variable genes should be used?", - "description": ( - "Registered focused, standard, and broad feature-set sizes " - "were all executed and compared." - ), - "branches": count_branches, - } - ) - return stages - - def _render_hvg_evidence(evidence: Mapping[str, Any]) -> str: rankings = _mappings(evidence.get("rankings")) candidates = _mappings(evidence.get("candidateMetrics")) @@ -5787,131 +3099,3 @@ def _render_technical_document(payload: Mapping[str, Any]) -> str: active_page="technical", body=body, ) - - -def _write_report_page(report_dir: Path, filename: str, document: str) -> Path: - destination = report_dir / filename - temporary = report_dir / f".{filename}.{uuid.uuid4().hex}.tmp" - try: - temporary.write_text(document, encoding="utf-8") - os.replace(temporary, destination) - finally: - temporary.unlink(missing_ok=True) - return destination - - -def generate_agent_report( - target: str | Path | DataStore, - workflow_run_id: str, - *, - workspace: str | None = None, -) -> Path: - """Generate a local HTML report for one completed automated workflow. - - The report directory contains a landing page, an analysis summary, and - technical details. The returned path points to the landing ``index.html``. - Existing derived report files may be replaced; immutable agent and - orchestration records are only read. - """ - root = _local_root(target) - resolved_workspace = ( - target.workspace if isinstance(target, DataStore) else workspace - ) - if ( - isinstance(target, DataStore) - and workspace is not None - and workspace != target.workspace - ): - raise ValueError("workspace does not match the DataStore workspace") - workflow = load_agent_workflow( - target, - workflow_run_id, - workspace=resolved_workspace, - ) - store = _open_datastore(target, root, workflow) - prefix, result, request = _load_completed_result(store, workflow) - reports = _collect_reports(store, result) - stage_attempts, resumes = _collect_history(store, prefix, workflow, request) - - active_root = ( - root if workflow.workspace is None else (root / workflow.workspace).resolve() - ) - if not active_root.is_relative_to(root): - raise ValueError("Workflow workspace resolves outside the analysis store") - report_dir = ( - active_root / "agents" / "runs" / workflow_run_id / "report" - ).resolve() - if not report_dir.is_relative_to(active_root): - raise ValueError("Agent report path resolves outside the analysis store") - plot_dir = report_dir / "plots" - report_dir.mkdir(parents=True, exist_ok=True) - preprocessing_plan = ( - result.preprocessingPlan.model_dump(mode="json") - if result.preprocessingPlan is not None - else {} - ) - experimental = _latest(reports, "experimental_context") - selected_qc_profile = _selected_qc_profile( - experimental, - _mapping(preprocessing_plan.get("cellQc")), - ) - cluster_counts, top_markers, plot_files, plot_notes = _collect_final_artifacts( - store, - result, - plot_dir, - qc_profile=selected_qc_profile, - ) - hvg_evidence = _collect_hvg_evidence( - store, - stage_attempts, - preprocessing_plan, - ) - hvg_plots, hvg_plot_notes = _collect_hvg_plots( - store, - stage_attempts, - preprocessing_plan, - plot_dir, - ) - plot_files.update(hvg_plots) - plot_notes.extend(hvg_plot_notes) - active_decisions = _collect_active_decisions(store, workflow_run_id) - default_feature_inventories = _collect_default_feature_inventories( - store, - preprocessing_plan, - ) - payload: dict[str, Any] = { - "status": result.status, - "currentStage": result.currentStage, - "workflowRunId": workflow_run_id, - "generatedAt": datetime.now(UTC).isoformat(), - "request": request.request.model_dump(mode="json"), - "effectiveConfig": request.config.model_dump(mode="json"), - "workflowResult": result.model_dump(mode="json"), - "reports": reports, - "stageAttempts": stage_attempts, - "workflowResumes": resumes, - "clusterCounts": cluster_counts, - "topMarkers": top_markers, - "plotFiles": plot_files, - "plotNotes": plot_notes, - "hvgEvidence": hvg_evidence, - "activeDecisions": active_decisions, - "defaultFeatureInventories": default_feature_inventories, - } - documents = ( - ("analysis.html", _render_analysis_document(payload)), - ("technical.html", _render_technical_document(payload)), - ("index.html", _render_index_document(payload)), - ) - destination = report_dir / "index.html" - for filename, document in documents: - written = _write_report_page(report_dir, filename, document) - if filename == "index.html": - destination = written - logger.info( - f"Generated HTML report for agent workflow {workflow_run_id}: {destination}" - ) - return destination - - -__all__ = ["generate_agent_report"] diff --git a/scarf/agent/types.py b/scarf/agent/types.py index c380c2a7..28e9ed3c 100644 --- a/scarf/agent/types.py +++ b/scarf/agent/types.py @@ -2,7 +2,7 @@ from typing import Any, Literal -from .config._deps import AGENT_INSTALL_HINT +from ._deps import AGENT_INSTALL_HINT try: from pydantic import BaseModel, ConfigDict, Field diff --git a/tests/test_agent_biological_interpretation.py b/tests/test_agent_biological_interpretation.py index 75b4ec00..c0b93bdd 100644 --- a/tests/test_agent_biological_interpretation.py +++ b/tests/test_agent_biological_interpretation.py @@ -19,12 +19,13 @@ from pydantic_ai.usage import RunUsage from zarr.storage import MemoryStore -import scarf.agent.biological_interpretation as biological_module +import scarf.agent.biological_interpretation.agent as biological_agent +import scarf.agent.biological_interpretation.tools as biological_tools +import scarf.agent.biological_interpretation.validation as biological_validation +from scarf.agent.biological_interpretation.agent import _SYSTEM_PROMPT from scarf.agent.biological_interpretation import ( - _SYSTEM_PROMPT, BiologicalContext, BiologicalInterpretationAgent, - BiologicalInterpretationDependencies, BiologicalInterpretationNeedsInput, BiologicalInterpretationReport, ClusterCompositionEvidence, @@ -40,6 +41,9 @@ inspect_cluster_markers, validate_biological_interpretation_report, ) +from scarf.agent.biological_interpretation.contracts import ( + BiologicalInterpretationDependencies, +) from scarf.agent.types import ( AgentRunInfo, ArtifactReferenceModel, @@ -922,7 +926,7 @@ def unavailable_structured_output(**kwargs: object) -> None: raise UnexpectedModelBehavior("structured output unavailable") monkeypatch.setattr( - biological_module, + biological_agent, "run_agent_sync", unavailable_structured_output, ) @@ -1016,7 +1020,7 @@ def test_handoff_selection_must_match_exact_cluster_selection() -> None: def test_integrated_handoff_uses_marker_assay_without_claiming_graph_ownership( monkeypatch: pytest.MonkeyPatch, ) -> None: - from scarf.agent import biological_interpretation as module + from scarf.agent.biological_interpretation import agent as module store = FakeStore() store.cluster = ArtifactRef( @@ -1096,17 +1100,17 @@ def test_integrated_handoff_requires_explicit_marker_assay() -> None: def test_biological_scalar_and_column_validation_edges() -> None: - assert biological_module._finite_float(None) is None - assert biological_module._finite_float("not-a-number") is None - assert biological_module._finite_float(float("nan")) is None - assert biological_module._string_value(np.int64(3)) == "3" + assert biological_tools._finite_float(None) is None + assert biological_tools._finite_float("not-a-number") is None + assert biological_tools._finite_float(float("nan")) is None + assert biological_tools._string_value(np.int64(3)) == "3" with pytest.raises(ValueError, match="not present in cell metadata"): - biological_module._check_column(FakeStore(), "missing", "sample column") + biological_tools._check_column(FakeStore(), "missing", "sample column") tool = SimpleNamespace(name="future_tool") deps = context(FakeStore()).deps assert ( - biological_module._prepare_biological_interpretation_tool( + biological_validation._prepare_biological_interpretation_tool( SimpleNamespace(deps=deps), tool ) is tool @@ -1197,7 +1201,7 @@ def run(deps: BiologicalInterpretationDependencies) -> None: lambda _ref: {"values": np.asarray([], dtype=int)}, ) monkeypatch.setattr( - biological_module, + biological_tools, "read_stored_selection_indices", lambda *_args, **_kwargs: np.asarray([], dtype=np.int64), ) @@ -1209,20 +1213,20 @@ def test_cluster_composition_metadata_alignment_edges( monkeypatch: pytest.MonkeyPatch, ) -> None: store = FakeStore() - original_rows = biological_module.read_metadata_rows - original_missing = biological_module.read_metadata_missing_rows + original_rows = biological_tools.read_metadata_rows + original_missing = biological_tools.read_metadata_missing_rows monkeypatch.setattr( - biological_module, + biological_tools, "read_metadata_rows", lambda *_args, **_kwargs: np.asarray(["control"]), ) with pytest.raises(ValueError, match="condition and cluster"): asyncio.run(inspect_cluster_composition(context(store))) - monkeypatch.setattr(biological_module, "read_metadata_rows", original_rows) + monkeypatch.setattr(biological_tools, "read_metadata_rows", original_rows) monkeypatch.setattr( - biological_module, + biological_tools, "read_metadata_missing_rows", lambda *_args, **_kwargs: np.asarray( [True] + [False] * (len(store.cells.cluster_values) - 1) @@ -1232,7 +1236,7 @@ def test_cluster_composition_metadata_alignment_edges( asyncio.run(inspect_cluster_composition(context(store))) monkeypatch.setattr( - biological_module, "read_metadata_missing_rows", original_missing + biological_tools, "read_metadata_missing_rows", original_missing ) def sample_misaligned( @@ -1241,18 +1245,18 @@ def sample_misaligned( values = original_rows(table, column, indices) return values[:-1] if column == "sample" else values - monkeypatch.setattr(biological_module, "read_metadata_rows", sample_misaligned) + monkeypatch.setattr(biological_tools, "read_metadata_rows", sample_misaligned) with pytest.raises(ValueError, match="sample and cluster"): asyncio.run(inspect_cluster_composition(context(store))) - monkeypatch.setattr(biological_module, "read_metadata_rows", original_rows) + monkeypatch.setattr(biological_tools, "read_metadata_rows", original_rows) def sample_missing(_table: object, column: str, indices: np.ndarray) -> np.ndarray: if column == "sample": return np.asarray([True] + [False] * (len(indices) - 1)) return np.zeros(len(indices), dtype=bool) - monkeypatch.setattr(biological_module, "read_metadata_missing_rows", sample_missing) + monkeypatch.setattr(biological_tools, "read_metadata_missing_rows", sample_missing) with pytest.raises(ValueError, match="sample column contains missing"): asyncio.run(inspect_cluster_composition(context(store))) @@ -1370,16 +1374,16 @@ def report(value: TreatmentObservation) -> BiologicalInterpretationReport: without_condition = deps.model_copy(update={"conditionColumn": None}) with pytest.raises(ModelRetry, match="condition column"): - biological_module._canonicalize_treatment_observations( + biological_validation._canonicalize_treatment_observations( report(observation), without_condition ) with pytest.raises(ModelRetry, match="remain descriptive"): - biological_module._canonicalize_treatment_observations( + biological_validation._canonicalize_treatment_observations( report(observation.model_copy(update={"isDescriptiveOnly": False})), deps, ) with pytest.raises(ModelRetry, match="exactly two distinct"): - biological_module._canonicalize_treatment_observations( + biological_validation._canonicalize_treatment_observations( report( observation.model_copy( update={"evidenceIds": [control.evidenceId, control.evidenceId]} @@ -1388,7 +1392,7 @@ def report(value: TreatmentObservation) -> BiologicalInterpretationReport: deps, ) with pytest.raises(ModelRetry, match="condition composition evidence"): - biological_module._canonicalize_treatment_observations( + biological_validation._canonicalize_treatment_observations( report( observation.model_copy( update={"evidenceIds": [control.evidenceId, "unknown"]} @@ -1397,18 +1401,18 @@ def report(value: TreatmentObservation) -> BiologicalInterpretationReport: deps, ) with pytest.raises(ModelRetry, match="distinct named conditions"): - biological_module._canonicalize_treatment_observations( + biological_validation._canonicalize_treatment_observations( report(observation.model_copy(update={"comparisonCondition": "control"})), deps, ) - canonical = biological_module._canonicalize_treatment_observations( + canonical = biological_validation._canonicalize_treatment_observations( report(observation), deps ) assert "equal mean independent-unit fractions" in canonical[0].observation higher = treated.model_copy(update={"meanFraction": 0.75}) deps.conditionEvidence[treated.evidenceId] = higher - canonical = biological_module._canonicalize_treatment_observations( + canonical = biological_validation._canonicalize_treatment_observations( report(observation.model_copy(update={"direction": "higher"})), deps ) assert "higher mean independent-unit fraction" in canonical[0].observation diff --git a/tests/test_agent_characterize_covariates.py b/tests/test_agent_characterize_covariates.py index fd7b1369..70e9a4b4 100644 --- a/tests/test_agent_characterize_covariates.py +++ b/tests/test_agent_characterize_covariates.py @@ -12,7 +12,7 @@ from scipy.sparse import csr_matrix from scarf.agent import CovariateCharacterization, characterize_covariates -from scarf.agent.characterize_covariates import ( +from scarf.agent.experimental_context.characterization import ( _Run, _assign_domain, _characterize_coefficient, @@ -25,7 +25,7 @@ _triage_columns, _validate_directions, ) -from scarf.agent.decide import DecisionValidationError +from scarf.agent.decisions.selection import DecisionValidationError from scarf.agent.types import ArtifactReferenceModel, Decision, EvidenceItem from scarf.datastore.datastore import DataStore from scarf.storage import ArtifactRef, ArtifactResolutionError @@ -528,7 +528,7 @@ def test_characterize_covariates_avoids_bulk_metadata_loads( store = _store_with_design(tmp_path) cell_selection = store.snapshot_cell_selection("I") characterize_covariates_module = import_module( - "scarf.agent.characterize_covariates" + "scarf.agent.experimental_context.characterization" ) original_fetch = store.cells.fetch original_fetch_all = store.cells.fetch_all @@ -630,7 +630,7 @@ def test_covariate_direction_validation_reports_structural_errors( def test_run_ask_audits_invalid_mocked_decision(monkeypatch) -> None: characterize_covariates_module = import_module( - "scarf.agent.characterize_covariates" + "scarf.agent.experimental_context.characterization" ) run = _Run( store=object(), diff --git a/tests/test_agent_characterize_features.py b/tests/test_agent_characterize_features.py index c564733a..daba9261 100644 --- a/tests/test_agent_characterize_features.py +++ b/tests/test_agent_characterize_features.py @@ -8,7 +8,7 @@ from scipy.sparse import csr_matrix from scarf.agent import FeatureCharacterization, characterize_features -from scarf.agent.characterize_features import ( +from scarf.agent.data_enrichment.characterization import ( _assist_species, _load_or_fetch_reference, _sex_coefficient_note, @@ -333,7 +333,9 @@ def test_reference_loading_audits_mocked_download_success_and_failure( tmp_path: Path, monkeypatch, ) -> None: - characterize_features_module = import_module("scarf.agent.characterize_features") + characterize_features_module = import_module( + "scarf.agent.data_enrichment.characterization" + ) reference = GeneReference( species="homo_sapiens", release="test", diff --git a/tests/test_agent_data_enrichment.py b/tests/test_agent_data_enrichment.py index ce65e686..d96afa71 100644 --- a/tests/test_agent_data_enrichment.py +++ b/tests/test_agent_data_enrichment.py @@ -9,7 +9,8 @@ from pydantic_ai.messages import ModelMessage, ModelResponse, ToolCallPart from pydantic_ai.models.function import AgentInfo, FunctionModel -from scarf.agent.characterize_features import FeatureCharacterization +import scarf.agent.data_enrichment.agent as data_enrichment_agent_module +from scarf.agent.data_enrichment.characterization import FeatureCharacterization from scarf.agent.data_enrichment import ( AdtControlEvidence, AssayFeatureInspection, @@ -136,7 +137,7 @@ def test_data_enrichment_models_have_factories_and_camelcase_fields() -> None: def test_data_enrichment_agent_uses_only_read_tools_and_context( monkeypatch: pytest.MonkeyPatch, ) -> None: - from scarf.agent import data_enrichment as module + from scarf.agent.data_enrichment import tools as module store = ReadOnlyStore() tool_names: set[str] = set() @@ -256,7 +257,7 @@ async def reply( def test_data_enrichment_batches_grounded_multimodal_evidence( monkeypatch: pytest.MonkeyPatch, ) -> None: - from scarf.agent import data_enrichment as module + from scarf.agent.data_enrichment import tools as module assay_features = { "RNA": ( @@ -410,7 +411,7 @@ async def reply( def test_data_enrichment_retries_hallucinated_features( monkeypatch: pytest.MonkeyPatch, ) -> None: - from scarf.agent import data_enrichment as module + from scarf.agent.data_enrichment import tools as module store = ReadOnlyStore() monkeypatch.setattr( @@ -489,7 +490,7 @@ async def reply( def test_data_enrichment_pauses_after_completed_inspection_without_selection( monkeypatch: pytest.MonkeyPatch, ) -> None: - from scarf.agent import data_enrichment as module + from scarf.agent.data_enrichment import tools as module store = ReadOnlyStore() monkeypatch.setattr( @@ -507,7 +508,11 @@ def unavailable_structured_output(**kwargs: object) -> None: asyncio.run(module.inspect_assay_features_batch(SimpleNamespace(deps=deps))) raise UnexpectedModelBehavior("structured output unavailable") - monkeypatch.setattr(module, "run_agent_sync", unavailable_structured_output) + monkeypatch.setattr( + data_enrichment_agent_module, + "run_agent_sync", + unavailable_structured_output, + ) result = DataEnrichmentAgent(object()).run( store, context=DataEnrichmentContext(organismHint="human"), @@ -528,7 +533,7 @@ def unavailable_structured_output(**kwargs: object) -> None: def test_unattended_data_enrichment_uses_inspected_policy_after_model_failure( monkeypatch: pytest.MonkeyPatch, ) -> None: - from scarf.agent import data_enrichment as module + from scarf.agent.data_enrichment import tools as module store = ReadOnlyStore() monkeypatch.setattr( @@ -543,7 +548,11 @@ def unavailable_structured_output(**kwargs: object) -> None: asyncio.run(module.inspect_assay_features_batch(SimpleNamespace(deps=deps))) raise UnexpectedModelBehavior("structured output unavailable") - monkeypatch.setattr(module, "run_agent_sync", unavailable_structured_output) + monkeypatch.setattr( + data_enrichment_agent_module, + "run_agent_sync", + unavailable_structured_output, + ) result = DataEnrichmentAgent(object(), unattended=True).run( store, context=DataEnrichmentContext(organismHint="human"), diff --git a/tests/test_agent_decide.py b/tests/test_agent_decide.py index 2fbf84ea..b9ed5d60 100644 --- a/tests/test_agent_decide.py +++ b/tests/test_agent_decide.py @@ -9,7 +9,7 @@ from pydantic_ai.models.test import TestModel from scarf.agent import DecisionValidationError, EvidenceItem, decide -from scarf.agent.decide import _SYSTEM_PROMPT, validate_decision +from scarf.agent.decisions.selection import _SYSTEM_PROMPT, validate_decision from scarf.agent.types import Decision diff --git a/tests/test_agent_decision_kernel.py b/tests/test_agent_decision_kernel.py index 49f8f04a..b068c13a 100644 --- a/tests/test_agent_decision_kernel.py +++ b/tests/test_agent_decision_kernel.py @@ -3,7 +3,7 @@ import pytest from pydantic import ValidationError -from scarf.agent.decision_kernel import ( +from scarf.agent.decisions.kernel import ( DecisionEvidence, DecisionOption, DecisionRecord, diff --git a/tests/test_agent_decision_persistence.py b/tests/test_agent_decision_persistence.py index a787c5ce..3d5d5ad0 100644 --- a/tests/test_agent_decision_persistence.py +++ b/tests/test_agent_decision_persistence.py @@ -11,10 +11,10 @@ from zarr.core.buffer import default_buffer_prototype from zarr.core.sync import sync -import scarf.agent.decision_persistence as persistence_module +import scarf.agent.persistence.decisions as persistence_module import scarf.agent.orchestrator.decisions as decisions_module from scarf.agent import record_io -from scarf.agent.decision_kernel import ( +from scarf.agent.decisions.kernel import ( DecisionEvidence, DecisionRecord, DecisionSelection, @@ -23,7 +23,7 @@ VerificationCheck, VerificationRecord, ) -from scarf.agent.decision_persistence import ( +from scarf.agent.persistence.decisions import ( DecisionPersistenceFormatError, DecisionWorkflowSnapshot, attach_audited_rna_decision, @@ -41,7 +41,7 @@ OrchestrationRequestRecord, ) from scarf.agent.orchestrator.decisions import DecisionStagesMixin -from scarf.agent.rna_decisions import ( +from scarf.agent.decisions.rna import ( build_cell_quality_decision, build_feature_policy_decision, build_pca_prefix_decision, diff --git a/tests/test_agent_exec.py b/tests/test_agent_exec.py index 69bdad4e..c516059a 100644 --- a/tests/test_agent_exec.py +++ b/tests/test_agent_exec.py @@ -2,6 +2,7 @@ import asyncio import json +import sys import threading import httpx @@ -106,6 +107,119 @@ def test_four_agent_objects_are_public() -> None: ) +def test_agent_facade_exports_remain_stable() -> None: + import scarf.agent as agent_package + + expected = { + "AgentInvocation", + "AgentName", + "AgentOrchestrator", + "AgentPersistenceTarget", + "AgentReport", + "AgentReportLink", + "AgentReportRecord", + "AgentReportReference", + "AgentReportType", + "AgentRunConfig", + "AgentTerminalStatus", + "AgentWorkflowRun", + "AgentWorkflowStatus", + "AssayPreprocessingPlan", + "AutomatedPreprocessingPlan", + "AutomatedWorkflowConfig", + "AutomatedWorkflowRequest", + "AutomatedWorkflowResult", + "AutomatedWorkflowResumeRequest", + "BatchSafetyEvidence", + "BiologicalContext", + "BiologicalInterpretationAgent", + "BiologicalInterpretationReport", + "CellQcPlan", + "CovariateCharacterization", + "DataEnrichmentAgent", + "DataEnrichmentContext", + "DataEnrichmentReport", + "Decision", + "DecisionEvidence", + "DecisionOption", + "DecisionRecord", + "DecisionSelection", + "DecisionSpec", + "DecisionValidationError", + "DecisionWorkflowRun", + "DeterministicDecisionAuditor", + "DatasetManifest", + "DatasetManifestDecision", + "EvidenceBundle", + "EvidenceItem", + "ExperimentalBiologyHandoff", + "ExperimentalContextAgent", + "ExperimentalContextResult", + "ExperimentalTuningHandoff", + "FeatureCharacterization", + "FinalAnalysisHandoff", + "FinalGraphSelection", + "IngestResult", + "IntegrationCandidateEvaluation", + "IntegrationMetrics", + "NamedArtifactSource", + "NativeAnalysisHandoff", + "NeedsInput", + "ParameterCandidate", + "ParameterSearchPlan", + "ParameterTuningAgent", + "ParameterTuningAssayInput", + "ParameterTuningReport", + "PendingDecision", + "PreprocessedAssayHandoff", + "ProtectedVariableEffect", + "RevisionRequest", + "StageResult", + "StageStatus", + "StudyContextSummary", + "StudyContract", + "TuningBiologyHandoff", + "VerificationCheck", + "VerificationRecord", + "WorkflowNeedsInput", + "WorkflowQuestion", + "WorkflowStageAttempt", + "WorkflowStageLink", + "characterize_covariates", + "characterize_features", + "check_runtime", + "create_agent_workflow", + "decide", + "detect_format", + "finalize_agent_workflow", + "generate_agent_report", + "get_default_parameter_candidates", + "ingest", + "inspect_h5ad_manifest", + "list_agent_reports", + "list_agent_workflows", + "load_agent_record", + "load_agent_report", + "load_agent_workflow", + "load_env", + "run_agent", + "run_agent_sync", + "save_agent_report", + "tune_parameters", + } + + assert set(agent_package.__all__) == expected + assert agent_package._deps is not None + assert { + "scarf.agent.biological_interpretation", + "scarf.agent.data_enrichment", + "scarf.agent.experimental_context", + "scarf.agent.parameter_tuning", + "scarf.agent.persistence", + "scarf.agent.report", + } <= sys.modules.keys() + + def test_model_settings_disable_thinking_across_provider_shapes() -> None: settings = get_model_settings( AgentRunConfig( diff --git a/tests/test_agent_experimental_context.py b/tests/test_agent_experimental_context.py index 00c57057..22626090 100644 --- a/tests/test_agent_experimental_context.py +++ b/tests/test_agent_experimental_context.py @@ -15,7 +15,11 @@ from pydantic_ai.usage import RunUsage from zarr.storage import MemoryStore -import scarf.agent.experimental_context as experimental_context_module +import scarf.agent.experimental_context.agent as experimental_context_agent +import scarf.agent.experimental_context.contracts as experimental_context_contracts +import scarf.agent.experimental_context.qc_evidence as experimental_context_qc +import scarf.agent.experimental_context.tools as experimental_context_tools +import scarf.agent.experimental_context.validation as experimental_context_validation from scarf.agent.experimental_context import ( BatchCorrectionPlan, BatchSafetyEvidence, @@ -34,7 +38,7 @@ score_current_representation, validate_experimental_context, ) -from scarf.agent.characterize_covariates import ( +from scarf.agent.experimental_context.characterization import ( CovariateCharacterization, _SelectionBoundCells, ) @@ -238,7 +242,7 @@ def metric_graph_connectivity(self, column: str, graph: ArtifactRef) -> float: @pytest.fixture(autouse=True) def _resolve_fake_graph_selection(monkeypatch: pytest.MonkeyPatch) -> None: - from scarf.agent import experimental_context as module + from scarf.agent.experimental_context import agent as module def resolve(root: zarr.Group, _graph: ArtifactRef) -> ArtifactRef: return ArtifactRef.from_dict(root.attrs["_test_cell_selection"]) @@ -569,7 +573,7 @@ def unavailable_design(**kwargs: Any) -> None: ) monkeypatch.setattr( - experimental_context_module, + experimental_context_agent, "run_agent_sync", unavailable_design, ) @@ -615,7 +619,7 @@ def unavailable_design(**kwargs: Any) -> None: ) monkeypatch.setattr( - experimental_context_module, + experimental_context_agent, "run_agent_sync", unavailable_design, ) @@ -1028,7 +1032,7 @@ def test_harmony_safety_uses_only_exact_proposed_batch_columns() -> None: def test_batch_safety_does_not_depend_on_pairwise_selected_flag( monkeypatch: pytest.MonkeyPatch, ) -> None: - from scarf.agent import experimental_context as module + from scarf.agent.experimental_context import tools as module store = _Store() decision = _design_decision(action="evaluateHarmony") @@ -1351,7 +1355,7 @@ async def reply( def test_harmony_requires_resolved_units_and_estimability( monkeypatch: pytest.MonkeyPatch, ) -> None: - from scarf.agent import experimental_context as module + from scarf.agent.experimental_context import validation as module characterization = CovariateCharacterization( status="needsInput", @@ -1472,7 +1476,7 @@ def test_named_artifact_and_qc_source_validation_edges() -> None: with pytest.raises(ValidationError, match="requires both name and artifact"): NamedArtifactSource(artifact=metric.artifact) - validate = experimental_context_module._validate_qc_sources + validate = experimental_context_contracts._validate_qc_sources with pytest.raises(ValueError, match="metadata attributes must be unique"): validate( action="globalGaussian", @@ -1651,14 +1655,14 @@ def test_experimental_context_private_input_guards( "htoIdentityColumn": "donor", }, ).deps - assert experimental_context_module._hto_identity_columns(deps) == [ + assert experimental_context_qc._hto_identity_columns(deps) == [ "sample", "donor", ] unknown_tool = SimpleNamespace(name="future_tool") assert ( - experimental_context_module._prepare_experimental_context_tool( + experimental_context_tools._prepare_experimental_context_tool( SimpleNamespace(deps=deps), unknown_tool, ) @@ -1669,12 +1673,13 @@ def test_experimental_context_private_input_guards( confounding=[{"coefficient": 3, "pairs": []}], ) assert ( - experimental_context_module.characterization_evidence(characterization) == set() + experimental_context_contracts.characterization_evidence(characterization) + == set() ) deps.cellSelection = None with pytest.raises(ValueError, match="exact artifact"): - experimental_context_module._cell_selection_ref(deps) + experimental_context_qc._cell_selection_ref(deps) deps.cellSelection = _artifact_ref = ArtifactRef( scope="assay", assay="RNA", @@ -1682,11 +1687,11 @@ def test_experimental_context_private_input_guards( artifact_id="4" * 64, ) with pytest.raises(ValueError, match="datastore cell selection"): - experimental_context_module._cell_selection_ref(deps) + experimental_context_qc._cell_selection_ref(deps) del _artifact_ref with pytest.raises(TypeError, match="NamedArtifactSource"): - experimental_context_module._source_ref( + experimental_context_qc._source_ref( object(), expected_kind="quality_metric", ) @@ -1695,12 +1700,12 @@ def test_experimental_context_private_input_guards( artifact=ArtifactReferenceModel(), ) with pytest.raises(ValueError, match="non-empty semantic name"): - experimental_context_module._source_ref( + experimental_context_qc._source_ref( blank_source, expected_kind="quality_metric", ) with pytest.raises(ValueError, match="quality_metric"): - experimental_context_module._source_ref( + experimental_context_qc._source_ref( NamedArtifactSource( name="identity", artifact=ArtifactReferenceModel( @@ -1722,16 +1727,16 @@ def test_experimental_context_private_input_guards( ) deps.htoIdentityArtifacts = [duplicate, duplicate] with pytest.raises(ValueError, match="names must be unique"): - experimental_context_module._hto_artifact_map(deps) + experimental_context_qc._hto_artifact_map(deps) deps.cellSelection = store.cell_selection monkeypatch.setattr( - experimental_context_module, + experimental_context_qc, "read_stored_selection_mask", lambda *_args, **_kwargs: np.ones(store.cells.N + 1, dtype=bool), ) with pytest.raises(ValueError, match="aligned boolean selection"): - experimental_context_module._active_cell_count(deps) + experimental_context_qc._active_cell_count(deps) def test_qc_profile_degradation_and_selection_guards( @@ -1742,7 +1747,7 @@ def test_qc_profile_degradation_and_selection_guards( deps = context.deps active = np.ones(store.cells.N, dtype=bool) notes: list[str] = [] - profile = experimental_context_module._global_qc_profile( + profile = experimental_context_qc._global_qc_profile( deps, ("RNA", "RNA"), active, @@ -1756,12 +1761,12 @@ def test_qc_profile_degradation_and_selection_guards( assert notes == ["Ignored constant QC metric 'constant'"] monkeypatch.setattr( - experimental_context_module, + experimental_context_qc, "gaussian_quantile_bounds", lambda *_args, **_kwargs: (float("nan"), float("nan")), ) notes = [] - profile = experimental_context_module._global_qc_profile( + profile = experimental_context_qc._global_qc_profile( deps, ("RNA", "RNA"), active, @@ -1798,39 +1803,39 @@ def test_qc_profile_degradation_and_selection_guards( deps.directions = {"cellQc": {"profileId": 3}} with pytest.raises(ModelRetry, match="profileId direction must be a string"): - experimental_context_module._canonical_cell_qc_plan( + experimental_context_validation._canonical_cell_qc_plan( CellQcPlan(), deps, characterization ) deps.directions = { "cellQc": {"sampleColumn": "sample", "sampleArtifactName": "identity"} } with pytest.raises(ModelRetry, match="cannot select both"): - experimental_context_module._canonical_cell_qc_plan( + experimental_context_validation._canonical_cell_qc_plan( CellQcPlan(), deps, characterization ) deps.directions = {"cellQc": {"sampleArtifactName": 3}} with pytest.raises(ModelRetry, match="sampleArtifactName must be a string"): - experimental_context_module._canonical_cell_qc_plan( + experimental_context_validation._canonical_cell_qc_plan( CellQcPlan(), deps, characterization ) deps.directions = {"cellQc": {"action": "unknown"}} with pytest.raises(ModelRetry, match="Unsupported cellQc.action"): - experimental_context_module._canonical_cell_qc_plan( + experimental_context_validation._canonical_cell_qc_plan( CellQcPlan(), deps, characterization ) deps.directions = {"cellQc": {"action": "sampleMad"}} with pytest.raises(ModelRetry, match="exactly one offered profile"): - experimental_context_module._canonical_cell_qc_plan( + experimental_context_validation._canonical_cell_qc_plan( CellQcPlan(), deps, characterization ) deps.directions = {"cellQc": {"profileId": "unknown"}} with pytest.raises(ModelRetry, match="was not offered"): - experimental_context_module._canonical_cell_qc_plan( + experimental_context_validation._canonical_cell_qc_plan( CellQcPlan(), deps, characterization ) deps.directions = {} - selected = experimental_context_module._canonical_cell_qc_plan( + selected = experimental_context_validation._canonical_cell_qc_plan( CellQcPlan(), deps, characterization ) assert selected.profileId == "global" @@ -1844,14 +1849,14 @@ def test_qc_profile_degradation_and_selection_guards( evidenceIds=["qcProfile:global"], ) with pytest.raises(ModelRetry, match="copy the selected offered profile"): - experimental_context_module._canonical_cell_qc_plan( + experimental_context_validation._canonical_cell_qc_plan( mismatched, deps, characterization ) missing_evidence = mismatched.model_copy( update={"attributes": ["RNA_nCounts"], "evidenceIds": []} ) with pytest.raises(ModelRetry, match="cite its exact profile"): - experimental_context_module._canonical_cell_qc_plan( + experimental_context_validation._canonical_cell_qc_plan( missing_evidence, deps, characterization ) @@ -1885,7 +1890,7 @@ def test_design_analysis_rejects_invalid_batch_proposals( notes=["design failed"], ) monkeypatch.setattr( - experimental_context_module, + experimental_context_tools, "characterize_covariates", lambda *_args, **_kwargs: failed, ) @@ -1922,7 +1927,7 @@ def test_design_analysis_records_not_computed_estimability( ) monkeypatch.setattr( - experimental_context_module, + experimental_context_tools, "reduce_observation_units", lambda *_args, **_kwargs: (_ for _ in ()).throw(ValueError("bad design")), ) @@ -2050,7 +2055,7 @@ def validate( candidate_coefficients: dict[str, dict[str, Any]] | None = None, requested: set[str] | None = None, ) -> None: - experimental_context_module._validate_batch_correction_plan( + experimental_context_validation._validate_batch_correction_plan( candidate, deps or context.deps, characterization, diff --git a/tests/test_agent_hvg_diagnostics.py b/tests/test_agent_hvg_diagnostics.py index cf94979a..a00d57cf 100644 --- a/tests/test_agent_hvg_diagnostics.py +++ b/tests/test_agent_hvg_diagnostics.py @@ -1,7 +1,7 @@ import numpy as np import pytest -from scarf.agent.hvg_diagnostics import ( +from scarf.agent.parameter_tuning.hvg import ( HvgGroupVariability, aggregate_hvg_rankings, effective_hvg_candidate_counts, diff --git a/tests/test_agent_ingest.py b/tests/test_agent_ingest.py index 5bc8e4c3..f8994180 100644 --- a/tests/test_agent_ingest.py +++ b/tests/test_agent_ingest.py @@ -86,7 +86,7 @@ def _patch_ingest_summary( import importlib ingest_common = importlib.import_module("scarf.agent.ingest.common") - persistence = importlib.import_module("scarf.agent.persistence") + persistence = importlib.import_module("scarf.agent.persistence.reports") def summarize( _zarr_path: str, diff --git a/tests/test_agent_orchestrator.py b/tests/test_agent_orchestrator.py index 4259cc7c..428eae89 100644 --- a/tests/test_agent_orchestrator.py +++ b/tests/test_agent_orchestrator.py @@ -27,8 +27,8 @@ FeatureSelectionPolicy, StudyContextSummary, ) -from scarf.agent.decision_kernel import DecisionSelection -from scarf.agent.decision_persistence import ( +from scarf.agent.decisions.kernel import DecisionSelection +from scarf.agent.persistence.decisions import ( load_latest_decision_workflow_snapshot, ) from scarf.agent.experimental_context import ( @@ -76,12 +76,15 @@ def _rna_workflow_model() -> tuple[FunctionModel, dict[str, int]]: } def prompt_text(messages: list[ModelMessage]) -> str: - return "\n".join( - part.content - for message in messages - for part in message.parts - if isinstance(getattr(part, "content", None), str) - ) + values: list[str] = [] + for message in messages: + for part in message.parts: + content = getattr(part, "content", None) + if isinstance(content, str): + values.append(content) + elif isinstance(content, tuple): + values.extend(item for item in content if isinstance(item, str)) + return "\n".join(values) def tool_result( messages: list[ModelMessage], @@ -278,6 +281,26 @@ async def reply( ) prompt = prompt_text(messages) + if any( + tool.parameters_json_schema.get("title") == "AnalysisVisualAdjudication" + for tool in info.output_tools + ): + payload, _ = json.JSONDecoder().raw_decode(prompt[prompt.index("{") :]) + return ModelResponse( + parts=[ + ToolCallPart( + tool_name=info.output_tools[0].name, + args={ + "status": "acceptable", + "selectedCandidateId": payload["selectedCandidateId"], + "rationale": ( + "The bounded diagnostic board agrees with the " + "registered numeric evidence." + ), + }, + ) + ] + ) payload, _ = json.JSONDecoder().raw_decode(prompt[prompt.index("{") :]) decision = payload decision_id = decision["decisionId"] diff --git a/tests/test_agent_orchestrator_stages.py b/tests/test_agent_orchestrator_stages.py index f83106bf..339fccd7 100644 --- a/tests/test_agent_orchestrator_stages.py +++ b/tests/test_agent_orchestrator_stages.py @@ -13,7 +13,7 @@ import scarf.agent.orchestrator.context as context_module import scarf.agent.orchestrator.journal as journal_module import scarf.agent.orchestrator.tuning as tuning_module -import scarf.agent.parameter_tuning as parameter_tuning_module +import scarf.agent.parameter_tuning.agent as parameter_tuning_agent from scarf.agent.orchestrator.preprocessing import PreprocessingStagesMixin from scarf.agent.config import AgentRunConfig from scarf.agent.config.agent_exec import ( @@ -63,14 +63,14 @@ finalize_parameter_tuning_selection, select_final_parameter_graph, ) -from scarf.agent.qc_profiles import RegisteredCellQcProfile +from scarf.agent.cell_quality.profiles import RegisteredCellQcProfile from scarf.agent.types import ( AgentRunInfo, ArtifactReferenceModel, BatchSafetyEvidence, ExperimentalTuningHandoff, ) -from scarf.agent.tuning_diagnostics import ( +from scarf.agent.parameter_tuning.diagnostics import ( _select_capture_cells, resolve_native_doublet_inputs, ) @@ -1591,7 +1591,7 @@ def selection_execution(**_kwargs: Any) -> Any: ), ) - monkeypatch.setattr(parameter_tuning_module, "run_agent_sync", selection_execution) + monkeypatch.setattr(parameter_tuning_agent, "run_agent_sync", selection_execution) class CountingAgent: config = AgentRunConfig() diff --git a/tests/test_agent_parameter_tuning.py b/tests/test_agent_parameter_tuning.py index 80ece26e..0fcbc893 100644 --- a/tests/test_agent_parameter_tuning.py +++ b/tests/test_agent_parameter_tuning.py @@ -15,7 +15,9 @@ ) from pydantic_ai.models.function import AgentInfo, FunctionModel -import scarf.agent.parameter_tuning as parameter_tuning_module +import scarf.agent.parameter_tuning.agent as parameter_tuning_agent +import scarf.agent.parameter_tuning.execution as parameter_tuning_execution +import scarf.agent.parameter_tuning.selection as parameter_tuning_selection from scarf.agent.parameter_tuning import ( ArtifactRecord, CandidateComparison, @@ -32,7 +34,6 @@ build_initial_parameter_candidates, evaluate_parameter_candidate, execute_parameter_candidate, - pending_parameter_tuning_report, FinalGraphComparison, FinalGraphNeedsInput, FinalGraphSelection, @@ -41,7 +42,6 @@ IntegrationCandidateEvaluation, IntegrationMetrics, parameter_batch_selection_prompt, - parameter_evaluation_payload, parameter_search_prompt, parameter_search_system_prompt, parameter_tuning_prompt, @@ -54,6 +54,8 @@ validate_parameter_search_plan, validate_parameter_tuning_report, ) +from scarf.agent.parameter_tuning.prompts import parameter_evaluation_payload +from scarf.agent.parameter_tuning.selection import pending_parameter_tuning_report from scarf.agent.types import ( AgentDataModel, ArtifactReferenceModel, @@ -1399,7 +1401,7 @@ async def reply( def test_parameter_tuning_agent_delegates_with_its_model_and_config( monkeypatch: pytest.MonkeyPatch, ) -> None: - from scarf.agent import parameter_tuning as module + from scarf.agent.parameter_tuning import agent as module model = object() expected = ParameterTuningReport.get_blank() @@ -1816,7 +1818,7 @@ async def reply( def test_batched_tuning_pauses_after_structured_output_exhaustion( monkeypatch: pytest.MonkeyPatch, ) -> None: - from scarf.agent import parameter_tuning as module + from scarf.agent.parameter_tuning import agent as module calls: list[str] = [] @@ -1857,7 +1859,7 @@ def unavailable_structured_output(**kwargs: Any) -> None: def test_single_tuning_pauses_after_structured_output_exhaustion( monkeypatch: pytest.MonkeyPatch, ) -> None: - from scarf.agent import parameter_tuning as module + from scarf.agent.parameter_tuning import agent as module def unavailable_structured_output(**_kwargs: Any) -> None: raise UnexpectedModelBehavior("structured output unavailable") @@ -1974,7 +1976,7 @@ def test_single_eligible_final_graph_skips_provider_selection() -> None: def test_final_graph_retry_exhaustion_pauses_when_multiple_options_exist( monkeypatch: pytest.MonkeyPatch, ) -> None: - from scarf.agent import parameter_tuning as module + from scarf.agent.parameter_tuning import selection as module reports: dict[str, ParameterTuningReport] = {} for assay, token in (("RNA", "7"), ("ADT", "8")): @@ -2142,7 +2144,7 @@ def test_parameter_tuning_handoff_validation_edges() -> None: def test_final_graph_option_filtering_and_validation_edges() -> None: report = ParameterTuningReport.get_example() with pytest.raises(ValueError, match="lacks an exact cell selection"): - parameter_tuning_module.final_graph_options( + parameter_tuning_selection.final_graph_options( report.model_copy(update={"cellSelection": None}), [], ) @@ -2154,7 +2156,7 @@ def test_final_graph_option_filtering_and_validation_edges() -> None: "assayReports": {}, } ) - assert parameter_tuning_module.final_graph_options(skipped_report, []) == {} + assert parameter_tuning_selection.final_graph_options(skipped_report, []) == {} ineligible_report = report.model_copy( update={ "evaluations": [ @@ -2163,7 +2165,7 @@ def test_final_graph_option_filtering_and_validation_edges() -> None: "assayReports": {}, } ) - assert parameter_tuning_module.final_graph_options(ineligible_report, []) == {} + assert parameter_tuning_selection.final_graph_options(ineligible_report, []) == {} mismatched_native = report.evaluations[0].model_copy( update={ "cellSelection": ArtifactReferenceModel( @@ -2174,7 +2176,7 @@ def test_final_graph_option_filtering_and_validation_edges() -> None: } ) with pytest.raises(ValueError, match="Native graph option uses a different"): - parameter_tuning_module.final_graph_options( + parameter_tuning_selection.final_graph_options( report.model_copy( update={"evaluations": [mismatched_native], "assayReports": {}} ), @@ -2195,11 +2197,11 @@ def test_final_graph_option_filtering_and_validation_edges() -> None: } ), ): - options = parameter_tuning_module.final_graph_options(report, [ignored]) + options = parameter_tuning_selection.final_graph_options(report, [ignored]) assert all(not key.startswith("integration:") for key in options) with pytest.raises(ValueError, match="Integrated graph option uses a different"): - parameter_tuning_module.final_graph_options( + parameter_tuning_selection.final_graph_options( report, [ integration.model_copy( @@ -2214,12 +2216,12 @@ def test_final_graph_option_filtering_and_validation_edges() -> None: ], ) with pytest.raises(ValueError, match="require integrationId"): - parameter_tuning_module.final_graph_options( + parameter_tuning_selection.final_graph_options( report, [integration.model_copy(update={"integrationId": ""})], ) with pytest.raises(ValueError, match="must be datastore-scoped"): - parameter_tuning_module.final_graph_options( + parameter_tuning_selection.final_graph_options( report, [ integration.model_copy( @@ -2235,7 +2237,9 @@ def test_final_graph_option_filtering_and_validation_edges() -> None: ], ) with pytest.raises(ValueError, match="Duplicate integration id"): - parameter_tuning_module.final_graph_options(report, [integration, integration]) + parameter_tuning_selection.final_graph_options( + report, [integration, integration] + ) def test_normalized_shape_and_candidate_metric_failure_edges() -> None: @@ -2247,14 +2251,14 @@ def load_artifact(self, _ref: Any) -> dict[str, Any]: return self.payload with pytest.raises(ValueError, match="does not contain"): - parameter_tuning_module.normalized_artifact_shape(ShapeStore({}), object()) + parameter_tuning_execution.normalized_artifact_shape(ShapeStore({}), object()) with pytest.raises(ValueError, match="two-dimensional"): - parameter_tuning_module.normalized_artifact_shape( + parameter_tuning_execution.normalized_artifact_shape( ShapeStore({"data": SimpleNamespace(shape=(4,))}), object(), ) with pytest.raises(ValueError, match="at least two cells"): - parameter_tuning_module.normalized_artifact_shape( + parameter_tuning_execution.normalized_artifact_shape( ShapeStore({"data": SimpleNamespace(shape=(1, 4))}), object(), ) @@ -2549,7 +2553,7 @@ def test_final_graph_selection_validation_edges() -> None: evidenceIds=[native_evidence], comparisons=[comparison], ) - validated = parameter_tuning_module.validate_final_graph_selection( + validated = parameter_tuning_selection.validate_final_graph_selection( valid, report, integration_evaluations=[integration], @@ -2558,7 +2562,7 @@ def test_final_graph_selection_validation_edges() -> None: assert validated.graphMethod == "native" with pytest.raises(ValueError, match="must finish"): - parameter_tuning_module.validate_final_graph_selection( + parameter_tuning_selection.validate_final_graph_selection( valid, report.model_copy(update={"status": "failed"}), integration_evaluations=[integration], @@ -2568,63 +2572,63 @@ def test_final_graph_selection_validation_edges() -> None: update={"evaluations": [], "recommendedCandidateId": None, "assayReports": {}} ) with pytest.raises(ValueError, match="No eligible"): - parameter_tuning_module.validate_final_graph_selection( + parameter_tuning_selection.validate_final_graph_selection( valid, no_options, integration_evaluations=[], marker_assay="RNA", ) with pytest.raises(ValueError, match="unknown evidence"): - parameter_tuning_module.validate_final_graph_selection( + parameter_tuning_selection.validate_final_graph_selection( valid.model_copy(update={"evidenceIds": ["unknown"]}), report, integration_evaluations=[integration], marker_assay="RNA", ) with pytest.raises(ValueError, match="concrete question"): - parameter_tuning_module.validate_final_graph_selection( + parameter_tuning_selection.validate_final_graph_selection( FinalGraphSelection(status="needsInput"), report, integration_evaluations=[integration], marker_assay="RNA", ) with pytest.raises(ValueError, match="done or needsInput"): - parameter_tuning_module.validate_final_graph_selection( + parameter_tuning_selection.validate_final_graph_selection( valid.model_copy(update={"status": "failed"}), report, integration_evaluations=[integration], marker_assay="RNA", ) with pytest.raises(ValueError, match="not eligible"): - parameter_tuning_module.validate_final_graph_selection( + parameter_tuning_selection.validate_final_graph_selection( valid.model_copy(update={"selectedOptionId": "missing"}), report, integration_evaluations=[integration], marker_assay="RNA", ) with pytest.raises(ValueError, match="cite selected-option"): - parameter_tuning_module.validate_final_graph_selection( + parameter_tuning_selection.validate_final_graph_selection( valid.model_copy(update={"evidenceIds": []}), report, integration_evaluations=[integration], marker_assay="RNA", ) with pytest.raises(ValueError, match="must not contain duplicates"): - parameter_tuning_module.validate_final_graph_selection( + parameter_tuning_selection.validate_final_graph_selection( valid.model_copy(update={"comparisons": [comparison, comparison]}), report, integration_evaluations=[integration], marker_assay="RNA", ) with pytest.raises(ValueError, match="one comparison for every"): - parameter_tuning_module.validate_final_graph_selection( + parameter_tuning_selection.validate_final_graph_selection( valid.model_copy(update={"comparisons": []}), report, integration_evaluations=[integration], marker_assay="RNA", ) with pytest.raises(ValueError, match="cite selected-option evidence"): - parameter_tuning_module.validate_final_graph_selection( + parameter_tuning_selection.validate_final_graph_selection( valid.model_copy( update={ "comparisons": [ @@ -2639,7 +2643,7 @@ def test_final_graph_selection_validation_edges() -> None: marker_assay="RNA", ) with pytest.raises(ValueError, match="cite comparator evidence"): - parameter_tuning_module.validate_final_graph_selection( + parameter_tuning_selection.validate_final_graph_selection( valid.model_copy( update={ "comparisons": [ @@ -2652,7 +2656,7 @@ def test_final_graph_selection_validation_edges() -> None: marker_assay="RNA", ) with pytest.raises(ValueError, match="requires a summary"): - parameter_tuning_module.validate_final_graph_selection( + parameter_tuning_selection.validate_final_graph_selection( valid.model_copy( update={"comparisons": [comparison.model_copy(update={"summary": ""})]} ), @@ -2681,7 +2685,7 @@ def test_experimental_tuning_handoff_resolution_edges() -> None: batchSafety=[safety], evidenceIds=[safety.evidenceId], ) - resolved = parameter_tuning_module._resolve_experimental_tuning_handoff( + resolved = parameter_tuning_agent._resolve_experimental_tuning_handoff( normalized_cell_selection=selection, batch_columns=[], preservation_columns=[], @@ -2714,21 +2718,21 @@ def test_experimental_tuning_handoff_resolution_edges() -> None: ] for updates, message in changes: with pytest.raises(ValueError, match=message): - parameter_tuning_module._resolve_experimental_tuning_handoff( + parameter_tuning_agent._resolve_experimental_tuning_handoff( normalized_cell_selection=selection, batch_columns=[], preservation_columns=[], experimental_handoff=handoff.model_copy(update=updates), ) with pytest.raises(ValueError, match="batch_columns conflict"): - parameter_tuning_module._resolve_experimental_tuning_handoff( + parameter_tuning_agent._resolve_experimental_tuning_handoff( normalized_cell_selection=selection, batch_columns=["other"], preservation_columns=[], experimental_handoff=handoff, ) with pytest.raises(ValueError, match="preservation_columns conflict"): - parameter_tuning_module._resolve_experimental_tuning_handoff( + parameter_tuning_agent._resolve_experimental_tuning_handoff( normalized_cell_selection=selection, batch_columns=[], preservation_columns=["other"], @@ -2748,18 +2752,18 @@ def test_prepare_parameter_tuning_dependencies_validation_edges() -> None: ({"identity_feature_limit": 1}, "identity_feature_limit"), ): with pytest.raises(ValueError, match=message): - parameter_tuning_module.prepare_parameter_tuning_dependencies( + parameter_tuning_agent.prepare_parameter_tuning_dependencies( store, normalized=normalized, **kwargs, ) with pytest.raises(TypeError, match="normalized ArtifactRef"): - parameter_tuning_module.prepare_parameter_tuning_dependencies( + parameter_tuning_agent.prepare_parameter_tuning_dependencies( store, normalized=_artifact("reduction", 10), ) with pytest.raises(ValueError, match="has no assay"): - parameter_tuning_module.prepare_parameter_tuning_dependencies( + parameter_tuning_agent.prepare_parameter_tuning_dependencies( store, normalized=ArtifactRef( scope="datastore", @@ -2799,25 +2803,25 @@ def inspect_artifact(self, _normalized: ArtifactRef) -> Any: for status, message in statuses: with pytest.raises(ValueError, match=message): invalid_store = StatusStore(status) - parameter_tuning_module.prepare_parameter_tuning_dependencies( + parameter_tuning_agent.prepare_parameter_tuning_dependencies( invalid_store, normalized=invalid_store.normalized, ) with pytest.raises(ValueError, match="batch_columns must be unique"): - parameter_tuning_module.prepare_parameter_tuning_dependencies( + parameter_tuning_agent.prepare_parameter_tuning_dependencies( store, normalized=normalized, batch_columns=["batch", "batch"], ) with pytest.raises(ValueError, match="candidates must be non-empty"): - parameter_tuning_module.prepare_parameter_tuning_dependencies( + parameter_tuning_agent.prepare_parameter_tuning_dependencies( store, normalized=normalized, candidates=[], ) with pytest.raises(ValueError, match="exceeds max_candidates"): - parameter_tuning_module.prepare_parameter_tuning_dependencies( + parameter_tuning_agent.prepare_parameter_tuning_dependencies( store, normalized=normalized, candidates=[ @@ -2827,13 +2831,13 @@ def inspect_artifact(self, _normalized: ArtifactRef) -> Any: max_candidates=1, ) with pytest.raises(ValueError, match="only ASCII"): - parameter_tuning_module.prepare_parameter_tuning_dependencies( + parameter_tuning_agent.prepare_parameter_tuning_dependencies( store, normalized=normalized, candidates=[candidate.model_copy(update={"candidateId": "bad-id"})], ) with pytest.raises(ValueError, match="Duplicate candidateId"): - parameter_tuning_module.prepare_parameter_tuning_dependencies( + parameter_tuning_agent.prepare_parameter_tuning_dependencies( store, normalized=normalized, candidates=[candidate, candidate], diff --git a/tests/test_agent_report.py b/tests/test_agent_report.py index 54961a35..d583d86c 100644 --- a/tests/test_agent_report.py +++ b/tests/test_agent_report.py @@ -14,11 +14,20 @@ from pydantic_ai import ModelRetry, UnexpectedModelBehavior import scarf.agent as agent_api -import scarf.agent.biological_interpretation as biological_module +import scarf.agent.biological_interpretation.tools as biological_tools +import scarf.agent.biological_interpretation.validation as biological_validation import scarf.agent.config.agent_exec as agent_exec_module -import scarf.agent.data_enrichment as enrichment_module -import scarf.agent.experimental_context as experimental_module -import scarf.agent.report as report_module +import scarf.agent.data_enrichment.agent as enrichment_agent +import scarf.agent.data_enrichment.tools as enrichment_tools +import scarf.agent.data_enrichment.validation as enrichment_validation +import scarf.agent.experimental_context.tools as experimental_tools +import scarf.agent.experimental_context.validation as experimental_validation +import scarf.agent.report.artifacts as report_artifacts +import scarf.agent.report.contracts as report_contracts +import scarf.agent.report.decision_tree as report_decision_tree +import scarf.agent.report.generator as report_generator +import scarf.agent.report.plots as report_plots +import scarf.agent.report.rendering as report_rendering import scarf.agent.orchestrator.journal as journal_module import scarf.agent.orchestrator.main as orchestrator_main from scarf.agent import ( @@ -33,13 +42,15 @@ load_agent_workflow, ) from scarf.agent.biological_interpretation import ( - BiologicalInterpretationDependencies, BiologicalInterpretationNeedsInput, BiologicalInterpretationReport, ClusterCompositionEvidence, ClusterMarkerEvidence, ) -from scarf.agent.characterize_covariates import CovariateCharacterization +from scarf.agent.biological_interpretation.contracts import ( + BiologicalInterpretationDependencies, +) +from scarf.agent.experimental_context.contracts import CovariateCharacterization from scarf.agent.data_enrichment import ( AssayFeatureInspection, DataEnrichmentAgent, @@ -454,23 +465,23 @@ def _patch_completed_workflow( ) monkeypatch.setattr( - report_module, + report_generator, "load_agent_workflow", lambda *_a, **_k: workflow, ) - monkeypatch.setattr(report_module, "_open_datastore", lambda *_a, **_k: object()) + monkeypatch.setattr(report_generator, "_open_datastore", lambda *_a, **_k: object()) monkeypatch.setattr( - report_module, + report_generator, "_load_completed_result", lambda *_a, **_k: ("agents/orchestrations", result, request_record), ) monkeypatch.setattr( - report_module, + report_generator, "_collect_reports", lambda *_a, **_k: _reports(study_context), ) monkeypatch.setattr( - report_module, + report_generator, "_collect_history", lambda *_a, **_k: ( [ @@ -498,9 +509,9 @@ def _patch_completed_workflow( [], ), ) - monkeypatch.setattr(report_module, "_collect_active_decisions", lambda *_a: {}) + monkeypatch.setattr(report_generator, "_collect_active_decisions", lambda *_a: {}) monkeypatch.setattr( - report_module, + report_generator, "_collect_default_feature_inventories", lambda *_a: [ { @@ -555,7 +566,7 @@ def collect_artifacts( [], ) - monkeypatch.setattr(report_module, "_collect_final_artifacts", collect_artifacts) + monkeypatch.setattr(report_generator, "_collect_final_artifacts", collect_artifacts) def collect_hvg_plots( _store: object, @@ -572,9 +583,9 @@ def collect_hvg_plots( ) return {"hvgGlobal": "plots/hvg_global.png"}, [] - monkeypatch.setattr(report_module, "_collect_hvg_plots", collect_hvg_plots) + monkeypatch.setattr(report_generator, "_collect_hvg_plots", collect_hvg_plots) monkeypatch.setattr( - report_module, + report_generator, "_collect_hvg_evidence", lambda *_a, **_k: { "assay": "RNA", @@ -820,21 +831,21 @@ def test_harmony_diagnostic_reports_execution_metrics_and_rejection_reason() -> "correctionOutcome": {"rationale": rejection}, } - evidence_markup = report_module._render_batch_evidence( + evidence_markup = report_rendering._render_batch_evidence( experimental, parameter, final, decisions, ) - stage = report_module._batch_tree_stage( + stage = report_decision_tree._batch_tree_stage( experimental, parameter, final, decisions, ) assert stage is not None - tree_markup = report_module._render_decision_tree([stage]) - technical_markup = report_module._render_harmony_technical_audit( + tree_markup = report_decision_tree._render_decision_tree([stage]) + technical_markup = report_rendering._render_harmony_technical_audit( experimental, parameter, final, @@ -876,7 +887,7 @@ def test_report_uses_workspace_path_and_can_be_regenerated( first.with_name("technical.html").write_text("stale technical", encoding="utf-8") monkeypatch.setattr( - report_module, + report_generator, "_collect_reports", lambda *_a, **_k: _reports("Regenerated context"), ) @@ -926,11 +937,11 @@ def test_report_rejects_remote_and_non_completed_workflows( zarr.open_group(str(root), mode="w", zarr_format=3) running = _workflow(status="running") monkeypatch.setattr( - report_module, + report_generator, "load_agent_workflow", lambda *_a, **_k: running, ) - monkeypatch.setattr(report_module, "_open_datastore", lambda *_a, **_k: object()) + monkeypatch.setattr(report_generator, "_open_datastore", lambda *_a, **_k: object()) with pytest.raises(RuntimeError, match="completed workflows"): generate_agent_report(root, running.workflowRunId) @@ -951,7 +962,7 @@ def test_orchestrator_generates_only_completed_local_reports_non_fatally( lambda _store: tmp_path / "data.zarr", ) monkeypatch.setattr( - report_module, + report_generator, "generate_agent_report", lambda target, workflow_run_id: ( generated.append((target, workflow_run_id)) @@ -978,7 +989,7 @@ def test_orchestrator_generates_only_completed_local_reports_non_fatally( lambda _store: tmp_path / "data.zarr", ) monkeypatch.setattr( - report_module, + report_generator, "generate_agent_report", lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("plot failed")), ) @@ -1021,31 +1032,32 @@ def __init__(self, *args: object, **kwargs: object) -> None: self.workspace = kwargs.get("workspace") self.z = object() - monkeypatch.setattr(report_module, "DataStore", LocalDataStore) - monkeypatch.setattr(report_module, "zarr_root_path", lambda _store: None) + monkeypatch.setattr(report_artifacts, "DataStore", LocalDataStore) + monkeypatch.setattr(report_generator, "DataStore", LocalDataStore) + monkeypatch.setattr(report_artifacts, "zarr_root_path", lambda _store: None) with pytest.raises(ValueError, match="local filesystem"): - report_module._local_root(LocalDataStore()) + report_artifacts._local_root(LocalDataStore()) monkeypatch.setattr( - report_module, + report_artifacts, "zarr_root_path", lambda _store: local_root, ) - assert report_module._local_root(f"file://{local_root}") == local_root.resolve() - assert report_module._local_root(str(local_root)) == local_root.resolve() + assert report_artifacts._local_root(f"file://{local_root}") == local_root.resolve() + assert report_artifacts._local_root(str(local_root)) == local_root.resolve() with pytest.raises(TypeError, match="local filesystem"): - report_module._local_root(object()) + report_artifacts._local_root(object()) with pytest.raises(FileNotFoundError): - report_module._local_root(tmp_path / "missing.zarr") + report_artifacts._local_root(tmp_path / "missing.zarr") workflow = _workflow() with pytest.raises(ValueError, match="workspace"): - report_module._open_datastore( + report_artifacts._open_datastore( LocalDataStore(workspace="other"), local_root, workflow, ) - opened = report_module._open_datastore(local_root, local_root, workflow) + opened = report_artifacts._open_datastore(local_root, local_root, workflow) assert opened.args == (str(local_root),) assert opened.kwargs["default_assay"] == "RNA" assert opened.kwargs["zarr_mode"] == "r" @@ -1067,7 +1079,7 @@ def __init__(self, *args: object, **kwargs: object) -> None: lambda *_args, **_kwargs: current_record, ) store = SimpleNamespace(z=object(), zw=object()) - assert report_module._load_request(store, "agents", "workflow-1") == valid_record + assert report_artifacts._load_request(store, "agents", "workflow-1") == valid_record invalid_records = ( ( @@ -1089,7 +1101,7 @@ def __init__(self, *args: object, **kwargs: object) -> None: ) for current_record, message in invalid_records: with pytest.raises(ValueError, match=message): - report_module._load_request(store, "agents", "workflow-1") + report_artifacts._load_request(store, "agents", "workflow-1") monkeypatch.setattr( journal_module, @@ -1103,22 +1115,22 @@ def __init__(self, *args: object, **kwargs: object) -> None: lambda *_args, **_kwargs: terminal_result, ) with pytest.raises(FileNotFoundError, match="no terminal result"): - report_module._load_completed_result(store, workflow) + report_artifacts._load_completed_result(store, workflow) terminal_result = SimpleNamespace(status="failed", finalAnalysis=object()) with pytest.raises(ValueError, match="final analysis"): - report_module._load_completed_result(store, workflow) + report_artifacts._load_completed_result(store, workflow) terminal_result = SimpleNamespace(status="completed", finalAnalysis=object()) monkeypatch.setattr( - report_module, + report_artifacts, "_load_request", lambda *_args, **_kwargs: SimpleNamespace( request=SimpleNamespace(workspace="other") ), ) with pytest.raises(ValueError, match="request workspace"): - report_module._load_completed_result(store, workflow) + report_artifacts._load_completed_result(store, workflow) invalid_attempt = WorkflowStageAttempt( status="failed", @@ -1126,7 +1138,7 @@ def __init__(self, *args: object, **kwargs: object) -> None: completedAtNs=2, error="not a valid error type!?: details", ) - assert report_module._stage_summary(invalid_attempt)["errorType"] == ( + assert report_artifacts._stage_summary(invalid_attempt)["errorType"] == ( "WorkflowStageError" ) @@ -1188,7 +1200,7 @@ class HvgStore: def load_artifact(self, ref: Any) -> Any: return groups[ref.artifact_id] - evidence = report_module._collect_hvg_evidence( + evidence = report_artifacts._collect_hvg_evidence( HvgStore(), [ { @@ -1235,39 +1247,39 @@ def load_artifact(self, ref: Any) -> Any: def test_report_renderer_edge_branches() -> None: - assert report_module._safe_assay_name("RNA / strange assay", "fallback") == ( + assert report_plots._safe_assay_name("RNA / strange assay", "fallback") == ( "rna_strange_assay" ) - assert report_module._safe_assay_name("***", "fallback") == "fallback" - assert report_module._scalar(None) == "Not provided" - assert "Nothing" in report_module._chips(None, empty="Nothing") - assert "value" in report_module._chips("value") - public_text = report_module._brief_text( + assert report_plots._safe_assay_name("***", "fallback") == "fallback" + assert report_contracts._scalar(None) == "Not provided" + assert "Nothing" in report_rendering._chips(None, empty="Nothing") + assert "value" in report_rendering._chips("value") + public_text = report_contracts._brief_text( f"Preserve donor_id from {'a' * 64} and 12345678-1234-1234-1234-123456789abc." ) assert "donor_id" not in public_text assert "a" * 64 not in public_text assert "12345678-1234-1234-1234-123456789abc" not in public_text - assert report_module._latest({"agent": {"status": "done"}}, "agent") == { + assert report_contracts._latest({"agent": {"status": "done"}}, "agent") == { "status": "done" } - assert report_module._latest({}, "agent") == {} + assert report_contracts._latest({}, "agent") == {} - native_plot = report_module._render_plots( + native_plot = report_plots._render_plots( {"nativeUmapRna": "plots/native.png"}, [], ) assert "Rna native UMAP" in native_plot assert "finalized native Rna" in native_plot - assert "No final cluster counts" in report_module._render_clusters({}) + assert "No final cluster counts" in report_rendering._render_clusters({}) legacy_parameter = { "fromAssay": "RNA", "evaluations": [{"candidateId": "native"}], } - assert report_module._parameter_rows(legacy_parameter)[0]["assay"] == "RNA" - assert "No Parameter Tuning report" in report_module._render_parameter_tuning({}) - rendered_parameter = report_module._render_parameter_tuning( + assert report_rendering._parameter_rows(legacy_parameter)[0]["assay"] == "RNA" + assert "No Parameter Tuning report" in report_rendering._render_parameter_tuning({}) + rendered_parameter = report_rendering._render_parameter_tuning( { "fromAssay": "RNA", "searchPlan": {"status": "refine"}, @@ -1277,13 +1289,13 @@ def test_report_renderer_edge_branches() -> None: ) assert "RNA" in rendered_parameter assert "final graph" in rendered_parameter - assert "No provider execution metadata" in report_module._render_executions({}) + assert "No provider execution metadata" in report_rendering._render_executions({}) def test_wide_technical_records_use_readable_card_layout() -> None: wide_row = {f"field_{index}": f"value {index}" for index in range(8)} wide_row["_selected"] = True - wide_markup = report_module._table([wide_row]) + wide_markup = report_rendering._table([wide_row]) assert " None: assert "Field 7" in wide_markup assert "value 7" in wide_markup - compact_markup = report_module._table([{"name": "candidate", "score": 0.5}]) + compact_markup = report_rendering._table([{"name": "candidate", "score": 0.5}]) assert "Name" in compact_markup @@ -1398,13 +1410,13 @@ def get_markers( } ) - monkeypatch.setattr(report_module, "MAX_EMBEDDING_PLOT_CELLS", 0) - monkeypatch.setattr(report_module, "MAX_COMPOSITION_PLOT_CELLS", 0) - monkeypatch.setattr(report_module, "MAX_DOTPLOT_CELLS", 0) - monkeypatch.setattr(report_module, "MAX_CONNECTIVITY_PLOT_CELLS", 0) + monkeypatch.setattr(report_plots, "MAX_EMBEDDING_PLOT_CELLS", 0) + monkeypatch.setattr(report_plots, "MAX_COMPOSITION_PLOT_CELLS", 0) + monkeypatch.setattr(report_plots, "MAX_DOTPLOT_CELLS", 0) + monkeypatch.setattr(report_plots, "MAX_CONNECTIVITY_PLOT_CELLS", 0) store = ArtifactStore() - counts, markers, plots, notes = report_module._collect_final_artifacts( + counts, markers, plots, notes = report_plots._collect_final_artifacts( store, result, tmp_path / "plots", @@ -1417,17 +1429,15 @@ def get_markers( assert any("markerDotplot: skipped" in note for note in notes) assert any("clusterConnectivity: skipped" in note for note in notes) - monkeypatch.setattr(report_module, "MAX_MARKER_DOTPLOT_FEATURES", 1) - _counts, _markers, _plots, one_marker_notes = ( - report_module._collect_final_artifacts(store, result, tmp_path / "plots-one") + monkeypatch.setattr(report_plots, "MAX_MARKER_DOTPLOT_FEATURES", 1) + _counts, _markers, _plots, one_marker_notes = report_plots._collect_final_artifacts( + store, result, tmp_path / "plots-one" ) assert any("markerDotplot: skipped" in note for note in one_marker_notes) - monkeypatch.setattr(report_module, "MAX_MARKER_DOTPLOT_FEATURES", object()) + monkeypatch.setattr(report_plots, "MAX_MARKER_DOTPLOT_FEATURES", object()) _counts, _markers, _plots, invalid_limit_notes = ( - report_module._collect_final_artifacts( - store, result, tmp_path / "plots-invalid" - ) + report_plots._collect_final_artifacts(store, result, tmp_path / "plots-invalid") ) assert any("markerDotplot: TypeError" in note for note in invalid_limit_notes) @@ -1435,7 +1445,7 @@ def get_markers( update={"finalAnalysis": FinalAnalysisHandoff.get_blank()} ) with pytest.raises(ValueError, match="lacks its selection"): - report_module._collect_final_artifacts( + report_plots._collect_final_artifacts( store, incomplete, tmp_path / "plots-incomplete", @@ -1461,7 +1471,7 @@ def test_data_enrichment_cache_rollback_and_pending_branches( assert ( asyncio.run( - enrichment_module.inspect_assay_features( + enrichment_tools.inspect_assay_features( completed_context, assay_name="RNA", ) @@ -1469,7 +1479,7 @@ def test_data_enrichment_cache_rollback_and_pending_branches( == inspection ) cached_batch = asyncio.run( - enrichment_module.inspect_assay_features_batch(completed_context) + enrichment_tools.inspect_assay_features_batch(completed_context) ) assert cached_batch.inspections == [inspection] assert cached_batch.evidenceIds == inspection.evidenceIds @@ -1480,7 +1490,7 @@ def test_data_enrichment_cache_rollback_and_pending_branches( ) with pytest.raises(ModelRetry, match="datastore"): asyncio.run( - enrichment_module.inspect_assay_features_batch( + enrichment_tools.inspect_assay_features_batch( SimpleNamespace(deps=incomplete) ) ) @@ -1488,12 +1498,12 @@ def test_data_enrichment_cache_rollback_and_pending_branches( provider_error = UnexpectedModelBehavior("provider output failed") with pytest.raises(UnexpectedModelBehavior, match="provider output failed"): - enrichment_module.pending_data_enrichment_report( + enrichment_validation.pending_data_enrichment_report( DataEnrichmentDependencies(assays=["RNA"]), error=provider_error, model_name="test-model", ) - pending = enrichment_module.pending_data_enrichment_report( + pending = enrichment_validation.pending_data_enrichment_report( DataEnrichmentDependencies( assays=["RNA"], inspections={"RNA": inspection}, @@ -1509,7 +1519,7 @@ def test_data_enrichment_cache_rollback_and_pending_branches( def fail_before_inspection(**_kwargs: object) -> object: raise UnexpectedModelBehavior("no inspection completed") - monkeypatch.setattr(enrichment_module, "run_agent_sync", fail_before_inspection) + monkeypatch.setattr(enrichment_agent, "run_agent_sync", fail_before_inspection) store = SimpleNamespace(assay_names=["RNA"]) with pytest.raises(UnexpectedModelBehavior, match="no inspection completed"): DataEnrichmentAgent(object()).run(store) @@ -1522,7 +1532,7 @@ def test_biological_interpretation_cache_and_fallback_branches() -> None: ) assert ( asyncio.run( - biological_module.inspect_cluster_composition( + biological_tools.inspect_cluster_composition( SimpleNamespace(deps=composition_deps) ) ) @@ -1536,7 +1546,7 @@ def test_biological_interpretation_cache_and_fallback_branches() -> None: ) assert ( asyncio.run( - biological_module.inspect_cluster_markers( + biological_tools.inspect_cluster_markers( SimpleNamespace(deps=marker_deps), cluster_id=marker.clusterId, ) @@ -1549,19 +1559,19 @@ def test_biological_interpretation_cache_and_fallback_branches() -> None: needsInput=BiologicalInterpretationNeedsInput(question="More context?"), ) with pytest.raises(ModelRetry, match="Only a needsInput"): - biological_module.validate_biological_interpretation_report( + biological_validation.validate_biological_interpretation_report( invalid_report, BiologicalInterpretationDependencies(clusterValues={"0": 0}), ) provider_error = UnexpectedModelBehavior("structured output failed") with pytest.raises(UnexpectedModelBehavior, match="structured output failed"): - biological_module.fallback_biological_interpretation_report( + biological_validation.fallback_biological_interpretation_report( BiologicalInterpretationDependencies(), error=provider_error, model_name="test-model", ) - needs_markers = biological_module.fallback_biological_interpretation_report( + needs_markers = biological_validation.fallback_biological_interpretation_report( BiologicalInterpretationDependencies( clusterValues={"0": 0}, evidenceIds={"composition:clusters"}, @@ -1603,7 +1613,7 @@ def test_experimental_context_rejects_invalid_batches_and_builds_pending_result( ) with pytest.raises(ModelRetry, match=message): asyncio.run( - experimental_module.analyze_experimental_design( + experimental_tools.analyze_experimental_design( SimpleNamespace(deps=deps), column_domains={}, coefficients_of_interest=[], @@ -1617,7 +1627,7 @@ def test_experimental_context_rejects_invalid_batches_and_builds_pending_result( columns=[{"name": "condition", "domain": "biological", "kind": "categorical"}], ) monkeypatch.setattr( - experimental_module, + experimental_validation, "characterize_covariates", lambda *_args, **_kwargs: characterization, ) @@ -1630,7 +1640,11 @@ def offer_profile( deps.qcProfiles[profile.profileId] = profile return [profile] - monkeypatch.setattr(experimental_module, "_offered_qc_profiles", offer_profile) + monkeypatch.setattr( + experimental_validation, + "_offered_qc_profiles", + offer_profile, + ) pending_deps = ExperimentalContextDependencies( cellSelection=ArtifactReferenceModel( scope="datastore", @@ -1639,7 +1653,7 @@ def offer_profile( ), htoIdentityColumns=["hto_identity"], ) - pending = experimental_module.pending_experimental_context_result( + pending = experimental_validation.pending_experimental_context_result( pending_deps, error=UnexpectedModelBehavior("design output failed"), model_name="test-model", diff --git a/tests/test_agent_rna_decisions.py b/tests/test_agent_rna_decisions.py index de4a32a8..c48cb5db 100644 --- a/tests/test_agent_rna_decisions.py +++ b/tests/test_agent_rna_decisions.py @@ -3,12 +3,12 @@ import pytest from pydantic import ValidationError -from scarf.agent.decision_kernel import ( +from scarf.agent.decisions.kernel import ( DecisionEvidence, DecisionRecord, EvidenceBundle, ) -from scarf.agent.rna_decisions import ( +from scarf.agent.decisions.rna import ( ClusterExecutorPayload, CorrectionOutcomeExecutorPayload, GraphExecutorPayload, diff --git a/tests/test_agent_sequential_tuning.py b/tests/test_agent_sequential_tuning.py index 84305994..3283e850 100644 --- a/tests/test_agent_sequential_tuning.py +++ b/tests/test_agent_sequential_tuning.py @@ -8,7 +8,7 @@ ParameterCandidate, ParameterCandidateEvaluation, ) -from scarf.agent.sequential_tuning import ( +from scarf.agent.parameter_tuning.sequential import ( CorrectionNeedSelection, ParameterPhaseEvidence, ParameterPhasePlan, @@ -334,11 +334,11 @@ def execute(deps: object, candidate_id: str) -> ParameterCandidateEvaluation: return _evaluation(by_id[candidate_id]) monkeypatch.setattr( - "scarf.agent.sequential_tuning.prepare_parameter_tuning_dependencies", + "scarf.agent.parameter_tuning.sequential.prepare_parameter_tuning_dependencies", prepare, ) monkeypatch.setattr( - "scarf.agent.sequential_tuning.execute_parameter_candidate", + "scarf.agent.parameter_tuning.sequential.execute_parameter_candidate", execute, ) diff --git a/tests/test_agent_tuning_diagnostics.py b/tests/test_agent_tuning_diagnostics.py index ce6f620c..b3aa2d74 100644 --- a/tests/test_agent_tuning_diagnostics.py +++ b/tests/test_agent_tuning_diagnostics.py @@ -2,7 +2,7 @@ import pytest from scipy.sparse import block_diag, csr_matrix -from scarf.agent.tuning_diagnostics import ( +from scarf.agent.parameter_tuning.diagnostics import ( _cross_unit_support, _subsample_partition_stability, ) diff --git a/tests/test_import_architecture.py b/tests/test_import_architecture.py index 8e33bec6..f9c6b1ce 100644 --- a/tests/test_import_architecture.py +++ b/tests/test_import_architecture.py @@ -558,6 +558,151 @@ def test_internal_modules_do_not_use_moved_symbols_from_hybrid_facades(): assert _moved_symbol_imports() == set() +def test_agent_implementations_live_in_owner_packages(): + agent_root = _SCARF_ROOT / "agent" + retired = { + "biological_interpretation.py", + "characterize_covariates.py", + "characterize_features.py", + "data_enrichment.py", + "decide.py", + "decision_kernel.py", + "decision_persistence.py", + "experimental_context.py", + "hvg_diagnostics.py", + "hypothesis_testing.py", + "parameter_tuning.py", + "persistence.py", + "qc_execution.py", + "qc_profiles.py", + "report.py", + "rna_decisions.py", + "sequential_tuning.py", + "study_contract.py", + "tuning_diagnostics.py", + } + required = { + "biological_interpretation": { + "__init__.py", + "agent.py", + "contracts.py", + "tools.py", + "validation.py", + }, + "cell_quality": {"__init__.py", "execution.py", "profiles.py"}, + "data_enrichment": { + "__init__.py", + "agent.py", + "characterization.py", + "contracts.py", + "tools.py", + "validation.py", + }, + "decisions": {"__init__.py", "kernel.py", "rna.py", "selection.py"}, + "experimental_context": { + "__init__.py", + "agent.py", + "characterization.py", + "contracts.py", + "qc_evidence.py", + "study.py", + "tools.py", + "validation.py", + }, + "hypotheses": {"__init__.py", "contracts.py", "execution.py"}, + "parameter_tuning": { + "__init__.py", + "agent.py", + "contracts.py", + "diagnostics.py", + "execution.py", + "hvg.py", + "prompts.py", + "selection.py", + "sequential.py", + }, + "persistence": { + "__init__.py", + "contracts.py", + "decisions.py", + "reports.py", + }, + "report": { + "__init__.py", + "artifacts.py", + "contracts.py", + "decision_tree.py", + "generator.py", + "plots.py", + "rendering.py", + }, + } + + assert retired.isdisjoint(path.name for path in agent_root.glob("*.py")) + for package, names in required.items(): + package_root = agent_root / package + assert package_root.is_dir() + assert names <= {path.name for path in package_root.glob("*.py")} + + +def test_agent_contracts_do_not_import_orchestration_or_reporting(): + contracts = sorted((_SCARF_ROOT / "agent").glob("*/contracts.py")) + forbidden = ("agent.orchestrator", "agent.report") + for path in contracts: + imports = _runtime_import_modules(path, include_function_local=False) + assert not { + module + for module in imports + if module.startswith(tuple(f"{root}." for root in forbidden)) + or module in forbidden + } + + shared_tools = _SCARF_ROOT / "agent" / "tools" / "__init__.py" + shared_imports = _runtime_import_modules( + shared_tools, + include_function_local=False, + ) + assert not { + module + for module in shared_imports + if module.startswith( + ( + "agent.biological_interpretation", + "agent.data_enrichment", + "agent.experimental_context", + "agent.orchestrator", + "agent.parameter_tuning", + "agent.persistence", + "agent.report", + ) + ) + } + + +def test_agent_internal_modules_import_concrete_owners(): + facades = { + "agent.biological_interpretation", + "agent.cell_quality", + "agent.data_enrichment", + "agent.decisions", + "agent.experimental_context", + "agent.hypotheses", + "agent.parameter_tuning", + "agent.persistence", + "agent.report", + } + violations: set[tuple[str, str]] = set() + agent_root = _SCARF_ROOT / "agent" + for path in agent_root.rglob("*.py"): + if path == agent_root / "__init__.py" or path.name == "__init__.py": + continue + for module in _runtime_import_modules(path): + if module in facades: + violations.add((path.relative_to(agent_root).as_posix(), module)) + + assert violations == set() + + def test_compatibility_only_modules_are_removed(): retired = { _SCARF_ROOT / "_types.py", diff --git a/tests/test_registered_qc_profiles.py b/tests/test_registered_qc_profiles.py index 034e1501..464999ba 100644 --- a/tests/test_registered_qc_profiles.py +++ b/tests/test_registered_qc_profiles.py @@ -10,7 +10,7 @@ from pydantic import ValidationError from zarr.storage import MemoryStore -import scarf.agent.experimental_context as experimental_context_module +import scarf.agent.experimental_context.validation as experimental_context_validation import scarf.agent.orchestrator.preprocessing as preprocessing_module from scarf.agent.experimental_context import ( CellQcPlan, @@ -19,8 +19,8 @@ inspect_cell_covariates, ) from scarf.agent.orchestrator.main import AgentOrchestrator -from scarf.agent.qc_execution import execute_registered_cell_qc -from scarf.agent.qc_profiles import ( +from scarf.agent.cell_quality.execution import execute_registered_cell_qc +from scarf.agent.cell_quality.profiles import ( RegisteredQcProjection, offered_registered_qc_profiles, project_registered_qc_profile, @@ -336,7 +336,7 @@ def test_experimental_context_offers_and_validates_registered_global_profile() - "fixedCutoff": None, } - selected = experimental_context_module._canonical_cell_qc_plan( + selected = experimental_context_validation._canonical_cell_qc_plan( CellQcPlan(), context.deps, inspected.characterization, From 5445197f92e0a8623f44009d953b571b6b888eb2 Mon Sep 17 00:00:00 2001 From: gautam8387 Date: Sun, 6 Sep 2026 01:34:21 +0200 Subject: [PATCH 09/21] agent fix; test; docs --- .../base.ipynb | 1044 +++++++++++++++++ .../base.ipynb | 944 --------------- docs/.jupyter_cache/global.db | Bin 36864 -> 36864 bytes docs/source/analysis_with_agents.md | 53 +- docs/source/developers/architecture.md | 8 + docs/source/tutorials/agent_workflow.md | 247 ++-- scarf/agent/decisions/rna.py | 6 +- scarf/agent/orchestrator/decisions.py | 4 + scarf/agent/orchestrator/finalization.py | 70 +- scarf/agent/parameter_tuning/diagnostics.py | 29 +- tests/test_agent_decision_persistence.py | 33 + tests/test_agent_rna_decisions.py | 10 + tests/test_agent_tuning_diagnostics.py | 62 + 13 files changed, 1396 insertions(+), 1114 deletions(-) create mode 100644 docs/.jupyter_cache/executed/c573a3cbb5a0f0e5d6b83d5f6b5a9d45/base.ipynb delete mode 100644 docs/.jupyter_cache/executed/d46bae099f63faffe6d8c4de5a9bc1d1/base.ipynb diff --git a/docs/.jupyter_cache/executed/c573a3cbb5a0f0e5d6b83d5f6b5a9d45/base.ipynb b/docs/.jupyter_cache/executed/c573a3cbb5a0f0e5d6b83d5f6b5a9d45/base.ipynb new file mode 100644 index 00000000..2b3a2ec6 --- /dev/null +++ b/docs/.jupyter_cache/executed/c573a3cbb5a0f0e5d6b83d5f6b5a9d45/base.ipynb @@ -0,0 +1,1044 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "c2a291b2", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
    Downloading bucket files18098007 / 18098007 complete18098007 / 18098007 complete
    " + ], + "text/plain": [ + "Downloading bucket files: 18098007 / 18098007 complete" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
    Downloading bytes18098007 / 18098007 complete18098007 / 18098007 complete
    " + ], + "text/plain": [ + "Downloading bytes: 18098007 / 18098007 complete" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "{'source': 'data.h5', 'destination': 'agent_workflow.zarr'}" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from contextlib import redirect_stdout\n", + "from io import StringIO\n", + "from pathlib import Path\n", + "\n", + "import scarf\n", + "from scarf.agent import (\n", + " AgentOrchestrator,\n", + " AgentRunConfig,\n", + " AutomatedWorkflowConfig,\n", + " AutomatedWorkflowRequest,\n", + " DecisionSelection,\n", + " generate_agent_report,\n", + " load_agent_report,\n", + " load_agent_workflow,\n", + ")\n", + "from scarf.agent.orchestrator import artifact_model_to_ref\n", + "\n", + "scarf.configure_output(level=\"WARNING\", progress=False)\n", + "\n", + "source_path = scarf.cytebase.connect(\"scarf_docs\").download(\n", + " \"tenx_5K_pbmc_rnaseq/data.h5\",\n", + " destination=\"scarf_datasets\",\n", + ")[0]\n", + "zarr_path = source_path.with_name(\"agent_workflow.zarr\")\n", + "\n", + "study_context = (\n", + " \"This is a human 10x Genomics 5K PBMC 3-prime gene-expression dataset \"\n", + " \"from peripheral blood collected from one healthy donor. The goal is \"\n", + " \"unsupervised identification and characterization of the major immune-cell \"\n", + " \"populations. No treatment comparison, technical batch covariate, paired \"\n", + " \"modality, or independent replication metadata is available. Do not invent \"\n", + " \"absent design variables or report treatment effects.\"\n", + ")\n", + "\n", + "{\"source\": source_path.name, \"destination\": zarr_path.name}" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "57e12005", + "metadata": { + "tags": [ + "remove-cell" + ] + }, + "outputs": [], + "source": [ + "import json\n", + "from typing import Any\n", + "\n", + "from pydantic_ai.messages import (\n", + " ModelMessage,\n", + " ModelResponse,\n", + " ToolCallPart,\n", + " ToolReturnPart,\n", + ")\n", + "from pydantic_ai.models.function import AgentInfo, FunctionModel\n", + "\n", + "from scarf.agent.biological_interpretation import (\n", + " BiologicalInterpretationReport,\n", + " ClusterCompositionEvidence,\n", + " ClusterInterpretation,\n", + " ClusterMarkerBatchEvidence,\n", + ")\n", + "from scarf.agent.data_enrichment import (\n", + " AssayFeatureInspectionBatch,\n", + " DataEnrichmentReport,\n", + " FeatureSelectionPolicy,\n", + " StudyContextSummary,\n", + ")\n", + "from scarf.agent.experimental_context import (\n", + " BatchCorrectionPlan,\n", + " CovariateEvidence,\n", + " ExperimentalContextDecision,\n", + ")\n", + "\n", + "def _prompt_text(messages: list[ModelMessage]) -> str:\n", + " values = []\n", + " for message in messages:\n", + " for part in message.parts:\n", + " content = getattr(part, \"content\", None)\n", + " if isinstance(content, str):\n", + " values.append(content)\n", + " elif isinstance(content, tuple):\n", + " values.extend(item for item in content if isinstance(item, str))\n", + " return \"\\n\".join(values)\n", + "\n", + "\n", + "def _tool_result(\n", + " messages: list[ModelMessage],\n", + " tool_name: str,\n", + " model_type: Any,\n", + ") -> Any:\n", + " for message in reversed(messages):\n", + " for part in reversed(message.parts):\n", + " if isinstance(part, ToolReturnPart) and part.tool_name == tool_name:\n", + " if isinstance(part.content, model_type):\n", + " return part.content\n", + " if isinstance(part.content, str):\n", + " return model_type.model_validate_json(part.content)\n", + " return model_type.model_validate(part.content)\n", + " raise AssertionError(f\"Missing tool return {tool_name!r}\")\n", + "\n", + "\n", + "def _tool_call(name: str, args: dict[str, Any] | None = None) -> ModelResponse:\n", + " return ModelResponse(parts=[ToolCallPart(tool_name=name, args=args or {})])\n", + "\n", + "\n", + "def _structured_output(info: AgentInfo, value: Any) -> ModelResponse:\n", + " payload = value.model_dump() if hasattr(value, \"model_dump\") else value\n", + " return _tool_call(info.output_tools[0].name, payload)\n", + "\n", + "\n", + "def _scripted_workflow_model() -> tuple[FunctionModel, dict[str, int]]:\n", + " state = {\n", + " \"enrichment\": 0,\n", + " \"context\": 0,\n", + " \"parameter\": 0,\n", + " \"biology\": 0,\n", + " \"requests\": 0,\n", + " }\n", + "\n", + " async def reply(\n", + " messages: list[ModelMessage],\n", + " info: AgentInfo,\n", + " ) -> ModelResponse:\n", + " state[\"requests\"] += 1\n", + " tools = {tool.name for tool in info.function_tools}\n", + "\n", + " if \"inspect_assay_features_batch\" in tools or state[\"enrichment\"] == 1:\n", + " if state[\"enrichment\"] == 0:\n", + " state[\"enrichment\"] = 1\n", + " return _tool_call(\"inspect_assay_features_batch\")\n", + "\n", + " batch = _tool_result(\n", + " messages,\n", + " \"inspect_assay_features_batch\",\n", + " AssayFeatureInspectionBatch,\n", + " )\n", + " policies = []\n", + " for inspection in batch.inspections:\n", + " species_observed = inspection.species != \"unknown\"\n", + " policy_evidence = list(inspection.evidenceIds)\n", + " if not species_observed:\n", + " policy_evidence.append(\"context:study\")\n", + " policies.append(\n", + " FeatureSelectionPolicy(\n", + " assay=inspection.assay,\n", + " species=(\n", + " inspection.species\n", + " if species_observed\n", + " else \"homo_sapiens\"\n", + " ),\n", + " speciesConfidence=\"high\" if species_observed else \"medium\",\n", + " speciesRationale=(\n", + " inspection.speciesReason\n", + " or \"The exact study paragraph identifies a human sample.\"\n", + " ),\n", + " excludeFamilies=[\n", + " family.family\n", + " for family in inspection.families\n", + " if family.count > 0 and family.defaultExclude is True\n", + " ],\n", + " protectFamilies=[\n", + " family.family\n", + " for family in inspection.families\n", + " if family.count > 0 and family.defaultExclude is False\n", + " ],\n", + " rationale=(\n", + " \"Exclude observed technical families and preserve \"\n", + " \"observed protected families.\"\n", + " ),\n", + " evidenceIds=list(dict.fromkeys(policy_evidence)),\n", + " )\n", + " )\n", + " state[\"enrichment\"] = 2\n", + " return _structured_output(\n", + " info,\n", + " DataEnrichmentReport(\n", + " status=\"done\",\n", + " studyContextSummary=StudyContextSummary(\n", + " organismReferences=[\"human\"],\n", + " tissueReferences=[\"peripheral blood\"],\n", + " experimentalReferences=[\n", + " \"10x Genomics 5K PBMC 3-prime gene-expression dataset\"\n", + " ],\n", + " analysisIntentReferences=[\n", + " \"unsupervised identification and characterization of \"\n", + " \"the major immune-cell populations\"\n", + " ],\n", + " ),\n", + " policies=policies,\n", + " ),\n", + " )\n", + "\n", + " if tools.intersection(\n", + " {\n", + " \"inspect_cell_covariates\",\n", + " \"analyze_experimental_design\",\n", + " \"score_current_representation\",\n", + " }\n", + " ) or state[\"context\"] in {1, 2}:\n", + " if state[\"context\"] == 0:\n", + " state[\"context\"] = 1\n", + " return _tool_call(\"inspect_cell_covariates\")\n", + " if state[\"context\"] == 1:\n", + " state[\"context\"] = 2\n", + " return _tool_call(\n", + " \"analyze_experimental_design\",\n", + " {\n", + " \"column_domains\": {},\n", + " \"coefficients_of_interest\": [],\n", + " \"units_of_inference\": {},\n", + " \"batch_columns\": [],\n", + " },\n", + " )\n", + "\n", + " design = _tool_result(\n", + " messages,\n", + " \"analyze_experimental_design\",\n", + " CovariateEvidence,\n", + " )\n", + " profile = next(\n", + " value\n", + " for value in design.qcProfiles\n", + " if value.action == \"skip\"\n", + " )\n", + " evidence_id = profile.evidenceId\n", + " state[\"context\"] = 3\n", + " return _structured_output(\n", + " info,\n", + " ExperimentalContextDecision(\n", + " batchCorrection=BatchCorrectionPlan(\n", + " action=\"skip\",\n", + " rationale=\"No trusted technical batch column was supplied.\",\n", + " evidenceIds=[evidence_id],\n", + " ),\n", + " rationale=\"No experimental covariates were supplied.\",\n", + " evidenceIds=[evidence_id],\n", + " ),\n", + " )\n", + "\n", + " if tools.intersection(\n", + " {\"inspect_cluster_composition\", \"inspect_cluster_markers_batch\"}\n", + " ) or state[\"biology\"]:\n", + " if state[\"biology\"] == 0:\n", + " state[\"biology\"] = 1\n", + " return _tool_call(\"inspect_cluster_composition\")\n", + " if state[\"biology\"] == 1:\n", + " composition = _tool_result(\n", + " messages,\n", + " \"inspect_cluster_composition\",\n", + " ClusterCompositionEvidence,\n", + " )\n", + " state[\"biology\"] = 2\n", + " return _tool_call(\n", + " \"inspect_cluster_markers_batch\",\n", + " {\"cluster_ids\": list(composition.clusterCounts)},\n", + " )\n", + "\n", + " marker_batch = _tool_result(\n", + " messages,\n", + " \"inspect_cluster_markers_batch\",\n", + " ClusterMarkerBatchEvidence,\n", + " )\n", + " interpretations = []\n", + " for cluster in marker_batch.clusters:\n", + " if cluster.evidenceId and cluster.markers:\n", + " marker = cluster.markers[0]\n", + " marker_name = marker.featureName or marker.featureId\n", + " interpretations.append(\n", + " ClusterInterpretation(\n", + " clusterId=cluster.clusterId,\n", + " proposedIdentity=f\"{marker_name}-high RNA state\",\n", + " identityIsHypothesis=True,\n", + " confidence=\"low\",\n", + " rationale=(\n", + " \"The returned marker panel is led by \"\n", + " f\"{marker_name}.\"\n", + " ),\n", + " evidenceIds=[cluster.evidenceId],\n", + " )\n", + " )\n", + " state[\"biology\"] = 3\n", + " return _structured_output(\n", + " info,\n", + " BiologicalInterpretationReport(\n", + " status=\"done\",\n", + " clusterInterpretations=interpretations,\n", + " evidenceIds=[item.evidenceIds[0] for item in interpretations],\n", + " limitations=[\n", + " \"The scripted documentation model returns marker-linked \"\n", + " \"hypotheses, not validated cell identities.\"\n", + " ],\n", + " stopReason=(\n", + " \"Every cluster with returned marker evidence was reviewed.\"\n", + " ),\n", + " ),\n", + " )\n", + "\n", + " prompt = _prompt_text(messages)\n", + " if any(\n", + " tool.parameters_json_schema.get(\"title\")\n", + " == \"AnalysisVisualAdjudication\"\n", + " for tool in info.output_tools\n", + " ):\n", + " payload, _ = json.JSONDecoder().raw_decode(prompt[prompt.index(\"{\") :])\n", + " return _structured_output(\n", + " info,\n", + " {\n", + " \"status\": \"acceptable\",\n", + " \"selectedCandidateId\": payload[\"selectedCandidateId\"],\n", + " \"rationale\": (\n", + " \"The bounded diagnostic board agrees with the registered \"\n", + " \"numeric evidence.\"\n", + " ),\n", + " },\n", + " )\n", + "\n", + " decision, _ = json.JSONDecoder().raw_decode(prompt[prompt.index(\"{\") :])\n", + " evidence_by_class = {}\n", + " evidence_class_by_id = {}\n", + " for item in decision[\"evidence\"]:\n", + " evidence_by_class.setdefault(\n", + " item[\"evidenceClass\"],\n", + " item[\"evidenceId\"],\n", + " )\n", + " evidence_class_by_id[item[\"evidenceId\"]] = item[\"evidenceClass\"]\n", + " preferred = decision.get(\"metricPreferredOptionId\")\n", + " selected = (\n", + " next(\n", + " option\n", + " for option in decision[\"options\"]\n", + " if option[\"optionId\"] == preferred\n", + " )\n", + " if preferred is not None\n", + " else next(\n", + " option\n", + " for option in decision[\"options\"]\n", + " if option[\"status\"] in {\"apply\", \"skip\"}\n", + " )\n", + " )\n", + " evidence_ids = list(selected.get(\"requiredEvidenceIds\", []))\n", + " cited_classes = {\n", + " evidence_class_by_id[evidence_id] for evidence_id in evidence_ids\n", + " }\n", + " for evidence_class in selected[\"requiredEvidenceClasses\"]:\n", + " if evidence_class not in cited_classes:\n", + " evidence_ids.append(evidence_by_class[evidence_class])\n", + " state[\"parameter\"] += 1\n", + " return _structured_output(\n", + " info,\n", + " DecisionSelection(\n", + " selectedOptionId=selected[\"optionId\"],\n", + " evidenceIds=evidence_ids,\n", + " rationale=\"Select the registered metric-preferred option.\",\n", + " confidence=\"high\",\n", + " ),\n", + " )\n", + "\n", + " return FunctionModel(reply), state" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "5e9d2bc8", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'initial_candidates': 1,\n", + " 'refinement_candidates': 0,\n", + " 'harmony_candidates': 0,\n", + " 'input_policy': 'unattended'}" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "model, model_state = _scripted_workflow_model()\n", + "config = AutomatedWorkflowConfig(\n", + " inputPolicy=\"unattended\",\n", + " primaryInitialCandidates=1,\n", + " secondaryInitialCandidates=1,\n", + " maxRefinedCandidatesPerAssay=0,\n", + " maxHarmonyCandidatesPerAssay=0,\n", + " integrationResolutionCandidates=1,\n", + " maxCandidateBranches=1,\n", + " minClusterCells=2,\n", + " agentRunConfig=AgentRunConfig(\n", + " requestLimit=5,\n", + " toolCallLimit=5,\n", + " ),\n", + ")\n", + "orchestrator = AgentOrchestrator(model, config=config)\n", + "request = AutomatedWorkflowRequest(\n", + " sourcePath=str(source_path),\n", + " zarrPath=str(zarr_path),\n", + " studyContext=study_context,\n", + " studyObjective=\"Discover stable major immune-cell populations.\",\n", + " primaryAssay=\"RNA\",\n", + " markerAssay=\"RNA\",\n", + " analysisAssays=[\"RNA\"],\n", + " ingestDirections={\"overwrite\": True, \"defaultAssay\": \"RNA\"},\n", + ")\n", + "\n", + "{\n", + " \"initial_candidates\": config.primaryInitialCandidates,\n", + " \"refinement_candidates\": config.maxRefinedCandidatesPerAssay,\n", + " \"harmony_candidates\": config.maxHarmonyCandidatesPerAssay,\n", + " \"input_policy\": config.inputPolicy,\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "ede2f406", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'status': 'completed',\n", + " 'stage': 'analysis_finalization',\n", + " 'primary_assay': 'RNA',\n", + " 'marker_assay': 'RNA',\n", + " 'cell_qc': 'skip',\n", + " 'routes': [{'assay': 'RNA', 'features': 'hvg', 'reduction': 'pca'}]}" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "with redirect_stdout(StringIO()):\n", + " result = orchestrator.run(request)\n", + "\n", + "if (\n", + " result.status != \"completed\"\n", + " or result.finalAnalysis is None\n", + " or result.preprocessingPlan is None\n", + " or result.workflowRun is None\n", + " or result.zarrPath is None\n", + "):\n", + " raise RuntimeError(f\"Unexpected workflow result: {result.status}, {result.notes}\")\n", + "\n", + "plan = result.preprocessingPlan\n", + "{\n", + " \"status\": result.status,\n", + " \"stage\": result.currentStage,\n", + " \"primary_assay\": plan.primaryAssay,\n", + " \"marker_assay\": plan.markerAssay,\n", + " \"cell_qc\": plan.cellQc.action,\n", + " \"routes\": [\n", + " {\n", + " \"assay\": assay.assay,\n", + " \"features\": assay.featureMethod,\n", + " \"reduction\": assay.reductionMethod,\n", + " }\n", + " for assay in plan.assays\n", + " ],\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "e68fb2d7", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'status': 'completed',\n", + " 'stage': 'analysis_finalization',\n", + " 'agent_reports': ['data_enrichment',\n", + " 'experimental_context',\n", + " 'parameter_tuning'],\n", + " 'model_requests': 12,\n", + " 'graph_method': 'native',\n", + " 'marker_assay': 'RNA'}" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "persisted_workflow = load_agent_workflow(\n", + " result.zarrPath,\n", + " result.workflowRun.workflowRunId,\n", + " workspace=result.workflowRun.workspace,\n", + ")\n", + "\n", + "{\n", + " \"status\": persisted_workflow.status,\n", + " \"stage\": result.currentStage,\n", + " \"agent_reports\": [ref.agentName for ref in result.reportReferences],\n", + " \"model_requests\": model_state[\"requests\"],\n", + " \"graph_method\": result.finalAnalysis.graphMethod,\n", + " \"marker_assay\": result.finalAnalysis.markerAssay,\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "22a6b72f", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'candidates': [{'assay': 'RNA',\n", + " 'candidate': 1,\n", + " 'dimensions': 10,\n", + " 'resolution': 1.0,\n", + " 'neighbors': 21,\n", + " 'eligible': True,\n", + " 'clusters': 15,\n", + " 'smallest_cluster': 27,\n", + " 'graph_silhouette': 0.36498694993471886},\n", + " {'assay': 'RNA',\n", + " 'candidate': 2,\n", + " 'dimensions': 20,\n", + " 'resolution': 1.0,\n", + " 'neighbors': 21,\n", + " 'eligible': True,\n", + " 'clusters': 11,\n", + " 'smallest_cluster': 30,\n", + " 'graph_silhouette': 0.3899432284072132},\n", + " {'assay': 'RNA',\n", + " 'candidate': 3,\n", + " 'dimensions': 30,\n", + " 'resolution': 1.0,\n", + " 'neighbors': 21,\n", + " 'eligible': True,\n", + " 'clusters': 13,\n", + " 'smallest_cluster': 26,\n", + " 'graph_silhouette': 0.16811359562882475},\n", + " {'assay': 'RNA',\n", + " 'candidate': 4,\n", + " 'dimensions': 50,\n", + " 'resolution': 1.0,\n", + " 'neighbors': 21,\n", + " 'eligible': True,\n", + " 'clusters': 12,\n", + " 'smallest_cluster': 28,\n", + " 'graph_silhouette': 0.2779521381867694},\n", + " {'assay': 'RNA',\n", + " 'candidate': 5,\n", + " 'dimensions': 10,\n", + " 'resolution': 1.0,\n", + " 'neighbors': 21,\n", + " 'eligible': True,\n", + " 'clusters': 15,\n", + " 'smallest_cluster': 27,\n", + " 'graph_silhouette': 0.36498694993471886},\n", + " {'assay': 'RNA',\n", + " 'candidate': 6,\n", + " 'dimensions': 10,\n", + " 'resolution': 1.0,\n", + " 'neighbors': 11,\n", + " 'eligible': True,\n", + " 'clusters': 19,\n", + " 'smallest_cluster': 26,\n", + " 'graph_silhouette': 0.40884816989912326},\n", + " {'assay': 'RNA',\n", + " 'candidate': 7,\n", + " 'dimensions': 10,\n", + " 'resolution': 1.0,\n", + " 'neighbors': 21,\n", + " 'eligible': True,\n", + " 'clusters': 15,\n", + " 'smallest_cluster': 27,\n", + " 'graph_silhouette': 0.36498694993471886},\n", + " {'assay': 'RNA',\n", + " 'candidate': 8,\n", + " 'dimensions': 10,\n", + " 'resolution': 1.0,\n", + " 'neighbors': 41,\n", + " 'eligible': True,\n", + " 'clusters': 10,\n", + " 'smallest_cluster': 75,\n", + " 'graph_silhouette': 0.3580048738360666},\n", + " {'assay': 'RNA',\n", + " 'candidate': 9,\n", + " 'dimensions': 10,\n", + " 'resolution': 0.25,\n", + " 'neighbors': 11,\n", + " 'eligible': True,\n", + " 'clusters': 8,\n", + " 'smallest_cluster': 27,\n", + " 'graph_silhouette': 0.6326973227366433},\n", + " {'assay': 'RNA',\n", + " 'candidate': 10,\n", + " 'dimensions': 10,\n", + " 'resolution': 0.5,\n", + " 'neighbors': 11,\n", + " 'eligible': True,\n", + " 'clusters': 11,\n", + " 'smallest_cluster': 29,\n", + " 'graph_silhouette': 0.44029372227500996},\n", + " {'assay': 'RNA',\n", + " 'candidate': 11,\n", + " 'dimensions': 10,\n", + " 'resolution': 0.75,\n", + " 'neighbors': 11,\n", + " 'eligible': True,\n", + " 'clusters': 14,\n", + " 'smallest_cluster': 29,\n", + " 'graph_silhouette': 0.3325216854391465},\n", + " {'assay': 'RNA',\n", + " 'candidate': 12,\n", + " 'dimensions': 10,\n", + " 'resolution': 1.0,\n", + " 'neighbors': 11,\n", + " 'eligible': True,\n", + " 'clusters': 19,\n", + " 'smallest_cluster': 26,\n", + " 'graph_silhouette': 0.40884816989912326},\n", + " {'assay': 'RNA',\n", + " 'candidate': 13,\n", + " 'dimensions': 10,\n", + " 'resolution': 1.25,\n", + " 'neighbors': 11,\n", + " 'eligible': True,\n", + " 'clusters': 20,\n", + " 'smallest_cluster': 21,\n", + " 'graph_silhouette': 0.40435179722829495},\n", + " {'assay': 'RNA',\n", + " 'candidate': 14,\n", + " 'dimensions': 10,\n", + " 'resolution': 1.5,\n", + " 'neighbors': 11,\n", + " 'eligible': True,\n", + " 'clusters': 23,\n", + " 'smallest_cluster': 26,\n", + " 'graph_silhouette': 0.3325738520931393}],\n", + " 'stop_reason': 'Four causal RNA parameter phases were selected.',\n", + " 'report_statuses': {'data_enrichment': 'done',\n", + " 'experimental_context': 'done',\n", + " 'parameter_tuning': 'done'}}" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "reports = {\n", + " reference.agentName: load_agent_report(result.zarrPath, reference)\n", + " for reference in result.reportReferences\n", + "}\n", + "parameter_report = reports[\"parameter_tuning\"]\n", + "\n", + "candidate_metrics = []\n", + "for assay, assay_report in parameter_report.assayReports.items():\n", + " for index, evaluation in enumerate(assay_report.evaluations, start=1):\n", + " candidate_metrics.append(\n", + " {\n", + " \"assay\": assay,\n", + " \"candidate\": index,\n", + " \"dimensions\": evaluation.parameters.dimensions,\n", + " \"resolution\": evaluation.parameters.leidenResolution,\n", + " \"neighbors\": evaluation.parameters.neighborsK,\n", + " \"eligible\": evaluation.eligible,\n", + " \"clusters\": evaluation.metrics.nClusters,\n", + " \"smallest_cluster\": evaluation.metrics.minClusterCells,\n", + " \"graph_silhouette\": evaluation.metrics.graphSilhouetteMedian,\n", + " }\n", + " )\n", + "\n", + "{\n", + " \"candidates\": candidate_metrics,\n", + " \"stop_reason\": parameter_report.stopReason,\n", + " \"report_statuses\": {\n", + " name: report.status for name, report in reports.items()\n", + " },\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "9f9b86f0", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAUsAAAFfCAYAAADH8O4TAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjEsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvctoD+AAAAAlwSFlzAAAPYQAAD2EBqD+naQABAABJREFUeJzsnXfYXFWd+D/n3DJ93nlrekihhiLSQaSqq6jYVldB7K66uip2/Vl2XRXrrrqya6EpoCKKBSkCgtTQSyAhJCHt7XX6zL1z7z3n98eZTBJBjStpeD/PkyfvzNy5c+6Zme98+1dorTUxMTExMX8WubsXEBMTE7M3EAvLmJiYmB0gFpYxMTExO0AsLGNiYmJ2gFhYxsTExOwAsbCMiYmJ2QFiYRkTExOzA8TCMiYmJmYHiIXl3xFRFFEqlVBK7e6l/M38X64lDENKpRLPRB3Gs2kvY3aMWFj+HbF8+XK6u7vZvHnzM3reZ1II7Sj/l2u56aab6O7uZnp6ere8fszeTSwsY/5m7rjjDrq7uxkeHt7dS4mJ2WnEwvJZilKKVqv1Z48JgoByufyU+6vVKp7nPe1z/vicURRRq9UAqFQqlEolKpXKU54XRdHTnvOPtdJms/knX/vPoZSiVCpRKpWoVqs79JxWq/UXteE/te6/dN6YZx+xsHyW8cgjj/CiF72IVCpFoVDg+c9/Pg8//PDTHnvFFVfQ29v7lPuPPvpovvzlL3duT09P89rXvpZ0Ok2hUOCAAw7gRz/6EQD33Xcfr3/96wE4/vjjWbRoESeeeOJ263nBC15AMpkkl8tx+OGHs3z58s7jW0zj888/n4ULF9Lf389XvvKVv/q6N23axKJFi1i0aBFz586lq6uL173udYyMjDzl2Msvv5ylS5eSy+Xo7u7mC1/4wlOO+Uvr/mP+3B7FPDuIheWziLVr1/L85z+fvr4+Nm7cSLVa5bzzzuOaa675m877yU9+kg0bNrBhwwYajQbXXXcdN998MwDHHXccv/3tbwFYuXIlpVKJFStWALBhwwZOOukkUqkUQ0ND1Go1XvWqV/HiF7/4KX7DCy+8kN///vfUajU+97nP/dVrXLx48Xaa5apVq6hWq7zpTW96yrH//d//zW9+8xsajQYXX3wxX/jCF7jkkks6j/81696RPYp5lqBjnjW8+93v1gsWLNC+7z/t47fffrsG9IYNG7TWWl966aXasqynHHfAAQfoz33uc53bL33pS/XrX//6P/m6t9xyiwb04ODgdve/733v0/l8XheLxc59Sil90EEHdc5/3XXXaUDfdNNNO3aRf+JatqXVaulisahvvPFGDehyubzda11xxRXbHf/Od75TL1u27K9a9x+//l/ao5i9n1izfBbx8MMPc8wxx+C67jN63nPPPZfrr7+eo446ik996lPccMMNhGH4F5/3wAMPcMQRRwB0tL5yuczhhx/OI488st2xy5Yt+5vWGEURn/rUp5g3bx6pVIqFCxfy6le/GuApEeujjz56u9tHHXUUa9as6VzTX7PuLfxf9yhm78He3QuIeeawLOuv+oIKIZ72/iiKtrt9+umnMzg4yO9//3tuv/123v3ud5NMJrntttvo6+v7s+e/6667WLRo0VMeO/LII7e77TjODq/76fjqV7/KhRdeyC9+8QtOOOEEpJQ88MADHHXUUU/Zk6e7bVkWUsq/et1b+L/uUczeQ6xZPos4/vjjueuuuzrR6b9Eb28vURQxMzPTua9SqTxt7mA2m+UVr3gFX//613nkkUdYu3Ztx1e5RZP9YyF7wgkncNRRR3W0s23/3XTTTf/Xy3xa7r33Xk499VROPPHEjtC79dZbn/bY22+/fbvbd9xxB4ccckjnef/Xdf+5PYrZ+4mF5bOID33oQ9i2zStf+UruvvtuBgcHufzyyzn33HOf9vhjjjmGQqHAxz/+cTZv3syKFSt4/etf/5TUlzPPPJPvfe97PP7444yOjnLZZZehlOKQQw4BYOnSpViWxbXXXsv09HQndegjH/kIg4ODvOtd7+KRRx5hbGyM5cuXc+655/Ltb3/7Gb32I444ghtvvJGbb76Z4eFhfvSjH/Hv//7vT3vspz71Ka655hoGBwf55je/yc9+9jM++9nPdh7/v6z7L+1RzLOA3e00jXlm2bx5s37b296mFy1apPfZZx99zjnn6KGhIa211suXL9ddXV1606ZNneNvu+02feKJJ+o5c+bo448/Xv/whz/URx99tD7vvPM6x2zYsEG/5z3v0QceeKCeN2+ePu200/TVV1+93et+97vf1QcffLDu7e3Vhx56aOf+0dFR/d73vlfvv//+etasWfp5z3ue/va3v609z9Naa33TTTfprq4uPT09/Vdd5x9fi+d5+txzz9VLlizR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", + "text/plain": [ + "
    " + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "final = result.finalAnalysis\n", + "if (\n", + " final.cellSelection is None\n", + " or final.clusters is None\n", + " or final.umap is None\n", + " or final.markers is None\n", + "):\n", + " raise RuntimeError(\"The completed final handoff is missing required artifacts\")\n", + "\n", + "final_store = scarf.DataStore(\n", + " result.zarrPath,\n", + " default_assay=final.primaryAssay,\n", + " min_features_per_cell=-1,\n", + " mito_pattern=\"\",\n", + " ribo_pattern=\"\",\n", + " zarr_mode=\"r\",\n", + " workspace=result.workflowRun.workspace,\n", + " nthreads=2,\n", + ")\n", + "cell_selection_ref = artifact_model_to_ref(final.cellSelection)\n", + "cluster_ref = artifact_model_to_ref(final.clusters)\n", + "umap_ref = artifact_model_to_ref(final.umap)\n", + "marker_ref = artifact_model_to_ref(final.markers)\n", + "\n", + "final_store.plots.embedding(\n", + " layout=umap_ref,\n", + " color_by=cluster_ref,\n", + " legend_loc=\"on_data\",\n", + " frame=\"none\",\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "94212ce0", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    group_idfeature_namescorefrac_exp
    01FPR10.970960.82675
    11TMEM176B0.956010.87702
    143912IL3RA0.896241.00000
    143922GAS60.895561.00000
    287823HIST1H2BG0.357380.18953
    287833ANO90.351180.09386
    431734VPREB30.943310.76994
    431744IGHD0.913140.74397
    575645LRRN30.792390.50154
    575655NOG0.710400.23722
    719556KLRF10.902280.87529
    719566PRSS230.899680.66118
    \n", + "
    " + ], + "text/plain": [ + " group_id feature_name score frac_exp\n", + "0 1 FPR1 0.97096 0.82675\n", + "1 1 TMEM176B 0.95601 0.87702\n", + "14391 2 IL3RA 0.89624 1.00000\n", + "14392 2 GAS6 0.89556 1.00000\n", + "28782 3 HIST1H2BG 0.35738 0.18953\n", + "28783 3 ANO9 0.35118 0.09386\n", + "43173 4 VPREB3 0.94331 0.76994\n", + "43174 4 IGHD 0.91314 0.74397\n", + "57564 5 LRRN3 0.79239 0.50154\n", + "57565 5 NOG 0.71040 0.23722\n", + "71955 6 KLRF1 0.90228 0.87529\n", + "71956 6 PRSS23 0.89968 0.66118" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "marker_table = final_store.get_markers(\n", + " marker=marker_ref,\n", + " group_id=None,\n", + " min_score=-1,\n", + " min_frac_exp=-1,\n", + ")\n", + "marker_table.sort_values(\n", + " [\"group_id\", \"score\"],\n", + " ascending=[True, False],\n", + ").groupby(\"group_id\", sort=True).head(2)[\n", + " [\"group_id\", \"feature_name\", \"score\", \"frac_exp\"]\n", + "].head(12)" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "9cbaf4dd", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'report': 'agent_workflow.zarr/agents/runs//report/index.html',\n", + " 'exists': True,\n", + " 'final_artifact_kinds': {'selection': 'cell_selection',\n", + " 'clusters': 'cluster_labels',\n", + " 'umap': 'embedding',\n", + " 'markers': 'marker_table'}}" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "report_path = generate_agent_report(\n", + " result.zarrPath,\n", + " result.workflowRun.workflowRunId,\n", + " workspace=result.workflowRun.workspace,\n", + ")\n", + "display_path = str(report_path.relative_to(Path(result.zarrPath).parent)).replace(\n", + " result.workflowRun.workflowRunId,\n", + " \"\",\n", + ")\n", + "\n", + "{\n", + " \"report\": display_path,\n", + " \"exists\": report_path.is_file(),\n", + " \"final_artifact_kinds\": {\n", + " \"selection\": cell_selection_ref.kind,\n", + " \"clusters\": cluster_ref.kind,\n", + " \"umap\": umap_ref.kind,\n", + " \"markers\": marker_ref.kind,\n", + " },\n", + "}" + ] + } + ], + "metadata": { + "description": "Run Scarf's resumable automated agent orchestrator on a 5K PBMC dataset.", + "jupytext": { + "cell_metadata_filter": "tags", + "text_representation": { + "extension": ".md", + "format_name": "myst", + "format_version": 0.13, + "jupytext_version": "1.14.1" + } + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.0" + }, + "source_map": [ + 14, + 64, + 100, + 106, + 424, + 433, + 467, + 475, + 504, + 514, + 529, + 542, + 573, + 587, + 618, + 623, + 636, + 648, + 669 + ] + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/.jupyter_cache/executed/d46bae099f63faffe6d8c4de5a9bc1d1/base.ipynb b/docs/.jupyter_cache/executed/d46bae099f63faffe6d8c4de5a9bc1d1/base.ipynb deleted file mode 100644 index b40bdfe8..00000000 --- a/docs/.jupyter_cache/executed/d46bae099f63faffe6d8c4de5a9bc1d1/base.ipynb +++ /dev/null @@ -1,944 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 1, - "id": "8aaecbef", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
    Downloading bucket files18098007 / 18098007 complete18098007 / 18098007 complete
    " - ], - "text/plain": [ - "Downloading bucket files: 18098007 / 18098007 complete" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "
    Downloading bytes18098007 / 18098007 complete18098007 / 18098007 complete
    " - ], - "text/plain": [ - "Downloading bytes: 18098007 / 18098007 complete" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/plain": [ - "{'source': 'data.h5', 'destination': 'agent_workflow.zarr'}" - ] - }, - "execution_count": 1, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "from contextlib import redirect_stdout\n", - "from io import StringIO\n", - "from pathlib import Path\n", - "\n", - "import scarf\n", - "from scarf.agent import (\n", - " AgentOrchestrator,\n", - " AgentRunConfig,\n", - " AutomatedWorkflowConfig,\n", - " AutomatedWorkflowRequest,\n", - " AutomatedWorkflowResumeRequest,\n", - " generate_agent_report,\n", - " load_agent_report,\n", - ")\n", - "from scarf.agent.orchestrator import artifact_model_to_ref\n", - "\n", - "scarf.configure_output(level=\"WARNING\", progress=False)\n", - "\n", - "source_path = scarf.cytebase.connect(\"scarf_docs\").download(\n", - " \"tenx_5K_pbmc_rnaseq/data.h5\",\n", - " destination=\"scarf_datasets\",\n", - ")[0]\n", - "zarr_path = source_path.with_name(\"agent_workflow.zarr\")\n", - "\n", - "study_context = (\n", - " \"This is a human 10x Genomics 5K PBMC 3-prime gene-expression dataset \"\n", - " \"from peripheral blood collected from one healthy donor. The goal is \"\n", - " \"unsupervised identification and characterization of the major immune-cell \"\n", - " \"populations. No treatment comparison, technical batch covariate, paired \"\n", - " \"modality, or independent replication metadata is available. Do not invent \"\n", - " \"absent design variables or report treatment effects.\"\n", - ")\n", - "\n", - "{\"source\": source_path.name, \"destination\": zarr_path.name}" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "efc9d4ff", - "metadata": { - "tags": [ - "remove-cell" - ] - }, - "outputs": [], - "source": [ - "import re\n", - "from typing import Any\n", - "\n", - "from pydantic_ai.messages import (\n", - " ModelMessage,\n", - " ModelResponse,\n", - " ToolCallPart,\n", - " ToolReturnPart,\n", - ")\n", - "from pydantic_ai.models.function import AgentInfo, FunctionModel\n", - "\n", - "from scarf.agent.biological_interpretation import (\n", - " BiologicalInterpretationReport,\n", - " ClusterCompositionEvidence,\n", - " ClusterInterpretation,\n", - " ClusterMarkerBatchEvidence,\n", - ")\n", - "from scarf.agent.data_enrichment import (\n", - " AssayFeatureInspectionBatch,\n", - " DataEnrichmentReport,\n", - " FeatureSelectionPolicy,\n", - " StudyContextSummary,\n", - ")\n", - "from scarf.agent.experimental_context import (\n", - " BatchCorrectionPlan,\n", - " CellQcPlan,\n", - " CovariateEvidence,\n", - " ExperimentalContextDecision,\n", - ")\n", - "from scarf.agent.parameter_tuning import (\n", - " FinalGraphSelection,\n", - " ParameterTuningReport,\n", - ")\n", - "\n", - "\n", - "def _prompt_text(messages: list[ModelMessage]) -> str:\n", - " return \"\\n\".join(\n", - " part.content\n", - " for message in messages\n", - " for part in message.parts\n", - " if isinstance(getattr(part, \"content\", None), str)\n", - " )\n", - "\n", - "\n", - "def _tool_result(\n", - " messages: list[ModelMessage],\n", - " tool_name: str,\n", - " model_type: Any,\n", - ") -> Any:\n", - " for message in reversed(messages):\n", - " for part in reversed(message.parts):\n", - " if isinstance(part, ToolReturnPart) and part.tool_name == tool_name:\n", - " if isinstance(part.content, model_type):\n", - " return part.content\n", - " if isinstance(part.content, str):\n", - " return model_type.model_validate_json(part.content)\n", - " return model_type.model_validate(part.content)\n", - " raise AssertionError(f\"Missing tool return {tool_name!r}\")\n", - "\n", - "\n", - "def _tool_call(name: str, args: dict[str, Any] | None = None) -> ModelResponse:\n", - " return ModelResponse(parts=[ToolCallPart(tool_name=name, args=args or {})])\n", - "\n", - "\n", - "def _structured_output(info: AgentInfo, value: Any) -> ModelResponse:\n", - " payload = value.model_dump() if hasattr(value, \"model_dump\") else value\n", - " return _tool_call(info.output_tools[0].name, payload)\n", - "\n", - "\n", - "def _scripted_workflow_model() -> tuple[FunctionModel, dict[str, int]]:\n", - " state = {\n", - " \"enrichment\": 0,\n", - " \"context\": 0,\n", - " \"parameter\": 0,\n", - " \"biology\": 0,\n", - " \"requests\": 0,\n", - " }\n", - "\n", - " async def reply(\n", - " messages: list[ModelMessage],\n", - " info: AgentInfo,\n", - " ) -> ModelResponse:\n", - " state[\"requests\"] += 1\n", - " tools = {tool.name for tool in info.function_tools}\n", - "\n", - " if \"inspect_assay_features_batch\" in tools or state[\"enrichment\"] == 1:\n", - " if state[\"enrichment\"] == 0:\n", - " state[\"enrichment\"] = 1\n", - " return _tool_call(\"inspect_assay_features_batch\")\n", - "\n", - " batch = _tool_result(\n", - " messages,\n", - " \"inspect_assay_features_batch\",\n", - " AssayFeatureInspectionBatch,\n", - " )\n", - " policies = []\n", - " for inspection in batch.inspections:\n", - " species_observed = inspection.species != \"unknown\"\n", - " policy_evidence = list(inspection.evidenceIds)\n", - " if not species_observed:\n", - " policy_evidence.append(\"context:study\")\n", - " policies.append(\n", - " FeatureSelectionPolicy(\n", - " assay=inspection.assay,\n", - " species=(\n", - " inspection.species\n", - " if species_observed\n", - " else \"homo_sapiens\"\n", - " ),\n", - " speciesConfidence=\"high\" if species_observed else \"medium\",\n", - " speciesRationale=(\n", - " inspection.speciesReason\n", - " or \"The exact study paragraph identifies a human sample.\"\n", - " ),\n", - " excludeFamilies=[\n", - " family.family\n", - " for family in inspection.families\n", - " if family.count > 0 and family.defaultExclude is True\n", - " ],\n", - " protectFamilies=[\n", - " family.family\n", - " for family in inspection.families\n", - " if family.count > 0 and family.defaultExclude is False\n", - " ],\n", - " rationale=(\n", - " \"Exclude observed technical families and preserve \"\n", - " \"observed protected families.\"\n", - " ),\n", - " evidenceIds=list(dict.fromkeys(policy_evidence)),\n", - " )\n", - " )\n", - " state[\"enrichment\"] = 2\n", - " return _structured_output(\n", - " info,\n", - " DataEnrichmentReport(\n", - " status=\"done\",\n", - " studyContextSummary=StudyContextSummary(\n", - " organismReferences=[\"human\"],\n", - " tissueReferences=[\"peripheral blood\"],\n", - " experimentalReferences=[\n", - " \"10x Genomics 5K PBMC 3-prime gene-expression dataset\"\n", - " ],\n", - " analysisIntentReferences=[\n", - " \"unsupervised identification and characterization of \"\n", - " \"the major immune-cell populations\"\n", - " ],\n", - " ),\n", - " policies=policies,\n", - " ),\n", - " )\n", - "\n", - " if tools.intersection(\n", - " {\n", - " \"inspect_cell_covariates\",\n", - " \"analyze_experimental_design\",\n", - " \"score_current_representation\",\n", - " }\n", - " ) or state[\"context\"] in {1, 2}:\n", - " if state[\"context\"] == 0:\n", - " state[\"context\"] = 1\n", - " return _tool_call(\"inspect_cell_covariates\")\n", - " if state[\"context\"] == 1:\n", - " state[\"context\"] = 2\n", - " return _tool_call(\n", - " \"analyze_experimental_design\",\n", - " {\n", - " \"column_domains\": {},\n", - " \"coefficients_of_interest\": [],\n", - " \"units_of_inference\": {},\n", - " \"batch_columns\": [],\n", - " },\n", - " )\n", - "\n", - " design = _tool_result(\n", - " messages,\n", - " \"analyze_experimental_design\",\n", - " CovariateEvidence,\n", - " )\n", - " profile = next(\n", - " value\n", - " for value in design.qcProfiles\n", - " if value.action == \"globalGaussian\"\n", - " )\n", - " evidence_id = profile.evidenceId\n", - " state[\"context\"] = 3\n", - " return _structured_output(\n", - " info,\n", - " ExperimentalContextDecision(\n", - " batchCorrection=BatchCorrectionPlan(\n", - " action=\"skip\",\n", - " rationale=\"No trusted technical batch column was supplied.\",\n", - " evidenceIds=[evidence_id],\n", - " ),\n", - " cellQc=CellQcPlan(\n", - " action=profile.action,\n", - " profileId=profile.profileId,\n", - " driverAssay=profile.driverAssay,\n", - " driverAssayType=profile.driverAssayType,\n", - " attributes=profile.attributes,\n", - " artifactMetrics=profile.artifactMetrics,\n", - " rationale=\"Apply the bounded global RNA QC profile.\",\n", - " evidenceIds=[evidence_id],\n", - " ),\n", - " rationale=\"No experimental covariates were supplied.\",\n", - " evidenceIds=[evidence_id],\n", - " ),\n", - " )\n", - "\n", - " if tools.intersection(\n", - " {\"inspect_cluster_composition\", \"inspect_cluster_markers_batch\"}\n", - " ) or state[\"biology\"]:\n", - " if state[\"biology\"] == 0:\n", - " state[\"biology\"] = 1\n", - " return _tool_call(\"inspect_cluster_composition\")\n", - " if state[\"biology\"] == 1:\n", - " composition = _tool_result(\n", - " messages,\n", - " \"inspect_cluster_composition\",\n", - " ClusterCompositionEvidence,\n", - " )\n", - " state[\"biology\"] = 2\n", - " return _tool_call(\n", - " \"inspect_cluster_markers_batch\",\n", - " {\"cluster_ids\": list(composition.clusterCounts)},\n", - " )\n", - "\n", - " marker_batch = _tool_result(\n", - " messages,\n", - " \"inspect_cluster_markers_batch\",\n", - " ClusterMarkerBatchEvidence,\n", - " )\n", - " interpretations = []\n", - " for cluster in marker_batch.clusters:\n", - " if cluster.evidenceId and cluster.markers:\n", - " marker = cluster.markers[0]\n", - " marker_name = marker.featureName or marker.featureId\n", - " interpretations.append(\n", - " ClusterInterpretation(\n", - " clusterId=cluster.clusterId,\n", - " proposedIdentity=f\"{marker_name}-high RNA state\",\n", - " identityIsHypothesis=True,\n", - " confidence=\"low\",\n", - " rationale=(\n", - " \"The returned marker panel is led by \"\n", - " f\"{marker_name}.\"\n", - " ),\n", - " evidenceIds=[cluster.evidenceId],\n", - " )\n", - " )\n", - " state[\"biology\"] = 3\n", - " return _structured_output(\n", - " info,\n", - " BiologicalInterpretationReport(\n", - " status=\"done\",\n", - " clusterInterpretations=interpretations,\n", - " evidenceIds=[item.evidenceIds[0] for item in interpretations],\n", - " limitations=[\n", - " \"The scripted documentation model returns marker-linked \"\n", - " \"hypotheses, not validated cell identities.\"\n", - " ],\n", - " stopReason=(\n", - " \"Every cluster with returned marker evidence was reviewed.\"\n", - " ),\n", - " ),\n", - " )\n", - "\n", - " prompt = _prompt_text(messages)\n", - " if state[\"parameter\"] == 0:\n", - " match = re.search(\n", - " r'\"candidateId\"\\s*:\\s*\"([A-Za-z0-9_]+)\"',\n", - " prompt,\n", - " )\n", - " if match is None:\n", - " raise AssertionError(\"The parameter prompt lacks a candidate ID\")\n", - " candidate_id = match.group(1)\n", - " evidence_id = f\"candidate:{candidate_id}:clusters\"\n", - " assay_report = ParameterTuningReport(\n", - " status=\"done\",\n", - " recommendedCandidateId=candidate_id,\n", - " confidence=\"high\",\n", - " rationale=\"The only authorized native branch is eligible.\",\n", - " evidenceIds=[evidence_id],\n", - " stopReason=\"The bounded one-candidate screen completed.\",\n", - " )\n", - " state[\"parameter\"] = 1\n", - " return _structured_output(\n", - " info,\n", - " ParameterTuningReport(\n", - " status=\"done\",\n", - " assayReports={\"RNA\": assay_report},\n", - " rationale=\"The RNA native screen completed.\",\n", - " evidenceIds=[evidence_id],\n", - " stopReason=\"Native selection completed.\",\n", - " ),\n", - " )\n", - "\n", - " match = re.search(\n", - " r'\"optionId\"\\s*:\\s*\"(native:RNA:([A-Za-z0-9_]+))\"',\n", - " prompt,\n", - " )\n", - " if match is None:\n", - " raise AssertionError(\"The final-selection prompt lacks a native option\")\n", - " option_id, candidate_id = match.groups()\n", - " evidence_id = f\"native:RNA:candidate:{candidate_id}:clusters\"\n", - " state[\"parameter\"] = 2\n", - " return _structured_output(\n", - " info,\n", - " FinalGraphSelection(\n", - " status=\"done\",\n", - " selectedOptionId=option_id,\n", - " graphMethod=\"native\",\n", - " nativeAssay=\"RNA\",\n", - " nativeCandidateId=candidate_id,\n", - " markerAssay=\"RNA\",\n", - " confidence=\"high\",\n", - " rationale=\"The sole eligible native graph is selected.\",\n", - " evidenceIds=[evidence_id],\n", - " ),\n", - " )\n", - "\n", - " return FunctionModel(reply), state" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "fa111e34", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'initial_candidates': 1,\n", - " 'refinement_candidates': 0,\n", - " 'harmony_candidates': 0,\n", - " 'allow_assumptions': False}" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "model, model_state = _scripted_workflow_model()\n", - "config = AutomatedWorkflowConfig(\n", - " primaryInitialCandidates=1,\n", - " secondaryInitialCandidates=1,\n", - " maxRefinedCandidatesPerAssay=0,\n", - " maxHarmonyCandidatesPerAssay=0,\n", - " integrationResolutionCandidates=1,\n", - " maxCandidateBranches=1,\n", - " minClusterCells=2,\n", - " agentRunConfig=AgentRunConfig(\n", - " requestLimit=5,\n", - " toolCallLimit=5,\n", - " ),\n", - ")\n", - "orchestrator = AgentOrchestrator(model, config=config)\n", - "request = AutomatedWorkflowRequest(\n", - " sourcePath=str(source_path),\n", - " zarrPath=str(zarr_path),\n", - " studyContext=study_context,\n", - " allowAssumptions=False,\n", - " primaryAssay=\"RNA\",\n", - " markerAssay=\"RNA\",\n", - " analysisAssays=[\"RNA\"],\n", - " ingestDirections={\"overwrite\": True, \"defaultAssay\": \"RNA\"},\n", - ")\n", - "\n", - "{\n", - " \"initial_candidates\": config.primaryInitialCandidates,\n", - " \"refinement_candidates\": config.maxRefinedCandidatesPerAssay,\n", - " \"harmony_candidates\": config.maxHarmonyCandidatesPerAssay,\n", - " \"allow_assumptions\": request.allowAssumptions,\n", - "}" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "cab25098", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'status': 'needsInput',\n", - " 'stage': 'preprocessing_plan',\n", - " 'question_id': 'approvePlanChecksum',\n", - " 'primary_assay': 'RNA',\n", - " 'marker_assay': 'RNA',\n", - " 'cell_qc': 'globalGaussian',\n", - " 'routes': [{'assay': 'RNA', 'features': 'hvg', 'reduction': 'pca'}]}" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "with redirect_stdout(StringIO()):\n", - " result = orchestrator.run(request)\n", - "\n", - "if (\n", - " result.status != \"needsInput\"\n", - " or result.currentStage != \"preprocessing_plan\"\n", - " or result.preprocessingPlan is None\n", - " or result.workflowRun is None\n", - " or result.zarrPath is None\n", - "):\n", - " raise RuntimeError(f\"Unexpected workflow result: {result.status}, {result.notes}\")\n", - "\n", - "question = result.needsInput.questions[0]\n", - "plan = result.preprocessingPlan\n", - "{\n", - " \"status\": result.status,\n", - " \"stage\": result.currentStage,\n", - " \"question_id\": question.questionId,\n", - " \"primary_assay\": plan.primaryAssay,\n", - " \"marker_assay\": plan.markerAssay,\n", - " \"cell_qc\": plan.cellQc.action,\n", - " \"routes\": [\n", - " {\n", - " \"assay\": assay.assay,\n", - " \"features\": assay.featureMethod,\n", - " \"reduction\": assay.reductionMethod,\n", - " }\n", - " for assay in plan.assays\n", - " ],\n", - "}" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "a825546f", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'status': 'completed',\n", - " 'stage': 'biological_interpretation',\n", - " 'agent_reports': ['data_enrichment',\n", - " 'experimental_context',\n", - " 'parameter_tuning',\n", - " 'biological_interpretation'],\n", - " 'model_requests': 9,\n", - " 'graph_method': 'native',\n", - " 'marker_assay': 'RNA'}" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "with redirect_stdout(StringIO()):\n", - " result = orchestrator.resume(\n", - " AutomatedWorkflowResumeRequest(\n", - " zarrPath=result.zarrPath,\n", - " workflowRunId=result.workflowRun.workflowRunId,\n", - " workspace=result.workflowRun.workspace,\n", - " answers={\"approvePlanChecksum\": plan.planChecksum},\n", - " )\n", - " )\n", - "\n", - "if result.status != \"completed\" or result.finalAnalysis is None:\n", - " raise RuntimeError(f\"Workflow stopped at {result.currentStage}: {result.notes}\")\n", - "\n", - "{\n", - " \"status\": result.status,\n", - " \"stage\": result.currentStage,\n", - " \"agent_reports\": [ref.agentName for ref in result.reportReferences],\n", - " \"model_requests\": model_state[\"requests\"],\n", - " \"graph_method\": result.finalAnalysis.graphMethod,\n", - " \"marker_assay\": result.finalAnalysis.markerAssay,\n", - "}" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "2a585c99", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'candidates': [{'assay': 'RNA',\n", - " 'candidate': 1,\n", - " 'dimensions': 21,\n", - " 'resolution': 1.0,\n", - " 'neighbors': 11,\n", - " 'eligible': True,\n", - " 'clusters': 17,\n", - " 'smallest_cluster': 29,\n", - " 'graph_silhouette': 0.21152588062616312}],\n", - " 'stop_reason': 'Native selection completed.',\n", - " 'report_statuses': {'data_enrichment': 'done',\n", - " 'experimental_context': 'done',\n", - " 'parameter_tuning': 'done',\n", - " 'biological_interpretation': 'done'}}" - ] - }, - "execution_count": 6, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "reports = {\n", - " reference.agentName: load_agent_report(result.zarrPath, reference)\n", - " for reference in result.reportReferences\n", - "}\n", - "parameter_report = reports[\"parameter_tuning\"]\n", - "\n", - "candidate_metrics = []\n", - "for assay, assay_report in parameter_report.assayReports.items():\n", - " for index, evaluation in enumerate(assay_report.evaluations, start=1):\n", - " candidate_metrics.append(\n", - " {\n", - " \"assay\": assay,\n", - " \"candidate\": index,\n", - " \"dimensions\": evaluation.parameters.dimensions,\n", - " \"resolution\": evaluation.parameters.leidenResolution,\n", - " \"neighbors\": evaluation.parameters.neighborsK,\n", - " \"eligible\": evaluation.eligible,\n", - " \"clusters\": evaluation.metrics.nClusters,\n", - " \"smallest_cluster\": evaluation.metrics.minClusterCells,\n", - " \"graph_silhouette\": evaluation.metrics.graphSilhouetteMedian,\n", - " }\n", - " )\n", - "\n", - "{\n", - " \"candidates\": candidate_metrics,\n", - " \"stop_reason\": parameter_report.stopReason,\n", - " \"report_statuses\": {\n", - " name: report.status for name, report in reports.items()\n", - " },\n", - "}" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "ecfe4ba3", - "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "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", - "text/plain": [ - "
    " - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "final = result.finalAnalysis\n", - "if (\n", - " final.cellSelection is None\n", - " or final.clusters is None\n", - " or final.umap is None\n", - " or final.markers is None\n", - "):\n", - " raise RuntimeError(\"The completed final handoff is missing required artifacts\")\n", - "\n", - "final_store = scarf.DataStore(\n", - " result.zarrPath,\n", - " default_assay=final.primaryAssay,\n", - " min_features_per_cell=-1,\n", - " mito_pattern=\"\",\n", - " ribo_pattern=\"\",\n", - " zarr_mode=\"r\",\n", - " workspace=result.workflowRun.workspace,\n", - " nthreads=2,\n", - ")\n", - "cell_selection_ref = artifact_model_to_ref(final.cellSelection)\n", - "cluster_ref = artifact_model_to_ref(final.clusters)\n", - "umap_ref = artifact_model_to_ref(final.umap)\n", - "marker_ref = artifact_model_to_ref(final.markers)\n", - "\n", - "final_store.plots.embedding(\n", - " layout=umap_ref,\n", - " color_by=cluster_ref,\n", - " legend_loc=\"on_data\",\n", - " frame=\"none\",\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "91065488", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
    \n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
    group_idfeature_namescorefrac_exp
    01S100A120.741380.98516
    11QPCT0.734600.53116
    1390810AL022069.10.138790.04211
    1390910AC073320.10.137700.04211
    2781611ADTRP0.233320.23611
    2781711FHIT0.205660.58333
    4172412ANKRD550.252000.16746
    4172512ADTRP0.249740.24242
    5563213TCL1A0.853080.96578
    5563313IGHD0.768871.00000
    6954014TNFRSF40.399100.37500
    6954114TTC39C-AS10.300780.16189
    \n", - "
    " - ], - "text/plain": [ - " group_id feature_name score frac_exp\n", - "0 1 S100A12 0.74138 0.98516\n", - "1 1 QPCT 0.73460 0.53116\n", - "13908 10 AL022069.1 0.13879 0.04211\n", - "13909 10 AC073320.1 0.13770 0.04211\n", - "27816 11 ADTRP 0.23332 0.23611\n", - "27817 11 FHIT 0.20566 0.58333\n", - "41724 12 ANKRD55 0.25200 0.16746\n", - "41725 12 ADTRP 0.24974 0.24242\n", - "55632 13 TCL1A 0.85308 0.96578\n", - "55633 13 IGHD 0.76887 1.00000\n", - "69540 14 TNFRSF4 0.39910 0.37500\n", - "69541 14 TTC39C-AS1 0.30078 0.16189" - ] - }, - "execution_count": 8, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "marker_table = final_store.get_markers(\n", - " marker=marker_ref,\n", - " group_id=None,\n", - " min_score=-1,\n", - " min_frac_exp=-1,\n", - ")\n", - "marker_table.sort_values(\n", - " [\"group_id\", \"score\"],\n", - " ascending=[True, False],\n", - ").groupby(\"group_id\", sort=True).head(2)[\n", - " [\"group_id\", \"feature_name\", \"score\", \"frac_exp\"]\n", - "].head(12)" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "78db2f51", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'report': 'agent_workflow.zarr/agents/runs//report/index.html',\n", - " 'exists': True,\n", - " 'final_artifact_kinds': {'selection': 'cell_selection',\n", - " 'clusters': 'cluster_labels',\n", - " 'umap': 'embedding',\n", - " 'markers': 'marker_table'}}" - ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "report_path = generate_agent_report(\n", - " result.zarrPath,\n", - " result.workflowRun.workflowRunId,\n", - " workspace=result.workflowRun.workspace,\n", - ")\n", - "display_path = str(report_path.relative_to(Path(result.zarrPath).parent)).replace(\n", - " result.workflowRun.workflowRunId,\n", - " \"\",\n", - ")\n", - "\n", - "{\n", - " \"report\": display_path,\n", - " \"exists\": report_path.is_file(),\n", - " \"final_artifact_kinds\": {\n", - " \"selection\": cell_selection_ref.kind,\n", - " \"clusters\": cluster_ref.kind,\n", - " \"umap\": umap_ref.kind,\n", - " \"markers\": marker_ref.kind,\n", - " },\n", - "}" - ] - } - ], - "metadata": { - "description": "Run Scarf's resumable automated agent orchestrator on a 5K PBMC dataset.", - "jupytext": { - "cell_metadata_filter": "tags", - "text_representation": { - "extension": ".md", - "format_name": "myst", - "format_version": 0.13, - "jupytext_version": "1.14.1" - } - }, - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.14.0" - }, - "source_map": [ - 14, - 58, - 93, - 99, - 424, - 433, - 466, - 475, - 506, - 516, - 538, - 551, - 582, - 596, - 627, - 632, - 645, - 657, - 678 - ] - }, - "nbformat": 4, - "nbformat_minor": 5 -} \ No newline at end of file diff --git a/docs/.jupyter_cache/global.db b/docs/.jupyter_cache/global.db index 24f8f48c15d3aa76ea4df8ab4d73ce5b0914923e..0543b766c1ad19daeafef6007dfdf0431d135403 100644 GIT binary patch delta 2879 zcmeH}yH8X>6vlT~Li3Ks*5e0EUp7IclMl{jFh#Pk)G!aqB z6jJgZ7`2c@8;BO--(hEEYr`2JFjpoqF_zoT@5^_;duHaGDJ|zp%emLbvh9n<&t==+ zU3+`+fi2*m$Upy7;BBL@(8T0qVH4Oy2g^2+K z8QHBZqLlkK@MJeVCR$rCk!o%c%iR5|L3&!HlF?`PoVG0@xoa6r`75m%i=@ZI6OL|u z@M)MwA-S8FzbB~N>`-vpFp-u49sfukPpF!$!@+GKJ=xQ)#PmcoxG%#ge_<8MXdX|L zcDF`?(}RgXV`bFe6NT>Z=%es|=}uUT+!jnc5z2iUy;s;d4YZ2L1&ih1B&K{KTa)ST z$&Yk(O-ha9I5{yP$JsQyE~nIl!6`YdXu(bi>B?iKODdKKW({3&iL_Iq81n|BsP<9v z;rG~&#=_semCs}{&6$-w+wJYvSm|~&%_`l_Pj%{!m2O8>_|M($!oPPr zgieG6rBX({FSXU}_HalnXdQpSc%qr>o)3CHB!&i-jP&qrV%)F!UyC-h=Fzfe5AGgC+}8Kbct@%Kcyp9`h%cQr$$;&LJy-J6%G_=*Mx YM&oZ1t<8 delta 2970 zcmb`JJ#SS<5QeV}0Rz59NPtC>!X^cRh^yJz*)K~e9E1=B3J|y$U%(h_gNPtVP((3K zd|iYH!k4y)OaFriQV=O=EJ1PmV(Ugv3<|sti-|Ll;?eScXoH?nVnteo?YmE z_(|9BqfZZX4L?5r+gE$8^giny+%mm+Y4gCQ8$B<2c5b}3VR6H*kFKs?T)%zYrS8S< z-mcNw(?{*L|KsR)pNj!-LnMX>8d{+yjevSTxc%^!qR_XF-7ffrkl>PjR)q5P<9Bp! z7NsqaO!=eY+MjLjsU1079~&O_gI5RqKgW;ems6+^DLI@K3I6ieb9p~mV=3tmiUPmm z#9Tf+A~G6M^GC(B_ip3xPR8&ahVV`_^UFzVgiW4CP@MADPPPkvsX-)9Te5=aKW@(C z!$T*vP^s{Og3#XA$Kkc=Ek3VE%F`{sV0Kr{LRyay9CFnlf0X#Xg@ewm>-lRnY{a;5crp) zKjq^AVbLYyVMPM?TdnC;zjO^}jF9F1E-OTS_nA&5rag@$;MKQkr-1qtTEl8m9?FK(9TpNF#yWU*M2L5ZbMO9AjWkpka zPZuZOu}~Wmq%Vw5f^$<^PK{4ZP3j4IktQb9xVvysOb9a*~ z2K1WPm<<<L)RF%+}`I*UJDxndFgls5q+-lwvjfegZ8e^Dflu_=QI`cCi2>rizI~^6{roS5 zj&6+$Pk_Yyd}@Z@BM0xzfT-}h!a+XZf8q)H8o!_i!$d_2bgdcP39 g(bAS4Fqt2KTfP6{!F+yxgJj_C2SD^2;%A`y51wM5_W%F@ diff --git a/docs/source/analysis_with_agents.md b/docs/source/analysis_with_agents.md index df92b1a4..864c2e75 100644 --- a/docs/source/analysis_with_agents.md +++ b/docs/source/analysis_with_agents.md @@ -8,8 +8,8 @@ description: Use Scarf safely in an autonomous or AI-assisted single-cell analys This page is a routing and reasoning guide for an AI agent that uses Scarf to analyse data. It does not replace the workflow tutorials or define one correct analysis. The study question, experimental design, and user instructions remain authoritative. -For an executable ingest-to-interpretation example with persisted checkpoints and resume, see -{doc}`tutorials/agent_workflow`. +For an executable ingest-to-finalization example with persisted decisions and report generation, +see {doc}`tutorials/agent_workflow`. ## Scope and authority @@ -104,26 +104,37 @@ unit of inference. ### When to use the automated agent workflow Use `AgentOrchestrator` when the input is a supported dataset path and the caller can supply one -study-context paragraph. The orchestrator owns a fixed stage order: ingest, Data Enrichment, -optional HTO demultiplexing, Experimental Context, preprocessing-plan approval, preprocessing, -Parameter Tuning, analysis finalization, and Biological Interpretation. The model does not write -exploratory code or choose arbitrary `DataStore` calls. It selects only validated policies and -candidate identifiers from bounded evidence; executor-owned public operations create and pass exact -immutable artifact references. +study-context paragraph and one study objective. The orchestrator owns a fixed stage order: ingest, +Data Enrichment, optional HTO demultiplexing, Experimental Context, preprocessing planning and +execution, Parameter Tuning, feature-policy review with optional revised preprocessing and tuning, +analysis review, and analysis finalization. The model does not write exploratory code or choose +arbitrary `DataStore` calls. It selects only validated policies and candidate identifiers from +bounded evidence; executor-owned public operations create and pass exact immutable artifact +references. + +`BiologicalInterpretationAgent` is a separate bounded facade for interpreting finalized cluster +and marker artifacts. It is not an automatic stage of `AgentOrchestrator`. ```python from scarf.agent import ( AgentOrchestrator, + AutomatedWorkflowConfig, AutomatedWorkflowRequest, - AutomatedWorkflowResumeRequest, ) -orchestrator = AgentOrchestrator(model) +orchestrator = AgentOrchestrator( + model, + config=AutomatedWorkflowConfig( + inputPolicy="unattended", + runConfoundedHarmonyDiagnostic=True, + ), +) result = orchestrator.run( AutomatedWorkflowRequest( sourcePath="study.h5ad", zarrPath="study.zarr", studyContext="One paragraph describing the study and analysis intent.", + studyObjective="Discover stable populations relevant to the study.", ) ) ``` @@ -133,14 +144,20 @@ invoking `ds.pipeline.run()` for every candidate. This keeps normalization, redu graph, clustering, metrics, promotion, UMAP, and marker artifacts explicit and enforces their order through lineage. `ds.pipeline.run()` remains the fixed baseline recipe described below. -With `allowAssumptions=False`, `run()` can persist a complete preprocessing plan and return -`needsInput` for approval. Resume only that running workflow with -`AutomatedWorkflowResumeRequest` and the persisted question identifiers. -`allowAssumptions=True` automatically approves the evidence-bounded preprocessing plan, but it does -not authorize invented metadata or unsafe batch correction. Any agent can still pause for genuine -ambiguity. A completed local workflow persists its terminal result and then creates a replaceable -HTML report. `generate_agent_report()` can regenerate that derived view without training new -analysis artifacts. +`studyObjective` is required. `inputPolicy="pause"` permits a running workflow to return +`needsInput`; resume only that exact workflow with `AutomatedWorkflowResumeRequest` and its +persisted question identifiers. `inputPolicy="unattended"` resolves bounded model deferrals through +registered policy and turns genuinely unresolved evidence into an explicit abstention or failure +instead of waiting for a person. It does not authorize invented metadata or unsafe batch +correction. `runConfoundedHarmonyDiagnostic=True` permits a matched diagnostic branch, but Harmony +still cannot be selected unless the design and preservation gates accept it. + +The repository notebooks `notebook/agent_workflow_new.ipynb` and +`notebook/agent_workflow_new_short.ipynb` demonstrate the unattended full-cohort and sampled smoke +test configurations. The short notebook creates a deterministic library-stratified H5AD beside +the source data and labels its result as a smoke test. A completed local workflow persists its +terminal result and then creates a replaceable HTML report. `generate_agent_report()` can +regenerate that derived view without training new analysis artifacts. ### When to use the pipeline diff --git a/docs/source/developers/architecture.md b/docs/source/developers/architecture.md index 8c831ce0..b2664499 100644 --- a/docs/source/developers/architecture.md +++ b/docs/source/developers/architecture.md @@ -134,6 +134,14 @@ ledger under `pipeline/runs`; it does not write live metadata. DataStore-owned p loading, and export consume narrow frozen-run views. Completed runs can be reopened by their immutable label or exact run ID. +`agent/` owns the evidence-bounded automated workflow. Its four public agent facades are +`data_enrichment`, `experimental_context`, `parameter_tuning`, and +`biological_interpretation`. Each package keeps contracts independent of its deterministic tools, +validation, and agent runner. Supporting responsibilities live in `decisions/`, `cell_quality/`, +`hypotheses/`, `persistence/`, and `report/`. `ingest/` and `orchestrator/` remain workflow owners. +Internal modules import these concrete owners rather than the broad `scarf.agent` facade, and +`agent/tools/` contains only infrastructure shared by more than one agent. + ### Presentation `plotting/` is the only plotting package. diff --git a/docs/source/tutorials/agent_workflow.md b/docs/source/tutorials/agent_workflow.md index 6e5937b2..90dc1575 100644 --- a/docs/source/tutorials/agent_workflow.md +++ b/docs/source/tutorials/agent_workflow.md @@ -17,10 +17,14 @@ kernelspec: # Run the automated agent workflow -This tutorial sends a 10x H5 dataset and one study-context paragraph to -`AgentOrchestrator`. The orchestrator runs Scarf's four bounded agents, owns the exact operation -order, persists every handoff, and returns exact final artifact references. The agents select from -executor-authorized operations and parameters. They do not write exploratory code. +This tutorial sends a 10x H5 dataset, one study-context paragraph, and one study objective to +`AgentOrchestrator`. The orchestrator owns the exact operation order, persists every handoff, and +returns exact final artifact references. Its agents select from executor-authorized operations and +parameters. They do not write exploratory code. + +Repository developers can also run `notebook/agent_workflow_new.ipynb` on the full abdominal +adipose cohort or `notebook/agent_workflow_new_short.ipynb` on its reproducible 2,000-cell smoke +sample. Both use unattended input policy and keep runtime files beside the notebooks. ```{mermaid} flowchart LR @@ -31,15 +35,18 @@ flowchart LR E --> F[Preprocessing plan] F --> G[Modality preprocessing] G --> H[Parameter Tuning] - H --> I[UMAP, clusters, and markers] - I --> J[Biological Interpretation] - J --> K[Persisted reports and local HTML] + H --> I[Feature-policy review] + I --> J[Optional revised preprocessing and tuning] + J --> K[Analysis review] + K --> L[UMAP, clusters, and markers] + L --> M[Persisted reports and local HTML] ``` The committed documentation build uses one scripted Pydantic AI `FunctionModel`. It exercises the -real tools, validators, preprocessing, candidate execution, finalization, persistence, and resume -path without an API key. Its biological labels remain low-confidence marker-linked hypotheses. A -live-provider configuration is shown at the end. +real tools, validators, preprocessing, candidate execution, finalization, persistence, and report +generation without an API key. It does not assign biological identities; that remains a separate +`BiologicalInterpretationAgent` call after finalization. A live-provider configuration is shown at +the end. ## 1. Download the raw teaching dataset @@ -66,9 +73,10 @@ from scarf.agent import ( AgentRunConfig, AutomatedWorkflowConfig, AutomatedWorkflowRequest, - AutomatedWorkflowResumeRequest, + DecisionSelection, generate_agent_report, load_agent_report, + load_agent_workflow, ) from scarf.agent.orchestrator import artifact_model_to_ref @@ -99,7 +107,7 @@ or evidence identifier still fails the production validator. ```{code-cell} ipython3 :tags: [remove-cell] -import re +import json from typing import Any from pydantic_ai.messages import ( @@ -124,23 +132,20 @@ from scarf.agent.data_enrichment import ( ) from scarf.agent.experimental_context import ( BatchCorrectionPlan, - CellQcPlan, CovariateEvidence, ExperimentalContextDecision, ) -from scarf.agent.parameter_tuning import ( - FinalGraphSelection, - ParameterTuningReport, -) - def _prompt_text(messages: list[ModelMessage]) -> str: - return "\n".join( - part.content - for message in messages - for part in message.parts - if isinstance(getattr(part, "content", None), str) - ) + values = [] + for message in messages: + for part in message.parts: + content = getattr(part, "content", None) + if isinstance(content, str): + values.append(content) + elif isinstance(content, tuple): + values.extend(item for item in content if isinstance(item, str)) + return "\n".join(values) def _tool_result( @@ -280,7 +285,7 @@ def _scripted_workflow_model() -> tuple[FunctionModel, dict[str, int]]: profile = next( value for value in design.qcProfiles - if value.action == "globalGaussian" + if value.action == "skip" ) evidence_id = profile.evidenceId state["context"] = 3 @@ -292,16 +297,6 @@ def _scripted_workflow_model() -> tuple[FunctionModel, dict[str, int]]: rationale="No trusted technical batch column was supplied.", evidenceIds=[evidence_id], ), - cellQc=CellQcPlan( - action=profile.action, - profileId=profile.profileId, - driverAssay=profile.driverAssay, - driverAssayType=profile.driverAssayType, - attributes=profile.attributes, - artifactMetrics=profile.artifactMetrics, - rationale="Apply the bounded global RNA QC profile.", - evidenceIds=[evidence_id], - ), rationale="No experimental covariates were supplied.", evidenceIds=[evidence_id], ), @@ -366,56 +361,62 @@ def _scripted_workflow_model() -> tuple[FunctionModel, dict[str, int]]: ) prompt = _prompt_text(messages) - if state["parameter"] == 0: - match = re.search( - r'"candidateId"\s*:\s*"([A-Za-z0-9_]+)"', - prompt, - ) - if match is None: - raise AssertionError("The parameter prompt lacks a candidate ID") - candidate_id = match.group(1) - evidence_id = f"candidate:{candidate_id}:clusters" - assay_report = ParameterTuningReport( - status="done", - recommendedCandidateId=candidate_id, - confidence="high", - rationale="The only authorized native branch is eligible.", - evidenceIds=[evidence_id], - stopReason="The bounded one-candidate screen completed.", - ) - state["parameter"] = 1 + if any( + tool.parameters_json_schema.get("title") + == "AnalysisVisualAdjudication" + for tool in info.output_tools + ): + payload, _ = json.JSONDecoder().raw_decode(prompt[prompt.index("{") :]) return _structured_output( info, - ParameterTuningReport( - status="done", - assayReports={"RNA": assay_report}, - rationale="The RNA native screen completed.", - evidenceIds=[evidence_id], - stopReason="Native selection completed.", - ), + { + "status": "acceptable", + "selectedCandidateId": payload["selectedCandidateId"], + "rationale": ( + "The bounded diagnostic board agrees with the registered " + "numeric evidence." + ), + }, ) - match = re.search( - r'"optionId"\s*:\s*"(native:RNA:([A-Za-z0-9_]+))"', - prompt, + decision, _ = json.JSONDecoder().raw_decode(prompt[prompt.index("{") :]) + evidence_by_class = {} + evidence_class_by_id = {} + for item in decision["evidence"]: + evidence_by_class.setdefault( + item["evidenceClass"], + item["evidenceId"], + ) + evidence_class_by_id[item["evidenceId"]] = item["evidenceClass"] + preferred = decision.get("metricPreferredOptionId") + selected = ( + next( + option + for option in decision["options"] + if option["optionId"] == preferred + ) + if preferred is not None + else next( + option + for option in decision["options"] + if option["status"] in {"apply", "skip"} + ) ) - if match is None: - raise AssertionError("The final-selection prompt lacks a native option") - option_id, candidate_id = match.groups() - evidence_id = f"native:RNA:candidate:{candidate_id}:clusters" - state["parameter"] = 2 + evidence_ids = list(selected.get("requiredEvidenceIds", [])) + cited_classes = { + evidence_class_by_id[evidence_id] for evidence_id in evidence_ids + } + for evidence_class in selected["requiredEvidenceClasses"]: + if evidence_class not in cited_classes: + evidence_ids.append(evidence_by_class[evidence_class]) + state["parameter"] += 1 return _structured_output( info, - FinalGraphSelection( - status="done", - selectedOptionId=option_id, - graphMethod="native", - nativeAssay="RNA", - nativeCandidateId=candidate_id, - markerAssay="RNA", + DecisionSelection( + selectedOptionId=selected["optionId"], + evidenceIds=evidence_ids, + rationale="Select the registered metric-preferred option.", confidence="high", - rationale="The sole eligible native graph is selected.", - evidenceIds=[evidence_id], ), ) @@ -425,7 +426,7 @@ def _scripted_workflow_model() -> tuple[FunctionModel, dict[str, int]]: ## 2. Configure one bounded teaching branch -The production defaults screen five candidates for the primary assay and may request one +The production defaults screen eleven candidates for the primary assay and may request one refinement. This documentation run uses one native RNA candidate, no refinement, and no Harmony. The smaller search exercises the same executor and persistence path while keeping the build bounded. Harmony would be eligible only if Experimental Context returned exact safe batch evidence. @@ -433,6 +434,7 @@ bounded. Harmony would be eligible only if Experimental Context returned exact s ```{code-cell} ipython3 model, model_state = _scripted_workflow_model() config = AutomatedWorkflowConfig( + inputPolicy="unattended", primaryInitialCandidates=1, secondaryInitialCandidates=1, maxRefinedCandidatesPerAssay=0, @@ -450,7 +452,7 @@ request = AutomatedWorkflowRequest( sourcePath=str(source_path), zarrPath=str(zarr_path), studyContext=study_context, - allowAssumptions=False, + studyObjective="Discover stable major immune-cell populations.", primaryAssay="RNA", markerAssay="RNA", analysisAssays=["RNA"], @@ -461,36 +463,33 @@ request = AutomatedWorkflowRequest( "initial_candidates": config.primaryInitialCandidates, "refinement_candidates": config.maxRefinedCandidatesPerAssay, "harmony_candidates": config.maxHarmonyCandidatesPerAssay, - "allow_assumptions": request.allowAssumptions, + "input_policy": config.inputPolicy, } ``` -## 3. Run to the persisted approval checkpoint +## 3. Run without an interactive checkpoint -With `allowAssumptions=False`, the orchestrator persists the exact proposed plan before asking the -caller to approve it. Data Enrichment and Experimental Context have already completed at this -point. The documentation captures the normal report-path printout so its output does not contain a -random workflow identifier. +The unattended policy lets registered rules resolve model deferrals. Genuine unresolved evidence +becomes an explicit abstention or failure rather than a pause. The documentation captures the +normal report-path printout so its output does not contain a random workflow identifier. ```{code-cell} ipython3 with redirect_stdout(StringIO()): result = orchestrator.run(request) if ( - result.status != "needsInput" - or result.currentStage != "preprocessing_plan" + result.status != "completed" + or result.finalAnalysis is None or result.preprocessingPlan is None or result.workflowRun is None or result.zarrPath is None ): raise RuntimeError(f"Unexpected workflow result: {result.status}, {result.notes}") -question = result.needsInput.questions[0] plan = result.preprocessingPlan { "status": result.status, "stage": result.currentStage, - "question_id": question.questionId, "primary_assay": plan.primaryAssay, "marker_assay": plan.markerAssay, "cell_qc": plan.cellQc.action, @@ -505,30 +504,23 @@ plan = result.preprocessingPlan } ``` -The plan checksum binds the approval to this exact plan. A different value is rejected rather than -approving whichever plan happens to be current. +The workflow persists the exact preprocessing plan, decisions, report handoffs, and final artifact +references before returning. -## 4. Resume the same workflow +## 4. Inspect the persisted workflow -Only a running persisted workflow can resume. The answer uses the question identifier and checksum -returned above. Completed stages and artifacts are validated and reused rather than executed again. +The returned workflow identity resolves the durable record. Reopening it does not execute an +analysis stage. ```{code-cell} ipython3 -with redirect_stdout(StringIO()): - result = orchestrator.resume( - AutomatedWorkflowResumeRequest( - zarrPath=result.zarrPath, - workflowRunId=result.workflowRun.workflowRunId, - workspace=result.workflowRun.workspace, - answers={"approvePlanChecksum": plan.planChecksum}, - ) - ) - -if result.status != "completed" or result.finalAnalysis is None: - raise RuntimeError(f"Workflow stopped at {result.currentStage}: {result.notes}") +persisted_workflow = load_agent_workflow( + result.zarrPath, + result.workflowRun.workflowRunId, + workspace=result.workflowRun.workspace, +) { - "status": result.status, + "status": persisted_workflow.status, "stage": result.currentStage, "agent_reports": [ref.agentName for ref in result.reportReferences], "model_requests": model_state["requests"], @@ -537,9 +529,9 @@ if result.status != "completed" or result.finalAnalysis is None: } ``` -The single scripted provider is called by all four agents. Deterministic operations, such as HTO -routing, preprocessing, candidate execution, promotion, UMAP, clustering, marker search, and -persistence, do not require separate model requests. +The single scripted provider handles every model-driven orchestrator stage. Deterministic +operations, such as HTO routing, preprocessing, candidate execution, promotion, UMAP, clustering, +marker search, and persistence, do not require separate model requests. ## 5. Review parameter evidence and agent reports @@ -589,9 +581,9 @@ comparison. ## 6. Plot the exact final UMAP and inspect markers `FinalAnalysisHandoff` separates graph ownership from marker-assay ownership and contains the exact -selection, graph, clusters, UMAP, and marker references used by Biological Interpretation. The -plotting call consumes those references directly; no coordinates or labels are copied into live -metadata columns. +selection, graph, clusters, UMAP, and marker references that can be passed to Biological +Interpretation. The plotting call consumes those references directly; no coordinates or labels are +copied into live metadata columns. ```{code-cell} ipython3 final = result.finalAnalysis @@ -679,10 +671,12 @@ display_path = str(report_path.relative_to(Path(result.zarrPath).parent)).replac ## Pauses, failures, and other input formats -`needsInput` keeps the workflow running. Inspect every returned question and supply only grounded -answers. `failed` and `abandoned` are terminal. An ingest question can occur before a persisted -workflow exists; update `ingestDirections` and call `run()` again in that case. A running workflow -can also be finalized as abandoned with `orchestrator.cancel()`. +With `inputPolicy="pause"`, `needsInput` keeps the workflow running. Inspect every returned +question and supply only grounded answers through `AutomatedWorkflowResumeRequest`. +`inputPolicy="unattended"` returns an explicit abstention or failure when evidence cannot be +resolved safely. `failed` and `abandoned` are terminal. An ingest ambiguity can occur before a +persisted workflow exists; update `ingestDirections` and call `run()` again in that case. A running +workflow can also be finalized as abandoned with `orchestrator.cancel()`. For another new local H5 or H5AD input, provide a destination that does not yet exist: @@ -691,9 +685,12 @@ request = AutomatedWorkflowRequest( sourcePath="study.h5ad", zarrPath="study.zarr", studyContext="One paragraph describing the study, design, and analysis intent.", - allowAssumptions=False, + studyObjective="Discover stable populations relevant to the study.", ) -result = AgentOrchestrator(model).run(request) +result = AgentOrchestrator( + model, + config=AutomatedWorkflowConfig(inputPolicy="unattended"), +).run(request) ``` For an existing Zarr input, omit `zarrPath` or set it to the same location. Its current `I` @@ -719,7 +716,13 @@ model = OpenAIChatModel( ), ) -orchestrator = AgentOrchestrator(model) +orchestrator = AgentOrchestrator( + model, + config=AutomatedWorkflowConfig( + inputPolicy="unattended", + runConfoundedHarmonyDiagnostic=True, + ), +) result = orchestrator.run( AutomatedWorkflowRequest( sourcePath="study.h5ad", @@ -728,7 +731,9 @@ result = orchestrator.run( "Human single-cell study with three biological replicates per " "condition; donor is the unit of inference and library is technical." ), - allowAssumptions=False, + studyObjective=( + "Discover stable populations while preserving the condition structure." + ), ) ) ``` diff --git a/scarf/agent/decisions/rna.py b/scarf/agent/decisions/rna.py index d36ccf2b..d82fb9c8 100644 --- a/scarf/agent/decisions/rna.py +++ b/scarf/agent/decisions/rna.py @@ -1434,6 +1434,10 @@ def build_cluster_partition_decision( raise RnaDecisionGateError( "metric_preferred_option_id must be a registered resolution option" ) + baseline_option_id = min( + rows, + key=lambda item: (abs(item[2] - 0.75), item[2]), + )[0] visible = [ DecisionOption( optionId=option_id, @@ -1479,7 +1483,7 @@ def build_cluster_partition_decision( question="Which registered partition is scientifically defensible?", visible_options=visible, executor_options=executor, - baseline_option_id="clusterResolution:balanced", + baseline_option_id=baseline_option_id, metric_preferred_option_id=metric_preferred_option_id, require_override_evidence=True, ) diff --git a/scarf/agent/orchestrator/decisions.py b/scarf/agent/orchestrator/decisions.py index 1832d62b..503ff1ad 100644 --- a/scarf/agent/orchestrator/decisions.py +++ b/scarf/agent/orchestrator/decisions.py @@ -223,6 +223,10 @@ def _validate_selection( "A metric override requires two independent non-geometric " "evidence classes" ) + elif selection.overrideOfOptionId is not None or selection.overrideEvidenceIds: + raise ValueError( + "Override fields require an eligible metric-preferred override" + ) return selection diff --git a/scarf/agent/orchestrator/finalization.py b/scarf/agent/orchestrator/finalization.py index 7acd3c29..8c52c51b 100644 --- a/scarf/agent/orchestrator/finalization.py +++ b/scarf/agent/orchestrator/finalization.py @@ -258,39 +258,52 @@ def analysis_finalization_stage( for name, artifact in sorted(selected.artifacts.items()) if name.startswith("doubletCellSelection:") ] - if not doublet_scores: - raise ValueError( - "Selected cluster evidence lacks advisory doublet scores" - ) if len(doublet_scores) != len(doublet_score_selections): raise ValueError( "Advisory doublet scores lack exact cell-selection lineage" ) - for index, doublet_model in enumerate(doublet_scores): - store.load_artifact(artifact_model_to_ref(doublet_model)) - artifacts[f"doubletScore{index}"] = doublet_model - doublet_selection = doublet_score_selections[index] - store.load_artifact(artifact_model_to_ref(doublet_selection)) - artifacts[f"doubletScoreSelection{index}"] = doublet_selection - limitations.extend( + doublet_limitations = [ warning for warning in selected.warnings if "doublet" in warning.lower() or "physical capture identity" in warning.lower() - ) - actions.append("reuse_advisory_doublet_scores") - operations.append( - { - "operation": "reuse_advisory_doublet_scores", - "artifacts": [ - value.model_dump(mode="json") for value in doublet_scores - ], - "cellSelections": [ - value.model_dump(mode="json") - for value in doublet_score_selections - ], - } - ) + ] + limitations.extend(doublet_limitations) + if doublet_scores: + for index, doublet_model in enumerate(doublet_scores): + store.load_artifact(artifact_model_to_ref(doublet_model)) + artifacts[f"doubletScore{index}"] = doublet_model + doublet_selection = doublet_score_selections[index] + store.load_artifact(artifact_model_to_ref(doublet_selection)) + artifacts[f"doubletScoreSelection{index}"] = doublet_selection + actions.append("reuse_advisory_doublet_scores") + operations.append( + { + "operation": "reuse_advisory_doublet_scores", + "artifacts": [ + value.model_dump(mode="json") for value in doublet_scores + ], + "cellSelections": [ + value.model_dump(mode="json") + for value in doublet_score_selections + ], + } + ) + elif any( + warning.startswith("Advisory doublet scoring was not run for assay ") + for warning in doublet_limitations + ): + actions.append("record_unavailable_advisory_doublet_scores") + operations.append( + { + "operation": "record_unavailable_advisory_doublet_scores", + "limitations": doublet_limitations, + } + ) + else: + raise ValueError( + "Selected cluster evidence lacks advisory doublet scores" + ) hypothesis_directions = request_record.request.experimentalDirections.get( "hypothesisTesting", @@ -560,7 +573,12 @@ def analysis_finalization_stage( "maximumClusterConcentration": ( selected.metrics.doubletHighScoreConcentration ), - "policy": "scoreAndFlagWithoutRemoval", + "policy": ( + "scoreAndFlagWithoutRemoval" + if doublet_scores + else "unavailable" + ), + "limitations": doublet_limitations, }, markerEvidence={ "coherence": selected.metrics.markerCoherence, diff --git a/scarf/agent/parameter_tuning/diagnostics.py b/scarf/agent/parameter_tuning/diagnostics.py index 0988e413..9a1fef83 100644 --- a/scarf/agent/parameter_tuning/diagnostics.py +++ b/scarf/agent/parameter_tuning/diagnostics.py @@ -28,6 +28,7 @@ from ...storage.refs import ArtifactRef from ...storage.selections import read_stored_selection_indices from ...storage.types import as_zarr_array +from ...utils.logging import logger from .contracts import ArtifactRecord, ParameterCandidateEvaluation from .selection import annotate_candidate_dominance @@ -40,7 +41,7 @@ "covariate_association", "adjacent_neighbor_overlap", ) -_MAX_DOUBLET_CAPTURES = 256 +_MAX_DOUBLET_CAPTURES = 512 SCARF_DEFAULT_DIAGNOSTIC_FAMILIES = ( "mitochondrial", "ribosomal", @@ -1051,6 +1052,26 @@ def score_advisory_doublets( selected, evaluations, ) + feature_ids = np.asarray(store.get_assay(assay).feats.fetch_all("ids")).astype(str) + unique_ids, id_counts = np.unique(feature_ids, return_counts=True) + duplicate_ids = unique_ids[id_counts > 1] + if duplicate_ids.size: + examples = duplicate_ids[:5].tolist() + limitation = ( + f"Advisory doublet scoring was not run for assay {assay!r} because " + f"{duplicate_ids.size} feature identifiers are duplicated. " + "Simulated-to-observed mapping requires unique identifiers. " + f"Examples: {examples}." + ) + logger.warning(limitation) + return AdvisoryDoubletScores( + scores=(), + cell_selections=(), + native_graph=native_graph, + native_clusters=native_clusters, + capture_column=capture_column, + limitations=(limitation,), + ) limitations: list[str] = [] if capture_column is None or capture_column not in store.cells.columns: score = store.run_doublet_detection( @@ -1496,7 +1517,7 @@ def augment_cluster_evaluations( selection_ref, doublet_evidence, ) - if doublet_evidence is not None + if doublet_evidence is not None and doublet_evidence.scores else None ) unit_scores = [ @@ -1587,7 +1608,7 @@ def augment_cluster_evaluations( f"candidate:{evaluation.candidateId}:doubletScoreTails", f"candidate:{evaluation.candidateId}:doubletCaptureCoverage", ] - if doublet_evidence is not None + if doublet_evidence is not None and doublet_evidence.scores else [] ), ] @@ -1620,7 +1641,7 @@ def augment_cluster_evaluations( doublet_evidence.native_clusters ), } - if doublet_evidence is not None + if doublet_evidence is not None and doublet_evidence.scores else {} ), } diff --git a/tests/test_agent_decision_persistence.py b/tests/test_agent_decision_persistence.py index 3d5d5ad0..7a22c528 100644 --- a/tests/test_agent_decision_persistence.py +++ b/tests/test_agent_decision_persistence.py @@ -576,6 +576,39 @@ def test_snapshot_storage_does_not_overwrite_existing_content(tmp_path: Any) -> ) +def test_selection_validator_rejects_ineligible_override_fields() -> None: + definition = build_feature_policy_decision( + evidence_bundle_id="bundle:features", + proposed_exclusion_families=["ribosomal"], + dominant_families=["ribosomal"], + protected_families=[], + ) + evidence = EvidenceBundle( + bundleId="bundle:features", + decisionId="featurePolicy", + evidence=[ + DecisionEvidence( + evidenceId="evidence:technical", + evidenceClass="technical", + summary="Ribosomal features dominate the representation.", + ) + ], + ) + selection = DecisionSelection( + selectedOptionId="featurePolicy:excludeEligibleBundle", + evidenceIds=["evidence:technical"], + rationale="Exclude the eligible family.", + overrideOfOptionId="featurePolicy:keepAll", + overrideEvidenceIds=["evidence:technical"], + ) + + with pytest.raises( + ValueError, + match="Override fields require an eligible metric-preferred override", + ): + decisions_module._validate_selection(definition, evidence, selection) + + def test_resolver_replays_an_exact_audited_decision_without_provider( tmp_path: Any, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_agent_rna_decisions.py b/tests/test_agent_rna_decisions.py index c48cb5db..e1168c4d 100644 --- a/tests/test_agent_rna_decisions.py +++ b/tests/test_agent_rna_decisions.py @@ -345,6 +345,16 @@ def test_clustering_uses_fixed_resolutions_and_requires_override_evidence() -> N assert compiled.verification.status == "passed" +def test_clustering_baseline_uses_nearest_registered_resolution() -> None: + definition = build_cluster_partition_decision( + evidence_bundle_id="bundle:cluster", + metric_preferred_option_id="clusterResolution:coarse", + resolution_candidates=(0.5,), + ) + + assert definition.spec.baselineOptionId == "clusterResolution:coarse" + + def test_clustering_can_abstain_without_inventing_a_resolution() -> None: definition = build_cluster_partition_decision( evidence_bundle_id="bundle:cluster", diff --git a/tests/test_agent_tuning_diagnostics.py b/tests/test_agent_tuning_diagnostics.py index b3aa2d74..61341f86 100644 --- a/tests/test_agent_tuning_diagnostics.py +++ b/tests/test_agent_tuning_diagnostics.py @@ -1,11 +1,17 @@ +from types import SimpleNamespace + import numpy as np import pytest from scipy.sparse import block_diag, csr_matrix +from scarf.agent.parameter_tuning import diagnostics as diagnostics_module +from scarf.agent.parameter_tuning.contracts import ParameterCandidateEvaluation from scarf.agent.parameter_tuning.diagnostics import ( _cross_unit_support, _subsample_partition_stability, + score_advisory_doublets, ) +from scarf.storage.refs import ArtifactRef def test_cross_unit_support_requires_replication_per_cluster() -> None: @@ -24,3 +30,59 @@ def test_subsample_partition_stability_reclusters_induced_graph() -> None: assert _subsample_partition_stability(graph, labels, 0.5) == pytest.approx(1.0) with pytest.raises(ValueError, match="does not align"): _subsample_partition_stability(graph, labels[:-1], 0.5) + + +def test_advisory_doublets_record_duplicate_feature_limitation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + graph = ArtifactRef( + scope="assay", + assay="RNA", + kind="connectivity_map", + artifact_id="a" * 64, + ) + clusters = ArtifactRef( + scope="assay", + assay="RNA", + kind="cluster_labels", + artifact_id="b" * 64, + ) + monkeypatch.setattr( + diagnostics_module, + "resolve_native_doublet_inputs", + lambda *_args: (clusters, graph), + ) + + class Features: + def fetch_all(self, column: str) -> np.ndarray: + assert column == "ids" + return np.asarray(["A", "A", "B", "C", "C"]) + + class Store: + def get_assay(self, assay: str) -> SimpleNamespace: + assert assay == "RNA" + return SimpleNamespace(feats=Features()) + + def run_doublet_detection(self, *_args: object, **_kwargs: object) -> None: + raise AssertionError("duplicate identifiers must stop doublet mapping") + + evidence = score_advisory_doublets( + Store(), + ParameterCandidateEvaluation.get_example(), + [ParameterCandidateEvaluation.get_example()], + assay="RNA", + feature_selection=ArtifactRef( + scope="assay", + assay="RNA", + kind="feature_selection", + artifact_id="c" * 64, + ), + capture_column="library_id", + ) + + assert evidence.scores == () + assert evidence.cell_selections == () + assert evidence.native_graph == graph + assert evidence.native_clusters == clusters + assert len(evidence.limitations) == 1 + assert "2 feature identifiers are duplicated" in evidence.limitations[0] From e6a7613caafd9439db69a795a4cdd95770cd3ec3 Mon Sep 17 00:00:00 2001 From: gautam8387 Date: Sun, 6 Sep 2026 15:14:00 +0200 Subject: [PATCH 10/21] fix tests --- tests/test_agent_orchestrator_stages.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/tests/test_agent_orchestrator_stages.py b/tests/test_agent_orchestrator_stages.py index 339fccd7..0e3e9269 100644 --- a/tests/test_agent_orchestrator_stages.py +++ b/tests/test_agent_orchestrator_stages.py @@ -13,7 +13,7 @@ import scarf.agent.orchestrator.context as context_module import scarf.agent.orchestrator.journal as journal_module import scarf.agent.orchestrator.tuning as tuning_module -import scarf.agent.parameter_tuning.agent as parameter_tuning_agent +import scarf.agent.parameter_tuning.selection as parameter_tuning_selection from scarf.agent.orchestrator.preprocessing import PreprocessingStagesMixin from scarf.agent.config import AgentRunConfig from scarf.agent.config.agent_exec import ( @@ -1479,13 +1479,9 @@ def test_initial_candidates_reject_fully_invalid_rank_or_neighbor_count() -> Non ) -def test_parameter_tuning_rejects_legacy_refinement_budget( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - del tmp_path, monkeypatch - with pytest.raises(ValueError, match="less than or equal to 0"): - AutomatedWorkflowConfig(maxRefinedCandidatesPerAssay=1) +def test_parameter_tuning_rejects_more_than_one_refinement_candidate() -> None: + with pytest.raises(ValueError, match="less than or equal to 1"): + AutomatedWorkflowConfig(maxRefinedCandidatesPerAssay=2) def test_final_selection_pause_exposes_exact_options_and_resumes_without_screen( @@ -1591,7 +1587,11 @@ def selection_execution(**_kwargs: Any) -> Any: ), ) - monkeypatch.setattr(parameter_tuning_agent, "run_agent_sync", selection_execution) + monkeypatch.setattr( + parameter_tuning_selection, + "run_agent_sync", + selection_execution, + ) class CountingAgent: config = AgentRunConfig() @@ -1625,6 +1625,11 @@ def select_final( lambda *_args, **_kwargs: CountingAgent(), ) orchestrator = AgentOrchestrator(object()) + monkeypatch.setattr( + orchestrator, + "_augment_legacy_scientific_evidence", + lambda _store, report, **_kwargs: report, + ) def evaluate_integrations(*_args: Any, **_kwargs: Any) -> list[Any]: calls["integrate"] += 1 From 49660421139f5979c327e521a647a68f89f2573f Mon Sep 17 00:00:00 2001 From: gautam8387 Date: Mon, 7 Sep 2026 11:40:21 +0200 Subject: [PATCH 11/21] test coverage --- tests/test_agent_data_enrichment.py | 399 +++++++++++++ tests/test_agent_decision_kernel.py | 642 +++++++++++++++++++++ tests/test_agent_experimental_context.py | 686 ++++++++++++++++++++++- tests/test_agent_rna_decisions.py | 489 ++++++++++++++++ tests/test_registered_qc_profiles.py | 564 ++++++++++++++++++- 5 files changed, 2772 insertions(+), 8 deletions(-) diff --git a/tests/test_agent_data_enrichment.py b/tests/test_agent_data_enrichment.py index d96afa71..84593422 100644 --- a/tests/test_agent_data_enrichment.py +++ b/tests/test_agent_data_enrichment.py @@ -10,6 +10,8 @@ from pydantic_ai.models.function import AgentInfo, FunctionModel import scarf.agent.data_enrichment.agent as data_enrichment_agent_module +import scarf.agent.data_enrichment.tools as data_enrichment_tools +import scarf.agent.data_enrichment.validation as data_enrichment_validation from scarf.agent.data_enrichment.characterization import FeatureCharacterization from scarf.agent.data_enrichment import ( AdtControlEvidence, @@ -31,6 +33,7 @@ FeatureSelectionPolicy, HtoTagEvidence, StudyContextSummary, + find_present_features, find_present_features_batch, validate_data_enrichment_report, ) @@ -594,6 +597,190 @@ def test_feature_lookup_cache_rejects_different_arguments() -> None: ) +def test_data_enrichment_tool_helpers_cover_resolution_edges() -> None: + assert data_enrichment_tools._assay_modality(None, "RNAassay") == ( + "RNA", + "RNAassay", + "assayClass", + ) + assert data_enrichment_tools._assay_modality(None, "ATACassay") == ( + "ATAC", + "ATACassay", + "assayClass", + ) + assert data_enrichment_tools._assay_modality(None, "CustomAssay") == ( + "unsupported", + "CustomAssay", + "assayClass", + ) + assert data_enrichment_tools._assay_modality(None, "") == ( + "unsupported", + "Assay", + "unknown", + ) + assert data_enrichment_tools._valid_peak_coordinate("chr1:10") is False + assert data_enrichment_tools._valid_peak_coordinate("chr1:start-20") is False + controls = data_enrichment_tools._inspect_adt_features( + "ADT", + [("control-1", "IgG control")], + ) + assert controls[0].matchedToken == "control" + assert data_enrichment_tools._inspect_atac_features("ATAC", []).status == "invalid" + assert ( + data_enrichment_tools._inspect_atac_features( + "ATAC", + ["chr1:10-20"], + ).status + == "valid" + ) + future_tool = SimpleNamespace(name="future_tool") + assert ( + data_enrichment_tools._prepare_data_enrichment_tool( + SimpleNamespace(deps=DataEnrichmentDependencies()), + future_tool, + ) + is future_tool + ) + + +def test_find_present_features_reports_casefold_ambiguity_and_absence() -> None: + class LookupFeatures: + @staticmethod + def fetch_all(column: str) -> list[str]: + return { + "ids": ["GENE1", "GENE2", "GAPDH"], + "names": ["shared", "shared", "GAPDH"], + }[column] + + store = SimpleNamespace( + get_assay=lambda _name: SimpleNamespace(feats=LookupFeatures()) + ) + deps = DataEnrichmentDependencies(store=store, assays=["RNA"]) + + result = asyncio.run( + find_present_features( + SimpleNamespace(deps=deps), + assay_name="RNA", + queries=["gapdh", "shared", "missing"], + ) + ) + + assert [item.status for item in result.results] == [ + "present", + "ambiguous", + "absent", + ] + assert deps.confirmedFeatures["RNA"] == {"GAPDH"} + assert result.results[1].evidenceIds == [] + assert result.results[2].matches == [] + + +def test_feature_lookup_tools_reject_invalid_requests() -> None: + with pytest.raises(ModelRetry, match="datastore is unavailable"): + asyncio.run( + find_present_features( + SimpleNamespace( + deps=DataEnrichmentDependencies(store=None, assays=["RNA"]) + ), + assay_name="RNA", + queries=["GAPDH"], + ) + ) + deps = DataEnrichmentDependencies(store=ReadOnlyStore(), assays=["RNA"]) + context = SimpleNamespace(deps=deps) + with pytest.raises(ModelRetry, match="requested assays"): + asyncio.run( + find_present_features( + context, + assay_name="ADT", + queries=["CD3"], + ) + ) + with pytest.raises(ModelRetry, match="between 1 and 50"): + asyncio.run( + find_present_features( + context, + assay_name="RNA", + queries=[], + ) + ) + with pytest.raises(ModelRetry, match="Unknown requested assays"): + asyncio.run( + find_present_features_batch( + context, + queries_by_assay={"ADT": ["CD3"]}, + ) + ) + with pytest.raises(ModelRetry, match="at least one assay"): + asyncio.run(find_present_features_batch(context, queries_by_assay={})) + with pytest.raises(ModelRetry, match="cannot be empty"): + asyncio.run( + find_present_features_batch( + context, + queries_by_assay={"RNA": []}, + ) + ) + with pytest.raises(ModelRetry, match="at most 50"): + asyncio.run( + find_present_features_batch( + context, + queries_by_assay={ + "RNA": [f"gene-{index}" for index in range(51)], + }, + ) + ) + with pytest.raises(ModelRetry, match="requested assays"): + asyncio.run( + data_enrichment_tools.inspect_assay_features( + context, + assay_name="ADT", + ) + ) + empty = SimpleNamespace( + deps=DataEnrichmentDependencies(store=ReadOnlyStore(), assays=[]) + ) + with pytest.raises(ModelRetry, match="No assays were requested"): + asyncio.run(data_enrichment_tools.inspect_assay_features_batch(empty)) + + +def test_assay_inspection_rejects_failed_or_modified_characterization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + context = SimpleNamespace( + deps=DataEnrichmentDependencies(store=ReadOnlyStore(), assays=["RNA"]) + ) + monkeypatch.setattr( + data_enrichment_tools, + "characterize_features", + lambda *_args, **_kwargs: FeatureCharacterization( + status="failed", + notes=["feature inspection failed"], + ), + ) + with pytest.raises(ModelRetry, match="feature inspection failed"): + asyncio.run( + data_enrichment_tools.inspect_assay_features( + context, + assay_name="RNA", + ) + ) + + modified = characterization() + modified.assays[0]["defaultFeatureInventory"] = {"blacklist": "modified"} + monkeypatch.setattr( + data_enrichment_tools, + "characterize_features", + lambda *_args, **_kwargs: modified, + ) + with pytest.raises(ModelRetry, match="exact default HVG blacklist"): + asyncio.run( + data_enrichment_tools.inspect_assay_features( + context, + assay_name="RNA", + ) + ) + + def test_data_enrichment_validates_policy_and_assay() -> None: with pytest.raises(ValueError, match="both excluded and protected"): FeatureSelectionPolicy( @@ -731,3 +918,215 @@ def test_artificial_feature_requires_feature_specific_evidence() -> None: with pytest.raises(ValueError, match="feature-specific"): validate_data_enrichment_report(deps, report) + + +def test_study_context_summary_rejects_unbounded_or_ungrounded_references() -> None: + context = DataEnrichmentContext(studyContext="Human lung study") + with pytest.raises(ValueError, match="verbatim caller text"): + data_enrichment_validation._ground_study_context_summary( + context, + StudyContextSummary(hypothesisReferences=["invented hypothesis"]), + ) + with pytest.raises(ValueError, match="at most 12"): + data_enrichment_validation._ground_study_context_summary( + context, + StudyContextSummary( + tissueReferences=[f"tissue-{index}" for index in range(13)] + ), + ) + long_reference = "x" * 241 + with pytest.raises(ValueError, match="may not exceed 240"): + data_enrichment_validation._ground_study_context_summary( + DataEnrichmentContext(studyContext=long_reference), + StudyContextSummary(analysisIntentReferences=[long_reference]), + ) + + +def test_feature_policy_validation_rejects_ungrounded_contracts() -> None: + inspection = AssayFeatureInspection( + assay="RNA", + species="unknown", + families=[ + FeatureFamilyEvidence( + family="mitochondrial", + defaultExclude=True, + evidenceId="assay:RNA:family:mitochondrial", + ) + ], + evidenceIds=["assay:RNA:species"], + ) + deps = DataEnrichmentDependencies( + store=ReadOnlyStore(), + context=DataEnrichmentContext(), + assays=["RNA"], + inspections={"RNA": inspection}, + evidenceIds={"assay:RNA:species"}, + ) + grounded = StudyContextSummary() + validate = data_enrichment_validation._validate_feature_policy + + unsupported = FeatureSelectionPolicy.model_construct( + assay="RNA", + species="unsupported", + evidenceIds=["assay:RNA:species"], + ) + with pytest.raises(ValueError, match="unsupported species"): + validate(deps, unsupported, grounded) + with pytest.raises(ValueError, match="requires evidence IDs"): + validate(deps, FeatureSelectionPolicy(assay="RNA"), grounded) + with pytest.raises(ValueError, match="was not inspected"): + validate( + deps.model_copy(update={"inspections": {}}), + FeatureSelectionPolicy( + assay="RNA", + evidenceIds=["assay:RNA:species"], + ), + grounded, + ) + with pytest.raises(ValueError, match="must cite context evidence"): + validate( + deps, + FeatureSelectionPolicy( + assay="RNA", + species="homo_sapiens", + evidenceIds=["assay:RNA:species"], + ), + grounded, + ) + with pytest.raises(ValueError, match="conflicts with inspected"): + validate( + deps.model_copy( + update={ + "inspections": { + "RNA": inspection.model_copy(update={"species": "homo_sapiens"}) + } + } + ), + FeatureSelectionPolicy( + assay="RNA", + species="mus_musculus", + evidenceIds=["assay:RNA:species"], + ), + grounded, + ) + with pytest.raises(ValueError, match="unobserved families"): + validate( + deps, + FeatureSelectionPolicy( + assay="RNA", + protectFamilies=["cellCycle"], + evidenceIds=["assay:RNA:species"], + ), + grounded, + ) + with pytest.raises(ValueError, match="unknown evidence IDs"): + validate( + deps, + FeatureSelectionPolicy( + assay="RNA", + evidenceIds=["evidence:unknown"], + ), + grounded, + ) + + +def test_feature_policy_accepts_feature_specific_context_evidence() -> None: + inspection = AssayFeatureInspection( + assay="RNA", + species="unknown", + evidenceIds=["assay:RNA:species"], + ) + deps = DataEnrichmentDependencies( + store=ReadOnlyStore(), + context=DataEnrichmentContext( + experimentalDetails=["ERCC-00002 spike-in"], + ), + assays=["RNA"], + inspections={"RNA": inspection}, + confirmedFeatures={"RNA": {"ERCC-00002"}}, + evidenceIds={"assay:RNA:species", "context:experiment:0"}, + ) + policy = FeatureSelectionPolicy( + assay="RNA", + artificialFeatures=["ERCC-00002"], + evidenceIds=["assay:RNA:species", "context:experiment:0"], + ) + + data_enrichment_validation._validate_feature_policy( + deps, + policy, + StudyContextSummary(), + ) + + assert policy.artificialFeatures == ["ERCC-00002"] + + +def test_data_enrichment_report_rejects_incomplete_assay_inventory() -> None: + inspection = AssayFeatureInspection( + assay="RNA", + species="unknown", + evidenceIds=["assay:RNA:species"], + ) + with pytest.raises(ValueError, match="Inspect every requested assay"): + validate_data_enrichment_report( + DataEnrichmentDependencies(store=ReadOnlyStore(), assays=["RNA"]), + DataEnrichmentReport( + status="needsInput", + unresolvedQuestions=["Inspect the requested assays."], + ), + ) + deps = DataEnrichmentDependencies( + store=ReadOnlyStore(), + assays=["RNA"], + inspections={"RNA": inspection}, + evidenceIds={"assay:RNA:species"}, + ) + with pytest.raises(ValueError, match="outside the requested set"): + validate_data_enrichment_report( + deps, + DataEnrichmentReport( + status="needsInput", + unresolvedQuestions=["Resolve the assay mismatch."], + policies=[ + FeatureSelectionPolicy( + assay="ADT", + evidenceIds=["assay:RNA:species"], + ) + ], + ), + ) + with pytest.raises(ValueError, match="one policy for every requested assay"): + validate_data_enrichment_report( + deps.model_copy(update={"assays": ["RNA", "ADT"]}), + DataEnrichmentReport( + status="done", + policies=[ + FeatureSelectionPolicy( + assay="RNA", + evidenceIds=["assay:RNA:species"], + ) + ], + ), + ) + + +def test_deterministic_enrichment_requires_complete_inspection_evidence() -> None: + error = RuntimeError("model failed") + incomplete = DataEnrichmentDependencies( + store=ReadOnlyStore(), + assays=["RNA"], + ) + with pytest.raises(RuntimeError, match="model failed"): + data_enrichment_validation.deterministic_data_enrichment_report( + incomplete, + error=error, + model_name="test", + ) + + empty_inspection = AssayFeatureInspection(assay="RNA", species="unknown") + with pytest.raises(ValueError, match="no deterministic feature evidence"): + data_enrichment_validation.deterministic_data_enrichment_report( + incomplete.model_copy(update={"inspections": {"RNA": empty_inspection}}), + error=error, + model_name="test", + ) diff --git a/tests/test_agent_decision_kernel.py b/tests/test_agent_decision_kernel.py index b068c13a..97cdb9b1 100644 --- a/tests/test_agent_decision_kernel.py +++ b/tests/test_agent_decision_kernel.py @@ -1,5 +1,7 @@ """Contract tests for the decision kernel and deterministic auditor.""" +from collections.abc import Callable + import pytest from pydantic import ValidationError @@ -7,14 +9,18 @@ DecisionEvidence, DecisionOption, DecisionRecord, + DecisionSelection, DecisionSpec, DecisionWorkflowRun, DeterministicDecisionAuditor, EvidenceBundle, + PendingDecision, + ProtectedVariableEffect, RevisionRequest, VerificationCheck, VerificationRecord, ) +from scarf.agent.types import ArtifactReferenceModel def _evidence_bundle() -> EvidenceBundle: @@ -121,6 +127,38 @@ def _decision_record( ) +def _verification_record( + record: DecisionRecord, + *, + verification_id: str | None = None, + status: str = "passed", +) -> VerificationRecord: + return VerificationRecord( + verificationId=verification_id or f"verification:{record.recordId}", + decisionRecordId=record.recordId, + status=status, + checks=[ + VerificationCheck( + checkId=f"check:{record.recordId}", + status=status, + summary=f"The decision {status} its deterministic check.", + ) + ], + ) + + +def _pending_decision() -> PendingDecision: + return PendingDecision( + questionId="question:cluster", + decisionId="clusterPartition", + definitionVersion=1, + evidenceBundleId="bundle:clusterPartition", + evidenceBundleSha256="0" * 64, + offeredOptionIds=["partition:coarse", "partition:fine"], + reason="More evidence is required.", + ) + + def test_option_contract_rejects_freeform_execution_parameters() -> None: with pytest.raises(ValidationError, match="Extra inputs are not permitted"): DecisionOption.model_validate( @@ -148,6 +186,30 @@ def test_evidence_bundle_rejects_duplicate_evidence_ids() -> None: ) +def test_evidence_contract_rejects_duplicate_artifacts_and_bad_checksums() -> None: + reference = ArtifactReferenceModel( + scope="assay", + assay="RNA", + kind="pca", + artifactId="a" * 64, + ) + with pytest.raises(ValidationError, match="must not contain duplicates"): + DecisionEvidence( + evidenceId="evidence:pca", + evidenceClass="geometric", + summary="Observed PCA evidence.", + artifactReferences=[reference, reference], + ) + + values = _evidence_bundle().model_dump() + values["contentSha256"] = "0" * 64 + with pytest.raises(ValidationError, match="does not match"): + EvidenceBundle.model_validate(values) + values["contentSha256"] = "INVALID" + with pytest.raises(ValidationError, match="lowercase SHA-256"): + EvidenceBundle.model_validate(values) + + @pytest.mark.parametrize( ("changes", "message"), [ @@ -178,6 +240,272 @@ def test_decision_record_rejects_non_exact_references( DecisionRecord.model_validate(values) +@pytest.mark.parametrize( + ("changes", "message"), + [ + ( + { + "overrideOfOptionId": "partition:fine", + "overrideEvidenceIds": ["evidence:stability"], + }, + "overrideEvidenceIds must be included", + ), + ( + {"overrideOfOptionId": "partition:invented"}, + "overrideOfOptionId must reference", + ), + ( + {"overrideOfOptionId": "partition:coarse"}, + "overrideOfOptionId must differ", + ), + ( + {"supersedes": "decision:cluster:1"}, + "cannot supersede itself", + ), + ( + { + "protectedVariableEffects": [ + ProtectedVariableEffect( + variable="disease", + status="preserved", + evidenceIds=["evidence:invented"], + summary="Disease structure is preserved.", + ).model_dump() + ] + }, + "protectedVariableEffects must reference", + ), + ], +) +def test_decision_record_rejects_invalid_override_and_lineage_references( + changes: dict[str, object], + message: str, +) -> None: + values = _decision_record().model_dump() + values.update(changes) + with pytest.raises(ValidationError, match=message): + DecisionRecord.model_validate(values) + + +@pytest.mark.parametrize( + ("factory", "message"), + [ + ( + lambda: DecisionEvidence( + evidenceId="invalid evidence", + evidenceClass="technical", + summary="Observed evidence.", + ), + "stable identifier", + ), + ( + lambda: DecisionEvidence( + evidenceId="evidence:trim", + evidenceClass="technical", + summary=" surrounding whitespace ", + ), + "surrounding whitespace", + ), + ( + lambda: DecisionEvidence( + evidenceId="evidence:artifact", + evidenceClass="technical", + summary="Observed evidence.", + artifactReferences=[ + ArtifactReferenceModel( + scope="assay", + assay="RNA", + kind="", + artifactId="a" * 64, + ) + ], + ), + "require kind and artifactId", + ), + ( + lambda: DecisionOption( + optionId="option:trim", + status="apply", + label=" Label ", + description="Description.", + ), + "surrounding whitespace", + ), + ( + lambda: DecisionOption( + optionId="option:classes", + status="apply", + label="Label", + description="Description.", + requiredEvidenceClasses=["technical", "technical"], + ), + "must not contain duplicates", + ), + ( + lambda: DecisionSpec.model_validate( + {**_decision_spec().model_dump(), "question": " Question "} + ), + "surrounding whitespace", + ), + ( + lambda: DecisionSpec.model_validate( + {**_decision_spec().model_dump(), "allowedSources": []} + ), + "must not be empty", + ), + ( + lambda: DecisionSpec.model_validate( + { + **_decision_spec().model_dump(), + "allowedSources": ["agent", "agent"], + } + ), + "must not contain duplicates", + ), + ( + lambda: DecisionSpec.model_validate( + { + **_decision_spec().model_dump(), + "baselineOptionId": "partition:unknown", + } + ), + "baselineOptionId must reference", + ), + ( + lambda: DecisionSpec.model_validate( + { + **_decision_spec().model_dump(), + "metricPreferredOptionId": "partition:unknown", + } + ), + "metricPreferredOptionId must reference", + ), + ( + lambda: DecisionSpec.model_validate( + { + **_decision_spec().model_dump(), + "metricPreferredOptionId": None, + } + ), + "requires metricPreferredOptionId", + ), + ( + lambda: ProtectedVariableEffect( + variable="condition", + status="preserved", + summary=" Whitespace ", + ), + "surrounding whitespace", + ), + ( + lambda: DecisionSelection( + selectedOptionId="partition:coarse", + rationale=" Whitespace ", + ), + "surrounding whitespace", + ), + ( + lambda: DecisionSelection( + selectedOptionId="partition:coarse", + evidenceIds=[], + rationale="Reason.", + overrideOfOptionId="partition:fine", + overrideEvidenceIds=["evidence:markers"], + ), + "must be included in evidenceIds", + ), + ( + lambda: DecisionSelection( + selectedOptionId="partition:coarse", + evidenceIds=["evidence:markers"], + rationale="Reason.", + overrideEvidenceIds=["evidence:markers"], + ), + "require overrideOfOptionId", + ), + ( + lambda: DecisionSelection( + selectedOptionId="partition:coarse", + rationale="Reason.", + overrideOfOptionId="partition:coarse", + ), + "must differ from selectedOptionId", + ), + ( + lambda: PendingDecision.model_validate( + {**_pending_decision().model_dump(), "reason": " Reason "} + ), + "surrounding whitespace", + ), + ( + lambda: PendingDecision.model_validate( + { + **_pending_decision().model_dump(), + "evidenceBundleSha256": "invalid", + } + ), + "lowercase SHA-256", + ), + ( + lambda: DecisionRecord.model_validate( + {**_decision_record().model_dump(), "rationale": " Reason "} + ), + "surrounding whitespace", + ), + ( + lambda: DecisionRecord.model_validate( + { + **_decision_record().model_dump(), + "evidenceBundleSha256": "invalid", + } + ), + "lowercase SHA-256", + ), + ( + lambda: DecisionRecord.model_validate( + {**_decision_record().model_dump(), "modelName": " model "} + ), + "without surrounding whitespace", + ), + ( + lambda: VerificationCheck( + checkId="check:trim", + status="passed", + summary=" Summary ", + ), + "surrounding whitespace", + ), + ( + lambda: RevisionRequest( + revisionId="revision:checksum", + targetDecisionRecordId="decision:cluster:1", + verificationId="verification:decision:cluster:1", + replacementOptionId="partition:fine", + reason="Reason.", + evidenceBundleSha256="invalid", + ), + "lowercase SHA-256", + ), + ( + lambda: RevisionRequest( + revisionId="revision:reason", + targetDecisionRecordId="decision:cluster:1", + verificationId="verification:decision:cluster:1", + replacementOptionId="partition:fine", + reason=" Reason ", + ), + "surrounding whitespace", + ), + ], +) +def test_kernel_rejects_invalid_scalar_and_collection_contracts( + factory: Callable[[], object], + message: str, +) -> None: + with pytest.raises(ValidationError, match=message): + factory() + + def test_auditor_accepts_an_exact_metric_preferred_decision() -> None: record = _decision_record() @@ -320,6 +648,98 @@ def test_human_choices_obey_the_same_source_and_status_contracts() -> None: assert failed == {"selectedOption", "decisionSource"} +@pytest.mark.parametrize( + ("status", "check_statuses", "message"), + [ + ("passed", ["passed", "failed"], "every check to pass"), + ("failed", ["passed"], "requires a failed check"), + ( + "inconclusive", + ["inconclusive", "failed"], + "inconclusive check and no failures", + ), + ( + "inconclusive", + ["passed"], + "inconclusive check and no failures", + ), + ], +) +def test_verification_record_enforces_aggregate_status( + status: str, + check_statuses: list[str], + message: str, +) -> None: + checks = [ + VerificationCheck( + checkId=f"check:{index}", + status=check_status, + summary="The check has an explicit result.", + ) + for index, check_status in enumerate(check_statuses) + ] + with pytest.raises(ValidationError, match=message): + VerificationRecord( + verificationId="verification:aggregate", + decisionRecordId="decision:aggregate", + status=status, + checks=checks, + ) + + +def test_verification_record_rejects_duplicate_check_ids() -> None: + check = VerificationCheck( + checkId="check:duplicate", + status="passed", + summary="The check passed.", + ) + with pytest.raises(ValidationError, match="check IDs"): + VerificationRecord( + verificationId="verification:duplicate", + decisionRecordId="decision:duplicate", + status="passed", + checks=[check, check], + ) + + +@pytest.mark.parametrize( + ("changes", "message"), + [ + ( + {"evidenceBundleId": "bundle:revision"}, + "ID and checksum must be provided together", + ), + ( + {"availableEvidenceIds": ["evidence:new"]}, + "requires an exact evidence bundle", + ), + ( + { + "evidenceBundleId": "bundle:revision", + "evidenceBundleSha256": "1" * 64, + "availableEvidenceIds": ["evidence:new"], + "evidenceIds": ["evidence:missing"], + }, + "reference its exact available inventory", + ), + ], +) +def test_revision_request_rejects_incomplete_evidence_references( + changes: dict[str, object], + message: str, +) -> None: + values = { + "revisionId": "revision:cluster", + "targetDecisionRecordId": "decision:cluster:1", + "verificationId": "verification:decision:cluster:1", + "replacementOptionId": "partition:fine", + "reason": "Use the registered alternative.", + **changes, + } + with pytest.raises(ValidationError, match=message): + RevisionRequest.model_validate(values) + + def test_workflow_ledger_accepts_one_verified_revision_chain() -> None: original = _decision_record(record_id="decision:cluster:1") original_verification = VerificationRecord( @@ -437,6 +857,178 @@ def test_workflow_ledger_rejects_upstream_revision_invalidation() -> None: ) +@pytest.mark.parametrize( + ("case", "message"), + [ + ("duplicateRecord", "unique recordId"), + ("missingSupersedes", "must supersede the current active"), + ("unexpectedSupersedes", "must reference an earlier matching decision"), + ("duplicateVerification", "unique verificationId"), + ("unknownVerificationRecord", "exact decision record"), + ("secondVerification", "only one verification"), + ("mismatchedVerification", "references must agree exactly"), + ], +) +def test_workflow_ledger_rejects_invalid_record_and_verification_topology( + case: str, + message: str, +) -> None: + first = _decision_record(record_id="decision:cluster:1") + second = _decision_record(record_id="decision:cluster:2") + records = [first] + verifications: list[VerificationRecord] = [] + + if case == "duplicateRecord": + records.append(first) + elif case == "missingSupersedes": + records.append(second) + elif case == "unexpectedSupersedes": + records = [ + _decision_record( + record_id="decision:cluster:2", + supersedes="decision:cluster:missing", + ) + ] + elif case == "duplicateVerification": + other = second.model_copy( + update={ + "decisionId": "featurePolicy", + "verificationId": first.verificationId, + } + ) + records.append(other) + verifications = [ + _verification_record(first), + _verification_record( + other, + verification_id=first.verificationId, + ), + ] + elif case == "unknownVerificationRecord": + verification = _verification_record(first).model_copy( + update={"decisionRecordId": "decision:missing"} + ) + verifications = [verification] + elif case == "secondVerification": + verifications = [ + _verification_record(first), + _verification_record( + first, + verification_id="verification:decision:cluster:other", + ), + ] + elif case == "mismatchedVerification": + verifications = [ + _verification_record( + first, + verification_id="verification:decision:cluster:other", + ) + ] + + with pytest.raises(ValidationError, match=message): + DecisionWorkflowRun( + workflowRunId="workflow:invalid-topology", + decisionRecords=records, + verificationRecords=verifications, + ) + + +@pytest.mark.parametrize( + ("case", "message"), + [ + ("duplicateRevision", "unique revisionId"), + ("duplicateTarget", "may be revised only once"), + ("unknownTarget", "exact decision record"), + ("wrongVerification", "target decision's verification"), + ("passedWithoutEvidence", "requires exact downstream evidence"), + ("unchangedOption", "must change the selected option"), + ("bundleDrift", "match the target bundle exactly"), + ("unknownInvalidation", "exact decision record"), + ("supersedingWithoutRevision", "requires a revision request"), + ("replacementMismatch", "select the requested replacement"), + ], +) +def test_workflow_ledger_rejects_invalid_revision_references( + case: str, + message: str, +) -> None: + original = _decision_record(record_id="decision:cluster:1") + failed_verification = _verification_record(original, status="failed") + revision = RevisionRequest( + revisionId="revision:cluster:1", + targetDecisionRecordId=original.recordId, + verificationId=failed_verification.verificationId, + replacementOptionId="partition:fine", + reason="Use the registered alternative.", + ) + replacement = _decision_record( + record_id="decision:cluster:2", + selected_option_id="partition:fine", + supersedes=original.recordId, + ) + records = [original] + verifications = [failed_verification] + revisions = [revision] + + if case == "duplicateRevision": + revisions.append(revision) + elif case == "duplicateTarget": + revisions.append( + revision.model_copy(update={"revisionId": "revision:cluster:2"}) + ) + elif case == "unknownTarget": + revisions = [ + revision.model_copy(update={"targetDecisionRecordId": "decision:missing"}) + ] + elif case == "wrongVerification": + revisions = [ + revision.model_copy(update={"verificationId": "verification:missing"}) + ] + elif case == "passedWithoutEvidence": + verifications = [_verification_record(original)] + elif case == "unchangedOption": + revisions = [ + revision.model_copy( + update={"replacementOptionId": original.selectedOptionId} + ) + ] + elif case == "bundleDrift": + revisions = [ + revision.model_copy( + update={ + "evidenceBundleId": original.evidenceBundleId, + "evidenceBundleSha256": "1" * 64, + } + ) + ] + elif case == "unknownInvalidation": + revisions = [ + revision.model_copy( + update={"invalidatesDecisionRecordIds": ["decision:missing"]} + ) + ] + elif case == "supersedingWithoutRevision": + records.append(replacement) + revisions = [] + elif case == "replacementMismatch": + records.append( + replacement.model_copy( + update={ + "selectedOptionId": "partition:abstain", + "status": "abstain", + } + ) + ) + + with pytest.raises(ValidationError, match=message): + DecisionWorkflowRun( + workflowRunId="workflow:invalid-revision", + decisionRecords=records, + verificationRecords=verifications, + revisionRequests=revisions, + ) + + def test_revision_replaces_target_and_recomputed_downstream_records() -> None: def record( record_id: str, @@ -555,6 +1147,56 @@ def test_completed_workflow_requires_verified_active_decisions() -> None: assert run.finalHandoffId == "handoff:1" +@pytest.mark.parametrize( + ("case", "message"), + [ + ("completedWithoutHandoff", "require finalHandoffId"), + ("completedWithPending", "cannot contain a pending decision"), + ("completedUnverified", "every active decision to pass"), + ("runningWithHandoff", "Only completed workflows"), + ("needsInputWithoutPause", "require a pending or active defer"), + ("runningWithPending", "Only needsInput workflows"), + ("abstainedWithoutDecision", "require an active abstain"), + ], +) +def test_workflow_terminal_status_requires_matching_ledger_state( + case: str, + message: str, +) -> None: + record = _decision_record() + values: dict[str, object] = { + "workflowRunId": "workflow:terminal", + "status": "running", + "decisionRecords": [record], + "verificationRecords": [_verification_record(record)], + } + if case == "completedWithoutHandoff": + values["status"] = "completed" + elif case == "completedWithPending": + values.update( + status="completed", + finalHandoffId="handoff:1", + pendingDecision=_pending_decision(), + ) + elif case == "completedUnverified": + values.update( + status="completed", + finalHandoffId="handoff:1", + verificationRecords=[], + ) + elif case == "runningWithHandoff": + values["finalHandoffId"] = "handoff:1" + elif case == "needsInputWithoutPause": + values["status"] = "needsInput" + elif case == "runningWithPending": + values["pendingDecision"] = _pending_decision() + elif case == "abstainedWithoutDecision": + values["status"] = "abstained" + + with pytest.raises(ValidationError, match=message): + DecisionWorkflowRun.model_validate(values) + + @pytest.mark.parametrize( ("status", "decision_status"), [("needsInput", "defer"), ("abstained", "abstain")], diff --git a/tests/test_agent_experimental_context.py b/tests/test_agent_experimental_context.py index 22626090..62e9fa32 100644 --- a/tests/test_agent_experimental_context.py +++ b/tests/test_agent_experimental_context.py @@ -23,8 +23,10 @@ from scarf.agent.experimental_context import ( BatchCorrectionPlan, BatchSafetyEvidence, + CaptureFailureEvidence, CellQcPlan, CellQcProfileEvidence, + ContrastPlan, CovariateEvidence, ExperimentalContextAgent, ExperimentalContextDecision, @@ -42,6 +44,7 @@ CovariateCharacterization, _SelectionBoundCells, ) +from scarf.agent.experimental_context.study import StudyContract, build_study_contract from scarf.agent.types import ( ArtifactReferenceModel, ExperimentalBiologyHandoff, @@ -181,6 +184,15 @@ def inspect_artifact(self, ref: ArtifactRef) -> SimpleNamespace: ) +def _replace_store_cells(store: _Store, values: dict[str, np.ndarray]) -> None: + store.cells = _Cells(values) + store.zw = zarr.open_group(store=MemoryStore(), mode="w") + cell_data = store.zw.create_group("cellData") + cell_data.create_array("ids", data=np.asarray(values["ids"]).astype("U16")) + cell_data.create_array("I", data=np.asarray(values["I"], dtype=bool)) + store.refresh_cell_selection() + + def _write_cell_artifact( store: _Store, *, @@ -188,15 +200,17 @@ def _write_cell_artifact( kind: Literal["quality_metric", "hto_identity"], values: np.ndarray, assay: str, + operation: str | None = None, + inputs: dict[str, Any] | None = None, ) -> NamedArtifactSource: planned = plan_cell_data_artifact( store.zw, scope="assay", assay=assay, kind=kind, - operation=f"test_{kind}_source", + operation=operation or f"test_{kind}_source", parameters={"name": name}, - inputs={}, + inputs=dict(inputs or {}), execution_options={}, cell_selection=store.cell_selection, arrays={"values": ((len(values),), None)}, @@ -433,6 +447,88 @@ def test_system_prompt_does_not_embed_fictional_output_values() -> None: assert "estimability:treatment" not in prompt +@pytest.mark.parametrize( + ("changes", "message"), + [ + ({"studyContext": ""}, "studyContext must be non-empty"), + ({"studyObjective": ""}, "studyObjective must be non-empty"), + ( + {"independentUnitColumns": ["donor", "donor"]}, + "must not contain duplicates", + ), + ( + { + "physicalCaptureColumn": "sample", + "conditionColumns": ["sample"], + }, + "cannot be the physical capture", + ), + ( + { + "technicalBatchColumns": ["batch"], + "conditionColumns": ["batch"], + }, + "cannot also be condition columns", + ), + ( + { + "correctionLicense": "safe", + "technicalBatchColumns": [], + }, + "requires batch columns", + ), + ], +) +def test_study_contract_rejects_inconsistent_design_authority( + changes: dict[str, object], + message: str, +) -> None: + values = StudyContract.get_blank().model_dump() + values.update(changes) + + with pytest.raises(ValidationError, match=message): + StudyContract.model_validate(values) + + +def test_study_contract_builder_records_correction_and_label_policies() -> None: + with pytest.raises(ValueError, match="must be done"): + build_study_contract( + study_context="Study context", + study_objective="Study objective", + experimental_result=SimpleNamespace(status="needsInput"), + ) + + def result(action: TestAction) -> SimpleNamespace: + return SimpleNamespace( + status="done", + decision=_design_decision(action=action), + batchSafety=[], + notes=[], + ) + + unsafe = build_study_contract( + study_context="Study context", + study_objective="Study objective", + experimental_result=result("unsafe"), + ) + unresolved = build_study_contract( + study_context="Study context", + study_objective="Study objective", + experimental_result=result("needsInput"), + ) + preservation = build_study_contract( + study_context="Study context", + study_objective="Study objective", + experimental_result=result("skip"), + author_label_policy="preservation", + ) + + assert unsafe.correctionLicense == "unsafeConfounded" + assert unresolved.correctionLicense == "indeterminate" + assert preservation.authorLabelPolicy == "preservation" + assert any("ineligible" in item for item in preservation.limitations) + + def test_validator_rejects_serialized_fields_inside_narrative() -> None: decision = ExperimentalContextDecision( rationale='Study design is unresolved.", "evidenceIds": ["column:batch"]', @@ -967,6 +1063,348 @@ def test_artifact_metrics_and_hto_grouping_are_exact_context_evidence() -> None: assert "HTO_htoIdentity" not in store.cells.columns +def test_qc_metric_sources_report_metadata_artifact_concordance() -> None: + store = _Store() + counts = np.linspace(10.0, 100.0, store.cells.N) + features = np.linspace(5.0, 50.0, store.cells.N) + store.cells._values["RNA_nCounts"] = counts + store.cells._values["RNA_nFeatures"] = features + count_artifact = _write_cell_artifact( + store, + name="RNA_nCounts", + kind="quality_metric", + values=counts.copy(), + assay="RNA", + ) + feature_artifact = _write_cell_artifact( + store, + name="RNA_nFeatures", + kind="quality_metric", + values=features + 1e-9, + assay="RNA", + ) + context = _context( + store, + quality_metric_artifacts=[count_artifact, feature_artifact], + ) + + inspected = asyncio.run(inspect_cell_covariates(context)) + + concordance = {item.metricRole: item for item in inspected.qcSourceConcordance} + assert concordance["count"].exactlyEqual is True + assert concordance["count"].numericallyClose is True + assert concordance["count"].meanAbsoluteDifference == 0.0 + assert concordance["feature"].exactlyEqual is False + assert concordance["feature"].numericallyClose is True + assert concordance["feature"].pearsonCorrelation == pytest.approx(1.0) + assert {item.evidenceId for item in inspected.qcSourceConcordance}.issubset( + inspected.evidenceIds + ) + + +def test_capture_failure_evidence_preserves_paired_design_after_exclusion() -> None: + store = _Store() + captures = np.repeat(["capture-a", "capture-b", "failed"], 20) + donors = np.repeat(["donor-a", "donor-b", "donor-c"], 20) + disease = np.tile(np.repeat(["case", "control"], 10), 3) + samples = np.asarray( + [ + f"{donor}-{condition}" + for donor, condition in zip(donors, disease, strict=True) + ] + ) + counts = np.concatenate( + [ + np.linspace(90.0, 110.0, 20), + np.linspace(95.0, 115.0, 20), + np.linspace(3.0, 7.0, 20), + ] + ) + features = np.concatenate( + [ + np.linspace(45.0, 55.0, 20), + np.linspace(48.0, 58.0, 20), + np.linspace(2.0, 6.0, 20), + ] + ) + mito = np.concatenate( + [ + np.linspace(1.0, 3.0, 20), + np.linspace(1.0, 4.0, 20), + np.linspace(25.0, 35.0, 20), + ] + ) + n_cells = len(captures) + _replace_store_cells( + store, + { + "I": np.ones(n_cells, dtype=bool), + "ids": np.asarray([f"cell-{index}" for index in range(n_cells)]), + "names": np.asarray([f"cell-{index}" for index in range(n_cells)]), + "capture": captures, + "donor": donors, + "sample": samples, + "disease": disease, + "RNA_nCounts": counts, + "RNA_nFeatures": features, + "RNA_percentMito": mito, + }, + ) + context = _context( + store, + directions={"physicalCaptureColumn": "capture"}, + ) + domains = { + "capture": "design", + "donor": "design", + "sample": "design", + "disease": "biological", + } + units = { + "disease": InferenceUnit( + observationUnit="sample", + independentUnit="donor", + ) + } + + asyncio.run(inspect_cell_covariates(context)) + analyzed = asyncio.run( + analyze_experimental_design( + context, + column_domains=domains, + coefficients_of_interest=["disease"], + units_of_inference=units, + batch_columns=[], + ) + ) + + profile = next( + item for item in analyzed.qcProfiles if item.registeredProfile == "captureMad5" + ) + failure = next( + item for item in profile.captureFailureEvidence if item.capture == "failed" + ) + assert failure.wholeCaptureFailure is True + assert failure.independentAdverseAxes >= 2 + assert failure.preservesConditionCoverage is True + assert failure.preservesIndependentUnitCoverage is True + assert failure.exclusionEligible is True + assert failure.conditionAndUnitSafety[0]["completePairsAfterExclusion"] == 2 + assert failure.conditionAndUnitSafety[0]["incompletePairsAfterExclusion"] == 0 + assert "failed" in profile.failedCaptureCandidates + assert "failed" in profile.excludableCaptureCandidates + for source in profile.metricSources: + assert source.missingCellsByCapture == { + "capture-a": 0, + "capture-b": 0, + "failed": 0, + } + + +def test_unusable_qc_sources_preserve_provenance_and_degradation_evidence() -> None: + store = _Store() + store.cells._values["RNA_nCounts"] = np.asarray(["bad"] * store.cells.N) + store.cells._values["RNA_nFeatures"] = np.asarray( + [*np.linspace(10.0, 20.0, store.cells.N - 1), np.nan] + ) + artifact = _write_cell_artifact( + store, + name="RNA_percentMito", + kind="quality_metric", + values=np.asarray([*np.linspace(1.0, 2.0, store.cells.N - 1), np.inf]), + assay="RNA", + operation="run_feature_percentage", + inputs={ + "lineage": [ + { + "scope": "invalid", + "kind": "invalid", + "artifact_id": "invalid", + }, + store.cell_selection.to_dict(), + store.cell_selection.to_dict(), + ] + }, + ) + context = _context(store, quality_metric_artifacts=[artifact]) + + inspected = asyncio.run(inspect_cell_covariates(context)) + + sources = { + (source.sourceType, source.metricName): source + for source in inspected.qcMetricSources + } + nonnumeric = sources[("metadataColumn", "RNA_nCounts")] + assert nonnumeric.usableForFiltering is False + assert nonnumeric.missingCells == store.cells.N + assert nonnumeric.notes == ["Metric is not numeric and cannot drive filtering"] + nonfinite = sources[("metadataColumn", "RNA_nFeatures")] + assert nonfinite.usableForFiltering is False + assert nonfinite.missingCells == 1 + derived = sources[("artifact", "RNA_percentMito")] + assert derived.origin == "derivedArtifact" + assert derived.usableForFiltering is False + assert derived.missingCells == 1 + assert derived.inputArtifacts == [ + ArtifactReferenceModel.from_artifact_ref(store.cell_selection) + ] + skip = next(profile for profile in inspected.qcProfiles if profile.action == "skip") + assert any("not numeric" in note for note in skip.notes) + assert any("non-finite" in note for note in skip.notes) + + +def test_directed_capture_artifact_enables_pooled_reference_profile() -> None: + store = _Store() + labels = np.repeat(["capture-a", "capture-b", "capture-c"], 20) + counts = np.concatenate( + [ + np.linspace(90.0, 110.0, 20), + np.linspace(95.0, 115.0, 20), + np.linspace(100.0, 120.0, 20), + ] + ) + n_cells = len(labels) + _replace_store_cells( + store, + { + "I": np.ones(n_cells, dtype=bool), + "ids": np.asarray([f"cell-{index}" for index in range(n_cells)]), + "names": np.asarray([f"cell-{index}" for index in range(n_cells)]), + "RNA_nCounts": counts, + }, + ) + capture = _write_cell_artifact( + store, + name="capture", + kind="hto_identity", + values=labels, + assay="HTO", + ) + context = _context( + store, + directions={ + "physicalCaptureColumn": "capture", + "cellQc": { + "pooledReferenceCaptures": ["capture-a", "capture-b"], + }, + }, + hto_identity_artifacts=[capture], + ) + + inspected = asyncio.run(inspect_cell_covariates(context)) + + profile = next( + item + for item in inspected.qcProfiles + if item.registeredProfile == "pooledReferenceMad5" + ) + assert profile.captureColumn is None + assert profile.captureArtifact == capture + assert profile.sampleArtifact == capture + assert profile.parameters["pooledReferenceCaptures"] == [ + "capture-a", + "capture-b", + ] + assert profile.activeCellsByCapture == { + "capture-a": 20, + "capture-b": 20, + "capture-c": 20, + } + + +def test_registered_only_without_qc_driver_returns_explicit_skip() -> None: + store = _Store() + store.assay_names = [] + context = _context(store, directions={"registeredQcOnly": True}) + + inspected = asyncio.run(inspect_cell_covariates(context)) + + assert len(inspected.qcProfiles) == 1 + profile = inspected.qcProfiles[0] + assert profile.action == "skip" + assert profile.driverAssay is None + assert profile.driverAssayType is None + assert profile.notes == [ + "No RNA or ATAC assay is eligible to drive automatic cell QC" + ] + + +def test_missing_rna_percentage_metrics_are_derived_through_public_artifacts() -> None: + store = _Store() + feature_values = { + "ids": np.asarray(["MT-CO1", "RPS3", "GAPDH"]), + "names": np.asarray(["MT-CO1", "RPS3", "GAPDH"]), + } + assay = SimpleNamespace( + feats=SimpleNamespace( + N=3, + fetch_all=lambda column: feature_values[column], + ) + ) + selection_calls: list[dict[str, object]] = [] + percentage_calls: list[dict[str, object]] = [] + + def set_feature_selection(**kwargs: object) -> ArtifactRef: + selection_calls.append(kwargs) + return ArtifactRef( + scope="assay", + assay="RNA", + kind="feature_selection", + artifact_id=str(len(selection_calls)) * 64, + ) + + def run_feature_percentage( + cell_selection: ArtifactRef, + feature_selection: ArtifactRef, + *, + invalidate_cache: bool, + ) -> ArtifactRef: + percentage_calls.append( + { + "cellSelection": cell_selection, + "featureSelection": feature_selection, + "invalidateCache": invalidate_cache, + } + ) + return ArtifactRef( + scope="assay", + assay="RNA", + kind="quality_metric", + artifact_id=str(len(percentage_calls) + 2) * 64, + ) + + store.get_assay = lambda assay_name: assay + store.set_feature_selection = set_feature_selection + store.run_feature_percentage = run_feature_percentage + + sources = experimental_context_qc._derive_missing_percentage_artifacts( + store, + cell_selection=store.cell_selection, + driver=("RNA", "RNA"), + quality_sources=[], + ) + + assert [source.name for source in sources] == [ + "RNA_percentMito", + "RNA_percentRibo", + ] + np.testing.assert_array_equal( + selection_calls[0]["mask"], + [True, False, False], + ) + np.testing.assert_array_equal( + selection_calls[1]["mask"], + [False, True, False], + ) + assert all(call["from_assay"] == "RNA" for call in selection_calls) + assert all(call["invalidate_cache"] is False for call in selection_calls) + assert [call["cellSelection"] for call in percentage_calls] == [ + store.cell_selection, + store.cell_selection, + ] + assert all(call["invalidateCache"] is False for call in percentage_calls) + + def test_validator_rejects_harmony_when_batch_confounds_biology() -> None: store = _Store() context = _context(store) @@ -1584,6 +2022,250 @@ def test_named_artifact_and_qc_source_validation_edges() -> None: ) +@pytest.mark.parametrize( + ("changes", "message"), + [ + ({"coefficient": " disease "}, "coefficient cannot contain"), + ({"sampleBy": " "}, "sampleBy must be"), + ({"pairBy": " donor "}, "pairBy must be"), + ({"groupOrder": ["case", "case"]}, "groupOrder must contain unique"), + ({"groupOrder": ["case", np.inf]}, "cannot contain non-finite"), + ({"expressionCutoff": np.nan}, "expressionCutoff must be finite"), + ( + {"expressionCutoff": 0.1}, + "expressionCutoff is only used with fraction", + ), + ( + {"groupOrder": ["a", "b", "c"], "test": "mann_whitney"}, + "mann_whitney requires exactly two", + ), + ( + {"groupOrder": ["a", "b"], "test": "kruskal_wallis"}, + "kruskal_wallis requires at least three", + ), + ({"test": "wilcoxon"}, "wilcoxon requires exactly two groups"), + ( + {"test": "mann_whitney", "pairBy": "donor"}, + "paired contrast must use the wilcoxon", + ), + ({"replicationPassed": False}, "licensed contrast requires resolved"), + ( + {"status": "blocked", "blockedReasons": []}, + "non-licensed contrast requires blockedReasons", + ), + ], +) +def test_contrast_plan_rejects_inconsistent_licenses( + changes: dict[str, object], + message: str, +) -> None: + values: dict[str, object] = { + "coefficient": "disease", + "groupOrder": ["case", "control"], + "sampleBy": "sample", + "test": "mann_whitney", + "status": "licensed", + "betweenUnitDesign": True, + "replicationPassed": True, + "estimabilityPassed": True, + } + values.update(changes) + with pytest.raises(ValidationError, match=message): + ContrastPlan.model_validate(values) + + +def test_contrast_plan_accepts_complete_paired_license() -> None: + plan = ContrastPlan( + coefficient="treatment", + groupOrder=["treated", "control"], + sampleBy="sample", + pairBy="donor", + test="wilcoxon", + status="licensed", + betweenUnitDesign=True, + replicationPassed=True, + estimabilityPassed=True, + pairedCoveragePassed=True, + ) + + assert plan.status == "licensed" + assert plan.test == "wilcoxon" + + +@pytest.mark.parametrize( + ("registered_profile", "action", "sample_column", "sample_artifact", "message"), + [ + ( + "retainWithFlags", + "registeredMad", + None, + None, + "non-filtering skip", + ), + ( + "retainWithFlags", + "skip", + "capture", + None, + "cannot include a capture source", + ), + ("globalMad5", "globalGaussian", None, None, "registeredMad action"), + ( + "captureMad5", + "registeredMad", + None, + None, + "requires exactly one proven capture source", + ), + ( + "globalMad5", + "registeredMad", + "capture", + None, + "cannot include a capture source", + ), + ], +) +def test_registered_qc_source_contract_rejects_inconsistent_modes( + registered_profile: str, + action: str, + sample_column: str | None, + sample_artifact: NamedArtifactSource | None, + message: str, +) -> None: + with pytest.raises(ValueError, match=message): + experimental_context_contracts._validate_qc_sources( + action=action, + attributes=["RNA_nCounts"], + artifact_metrics=[], + sample_column=sample_column, + sample_artifact=sample_artifact, + registered_profile=registered_profile, + ) + + +def test_registered_qc_source_contract_accepts_global_capture_and_retain_modes() -> ( + None +): + capture = NamedArtifactSource( + name="capture", + artifact=ArtifactReferenceModel( + assay="HTO", + kind="hto_identity", + artifactId="4" * 64, + ), + ) + validate = experimental_context_contracts._validate_qc_sources + + validate( + action="skip", + attributes=[], + artifact_metrics=[], + sample_column=None, + sample_artifact=None, + registered_profile="retainWithFlags", + ) + validate( + action="registeredMad", + attributes=["RNA_nCounts"], + artifact_metrics=[], + sample_column=None, + sample_artifact=None, + registered_profile="globalMad5", + ) + validate( + action="registeredMad", + attributes=["RNA_nCounts"], + artifact_metrics=[], + sample_column=None, + sample_artifact=capture, + registered_profile="captureMad5", + ) + + +@pytest.mark.parametrize( + ("changes", "message"), + [ + ( + {"independentAdverseAxes": 1}, + "axis count must match", + ), + ( + {"wholeCaptureFailure": False}, + "requires at least two independent", + ), + ( + {"preservesConditionCoverage": False}, + "exclusion requires failure and preserved", + ), + ], +) +def test_capture_failure_evidence_rejects_inconsistent_state( + changes: dict[str, object], + message: str, +) -> None: + values: dict[str, object] = { + "capture": "failed", + "activeCells": 20, + "retainedCells": 2, + "retainedFraction": 0.1, + "adverseAxes": ["count", "feature"], + "independentAdverseAxes": 2, + "wholeCaptureFailure": True, + "preservesConditionCoverage": True, + "preservesIndependentUnitCoverage": True, + "exclusionEligible": True, + } + values.update(changes) + with pytest.raises(ValidationError, match=message): + CaptureFailureEvidence.model_validate(values) + + +def test_cell_qc_profile_requires_exact_capture_failure_inventory() -> None: + capture = NamedArtifactSource( + name="capture", + artifact=ArtifactReferenceModel( + assay="HTO", + kind="hto_identity", + artifactId="5" * 64, + ), + ) + failure = CaptureFailureEvidence( + capture="failed", + activeCells=20, + retainedCells=2, + retainedFraction=0.1, + adverseAxes=["count", "feature"], + independentAdverseAxes=2, + wholeCaptureFailure=True, + preservesConditionCoverage=True, + preservesIndependentUnitCoverage=True, + exclusionEligible=True, + ) + with pytest.raises(ValidationError, match="mutually exclusive"): + CellQcProfileEvidence( + action="skip", + captureColumn="capture", + captureArtifact=capture, + ) + with pytest.raises(ValidationError, match="failure evidence must be unique"): + CellQcProfileEvidence( + action="skip", + captureFailureEvidence=[failure, failure], + ) + with pytest.raises(ValidationError, match="failed captures must match"): + CellQcProfileEvidence( + action="skip", + captureFailureEvidence=[failure], + ) + with pytest.raises(ValidationError, match="excludable captures must match"): + CellQcProfileEvidence( + action="skip", + captureFailureEvidence=[failure], + failedCaptureCandidates=["failed"], + ) + + def test_experimental_handoff_validation_edges() -> None: result = ExperimentalContextResult.get_example() without_selection = result.model_copy(update={"cellSelection": None}) diff --git a/tests/test_agent_rna_decisions.py b/tests/test_agent_rna_decisions.py index e1168c4d..0f27cf8e 100644 --- a/tests/test_agent_rna_decisions.py +++ b/tests/test_agent_rna_decisions.py @@ -1,5 +1,7 @@ """Tests for deterministic RNA decision definitions and compilation.""" +from collections.abc import Callable + import pytest from pydantic import ValidationError @@ -9,15 +11,19 @@ EvidenceBundle, ) from scarf.agent.decisions.rna import ( + CellQualityExecutorPayload, ClusterExecutorPayload, CorrectionOutcomeExecutorPayload, + FeaturePolicyExecutorPayload, GraphExecutorPayload, HvgExecutorPayload, + HvgRankingExecutorPayload, PcaPrefixExecutorPayload, RNA_DECISION_TRANSITION_GRAPH, RnaDecisionCompilationError, RnaDecisionGateError, RnaDecisionRegistry, + RnaExecutorOption, RnaDecisionTransition, RnaDecisionTransitionGraph, build_cell_quality_decision, @@ -28,9 +34,11 @@ build_feature_policy_decision, build_graph_k_decision, build_hvg_count_decision, + build_hvg_ranking_decision, build_pca_prefix_decision, build_qc_grouping_decision, compile_rna_decision, + require_option_evidence, ) @@ -138,6 +146,329 @@ def test_cell_quality_registry_offers_only_eligible_pooled_reference() -> None: assert "cellQuality:pooledReferenceMad5" in definition.spec.option_by_id() +@pytest.mark.parametrize( + ("factory", "message"), + [ + ( + lambda: CellQualityExecutorPayload( + profile="retainWithFlags", + lowerCountMad=5.0, + groupByCapture=False, + pooledReference=False, + sensitivityOnly=False, + ), + "cannot define removal thresholds", + ), + ( + lambda: CellQualityExecutorPayload( + profile="retainWithFlags", + groupByCapture=True, + pooledReference=False, + sensitivityOnly=False, + ), + "cannot enable filtering modes", + ), + ( + lambda: CellQualityExecutorPayload( + profile="globalMad5", + lowerCountMad=5.0, + lowerFeatureMad=None, + upperMitoMad=5.0, + groupByCapture=False, + pooledReference=False, + sensitivityOnly=False, + ), + "require all three MAD thresholds", + ), + ( + lambda: CellQualityExecutorPayload( + profile="captureMad5", + lowerCountMad=5.0, + lowerFeatureMad=5.0, + upperMitoMad=5.0, + groupByCapture=False, + pooledReference=False, + sensitivityOnly=False, + ), + "Capture profiles and groupByCapture", + ), + ( + lambda: CellQualityExecutorPayload( + profile="globalMad5", + lowerCountMad=5.0, + lowerFeatureMad=5.0, + upperMitoMad=5.0, + groupByCapture=False, + pooledReference=True, + sensitivityOnly=False, + ), + "pooledReferenceMad5 and pooledReference", + ), + ( + lambda: CellQualityExecutorPayload( + profile="globalMad5", + lowerCountMad=5.0, + lowerFeatureMad=5.0, + upperMitoMad=5.0, + groupByCapture=False, + pooledReference=False, + sensitivityOnly=True, + ), + "captureMad3Sensitivity and sensitivityOnly", + ), + ( + lambda: FeaturePolicyExecutorPayload( + policy="excludeEligibleBundle", + excludedFamilies=["ribosomal", "ribosomal"], + ), + "must not contain duplicates", + ), + ( + lambda: FeaturePolicyExecutorPayload( + policy="keepAll", + excludedFamilies=["ribosomal"], + ), + "keepAll cannot exclude", + ), + ( + lambda: FeaturePolicyExecutorPayload( + policy="excludeScarfDefaults", + useScarfDefaultBlacklist=False, + ), + "requires only the Scarf default blacklist", + ), + ( + lambda: FeaturePolicyExecutorPayload( + policy="excludeEligibleBundle", + ), + "requires gene families", + ), + ( + lambda: FeaturePolicyExecutorPayload( + policy="excludeEligibleBundle", + excludedFamilies=["ribosomal"], + useScarfDefaultBlacklist=True, + ), + "cannot silently add", + ), + ( + lambda: CorrectionOutcomeExecutorPayload( + outcome="acceptHarmony", + useHarmony=False, + ), + "must agree", + ), + ( + lambda: RnaExecutorOption( + checkpoint="cellQuality", + optionId=" invalid ", + payload=CellQualityExecutorPayload( + profile="retainWithFlags", + groupByCapture=False, + pooledReference=False, + sensitivityOnly=False, + ), + ), + "without surrounding whitespace", + ), + ( + lambda: RnaDecisionTransition( + fromCheckpoint="cellQuality", + onStatus="apply", + ), + "exactly one checkpoint or terminal", + ), + ], +) +def test_rna_payload_contracts_reject_inconsistent_modes( + factory: Callable[[], object], + message: str, +) -> None: + with pytest.raises(ValidationError, match=message): + factory() + + +def test_rna_definition_and_transition_contracts_reject_registry_drift() -> None: + definition = build_pca_prefix_decision( + evidence_bundle_id="bundle:pca", + matrix_rank=20, + ) + values = definition.model_dump() + values["spec"]["checkpoint"] = "cellQuality" + with pytest.raises(ValidationError, match="checkpoint must match"): + type(definition).model_validate(values) + + values = definition.model_dump() + values["executorOptions"][1]["optionId"] = values["executorOptions"][0]["optionId"] + with pytest.raises(ValidationError, match="duplicate option IDs"): + type(definition).model_validate(values) + + values = definition.model_dump() + values["executorOptions"][0]["checkpoint"] = "cellQuality" + with pytest.raises(ValidationError, match="registry checkpoint"): + type(definition).model_validate(values) + + with pytest.raises(KeyError, match="Unknown option ID"): + definition.executor_option("pcaPrefix:unknown") + + with pytest.raises(ValidationError, match="v1 RNA checkpoint order"): + RnaDecisionTransitionGraph( + orderedNodes=tuple(reversed(RNA_DECISION_TRANSITION_GRAPH.orderedNodes)), + ) + transition = RnaDecisionTransition( + fromCheckpoint="cellQuality", + onStatus="apply", + toCheckpoint="featurePolicy", + ) + with pytest.raises(ValidationError, match="unique checkpoint/status triggers"): + RnaDecisionTransitionGraph(transitions=[transition, transition]) + + +def test_rna_builders_reject_empty_duplicate_and_out_of_range_inventories() -> None: + invalid_calls: list[tuple[Callable[[], object], str]] = [ + ( + lambda: build_cell_quality_decision( + evidence_bundle_id="bundle:cellQuality", + available_profiles=["globalMad5", "globalMad5"], + ), + "must not contain duplicates", + ), + ( + lambda: build_cell_quality_decision( + evidence_bundle_id="bundle:cellQuality", + available_profiles=[], + ), + "At least one cell-quality profile", + ), + ( + lambda: build_hvg_count_decision( + evidence_bundle_id="bundle:hvg", + eligible_feature_count=1, + ranking_mode="global", + ), + "at least two eligible genes", + ), + ( + lambda: build_hvg_count_decision( + evidence_bundle_id="bundle:hvg", + eligible_feature_count=100, + ranking_mode="global", + candidate_counts=[True], + ), + "positive integers", + ), + ( + lambda: build_feature_policy_decision( + evidence_bundle_id="bundle:features", + proposed_exclusion_families=["ribosomal", "ribosomal"], + dominant_families=["ribosomal"], + protected_families=[], + ), + "must not contain duplicates", + ), + ( + lambda: build_pca_prefix_decision( + evidence_bundle_id="bundle:pca", + matrix_rank=1, + ), + "matrix rank of at least two", + ), + ( + lambda: build_pca_prefix_decision( + evidence_bundle_id="bundle:pca", + matrix_rank=20, + candidate_dimensions=[], + ), + "At least one PCA candidate", + ), + ( + lambda: build_graph_k_decision( + evidence_bundle_id="bundle:graph", + n_cells=2, + ), + "at least three cells", + ), + ( + lambda: build_graph_k_decision( + evidence_bundle_id="bundle:graph", + n_cells=20, + candidate_neighbors=[], + ), + "At least one graph candidate", + ), + ( + lambda: build_cluster_partition_decision( + evidence_bundle_id="bundle:cluster", + metric_preferred_option_id="clusterResolution:balanced", + resolution_candidates=[0.5, 0.5], + ), + "unique values", + ), + ( + lambda: build_cluster_partition_decision( + evidence_bundle_id="bundle:cluster", + metric_preferred_option_id="clusterResolution:unknown", + resolution_candidates=[0.5], + ), + "must be a registered resolution option", + ), + ] + + for call, message in invalid_calls: + with pytest.raises(RnaDecisionGateError, match=message): + call() + + +def test_qc_grouping_offers_licensed_capture_and_pooled_modes() -> None: + definition = build_qc_grouping_decision( + evidence_bundle_id="bundle:cellQuality", + physical_capture_eligible=True, + pooled_reference_eligible=True, + ) + + assert [option.optionId for option in definition.spec.options] == [ + "qcGrouping:global", + "qcGrouping:physicalCapture", + "qcGrouping:pooledReference", + "qcGrouping:defer", + ] + assert [ + definition.executor_option(option_id).payload.groupingMode + for option_id in ( + "qcGrouping:global", + "qcGrouping:physicalCapture", + "qcGrouping:pooledReference", + ) + ] == ["global", "physicalCapture", "pooledReference"] + + +def test_hvg_ranking_and_correction_need_build_all_licensed_options() -> None: + ranking = build_hvg_ranking_decision( + evidence_bundle_id="bundle:hvg-ranking", + batch_aware_eligible=True, + ) + assert [option.optionId for option in ranking.spec.options] == [ + "hvgRanking:global", + "hvgRanking:batchAware", + "hvgRanking:defer", + ] + batch_payload = ranking.executor_option("hvgRanking:batchAware").payload + assert isinstance(batch_payload, HvgRankingExecutorPayload) + assert batch_payload.rankingMode == "batchAware" + + correction = build_correction_need_decision( + evidence_bundle_id="bundle:correction-need", + license="safe", + ) + assert [(option.optionId, option.status) for option in correction.spec.options] == [ + ("correctionNeed:needed", "apply"), + ("correctionNeed:notNeeded", "skip"), + ("correctionNeed:indeterminate", "defer"), + ] + assert correction.executor_option("correctionNeed:needed").payload.need == "needed" + assert correction.spec.baselineOptionId == "correctionNeed:notNeeded" + + def test_hvg_counts_are_capped_and_numeric_values_stay_in_payloads() -> None: definition = build_hvg_count_decision( evidence_bundle_id="bundle:hvg", @@ -303,6 +634,84 @@ def test_graph_candidates_are_capped_and_deduplicated() -> None: assert payload.neighborsK == 14 +def test_custom_numeric_candidate_grids_are_capped_and_deduplicated() -> None: + hvg = build_hvg_count_decision( + evidence_bundle_id="bundle:hvg", + eligible_feature_count=3000, + ranking_mode="global", + candidate_counts=[750, 2000, 9000, 750], + ) + assert [option.optionId for option in hvg.spec.options] == [ + "hvgCount:n750", + "hvgCount:standard", + "hvgCount:n3000", + "hvgCount:defer", + ] + assert hvg.executor_option("hvgCount:n3000").payload.topN == 3000 + + pca = build_pca_prefix_decision( + evidence_bundle_id="bundle:pca", + matrix_rank=15, + candidate_dimensions=[7, 20, 7], + ) + assert [option.optionId for option in pca.spec.options] == [ + "pcaPrefix:n7", + "pcaPrefix:n15", + "pcaPrefix:defer", + ] + assert pca.executor_option("pcaPrefix:n15").payload.dimensions == 15 + + graph = build_graph_k_decision( + evidence_bundle_id="bundle:graph", + n_cells=13, + candidate_neighbors=[3, 50, 3], + ) + assert [option.optionId for option in graph.spec.options] == [ + "graphScale:k3", + "graphScale:k12", + "graphScale:defer", + ] + assert graph.executor_option("graphScale:k12").payload.neighborsK == 12 + + cluster = build_cluster_partition_decision( + evidence_bundle_id="bundle:cluster", + metric_preferred_option_id="clusterResolution:balanced", + resolution_candidates=[0.4, 0.75], + ) + assert cluster.executor_option( + "clusterResolution:r0p4" + ).payload.leidenResolution == pytest.approx(0.4) + assert cluster.spec.baselineOptionId == "clusterResolution:balanced" + + +def test_custom_candidate_grids_reject_empty_or_invalid_values() -> None: + with pytest.raises(RnaDecisionGateError, match="HVG candidate"): + build_hvg_count_decision( + evidence_bundle_id="bundle:hvg", + eligible_feature_count=3000, + ranking_mode="global", + candidate_counts=[], + ) + with pytest.raises(RnaDecisionGateError, match="PCA candidate"): + build_pca_prefix_decision( + evidence_bundle_id="bundle:pca", + matrix_rank=20, + candidate_dimensions=[True], + ) + with pytest.raises(RnaDecisionGateError, match="Graph candidates"): + build_graph_k_decision( + evidence_bundle_id="bundle:graph", + n_cells=20, + candidate_neighbors=[1], + ) + with pytest.raises(RnaDecisionGateError, match="cluster resolution"): + build_cluster_partition_decision( + evidence_bundle_id="bundle:cluster", + metric_preferred_option_id="clusterResolution:balanced", + resolution_candidates=[], + ) + + def test_clustering_uses_fixed_resolutions_and_requires_override_evidence() -> None: definition = build_cluster_partition_decision( evidence_bundle_id="bundle:cluster", @@ -419,6 +828,86 @@ def test_registry_requires_ordered_definitions_and_transition_coverage() -> None RnaDecisionRegistry(definitions=[features, cell_quality]) +def test_registry_and_transition_lookup_reject_incomplete_inventories() -> None: + cell_quality = build_cell_quality_decision( + evidence_bundle_id="bundle:cellQuality", + available_profiles=["retainWithFlags", "globalMad5"], + ) + duplicate_id = cell_quality.model_copy( + update={ + "checkpoint": "featurePolicy", + "spec": cell_quality.spec.model_copy( + update={"checkpoint": "featurePolicy"}, + ), + "executorOptions": [ + option.model_copy(update={"checkpoint": "featurePolicy"}) + for option in cell_quality.executorOptions + ], + } + ) + with pytest.raises(ValidationError, match="decision IDs must be unique"): + RnaDecisionRegistry(definitions=[cell_quality, duplicate_id]) + + duplicate_checkpoint = cell_quality.model_copy( + update={ + "spec": cell_quality.spec.model_copy( + update={"decisionId": "otherCellQuality"}, + ) + } + ) + with pytest.raises(ValidationError, match="checkpoints must be unique"): + RnaDecisionRegistry(definitions=[cell_quality, duplicate_checkpoint]) + + incomplete_graph = RnaDecisionTransitionGraph( + transitions=[ + RnaDecisionTransition( + fromCheckpoint="cellQuality", + onStatus="apply", + toCheckpoint="featurePolicy", + ) + ] + ) + with pytest.raises(ValidationError, match="No transition for cellQuality/skip"): + RnaDecisionRegistry( + definitions=[cell_quality], + transitionGraph=incomplete_graph, + ) + with pytest.raises(KeyError, match="No RNA transition"): + incomplete_graph.resolve("cellQuality", "abstain") + with pytest.raises(KeyError, match="No RNA decision definition"): + RnaDecisionRegistry().definition("cellQuality") + + +def test_compile_requires_exact_verification_reference() -> None: + definition = build_pca_prefix_decision( + evidence_bundle_id="bundle:pca", + matrix_rank=20, + ) + bundle = _bundle("pcaPrefix", "bundle:pca", ["geometric", "technical"]) + record = _record(definition, bundle, "pcaPrefix:standard") + record = record.model_copy(update={"verificationId": "verification:other"}) + + with pytest.raises( + RnaDecisionCompilationError, + match="deterministic verification ID", + ): + compile_rna_decision(definition, bundle, record) + + +def test_option_evidence_binding_rejects_unknown_and_scalar_requirements() -> None: + definition = build_pca_prefix_decision( + evidence_bundle_id="bundle:pca", + matrix_rank=20, + ) + with pytest.raises(ValueError, match="unknown options"): + require_option_evidence(definition, {"pcaPrefix:invented": ["evidence:x"]}) + with pytest.raises(TypeError, match="sequences of IDs"): + require_option_evidence( + definition, + {"pcaPrefix:standard": "evidence:x"}, + ) + + def test_definition_rejects_executor_inventory_drift() -> None: definition = build_pca_prefix_decision( evidence_bundle_id="bundle:pca", matrix_rank=50 diff --git a/tests/test_registered_qc_profiles.py b/tests/test_registered_qc_profiles.py index 464999ba..f3e4708a 100644 --- a/tests/test_registered_qc_profiles.py +++ b/tests/test_registered_qc_profiles.py @@ -12,6 +12,17 @@ import scarf.agent.experimental_context.validation as experimental_context_validation import scarf.agent.orchestrator.preprocessing as preprocessing_module +from scarf.agent.cell_quality.execution import ( + execute_auto_cell_qc, + execute_registered_cell_qc, +) +from scarf.agent.cell_quality.profiles import ( + RegisteredQcProjection, + offered_registered_qc_profiles, + project_auto_filter_profile, + project_registered_qc_profile, + qc_metric_execution_name, +) from scarf.agent.experimental_context import ( CellQcPlan, CellQcProfileEvidence, @@ -19,14 +30,13 @@ inspect_cell_covariates, ) from scarf.agent.orchestrator.main import AgentOrchestrator -from scarf.agent.cell_quality.execution import execute_registered_cell_qc -from scarf.agent.cell_quality.profiles import ( - RegisteredQcProjection, - offered_registered_qc_profiles, - project_registered_qc_profile, -) from scarf.agent.types import ArtifactReferenceModel from scarf.datastore._operations.quality_control import _QualityControlOperationsMixin +from scarf.metadata.artifacts import ( + plan_cell_data_artifact, + write_cell_data_artifact, +) +from scarf.metadata.selection import NamedCellArtifact from scarf.storage.artifacts import ( ArtifactRef, artifact_group, @@ -134,6 +144,248 @@ def _memory_qc_store( return store, store.snapshot_cell_selection("I") +def _write_memory_cell_artifact( + store: _MemoryQcStore, + *, + selection: ArtifactRef, + name: str, + kind: str, + values: np.ndarray, + assay: str, +) -> NamedCellArtifact: + resolved = np.asarray(values) + planned = plan_cell_data_artifact( + store.zw, + scope="assay", + assay=assay, + kind=kind, + operation=f"test_{kind}", + parameters={"name": name}, + inputs={}, + execution_options={}, + cell_selection=selection, + arrays={"values": (resolved.shape, None)}, + ) + write_cell_data_artifact(store.zw, planned, {"values": resolved}) + return NamedCellArtifact(name=name, artifact=planned.ref) + + +def _registered_execution_case() -> tuple[ + _MemoryQcStore, + ArtifactRef, + RegisteredQcProjection, + dict[str, Any], +]: + values = np.linspace(80.0, 120.0, 42) + store, selection = _memory_qc_store( + { + "RNA_nCounts": values, + "capture": np.asarray(["a"] * 21 + ["b"] * 21), + } + ) + projection = project_registered_qc_profile( + "globalMad5", + values_by_metric={"RNA_nCounts": values}, + active=np.ones(42, dtype=bool), + ) + return ( + store, + selection, + projection, + { + "profile_parameters": _profile_parameters(projection), + "expected_active_cells": 42, + "expected_retained_cells": projection.retainedCells, + "expected_flag_counts": projection.flagCounts, + "attrs": ["RNA_nCounts"], + "cell_selection": selection, + }, + ) + + +@pytest.mark.parametrize( + ("case", "message"), + [ + ("unknownProfile", "Unknown registered"), + ("invalidActive", "positive integer"), + ("invalidRetained", "non-negative integer"), + ("invalidFlagCount", "non-empty names"), + ("parameterInventory", "do not match policy version"), + ("policyVersion", "policyVersion must be 1"), + ("profileMismatch", "profile and parameters disagree"), + ("nMads", "requires nMads"), + ("boundPolicy", "boundPolicy is not supported"), + ("resolvedBoundsType", "resolvedBounds must be a list"), + ("pooledReferencesType", "must be a list of strings"), + ("attributeType", "attrs must contain only column names"), + ("artifactType", "NamedCellArtifact"), + ("artifactKind", "quality_metric"), + ("duplicateArtifactName", "unique semantic names"), + ("unexpectedCapture", "cannot use a capture source"), + ("missingAttribute", "not found"), + ("activeMismatch", "active-cell count differs"), + ("nonfiniteMetadata", "non-finite entries"), + ("retainedMismatch", "retained-cell count differs"), + ("flagMismatch", "diagnostic-flag counts differ"), + ], +) +def test_registered_qc_execution_rejects_inconsistent_evidence( + case: str, + message: str, +) -> None: + store, selection, projection, arguments = _registered_execution_case() + profile = "globalMad5" + parameters = deepcopy(arguments["profile_parameters"]) + arguments["profile_parameters"] = parameters + + if case == "unknownProfile": + profile = "invented" + elif case == "invalidActive": + arguments["expected_active_cells"] = False + elif case == "invalidRetained": + arguments["expected_retained_cells"] = -1 + elif case == "invalidFlagCount": + arguments["expected_flag_counts"] = {"": 0} + elif case == "parameterInventory": + parameters.pop("captureSizes") + elif case == "policyVersion": + parameters["policyVersion"] = 2 + elif case == "profileMismatch": + parameters["profile"] = "retainWithFlags" + elif case == "nMads": + parameters["nMads"] = 4.0 + elif case == "boundPolicy": + parameters["boundPolicy"] = {} + elif case == "resolvedBoundsType": + parameters["resolvedBounds"] = {} + elif case == "pooledReferencesType": + parameters["pooledReferenceCaptures"] = [1] + elif case == "attributeType": + arguments["attrs"] = [1] + elif case == "artifactType": + arguments["artifact_metrics"] = [object()] + elif case == "artifactKind": + arguments["artifact_metrics"] = [ + NamedCellArtifact(name="metric", artifact=selection) + ] + elif case == "duplicateArtifactName": + metric = _write_memory_cell_artifact( + store, + selection=selection, + name="metric", + kind="quality_metric", + values=np.linspace(1.0, 2.0, 42), + assay="RNA", + ) + arguments["artifact_metrics"] = [metric, metric] + elif case == "unexpectedCapture": + arguments["sample_column"] = "capture" + elif case == "missingAttribute": + arguments["attrs"] = ["missing"] + elif case == "activeMismatch": + arguments["expected_active_cells"] = 41 + elif case == "nonfiniteMetadata": + store.cells._get_array("RNA_nCounts")[0] = np.nan + elif case == "retainedMismatch": + arguments["expected_retained_cells"] = projection.retainedCells - 1 + elif case == "flagMismatch": + arguments["expected_flag_counts"] = {"invented": 1} + + with pytest.raises((KeyError, TypeError, ValueError), match=message): + execute_registered_cell_qc(store, profile, **arguments) + + +@pytest.mark.parametrize( + ("case", "message"), + [ + ("unknownAction", "Unknown automatic"), + ("attributeType", "attrs must contain only column names"), + ("sampleSources", "sample_column and sample_artifact"), + ("captureSources", "capture_column and capture_artifact"), + ("missingSampleSource", "requires exactly one sample source"), + ("unexpectedSample", "cannot use a core sample source"), + ("missingGrouping", "grouping column"), + ("missingAttribute", "not found"), + ("invalidActive", "positive integer"), + ("invalidRetained", "non-negative integer"), + ("invalidFlagCount", "non-empty names"), + ("activeMismatch", "active-cell count differs"), + ("parameterMismatch", "parameters do not match"), + ("boundsMismatch", "resolved bounds differ"), + ("retainedMismatch", "retained-cell count differs"), + ("flagMismatch", "flag counts differ"), + ], +) +def test_auto_qc_execution_rejects_inconsistent_evidence( + case: str, + message: str, +) -> None: + values = np.linspace(80.0, 120.0, 42) + labels = np.asarray(["a"] * 21 + ["b"] * 21) + store, selection = _memory_qc_store({"RNA_nCounts": values, "capture": labels}) + projection = project_auto_filter_profile( + "globalGaussian", + values_by_metric={"RNA_nCounts": values}, + active=np.ones(42, dtype=bool), + ) + action = "globalGaussian" + arguments: dict[str, Any] = { + "profile_parameters": deepcopy(projection.parameters), + "expected_active_cells": 42, + "expected_retained_cells": projection.retainedCells, + "expected_flag_counts": projection.flagCounts, + "expected_resolved_bounds": deepcopy(projection.parameters["resolvedBounds"]), + "attrs": ["RNA_nCounts"], + "cell_selection": selection, + } + identity = _write_memory_cell_artifact( + store, + selection=selection, + name="capture", + kind="hto_identity", + values=labels, + assay="HTO", + ) + + if case == "unknownAction": + action = "invented" + elif case == "attributeType": + arguments["attrs"] = [1] + elif case == "sampleSources": + arguments["sample_column"] = "capture" + arguments["sample_artifact"] = identity + elif case == "captureSources": + arguments["capture_column"] = "capture" + arguments["capture_artifact"] = identity + elif case == "missingSampleSource": + action = "sampleMad" + elif case == "unexpectedSample": + arguments["sample_column"] = "capture" + elif case == "missingGrouping": + arguments["capture_column"] = "missing" + elif case == "missingAttribute": + arguments["attrs"] = ["missing"] + elif case == "invalidActive": + arguments["expected_active_cells"] = False + elif case == "invalidRetained": + arguments["expected_retained_cells"] = -1 + elif case == "invalidFlagCount": + arguments["expected_flag_counts"] = {"": 0} + elif case == "activeMismatch": + arguments["expected_active_cells"] = 41 + elif case == "parameterMismatch": + arguments["profile_parameters"]["minP"] = 0.02 + elif case == "boundsMismatch": + arguments["expected_resolved_bounds"] = {} + elif case == "retainedMismatch": + arguments["expected_retained_cells"] = projection.retainedCells - 1 + elif case == "flagMismatch": + arguments["expected_flag_counts"] = {"invented": 1} + + with pytest.raises((KeyError, TypeError, ValueError), match=message): + execute_auto_cell_qc(store, action, **arguments) + + def test_global_registered_profile_uses_one_sided_data_derived_bounds() -> None: values = _quality_values() projection = project_registered_qc_profile( @@ -533,6 +785,306 @@ def test_datastore_rejects_modified_registered_bounds() -> None: ) +def test_execute_auto_cell_qc_global_gaussian_persists_exact_outputs() -> None: + values = _quality_values() + store, source = _memory_qc_store(values) + active = np.ones(store.cells.N, dtype=bool) + projection = project_auto_filter_profile( + "globalGaussian", + values_by_metric=values, + active=active, + ) + live_before = store.cells.fetch_all("I").copy() + + selected, flags = execute_auto_cell_qc( + store, + "globalGaussian", + profile_parameters=projection.parameters, + expected_active_cells=store.cells.N, + expected_retained_cells=projection.retainedCells, + expected_flag_counts=projection.flagCounts, + expected_resolved_bounds=projection.parameters["resolvedBounds"], + attrs=list(values), + cell_selection=source, + ) + + stored_selection = read_stored_selection_mask( + store.zw, + selected, + kind="cell_selection", + scope="datastore", + assay=None, + table_path="cellData", + ) + np.testing.assert_array_equal(stored_selection, projection.keep) + np.testing.assert_array_equal(store.cells.fetch_all("I"), live_before) + assert flags is not None + assert store.inspect_artifact(selected).operation == "auto_filter_cells" + flag_status = store.inspect_artifact(flags) + assert flag_status.operation == "run_auto_cell_qc_flags" + flag_names = flag_status.parameters["flagNames"] + flag_values = np.asarray(artifact_group(store.zw, flags)["values"][:], dtype=bool) + assert { + name: int(flag_values[:, index].sum()) for index, name in enumerate(flag_names) + } == projection.flagCounts + + repeated = execute_auto_cell_qc( + store, + "globalGaussian", + profile_parameters=projection.parameters, + expected_active_cells=store.cells.N, + expected_retained_cells=projection.retainedCells, + expected_flag_counts=projection.flagCounts, + expected_resolved_bounds=projection.parameters["resolvedBounds"], + attrs=list(values), + cell_selection=source, + ) + assert repeated == (selected, flags) + + +@pytest.mark.parametrize("capture_kind", ["metadata", "artifact"]) +def test_execute_auto_cell_qc_global_gaussian_tracks_artifact_and_capture_sources( + capture_kind: str, +) -> None: + labels = np.asarray(["capture-a"] * 30 + ["capture-b"] * 30) + metadata_counts = np.concatenate( + [np.linspace(90.0, 110.0, 30), np.linspace(95.0, 115.0, 30)] + ) + artifact_mito = np.concatenate( + [np.linspace(1.0, 3.0, 30), np.linspace(2.0, 4.0, 30)] + ) + artifact_mito[-1] = 50.0 + initial = {"RNA_nCounts": metadata_counts} + if capture_kind == "metadata": + initial["capture"] = labels + store, source = _memory_qc_store(initial) + metric = _write_memory_cell_artifact( + store, + selection=source, + name="RNA_percentMito", + kind="quality_metric", + values=artifact_mito, + assay="RNA", + ) + capture = ( + None + if capture_kind == "metadata" + else _write_memory_cell_artifact( + store, + selection=source, + name="capture", + kind="hto_identity", + values=labels, + assay="HTO", + ) + ) + projection = project_auto_filter_profile( + "globalGaussian", + values_by_metric={ + "RNA_nCounts": metadata_counts, + "RNA_percentMito": artifact_mito, + }, + active=np.ones(store.cells.N, dtype=bool), + sample_labels=labels, + grouping_proven=True, + ) + + selected, flags = execute_auto_cell_qc( + store, + "globalGaussian", + profile_parameters=projection.parameters, + expected_active_cells=store.cells.N, + expected_retained_cells=projection.retainedCells, + expected_flag_counts=projection.flagCounts, + expected_resolved_bounds=projection.parameters["resolvedBounds"], + attrs=["RNA_nCounts"], + artifact_metrics=[metric], + cell_selection=source, + capture_column="capture" if capture_kind == "metadata" else None, + capture_artifact=capture, + ) + + assert flags is not None + np.testing.assert_array_equal( + read_stored_selection_mask( + store.zw, + selected, + kind="cell_selection", + scope="datastore", + assay=None, + table_path="cellData", + ), + projection.keep, + ) + inputs = store.inspect_artifact(flags).inputs + assert inputs["artifact_metrics"] == {"RNA_percentMito": metric.artifact.to_dict()} + if capture_kind == "metadata": + assert inputs["grouping_source"]["column"] == "capture" + else: + assert capture is not None + assert inputs["grouping_source"]["artifact"] == capture.artifact.to_dict() + + +@pytest.mark.parametrize("source_kind", ["metadata", "artifact"]) +def test_execute_auto_cell_qc_sample_mad_uses_exact_grouping_source( + source_kind: str, +) -> None: + labels = np.asarray(["sample-a"] * 22 + ["sample-b"] * 22) + counts = np.concatenate( + [np.linspace(90.0, 110.0, 22), np.append(np.linspace(95.0, 115.0, 21), 1000.0)] + ) + initial = {"RNA_nCounts": counts} + if source_kind == "metadata": + initial["sample"] = labels + store, source = _memory_qc_store(initial) + sample_artifact = ( + None + if source_kind == "metadata" + else _write_memory_cell_artifact( + store, + selection=source, + name="sample", + kind="hto_identity", + values=labels, + assay="HTO", + ) + ) + projection = project_auto_filter_profile( + "sampleMad", + values_by_metric={"RNA_nCounts": counts}, + active=np.ones(store.cells.N, dtype=bool), + sample_labels=labels, + grouping_proven=True, + n_mads=3.0, + min_cells_per_sample=20, + ) + skip_reasons = projection.parameters["skipReasons"] + assert isinstance(skip_reasons, dict) + parameters = { + "nMads": 3.0, + "minCellsPerSample": 20, + "nSamples": len(projection.captureSizes), + "nSkippedSamples": len(skip_reasons), + } + + selected, flags = execute_auto_cell_qc( + store, + "sampleMad", + profile_parameters=parameters, + expected_active_cells=store.cells.N, + expected_retained_cells=projection.retainedCells, + expected_flag_counts=projection.flagCounts, + expected_resolved_bounds=projection.parameters["resolvedBounds"], + attrs=["RNA_nCounts"], + cell_selection=source, + sample_column="sample" if source_kind == "metadata" else None, + sample_artifact=sample_artifact, + ) + + stored_selection = read_stored_selection_mask( + store.zw, + selected, + kind="cell_selection", + scope="datastore", + assay=None, + table_path="cellData", + ) + np.testing.assert_array_equal(stored_selection, projection.keep) + assert not stored_selection[-1] + assert flags is not None + grouping_source = store.inspect_artifact(flags).inputs["grouping_source"] + if source_kind == "metadata": + assert grouping_source["source"] == "metadataColumn" + assert grouping_source["column"] == "sample" + assert grouping_source["fingerprint"] + else: + assert sample_artifact is not None + assert grouping_source == { + "source": "artifact", + "artifact": sample_artifact.artifact.to_dict(), + } + + +def test_execute_registered_capture_qc_resolves_artifact_metric_collision() -> None: + labels = np.asarray(["capture-a"] * 30 + ["capture-b"] * 30) + metadata_counts = np.concatenate( + [np.linspace(90.0, 110.0, 30), np.linspace(95.0, 115.0, 30)] + ) + metadata_counts[0] = 1.0 + artifact_counts = np.concatenate( + [np.linspace(45.0, 55.0, 30), np.linspace(48.0, 58.0, 30)] + ) + artifact_counts[-1] = 500.0 + store, source = _memory_qc_store({"RNA_nCounts": metadata_counts}) + metric = _write_memory_cell_artifact( + store, + selection=source, + name="RNA_nCounts", + kind="quality_metric", + values=artifact_counts, + assay="RNA", + ) + capture = _write_memory_cell_artifact( + store, + selection=source, + name="capture", + kind="hto_identity", + values=labels, + assay="HTO", + ) + execution_name = qc_metric_execution_name( + metric.name, + artifact_id=metric.artifact.artifact_id, + collides_with_metadata=True, + ) + projection = project_registered_qc_profile( + "captureMad5", + values_by_metric={ + "RNA_nCounts": metadata_counts, + execution_name: artifact_counts, + }, + active=np.ones(store.cells.N, dtype=bool), + capture_labels=labels, + grouping_proven=True, + min_cells_per_capture=20, + ) + parameters = _profile_parameters(projection) + + selected, flags = execute_registered_cell_qc( + store, + "captureMad5", + profile_parameters=parameters, + expected_active_cells=store.cells.N, + expected_retained_cells=projection.retainedCells, + expected_flag_counts=projection.flagCounts, + attrs=["RNA_nCounts"], + artifact_metrics=[metric], + cell_selection=source, + sample_artifact=capture, + ) + + stored_selection = read_stored_selection_mask( + store.zw, + selected, + kind="cell_selection", + scope="datastore", + assay=None, + table_path="cellData", + ) + np.testing.assert_array_equal(stored_selection, projection.keep) + assert flags is not None + selection_status = store.inspect_artifact(selected) + assert selection_status.parameters["profileParameters"]["captureSizes"] == { + "capture-a": 30, + "capture-b": 30, + } + assert selection_status.inputs["capture_artifact"] == capture.artifact.to_dict() + flag_status = store.inspect_artifact(flags) + assert flag_status.inputs["artifact_metrics"] == { + execution_name: metric.artifact.to_dict() + } + + def test_orchestrator_executes_retain_with_flags_instead_of_plain_skip( monkeypatch: pytest.MonkeyPatch, ) -> None: From 0bc9d11562f0994a631c9c21535a909ef5283ab0 Mon Sep 17 00:00:00 2001 From: gautam8387 Date: Mon, 7 Sep 2026 16:03:26 +0200 Subject: [PATCH 12/21] rna only; cut redundant code --- .../base.ipynb | 1044 ---------- .../base.ipynb | 930 +++++++++ docs/.jupyter_cache/global.db | Bin 36864 -> 36864 bytes docs/source/analysis_with_agents.md | 62 +- docs/source/tutorials/agent_workflow.md | 142 +- scarf/agent/__init__.py | 2 + scarf/agent/experimental_context/agent.py | 4 +- scarf/agent/experimental_context/contracts.py | 1 + .../agent/experimental_context/qc_evidence.py | 17 +- scarf/agent/orchestrator/__init__.py | 2 + scarf/agent/orchestrator/api.py | 55 + scarf/agent/orchestrator/budget.py | 111 ++ scarf/agent/orchestrator/context.py | 94 +- scarf/agent/orchestrator/finalization.py | 308 +-- scarf/agent/orchestrator/journal.py | 31 +- scarf/agent/orchestrator/main.py | 104 +- scarf/agent/orchestrator/models.py | 173 +- scarf/agent/orchestrator/preprocessing.py | 543 ++--- scarf/agent/orchestrator/rna.py | 187 ++ scarf/agent/orchestrator/tuning.py | 1746 ++--------------- scarf/agent/parameter_tuning/diagnostics.py | 193 +- scarf/agent/parameter_tuning/execution.py | 169 +- scarf/agent/report/generator.py | 9 +- scarf/agent/report/rendering.py | 91 +- tests/test_agent_beginner.py | 383 ++++ tests/test_agent_exec.py | 1 + tests/test_agent_orchestrator.py | 79 +- .../test_agent_orchestrator_journal_edges.py | 4 +- tests/test_agent_orchestrator_lifecycle.py | 2 +- tests/test_agent_orchestrator_stages.py | 1214 +----------- tests/test_agent_report.py | 47 +- tests/test_agent_rna_workflow_scope.py | 258 +++ tests/test_agent_tuning_reuse.py | 221 +++ tests/test_agent_work_budget.py | 250 +++ 34 files changed, 3661 insertions(+), 4816 deletions(-) delete mode 100644 docs/.jupyter_cache/executed/c573a3cbb5a0f0e5d6b83d5f6b5a9d45/base.ipynb create mode 100644 docs/.jupyter_cache/executed/e470ea1bc7598db9f553a48c4f356d77/base.ipynb create mode 100644 scarf/agent/orchestrator/api.py create mode 100644 scarf/agent/orchestrator/budget.py create mode 100644 scarf/agent/orchestrator/rna.py create mode 100644 tests/test_agent_beginner.py create mode 100644 tests/test_agent_rna_workflow_scope.py create mode 100644 tests/test_agent_tuning_reuse.py create mode 100644 tests/test_agent_work_budget.py diff --git a/docs/.jupyter_cache/executed/c573a3cbb5a0f0e5d6b83d5f6b5a9d45/base.ipynb b/docs/.jupyter_cache/executed/c573a3cbb5a0f0e5d6b83d5f6b5a9d45/base.ipynb deleted file mode 100644 index 2b3a2ec6..00000000 --- a/docs/.jupyter_cache/executed/c573a3cbb5a0f0e5d6b83d5f6b5a9d45/base.ipynb +++ /dev/null @@ -1,1044 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 1, - "id": "c2a291b2", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
    Downloading bucket files18098007 / 18098007 complete18098007 / 18098007 complete
    " - ], - "text/plain": [ - "Downloading bucket files: 18098007 / 18098007 complete" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "
    Downloading bytes18098007 / 18098007 complete18098007 / 18098007 complete
    " - ], - "text/plain": [ - "Downloading bytes: 18098007 / 18098007 complete" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/plain": [ - "{'source': 'data.h5', 'destination': 'agent_workflow.zarr'}" - ] - }, - "execution_count": 1, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "from contextlib import redirect_stdout\n", - "from io import StringIO\n", - "from pathlib import Path\n", - "\n", - "import scarf\n", - "from scarf.agent import (\n", - " AgentOrchestrator,\n", - " AgentRunConfig,\n", - " AutomatedWorkflowConfig,\n", - " AutomatedWorkflowRequest,\n", - " DecisionSelection,\n", - " generate_agent_report,\n", - " load_agent_report,\n", - " load_agent_workflow,\n", - ")\n", - "from scarf.agent.orchestrator import artifact_model_to_ref\n", - "\n", - "scarf.configure_output(level=\"WARNING\", progress=False)\n", - "\n", - "source_path = scarf.cytebase.connect(\"scarf_docs\").download(\n", - " \"tenx_5K_pbmc_rnaseq/data.h5\",\n", - " destination=\"scarf_datasets\",\n", - ")[0]\n", - "zarr_path = source_path.with_name(\"agent_workflow.zarr\")\n", - "\n", - "study_context = (\n", - " \"This is a human 10x Genomics 5K PBMC 3-prime gene-expression dataset \"\n", - " \"from peripheral blood collected from one healthy donor. The goal is \"\n", - " \"unsupervised identification and characterization of the major immune-cell \"\n", - " \"populations. No treatment comparison, technical batch covariate, paired \"\n", - " \"modality, or independent replication metadata is available. Do not invent \"\n", - " \"absent design variables or report treatment effects.\"\n", - ")\n", - "\n", - "{\"source\": source_path.name, \"destination\": zarr_path.name}" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "57e12005", - "metadata": { - "tags": [ - "remove-cell" - ] - }, - "outputs": [], - "source": [ - "import json\n", - "from typing import Any\n", - "\n", - "from pydantic_ai.messages import (\n", - " ModelMessage,\n", - " ModelResponse,\n", - " ToolCallPart,\n", - " ToolReturnPart,\n", - ")\n", - "from pydantic_ai.models.function import AgentInfo, FunctionModel\n", - "\n", - "from scarf.agent.biological_interpretation import (\n", - " BiologicalInterpretationReport,\n", - " ClusterCompositionEvidence,\n", - " ClusterInterpretation,\n", - " ClusterMarkerBatchEvidence,\n", - ")\n", - "from scarf.agent.data_enrichment import (\n", - " AssayFeatureInspectionBatch,\n", - " DataEnrichmentReport,\n", - " FeatureSelectionPolicy,\n", - " StudyContextSummary,\n", - ")\n", - "from scarf.agent.experimental_context import (\n", - " BatchCorrectionPlan,\n", - " CovariateEvidence,\n", - " ExperimentalContextDecision,\n", - ")\n", - "\n", - "def _prompt_text(messages: list[ModelMessage]) -> str:\n", - " values = []\n", - " for message in messages:\n", - " for part in message.parts:\n", - " content = getattr(part, \"content\", None)\n", - " if isinstance(content, str):\n", - " values.append(content)\n", - " elif isinstance(content, tuple):\n", - " values.extend(item for item in content if isinstance(item, str))\n", - " return \"\\n\".join(values)\n", - "\n", - "\n", - "def _tool_result(\n", - " messages: list[ModelMessage],\n", - " tool_name: str,\n", - " model_type: Any,\n", - ") -> Any:\n", - " for message in reversed(messages):\n", - " for part in reversed(message.parts):\n", - " if isinstance(part, ToolReturnPart) and part.tool_name == tool_name:\n", - " if isinstance(part.content, model_type):\n", - " return part.content\n", - " if isinstance(part.content, str):\n", - " return model_type.model_validate_json(part.content)\n", - " return model_type.model_validate(part.content)\n", - " raise AssertionError(f\"Missing tool return {tool_name!r}\")\n", - "\n", - "\n", - "def _tool_call(name: str, args: dict[str, Any] | None = None) -> ModelResponse:\n", - " return ModelResponse(parts=[ToolCallPart(tool_name=name, args=args or {})])\n", - "\n", - "\n", - "def _structured_output(info: AgentInfo, value: Any) -> ModelResponse:\n", - " payload = value.model_dump() if hasattr(value, \"model_dump\") else value\n", - " return _tool_call(info.output_tools[0].name, payload)\n", - "\n", - "\n", - "def _scripted_workflow_model() -> tuple[FunctionModel, dict[str, int]]:\n", - " state = {\n", - " \"enrichment\": 0,\n", - " \"context\": 0,\n", - " \"parameter\": 0,\n", - " \"biology\": 0,\n", - " \"requests\": 0,\n", - " }\n", - "\n", - " async def reply(\n", - " messages: list[ModelMessage],\n", - " info: AgentInfo,\n", - " ) -> ModelResponse:\n", - " state[\"requests\"] += 1\n", - " tools = {tool.name for tool in info.function_tools}\n", - "\n", - " if \"inspect_assay_features_batch\" in tools or state[\"enrichment\"] == 1:\n", - " if state[\"enrichment\"] == 0:\n", - " state[\"enrichment\"] = 1\n", - " return _tool_call(\"inspect_assay_features_batch\")\n", - "\n", - " batch = _tool_result(\n", - " messages,\n", - " \"inspect_assay_features_batch\",\n", - " AssayFeatureInspectionBatch,\n", - " )\n", - " policies = []\n", - " for inspection in batch.inspections:\n", - " species_observed = inspection.species != \"unknown\"\n", - " policy_evidence = list(inspection.evidenceIds)\n", - " if not species_observed:\n", - " policy_evidence.append(\"context:study\")\n", - " policies.append(\n", - " FeatureSelectionPolicy(\n", - " assay=inspection.assay,\n", - " species=(\n", - " inspection.species\n", - " if species_observed\n", - " else \"homo_sapiens\"\n", - " ),\n", - " speciesConfidence=\"high\" if species_observed else \"medium\",\n", - " speciesRationale=(\n", - " inspection.speciesReason\n", - " or \"The exact study paragraph identifies a human sample.\"\n", - " ),\n", - " excludeFamilies=[\n", - " family.family\n", - " for family in inspection.families\n", - " if family.count > 0 and family.defaultExclude is True\n", - " ],\n", - " protectFamilies=[\n", - " family.family\n", - " for family in inspection.families\n", - " if family.count > 0 and family.defaultExclude is False\n", - " ],\n", - " rationale=(\n", - " \"Exclude observed technical families and preserve \"\n", - " \"observed protected families.\"\n", - " ),\n", - " evidenceIds=list(dict.fromkeys(policy_evidence)),\n", - " )\n", - " )\n", - " state[\"enrichment\"] = 2\n", - " return _structured_output(\n", - " info,\n", - " DataEnrichmentReport(\n", - " status=\"done\",\n", - " studyContextSummary=StudyContextSummary(\n", - " organismReferences=[\"human\"],\n", - " tissueReferences=[\"peripheral blood\"],\n", - " experimentalReferences=[\n", - " \"10x Genomics 5K PBMC 3-prime gene-expression dataset\"\n", - " ],\n", - " analysisIntentReferences=[\n", - " \"unsupervised identification and characterization of \"\n", - " \"the major immune-cell populations\"\n", - " ],\n", - " ),\n", - " policies=policies,\n", - " ),\n", - " )\n", - "\n", - " if tools.intersection(\n", - " {\n", - " \"inspect_cell_covariates\",\n", - " \"analyze_experimental_design\",\n", - " \"score_current_representation\",\n", - " }\n", - " ) or state[\"context\"] in {1, 2}:\n", - " if state[\"context\"] == 0:\n", - " state[\"context\"] = 1\n", - " return _tool_call(\"inspect_cell_covariates\")\n", - " if state[\"context\"] == 1:\n", - " state[\"context\"] = 2\n", - " return _tool_call(\n", - " \"analyze_experimental_design\",\n", - " {\n", - " \"column_domains\": {},\n", - " \"coefficients_of_interest\": [],\n", - " \"units_of_inference\": {},\n", - " \"batch_columns\": [],\n", - " },\n", - " )\n", - "\n", - " design = _tool_result(\n", - " messages,\n", - " \"analyze_experimental_design\",\n", - " CovariateEvidence,\n", - " )\n", - " profile = next(\n", - " value\n", - " for value in design.qcProfiles\n", - " if value.action == \"skip\"\n", - " )\n", - " evidence_id = profile.evidenceId\n", - " state[\"context\"] = 3\n", - " return _structured_output(\n", - " info,\n", - " ExperimentalContextDecision(\n", - " batchCorrection=BatchCorrectionPlan(\n", - " action=\"skip\",\n", - " rationale=\"No trusted technical batch column was supplied.\",\n", - " evidenceIds=[evidence_id],\n", - " ),\n", - " rationale=\"No experimental covariates were supplied.\",\n", - " evidenceIds=[evidence_id],\n", - " ),\n", - " )\n", - "\n", - " if tools.intersection(\n", - " {\"inspect_cluster_composition\", \"inspect_cluster_markers_batch\"}\n", - " ) or state[\"biology\"]:\n", - " if state[\"biology\"] == 0:\n", - " state[\"biology\"] = 1\n", - " return _tool_call(\"inspect_cluster_composition\")\n", - " if state[\"biology\"] == 1:\n", - " composition = _tool_result(\n", - " messages,\n", - " \"inspect_cluster_composition\",\n", - " ClusterCompositionEvidence,\n", - " )\n", - " state[\"biology\"] = 2\n", - " return _tool_call(\n", - " \"inspect_cluster_markers_batch\",\n", - " {\"cluster_ids\": list(composition.clusterCounts)},\n", - " )\n", - "\n", - " marker_batch = _tool_result(\n", - " messages,\n", - " \"inspect_cluster_markers_batch\",\n", - " ClusterMarkerBatchEvidence,\n", - " )\n", - " interpretations = []\n", - " for cluster in marker_batch.clusters:\n", - " if cluster.evidenceId and cluster.markers:\n", - " marker = cluster.markers[0]\n", - " marker_name = marker.featureName or marker.featureId\n", - " interpretations.append(\n", - " ClusterInterpretation(\n", - " clusterId=cluster.clusterId,\n", - " proposedIdentity=f\"{marker_name}-high RNA state\",\n", - " identityIsHypothesis=True,\n", - " confidence=\"low\",\n", - " rationale=(\n", - " \"The returned marker panel is led by \"\n", - " f\"{marker_name}.\"\n", - " ),\n", - " evidenceIds=[cluster.evidenceId],\n", - " )\n", - " )\n", - " state[\"biology\"] = 3\n", - " return _structured_output(\n", - " info,\n", - " BiologicalInterpretationReport(\n", - " status=\"done\",\n", - " clusterInterpretations=interpretations,\n", - " evidenceIds=[item.evidenceIds[0] for item in interpretations],\n", - " limitations=[\n", - " \"The scripted documentation model returns marker-linked \"\n", - " \"hypotheses, not validated cell identities.\"\n", - " ],\n", - " stopReason=(\n", - " \"Every cluster with returned marker evidence was reviewed.\"\n", - " ),\n", - " ),\n", - " )\n", - "\n", - " prompt = _prompt_text(messages)\n", - " if any(\n", - " tool.parameters_json_schema.get(\"title\")\n", - " == \"AnalysisVisualAdjudication\"\n", - " for tool in info.output_tools\n", - " ):\n", - " payload, _ = json.JSONDecoder().raw_decode(prompt[prompt.index(\"{\") :])\n", - " return _structured_output(\n", - " info,\n", - " {\n", - " \"status\": \"acceptable\",\n", - " \"selectedCandidateId\": payload[\"selectedCandidateId\"],\n", - " \"rationale\": (\n", - " \"The bounded diagnostic board agrees with the registered \"\n", - " \"numeric evidence.\"\n", - " ),\n", - " },\n", - " )\n", - "\n", - " decision, _ = json.JSONDecoder().raw_decode(prompt[prompt.index(\"{\") :])\n", - " evidence_by_class = {}\n", - " evidence_class_by_id = {}\n", - " for item in decision[\"evidence\"]:\n", - " evidence_by_class.setdefault(\n", - " item[\"evidenceClass\"],\n", - " item[\"evidenceId\"],\n", - " )\n", - " evidence_class_by_id[item[\"evidenceId\"]] = item[\"evidenceClass\"]\n", - " preferred = decision.get(\"metricPreferredOptionId\")\n", - " selected = (\n", - " next(\n", - " option\n", - " for option in decision[\"options\"]\n", - " if option[\"optionId\"] == preferred\n", - " )\n", - " if preferred is not None\n", - " else next(\n", - " option\n", - " for option in decision[\"options\"]\n", - " if option[\"status\"] in {\"apply\", \"skip\"}\n", - " )\n", - " )\n", - " evidence_ids = list(selected.get(\"requiredEvidenceIds\", []))\n", - " cited_classes = {\n", - " evidence_class_by_id[evidence_id] for evidence_id in evidence_ids\n", - " }\n", - " for evidence_class in selected[\"requiredEvidenceClasses\"]:\n", - " if evidence_class not in cited_classes:\n", - " evidence_ids.append(evidence_by_class[evidence_class])\n", - " state[\"parameter\"] += 1\n", - " return _structured_output(\n", - " info,\n", - " DecisionSelection(\n", - " selectedOptionId=selected[\"optionId\"],\n", - " evidenceIds=evidence_ids,\n", - " rationale=\"Select the registered metric-preferred option.\",\n", - " confidence=\"high\",\n", - " ),\n", - " )\n", - "\n", - " return FunctionModel(reply), state" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "5e9d2bc8", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'initial_candidates': 1,\n", - " 'refinement_candidates': 0,\n", - " 'harmony_candidates': 0,\n", - " 'input_policy': 'unattended'}" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "model, model_state = _scripted_workflow_model()\n", - "config = AutomatedWorkflowConfig(\n", - " inputPolicy=\"unattended\",\n", - " primaryInitialCandidates=1,\n", - " secondaryInitialCandidates=1,\n", - " maxRefinedCandidatesPerAssay=0,\n", - " maxHarmonyCandidatesPerAssay=0,\n", - " integrationResolutionCandidates=1,\n", - " maxCandidateBranches=1,\n", - " minClusterCells=2,\n", - " agentRunConfig=AgentRunConfig(\n", - " requestLimit=5,\n", - " toolCallLimit=5,\n", - " ),\n", - ")\n", - "orchestrator = AgentOrchestrator(model, config=config)\n", - "request = AutomatedWorkflowRequest(\n", - " sourcePath=str(source_path),\n", - " zarrPath=str(zarr_path),\n", - " studyContext=study_context,\n", - " studyObjective=\"Discover stable major immune-cell populations.\",\n", - " primaryAssay=\"RNA\",\n", - " markerAssay=\"RNA\",\n", - " analysisAssays=[\"RNA\"],\n", - " ingestDirections={\"overwrite\": True, \"defaultAssay\": \"RNA\"},\n", - ")\n", - "\n", - "{\n", - " \"initial_candidates\": config.primaryInitialCandidates,\n", - " \"refinement_candidates\": config.maxRefinedCandidatesPerAssay,\n", - " \"harmony_candidates\": config.maxHarmonyCandidatesPerAssay,\n", - " \"input_policy\": config.inputPolicy,\n", - "}" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "ede2f406", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'status': 'completed',\n", - " 'stage': 'analysis_finalization',\n", - " 'primary_assay': 'RNA',\n", - " 'marker_assay': 'RNA',\n", - " 'cell_qc': 'skip',\n", - " 'routes': [{'assay': 'RNA', 'features': 'hvg', 'reduction': 'pca'}]}" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "with redirect_stdout(StringIO()):\n", - " result = orchestrator.run(request)\n", - "\n", - "if (\n", - " result.status != \"completed\"\n", - " or result.finalAnalysis is None\n", - " or result.preprocessingPlan is None\n", - " or result.workflowRun is None\n", - " or result.zarrPath is None\n", - "):\n", - " raise RuntimeError(f\"Unexpected workflow result: {result.status}, {result.notes}\")\n", - "\n", - "plan = result.preprocessingPlan\n", - "{\n", - " \"status\": result.status,\n", - " \"stage\": result.currentStage,\n", - " \"primary_assay\": plan.primaryAssay,\n", - " \"marker_assay\": plan.markerAssay,\n", - " \"cell_qc\": plan.cellQc.action,\n", - " \"routes\": [\n", - " {\n", - " \"assay\": assay.assay,\n", - " \"features\": assay.featureMethod,\n", - " \"reduction\": assay.reductionMethod,\n", - " }\n", - " for assay in plan.assays\n", - " ],\n", - "}" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "e68fb2d7", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'status': 'completed',\n", - " 'stage': 'analysis_finalization',\n", - " 'agent_reports': ['data_enrichment',\n", - " 'experimental_context',\n", - " 'parameter_tuning'],\n", - " 'model_requests': 12,\n", - " 'graph_method': 'native',\n", - " 'marker_assay': 'RNA'}" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "persisted_workflow = load_agent_workflow(\n", - " result.zarrPath,\n", - " result.workflowRun.workflowRunId,\n", - " workspace=result.workflowRun.workspace,\n", - ")\n", - "\n", - "{\n", - " \"status\": persisted_workflow.status,\n", - " \"stage\": result.currentStage,\n", - " \"agent_reports\": [ref.agentName for ref in result.reportReferences],\n", - " \"model_requests\": model_state[\"requests\"],\n", - " \"graph_method\": result.finalAnalysis.graphMethod,\n", - " \"marker_assay\": result.finalAnalysis.markerAssay,\n", - "}" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "22a6b72f", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'candidates': [{'assay': 'RNA',\n", - " 'candidate': 1,\n", - " 'dimensions': 10,\n", - " 'resolution': 1.0,\n", - " 'neighbors': 21,\n", - " 'eligible': True,\n", - " 'clusters': 15,\n", - " 'smallest_cluster': 27,\n", - " 'graph_silhouette': 0.36498694993471886},\n", - " {'assay': 'RNA',\n", - " 'candidate': 2,\n", - " 'dimensions': 20,\n", - " 'resolution': 1.0,\n", - " 'neighbors': 21,\n", - " 'eligible': True,\n", - " 'clusters': 11,\n", - " 'smallest_cluster': 30,\n", - " 'graph_silhouette': 0.3899432284072132},\n", - " {'assay': 'RNA',\n", - " 'candidate': 3,\n", - " 'dimensions': 30,\n", - " 'resolution': 1.0,\n", - " 'neighbors': 21,\n", - " 'eligible': True,\n", - " 'clusters': 13,\n", - " 'smallest_cluster': 26,\n", - " 'graph_silhouette': 0.16811359562882475},\n", - " {'assay': 'RNA',\n", - " 'candidate': 4,\n", - " 'dimensions': 50,\n", - " 'resolution': 1.0,\n", - " 'neighbors': 21,\n", - " 'eligible': True,\n", - " 'clusters': 12,\n", - " 'smallest_cluster': 28,\n", - " 'graph_silhouette': 0.2779521381867694},\n", - " {'assay': 'RNA',\n", - " 'candidate': 5,\n", - " 'dimensions': 10,\n", - " 'resolution': 1.0,\n", - " 'neighbors': 21,\n", - " 'eligible': True,\n", - " 'clusters': 15,\n", - " 'smallest_cluster': 27,\n", - " 'graph_silhouette': 0.36498694993471886},\n", - " {'assay': 'RNA',\n", - " 'candidate': 6,\n", - " 'dimensions': 10,\n", - " 'resolution': 1.0,\n", - " 'neighbors': 11,\n", - " 'eligible': True,\n", - " 'clusters': 19,\n", - " 'smallest_cluster': 26,\n", - " 'graph_silhouette': 0.40884816989912326},\n", - " {'assay': 'RNA',\n", - " 'candidate': 7,\n", - " 'dimensions': 10,\n", - " 'resolution': 1.0,\n", - " 'neighbors': 21,\n", - " 'eligible': True,\n", - " 'clusters': 15,\n", - " 'smallest_cluster': 27,\n", - " 'graph_silhouette': 0.36498694993471886},\n", - " {'assay': 'RNA',\n", - " 'candidate': 8,\n", - " 'dimensions': 10,\n", - " 'resolution': 1.0,\n", - " 'neighbors': 41,\n", - " 'eligible': True,\n", - " 'clusters': 10,\n", - " 'smallest_cluster': 75,\n", - " 'graph_silhouette': 0.3580048738360666},\n", - " {'assay': 'RNA',\n", - " 'candidate': 9,\n", - " 'dimensions': 10,\n", - " 'resolution': 0.25,\n", - " 'neighbors': 11,\n", - " 'eligible': True,\n", - " 'clusters': 8,\n", - " 'smallest_cluster': 27,\n", - " 'graph_silhouette': 0.6326973227366433},\n", - " {'assay': 'RNA',\n", - " 'candidate': 10,\n", - " 'dimensions': 10,\n", - " 'resolution': 0.5,\n", - " 'neighbors': 11,\n", - " 'eligible': True,\n", - " 'clusters': 11,\n", - " 'smallest_cluster': 29,\n", - " 'graph_silhouette': 0.44029372227500996},\n", - " {'assay': 'RNA',\n", - " 'candidate': 11,\n", - " 'dimensions': 10,\n", - " 'resolution': 0.75,\n", - " 'neighbors': 11,\n", - " 'eligible': True,\n", - " 'clusters': 14,\n", - " 'smallest_cluster': 29,\n", - " 'graph_silhouette': 0.3325216854391465},\n", - " {'assay': 'RNA',\n", - " 'candidate': 12,\n", - " 'dimensions': 10,\n", - " 'resolution': 1.0,\n", - " 'neighbors': 11,\n", - " 'eligible': True,\n", - " 'clusters': 19,\n", - " 'smallest_cluster': 26,\n", - " 'graph_silhouette': 0.40884816989912326},\n", - " {'assay': 'RNA',\n", - " 'candidate': 13,\n", - " 'dimensions': 10,\n", - " 'resolution': 1.25,\n", - " 'neighbors': 11,\n", - " 'eligible': True,\n", - " 'clusters': 20,\n", - " 'smallest_cluster': 21,\n", - " 'graph_silhouette': 0.40435179722829495},\n", - " {'assay': 'RNA',\n", - " 'candidate': 14,\n", - " 'dimensions': 10,\n", - " 'resolution': 1.5,\n", - " 'neighbors': 11,\n", - " 'eligible': True,\n", - " 'clusters': 23,\n", - " 'smallest_cluster': 26,\n", - " 'graph_silhouette': 0.3325738520931393}],\n", - " 'stop_reason': 'Four causal RNA parameter phases were selected.',\n", - " 'report_statuses': {'data_enrichment': 'done',\n", - " 'experimental_context': 'done',\n", - " 'parameter_tuning': 'done'}}" - ] - }, - "execution_count": 6, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "reports = {\n", - " reference.agentName: load_agent_report(result.zarrPath, reference)\n", - " for reference in result.reportReferences\n", - "}\n", - "parameter_report = reports[\"parameter_tuning\"]\n", - "\n", - "candidate_metrics = []\n", - "for assay, assay_report in parameter_report.assayReports.items():\n", - " for index, evaluation in enumerate(assay_report.evaluations, start=1):\n", - " candidate_metrics.append(\n", - " {\n", - " \"assay\": assay,\n", - " \"candidate\": index,\n", - " \"dimensions\": evaluation.parameters.dimensions,\n", - " \"resolution\": evaluation.parameters.leidenResolution,\n", - " \"neighbors\": evaluation.parameters.neighborsK,\n", - " \"eligible\": evaluation.eligible,\n", - " \"clusters\": evaluation.metrics.nClusters,\n", - " \"smallest_cluster\": evaluation.metrics.minClusterCells,\n", - " \"graph_silhouette\": evaluation.metrics.graphSilhouetteMedian,\n", - " }\n", - " )\n", - "\n", - "{\n", - " \"candidates\": candidate_metrics,\n", - " \"stop_reason\": parameter_report.stopReason,\n", - " \"report_statuses\": {\n", - " name: report.status for name, report in reports.items()\n", - " },\n", - "}" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "9f9b86f0", - "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAUsAAAFfCAYAAADH8O4TAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjEsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvctoD+AAAAAlwSFlzAAAPYQAAD2EBqD+naQABAABJREFUeJzsnXfYXFWd+D/n3DJ93nlrekihhiLSQaSqq6jYVldB7K66uip2/Vl2XRXrrrqya6EpoCKKBSkCgtTQSyAhJCHt7XX6zL1z7z3n98eZTBJBjStpeD/PkyfvzNy5c+6Zme98+1dorTUxMTExMX8WubsXEBMTE7M3EAvLmJiYmB0gFpYxMTExO0AsLGNiYmJ2gFhYxsTExOwAsbCMiYmJ2QFiYRkTExOzA8TCMiYmJmYHiIXl3xFRFFEqlVBK7e6l/M38X64lDENKpRLPRB3Gs2kvY3aMWFj+HbF8+XK6u7vZvHnzM3reZ1II7Sj/l2u56aab6O7uZnp6ere8fszeTSwsY/5m7rjjDrq7uxkeHt7dS4mJ2WnEwvJZilKKVqv1Z48JgoByufyU+6vVKp7nPe1z/vicURRRq9UAqFQqlEolKpXKU54XRdHTnvOPtdJms/knX/vPoZSiVCpRKpWoVqs79JxWq/UXteE/te6/dN6YZx+xsHyW8cgjj/CiF72IVCpFoVDg+c9/Pg8//PDTHnvFFVfQ29v7lPuPPvpovvzlL3duT09P89rXvpZ0Ok2hUOCAAw7gRz/6EQD33Xcfr3/96wE4/vjjWbRoESeeeOJ263nBC15AMpkkl8tx+OGHs3z58s7jW0zj888/n4ULF9Lf389XvvKVv/q6N23axKJFi1i0aBFz586lq6uL173udYyMjDzl2Msvv5ylS5eSy+Xo7u7mC1/4wlOO+Uvr/mP+3B7FPDuIheWziLVr1/L85z+fvr4+Nm7cSLVa5bzzzuOaa675m877yU9+kg0bNrBhwwYajQbXXXcdN998MwDHHXccv/3tbwFYuXIlpVKJFStWALBhwwZOOukkUqkUQ0ND1Go1XvWqV/HiF7/4KX7DCy+8kN///vfUajU+97nP/dVrXLx48Xaa5apVq6hWq7zpTW96yrH//d//zW9+8xsajQYXX3wxX/jCF7jkkks6j/81696RPYp5lqBjnjW8+93v1gsWLNC+7z/t47fffrsG9IYNG7TWWl966aXasqynHHfAAQfoz33uc53bL33pS/XrX//6P/m6t9xyiwb04ODgdve/733v0/l8XheLxc59Sil90EEHdc5/3XXXaUDfdNNNO3aRf+JatqXVaulisahvvPFGDehyubzda11xxRXbHf/Od75TL1u27K9a9x+//l/ao5i9n1izfBbx8MMPc8wxx+C67jN63nPPPZfrr7+eo446ik996lPccMMNhGH4F5/3wAMPcMQRRwB0tL5yuczhhx/OI488st2xy5Yt+5vWGEURn/rUp5g3bx6pVIqFCxfy6le/GuApEeujjz56u9tHHXUUa9as6VzTX7PuLfxf9yhm78He3QuIeeawLOuv+oIKIZ72/iiKtrt9+umnMzg4yO9//3tuv/123v3ud5NMJrntttvo6+v7s+e/6667WLRo0VMeO/LII7e77TjODq/76fjqV7/KhRdeyC9+8QtOOOEEpJQ88MADHHXUUU/Zk6e7bVkWUsq/et1b+L/uUczeQ6xZPos4/vjjueuuuzrR6b9Eb28vURQxMzPTua9SqTxt7mA2m+UVr3gFX//613nkkUdYu3Ztx1e5RZP9YyF7wgkncNRRR3W0s23/3XTTTf/Xy3xa7r33Xk499VROPPHEjtC79dZbn/bY22+/fbvbd9xxB4ccckjnef/Xdf+5PYrZ+4mF5bOID33oQ9i2zStf+UruvvtuBgcHufzyyzn33HOf9vhjjjmGQqHAxz/+cTZv3syKFSt4/etf/5TUlzPPPJPvfe97PP7444yOjnLZZZehlOKQQw4BYOnSpViWxbXXXsv09HQndegjH/kIg4ODvOtd7+KRRx5hbGyM5cuXc+655/Ltb3/7Gb32I444ghtvvJGbb76Z4eFhfvSjH/Hv//7vT3vspz71Ka655hoGBwf55je/yc9+9jM++9nPdh7/v6z7L+1RzLOA3e00jXlm2bx5s37b296mFy1apPfZZx99zjnn6KGhIa211suXL9ddXV1606ZNneNvu+02feKJJ+o5c+bo448/Xv/whz/URx99tD7vvPM6x2zYsEG/5z3v0QceeKCeN2+ePu200/TVV1+93et+97vf1QcffLDu7e3Vhx56aOf+0dFR/d73vlfvv//+etasWfp5z3ue/va3v609z9Naa33TTTfprq4uPT09/Vdd5x9fi+d5+txzz9VLlizR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", - "text/plain": [ - "
    " - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "final = result.finalAnalysis\n", - "if (\n", - " final.cellSelection is None\n", - " or final.clusters is None\n", - " or final.umap is None\n", - " or final.markers is None\n", - "):\n", - " raise RuntimeError(\"The completed final handoff is missing required artifacts\")\n", - "\n", - "final_store = scarf.DataStore(\n", - " result.zarrPath,\n", - " default_assay=final.primaryAssay,\n", - " min_features_per_cell=-1,\n", - " mito_pattern=\"\",\n", - " ribo_pattern=\"\",\n", - " zarr_mode=\"r\",\n", - " workspace=result.workflowRun.workspace,\n", - " nthreads=2,\n", - ")\n", - "cell_selection_ref = artifact_model_to_ref(final.cellSelection)\n", - "cluster_ref = artifact_model_to_ref(final.clusters)\n", - "umap_ref = artifact_model_to_ref(final.umap)\n", - "marker_ref = artifact_model_to_ref(final.markers)\n", - "\n", - "final_store.plots.embedding(\n", - " layout=umap_ref,\n", - " color_by=cluster_ref,\n", - " legend_loc=\"on_data\",\n", - " frame=\"none\",\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "94212ce0", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
    \n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
    group_idfeature_namescorefrac_exp
    01FPR10.970960.82675
    11TMEM176B0.956010.87702
    143912IL3RA0.896241.00000
    143922GAS60.895561.00000
    287823HIST1H2BG0.357380.18953
    287833ANO90.351180.09386
    431734VPREB30.943310.76994
    431744IGHD0.913140.74397
    575645LRRN30.792390.50154
    575655NOG0.710400.23722
    719556KLRF10.902280.87529
    719566PRSS230.899680.66118
    \n", - "
    " - ], - "text/plain": [ - " group_id feature_name score frac_exp\n", - "0 1 FPR1 0.97096 0.82675\n", - "1 1 TMEM176B 0.95601 0.87702\n", - "14391 2 IL3RA 0.89624 1.00000\n", - "14392 2 GAS6 0.89556 1.00000\n", - "28782 3 HIST1H2BG 0.35738 0.18953\n", - "28783 3 ANO9 0.35118 0.09386\n", - "43173 4 VPREB3 0.94331 0.76994\n", - "43174 4 IGHD 0.91314 0.74397\n", - "57564 5 LRRN3 0.79239 0.50154\n", - "57565 5 NOG 0.71040 0.23722\n", - "71955 6 KLRF1 0.90228 0.87529\n", - "71956 6 PRSS23 0.89968 0.66118" - ] - }, - "execution_count": 8, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "marker_table = final_store.get_markers(\n", - " marker=marker_ref,\n", - " group_id=None,\n", - " min_score=-1,\n", - " min_frac_exp=-1,\n", - ")\n", - "marker_table.sort_values(\n", - " [\"group_id\", \"score\"],\n", - " ascending=[True, False],\n", - ").groupby(\"group_id\", sort=True).head(2)[\n", - " [\"group_id\", \"feature_name\", \"score\", \"frac_exp\"]\n", - "].head(12)" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "9cbaf4dd", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'report': 'agent_workflow.zarr/agents/runs//report/index.html',\n", - " 'exists': True,\n", - " 'final_artifact_kinds': {'selection': 'cell_selection',\n", - " 'clusters': 'cluster_labels',\n", - " 'umap': 'embedding',\n", - " 'markers': 'marker_table'}}" - ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "report_path = generate_agent_report(\n", - " result.zarrPath,\n", - " result.workflowRun.workflowRunId,\n", - " workspace=result.workflowRun.workspace,\n", - ")\n", - "display_path = str(report_path.relative_to(Path(result.zarrPath).parent)).replace(\n", - " result.workflowRun.workflowRunId,\n", - " \"\",\n", - ")\n", - "\n", - "{\n", - " \"report\": display_path,\n", - " \"exists\": report_path.is_file(),\n", - " \"final_artifact_kinds\": {\n", - " \"selection\": cell_selection_ref.kind,\n", - " \"clusters\": cluster_ref.kind,\n", - " \"umap\": umap_ref.kind,\n", - " \"markers\": marker_ref.kind,\n", - " },\n", - "}" - ] - } - ], - "metadata": { - "description": "Run Scarf's resumable automated agent orchestrator on a 5K PBMC dataset.", - "jupytext": { - "cell_metadata_filter": "tags", - "text_representation": { - "extension": ".md", - "format_name": "myst", - "format_version": 0.13, - "jupytext_version": "1.14.1" - } - }, - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.14.0" - }, - "source_map": [ - 14, - 64, - 100, - 106, - 424, - 433, - 467, - 475, - 504, - 514, - 529, - 542, - 573, - 587, - 618, - 623, - 636, - 648, - 669 - ] - }, - "nbformat": 4, - "nbformat_minor": 5 -} \ No newline at end of file diff --git a/docs/.jupyter_cache/executed/e470ea1bc7598db9f553a48c4f356d77/base.ipynb b/docs/.jupyter_cache/executed/e470ea1bc7598db9f553a48c4f356d77/base.ipynb new file mode 100644 index 00000000..44c025bd --- /dev/null +++ b/docs/.jupyter_cache/executed/e470ea1bc7598db9f553a48c4f356d77/base.ipynb @@ -0,0 +1,930 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "0dbfd15d", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
    Downloading bucket files18098007 / 18098007 complete18098007 / 18098007 complete
    " + ], + "text/plain": [ + "Downloading bucket files: 18098007 / 18098007 complete" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
    Downloading bytes18098007 / 18098007 complete18098007 / 18098007 complete
    " + ], + "text/plain": [ + "Downloading bytes: 18098007 / 18098007 complete" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "{'source': 'data.h5', 'destination': 'agent_workflow.zarr'}" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from contextlib import redirect_stdout\n", + "from io import StringIO\n", + "from pathlib import Path\n", + "\n", + "import scarf\n", + "from scarf.agent import (\n", + " AgentOrchestrator,\n", + " AgentRunConfig,\n", + " AutomatedWorkflowConfig,\n", + " AutomatedWorkflowRequest,\n", + " DecisionSelection,\n", + " load_agent_report,\n", + " load_agent_workflow,\n", + ")\n", + "\n", + "scarf.configure_output(level=\"WARNING\", progress=False)\n", + "\n", + "source_path = scarf.cytebase.connect(\"scarf_docs\").download(\n", + " \"tenx_5K_pbmc_rnaseq/data.h5\",\n", + " destination=\"scarf_datasets\",\n", + ")[0]\n", + "zarr_path = source_path.with_name(\"agent_workflow.zarr\")\n", + "\n", + "study_context = (\n", + " \"This is a human 10x Genomics 5K PBMC 3-prime gene-expression dataset \"\n", + " \"from peripheral blood collected from one healthy donor. The goal is \"\n", + " \"unsupervised identification and characterization of the major immune-cell \"\n", + " \"populations. No treatment comparison, technical batch covariate, paired \"\n", + " \"modality, or independent replication metadata is available. Do not invent \"\n", + " \"absent design variables or report treatment effects.\"\n", + ")\n", + "\n", + "{\"source\": source_path.name, \"destination\": zarr_path.name}" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "c574d07b", + "metadata": { + "tags": [ + "remove-cell" + ] + }, + "outputs": [], + "source": [ + "import json\n", + "from typing import Any\n", + "\n", + "from pydantic_ai.messages import (\n", + " ModelMessage,\n", + " ModelResponse,\n", + " ToolCallPart,\n", + " ToolReturnPart,\n", + ")\n", + "from pydantic_ai.models.function import AgentInfo, FunctionModel\n", + "\n", + "from scarf.agent.biological_interpretation import (\n", + " BiologicalInterpretationReport,\n", + " ClusterCompositionEvidence,\n", + " ClusterInterpretation,\n", + " ClusterMarkerBatchEvidence,\n", + ")\n", + "from scarf.agent.data_enrichment import (\n", + " AssayFeatureInspectionBatch,\n", + " DataEnrichmentReport,\n", + " FeatureSelectionPolicy,\n", + " StudyContextSummary,\n", + ")\n", + "from scarf.agent.experimental_context import (\n", + " BatchCorrectionPlan,\n", + " CovariateEvidence,\n", + " ExperimentalContextDecision,\n", + ")\n", + "\n", + "def _prompt_text(messages: list[ModelMessage]) -> str:\n", + " values = []\n", + " for message in messages:\n", + " for part in message.parts:\n", + " content = getattr(part, \"content\", None)\n", + " if isinstance(content, str):\n", + " values.append(content)\n", + " elif isinstance(content, tuple):\n", + " values.extend(item for item in content if isinstance(item, str))\n", + " return \"\\n\".join(values)\n", + "\n", + "\n", + "def _tool_result(\n", + " messages: list[ModelMessage],\n", + " tool_name: str,\n", + " model_type: Any,\n", + ") -> Any:\n", + " for message in reversed(messages):\n", + " for part in reversed(message.parts):\n", + " if isinstance(part, ToolReturnPart) and part.tool_name == tool_name:\n", + " if isinstance(part.content, model_type):\n", + " return part.content\n", + " if isinstance(part.content, str):\n", + " return model_type.model_validate_json(part.content)\n", + " return model_type.model_validate(part.content)\n", + " raise AssertionError(f\"Missing tool return {tool_name!r}\")\n", + "\n", + "\n", + "def _tool_call(name: str, args: dict[str, Any] | None = None) -> ModelResponse:\n", + " return ModelResponse(parts=[ToolCallPart(tool_name=name, args=args or {})])\n", + "\n", + "\n", + "def _structured_output(info: AgentInfo, value: Any) -> ModelResponse:\n", + " payload = value.model_dump() if hasattr(value, \"model_dump\") else value\n", + " return _tool_call(info.output_tools[0].name, payload)\n", + "\n", + "\n", + "def _scripted_workflow_model() -> tuple[FunctionModel, dict[str, int]]:\n", + " state = {\n", + " \"enrichment\": 0,\n", + " \"context\": 0,\n", + " \"parameter\": 0,\n", + " \"biology\": 0,\n", + " \"requests\": 0,\n", + " }\n", + "\n", + " async def reply(\n", + " messages: list[ModelMessage],\n", + " info: AgentInfo,\n", + " ) -> ModelResponse:\n", + " state[\"requests\"] += 1\n", + " tools = {tool.name for tool in info.function_tools}\n", + "\n", + " if \"inspect_assay_features_batch\" in tools or state[\"enrichment\"] == 1:\n", + " if state[\"enrichment\"] == 0:\n", + " state[\"enrichment\"] = 1\n", + " return _tool_call(\"inspect_assay_features_batch\")\n", + "\n", + " batch = _tool_result(\n", + " messages,\n", + " \"inspect_assay_features_batch\",\n", + " AssayFeatureInspectionBatch,\n", + " )\n", + " policies = []\n", + " for inspection in batch.inspections:\n", + " species_observed = inspection.species != \"unknown\"\n", + " policy_evidence = list(inspection.evidenceIds)\n", + " if not species_observed:\n", + " policy_evidence.append(\"context:study\")\n", + " policies.append(\n", + " FeatureSelectionPolicy(\n", + " assay=inspection.assay,\n", + " species=(\n", + " inspection.species\n", + " if species_observed\n", + " else \"homo_sapiens\"\n", + " ),\n", + " speciesConfidence=\"high\" if species_observed else \"medium\",\n", + " speciesRationale=(\n", + " inspection.speciesReason\n", + " or \"The exact study paragraph identifies a human sample.\"\n", + " ),\n", + " excludeFamilies=[\n", + " family.family\n", + " for family in inspection.families\n", + " if family.count > 0 and family.defaultExclude is True\n", + " ],\n", + " protectFamilies=[\n", + " family.family\n", + " for family in inspection.families\n", + " if family.count > 0 and family.defaultExclude is False\n", + " ],\n", + " rationale=(\n", + " \"Exclude observed technical families and preserve \"\n", + " \"observed protected families.\"\n", + " ),\n", + " evidenceIds=list(dict.fromkeys(policy_evidence)),\n", + " )\n", + " )\n", + " state[\"enrichment\"] = 2\n", + " return _structured_output(\n", + " info,\n", + " DataEnrichmentReport(\n", + " status=\"done\",\n", + " studyContextSummary=StudyContextSummary(\n", + " organismReferences=[\"human\"],\n", + " tissueReferences=[\"peripheral blood\"],\n", + " experimentalReferences=[\n", + " \"10x Genomics 5K PBMC 3-prime gene-expression dataset\"\n", + " ],\n", + " analysisIntentReferences=[\n", + " \"unsupervised identification and characterization of \"\n", + " \"the major immune-cell populations\"\n", + " ],\n", + " ),\n", + " policies=policies,\n", + " ),\n", + " )\n", + "\n", + " if tools.intersection(\n", + " {\n", + " \"inspect_cell_covariates\",\n", + " \"analyze_experimental_design\",\n", + " \"score_current_representation\",\n", + " }\n", + " ) or state[\"context\"] in {1, 2}:\n", + " if state[\"context\"] == 0:\n", + " state[\"context\"] = 1\n", + " return _tool_call(\"inspect_cell_covariates\")\n", + " if state[\"context\"] == 1:\n", + " state[\"context\"] = 2\n", + " return _tool_call(\n", + " \"analyze_experimental_design\",\n", + " {\n", + " \"column_domains\": {},\n", + " \"coefficients_of_interest\": [],\n", + " \"units_of_inference\": {},\n", + " \"batch_columns\": [],\n", + " },\n", + " )\n", + "\n", + " design = _tool_result(\n", + " messages,\n", + " \"analyze_experimental_design\",\n", + " CovariateEvidence,\n", + " )\n", + " profile = next(\n", + " value\n", + " for value in design.qcProfiles\n", + " if value.action == \"skip\"\n", + " )\n", + " evidence_id = profile.evidenceId\n", + " state[\"context\"] = 3\n", + " return _structured_output(\n", + " info,\n", + " ExperimentalContextDecision(\n", + " batchCorrection=BatchCorrectionPlan(\n", + " action=\"skip\",\n", + " rationale=\"No trusted technical batch column was supplied.\",\n", + " evidenceIds=[evidence_id],\n", + " ),\n", + " rationale=\"No experimental covariates were supplied.\",\n", + " evidenceIds=[evidence_id],\n", + " ),\n", + " )\n", + "\n", + " if tools.intersection(\n", + " {\"inspect_cluster_composition\", \"inspect_cluster_markers_batch\"}\n", + " ) or state[\"biology\"]:\n", + " if state[\"biology\"] == 0:\n", + " state[\"biology\"] = 1\n", + " return _tool_call(\"inspect_cluster_composition\")\n", + " if state[\"biology\"] == 1:\n", + " composition = _tool_result(\n", + " messages,\n", + " \"inspect_cluster_composition\",\n", + " ClusterCompositionEvidence,\n", + " )\n", + " state[\"biology\"] = 2\n", + " return _tool_call(\n", + " \"inspect_cluster_markers_batch\",\n", + " {\"cluster_ids\": list(composition.clusterCounts)},\n", + " )\n", + "\n", + " marker_batch = _tool_result(\n", + " messages,\n", + " \"inspect_cluster_markers_batch\",\n", + " ClusterMarkerBatchEvidence,\n", + " )\n", + " interpretations = []\n", + " for cluster in marker_batch.clusters:\n", + " if cluster.evidenceId and cluster.markers:\n", + " marker = cluster.markers[0]\n", + " marker_name = marker.featureName or marker.featureId\n", + " interpretations.append(\n", + " ClusterInterpretation(\n", + " clusterId=cluster.clusterId,\n", + " proposedIdentity=f\"{marker_name}-high RNA state\",\n", + " identityIsHypothesis=True,\n", + " confidence=\"low\",\n", + " rationale=(\n", + " \"The returned marker panel is led by \"\n", + " f\"{marker_name}.\"\n", + " ),\n", + " evidenceIds=[cluster.evidenceId],\n", + " )\n", + " )\n", + " state[\"biology\"] = 3\n", + " return _structured_output(\n", + " info,\n", + " BiologicalInterpretationReport(\n", + " status=\"done\",\n", + " clusterInterpretations=interpretations,\n", + " evidenceIds=[item.evidenceIds[0] for item in interpretations],\n", + " limitations=[\n", + " \"The scripted documentation model returns marker-linked \"\n", + " \"hypotheses, not validated cell identities.\"\n", + " ],\n", + " stopReason=(\n", + " \"Every cluster with returned marker evidence was reviewed.\"\n", + " ),\n", + " ),\n", + " )\n", + "\n", + " prompt = _prompt_text(messages)\n", + " if any(\n", + " tool.parameters_json_schema.get(\"title\")\n", + " == \"AnalysisVisualAdjudication\"\n", + " for tool in info.output_tools\n", + " ):\n", + " payload, _ = json.JSONDecoder().raw_decode(prompt[prompt.index(\"{\") :])\n", + " return _structured_output(\n", + " info,\n", + " {\n", + " \"status\": \"acceptable\",\n", + " \"selectedCandidateId\": payload[\"selectedCandidateId\"],\n", + " \"rationale\": (\n", + " \"The bounded diagnostic board agrees with the registered \"\n", + " \"numeric evidence.\"\n", + " ),\n", + " },\n", + " )\n", + "\n", + " decision, _ = json.JSONDecoder().raw_decode(prompt[prompt.index(\"{\") :])\n", + " evidence_by_class = {}\n", + " evidence_class_by_id = {}\n", + " for item in decision[\"evidence\"]:\n", + " evidence_by_class.setdefault(\n", + " item[\"evidenceClass\"],\n", + " item[\"evidenceId\"],\n", + " )\n", + " evidence_class_by_id[item[\"evidenceId\"]] = item[\"evidenceClass\"]\n", + " preferred = decision.get(\"metricPreferredOptionId\")\n", + " selected = (\n", + " next(\n", + " option\n", + " for option in decision[\"options\"]\n", + " if option[\"optionId\"] == preferred\n", + " )\n", + " if preferred is not None\n", + " else next(\n", + " option\n", + " for option in decision[\"options\"]\n", + " if option[\"status\"] in {\"apply\", \"skip\"}\n", + " )\n", + " )\n", + " evidence_ids = list(selected.get(\"requiredEvidenceIds\", []))\n", + " cited_classes = {\n", + " evidence_class_by_id[evidence_id] for evidence_id in evidence_ids\n", + " }\n", + " for evidence_class in selected[\"requiredEvidenceClasses\"]:\n", + " if evidence_class not in cited_classes:\n", + " evidence_ids.append(evidence_by_class[evidence_class])\n", + " state[\"parameter\"] += 1\n", + " return _structured_output(\n", + " info,\n", + " DecisionSelection(\n", + " selectedOptionId=selected[\"optionId\"],\n", + " evidenceIds=evidence_ids,\n", + " rationale=\"Select the registered metric-preferred option.\",\n", + " confidence=\"high\",\n", + " ),\n", + " )\n", + "\n", + " return FunctionModel(reply), state" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "9b066d3c", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'candidate_evaluation_limit': 14,\n", + " 'refinement_candidates': 0,\n", + " 'harmony_candidates': 0,\n", + " 'input_policy': 'unattended'}" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "model, model_state = _scripted_workflow_model()\n", + "config = AutomatedWorkflowConfig(\n", + " inputPolicy=\"unattended\",\n", + " maxRefinedCandidatesPerAssay=0,\n", + " maxHarmonyCandidatesPerAssay=0,\n", + " maxCandidateEvaluations=14,\n", + " hvgCandidateCounts=(1000,),\n", + " pcaCandidateDimensions=(20,),\n", + " graphNeighborCandidates=(21,),\n", + " leidenResolutionCandidates=(1.0,),\n", + " minClusterCells=2,\n", + " agentRunConfig=AgentRunConfig(\n", + " requestLimit=5,\n", + " toolCallLimit=5,\n", + " ),\n", + ")\n", + "orchestrator = AgentOrchestrator(model, config=config)\n", + "request = AutomatedWorkflowRequest(\n", + " sourcePath=str(source_path),\n", + " zarrPath=str(zarr_path),\n", + " studyContext=study_context,\n", + " studyObjective=\"Discover stable major immune-cell populations.\",\n", + " primaryAssay=\"RNA\",\n", + " markerAssay=\"RNA\",\n", + " analysisAssays=[\"RNA\"],\n", + " ingestDirections={\"overwrite\": True, \"defaultAssay\": \"RNA\"},\n", + ")\n", + "\n", + "{\n", + " \"candidate_evaluation_limit\": config.maxCandidateEvaluations,\n", + " \"refinement_candidates\": config.maxRefinedCandidatesPerAssay,\n", + " \"harmony_candidates\": config.maxHarmonyCandidatesPerAssay,\n", + " \"input_policy\": config.inputPolicy,\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "5c4749e6", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'status': 'completed',\n", + " 'stage': 'analysis_finalization',\n", + " 'primary_assay': 'RNA',\n", + " 'marker_assay': 'RNA',\n", + " 'cell_qc': 'skip',\n", + " 'routes': [{'assay': 'RNA', 'features': 'hvg', 'reduction': 'pca'}]}" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "with redirect_stdout(StringIO()):\n", + " result = orchestrator.run(request)\n", + "\n", + "if (\n", + " result.status != \"completed\"\n", + " or result.finalAnalysis is None\n", + " or result.preprocessingPlan is None\n", + " or result.workflowRun is None\n", + " or result.zarrPath is None\n", + "):\n", + " raise RuntimeError(f\"Unexpected workflow result: {result.status}, {result.notes}\")\n", + "\n", + "plan = result.preprocessingPlan\n", + "{\n", + " \"status\": result.status,\n", + " \"stage\": result.currentStage,\n", + " \"primary_assay\": plan.primaryAssay,\n", + " \"marker_assay\": plan.markerAssay,\n", + " \"cell_qc\": plan.cellQc.action,\n", + " \"routes\": [\n", + " {\n", + " \"assay\": assay.assay,\n", + " \"features\": assay.featureMethod,\n", + " \"reduction\": assay.reductionMethod,\n", + " }\n", + " for assay in plan.assays\n", + " ],\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "a0cebe8d", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'status': 'completed',\n", + " 'stage': 'analysis_finalization',\n", + " 'agent_reports': ['data_enrichment',\n", + " 'experimental_context',\n", + " 'parameter_tuning'],\n", + " 'model_requests': 12,\n", + " 'graph_method': 'native',\n", + " 'marker_assay': 'RNA'}" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "persisted_workflow = load_agent_workflow(\n", + " result.zarrPath,\n", + " result.workflowRun.workflowRunId,\n", + " workspace=result.workflowRun.workspace,\n", + ")\n", + "\n", + "{\n", + " \"status\": persisted_workflow.status,\n", + " \"stage\": result.currentStage,\n", + " \"agent_reports\": [ref.agentName for ref in result.reportReferences],\n", + " \"model_requests\": model_state[\"requests\"],\n", + " \"graph_method\": result.finalAnalysis.graphMethod,\n", + " \"marker_assay\": result.finalAnalysis.markerAssay,\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "7cff27cb", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'candidates': [{'assay': 'RNA',\n", + " 'candidate': 1,\n", + " 'dimensions': 20,\n", + " 'resolution': 1.0,\n", + " 'neighbors': 21,\n", + " 'eligible': True,\n", + " 'clusters': 11,\n", + " 'smallest_cluster': 30,\n", + " 'graph_silhouette': 0.3899432284072132},\n", + " {'assay': 'RNA',\n", + " 'candidate': 2,\n", + " 'dimensions': 20,\n", + " 'resolution': 1.0,\n", + " 'neighbors': 21,\n", + " 'eligible': True,\n", + " 'clusters': 11,\n", + " 'smallest_cluster': 30,\n", + " 'graph_silhouette': 0.3899432284072132},\n", + " {'assay': 'RNA',\n", + " 'candidate': 3,\n", + " 'dimensions': 20,\n", + " 'resolution': 1.0,\n", + " 'neighbors': 21,\n", + " 'eligible': True,\n", + " 'clusters': 11,\n", + " 'smallest_cluster': 30,\n", + " 'graph_silhouette': 0.3899432284072132},\n", + " {'assay': 'RNA',\n", + " 'candidate': 4,\n", + " 'dimensions': 20,\n", + " 'resolution': 1.0,\n", + " 'neighbors': 21,\n", + " 'eligible': True,\n", + " 'clusters': 11,\n", + " 'smallest_cluster': 30,\n", + " 'graph_silhouette': 0.3899432284072132}],\n", + " 'stop_reason': 'Four causal RNA parameter phases were selected.',\n", + " 'report_statuses': {'data_enrichment': 'done',\n", + " 'experimental_context': 'done',\n", + " 'parameter_tuning': 'done'}}" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "reports = {\n", + " reference.agentName: load_agent_report(result.zarrPath, reference)\n", + " for reference in result.reportReferences\n", + "}\n", + "parameter_report = reports[\"parameter_tuning\"]\n", + "\n", + "candidate_metrics = []\n", + "for assay, assay_report in parameter_report.assayReports.items():\n", + " for index, evaluation in enumerate(assay_report.evaluations, start=1):\n", + " candidate_metrics.append(\n", + " {\n", + " \"assay\": assay,\n", + " \"candidate\": index,\n", + " \"dimensions\": evaluation.parameters.dimensions,\n", + " \"resolution\": evaluation.parameters.leidenResolution,\n", + " \"neighbors\": evaluation.parameters.neighborsK,\n", + " \"eligible\": evaluation.eligible,\n", + " \"clusters\": evaluation.metrics.nClusters,\n", + " \"smallest_cluster\": evaluation.metrics.minClusterCells,\n", + " \"graph_silhouette\": evaluation.metrics.graphSilhouetteMedian,\n", + " }\n", + " )\n", + "\n", + "{\n", + " \"candidates\": candidate_metrics,\n", + " \"stop_reason\": parameter_report.stopReason,\n", + " \"report_statuses\": {\n", + " name: report.status for name, report in reports.items()\n", + " },\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "29890691", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "
    " + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "final = result.finalAnalysis\n", + "if (\n", + " final.cellSelection is None\n", + " or final.clusters is None\n", + " or final.umap is None\n", + " or final.markers is None\n", + "):\n", + " raise RuntimeError(\"The completed final handoff is missing required artifacts\")\n", + "\n", + "result.plot_embedding(\n", + " legend_loc=\"on_data\",\n", + " frame=\"none\",\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "4c706504", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    group_idfeature_namescorefrac_exp
    01MARC10.755610.36202
    11ALDH1A10.754780.56380
    1439110MAL0.357710.67361
    1439210ADTRP0.346100.18519
    2878211FCER1A0.820670.85437
    2878311FLT30.723760.68932
    431732MT-ND60.190070.44218
    431742HIST1H2BG0.186340.15873
    575643GNG110.843811.00000
    575653CLU0.835190.93333
    719554VPREB30.924190.77222
    719564IGHD0.899740.74444
    \n", + "
    " + ], + "text/plain": [ + " group_id feature_name score frac_exp\n", + "0 1 MARC1 0.75561 0.36202\n", + "1 1 ALDH1A1 0.75478 0.56380\n", + "14391 10 MAL 0.35771 0.67361\n", + "14392 10 ADTRP 0.34610 0.18519\n", + "28782 11 FCER1A 0.82067 0.85437\n", + "28783 11 FLT3 0.72376 0.68932\n", + "43173 2 MT-ND6 0.19007 0.44218\n", + "43174 2 HIST1H2BG 0.18634 0.15873\n", + "57564 3 GNG11 0.84381 1.00000\n", + "57565 3 CLU 0.83519 0.93333\n", + "71955 4 VPREB3 0.92419 0.77222\n", + "71956 4 IGHD 0.89974 0.74444" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "marker_table = result.get_markers(\n", + " group_id=None,\n", + " min_score=-1,\n", + " min_frac_exp=-1,\n", + ")\n", + "marker_table.sort_values(\n", + " [\"group_id\", \"score\"],\n", + " ascending=[True, False],\n", + ").groupby(\"group_id\", sort=True).head(2)[\n", + " [\"group_id\", \"feature_name\", \"score\", \"frac_exp\"]\n", + "].head(12)" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "bef7a115", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'report': 'agent_workflow.zarr/agents/runs//report/index.html',\n", + " 'exists': True,\n", + " 'final_artifact_kinds': {'selection': 'cell_selection',\n", + " 'clusters': 'cluster_labels',\n", + " 'umap': 'embedding',\n", + " 'markers': 'marker_table'}}" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "report_path = result.report()\n", + "display_path = str(report_path.relative_to(Path(result.zarrPath).parent)).replace(\n", + " result.workflowRun.workflowRunId,\n", + " \"\",\n", + ")\n", + "\n", + "{\n", + " \"report\": display_path,\n", + " \"exists\": report_path.is_file(),\n", + " \"final_artifact_kinds\": {\n", + " \"selection\": final.cellSelection.kind,\n", + " \"clusters\": final.clusters.kind,\n", + " \"umap\": final.umap.kind,\n", + " \"markers\": final.markers.kind,\n", + " },\n", + "}" + ] + } + ], + "metadata": { + "description": "Choose, explain, and execute RNA analysis settings with Scarf agents.", + "jupytext": { + "cell_metadata_filter": "tags", + "text_representation": { + "extension": ".md", + "format_name": "myst", + "format_version": 0.13, + "jupytext_version": "1.14.1" + } + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.0" + }, + "source_map": [ + 14, + 100, + 134, + 140, + 458, + 469, + 504, + 512, + 541, + 551, + 566, + 579, + 610, + 623, + 637, + 642, + 654, + 667, + 684 + ] + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/.jupyter_cache/global.db b/docs/.jupyter_cache/global.db index 0543b766c1ad19daeafef6007dfdf0431d135403..e73abe98273f7d8ea23244421f83d1089c098604 100644 GIT binary patch delta 2995 zcmeH}J5N+W6vua0L}VYt1R=hX1qvdDn|Yjhkr+i|j0F)87TiUUrv?I=Xh8%bmjaE5 zLZ*{q% z9B=cAxfF|a*-uM-|Lw>&_ke1hJ6x-MU|zThB{CtDz}1S9=9zv%iA1S{kTuhCl~ z^lV|q)pNmsc+VRVrR)2qn|>uf1l*XX-t$@ldb@AdU7krUg;#Egqx1G}{uvU)bb6oO5RUZumuqf3!45Uvcv^zl4;G20@Bjb+ delta 2903 zcmeH}%}Z557{<@NnVI*)O(}hW3KkR|X5M!`g(!*INojVq>sNkcp=3cVB$d1_EHW#Z zMK1jd3Tu&28;us(ztF$Xrfr*MRB}!Z1kv(t&+p;6&wbCC_nFz1^z2Id<*`)L((wza zrqvs7E}t6De$F;#rmMeJHynIWwOLhn;BMtc<BK-y+%^Da`o(6okzg)H|ZgdLfaYD6VBNk0T1qL|4L}*fDb+@^?glsqKEUn+IbB z2t4qFomt+2b~FCp9eC;AcOXIwLX2DpCEm_9W_C9_#2TawUu+za#I!AVO#rd7 zEye?zeTOLXW5Fvv`xKdLxIM+UL!?=L<`p-@Qd1tj$T?y!au*j97Z1q^v0WeVcZ5;% sebFm=>rYUKu-+1h=GF5=eqs9rgZa0JGG|{b`|}i(2^#1ih#dj`3$Z$ixc~qF diff --git a/docs/source/analysis_with_agents.md b/docs/source/analysis_with_agents.md index 864c2e75..61a013a8 100644 --- a/docs/source/analysis_with_agents.md +++ b/docs/source/analysis_with_agents.md @@ -103,9 +103,12 @@ unit of inference. ### When to use the automated agent workflow -Use `AgentOrchestrator` when the input is a supported dataset path and the caller can supply one -study-context paragraph and one study objective. The orchestrator owns a fixed stage order: ingest, -Data Enrichment, optional HTO demultiplexing, Experimental Context, preprocessing planning and +Use `analyze_rna` when the input is a supported dataset path and the caller can supply one +study-context paragraph and one study objective. The automated workflow supports one RNA assay; +other modalities may coexist in the store but are not analyzed. Automated multimodal integration +and hypothesis testing are deferred. Use the ordinary Scarf APIs for those analyses. +The orchestrator owns a fixed stage order: ingest, +Data Enrichment, Experimental Context, preprocessing planning and execution, Parameter Tuning, feature-policy review with optional revised preprocessing and tuning, analysis review, and analysis finalization. The model does not write exploratory code or choose arbitrary `DataStore` calls. It selects only validated policies and candidate identifiers from @@ -116,35 +119,42 @@ references. and marker artifacts. It is not an automatic stage of `AgentOrchestrator`. ```python -from scarf.agent import ( - AgentOrchestrator, - AutomatedWorkflowConfig, - AutomatedWorkflowRequest, +from scarf.agent import analyze_rna + +result = analyze_rna( + "study.h5ad", + zarr_path="study.zarr", + model=model, + study_context="One paragraph describing the study and analysis intent.", + study_objective="Discover stable populations relevant to the study.", + max_candidates=50, ) +if result.status != "completed": + raise RuntimeError(f"{result.status}: {'; '.join(result.notes)}") -orchestrator = AgentOrchestrator( - model, - config=AutomatedWorkflowConfig( - inputPolicy="unattended", - runConfoundedHarmonyDiagnostic=True, - ), -) -result = orchestrator.run( - AutomatedWorkflowRequest( - sourcePath="study.h5ad", - zarrPath="study.zarr", - studyContext="One paragraph describing the study and analysis intent.", - studyObjective="Discover stable populations relevant to the study.", - ) -) +result.plot_embedding() +markers = result.get_markers() +report_path = result.report() ``` +Pass `assay` when the store has multiple RNA assays. The result helpers reopen the saved +workspace read-only and use exact final artifacts. `report()` returns the existing local HTML +path or generates the missing view, without opening a browser. + The parameter screen uses granular public operations for each authorized branch rather than invoking `ds.pipeline.run()` for every candidate. This keeps normalization, reduction, neighbours, graph, clustering, metrics, promotion, UMAP, and marker artifacts explicit and enforces their order through lineage. `ds.pipeline.run()` remains the fixed baseline recipe described below. -`studyObjective` is required. `inputPolicy="pause"` permits a running workflow to return +`analyze_rna` runs unattended; its `study_objective` is required. `max_candidates` limits reserved +candidate slots across the workflow. Before screening, each pass reserves every configured +alternative, including conditional candidates that may not execute. Defaults reserve 25 slots +for the baseline and another 25 if a feature-policy revision runs; the default limit of 50 admits +both passes. A smaller limit never shrinks the candidate lists. A pass larger than the remaining +budget fails before screening. This controls admission, not actual execution counts, elapsed time, +QC diagnostics, or provider usage. Use `AgentOrchestrator` with +`AutomatedWorkflowConfig` for explicit candidate lists, workspaces, and provider limits. +Its `inputPolicy="pause"` permits a running workflow to return `needsInput`; resume only that exact workflow with `AutomatedWorkflowResumeRequest` and its persisted question identifiers. `inputPolicy="unattended"` resolves bounded model deferrals through registered policy and turns genuinely unresolved evidence into an explicit abstention or failure @@ -159,6 +169,12 @@ the source data and labels its result as a smoke test. A completed local workflo terminal result and then creates a replaceable HTML report. `generate_agent_report()` can regenerate that derived view without training new analysis artifacts. +Configuration compatibility is explicit: `maxCandidateEvaluations` replaces +`maxCandidateBranches`, and obsolete initial-candidate, integration, assay-count, and stability +controls are rejected. Saved workflows with the old configuration shape cannot resume or +regenerate reports in this release. Create a new workflow with explicit candidate lists; saved +analysis artifacts remain readable through ordinary Scarf artifact APIs. No records are migrated. + ### When to use the pipeline `ds.pipeline.run()` is a persistent baseline workflow. diff --git a/docs/source/tutorials/agent_workflow.md b/docs/source/tutorials/agent_workflow.md index 90dc1575..99246c87 100644 --- a/docs/source/tutorials/agent_workflow.md +++ b/docs/source/tutorials/agent_workflow.md @@ -1,5 +1,5 @@ --- -description: Run Scarf's resumable automated agent orchestrator on a 5K PBMC dataset. +description: Choose, explain, and execute RNA analysis settings with Scarf agents. jupytext: cell_metadata_filter: tags text_representation: @@ -15,12 +15,48 @@ kernelspec: (agent_workflow)= -# Run the automated agent workflow +# Choose and explain RNA analysis settings -This tutorial sends a 10x H5 dataset, one study-context paragraph, and one study objective to -`AgentOrchestrator`. The orchestrator owns the exact operation order, persists every handoff, and -returns exact final artifact references. Its agents select from executor-authorized operations and -parameters. They do not write exploratory code. +Scarf computes evidence about your data, the agent chooses between bounded alternatives, and +Scarf executes the selected settings. Start with a dataset, study context, and a configured +Pydantic AI model: + +```python +from scarf.agent import analyze_rna + +result = analyze_rna( + "study.h5ad", + zarr_path="study.zarr", + model=model, + study_context="Human blood from one healthy donor, with no treatment comparison.", + study_objective="Identify stable major immune-cell populations.", + max_candidates=50, +) +if result.status != "completed": + raise RuntimeError(f"{result.status}: {'; '.join(result.notes)}") + +result.plot_embedding() +markers = result.get_markers() +report_path = result.report() +``` + +This release analyzes one RNA assay. Stores may contain other modalities; pass `assay="counts"` +when more than one RNA assay is available. Automated multimodal integration and hypothesis testing +are outside this workflow. Markers are descriptive evidence. The optional agent dependency is +installed with `uv pip install "scarf[agent]"`. + +`max_candidates` limits reserved candidate slots across the initial analysis and any feature-policy +revision. Each pass reserves all configured alternatives before screening, including conditional +candidates that may not execute. The defaults reserve 25 slots for the baseline and another 25 +if a feature-policy revision runs. The default limit of 50 admits both passes; a smaller limit +never shrinks the candidate lists. An insufficient remaining budget stops admission of that pass. +The limit does not count actual executions or bound runtime or provider tokens. The result +methods use the completed analysis directly and reopen its store read-only; they do not retrain +UMAP or copy results into live metadata. `report()` returns a local path without opening a browser. + +The executable example below uses the advanced `AgentOrchestrator` interface to keep a teaching +run small and reproducible. That interface also supports explicit candidate lists, workspaces, +provider limits, and resumable checkpoints. Repository developers can also run `notebook/agent_workflow_new.ipynb` on the full abdominal adipose cohort or `notebook/agent_workflow_new_short.ipynb` on its reproducible 2,000-cell smoke @@ -30,10 +66,9 @@ sample. Both use unattended input policy and keep runtime files beside the noteb flowchart LR A[Input dataset and study context] --> B[Ingest] B --> C[Data Enrichment] - C --> D[HTO demultiplexing when present] - D --> E[Experimental Context] + C --> E[Experimental Context] E --> F[Preprocessing plan] - F --> G[Modality preprocessing] + F --> G[RNA preprocessing] G --> H[Parameter Tuning] H --> I[Feature-policy review] I --> J[Optional revised preprocessing and tuning] @@ -54,7 +89,7 @@ Install the optional agent dependencies before running this workflow outside the environment: ```console -pip install "scarf[agent]" +uv pip install "scarf[agent]" ``` The documentation run converts a raw H5 file into a separate teaching store. The explicit @@ -74,11 +109,9 @@ from scarf.agent import ( AutomatedWorkflowConfig, AutomatedWorkflowRequest, DecisionSelection, - generate_agent_report, load_agent_report, load_agent_workflow, ) -from scarf.agent.orchestrator import artifact_model_to_ref scarf.configure_output(level="WARNING", progress=False) @@ -424,23 +457,26 @@ def _scripted_workflow_model() -> tuple[FunctionModel, dict[str, int]]: ``` -## 2. Configure one bounded teaching branch +## 2. Configure a bounded teaching search -The production defaults screen eleven candidates for the primary assay and may request one -refinement. This documentation run uses one native RNA candidate, no refinement, and no Harmony. -The smaller search exercises the same executor and persistence path while keeping the build -bounded. Harmony would be eligible only if Experimental Context returned exact safe batch evidence. +This run uses one HVG count and singleton PCA, neighbor, and clustering-resolution lists, with +no refinement or Harmony. Each tuning pass still evaluates four stage candidates: PCA, the native +correction baseline, neighbors, and clustering. Including the HVG screen and selection evaluations, +the executor reserves seven evaluations per pass. The limit of fourteen permits a second pass +after a feature-policy revision. This is a small sequential search. QC comparisons, stability +diagnostics, provider requests, and finalization have separate costs. ```{code-cell} ipython3 model, model_state = _scripted_workflow_model() config = AutomatedWorkflowConfig( inputPolicy="unattended", - primaryInitialCandidates=1, - secondaryInitialCandidates=1, maxRefinedCandidatesPerAssay=0, maxHarmonyCandidatesPerAssay=0, - integrationResolutionCandidates=1, - maxCandidateBranches=1, + maxCandidateEvaluations=14, + hvgCandidateCounts=(1000,), + pcaCandidateDimensions=(20,), + graphNeighborCandidates=(21,), + leidenResolutionCandidates=(1.0,), minClusterCells=2, agentRunConfig=AgentRunConfig( requestLimit=5, @@ -460,7 +496,7 @@ request = AutomatedWorkflowRequest( ) { - "initial_candidates": config.primaryInitialCandidates, + "candidate_evaluation_limit": config.maxCandidateEvaluations, "refinement_candidates": config.maxRefinedCandidatesPerAssay, "harmony_candidates": config.maxHarmonyCandidatesPerAssay, "input_policy": config.inputPolicy, @@ -530,7 +566,7 @@ persisted_workflow = load_agent_workflow( ``` The single scripted provider handles every model-driven orchestrator stage. Deterministic -operations, such as HTO routing, preprocessing, candidate execution, promotion, UMAP, clustering, +operations, such as RNA preprocessing, candidate execution, promotion, UMAP, clustering, marker search, and persistence, do not require separate model requests. ## 5. Review parameter evidence and agent reports @@ -573,17 +609,16 @@ for assay, assay_report in parameter_report.assayReports.items(): } ``` -This one-candidate teaching run demonstrates execution and selection, not a broad parameter search. -The default configuration evaluates more initial candidates and may execute one evidence-driven +This teaching run demonstrates the successive parameter decisions with one option per stage. +The default configuration compares explicit HVG, PCA, neighbor, and resolution lists and may execute one evidence-driven refinement. Harmony is added only when the exact Experimental Context handoff authorizes a matched comparison. ## 6. Plot the exact final UMAP and inspect markers -`FinalAnalysisHandoff` separates graph ownership from marker-assay ownership and contains the exact -selection, graph, clusters, UMAP, and marker references that can be passed to Biological -Interpretation. The plotting call consumes those references directly; no coordinates or labels are -copied into live metadata columns. +The result uses its final UMAP and cluster artifacts directly. Display options are forwarded to +Scarf's plotting API. Exact artifact references remain available in `result.finalAnalysis` for +advanced workflows and Biological Interpretation. ```{code-cell} ipython3 final = result.finalAnalysis @@ -595,24 +630,7 @@ if ( ): raise RuntimeError("The completed final handoff is missing required artifacts") -final_store = scarf.DataStore( - result.zarrPath, - default_assay=final.primaryAssay, - min_features_per_cell=-1, - mito_pattern="", - ribo_pattern="", - zarr_mode="r", - workspace=result.workflowRun.workspace, - nthreads=2, -) -cell_selection_ref = artifact_model_to_ref(final.cellSelection) -cluster_ref = artifact_model_to_ref(final.clusters) -umap_ref = artifact_model_to_ref(final.umap) -marker_ref = artifact_model_to_ref(final.markers) - -final_store.plots.embedding( - layout=umap_ref, - color_by=cluster_ref, +result.plot_embedding( legend_loc="on_data", frame="none", ) @@ -622,8 +640,7 @@ UMAP is a presentation artifact. The tuning agent compares graph and metadata me appearance, and the orchestrator does not train several UMAPs to choose the most attractive one. ```{code-cell} ipython3 -marker_table = final_store.get_markers( - marker=marker_ref, +marker_table = result.get_markers( group_id=None, min_score=-1, min_frac_exp=-1, @@ -639,19 +656,16 @@ marker_table.sort_values( Marker scores are cell-level descriptive evidence. They are not replicate-aware differential expression, and the scripted identities remain hypotheses. -## 7. Open or regenerate the local HTML report +## 7. Find the local HTML report A completed local workflow first persists its terminal result and then writes a replaceable HTML -view under `agents/runs//report/index.html`. Calling -`generate_agent_report()` regenerates that view from the persisted workflow and existing analysis -artifacts. It does not train another UMAP. +view under `agents/runs//report/index.html`. `result.report()` returns that path, +generating the view from saved results if it is missing. It opens directly on the analysis and +does not train another UMAP. Advanced callers can use `generate_agent_report()` to explicitly +regenerate an existing view. ```{code-cell} ipython3 -report_path = generate_agent_report( - result.zarrPath, - result.workflowRun.workflowRunId, - workspace=result.workflowRun.workspace, -) +report_path = result.report() display_path = str(report_path.relative_to(Path(result.zarrPath).parent)).replace( result.workflowRun.workflowRunId, "", @@ -661,10 +675,10 @@ display_path = str(report_path.relative_to(Path(result.zarrPath).parent)).replac "report": display_path, "exists": report_path.is_file(), "final_artifact_kinds": { - "selection": cell_selection_ref.kind, - "clusters": cluster_ref.kind, - "umap": umap_ref.kind, - "markers": marker_ref.kind, + "selection": final.cellSelection.kind, + "clusters": final.clusters.kind, + "umap": final.umap.kind, + "markers": final.markers.kind, }, } ``` @@ -691,6 +705,8 @@ result = AgentOrchestrator( model, config=AutomatedWorkflowConfig(inputPolicy="unattended"), ).run(request) +if result.status != "completed": + raise RuntimeError(f"{result.status}: {'; '.join(result.notes)}") ``` For an existing Zarr input, omit `zarrPath` or set it to the same location. Its current `I` @@ -736,6 +752,8 @@ result = orchestrator.run( ), ) ) +if result.status != "completed": + raise RuntimeError(f"{result.status}: {'; '.join(result.notes)}") ``` Provider output remains provisional. Scarf validates evidence identifiers, operations, artifact diff --git a/scarf/agent/__init__.py b/scarf/agent/__init__.py index 83fc10e2..3f2eb219 100644 --- a/scarf/agent/__init__.py +++ b/scarf/agent/__init__.py @@ -52,6 +52,7 @@ ) from .orchestrator import ( AgentOrchestrator, + analyze_rna, AssayPreprocessingPlan, AutomatedPreprocessingPlan, AutomatedWorkflowConfig, @@ -190,6 +191,7 @@ "VerificationCheck", "VerificationRecord", "characterize_covariates", + "analyze_rna", "characterize_features", "check_runtime", "create_agent_workflow", diff --git a/scarf/agent/experimental_context/agent.py b/scarf/agent/experimental_context/agent.py index 5e393ac3..46dc7023 100644 --- a/scarf/agent/experimental_context/agent.py +++ b/scarf/agent/experimental_context/agent.py @@ -137,6 +137,7 @@ def run( connectivity_map: ArtifactRef | None = None, quality_metric_artifacts: Sequence[NamedArtifactSource] = (), hto_identity_artifacts: Sequence[NamedArtifactSource] = (), + qc_assay: str | None = None, ) -> ExperimentalContextResult: """Inspect one datastore and return a validated experimental-context report.""" study_context = (study_context or "").strip() @@ -196,7 +197,7 @@ def run( quality_sources = _derive_missing_percentage_artifacts( store, cell_selection=cell_selection, - driver=_qc_driver(store), + driver=_qc_driver(store, qc_assay), quality_sources=quality_metric_artifacts, ) hto_sources = list(hto_identity_artifacts) @@ -235,6 +236,7 @@ def run( ) deps = ExperimentalContextDependencies( store=store, + qcAssay=qc_assay, cells=_SelectionBoundCells( store.zw, store.cells, diff --git a/scarf/agent/experimental_context/contracts.py b/scarf/agent/experimental_context/contracts.py index 8d31ee80..6046d6d8 100644 --- a/scarf/agent/experimental_context/contracts.py +++ b/scarf/agent/experimental_context/contracts.py @@ -892,6 +892,7 @@ class ExperimentalContextDependencies(AgentDataModel): model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") store: Any = Field(default=None, exclude=True) + qcAssay: str | None = Field(default=None, exclude=True) cells: Any = Field(default=None, exclude=True) neighbors: Any = Field(default=None, exclude=True) connectivityMap: Any = Field(default=None, exclude=True) diff --git a/scarf/agent/experimental_context/qc_evidence.py b/scarf/agent/experimental_context/qc_evidence.py index 719ca489..4fa6ab00 100644 --- a/scarf/agent/experimental_context/qc_evidence.py +++ b/scarf/agent/experimental_context/qc_evidence.py @@ -59,9 +59,20 @@ def _persisted_assay_type(store: Any, assay_name: str) -> str: return assay_name if assay_name in {"RNA", "ATAC", "ADT", "HTO"} else "Assay" -def _qc_driver(store: Any) -> tuple[str, CellQcDriverType] | None: - """Choose the first RNA assay, otherwise the first ATAC assay.""" +def _qc_driver( + store: Any, selected_assay: str | None = None +) -> tuple[str, CellQcDriverType] | None: + """Use an explicit QC assay, otherwise the first RNA or ATAC assay.""" assay_names = [str(name) for name in getattr(store, "assay_names", [])] + if selected_assay is not None: + if selected_assay not in assay_names: + raise ValueError(f"Unknown QC assay {selected_assay!r}") + selected_type = _persisted_assay_type(store, selected_assay) + if selected_type == "RNA": + return selected_assay, "RNA" + if selected_type == "ATAC": + return selected_assay, "ATAC" + raise ValueError("The selected QC assay must have persisted RNA or ATAC type") for assay_type in ("RNA", "ATAC"): for assay_name in assay_names: if _persisted_assay_type(store, assay_name) == assay_type: @@ -1412,7 +1423,7 @@ def _offered_qc_profiles( """Project bounded QC profiles against the exact shared cell selection.""" active_cells = _active_cell_count(deps) active = np.ones(active_cells, dtype=bool) - driver = _qc_driver(deps.store) + driver = _qc_driver(deps.store, deps.qcAssay) driver_assay = driver[0] if driver is not None else None driver_type = driver[1] if driver is not None else None skip_id = _qc_profile_id( diff --git a/scarf/agent/orchestrator/__init__.py b/scarf/agent/orchestrator/__init__.py index f7ce55f1..f6cdc6b7 100644 --- a/scarf/agent/orchestrator/__init__.py +++ b/scarf/agent/orchestrator/__init__.py @@ -1,6 +1,7 @@ """Public facade for automated Scarf agent orchestration.""" from .main import AgentOrchestrator +from .api import analyze_rna from .models import ( AssayPreprocessingPlan, AutomatedPreprocessingPlan, @@ -20,6 +21,7 @@ __all__ = [ "AgentOrchestrator", + "analyze_rna", "AssayPreprocessingPlan", "AutomatedPreprocessingPlan", "AutomatedWorkflowConfig", diff --git a/scarf/agent/orchestrator/api.py b/scarf/agent/orchestrator/api.py new file mode 100644 index 00000000..63053092 --- /dev/null +++ b/scarf/agent/orchestrator/api.py @@ -0,0 +1,55 @@ +"""Small entry point for the supported automated RNA analysis.""" + +from pathlib import Path +from typing import Any + +from .main import AgentOrchestrator +from .models import ( + AutomatedWorkflowConfig, + AutomatedWorkflowRequest, + AutomatedWorkflowResult, +) + + +def analyze_rna( + source: str | Path, + *, + model: Any, + study_context: str, + study_objective: str, + assay: str | None = None, + zarr_path: str | Path | None = None, + max_candidates: int = 50, +) -> AutomatedWorkflowResult: + """Choose and explain settings for one RNA assay, then execute them. + + ``source`` is a supported input file or an existing Zarr store. ``assay`` + selects the RNA assay when the input contains more than one. The workflow + runs unattended and returns a structured outcome; check ``result.status`` + before consuming it. A completed result provides ``plot_embedding()``, + ``get_markers()``, and ``report()``. + + ``max_candidates`` limits reserved candidate slots across the workflow. + Each pass reserves all configured alternatives before screening, including + conditional candidates that may not execute. Defaults reserve 25 slots for + the baseline and another 25 if a feature-policy revision runs. A limit of + 50 admits both passes; a smaller limit never shrinks the candidate lists. + This is admission control, not a count of actual executions or a wall-time + or provider-token limit. Use ``AgentOrchestrator`` and + ``AutomatedWorkflowConfig`` for explicit candidate lists, workspaces, + provider limits, and resumable pauses. + """ + request = AutomatedWorkflowRequest( + sourcePath=str(source), + zarrPath=str(zarr_path) if zarr_path is not None else None, + studyContext=study_context, + studyObjective=study_objective, + primaryAssay=assay, + markerAssay=assay, + analysisAssays=[assay] if assay is not None else [], + ) + config = AutomatedWorkflowConfig( + inputPolicy="unattended", + maxCandidateEvaluations=max_candidates, + ) + return AgentOrchestrator(model, config=config).run(request) diff --git a/scarf/agent/orchestrator/budget.py b/scarf/agent/orchestrator/budget.py new file mode 100644 index 00000000..23f7f343 --- /dev/null +++ b/scarf/agent/orchestrator/budget.py @@ -0,0 +1,111 @@ +"""Conservative candidate admission using the existing stage journal.""" + +from typing import Any + +from ...datastore.datastore import DataStore +from ...utils.logging import logger +from .. import record_io +from ..persistence.contracts import AgentWorkflowRun +from . import journal +from .models import ( + AutomatedWorkflowConfig, + OrchestrationRequestRecord, + WorkflowStageName, +) + + +_PASS_STAGES: dict[WorkflowStageName, str] = { + "preprocessing": "baseline", + "feature_policy_preprocessing": "featureRevision", +} + + +def candidate_pass_breakdown(config: AutomatedWorkflowConfig) -> dict[str, int]: + """Reserve every configured alternative, including conditional work.""" + return { + "hvg": 3 * len(config.hvgCandidateCounts), + "pca": len(config.pcaCandidateDimensions), + "nativeCorrection": 1, + "harmony": config.maxHarmonyCandidatesPerAssay, + "neighbors": len(config.graphNeighborCandidates), + "resolutions": len(config.leidenResolutionCandidates), + "refinement": config.maxRefinedCandidatesPerAssay, + } + + +def reserve_candidate_pass( + store: DataStore, + prefix: str, + workflow: AgentWorkflowRun, + request_record: OrchestrationRequestRecord, + stage_name: WorkflowStageName, +) -> dict[str, Any]: + """Admit a logical pass before its immutable started record is written. + + The caller persists the returned reservation in ``inputs.candidateBudget``. + Repeated attempts retain the same slots; conditional work does not refund + slots. This bounds candidate alternatives, not numerical work or wall time. + """ + if stage_name not in _PASS_STAGES: + raise ValueError("Candidate reservations require a preprocessing stage") + if workflow.workflowRunId != request_record.workflowRunId: + raise ValueError("Candidate budget belongs to a different workflow") + breakdown = candidate_pass_breakdown(request_record.config) + per_pass = sum(breakdown.values()) + admitted: set[str] = set() + for stage, logical_pass in _PASS_STAGES.items(): + expected = { + "logicalPass": logical_pass, + "reserved": per_pass, + "breakdown": breakdown, + } + for started in journal._stage_starts( + store.zw, prefix, workflow.workflowRunId, stage + ): + if ( + started.requestSha256 != request_record.requestSha256 + or started.configSha256 != request_record.configSha256 + ): + raise ValueError( + "Candidate reservation request/config identity differs" + ) + reservation = started.inputs.get("candidateBudget") + if reservation is None: + if started.inputs.get("candidateBudgetRejected") is True: + continue + if stage == "feature_policy_preprocessing" and isinstance( + started.inputs.get("baselineAttemptId"), str + ): + continue + raise ValueError( + "Preprocessing history lacks its candidate reservation; " + "start a new workflow" + ) + if record_io.canonical_json_bytes( + reservation + ) != record_io.canonical_json_bytes(expected): + raise ValueError( + f"Persisted {logical_pass} candidate reservation differs " + "from the immutable workflow configuration" + ) + admitted.add(logical_pass) + logical_pass = _PASS_STAGES[stage_name] + if logical_pass == "featureRevision" and "baseline" not in admitted: + raise ValueError("Feature revision requires a reserved baseline pass") + total = per_pass * len(admitted | {logical_pass}) + limit = request_record.config.maxCandidateEvaluations + details = ", ".join(f"{name}={count}" for name, count in breakdown.items()) + if total > limit: + already_reserved = per_pass * len(admitted) + raise ValueError( + f"Candidate budget exceeded before {logical_pass}: " + f"{already_reserved} slots already reserved, {per_pass} required " + f"for this pass ({details}), workflow limit={limit}. " + "Increase maxCandidateEvaluations or explicitly reduce the candidate " + "lists in a new workflow. No candidate lists were truncated." + ) + logger.info( + f"Candidate work: {logical_pass} reserves {per_pass} slots ({details}); " + f"workflow reserved {total}/{limit}. Actual evaluations may be fewer." + ) + return {"logicalPass": logical_pass, "reserved": per_pass, "breakdown": breakdown} diff --git a/scarf/agent/orchestrator/context.py b/scarf/agent/orchestrator/context.py index a8c354d6..71417323 100644 --- a/scarf/agent/orchestrator/context.py +++ b/scarf/agent/orchestrator/context.py @@ -1,4 +1,4 @@ -"""Ingest, enrichment, HTO, and experimental-context workflow stages.""" +"""Ingest, RNA enrichment, quality metrics, and experimental-context stages.""" import re from collections.abc import Mapping, Sequence @@ -37,6 +37,11 @@ WorkflowStageLink, artifact_model_to_ref, ) +from .rna import ( + selected_store_rna_assay, + validate_rna_context, + validate_rna_directions, +) class ContextStagesMixin: @@ -196,6 +201,7 @@ def data_enrichment_stage( *, resume_record: OrchestrationResumeRecord | None = None, ) -> tuple[WorkflowStageAttempt, DataEnrichmentReport]: + selected = selected_store_rna_assay(store, request_record.request) prefix = journal._ensure_orchestration_store(store) existing = journal._validated_done_outcome( store, @@ -210,9 +216,18 @@ def data_enrichment_stage( f"Workflow {workflow.workflowRunId}: reusing Data Enrichment report" ) report = journal.load_stage_report(store, existing, DataEnrichmentReport) - return existing, cast(DataEnrichmentReport, report) + report = cast(DataEnrichmentReport, report) + if ( + len(report.policies) != 1 + or report.policies[0].assay != selected + or report.policies[0].assayModality != "RNA" + ): + raise ValueError( + "Saved enrichment includes unsupported assays; start a new RNA workflow." + ) + return existing, report request = request_record.request - selected_assays = request.analysisAssays or list(store.assay_names) + selected_assays = [selected] logger.info( f"Workflow {workflow.workflowRunId}: Data Enrichment will inspect " f"{len(selected_assays)} assay(s)" @@ -401,6 +416,15 @@ def _hto_stage( *, resume_record: OrchestrationResumeRecord | None = None, ) -> WorkflowStageAttempt: + selected = selected_store_rna_assay(store, request_record.request) + if ( + len(enrichment.policies) != 1 + or enrichment.policies[0].assay != selected + or enrichment.policies[0].assayModality != "RNA" + ): + raise ValueError( + "Quality metrics require enrichment of only the selected RNA assay" + ) prefix = journal._ensure_orchestration_store(store) existing = journal._validated_done_outcome( store, @@ -416,24 +440,21 @@ def _hto_stage( "qualityMetricArtifacts", "quality_metric", ) - self._named_stage_artifacts( + hto_sources = self._named_stage_artifacts( existing, "htoIdentityArtifacts", "hto_identity", ) + if hto_sources: + raise ValueError( + "Saved automatic HTO processing is unsupported; start a new RNA workflow." + ) logger.info( - f"Workflow {workflow.workflowRunId}: reusing HTO demultiplexing stage" + f"Workflow {workflow.workflowRunId}: reusing RNA quality metrics" ) return existing cell_selection_ref = artifact_model_to_ref(cell_selection) - eligible_hto = sum( - policy.assayModality == "HTO" and policy.demultiplexEligible - for policy in enrichment.policies - ) - logger.info( - f"Workflow {workflow.workflowRunId}: HTO stage found " - f"{eligible_hto} eligible assay(s)" - ) + logger.info(f"Workflow {workflow.workflowRunId}: computing RNA quality metrics") started = journal._start_attempt( store.zw, prefix, @@ -556,39 +577,6 @@ def _hto_stage( } ) actions.append(f"compute_{action_suffix}:{policy.assay}") - if policy.assayModality != "HTO" or not policy.demultiplexEligible: - continue - identity_ref = store.run_hto_demultiplexing( - cell_selection_ref, - from_assay=policy.assay, - random_seed=0, - invalidate_cache=False, - ) - identity_model = ArtifactReferenceModel.from_artifact_ref(identity_ref) - artifact_name = f"{policy.assay}_htoIdentity" - if artifact_name in artifacts: - raise ValueError( - f"Duplicate generated artifact name {artifact_name!r}" - ) - source = NamedArtifactSource( - name=artifact_name, - artifact=identity_model, - ) - artifacts[artifact_name] = identity_model - cast(list[dict[str, Any]], outputs["htoIdentityArtifacts"]).append( - source.model_dump(mode="json") - ) - cast(list[dict[str, Any]], outputs["operations"]).append( - { - "operation": "run_hto_demultiplexing", - "assay": policy.assay, - "cellSelection": cell_selection.model_dump(mode="json"), - "randomSeed": 0, - "invalidateCache": False, - "artifact": identity_model.model_dump(mode="json"), - } - ) - actions.append(f"demultiplex_hto:{policy.assay}") outcome = journal._complete_attempt( started, status="done", @@ -598,9 +586,7 @@ def _hto_stage( ) journal._save_outcome(store.zw, prefix, outcome) logger.info( - f"Workflow {workflow.workflowRunId}: HTO stage produced " - f"{len(cast(list[dict[str, Any]], outputs['htoIdentityArtifacts']))} " - "identity artifact(s)" + f"Workflow {workflow.workflowRunId}: RNA quality metrics completed" ) return outcome except Exception as exc: @@ -629,6 +615,11 @@ def experimental_context_stage( *, resume_record: OrchestrationResumeRecord | None = None, ) -> tuple[WorkflowStageAttempt, ExperimentalContextResult]: + selected = selected_store_rna_assay(store, request_record.request) + if hto_identity_artifacts: + raise ValueError( + "Automatic HTO identities are unsupported by the RNA workflow" + ) prefix = journal._ensure_orchestration_store(store) context_artifacts = self._experimental_context_artifacts( cell_selection, @@ -652,6 +643,7 @@ def experimental_context_stage( store, existing, ExperimentalContextResult ) resolved_report = cast(ExperimentalContextResult, report) + validate_rna_context(resolved_report, selected) if existing.artifacts != context_artifacts: raise ValueError( "Persisted Experimental Context stage artifacts are stale" @@ -694,6 +686,7 @@ def experimental_context_stage( directions.update(dict(supplied_directions)) elif isinstance(supplied_directions, str) and supplied_directions.strip(): directions["callerAnswer"] = supplied_directions.strip() + validate_rna_directions(directions) if request_record.request.authorLabelPolicy == "holdout": held_out_columns = sorted( column @@ -910,6 +903,7 @@ def find_held_out_references(value: Any) -> None: ) report = agent.run( store, + qc_assay=selected, study_context=request_record.request.studyContext, study_objective=request_record.request.studyObjective, cell_selection=cell_selection_ref, @@ -953,6 +947,8 @@ def find_held_out_references(value: Any) -> None: raise ValueError( "Experimental Context returned a different cell selection" ) + if report.status == "done": + validate_rna_context(report, selected) if report.qualityMetricArtifacts != list(quality_metric_artifacts): raise ValueError( "Experimental Context returned different quality metric artifacts" diff --git a/scarf/agent/orchestrator/finalization.py b/scarf/agent/orchestrator/finalization.py index 8c52c51b..266168a9 100644 --- a/scarf/agent/orchestrator/finalization.py +++ b/scarf/agent/orchestrator/finalization.py @@ -1,6 +1,5 @@ """Final analysis and biological interpretation workflow stages.""" -import hashlib import json from collections.abc import Mapping, Sequence from typing import Any, Literal, cast @@ -15,13 +14,6 @@ from ..data_enrichment.contracts import DataEnrichmentReport from ..experimental_context.contracts import ExperimentalContextResult from ..experimental_context.study import StudyContract -from ..hypotheses.contracts import ( - ClusterSelectionContract, - HypothesisContract, - HypothesisFeaturePanel, - HypothesisTestExecution, -) -from ..hypotheses.execution import execute_hypothesis_contract from ..parameter_tuning.agent import ParameterTuningAgent from ..parameter_tuning.contracts import ParameterTuningReport from ..persistence.contracts import ( @@ -125,11 +117,6 @@ def analysis_finalization_stage( else None ), "analysisReviewEvidence": dict(analysis_review_evidence or {}), - "hypothesisTestingPolicy": { - "contract": "licensedSampleAwareNormalizedExpression", - "adjustment": "fdr_bh", - "exploratoryMarkers": "selectedFeatureLevelMarkers", - }, }, resume_record=resume_record, ) @@ -156,6 +143,8 @@ def analysis_finalization_stage( "Decision-driven v1 cannot finalize an integrated SNN or WNN graph" ) preprocessed_assay = preprocessed[0] + if preprocessed_assay.assayType != "RNA": + raise ValueError("Automated finalization supports RNA only") if preprocessed_assay.assay != plan.primaryAssay: raise ValueError("The final RNA assay does not match preprocessing") if ( @@ -305,246 +294,6 @@ def analysis_finalization_stage( "Selected cluster evidence lacks advisory doublet scores" ) - hypothesis_directions = request_record.request.experimentalDirections.get( - "hypothesisTesting", - {}, - ) - if not isinstance(hypothesis_directions, Mapping): - raise ValueError( - "experimentalDirections.hypothesisTesting must be a mapping" - ) - raw_explicit_features = hypothesis_directions.get( - "explicitFeatures", - {}, - ) - raw_cluster_scopes = hypothesis_directions.get("clusters", {}) - if not isinstance(raw_explicit_features, Mapping): - raise ValueError( - "hypothesisTesting.explicitFeatures must map coefficients " - "to feature lists" - ) - if not isinstance(raw_cluster_scopes, Mapping): - raise ValueError( - "hypothesisTesting.clusters must map coefficients " - "to cluster-label lists" - ) - - exploratory_features = list( - dict.fromkeys( - feature - for features in selected.metrics.topMarkerGenes.values() - for feature in features - ) - )[: request_record.config.maxIdentityFeatures] - hypothesis_executions: list[HypothesisTestExecution] = [] - hypothesis_questions: list[WorkflowQuestion] = [] - answer_values = answers or {} - for contrast in ( - experimental.contrastPlans if experimental is not None else [] - ): - panels: list[HypothesisFeaturePanel] = [] - directed_features = raw_explicit_features.get( - contrast.coefficient, - raw_explicit_features.get("*", []), - ) - if not isinstance(directed_features, list | tuple) or any( - not isinstance(value, str) for value in directed_features - ): - raise ValueError( - "Each hypothesisTesting.explicitFeatures value must be " - "a list of feature names" - ) - explicit_features = list( - dict.fromkeys( - feature.strip() - for feature in directed_features - if feature.strip() - ) - ) - if explicit_features: - panels.append( - HypothesisFeaturePanel( - panelId=f"explicit:{contrast.coefficient}", - purpose="explicit", - features=explicit_features, - evidenceIds=[ - f"request:hypothesisFeatures:{contrast.coefficient}" - ], - ) - ) - if exploratory_features: - panels.append( - HypothesisFeaturePanel( - panelId=f"exploratoryMarkers:{contrast.coefficient}", - purpose="exploratoryMarkers", - features=exploratory_features, - sourceArtifact=marker_model, - evidenceIds=[f"artifact:markers:{marker_model.artifactId}"], - ) - ) - - cluster_selection = None - directed_clusters = raw_cluster_scopes.get(contrast.coefficient) - if directed_clusters is not None: - if not isinstance(directed_clusters, list | tuple): - raise ValueError( - "Each hypothesisTesting.clusters value must be a " - "cluster-label list" - ) - cluster_selection = ClusterSelectionContract( - clusterArtifact=final_clusters, - include=list(directed_clusters), - ) - grouping_artifact = next( - ( - source.artifact - for source in ( - experimental.htoIdentityArtifacts - if experimental is not None - else [] - ) - if source.name == contrast.coefficient - ), - None, - ) - identity_payload = { - "workflowRunId": workflow.workflowRunId, - "contrast": contrast.model_dump(mode="json"), - "panels": [panel.model_dump(mode="json") for panel in panels], - "clusterSelection": ( - cluster_selection.model_dump(mode="json") - if cluster_selection is not None - else None - ), - "cellSelection": cell_selection.model_dump(mode="json"), - "assay": plan.markerAssay, - } - identity = hashlib.sha256( - json.dumps( - identity_payload, - sort_keys=True, - separators=(",", ":"), - ).encode() - ).hexdigest()[:24] - contract = HypothesisContract( - contractId=f"hypothesis:{identity}", - familyId=f"contrast:{identity}", - contrast=contrast, - cellSelection=cell_selection, - groupingArtifact=grouping_artifact, - clusterSelection=cluster_selection, - featurePanels=panels, - fromAssay=plan.markerAssay, - evidenceIds=[ - contrast.evidenceId, - *contrast.evidenceIds, - f"artifact:clusters:{final_clusters.artifactId}", - ], - ) - execution = execute_hypothesis_contract(store, contract) - question_id = f"analysisContrast:{identity}" - if execution.status == "needsInput": - disposition = answer_values.get(question_id) - if disposition == "skip": - execution = execution.model_copy( - update={ - "status": "blocked", - "blockedReasons": list( - dict.fromkeys( - [ - *execution.blockedReasons, - "callerSkippedUnresolvedContrast", - ] - ) - ), - } - ) - elif disposition is not None: - raise ValueError(f"{question_id} must be answered with 'skip'") - elif request_record.config.inputPolicy == "unattended": - execution = execution.model_copy( - update={ - "status": "blocked", - "blockedReasons": list( - dict.fromkeys( - [ - *execution.blockedReasons, - "unattendedSkippedUnresolvedContrast", - ] - ) - ), - } - ) - else: - hypothesis_questions.append( - WorkflowQuestion( - questionId=question_id, - question=( - f"The contrast for {contrast.coefficient!r} " - "does not have a complete licensed design or " - "feature family. Stop to revise the immutable " - "request, or explicitly skip this unsupported " - "contrast." - ), - options=["skip"], - evidenceIds=list(execution.evidenceIds), - ) - ) - hypothesis_executions.append(execution) - if execution.statisticalTestArtifact is not None: - statistical_artifact = execution.statisticalTestArtifact - artifacts[f"statisticalTest{len(artifacts)}"] = statistical_artifact - actions.append("run_licensed_statistical_testing") - operations.append( - { - "operation": "execute_hypothesis_contract", - "contract": contract.model_dump(mode="json"), - "execution": execution.model_dump(mode="json"), - } - ) - - if hypothesis_questions: - outcome = journal._complete_attempt( - started, - status="needsInput", - artifacts=artifacts, - outputs={ - "hypothesisExecutions": [ - value.model_dump(mode="json") - for value in hypothesis_executions - ], - "operations": operations, - }, - actions=[*actions, "pause_unresolved_hypothesis"], - needs_input=WorkflowNeedsInput(questions=hypothesis_questions), - notes=[ - "At least one requested contrast lacks a licensed, " - "estimable sample-aware test." - ], - ) - journal._save_outcome(store.zw, prefix, outcome) - return outcome, FinalAnalysisHandoff.get_blank() - - statistical_tests = [ - value.statisticalTestArtifact - for value in hypothesis_executions - if value.statisticalTestArtifact is not None - ] - limitations.extend( - ( - f"Contrast {value.contrast.coefficient!r} produced no p-value: " - f"{'; '.join(value.blockedReasons)}." - ) - for value in hypothesis_executions - if value.status != "executed" - ) - if statistical_tests: - limitations.append( - "Statistical results are sample-level normalized-expression " - "distribution tests, not raw-count pseudobulk differential-" - "expression models." - ) - final_analysis = FinalAnalysisHandoff( workflowRunId=workflow.workflowRunId, primaryAssay=plan.primaryAssay, @@ -558,7 +307,6 @@ def analysis_finalization_stage( umap=final_umap, markerFeatures=preprocessed_assay.markerFeatures, markers=marker_model, - statisticalTests=statistical_tests, doubletScores=doublet_scores, doubletScoreSelections=doublet_score_selections, doubletEvidence={ @@ -600,9 +348,6 @@ def analysis_finalization_stage( }, analysisEvidence={ "analysisReview": dict(analysis_review_evidence or {}), - "hypothesisTests": [ - value.model_dump(mode="json") for value in hypothesis_executions - ], **( { "contrastPlans": [ @@ -858,55 +603,10 @@ def finalize_selected_graph( ]: if tuning_report.cellSelection is None: raise ValueError("Final graph selection lacks an exact cell selection") - final_cell_selection = tuning_report.cellSelection.model_dump(mode="json") if tuning_report.recommendedIntegrationId is not None: - selected_integration = next( - value - for value in tuning_report.integrationEvaluations - if value.integrationId == tuning_report.recommendedIntegrationId - ) - if selected_integration.graphArtifact is None: - raise ValueError("Selected integration lacks its graph artifact") - final_graph = ArtifactReferenceModel.model_validate( - selected_integration.graphArtifact.model_dump() - ) - graph_method = selected_integration.method - logger.info( - f"Finalizing selected {graph_method.upper()} graph " - f"{selected_integration.integrationId!r}" - ) - graph_ref = artifact_model_to_ref(final_graph) - primary_native = next( - value for value in native_handoffs if value.assay == plan.primaryAssay - ) - if primary_native.embeddingInitialization is None: - raise ValueError( - "Primary native analysis lacks embedding initialization" - ) - final_initialization = primary_native.embeddingInitialization - umap_ref = store.run_umap( - graph_ref, - artifact_model_to_ref(final_initialization), - parallel=False, - random_seed=4444, - invalidate_cache=False, - ) - final_umap = ArtifactReferenceModel.from_artifact_ref(umap_ref) - actions.append(f"run_final_umap:{graph_method}") - operations.append( - { - "operation": "run_umap", - "graphMethod": graph_method, - "graph": final_graph.model_dump(mode="json"), - "initialization": final_initialization.model_dump(mode="json"), - "cellSelection": final_cell_selection, - "parallel": False, - "randomSeed": 4444, - "invalidateCache": False, - "artifact": final_umap.model_dump(mode="json"), - } + raise ValueError( + "Automated RNA finalization cannot use an integrated graph" ) - return graph_method, final_graph, final_initialization, final_umap graph_assay = tuning_report.graphAssay if graph_assay is None: raise ValueError("Native final selection lacks graphAssay") diff --git a/scarf/agent/orchestrator/journal.py b/scarf/agent/orchestrator/journal.py index 78ff31ad..94ec1b3e 100644 --- a/scarf/agent/orchestrator/journal.py +++ b/scarf/agent/orchestrator/journal.py @@ -224,7 +224,14 @@ def _read_model( try: return model_type.model_validate_json(raw) except ValueError as exc: - raise ValueError(f"Malformed orchestration record {key!r}") from exc + hint = ( + "; recreate the request with the current AutomatedWorkflowConfig " + "and start a new workflow. Older saved request/config shapes are " + "unsupported and are not migrated" + if model_type is OrchestrationRequestRecord + else "" + ) + raise ValueError(f"Malformed orchestration record {key!r}{hint}") from exc def _write_model_once(group: zarr.Group, key: str, value: AgentDataModel) -> None: @@ -345,10 +352,9 @@ def _save_outcome( f"artifacts={len(outcome.artifacts)}, actions={len(outcome.actions)}" ) if outcome.status == "failed": - error_kind = (outcome.error or "unknown error").partition(":")[0] logger.error( - f"Workflow {outcome.workflowRunId}: stage={outcome.stage!r} " - f"failed ({error_kind}; {details}; {elapsed_seconds:.1f}s)" + f"Stage {outcome.stage!r} failed: " + f"{outcome.error or 'unknown error'} ({elapsed_seconds:.1f}s)" ) elif outcome.status == "needsInput": question_count = ( @@ -369,6 +375,23 @@ def _save_outcome( f"Workflow {outcome.workflowRunId}: completed stage={outcome.stage!r} " f"({details}; {elapsed_seconds:.1f}s)" ) + if ( + outcome.status == "done" + and "reuse_baseline_preprocessing" not in outcome.actions + ): + assays = outcome.outputs.get("assays") + if isinstance(assays, list): + count = sum( + len(assay.get("featureCandidateEvaluations", [])) + for assay in assays + if isinstance(assay, Mapping) + ) + logger.info(f"HVG comparison: {count} actual candidate evaluations.") + if "candidateCount" in outcome.outputs: + logger.info( + "Parameter tuning: " + f"{outcome.outputs['candidateCount']} actual candidate evaluations." + ) def _stage_outcomes( diff --git a/scarf/agent/orchestrator/main.py b/scarf/agent/orchestrator/main.py index a2090e33..70a3f42f 100644 --- a/scarf/agent/orchestrator/main.py +++ b/scarf/agent/orchestrator/main.py @@ -10,6 +10,7 @@ import zarr from ...datastore.datastore import DataStore +from ...datastore.summary import summarize_zarr_readonly from ...storage.stores import zarr_root_path from ...utils.logging import logger from .. import record_io @@ -45,6 +46,12 @@ WorkflowStageName, ) from .preprocessing import PreprocessingStagesMixin +from .rna import ( + selected_rna_assay, + validate_rna_directions, + validate_rna_request_fields, + validate_saved_rna_history, +) from .tuning import TuningStagesMixin @@ -80,7 +87,7 @@ class AgentOrchestrator( TuningStagesMixin, FinalizationStagesMixin, ): - """Run one bounded, persisted workflow through the four Scarf agents.""" + """Run one bounded, persisted single-RNA analysis workflow.""" def __init__( self, @@ -93,6 +100,21 @@ def __init__( def run(self, request: AutomatedWorkflowRequest) -> AutomatedWorkflowResult: """Ingest the request and continue until completion or a persisted pause.""" + result = self._run(request) + if result.status == "failed": + logger.error( + f"RNA analysis failed during {result.currentStage}: " + + "; ".join( + result.notes or ["See the saved stage outcome for details."] + ) + ) + return result + + def _run(self, request: AutomatedWorkflowRequest) -> AutomatedWorkflowResult: + try: + validate_rna_request_fields(request) + except ValueError as exc: + return AutomatedWorkflowResult(notes=[str(exc)]) format_name = detect_format(request.sourcePath) dataset_manifest: DatasetManifest | None = None logger.info( @@ -232,29 +254,28 @@ def run(self, request: AutomatedWorkflowRequest) -> AutomatedWorkflowResult: } request = request.model_copy(update={"ingestDirections": ingest_directions}) - if format_name == "zarr" and request.workspace is not None: + if format_name == "zarr": zarr_path = str(Path(request.sourcePath).resolve()) effective_request = request.model_copy(update={"zarrPath": zarr_path}) try: - store = self.open_store(zarr_path, effective_request) - except (KeyError, RuntimeError, TypeError, ValueError) as exc: - logger.error( - "Opening the automated workflow workspace failed " - f"({type(exc).__name__})" + summary = summarize_zarr_readonly( + zarr_path, + workspace=request.workspace, ) + except (OSError, KeyError, RuntimeError, TypeError, ValueError) as exc: return AutomatedWorkflowResult( status="failed", currentStage="ingest", zarrPath=zarr_path, - notes=[f"Opening the requested workspace failed: {exc}"], + notes=[f"Opening the requested RNA store failed: {exc}"], ) ingest_result = IngestResult( status="done", format="zarr", zarrPath=zarr_path, - assayNames=list(store.assay_names), - summary=store.summary().to_dict(), - actions=["summarize_zarr_workspace"], + assayNames=[assay.name for assay in summary.assays], + summary=summary.to_dict(), + actions=["summarize_zarr"], ) else: ingest_result = ingest( @@ -308,8 +329,44 @@ def run(self, request: AutomatedWorkflowRequest) -> AutomatedWorkflowResult: notes=list(ingest_result.notes), ) - if not (format_name == "zarr" and request.workspace is not None): + try: + selected = selected_rna_assay( + effective_request, + { + value["name"]: value["assay_type"] + for value in (ingest_result.summary or {}).get("assays", []) + }, + ) + effective_request = effective_request.model_copy( + update={ + "primaryAssay": selected, + "markerAssay": selected, + "analysisAssays": [selected], + } + ) store = self.open_store(ingest_result.zarrPath, effective_request) + except (OSError, KeyError, RuntimeError, TypeError, ValueError) as exc: + terminal = ( + finalize_agent_workflow( + ingest_result.zarrPath, + ingest_result.workflowRun.workflowRunId, + status="failed", + message=str(exc), + workspace=request.workspace, + ) + if ingest_result.workflowRun is not None + else None + ) + return AutomatedWorkflowResult( + zarrPath=ingest_result.zarrPath, + workflowRun=terminal, + notes=[str(exc)], + ) + ignored = [name for name in store.assay_names if name != selected] + logger.info( + f"RNA analysis: selected assay {selected!r}" + + (f"; ignored other assays {ignored}" if ignored else "") + ) workflow = ingest_result.workflowRun or create_agent_workflow(store) logger.info( f"Continuing automated workflow {workflow.workflowRunId} with " @@ -337,6 +394,23 @@ def resume( request: AutomatedWorkflowResumeRequest, ) -> AutomatedWorkflowResult: """Resume a running workflow after validating its immutable request.""" + result = self._resume(request) + if result.status == "failed": + logger.error( + f"RNA analysis failed during {result.currentStage}: " + + "; ".join( + result.notes or ["See the saved stage outcome for details."] + ) + ) + return result + + def _resume( + self, + request: AutomatedWorkflowResumeRequest, + ) -> AutomatedWorkflowResult: + directions = request.answers.get("experimentalDirections") + if isinstance(directions, Mapping): + validate_rna_directions(directions) logger.info( f"Resuming automated workflow {request.workflowRunId} with " f"{len(request.answers)} answer field(s)" @@ -692,6 +766,12 @@ def load_request_for_resume( raise ValueError("Stored orchestration config checksum is invalid") if record.contentSha256 != journal._record_checksum(record): raise ValueError("Stored orchestration request envelope is invalid") + summary = summarize_zarr_readonly(request.zarrPath, workspace=request.workspace) + selected = selected_rna_assay( + record.request, + {assay.name: assay.assay_type for assay in summary.assays}, + ) + validate_saved_rna_history(active, prefix, request.workflowRunId, selected) store = self.open_store(request.zarrPath, record.request) return record, store diff --git a/scarf/agent/orchestrator/models.py b/scarf/agent/orchestrator/models.py index 315ac6f2..6b16d834 100644 --- a/scarf/agent/orchestrator/models.py +++ b/scarf/agent/orchestrator/models.py @@ -1,8 +1,11 @@ """Public data models for resumable automated agent workflows.""" import hashlib +import math import re -from typing import Any, Literal +from collections.abc import Mapping +from pathlib import Path +from typing import TYPE_CHECKING, Any, Literal from pydantic import Field, field_validator, model_validator @@ -16,6 +19,12 @@ from ..persistence.contracts import AgentReportReference, AgentWorkflowRun from ..types import AgentDataModel, ArtifactReferenceModel +if TYPE_CHECKING: + import pandas as pd + + from ...datastore.datastore import DataStore + from ...plotting._figure import PlotResult + type AutomatedWorkflowStatus = Literal[ "completed", "needsInput", @@ -418,16 +427,12 @@ class AutomatedWorkflowConfig(AgentDataModel): default="pause", exclude_if=lambda value: value == "pause", ) - primaryInitialCandidates: int = Field(default=11, ge=1) - secondaryInitialCandidates: int = Field(default=3, ge=1) maxRefinedCandidatesPerAssay: int = Field(default=1, ge=0, le=1) maxHarmonyCandidatesPerAssay: int = Field(default=1, ge=0, le=1) runConfoundedHarmonyDiagnostic: bool = False - integrationResolutionCandidates: int = Field(default=3, ge=1) - maxCandidateBranches: int = Field(default=24, ge=1) + maxCandidateEvaluations: int = Field(default=50, ge=1) minClusterCells: int = Field(default=20, ge=1) maxIdentityFeatures: int = Field(default=64, ge=2) - maxGraphAssays: int = Field(default=3, ge=1) hvgCandidateCounts: tuple[int, ...] = (1000, 2000, 4000) pcaCandidateDimensions: tuple[int, ...] = (10, 20, 30, 50) graphNeighborCandidates: tuple[int, ...] = (11, 21, 41) @@ -439,39 +444,65 @@ class AutomatedWorkflowConfig(AgentDataModel): 1.25, 1.5, ) - leidenSeeds: tuple[int, ...] = (0, 1, 2) - clusterSubsamples: int = Field(default=2, ge=0, le=5) - clusterSubsampleFraction: float = Field(default=0.8, gt=0.0, lt=1.0) maxRevisions: int = Field(default=2, ge=0, le=2) allowDownloads: bool = False cacheDir: str | None = None agentRunConfig: AgentRunConfig = Field(default_factory=AgentRunConfig) + @model_validator(mode="before") + @classmethod + def reject_obsolete_configuration(cls, value: Any) -> Any: + if isinstance(value, Mapping): + obsolete = sorted( + set(value) + & { + "primaryInitialCandidates", + "secondaryInitialCandidates", + "integrationResolutionCandidates", + "maxCandidateBranches", + "maxGraphAssays", + "leidenSeeds", + "clusterSubsamples", + "clusterSubsampleFraction", + } + ) + if obsolete: + raise ValueError( + "Unsupported legacy workflow configuration fields: " + + ", ".join(obsolete) + + ". Create a new single-RNA workflow configuration with " + "explicit candidate lists and maxCandidateEvaluations. " + "Saved workflows using these fields cannot be resumed or " + "regenerated with this release; their analysis artifacts " + "remain available through Scarf's artifact APIs." + ) + return value + @model_validator(mode="after") def validate_candidate_registry(self) -> "AutomatedWorkflowConfig": - integer_fields = ( - "hvgCandidateCounts", - "pcaCandidateDimensions", - "graphNeighborCandidates", - ) - for field_name in integer_fields: + integer_minimums = { + "hvgCandidateCounts": 3, + "pcaCandidateDimensions": 2, + "graphNeighborCandidates": 2, + } + for field_name, minimum in integer_minimums.items(): values = getattr(self, field_name) - if not values or any(value < 1 for value in values): - raise ValueError(f"{field_name} must contain positive integers") + if not values or any(value < minimum for value in values): + raise ValueError( + f"{field_name} must contain integers of at least {minimum}" + ) if len(values) != len(set(values)) or tuple(sorted(values)) != values: raise ValueError(f"{field_name} must be sorted and unique") resolutions = self.leidenResolutionCandidates if ( not resolutions - or any(value <= 0 for value in resolutions) + or any(not math.isfinite(value) or value <= 0 for value in resolutions) or len(resolutions) != len(set(resolutions)) or tuple(sorted(resolutions)) != resolutions ): raise ValueError( - "leidenResolutionCandidates must be positive, sorted, and unique" + "leidenResolutionCandidates must be finite, positive, sorted, and unique" ) - if not self.leidenSeeds or len(self.leidenSeeds) != len(set(self.leidenSeeds)): - raise ValueError("leidenSeeds must be non-empty and unique") return self @classmethod @@ -509,10 +540,9 @@ def validate_request(self) -> "AutomatedWorkflowRequest": raise ValueError("studyObjective must be non-empty") if len(set(self.analysisAssays)) != len(self.analysisAssays): raise ValueError("analysisAssays must be unique") - if len(set(self.pairedAssays)) != len(self.pairedAssays): - raise ValueError("pairedAssays must be unique") - if self.pairedAssays and len(self.pairedAssays) < 2: - raise ValueError("pairedAssays must contain at least two assays") + from .rna import validate_rna_request_fields + + validate_rna_request_fields(self) return self @classmethod @@ -585,6 +615,99 @@ class AutomatedWorkflowResult(AgentDataModel): notes: list[str] = Field(default_factory=list) contentSha256: str = "" + def _completed_analysis(self) -> FinalAnalysisHandoff: + if self.status != "completed": + detail = "; ".join(self.notes) + raise RuntimeError( + f"Analysis did not complete: {self.status} at {self.currentStage}" + + (f" ({detail})" if detail else "") + ) + if self.finalAnalysis is None or self.workflowRun is None or not self.zarrPath: + raise RuntimeError( + "Completed analysis is missing its store or final handoff" + ) + return self.finalAnalysis + + def _analysis_store(self) -> "DataStore": + from ...datastore.datastore import DataStore + + final = self._completed_analysis() + assert self.workflowRun is not None and self.zarrPath is not None + return DataStore( + self.zarrPath, + default_assay=final.primaryAssay, + min_features_per_cell=-1, + mito_pattern="", + ribo_pattern="", + zarr_mode="r", + workspace=self.workflowRun.workspace, + ) + + def plot_embedding(self, **kwargs: Any) -> "PlotResult": + """Plot the final UMAP, colored by the selected clusters by default. + + Display options are forwarded to ``DataStore.plots.embedding``. The + persisted layout is fixed; no new embedding is computed. + """ + final = self._completed_analysis() + if final.umap is None or final.clusters is None: + raise RuntimeError("Completed analysis is missing its UMAP or clusters") + if "layout" in kwargs or "run" in kwargs: + raise ValueError("plot_embedding uses the completed analysis layout") + kwargs.setdefault("color_by", artifact_model_to_ref(final.clusters)) + return self._analysis_store().plots.embedding( + layout=artifact_model_to_ref(final.umap), + **kwargs, + ) + + def get_markers( + self, + *, + group_id: str | int | None = None, + min_score: float = 0.25, + min_frac_exp: float = 0.2, + ) -> "pd.DataFrame": + """Read the final marker table with Scarf's standard marker filters.""" + final = self._completed_analysis() + if final.markers is None: + raise RuntimeError("Completed analysis is missing its marker table") + return self._analysis_store().get_markers( + marker=artifact_model_to_ref(final.markers), + group_id=group_id, + min_score=min_score, + min_frac_exp=min_frac_exp, + ) + + def report(self) -> Path: + """Return the local HTML report, generating it if it is missing.""" + from ..report.artifacts import _local_root + from ..report.generator import generate_agent_report + + self._completed_analysis() + assert self.workflowRun is not None and self.zarrPath is not None + root = _local_root(self.zarrPath) + workspace = self.workflowRun.workspace + active_root = root if workspace is None else (root / workspace).resolve() + if not active_root.is_relative_to(root): + raise ValueError("Workflow workspace resolves outside the analysis store") + report_path = ( + active_root + / "agents" + / "runs" + / self.workflowRun.workflowRunId + / "report" + / "index.html" + ).resolve() + if not report_path.is_relative_to(active_root): + raise ValueError("Agent report path resolves outside the analysis store") + if report_path.is_file(): + return report_path + return generate_agent_report( + self.zarrPath, + self.workflowRun.workflowRunId, + workspace=workspace, + ) + @model_validator(mode="after") def validate_terminal_handoff(self) -> "AutomatedWorkflowResult": if self.status != "completed": diff --git a/scarf/agent/orchestrator/preprocessing.py b/scarf/agent/orchestrator/preprocessing.py index 2f385789..3bb182c9 100644 --- a/scarf/agent/orchestrator/preprocessing.py +++ b/scarf/agent/orchestrator/preprocessing.py @@ -61,7 +61,10 @@ augment_cluster_evaluations, augment_pca_evaluations, ) -from ..parameter_tuning.execution import execute_parameter_candidate +from ..parameter_tuning.execution import ( + candidate_metric_cache, + execute_parameter_candidate, +) from ..parameter_tuning.hvg import ( HvgRanking, compare_hvg_ranking_to_default, @@ -70,6 +73,7 @@ from ..persistence.contracts import AgentWorkflowRun from ..types import ArtifactReferenceModel from . import journal +from .budget import reserve_candidate_pass from .decisions import DecisionStagesMixin from .models import ( AssayPreprocessingPlan, @@ -77,7 +81,6 @@ OrchestrationRequestRecord, OrchestrationResumeRecord, PreprocessedAssayHandoff, - ReductionMethod, WorkflowNeedsInput, WorkflowQuestion, WorkflowStageAttempt, @@ -86,6 +89,13 @@ artifact_model_to_ref, ) +from .rna import ( + selected_store_rna_assay, + validate_rna_context, + validate_rna_handoffs, + validate_rna_plan, +) + class _DecisionNeedsInput(RuntimeError): def __init__( @@ -606,6 +616,8 @@ def preprocessing_plan_stage( *, resume_record: OrchestrationResumeRecord | None = None, ) -> tuple[WorkflowStageAttempt, AutomatedPreprocessingPlan]: + selected = selected_store_rna_assay(store, request_record.request) + validate_rna_context(experimental, selected) prefix = journal._ensure_orchestration_store(store) existing = journal._validated_done_outcome( store, @@ -619,9 +631,11 @@ def preprocessing_plan_stage( logger.info( f"Workflow {workflow.workflowRunId}: reusing preprocessing plan" ) - return existing, AutomatedPreprocessingPlan.model_validate( + cached_plan = AutomatedPreprocessingPlan.model_validate( existing.outputs["preprocessingPlan"] ) + validate_rna_plan(cached_plan, selected) + return existing, cached_plan if experimental.cellSelection is None: raise ValueError("Experimental Context lacks an exact cell selection") cell_qc_artifacts = self._cell_qc_candidate_artifacts(experimental.qcProfiles) @@ -666,17 +680,7 @@ def preprocessing_plan_stage( ingest_outcome, cell_qc, ) - graph_plans = [value for value in plan.assays if value.graphEligible] - if ( - len(graph_plans) != 1 - or graph_plans[0].assayType != "RNA" - or plan.pairedAssays - or plan.primaryAssay != graph_plans[0].assay - or plan.markerAssay != graph_plans[0].assay - ): - raise ValueError( - "The automated decision workflow accepts one unpaired RNA assay" - ) + validate_rna_plan(plan, selected) plan = plan.model_copy(update={"cellQualityPayload": cell_payload}) feature_payload, feature_decision_snapshot = ( self._resolve_feature_policy_decision( @@ -775,15 +779,21 @@ def build_preprocessing_plan( ingest_outcome: WorkflowStageAttempt, cell_qc: CellQcPlan, ) -> AutomatedPreprocessingPlan: + del ingest_outcome request = request_record.request store_summary = store.summary() - summaries = {value.name: value for value in store_summary.assays} - policies = {value.assay: value for value in enrichment.policies} - inspections = {value.assay: value for value in enrichment.inspections} - selected_names = request.analysisAssays or list(store.assay_names) - assay_plans: list[AssayPreprocessingPlan] = [] - graph_assays: list[str] = [] - limitations: list[str] = list(enrichment.limitations) + selected = selected_store_rna_assay(store, request) + summary = next( + value for value in store_summary.assays if value.name == selected + ) + policy = next( + (value for value in enrichment.policies if value.assay == selected), None + ) + inspection = next( + (value for value in enrichment.inspections if value.assay == selected), None + ) + if policy is not None and policy.assayModality != "RNA": + raise ValueError("Enrichment policy does not match the selected RNA assay") selected_qc_profile = next( ( value @@ -797,111 +807,25 @@ def build_preprocessing_plan( if selected_qc_profile is not None and selected_qc_profile.retainedCells > 0 else store_summary.active_cells ) - effective_min_cells = min(20, max(1, projected_cells // 10)) - for assay_name in selected_names: - summary = summaries[assay_name] - policy = policies.get(assay_name) - modality = ( - policy.assayModality - if policy is not None - else ( - summary.assay_type - if summary.assay_type in {"RNA", "ATAC", "ADT", "HTO"} - else "unsupported" - ) - ) - plan = self.build_assay_preprocessing_plan( - store, - request_record, - assay_name, - summary, - policy, - inspections.get(assay_name), - modality, - effective_min_cells, - ) - if plan.graphEligible: - graph_assays.append(assay_name) - if modality == "unsupported": - limitations.extend(plan.limitations) - assay_plans.append(plan) - if not graph_assays: - raise ValueError("No supported graph-bearing assay remains") - if len(graph_assays) > request_record.config.maxGraphAssays: - raise ValueError( - "Too many graph-bearing assays; provide analysisAssays to select at " - f"most {request_record.config.maxGraphAssays}" - ) - modality_counts: dict[str, int] = {} - for name in graph_assays: - modality_counts[summaries[name].assay_type] = ( - modality_counts.get(summaries[name].assay_type, 0) + 1 - ) - duplicate_modalities = sorted( - name for name, count in modality_counts.items() if count > 1 + assay_plan = self.build_assay_preprocessing_plan( + store, + request_record, + selected, + summary, + policy, + inspection, + "RNA", + min(20, max(1, projected_cells // 10)), ) - if duplicate_modalities and not request.analysisAssays: - raise ValueError( - "Multiple same-kind biological assays require explicit " - f"analysisAssays selection: {duplicate_modalities}" - ) - primary = request.primaryAssay - if primary is not None and primary not in graph_assays: - raise ValueError("primaryAssay must name a graph-bearing selected assay") - if primary is None: - primary = next( - ( - name - for modality in ("RNA", "ADT", "ATAC") - for name in graph_assays - if summaries[name].assay_type == modality - ), - graph_assays[0], - ) - if request.markerAssay is not None: - if request.markerAssay not in graph_assays: - raise ValueError("markerAssay must name a graph-bearing selected assay") - marker_assay = request.markerAssay - else: - marker_assay = next( - ( - name - for modality in ("RNA", "ADT", "ATAC") - for name in graph_assays - if summaries[name].assay_type == modality - ), - primary, - ) - if request.pairedAssays: - paired = list(request.pairedAssays) - unknown_paired = sorted(set(paired) - set(graph_assays)) - if unknown_paired: - raise ValueError( - f"pairedAssays contains non-graph assays: {unknown_paired}" - ) - if primary not in paired: - raise ValueError("pairedAssays must include the primary assay") - elif ( - len(graph_assays) > 1 - and ingest_outcome.outputs.get("pairingProvenance") - == "singleSourceSharedCellAxis" - ): - paired = list(graph_assays) - else: - paired = [] - if len(graph_assays) > 1: - limitations.append( - "Multimodal integration skipped because pairing provenance " - "was not supplied" - ) + if not assay_plan.graphEligible: + raise ValueError("RNA requires at least three features for PCA") final_plan = AutomatedPreprocessingPlan( - primaryAssay=primary, - markerAssay=marker_assay, + primaryAssay=selected, + markerAssay=selected, cellSelection=experimental.cellSelection, cellQc=cell_qc, - assays=assay_plans, - pairedAssays=paired, - limitations=list(dict.fromkeys(limitations)), + assays=[assay_plan], + limitations=list(dict.fromkeys(enrichment.limitations)), ) checksum = hashlib.sha256( record_io.canonical_json_bytes( @@ -921,188 +845,52 @@ def build_assay_preprocessing_plan( modality: str, effective_min_cells: int, ) -> AssayPreprocessingPlan: - excluded: list[str] = [] - evidence_ids: list[str] = [] - if policy is not None: - excluded = list( - dict.fromkeys( - [ - *policy.excludeFeatures, - *policy.artificialFeatures, - *( - reference.featureId - for reference in policy.exactControlFeatures - ), - *( - reference.featureName - for reference in policy.exactControlFeatures - ), - ] - ) - ) - evidence_ids = list(policy.evidenceIds) - if modality == "RNA": - graph_eligible = summary.total_features >= 3 - proposed_families = ( - list(policy.excludeFamilies) if policy is not None else [] - ) - return AssayPreprocessingPlan( - assay=assay_name, - assayType=summary.assay_type, - role="graph" if graph_eligible else "unsupported", - graphEligible=graph_eligible, - markerEligible=graph_eligible, - featureMethod="hvg" if graph_eligible else "none", - reductionMethod="pca" if graph_eligible else "none", - featureParameters={ - "topN": min(2000, summary.total_features), - "minCells": effective_min_cells, - "excludeFamilies": [], - "proposedExcludeFamilies": proposed_families, - "protectFamilies": ( - list(policy.protectFamilies) if policy is not None else [] - ), - "species": ( - inspection.species if inspection is not None else "unknown" - ), - "defaultFeatureInventory": ( - inspection.defaultFeatureInventory.model_dump(mode="json") - if inspection is not None - and inspection.defaultFeatureInventory is not None - else None - ), - }, - normalizationParameters={ - "logTransform": True, - "renormalizeSubset": True, - }, - reductionParameters={"dimensions": min(50, summary.total_features - 1)}, - exactExcludedFeatures=( - list(policy.artificialFeatures) if policy is not None else [] + del store, request_record + if modality != "RNA" or summary.assay_type != "RNA": + raise ValueError("Automated preprocessing supports RNA only") + evidence_ids = list(policy.evidenceIds) if policy is not None else [] + graph_eligible = summary.total_features >= 3 + proposed_families = list(policy.excludeFamilies) if policy is not None else [] + return AssayPreprocessingPlan( + assay=assay_name, + assayType=summary.assay_type, + role="graph" if graph_eligible else "unsupported", + graphEligible=graph_eligible, + markerEligible=graph_eligible, + featureMethod="hvg" if graph_eligible else "none", + reductionMethod="pca" if graph_eligible else "none", + featureParameters={ + "topN": min(2000, summary.total_features), + "minCells": effective_min_cells, + "excludeFamilies": [], + "proposedExcludeFamilies": proposed_families, + "protectFamilies": ( + list(policy.protectFamilies) if policy is not None else [] ), - evidenceIds=evidence_ids, - limitations=( - [] - if graph_eligible - else ["RNA requires at least three features for PCA"] + "species": ( + inspection.species if inspection is not None else "unknown" ), - ) - if modality == "ATAC": - graph_eligible = summary.total_features >= 3 - return AssayPreprocessingPlan( - assay=assay_name, - assayType=summary.assay_type, - role="graph" if graph_eligible else "unsupported", - graphEligible=graph_eligible, - markerEligible=graph_eligible, - featureMethod="prevalentPeaks" if graph_eligible else "none", - reductionMethod="lsi" if graph_eligible else "none", - featureParameters={"topN": min(25000, summary.total_features)}, - normalizationParameters={ - "logTransform": False, - "renormalizeSubset": False, - }, - reductionParameters={"dimensions": 50, "skipFirst": True}, - evidenceIds=evidence_ids, - limitations=list( - dict.fromkeys( - [ - *( - [] - if graph_eligible - else [ - "ATAC requires at least three peak features for LSI" - ] - ), - *( - [ - "ATAC feature coordinates are not uniformly " - "valid chrom:start-end intervals; the genome " - "build remains unknown" - ] - if policy is not None - and policy.peakCoordinateStatus - in {"partial", "invalid"} - else [] - ), - ] - ) + "defaultFeatureInventory": ( + inspection.defaultFeatureInventory.model_dump(mode="json") + if inspection is not None + and inspection.defaultFeatureInventory is not None + else None ), - ) - if modality == "ADT": - assay = store.get_assay(assay_name) - feature_ids = np.asarray(assay.feats.fetch_all("ids")).astype(str) - feature_names = np.asarray(assay.feats.fetch_all("names")).astype(str) - excluded = ( - list( - dict.fromkeys( - value - for reference in policy.exactControlFeatures - for value in (reference.featureId, reference.featureName) - if value - ) - ) - if policy is not None - else [] - ) - excluded_values = {value for value in excluded if value} - panel_mask = ~np.isin(feature_ids, list(excluded_values)) - panel_mask &= ~np.isin(feature_names, list(excluded_values)) - selected_count = int(panel_mask.sum()) - graph_eligible = selected_count >= 2 - reduction = ( - "identity" + }, + normalizationParameters={ + "logTransform": True, + "renormalizeSubset": True, + }, + reductionParameters={"dimensions": min(50, summary.total_features - 1)}, + exactExcludedFeatures=( + list(policy.artificialFeatures) if policy is not None else [] + ), + evidenceIds=evidence_ids, + limitations=( + [] if graph_eligible - and selected_count <= request_record.config.maxIdentityFeatures - else ("pca" if graph_eligible else "none") - ) - return AssayPreprocessingPlan( - assay=assay_name, - assayType=summary.assay_type, - role="graph" if graph_eligible else "unsupported", - graphEligible=graph_eligible, - markerEligible=graph_eligible, - featureMethod="panel" if graph_eligible else "none", - reductionMethod=cast(ReductionMethod, reduction), - normalizationParameters={ - "logTransform": False, - "renormalizeSubset": False, - }, - reductionParameters={ - "dimensions": ( - selected_count - if reduction == "identity" - else min(15, max(2, selected_count - 1)) - ) - }, - exactExcludedFeatures=excluded, - evidenceIds=evidence_ids, - limitations=( - [ - "ADT control inventory was truncated; only exact observed " - "control features were excluded" - ] - if inspection is not None and inspection.modalityEvidence.truncated - else [] - ), - ) - if modality == "HTO": - return AssayPreprocessingPlan( - assay=assay_name, - assayType=summary.assay_type, - role="hto", - graphEligible=False, - markerEligible=False, - featureMethod="none", - reductionMethod="none", - evidenceIds=evidence_ids, - ) - message = f"Unsupported assay {assay_name!r} ({summary.assay_type})" - return AssayPreprocessingPlan( - assay=assay_name, - assayType=summary.assay_type, - role="unsupported", - limitations=[message], + else ["RNA requires at least three features for PCA"] + ), ) def preprocessing_stage( @@ -1123,7 +911,30 @@ def preprocessing_stage( list[PreprocessedAssayHandoff], AutomatedPreprocessingPlan, ]: + selected = selected_store_rna_assay(store, request_record.request) + validate_rna_plan(plan, selected) + validate_rna_context(experimental, selected) prefix = journal._ensure_orchestration_store(store) + try: + candidate_budget = reserve_candidate_pass( + store, prefix, workflow, request_record, stage_name + ) + except ValueError as exc: + rejected = journal._start_attempt( + store.zw, + prefix, + workflow.workflowRunId, + stage_name, + request_record, + parents, + inputs={"candidateBudgetRejected": True}, + resume_record=resume_record, + ) + return ( + journal.finish_exception(store, prefix, workflow, rejected, exc), + [], + plan, + ) existing = journal._validated_done_outcome( store, prefix, @@ -1136,16 +947,16 @@ def preprocessing_stage( logger.info( f"Workflow {workflow.workflowRunId}: reusing preprocessing artifacts" ) - return ( - existing, - [ - PreprocessedAssayHandoff.model_validate(value) - for value in existing.outputs["assays"] - ], - AutomatedPreprocessingPlan.model_validate( - existing.outputs["resolvedPreprocessingPlan"] - ), + cached_handoffs = [ + PreprocessedAssayHandoff.model_validate(value) + for value in existing.outputs["assays"] + ] + cached_plan = AutomatedPreprocessingPlan.model_validate( + existing.outputs["resolvedPreprocessingPlan"] ) + validate_rna_plan(cached_plan, selected) + validate_rna_handoffs(cached_handoffs, selected) + return existing, cached_handoffs, cached_plan if plan.cellSelection is None: raise ValueError("Preprocessing plan lacks an exact cell selection") if experimental.cellSelection != plan.cellSelection: @@ -1163,6 +974,7 @@ def preprocessing_stage( inputs={ "preprocessingPlan": plan.model_dump(mode="json"), "cellSelection": plan.cellSelection.model_dump(mode="json"), + "candidateBudget": candidate_budget, }, resume_record=resume_record, ) @@ -1222,35 +1034,37 @@ def preprocessing_stage( "Executed cell-QC retention differs from the selected profile" ) logger.info( - f"Workflow {workflow.workflowRunId}: preprocessing retained " - f"{active_cells} active cell(s)" + f"QC: compared {len(experimental.qcProfiles)} policies; retained " + f"{active_cells:,}/{selected_profile.activeCells:,} cells " + f"({selected_profile.retainedFraction:.0%})." ) if active_cells < 3: raise ValueError("Preprocessing requires at least three active cells") handoffs: list[PreprocessedAssayHandoff] = [] - for assay_plan in plan.assays: - if not assay_plan.graphEligible: - continue - logger.info( - f"Workflow {workflow.workflowRunId}: preprocessing assay " - f"{assay_plan.assay!r} via {assay_plan.featureMethod}/" - f"{assay_plan.reductionMethod}" - ) - handoffs.append( - self.preprocess_assay( - store, - assay_plan, - cell_selection=cell_selection, - cell_selection_model=cell_selection_model, - active_cells=active_cells, - request_record=request_record, - study_contract=study_contract, - answers=answers, - actions=actions, - operations=operations, - artifacts=artifacts, + with candidate_metric_cache(): + for assay_plan in plan.assays: + if not assay_plan.graphEligible: + continue + logger.info( + f"Workflow {workflow.workflowRunId}: preprocessing assay " + f"{assay_plan.assay!r} via {assay_plan.featureMethod}/" + f"{assay_plan.reductionMethod}" + ) + handoffs.append( + self.preprocess_assay( + store, + assay_plan, + cell_selection=cell_selection, + cell_selection_model=cell_selection_model, + active_cells=active_cells, + request_record=request_record, + study_contract=study_contract, + answers=answers, + actions=actions, + operations=operations, + artifacts=artifacts, + ) ) - ) resolved_plan = self._plan_with_selected_hvg_counts( plan, handoffs, @@ -1663,10 +1477,10 @@ def preprocess_assay( qc_columns=[ value for value in ( - "RNA_nCounts", - "RNA_nFeatures", - "RNA_percentMito", - "RNA_percentRibo", + f"{assay_plan.assay}_nCounts", + f"{assay_plan.assay}_nFeatures", + f"{assay_plan.assay}_percentMito", + f"{assay_plan.assay}_percentRibo", ) if value in store.cells.columns ], @@ -2273,61 +2087,6 @@ def preprocess_assay( ), } ) - elif assay_plan.featureMethod == "prevalentPeaks": - actual_top_n = min( - int(assay_plan.featureParameters["topN"]), - assay.feats.N - 1, - ) - graph_features = store.select_prevalent_peaks( - cell_selection, - from_assay=assay_plan.assay, - top_n=actual_top_n, - invalidate_cache=False, - ) - marker_features = graph_features - actions.append(f"select_prevalent_peaks:{assay_plan.assay}") - operations.append( - { - "operation": "select_prevalent_peaks", - "assay": assay_plan.assay, - "cellSelection": cell_selection_model.model_dump(mode="json"), - "topN": actual_top_n, - "invalidateCache": False, - "artifact": ArtifactReferenceModel.from_artifact_ref( - graph_features - ).model_dump(mode="json"), - } - ) - elif assay_plan.featureMethod == "panel": - mask = np.ones(assay.feats.N, dtype=bool) - ids = np.asarray(assay.feats.fetch_all("ids")).astype(str) - names = np.asarray(assay.feats.fetch_all("names")).astype(str) - excluded = set(assay_plan.exactExcludedFeatures) - if excluded: - mask &= ~np.isin(ids, list(excluded)) - mask &= ~np.isin(names, list(excluded)) - if int(mask.sum()) < 2: - raise ValueError( - f"ADT assay {assay_plan.assay!r} has fewer than two non-control features" - ) - graph_features = store.set_feature_selection( - from_assay=assay_plan.assay, - mask=mask, - invalidate_cache=False, - ) - marker_features = graph_features - actions.append(f"select_adt_panel:{assay_plan.assay}") - operations.append( - { - "operation": "set_feature_selection", - "assay": assay_plan.assay, - "selectedFeatures": int(mask.sum()), - "exactExcludedFeatures": sorted(excluded), - "artifact": ArtifactReferenceModel.from_artifact_ref( - graph_features - ).model_dump(mode="json"), - } - ) else: raise ValueError(f"Unsupported feature route {assay_plan.featureMethod!r}") normalized = ( diff --git a/scarf/agent/orchestrator/rna.py b/scarf/agent/orchestrator/rna.py new file mode 100644 index 00000000..9e096cf2 --- /dev/null +++ b/scarf/agent/orchestrator/rna.py @@ -0,0 +1,187 @@ +"""Supported assay selection for the automated RNA workflow.""" + +from collections.abc import Mapping, Sequence +from typing import Any + + +def validate_rna_directions(directions: Mapping[str, Any]) -> None: + """Reject automated operations outside the RNA analysis contract.""" + if "hypothesisTesting" in directions: + raise ValueError( + "Automated hypothesis testing is not supported; run statistical " + "analysis separately after the RNA workflow." + ) + + +def validate_rna_request_fields(request: Any) -> None: + """Validate routing that does not require inspecting the input store.""" + if len(request.analysisAssays) > 1: + raise ValueError("analysisAssays must select at most one RNA assay") + if request.pairedAssays: + raise ValueError("pairedAssays is unsupported by the single-RNA workflow") + selected = request.analysisAssays[0] if request.analysisAssays else None + if selected is not None and request.primaryAssay not in {None, selected}: + raise ValueError("primaryAssay must match the selected analysisAssays entry") + selected = selected or request.primaryAssay + if selected is not None and request.markerAssay not in {None, selected}: + raise ValueError("markerAssay must match the selected RNA assay") + validate_rna_directions(request.experimentalDirections) + + +def selected_rna_assay(request: Any, assay_types: Mapping[str, str]) -> str: + """Resolve one explicit or uniquely available persisted RNA assay.""" + validate_rna_request_fields(request) + selected = ( + request.analysisAssays[0] if request.analysisAssays else request.primaryAssay + ) + if selected is None: + rna_assays = [ + name for name, assay_type in assay_types.items() if assay_type == "RNA" + ] + if len(rna_assays) != 1: + raise ValueError( + "The automated workflow requires one RNA assay; " + f"found {len(rna_assays)}. Select one with primaryAssay or " + "analysisAssays when multiple RNA assays are present." + ) + selected = rna_assays[0] + if selected not in assay_types: + raise ValueError(f"Unknown requested RNA assay {selected!r}") + if assay_types[selected] != "RNA": + raise ValueError( + f"Selected assay {selected!r} has type {assay_types[selected]!r}; " + "the automated workflow supports RNA only." + ) + if request.markerAssay not in {None, selected}: + raise ValueError("markerAssay must match the selected RNA assay") + return str(selected) + + +def selected_store_rna_assay(store: Any, request: Any) -> str: + """Resolve the workflow assay from the store's persisted summary.""" + return selected_rna_assay( + request, + {value.name: value.assay_type for value in store.summary().assays}, + ) + + +def validate_rna_plan(plan: Any, selected: str) -> None: + """Reject a stale or unsupported cached preprocessing route.""" + if ( + len(plan.assays) != 1 + or plan.assays[0].assay != selected + or plan.assays[0].assayType != "RNA" + or not plan.assays[0].graphEligible + or plan.primaryAssay != selected + or plan.markerAssay != selected + or plan.pairedAssays + ): + raise ValueError( + "Preprocessing must contain only the selected RNA assay. " + "Start a new workflow for incompatible saved analysis state." + ) + + +def validate_rna_context(report: Any, selected: str) -> None: + """Keep reused QC evidence on the selected RNA assay.""" + if report.htoIdentityArtifacts: + raise ValueError( + "Saved automatic HTO processing is unsupported; start a new RNA workflow." + ) + for profile in report.qcProfiles: + if profile.driverAssay != selected or profile.driverAssayType != "RNA": + raise ValueError( + "QC evidence must use the selected RNA assay; start a new workflow " + "for incompatible saved QC state." + ) + if report.cellQc.driverAssay not in {None, selected}: + raise ValueError("Cell QC must use the selected RNA assay") + + +def validate_rna_handoffs(handoffs: Sequence[Any], selected: str) -> None: + """Keep saved preprocessing on the selected persisted RNA modality.""" + if ( + len(handoffs) != 1 + or handoffs[0].assay != selected + or handoffs[0].assayType != "RNA" + ): + raise ValueError( + "Saved preprocessing must contain only the selected RNA assay; " + "start a new workflow for incompatible saved analysis state." + ) + + +def validate_saved_rna_history( + root: Any, prefix: str, workflow_run_id: str, selected: str +) -> None: + """Reject incompatible saved routes before resume opens the store for writes.""" + from ..persistence.reports import load_agent_report + from . import journal + from .models import ( + _STAGE_ORDER, + AutomatedPreprocessingPlan, + PreprocessedAssayHandoff, + ) + + loaded_reports: set[tuple[str, str]] = set() + for stage in _STAGE_ORDER: + for outcome in journal._stage_outcomes(root, prefix, workflow_run_id, stage): + if outcome.outputs.get("htoIdentityArtifacts"): + raise ValueError( + "Saved automatic HTO processing is unsupported; start a new RNA workflow." + ) + for name in ("preprocessingPlan", "resolvedPreprocessingPlan"): + if outcome.outputs.get(name): + validate_rna_plan( + AutomatedPreprocessingPlan.model_validate( + outcome.outputs[name] + ), + selected, + ) + if stage in {"preprocessing", "feature_policy_preprocessing"} and ( + "assays" in outcome.outputs + ): + validate_rna_handoffs( + [ + PreprocessedAssayHandoff.model_validate(value) + for value in outcome.outputs["assays"] + ], + selected, + ) + for reference in outcome.reportReferences: + if reference.agentName not in { + "data_enrichment", + "experimental_context", + "parameter_tuning", + }: + continue + identity = (reference.agentName, reference.agentRunId) + if identity in loaded_reports: + continue + loaded_reports.add(identity) + report = load_agent_report(root, reference) + if report.status != "done": + continue + if reference.agentName == "data_enrichment": + policies = getattr(report, "policies", []) + if ( + len(policies) != 1 + or policies[0].assay != selected + or policies[0].assayModality != "RNA" + ): + raise ValueError( + "Saved enrichment includes unsupported assays; start a new RNA workflow." + ) + elif reference.agentName == "experimental_context": + validate_rna_context(report, selected) + elif reference.agentName == "parameter_tuning": + assays = getattr(report, "assayReports", {}) + if ( + getattr(report, "recommendedIntegrationId", None) is not None + or set(assays) - {selected} + or getattr(report, "fromAssay", selected) != selected + ): + raise ValueError( + "Saved tuning includes unsupported assays or integration; " + "start a new RNA workflow." + ) diff --git a/scarf/agent/orchestrator/tuning.py b/scarf/agent/orchestrator/tuning.py index eccd2773..e03ee991 100644 --- a/scarf/agent/orchestrator/tuning.py +++ b/scarf/agent/orchestrator/tuning.py @@ -1,4 +1,4 @@ -"""Parameter tuning and multimodal integration workflow stages.""" +"""Sequential RNA parameter tuning and review stages.""" import hashlib import io @@ -9,7 +9,6 @@ import numpy as np from pydantic import Field from pydantic_ai.exceptions import AgentRunError -from sklearn.metrics import adjusted_rand_score, normalized_mutual_info_score from ...datastore.datastore import DataStore from ...metadata.rows import read_metadata_rows_chunkwise @@ -47,15 +46,8 @@ from ..experimental_context.study import StudyContract from ..parameter_tuning.agent import ParameterTuningAgent from ..parameter_tuning.contracts import ( - ArtifactRecord, - FinalGraphComparison, - FinalGraphSelection, - IntegrationCandidateEvaluation, - IntegrationMetrics, - ParameterCandidate, ParameterCandidateEvaluation, ParameterSearchPlan, - ParameterTuningAssayInput, ParameterTuningDependencies, ParameterTuningReport, ) @@ -63,8 +55,13 @@ SCARF_DEFAULT_DIAGNOSTIC_FAMILIES, augment_cluster_evaluations, augment_pca_evaluations, + restore_advisory_doublets, score_advisory_doublets, ) +from ..parameter_tuning.execution import ( + _metadata_column_fingerprint, + candidate_metric_cache, +) from ..parameter_tuning.prompts import ( parameter_search_prompt, parameter_search_system_prompt, @@ -72,10 +69,9 @@ parameter_tuning_system_prompt, ) from ..parameter_tuning.selection import ( - final_graph_options, + harmony_acceptance_gate, finalize_parameter_tuning_selection, pending_parameter_tuning_report, - validate_final_graph_selection, validate_parameter_tuning_report, ) from ..parameter_tuning.sequential import ( @@ -94,16 +90,9 @@ ) from ..persistence.contracts import ( AgentInvocation, - AgentReportLink, AgentReportReference, AgentWorkflowRun, ) -from ..persistence.reports import ( - list_agent_reports, - load_agent_record, - load_agent_report, - save_agent_report, -) from ..types import AgentDataModel, ArtifactReferenceModel, ExperimentalTuningHandoff from . import journal from .decisions import DecisionResolution, DecisionStagesMixin @@ -128,216 +117,15 @@ def _bounded_evidence_summary(summary: str) -> str: return summary if len(summary) <= 2_000 else f"{summary[:1_997].rstrip()}..." -def harmony_acceptance_gate( - native: ParameterCandidateEvaluation | None, - harmony: ParameterCandidateEvaluation | None, - *, - batch_columns: Sequence[str], - protected_columns: Sequence[str], - independent_unit_columns: Sequence[str], - tolerance: float = 0.05, - require_doublet_evidence: bool = False, -) -> tuple[bool, list[str]]: - """Require measured batch improvement without material biological loss.""" - if tolerance < 0 or not np.isfinite(tolerance): - raise ValueError("Harmony gate tolerance must be finite and non-negative") - reasons: list[str] = [] - if native is None or harmony is None: - return False, ["Matched native and Harmony candidates are unavailable."] - if native.status != "done" or not native.eligible: - reasons.append("The matched native candidate is not an eligible execution.") - if harmony.status != "done" or not harmony.eligible: - reasons.append("The matched Harmony candidate is not an eligible execution.") - if native.parameters.useHarmony or not harmony.parameters.useHarmony: - reasons.append("Candidates do not have native and Harmony correction modes.") - native_parameters = native.parameters.model_dump( - mode="json", - exclude={"candidateId", "useHarmony"}, - ) - harmony_parameters = harmony.parameters.model_dump( - mode="json", - exclude={"candidateId", "useHarmony"}, - ) - if native_parameters != harmony_parameters: - reasons.append("Native and Harmony candidate parameters are not matched.") - if native.cellSelection != harmony.cellSelection: - reasons.append("Native and Harmony candidates use different cell selections.") - if not batch_columns: - reasons.append("No approved batch metric was supplied.") - batch_deltas: dict[str, float] = {} - for column in batch_columns: - native_score = native.metrics.batchMixing.get(column) - harmony_score = harmony.metrics.batchMixing.get(column) - if native_score is None or harmony_score is None: - reasons.append(f"Batch comparison is missing for {column!r}.") - continue - batch_deltas[column] = harmony_score - native_score - if len(batch_deltas) != len(batch_columns): - reasons.append("Not every approved batch metric was compared.") - elif not any(delta > tolerance for delta in batch_deltas.values()): - reasons.append( - "Harmony did not improve an approved batch metric beyond tolerance." - ) - if any(delta < -tolerance for delta in batch_deltas.values()): - reasons.append("Harmony materially worsened an approved batch metric.") - - for column in protected_columns: - native_scores = native.metrics.biologicalPreservation.get(column) - harmony_scores = harmony.metrics.biologicalPreservation.get(column) - if not native_scores or not harmony_scores: - reasons.append(f"Protected comparison is missing for {column!r}.") - continue - missing_metrics = set(native_scores).difference(harmony_scores) - if missing_metrics: - reasons.append( - f"Harmony is missing protected metrics for {column!r}: " - f"{sorted(missing_metrics)}." - ) - continue - shared = set(native_scores).intersection(harmony_scores) - if not shared: - reasons.append(f"Protected metrics do not align for {column!r}.") - continue - if any( - harmony_scores[name] < native_scores[name] - tolerance for name in shared - ): - reasons.append( - f"Harmony materially degraded protected evidence for {column!r}." - ) - if independent_unit_columns: - if ( - native.metrics.crossUnitSupport is None - or harmony.metrics.crossUnitSupport is None - ): - reasons.append("Cross-unit support comparison is missing.") - elif ( - harmony.metrics.crossUnitSupport - < native.metrics.crossUnitSupport - tolerance - ): - reasons.append("Harmony materially degraded cross-unit support.") - if ( - native.metrics.markerCoherence is None - or harmony.metrics.markerCoherence is None - ): - reasons.append("Marker-coherence comparison is missing.") - elif harmony.metrics.markerCoherence < native.metrics.markerCoherence - tolerance: - reasons.append("Harmony materially degraded marker coherence.") - for label, native_value, harmony_value in ( - ( - "marker specificity", - native.metrics.markerSpecificityMedian, - harmony.metrics.markerSpecificityMedian, - ), - ( - "cluster connectivity", - native.metrics.clusterConnectivity, - harmony.metrics.clusterConnectivity, - ), - ( - "membership strength", - native.metrics.membershipStrengthMean, - harmony.metrics.membershipStrengthMean, - ), - ): - if native_value is None and harmony_value is None: - continue - if native_value is None or harmony_value is None: - reasons.append(f"Matched {label} comparison is missing.") - elif harmony_value < native_value - tolerance: - reasons.append(f"Harmony materially degraded {label}.") - native_doublet = native.metrics.doubletHighScoreConcentration - harmony_doublet = harmony.metrics.doubletHighScoreConcentration - if ( - require_doublet_evidence - or native_doublet is not None - or harmony_doublet is not None - ): - if native_doublet is None or harmony_doublet is None: - reasons.append("Matched doublet-concentration comparison is missing.") - elif harmony_doublet > native_doublet + tolerance: - reasons.append("Harmony materially concentrated advisory doublet scores.") - return not reasons, reasons - - -def enforce_harmony_acceptance( - report: ParameterTuningReport, - *, - batch_columns: Sequence[str], - protected_columns: Sequence[str], - independent_unit_columns: Sequence[str], - selectable: bool, -) -> ParameterTuningReport: - """Prevent an unlicensed or unsupported Harmony branch from promotion.""" - updated_reports: dict[str, ParameterTuningReport] = {} - recommended = dict(report.recommendedByAssay) - for assay, assay_report in report.assayReports.items(): - selected = next( - ( - evaluation - for evaluation in assay_report.evaluations - if evaluation.candidateId == assay_report.recommendedCandidateId - ), - None, - ) - if selected is None or not selected.parameters.useHarmony: - updated_reports[assay] = assay_report - continue - selected_parameters = selected.parameters.model_dump( - mode="json", - exclude={"candidateId", "useHarmony"}, - ) - native = next( - ( - evaluation - for evaluation in assay_report.evaluations - if not evaluation.parameters.useHarmony - and evaluation.status == "done" - and evaluation.eligible - and evaluation.parameters.model_dump( - mode="json", - exclude={"candidateId", "useHarmony"}, - ) - == selected_parameters - ), - None, - ) - accepted, reasons = harmony_acceptance_gate( - native, - selected, - batch_columns=batch_columns, - protected_columns=protected_columns, - independent_unit_columns=independent_unit_columns, - require_doublet_evidence=True, - ) - if selectable and accepted: - updated_reports[assay] = assay_report - continue - if native is None: - raise ValueError( - f"Harmony recommendation for assay {assay!r} lacks a matched " - "eligible native candidate" - ) - reason = ( - "Harmony was diagnostic-only." - if not selectable - else f"Harmony did not pass acceptance: {reasons}." - ) - updated_reports[assay] = assay_report.model_copy( - update={ - "recommendedCandidateId": native.candidateId, - "selectedArtifacts": dict(native.artifacts), - "evidenceIds": list( - dict.fromkeys([*assay_report.evidenceIds, *native.evidenceIds]) - ), - "tradeoffs": [*assay_report.tradeoffs, reason], - } +def _stable_phase_evaluations( + evaluations: Sequence[ParameterCandidateEvaluation], +) -> tuple[ParameterCandidateEvaluation, ...]: + """Give fresh and restored evidence the same persisted mapping order.""" + return tuple( + ParameterCandidateEvaluation.model_validate_json( + record_io.canonical_json_bytes(evaluation.model_dump(mode="json")) ) - recommended[assay] = native.candidateId - return report.model_copy( - update={ - "assayReports": updated_reports, - "recommendedByAssay": recommended, - } + for evaluation in evaluations ) @@ -1715,156 +1503,6 @@ def _phase_from_resolution( ) return validate_parameter_phase_selection(plan, evaluations, selection) - @staticmethod - def _augment_legacy_scientific_evidence( - store: DataStore, - report: ParameterTuningReport, - *, - plan: AutomatedPreprocessingPlan, - preprocessed: Sequence[PreprocessedAssayHandoff], - study_contract: StudyContract | None, - ) -> ParameterTuningReport: - handoff_by_assay = {value.assay: value for value in preprocessed} - plan_by_assay = {value.assay: value for value in plan.assays} - updated_reports: dict[str, ParameterTuningReport] = {} - for assay, assay_report in report.assayReports.items(): - handoff = handoff_by_assay[assay] - assay_plan = plan_by_assay[assay] - diagnostic_batch_columns = ( - [ - column - for column in dict.fromkeys( - ( - study_contract.physicalCaptureColumn, - *study_contract.technicalBatchColumns, - ) - ) - if column is not None - ] - if study_contract is not None - else [] - ) - if handoff.graphFeatures is None or handoff.markerFeatures is None: - raise ValueError( - f"Assay {assay!r} lacks feature selections for diagnostics" - ) - nominated_families = cast( - list[str], - assay_plan.featureParameters.get( - "proposedExcludeFamilies", - [], - ), - ) - diagnostic_families = list( - dict.fromkeys( - [ - *SCARF_DEFAULT_DIAGNOSTIC_FAMILIES, - *nominated_families, - ] - ) - ) - protected_families = cast( - list[str], - assay_plan.featureParameters.get("protectFamilies", []), - ) - pca_augmented = list( - augment_pca_evaluations( - store, - assay_report.evaluations, - feature_selection=artifact_model_to_ref(handoff.graphFeatures), - nominated_families=diagnostic_families, - protected_families=protected_families, - technical_columns=diagnostic_batch_columns, - batch_columns=diagnostic_batch_columns, - protected_columns=( - study_contract.protectedColumns - if study_contract is not None - else [] - ), - qc_columns=[ - column - for column in plan.cellQc.attributes - if column in store.cells.columns - ], - ) - ) - selected = next( - ( - evaluation - for evaluation in pca_augmented - if evaluation.candidateId == assay_report.recommendedCandidateId - ), - None, - ) - if selected is None: - raise ValueError( - f"Assay {assay!r} lacks its recommended candidate execution" - ) - native = next( - ( - evaluation - for evaluation in pca_augmented - if not evaluation.parameters.useHarmony - and evaluation.status == "done" - and evaluation.eligible - ), - None, - ) - if native is None: - raise ValueError( - f"Assay {assay!r} lacks an eligible native doublet baseline" - ) - doublet_evidence = score_advisory_doublets( - store, - native, - pca_augmented, - assay=assay, - feature_selection=artifact_model_to_ref(handoff.graphFeatures), - capture_column=( - study_contract.physicalCaptureColumn - if study_contract is not None - else None - ), - ) - augmented = list( - augment_cluster_evaluations( - store, - pca_augmented, - marker_assay=plan.markerAssay, - marker_features=artifact_model_to_ref(handoff.markerFeatures), - independent_unit_columns=( - study_contract.independentUnitColumns - if study_contract is not None - else [] - ), - technical_columns=diagnostic_batch_columns, - nominated_families=diagnostic_families, - protected_families=protected_families, - doublet_evidence=doublet_evidence, - ) - ) - augmented_selected = next( - value - for value in augmented - if value.candidateId == selected.candidateId - ) - updated_reports[assay] = assay_report.model_copy( - update={ - "evaluations": augmented, - "selectedArtifacts": dict(augmented_selected.artifacts), - } - ) - root_updates: dict[str, Any] = {"assayReports": updated_reports} - if report.fromAssay in updated_reports: - primary = updated_reports[report.fromAssay] - root_updates.update( - { - "evaluations": list(primary.evaluations), - "selectedArtifacts": dict(primary.selectedArtifacts), - } - ) - return report.model_copy(update=root_updates) - def _run_sequential_rna_tuning( self, store: DataStore, @@ -1890,6 +1528,7 @@ def _run_sequential_rna_tuning( raise ValueError("Decision-driven v1 tuning requires normalized RNA") if prior is not None and prior.assay != handoff.assay: raise ValueError("Persisted sequential evidence belongs to another assay") + selected_cells = artifact_model_to_ref(handoff.cellSelection) prior_phases = ( {value.plan.phase: value for value in prior.phases} if prior is not None @@ -1908,6 +1547,17 @@ def phase_evaluations( f"Persisted {phase_plan.phase!r} plan differs from the " "current registered plan" ) + for evaluation in persisted.evaluations: + if evaluation.cellSelection is not None and ( + artifact_model_to_ref(evaluation.cellSelection) != selected_cells + ): + raise ValueError("Persisted tuning evidence uses different cells") + for artifact in evaluation.artifacts.values(): + status = store.inspect_artifact(artifact_model_to_ref(artifact)) + if not status.exists or not status.complete: + raise ValueError( + "Persisted tuning evidence contains unavailable artifacts" + ) logger.info( f"Workflow {workflow.workflowRunId}: reusing persisted " f"{phase_plan.phase} executor evidence" @@ -2057,34 +1707,36 @@ def return_pending( identity_feature_limit=request_record.config.maxIdentityFeatures, ), ) - raw_pca = augment_pca_evaluations( - store, - raw_pca, - feature_selection=artifact_model_to_ref(handoff.graphFeatures), - nominated_families=diagnostic_families, - protected_families=protected_families, - technical_columns=diagnostic_batch_columns, - batch_columns=diagnostic_batch_columns, - protected_columns=study_contract.protectedColumns, - qc_columns=[ - column - for column in plan.cellQc.attributes - if column in store.cells.columns - ], - ) + if "pcaPrefix" not in prior_phases: + raw_pca = augment_pca_evaluations( + store, + raw_pca, + feature_selection=artifact_model_to_ref(handoff.graphFeatures), + nominated_families=diagnostic_families, + protected_families=protected_families, + technical_columns=diagnostic_batch_columns, + batch_columns=diagnostic_batch_columns, + protected_columns=study_contract.protectedColumns, + qc_columns=[ + column + for column in plan.cellQc.attributes + if column in store.cells.columns + ], + ) pca_items: list[DecisionEvidence] = [] pca_evaluations: list[ParameterCandidateEvaluation] = [] eligible_pca_dimensions: list[int] = [] pca_evidence_by_dimensions: dict[int, list[str]] = {} - for evaluation in raw_pca: + for evaluation in _stable_phase_evaluations(raw_pca): evidence_ids: list[str] = [] if evaluation.status == "done" and evaluation.eligible: eligible_pca_dimensions.append(evaluation.parameters.dimensions) technical_id = f"evidence:pca:{evaluation.candidateId}:technical" loading_preview = { component: genes[:3] - for component, genes in list( - evaluation.metrics.topLoadingGenes.items() + for component, genes in sorted( + evaluation.metrics.topLoadingGenes.items(), + key=lambda item: int(item[0].removeprefix("PC")), )[:10] } cumulative_variance = ( @@ -2432,31 +2084,33 @@ def return_pending( ), None, ) - correction_doublets = ( - score_advisory_doublets( - store, - correction_native, - correction_evaluations, - assay=handoff.assay, - feature_selection=artifact_model_to_ref(handoff.graphFeatures), - capture_column=study_contract.physicalCaptureColumn, + if "batchCorrection" not in prior_phases: + correction_doublets = ( + score_advisory_doublets( + store, + correction_native, + correction_evaluations, + assay=handoff.assay, + feature_selection=artifact_model_to_ref(handoff.graphFeatures), + capture_column=study_contract.physicalCaptureColumn, + ) + if correction_native is not None + else None ) - if correction_native is not None - else None - ) - correction_evaluations = list( - augment_cluster_evaluations( - store, - correction_evaluations, - marker_assay=plan.markerAssay, - marker_features=artifact_model_to_ref(handoff.markerFeatures), - independent_unit_columns=study_contract.independentUnitColumns, - technical_columns=diagnostic_batch_columns, - nominated_families=diagnostic_families, - protected_families=protected_families, - doublet_evidence=correction_doublets, + correction_evaluations = list( + augment_cluster_evaluations( + store, + correction_evaluations, + marker_assay=plan.markerAssay, + marker_features=artifact_model_to_ref(handoff.markerFeatures), + independent_unit_columns=study_contract.independentUnitColumns, + technical_columns=diagnostic_batch_columns, + nominated_families=diagnostic_families, + protected_families=protected_families, + doublet_evidence=correction_doublets, + ) ) - ) + correction_evaluations = list(_stable_phase_evaluations(correction_evaluations)) native_evaluation = next( ( evaluation @@ -2700,7 +2354,12 @@ def return_pending( None, ) graph_doublet_evidence = ( - score_advisory_doublets( + restore_advisory_doublets( + graph_doublet_reference, + capture_column=study_contract.physicalCaptureColumn, + ) + if "graphK" in prior_phases and graph_doublet_reference is not None + else score_advisory_doublets( store, graph_doublet_reference, raw_graph, @@ -2711,22 +2370,23 @@ def return_pending( if graph_doublet_reference is not None else None ) - raw_graph = augment_cluster_evaluations( - store, - raw_graph, - marker_assay=plan.markerAssay, - marker_features=artifact_model_to_ref(handoff.markerFeatures), - independent_unit_columns=study_contract.independentUnitColumns, - technical_columns=diagnostic_batch_columns, - nominated_families=diagnostic_families, - protected_families=protected_families, - doublet_evidence=graph_doublet_evidence, - ) + if "graphK" not in prior_phases: + raw_graph = augment_cluster_evaluations( + store, + raw_graph, + marker_assay=plan.markerAssay, + marker_features=artifact_model_to_ref(handoff.markerFeatures), + independent_unit_columns=study_contract.independentUnitColumns, + technical_columns=diagnostic_batch_columns, + nominated_families=diagnostic_families, + protected_families=protected_families, + doublet_evidence=graph_doublet_evidence, + ) graph_items: list[DecisionEvidence] = [] graph_evaluations: list[ParameterCandidateEvaluation] = [] eligible_graph_values: list[int] = [] graph_evidence_by_k: dict[int, list[str]] = {} - for evaluation in raw_graph: + for evaluation in _stable_phase_evaluations(raw_graph): graph_extra: list[str] = [] if evaluation.status == "done" and evaluation.eligible: eligible_graph_values.append(evaluation.parameters.neighborsK) @@ -2903,21 +2563,9 @@ def return_pending( doublet_evidence = graph_doublet_evidence cluster_plan = planner.clustering_phase(selected_graph.parameters) persisted_cluster = prior_phases.get(cluster_plan.phase) - if persisted_cluster is not None: - if persisted_cluster.plan != cluster_plan: - raise ValueError( - "Persisted clusteringResolution plan differs from the " - "current registered plan" - ) - logger.info( - f"Workflow {workflow.workflowRunId}: reusing persisted " - "clusteringResolution executor evidence" - ) - raw_clusters: Sequence[ParameterCandidateEvaluation] = ( - persisted_cluster.evaluations - ) - else: - raw_clusters = execute_parameter_phase( + raw_clusters = phase_evaluations( + cluster_plan, + lambda: execute_parameter_phase( store, normalized=normalized, plan=cluster_plan, @@ -2930,26 +2578,30 @@ def return_pending( experimental_handoff=experimental_handoff, min_cluster_cells=request_record.config.minClusterCells, identity_feature_limit=request_record.config.maxIdentityFeatures, - ) - cluster_evaluations = list( - augment_cluster_evaluations( - store, - raw_clusters, - marker_assay=plan.markerAssay, - marker_features=artifact_model_to_ref(handoff.markerFeatures), - independent_unit_columns=study_contract.independentUnitColumns, - technical_columns=diagnostic_batch_columns, - nominated_families=diagnostic_families, - protected_families=protected_families, - doublet_evidence=doublet_evidence, - ) + ), ) + if persisted_cluster is not None: + cluster_evaluations = list(raw_clusters) + else: + cluster_evaluations = list( + augment_cluster_evaluations( + store, + raw_clusters, + marker_assay=plan.markerAssay, + marker_features=artifact_model_to_ref(handoff.markerFeatures), + independent_unit_columns=study_contract.independentUnitColumns, + technical_columns=diagnostic_batch_columns, + nominated_families=diagnostic_families, + protected_families=protected_families, + doublet_evidence=doublet_evidence, + ) + ) cluster_items: list[DecisionEvidence] = [] scored: list[tuple[float, float, ParameterCandidateEvaluation]] = [] eligible_cluster_values: list[float] = [] augmented_clusters: list[ParameterCandidateEvaluation] = [] cluster_evidence_by_resolution: dict[float, list[str]] = {} - for evaluation in cluster_evaluations: + for evaluation in _stable_phase_evaluations(cluster_evaluations): cluster_extra: list[str] = [] if evaluation.status == "done" and evaluation.eligible: marker_auc_preview = dict( @@ -4584,6 +4236,38 @@ def parameter_tuning_stage( stage_name: WorkflowStageName = "parameter_tuning", ) -> tuple[WorkflowStageAttempt, ParameterTuningReport]: prefix = journal._ensure_orchestration_store(store) + cell_selection = preprocessed[0].cellSelection if preprocessed else None + if cell_selection is None or any( + value.cellSelection != cell_selection for value in preprocessed + ): + raise ValueError("Preprocessed assays must share one exact cell selection") + experimental_handoff = experimental.to_parameter_tuning_handoff().model_copy( + update={"cellSelection": cell_selection} + ) + metadata_columns = { + *experimental_handoff.batchColumns, + *experimental_handoff.preservationColumns, + *plan.cellQc.attributes, + } + if study_contract is not None: + metadata_columns.update(study_contract.technicalBatchColumns) + metadata_columns.update(study_contract.protectedColumns) + metadata_columns.update(study_contract.independentUnitColumns) + if study_contract.physicalCaptureColumn is not None: + metadata_columns.add(study_contract.physicalCaptureColumn) + metadata_fingerprints = { + column: ( + _metadata_column_fingerprint(store.cells, column) + if column in store.cells.columns + else None + ) + for column in sorted(metadata_columns) + } + feature_metadata = store.get_assay(plan.primaryAssay).feats + feature_metadata_fingerprints = { + column: _metadata_column_fingerprint(feature_metadata, column) + for column in ("ids", "names") + } existing = journal._validated_done_outcome( store, prefix, @@ -4593,8 +4277,17 @@ def parameter_tuning_stage( parents, ) if existing is not None: - logger.info( - f"Workflow {workflow.workflowRunId}: reusing Parameter Tuning report" + if ( + existing.inputs.get("metadataFingerprints") != metadata_fingerprints + or existing.inputs.get("featureMetadataFingerprints") + != feature_metadata_fingerprints + ): + raise ValueError( + "Tuning metadata changed since the saved evidence was computed; " + "restore the original metadata or start a new workflow" + ) + logger.info( + f"Workflow {workflow.workflowRunId}: reusing Parameter Tuning report" ) report = journal.load_stage_report(store, existing, ParameterTuningReport) return existing, cast(ParameterTuningReport, report) @@ -4607,46 +4300,21 @@ def parameter_tuning_stage( parents, required_status="needsInput", ) - resumable_report: ParameterTuningReport | None = None prior_sequential: SequentialAssayTuningEvidence | None = None - if paused is not None and paused.reportReferences: - loaded = journal.load_stage_report(store, paused, ParameterTuningReport) - candidate_report = cast(ParameterTuningReport, loaded) - if paused.outputs.get("sequentialEvidence") is not None: - prior_sequential = SequentialAssayTuningEvidence.model_validate( - paused.outputs["sequentialEvidence"] - ) - if ( - candidate_report.finalSelection is not None - and candidate_report.finalSelection.status == "needsInput" - and candidate_report.assayReports - ): - resumable_report = candidate_report - cell_selection = preprocessed[0].cellSelection if preprocessed else None - if cell_selection is None or any( - value.cellSelection != cell_selection for value in preprocessed + if paused is not None and paused.outputs.get("sequentialEvidence") is not None: + prior_sequential = SequentialAssayTuningEvidence.model_validate( + paused.outputs["sequentialEvidence"] + ) + if paused is not None and ( + paused.inputs.get("metadataFingerprints") != metadata_fingerprints + or paused.inputs.get("featureMetadataFingerprints") + != feature_metadata_fingerprints ): - raise ValueError("Preprocessed assays must share one exact cell selection") - experimental_handoff = experimental.to_parameter_tuning_handoff().model_copy( - update={"cellSelection": cell_selection} - ) - tuning_answer = answers.get("parameter_tuning") - if isinstance(tuning_answer, Mapping): - tuning_directions = json.dumps( - dict(tuning_answer), - sort_keys=True, + raise ValueError( + "Tuning metadata changed since the saved evidence was computed; " + "restore the original metadata or start a new workflow" ) - elif isinstance(tuning_answer, str): - tuning_directions = tuning_answer.strip() - else: - tuning_directions = "" - objective_direction = ( - "Authoritative study objective: " - f"{request_record.request.studyObjective.strip()}" - ) - tuning_directions = "\n".join( - value for value in (objective_direction, tuning_directions) if value - ) + tuning_answer = answers.get("parameter_tuning") started = journal._start_attempt( store.zw, prefix, @@ -4668,18 +4336,18 @@ def parameter_tuning_stage( "finalGraphOptionId": answers.get("finalGraphOptionId"), "parameterTuning": tuning_answer, "studyObjective": request_record.request.studyObjective, + "metadataFingerprints": metadata_fingerprints, + "featureMetadataFingerprints": feature_metadata_fingerprints, "resumeFromAttempt": (paused.attemptId if paused is not None else None), }, resume_record=resume_record, ) report = ParameterTuningReport.get_blank() actions: list[str] = [] - integration_evaluations: list[IntegrationCandidateEvaluation] = [] candidate_payload: dict[str, list[dict[str, Any]]] = {} - paired = list(plan.pairedAssays) logger.info( f"Workflow {workflow.workflowRunId}: Parameter Tuning started for " - f"{len(preprocessed)} assay(s), paired={len(paired)}" + f"{len(preprocessed)} RNA assay(s)" ) try: agent = ParameterTuningAgent( @@ -4699,7 +4367,10 @@ def parameter_tuning_stage( if recovered is not None: recovered_report, recovered_reference = recovered report = cast(ParameterTuningReport, recovered_report) - integration_evaluations = list(report.integrationEvaluations) + if report.integrationEvaluations or report.recommendedIntegrationId: + raise ValueError( + "Saved tuning report contains unsupported integration" + ) candidate_payload = { assay: [ evaluation.parameters.model_dump(mode="json") @@ -4721,9 +4392,7 @@ def parameter_tuning_stage( report, plan, preprocessed, - integration_evaluations, candidate_payload, - paired, enrichment_reference, experimental_reference, experimental_handoff, @@ -4731,11 +4400,11 @@ def parameter_tuning_stage( actions, persisted_reference=recovered_reference, ) - if len(preprocessed) == 1 and not plan.pairedAssays: - if study_contract is None: - raise ValueError( - "Decision-driven RNA tuning requires a StudyContract" - ) + if len(preprocessed) != 1 or plan.pairedAssays: + raise ValueError("Automated parameter tuning requires one RNA assay") + if study_contract is None: + raise ValueError("Decision-driven RNA tuning requires a StudyContract") + with candidate_metric_cache(): report, sequential_evidence = self._run_sequential_rna_tuning( store, workflow, @@ -4747,299 +4416,20 @@ def parameter_tuning_stage( answers, prior_sequential, ) - candidate_payload = { - sequential_evidence.assay: [ - evaluation.parameters.model_dump(mode="json") - for evaluation in report.evaluations - ] - } - actions.extend( - f"adjudicate_{phase.plan.phase}" - for phase in sequential_evidence.phases - ) - if report.searchPlan is not None: - actions.append("review_parameter_refinement") - actions.extend( - f"execute_refined_candidate:{candidate.candidateId}" - for candidate in report.searchPlan.candidates - ) - return self.save_parameter_tuning_outcome( - store, - prefix, - workflow, - request_record, - started, - report, - plan, - preprocessed, - [], - candidate_payload, - [], - enrichment_reference, - experimental_reference, - experimental_handoff, - agent, - actions, - sequential_evidence=sequential_evidence, - ) - if resumable_report is not None: - assert paused is not None - resumed_integration_evaluations = list( - resumable_report.integrationEvaluations - ) - report = resumable_report.model_copy( - update={ - "status": "done", - "needsInput": None, - "finalSelection": None, - "recommendedIntegrationId": None, - "finalClusterColumn": None, - "finalClusterArtifact": None, - "graphAssay": None, - } - ) - report = self.select_final_graph( - agent, - report, - resumed_integration_evaluations, - marker_assay=plan.markerAssay, - answers=answers, - ) - logger.info( - f"Workflow {workflow.workflowRunId}: resumed final graph " - "selection without rerunning candidate evaluation" - ) - resumed_candidate_payload = { - assay: [ - evaluation.parameters.model_dump(mode="json") - for evaluation in assay_report.evaluations - ] - for assay, assay_report in report.assayReports.items() - } - return self.save_parameter_tuning_outcome( - store, - prefix, - workflow, - request_record, - started, - report, - plan, - preprocessed, - resumed_integration_evaluations, - resumed_candidate_payload, - list(plan.pairedAssays), - enrichment_reference, - experimental_reference, - experimental_handoff, - agent, - ["reuse_parameter_screen_and_integrations"], - prior_tuning_reference=paused.reportReferences[0], - ) - handoff_by_assay = {value.assay: value for value in preprocessed} - common_k = None - if paired: - common_k = min( - 21, - min(handoff_by_assay[assay].nCells - 1 for assay in paired), - ) - if common_k < 2: - raise ValueError("Paired integration requires at least three cells") - integration_budget = ( - 2 * request_record.config.integrationResolutionCandidates - if len(paired) >= 2 - else 0 - ) - diagnostic_batch_candidates = ( - ( - tuple(study_contract.technicalBatchColumns) - if study_contract.correctionLicense == "safe" - and experimental_handoff.batchAction == "evaluateHarmony" - else ( - ( - study_contract.physicalCaptureColumn, - *study_contract.technicalBatchColumns, - ) - if request_record.config.runConfoundedHarmonyDiagnostic - else tuple(study_contract.technicalBatchColumns) - ) - ) - if study_contract is not None - else tuple(experimental_handoff.batchColumns) - ) - diagnostic_batch_columns = [ - column - for column in dict.fromkeys(diagnostic_batch_candidates) - if column is not None - and column in store.cells.columns - and len(np.unique(store.cells.fetch(column, key="I"))) > 1 - ] - harmony_selectable = bool( - study_contract is not None - and study_contract.correctionLicense == "safe" - and experimental_handoff.batchAction == "evaluateHarmony" - and diagnostic_batch_columns - and sorted(diagnostic_batch_columns) - == sorted(experimental_handoff.batchColumns) - ) - assay_inputs: list[ParameterTuningAssayInput] = [] - for handoff in preprocessed: - if handoff.normalized is None: - raise ValueError(f"Assay {handoff.assay!r} lacks normalization") - initial_count = ( - request_record.config.primaryInitialCandidates - if handoff.assay == plan.primaryAssay - else request_record.config.secondaryInitialCandidates - ) - neighbors_k = common_k or min(21, handoff.nCells - 1) - candidates = self.initial_parameter_candidates( - workflow.workflowRunId, - handoff, - count=initial_count, - neighbors_k=neighbors_k, - dimension_candidates=(request_record.config.pcaCandidateDimensions), - neighbor_candidates=(request_record.config.graphNeighborCandidates), - resolution_candidates=( - request_record.config.leidenResolutionCandidates - ), - ) - if ( - diagnostic_batch_columns - and request_record.config.maxHarmonyCandidatesPerAssay == 1 - and ( - harmony_selectable - or request_record.config.runConfoundedHarmonyDiagnostic - ) - ): - baseline = candidates[0] - candidates.append( - baseline.model_copy( - update={ - "candidateId": f"{baseline.candidateId}_harmony", - "useHarmony": True, - } - ) - ) - candidate_payload[handoff.assay] = [ - value.model_dump(mode="json") for value in candidates + candidate_payload = { + sequential_evidence.assay: [ + evaluation.parameters.model_dump(mode="json") + for evaluation in report.evaluations ] - logger.info( - f"Workflow {workflow.workflowRunId}: planned " - f"{len(candidates)} native candidate(s) for assay " - f"{handoff.assay!r} (harmony=" - f"{sum(value.useHarmony for value in candidates)})" - ) - assay_inputs.append( - ParameterTuningAssayInput( - normalized=artifact_model_to_ref(handoff.normalized), - candidates=candidates, - batchColumns=list(diagnostic_batch_columns), - preservationColumns=list( - experimental_handoff.preservationColumns - ), - experimentalHandoff=( - experimental_handoff if harmony_selectable else None - ), - maxCandidates=( - len(candidates) - + request_record.config.maxRefinedCandidatesPerAssay - ), - maxRefinedCandidates=( - request_record.config.maxRefinedCandidatesPerAssay - ), - allowHarmonyRefinement=harmony_selectable, - minClusterCells=request_record.config.minClusterCells, - identityFeatureLimit=request_record.config.maxIdentityFeatures, - ) - ) - planned_native = sum(value.maxCandidates for value in assay_inputs) - if ( - planned_native + integration_budget - > request_record.config.maxCandidateBranches - ): - raise ValueError( - "The native and integrated candidate plan exceeds the global " - f"branch limit {request_record.config.maxCandidateBranches}" - ) - logger.info( - f"Workflow {workflow.workflowRunId}: executing " - f"{planned_native} native candidate branch(es) with " - f"{integration_budget} reserved integration branch(es)" - ) - report = agent.run_batch( - store, - assays=assay_inputs, - primary_assay=plan.primaryAssay, - max_total_candidates=( - request_record.config.maxCandidateBranches - integration_budget - ), - selection_directions=tuning_directions, - ) - report = self._augment_legacy_scientific_evidence( - store, - report, - plan=plan, - preprocessed=preprocessed, - study_contract=study_contract, - ) - report = enforce_harmony_acceptance( - report, - batch_columns=diagnostic_batch_columns, - protected_columns=( - study_contract.protectedColumns - if study_contract is not None - else experimental_handoff.preservationColumns - ), - independent_unit_columns=( - study_contract.independentUnitColumns - if study_contract is not None - else [] - ), - selectable=harmony_selectable, + } + actions.extend( + f"adjudicate_{phase.plan.phase}" for phase in sequential_evidence.phases ) - logger.info( - f"Workflow {workflow.workflowRunId}: Parameter Tuning returned " - f"status={report.status!r}, evaluated={report.totalCandidates}" - ) - if report.status == "done": - for assay, assay_report in report.assayReports.items(): - normalized = handoff_by_assay[assay].normalized - assert normalized is not None - agent.promote( - store, - report=assay_report, - normalized=artifact_model_to_ref(normalized), - identity_feature_limit=request_record.config.maxIdentityFeatures, - ) - actions.append(f"promote_native:{assay}") - logger.info( - f"Workflow {workflow.workflowRunId}: promoted native " - f"candidate {assay_report.recommendedCandidateId!r} for " - f"assay {assay!r}" - ) - integration_evaluations = self.evaluate_integrations( - store, - workflow.workflowRunId, - plan, - report, - experimental_handoff, - request_record.config, - started=started, - parent_reports=[ - journal._report_link(enrichment_reference), - journal._report_link(experimental_reference), - ], - actions=actions, - ) - logger.info( - f"Workflow {workflow.workflowRunId}: evaluated " - f"{len(integration_evaluations)} integration candidate(s)" - ) - report = self.select_final_graph( - agent, - report, - integration_evaluations, - marker_assay=plan.markerAssay, - answers=answers, + if report.searchPlan is not None: + actions.append("review_parameter_refinement") + actions.extend( + f"execute_refined_candidate:{candidate.candidateId}" + for candidate in report.searchPlan.candidates ) return self.save_parameter_tuning_outcome( store, @@ -5050,14 +4440,13 @@ def parameter_tuning_stage( report, plan, preprocessed, - integration_evaluations, candidate_payload, - paired, enrichment_reference, experimental_reference, experimental_handoff, agent, actions, + sequential_evidence=sequential_evidence, ) except Exception as exc: failure_artifacts: dict[str, ArtifactReferenceModel] = { @@ -5069,19 +4458,6 @@ def parameter_tuning_stage( failure_artifacts[ f"{assay}_{evaluation.parameters.candidateId}_{name}" ] = ArtifactReferenceModel.model_validate(artifact.model_dump()) - for integration_evaluation in integration_evaluations: - if integration_evaluation.graphArtifact is not None: - failure_artifacts[ - f"{integration_evaluation.integrationId}_graph" - ] = ArtifactReferenceModel.model_validate( - integration_evaluation.graphArtifact.model_dump() - ) - if integration_evaluation.clusterArtifact is not None: - failure_artifacts[ - f"{integration_evaluation.integrationId}_clusters" - ] = ArtifactReferenceModel.model_validate( - integration_evaluation.clusterArtifact.model_dump() - ) outcome = journal.finish_exception( store, prefix, @@ -5092,74 +4468,10 @@ def parameter_tuning_stage( actions=actions, outputs={ "candidatePlan": candidate_payload, - "integrationEvaluations": [ - value.model_dump(mode="json") - for value in integration_evaluations - ], }, ) return outcome, ParameterTuningReport.get_blank() - def select_final_graph( - self, - agent: ParameterTuningAgent, - report: ParameterTuningReport, - integration_evaluations: Sequence[IntegrationCandidateEvaluation], - *, - marker_assay: str, - answers: Mapping[str, Any], - ) -> ParameterTuningReport: - directed_option = answers.get("finalGraphOptionId") - if not isinstance(directed_option, str) or not directed_option: - logger.info( - f"Selecting final graph from native and " - f"{len(integration_evaluations)} integration evaluation(s)" - ) - return agent.select_final( - report=report, - integration_evaluations=integration_evaluations, - marker_assay=marker_assay, - ) - options = final_graph_options(report, integration_evaluations) - if directed_option not in options: - raise ValueError("finalGraphOptionId is not an eligible option") - logger.info(f"Applying caller-selected final graph {directed_option!r}") - selected_evidence = list(options[directed_option]["evidenceIds"]) - selection = FinalGraphSelection( - status="done", - selectedOptionId=directed_option, - markerAssay=marker_assay, - confidence="high", - rationale="The caller selected this persisted eligible option.", - evidenceIds=selected_evidence, - comparisons=[ - FinalGraphComparison( - optionId=option_id, - summary="The caller preferred the selected eligible option.", - evidenceIds=[ - *selected_evidence, - *cast(list[str], option["evidenceIds"]), - ], - ) - for option_id, option in options.items() - if option_id != directed_option - ], - ) - selection = validate_final_graph_selection( - selection, - report, - integration_evaluations=integration_evaluations, - marker_assay=marker_assay, - ) - return finalize_parameter_tuning_selection( - report, - marker_assay=marker_assay, - integration_evaluations=integration_evaluations, - recommended_integration_id=selection.integrationId, - native_assay=selection.nativeAssay, - final_selection=selection, - ) - def save_parameter_tuning_outcome( self, store: DataStore, @@ -5170,9 +4482,7 @@ def save_parameter_tuning_outcome( report: ParameterTuningReport, plan: AutomatedPreprocessingPlan, preprocessed: Sequence[PreprocessedAssayHandoff], - integration_evaluations: Sequence[IntegrationCandidateEvaluation], candidate_payload: Mapping[str, list[dict[str, Any]]], - paired: Sequence[str], enrichment_reference: AgentReportReference, experimental_reference: AgentReportReference, experimental_handoff: ExperimentalTuningHandoff, @@ -5192,19 +4502,6 @@ def save_parameter_tuning_outcome( if value.normalized is not None: invocation_artifacts[f"{value.assay}_normalized"] = value.normalized invocation_artifacts["cellSelection"] = experimental_handoff.cellSelection - for integration_evaluation in integration_evaluations: - if integration_evaluation.graphArtifact is not None: - invocation_artifacts[ - f"{integration_evaluation.integrationId}_graph" - ] = ArtifactReferenceModel.model_validate( - integration_evaluation.graphArtifact.model_dump() - ) - if integration_evaluation.clusterArtifact is not None: - invocation_artifacts[ - f"{integration_evaluation.integrationId}_clusters" - ] = ArtifactReferenceModel.model_validate( - integration_evaluation.clusterArtifact.model_dump() - ) stage_artifacts = dict(invocation_artifacts) for assay, assay_report in report.assayReports.items(): for name, artifact in assay_report.selectedArtifacts.items(): @@ -5215,17 +4512,7 @@ def save_parameter_tuning_outcome( stage_artifacts["final_clusters"] = ArtifactReferenceModel.model_validate( report.finalClusterArtifact.model_dump() ) - if report.recommendedIntegrationId is not None: - selected_integration = next( - value - for value in integration_evaluations - if value.integrationId == report.recommendedIntegrationId - ) - if selected_integration.graphArtifact is not None: - stage_artifacts["final_graph"] = ArtifactReferenceModel.model_validate( - selected_integration.graphArtifact.model_dump() - ) - elif report.graphAssay is not None: + if report.graphAssay is not None: assay_reports = report.assayReports or {report.fromAssay: report} graph_artifact = assay_reports[report.graphAssay].selectedArtifacts[ "connectivityMap" @@ -5233,22 +4520,6 @@ def save_parameter_tuning_outcome( stage_artifacts["final_graph"] = ArtifactReferenceModel.model_validate( graph_artifact.model_dump() ) - checkpoint_ids = { - f"{journal._stage_execution_id(started)}_integration_{method}" - for method in {evaluation.method for evaluation in integration_evaluations} - } - checkpoint_references = sorted( - ( - reference - for reference in list_agent_reports( - store, - started.workflowRunId, - agent_name="parameter_tuning", - ) - if reference.agentRunId in checkpoint_ids - ), - key=lambda value: value.agentRunId, - ) if persisted_reference is None: saved_report, reference = journal._save_stage_report( store, @@ -5264,22 +4535,19 @@ def save_parameter_tuning_outcome( if prior_tuning_reference is not None else [] ), - *[ - journal._report_link(value) - for value in checkpoint_references - ], ], inputs={ "assays": dict(candidate_payload), "primaryAssay": plan.primaryAssay, "markerAssay": plan.markerAssay, - "pairedAssays": list(paired), "cellSelection": ( experimental_handoff.cellSelection.model_dump(mode="json") if experimental_handoff.cellSelection is not None else None ), - "maxCandidateBranches": request_record.config.maxCandidateBranches, + "maxCandidateEvaluations": ( + request_record.config.maxCandidateEvaluations + ), }, artifacts=stage_artifacts, runConfig=agent.config, @@ -5290,7 +4558,7 @@ def save_parameter_tuning_outcome( report = cast(ParameterTuningReport, saved_report) else: reference = persisted_reference - stage_report_references = [reference, *checkpoint_references] + stage_report_references = [reference] operations: list[dict[str, Any]] = [] for assay, assay_report in report.assayReports.items(): for candidate_evaluation in assay_report.evaluations: @@ -5318,68 +4586,6 @@ def save_parameter_tuning_outcome( }, } ) - seen_integrated_graphs: set[tuple[str, str]] = set() - for integration_evaluation in integration_evaluations: - integration_graph = integration_evaluation.graphArtifact - graph_id = ( - integration_graph.artifactId if integration_graph is not None else "" - ) - graph_key = (integration_evaluation.method, graph_id) - if graph_id and graph_key not in seen_integrated_graphs: - assert integration_graph is not None - seen_integrated_graphs.add(graph_key) - source_key = ( - "connectivityMap" - if integration_evaluation.method == "snn" - else "neighbors" - ) - source_artifacts = [] - for assay in integration_evaluation.assays: - assay_report = report.assayReports[assay] - selected = next( - value - for value in assay_report.evaluations - if value.candidateId == assay_report.recommendedCandidateId - ) - source_artifacts.append( - selected.artifacts[source_key].model_dump(mode="json") - ) - operations.append( - { - "operation": "integrate_assays", - "method": integration_evaluation.method, - "sources": source_artifacts, - "invalidateCache": True, - "l2Normalize": True, - "artifact": integration_graph.model_dump(mode="json"), - } - ) - if integration_graph is None: - continue - operations.append( - { - "operation": "run_leiden_clustering", - "integrationId": integration_evaluation.integrationId, - "status": integration_evaluation.status, - "resolution": integration_evaluation.resolution, - "graph": integration_graph.model_dump(mode="json"), - "cellSelection": ( - integration_evaluation.cellSelection.model_dump(mode="json") - if integration_evaluation.cellSelection is not None - else None - ), - "backend": "igraph", - "symmetricGraph": False, - "graphUpperOnly": False, - "randomSeed": 4444, - "invalidateCache": False, - "artifact": ( - integration_evaluation.clusterArtifact.model_dump(mode="json") - if integration_evaluation.clusterArtifact is not None - else None - ), - } - ) if ( report.status == "needsInput" and request_record.config.inputPolicy == "unattended" @@ -5518,564 +4724,8 @@ def save_parameter_tuning_outcome( journal._save_outcome(store.zw, prefix, outcome) logger.info( f"Workflow {workflow.workflowRunId}: Parameter Tuning outcome " - f"status={outcome.status!r}, candidates={report.totalCandidates}, " - f"integrations={len(integration_evaluations)}" + f"status={outcome.status!r}, candidates={report.totalCandidates}" ) if outcome.status == "failed": journal.finalize_failed(store, workflow, outcome.error or "tuning failed") return outcome, report - - def initial_parameter_candidates( - self, - workflow_run_id: str, - handoff: PreprocessedAssayHandoff, - *, - count: int, - neighbors_k: int, - dimension_candidates: Sequence[int] = (10, 20, 30, 50), - neighbor_candidates: Sequence[int] = (11, 21, 41), - resolution_candidates: Sequence[float] = ( - 0.25, - 0.5, - 0.75, - 1.0, - 1.25, - 1.5, - ), - ) -> list[ParameterCandidate]: - max_dimensions = min(handoff.nCells, handoff.nFeatures) - 1 - if neighbors_k < 2 or neighbors_k >= handoff.nCells: - raise ValueError( - f"Assay {handoff.assay!r} has no rank-valid graph candidate" - ) - if handoff.reductionMethod == "identity": - if handoff.nFeatures < 2: - raise ValueError( - f"Assay {handoff.assay!r} has no rank-valid graph candidate" - ) - dimensions = handoff.nFeatures - dimension_values = [dimensions] - elif max_dimensions < 2: - raise ValueError( - f"Assay {handoff.assay!r} has no rank-valid graph candidate" - ) - elif handoff.reductionMethod == "lsi": - dimensions = min(50, max_dimensions) - dimension_values = [ - dimensions, - min(30, max_dimensions), - min(70, max_dimensions), - ] - else: - dimension_values = [ - min(value, max_dimensions) - for value in dimension_candidates - if value >= 2 - ] - if not dimension_values: - dimension_values = [min(20, max_dimensions)] - dimensions = min(20, max_dimensions) - if dimensions not in dimension_values: - dimension_values.append(dimensions) - unique_dimensions = list( - dict.fromkeys(value for value in dimension_values if value >= 2) - ) - baseline_dimensions = ( - min(20, max_dimensions) - if handoff.reductionMethod == "pca" - else unique_dimensions[0] - ) - unique_dimensions = [ - baseline_dimensions, - *(value for value in unique_dimensions if value != baseline_dimensions), - ] - specifications: list[tuple[int, float, int]] = [ - (value, 1.0, neighbors_k) for value in unique_dimensions - ] - for candidate_k in neighbor_candidates: - effective_k = min(candidate_k, handoff.nCells - 1) - specification = (baseline_dimensions, 1.0, effective_k) - if effective_k >= 2 and specification not in specifications: - specifications.append(specification) - for resolution in resolution_candidates: - if len(specifications) >= count: - break - specification = (baseline_dimensions, float(resolution), neighbors_k) - if specification not in specifications: - specifications.append(specification) - token = workflow_run_id[:10] - assay_token = journal._safe_label(handoff.assay).lower() - if len(assay_token) > 32: - digest = hashlib.blake2b( - handoff.assay.encode("utf-8"), digest_size=4 - ).hexdigest() - assay_token = f"{assay_token[:23]}_{digest}" - if handoff.reductionMethod == "identity": - candidates = [ - ParameterCandidate( - candidateId=f"w_{token}_{assay_token}_0", - reductionMethod="identity", - dimensions=handoff.nFeatures, - leidenResolution=1.0, - neighborsK=neighbors_k, - ) - ] - if count > 1 and max_dimensions >= 2: - candidates.append( - ParameterCandidate( - candidateId=f"w_{token}_{assay_token}_1", - reductionMethod="pca", - dimensions=min(21, max_dimensions), - leidenResolution=1.0, - neighborsK=neighbors_k, - ) - ) - for resolution in resolution_candidates: - if len(candidates) >= count: - break - index = len(candidates) - candidates.append( - ParameterCandidate( - candidateId=f"w_{token}_{assay_token}_{index}", - reductionMethod="identity", - dimensions=handoff.nFeatures, - leidenResolution=resolution, - neighborsK=neighbors_k, - ) - ) - return candidates - return [ - ParameterCandidate( - candidateId=f"w_{token}_{assay_token}_{index}", - reductionMethod=cast(Any, handoff.reductionMethod), - dimensions=dimension, - leidenResolution=resolution, - neighborsK=candidate_k, - ) - for index, (dimension, resolution, candidate_k) in enumerate( - specifications[:count] - ) - ] - - def load_integration_checkpoint( - self, - store: DataStore, - started: WorkflowStageAttempt, - method: Literal["snn", "wnn"], - ) -> tuple[list[IntegrationCandidateEvaluation], AgentReportReference] | None: - checkpoint_id = f"{journal._stage_execution_id(started)}_integration_{method}" - matches = [ - reference - for reference in list_agent_reports( - store, - started.workflowRunId, - agent_name="parameter_tuning", - ) - if reference.agentRunId == checkpoint_id - ] - if not matches: - return None - if len(matches) != 1: - raise ValueError("An integration checkpoint has multiple reports") - reference = matches[0] - record = load_agent_record(store, reference) - if ( - record.invocation.inputs.get("orchestrationExecutionId") != checkpoint_id - or record.invocation.inputs.get("stageExecutionId") - != journal._stage_execution_id(started) - or record.invocation.inputs.get("method") != method - ): - raise ValueError("Integration checkpoint identity is stale") - for artifact in record.invocation.artifacts.values(): - store.load_artifact(artifact_model_to_ref(artifact)) - report = load_agent_report(store, reference) - if not isinstance(report, ParameterTuningReport): - raise TypeError("Integration checkpoint is not a Parameter Tuning report") - evaluations = list(report.integrationEvaluations) - if not evaluations or any(value.method != method for value in evaluations): - raise ValueError("Integration checkpoint contains the wrong method") - logger.info( - f"Workflow {started.workflowRunId}: recovered {method.upper()} " - f"checkpoint with {len(evaluations)} evaluation(s)" - ) - return evaluations, reference - - def save_integration_checkpoint( - self, - store: DataStore, - started: WorkflowStageAttempt, - report: ParameterTuningReport, - method: Literal["snn", "wnn"], - evaluations: Sequence[IntegrationCandidateEvaluation], - parent_reports: Sequence[AgentReportLink], - ) -> AgentReportReference: - checkpoint_id = f"{journal._stage_execution_id(started)}_integration_{method}" - checkpoint_report = report.model_copy( - update={ - "integrationEvaluations": list(evaluations), - "recommendedIntegrationId": None, - "finalClusterColumn": None, - "finalClusterArtifact": None, - "finalSelection": None, - } - ) - artifacts: dict[str, ArtifactReferenceModel] = {} - cell_selection = next( - ( - evaluation.cellSelection - for evaluation in evaluations - if evaluation.cellSelection is not None - ), - None, - ) - if cell_selection is not None: - artifacts["cellSelection"] = cell_selection - for evaluation in evaluations: - if evaluation.graphArtifact is not None: - artifacts[f"{evaluation.integrationId}_graph"] = ( - ArtifactReferenceModel.model_validate( - evaluation.graphArtifact.model_dump() - ) - ) - if evaluation.clusterArtifact is not None: - artifacts[f"{evaluation.integrationId}_clusters"] = ( - ArtifactReferenceModel.model_validate( - evaluation.clusterArtifact.model_dump() - ) - ) - invocation = AgentInvocation( - agentName="parameter_tuning", - parentReports=list(parent_reports), - inputs={ - "orchestrationExecutionId": checkpoint_id, - "stageExecutionId": journal._stage_execution_id(started), - "method": method, - "cellSelection": ( - cell_selection.model_dump(mode="json") - if cell_selection is not None - else None - ), - }, - artifacts=artifacts, - ) - try: - reference = save_agent_report( - store, - started.workflowRunId, - checkpoint_report, - invocation=invocation, - agent_run_id=checkpoint_id, - ) - logger.info( - f"Workflow {started.workflowRunId}: persisted {method.upper()} " - f"checkpoint with {len(evaluations)} evaluation(s)" - ) - return reference - except FileExistsError: - recovered = self.load_integration_checkpoint(store, started, method) - if recovered is None: - raise - return recovered[1] - - def evaluate_integrations( - self, - store: DataStore, - workflow_run_id: str, - plan: AutomatedPreprocessingPlan, - report: ParameterTuningReport, - experimental_handoff: ExperimentalTuningHandoff, - config: AutomatedWorkflowConfig, - *, - started: WorkflowStageAttempt | None = None, - parent_reports: Sequence[AgentReportLink] = (), - actions: list[str] | None = None, - ) -> list[IntegrationCandidateEvaluation]: - assays = list(plan.pairedAssays) - if len(assays) < 2: - logger.info("Skipping SNN/WNN evaluation: fewer than two paired assays") - return [] - selected_k = { - next( - evaluation.parameters.neighborsK - for evaluation in assay_report.evaluations - if evaluation.candidateId == assay_report.recommendedCandidateId - ) - for assay, assay_report in report.assayReports.items() - if assay in assays - } - if len(selected_k) != 1: - raise ValueError("SNN and WNN require one common selected neighborsK") - primary_report = report.assayReports[plan.primaryAssay] - primary_evaluation = next( - value - for value in primary_report.evaluations - if value.candidateId == primary_report.recommendedCandidateId - ) - center = primary_evaluation.parameters.leidenResolution - count = config.integrationResolutionCandidates - multipliers = [1.0] if count == 1 else np.linspace(0.5, 1.5, count).tolist() - resolutions = list( - dict.fromkeys(max(0.05, round(center * value, 6)) for value in multipliers) - ) - logger.info( - f"Evaluating SNN and WNN across {len(resolutions)} resolution(s) " - f"for {len(assays)} paired assay(s)" - ) - if report.cellSelection is None: - raise ValueError("Parameter tuning report lacks an exact cell selection") - cell_selection = report.cellSelection - native_labels: dict[str, np.ndarray[Any, Any]] = {} - for assay, assay_report in report.assayReports.items(): - if assay not in assays: - continue - selected = next( - value - for value in assay_report.evaluations - if value.candidateId == assay_report.recommendedCandidateId - ) - cluster_model = ArtifactReferenceModel.model_validate( - selected.artifacts["clusters"].model_dump() - ) - cluster_group = store.load_artifact(artifact_model_to_ref(cluster_model)) - cluster_values = cast(Any, cluster_group["values"]) - native_labels[assay] = np.asarray(cluster_values[:]) - token = workflow_run_id[:12] - evaluations: list[IntegrationCandidateEvaluation] = [] - integration_methods: tuple[Literal["snn", "wnn"], ...] = ("snn", "wnn") - for method in integration_methods: - evaluations.extend( - self.evaluate_integration_method( - store, - method, - token, - assays, - resolutions, - native_labels, - report, - experimental_handoff, - config, - cell_selection, - started=started, - parent_reports=parent_reports, - actions=actions, - ) - ) - return evaluations - - def evaluate_integration_method( - self, - store: DataStore, - method: Literal["snn", "wnn"], - token: str, - assays: list[str], - resolutions: Sequence[float], - native_labels: Mapping[str, np.ndarray[Any, Any]], - report: ParameterTuningReport, - experimental_handoff: ExperimentalTuningHandoff, - config: AutomatedWorkflowConfig, - cell_selection: ArtifactReferenceModel, - *, - started: WorkflowStageAttempt | None, - parent_reports: Sequence[AgentReportLink], - actions: list[str] | None, - ) -> list[IntegrationCandidateEvaluation]: - if started is not None: - recovered = self.load_integration_checkpoint(store, started, method) - if recovered is not None: - if actions is not None: - actions.append(f"recover_integration_checkpoint:{method}") - return recovered[0] - logger.info( - f"Evaluating {method.upper()} integration across " - f"{len(resolutions)} resolution(s)" - ) - evaluations: list[IntegrationCandidateEvaluation] = [] - source_key = "connectivityMap" if method == "snn" else "neighbors" - sources = [] - for assay in assays: - assay_report = report.assayReports[assay] - selected = next( - value - for value in assay_report.evaluations - if value.candidateId == assay_report.recommendedCandidateId - ) - source_model = ArtifactReferenceModel.model_validate( - selected.artifacts[source_key].model_dump() - ) - sources.append(artifact_model_to_ref(source_model)) - try: - graph_ref = store.integrate_assays( - sources, - method=method, - invalidate_cache=True, - l2_normalize=True, - ) - weights_valid: bool | None = None - if method == "wnn": - graph_group = store.load_artifact(graph_ref) - stored_weights = cast(Any, graph_group["modality_weights"]) - weights = np.asarray(stored_weights[:], dtype=float) - weights_valid = bool( - weights.shape - == (len(next(iter(native_labels.values()))), len(assays)) - and np.all(np.isfinite(weights)) - and np.all(weights >= 0) - and np.allclose(weights.sum(axis=1), 1.0, rtol=1e-5, atol=1e-6) - ) - except Exception as exc: - logger.warning( - f"{method.upper()} graph construction failed " - f"({type(exc).__name__}); persisting failed evaluations" - ) - for index, resolution in enumerate(resolutions): - evaluations.append( - IntegrationCandidateEvaluation( - integrationId=f"{method}_{token}_{index}", - method=cast(Any, method), - assays=assays, - status="failed", - cellSelection=cell_selection, - resolution=resolution, - error=f"{type(exc).__name__}: {exc}", - ) - ) - if started is not None: - self.save_integration_checkpoint( - store, - started, - report, - method, - evaluations, - parent_reports, - ) - if actions is not None: - actions.append(f"checkpoint_integration:{method}") - return evaluations - for index, resolution in enumerate(resolutions): - integration_id = f"{method}_{token}_{index}" - warnings: list[str] = [] - evidence_ids = [f"integration:{integration_id}:clusters"] - try: - cluster_ref = store.run_leiden_clustering( - graph_ref, - resolution=resolution, - backend="igraph", - symmetric_graph=False, - graph_upper_only=False, - random_seed=4444, - invalidate_cache=False, - ) - cluster_group = store.load_artifact(cluster_ref) - cluster_values = cast(Any, cluster_group["values"]) - values = np.asarray(cluster_values[:]) - _labels, counts = np.unique(values, return_counts=True) - metrics = IntegrationMetrics( - nClusters=int(len(counts)), - minClusterCells=int(counts.min()), - minClusterFraction=float(counts.min() / len(values)), - modalityWeightsValid=weights_valid, - ) - for assay, native in native_labels.items(): - metrics.adjustedRandByAssay[assay] = float( - adjusted_rand_score(native, values) - ) - metrics.normalizedMutualInformationByAssay[assay] = float( - normalized_mutual_info_score(native, values) - ) - evidence_ids.extend( - [ - f"integration:{integration_id}:ari:{assay}", - f"integration:{integration_id}:nmi:{assay}", - ] - ) - for column in experimental_handoff.preservationColumns: - try: - value = float( - store.metric_graph_connectivity( - column, - graph_ref, - ) - ) - if np.isfinite(value): - metrics.biologicalConnectivity[column] = value - evidence_ids.append( - f"integration:{integration_id}:graphConnectivity:{column}" - ) - except (KeyError, RuntimeError, TypeError, ValueError) as exc: - warnings.append( - f"Graph connectivity for {column!r} unavailable: {exc}" - ) - if method == "wnn": - evidence_ids.append(f"integration:{integration_id}:modalityWeights") - reasons: list[str] = [] - missing_connectivity = sorted( - set(experimental_handoff.preservationColumns) - - set(metrics.biologicalConnectivity) - ) - if missing_connectivity: - reasons.append( - "trusted-label connectivity is unavailable for " - + ", ".join(missing_connectivity) - ) - if metrics.nClusters is None or metrics.nClusters < 2: - reasons.append("fewer than two clusters") - if ( - metrics.minClusterCells is None - or metrics.minClusterCells < config.minClusterCells - ): - reasons.append("smallest cluster is below the configured minimum") - if method == "wnn" and weights_valid is not True: - reasons.append("WNN modality weights are invalid") - evaluations.append( - IntegrationCandidateEvaluation( - integrationId=integration_id, - method=cast(Any, method), - assays=assays, - status="done", - eligible=not reasons, - cellSelection=cell_selection, - resolution=resolution, - graphArtifact=ArtifactRecord.from_ref(graph_ref), - clusterArtifact=ArtifactRecord.from_ref(cluster_ref), - metrics=metrics, - evidenceIds=evidence_ids, - eligibilityReasons=reasons, - warnings=warnings, - ) - ) - except Exception as exc: - evaluations.append( - IntegrationCandidateEvaluation( - integrationId=integration_id, - method=cast(Any, method), - assays=assays, - status="failed", - cellSelection=cell_selection, - resolution=resolution, - graphArtifact=ArtifactRecord.from_ref(graph_ref), - evidenceIds=evidence_ids, - warnings=warnings, - error=f"{type(exc).__name__}: {exc}", - ) - ) - if started is not None: - self.save_integration_checkpoint( - store, - started, - report, - method, - evaluations, - parent_reports, - ) - if actions is not None: - actions.append(f"checkpoint_integration:{method}") - eligible_count = sum( - value.status == "done" and value.eligible for value in evaluations - ) - failed_count = sum(value.status == "failed" for value in evaluations) - logger.info( - f"Completed {method.upper()} integration evaluation: " - f"eligible={eligible_count}, failed={failed_count}, " - f"total={len(evaluations)}" - ) - return evaluations diff --git a/scarf/agent/parameter_tuning/diagnostics.py b/scarf/agent/parameter_tuning/diagnostics.py index 9a1fef83..c4f9a1e5 100644 --- a/scarf/agent/parameter_tuning/diagnostics.py +++ b/scarf/agent/parameter_tuning/diagnostics.py @@ -1,5 +1,6 @@ """Deterministic representation and partition evidence for RNA decisions.""" +import hashlib from collections.abc import Mapping, Sequence from dataclasses import dataclass, field from typing import Any, cast @@ -30,6 +31,7 @@ from ...storage.types import as_zarr_array from ...utils.logging import logger from .contracts import ArtifactRecord, ParameterCandidateEvaluation +from .execution import _cached_candidate_metric, _metadata_column_fingerprint from .selection import annotate_candidate_dominance _PCA_DIAGNOSTIC_ARRAYS = ( @@ -70,6 +72,43 @@ class AdvisoryDoubletScores: limitations: tuple[str, ...] = () +def restore_advisory_doublets( + evaluation: ParameterCandidateEvaluation, + *, + capture_column: str | None, +) -> AdvisoryDoubletScores: + """Restore the exact doublet inputs from an augmented phase evaluation.""" + score_keys = sorted( + (key for key in evaluation.artifacts if key.startswith("doubletScore:")), + key=lambda key: int(key.split(":", 1)[1]), + ) + if score_keys != [f"doubletScore:{index}" for index in range(len(score_keys))]: + raise ValueError("Persisted doublet score inventory is incomplete") + captures = tuple(evaluation.metrics.doubletScoreByCapture) + if len(captures) != len(score_keys): + raise ValueError("Persisted doublet capture summaries do not align") + return AdvisoryDoubletScores( + scores=tuple(_artifact_ref(evaluation, key) for key in score_keys), + cell_selections=tuple( + _artifact_ref(evaluation, f"doubletCellSelection:{index}") + for index in range(len(score_keys)) + ), + native_graph=_artifact_ref(evaluation, "doubletNativeGraph"), + native_clusters=_artifact_ref(evaluation, "doubletNativeClusters"), + capture_values=captures, + score_summaries=tuple( + dict(evaluation.metrics.doubletScoreByCapture[capture]) + for capture in captures + ), + score_quantiles=dict(evaluation.metrics.doubletScoreQuantiles), + capture_coverage=evaluation.metrics.doubletCaptureCoverage, + capture_column=capture_column, + limitations=tuple( + warning for warning in evaluation.warnings if "doublet" in warning.lower() + ), + ) + + def _artifact_ref( evaluation: ParameterCandidateEvaluation, name: str, @@ -515,6 +554,108 @@ def _write_pca_diagnostic( reduction_group = store.load_artifact(reduction) coordinates = as_zarr_array(reduction_group["data"], name="data") loadings = as_zarr_array(reduction_group["loadings"], name="loadings") + dimensions = int(coordinates.shape[1]) + top_n = min(20, len(selected_indices)) + if loadings.shape != (len(selected_indices), dimensions): + raise ValueError("PCA loadings do not align with selected features") + planned = plan_artifact( + store.zw, + scope="assay", + assay=reduction.assay, + kind="feature_summary", + operation="diagnose_pca_representation", + parameters={ + "family_names": list(family_masks), + "covariate_columns": list(covariate_columns), + "covariate_roles": list(covariate_roles), + "top_loading_count": top_n, + "family_mask_fingerprints": { + family: hashlib.sha256( + np.asarray(mask, dtype=bool).tobytes() + ).hexdigest() + for family, mask in family_masks.items() + }, + "covariate_fingerprints": { + column: _metadata_column_fingerprint(store.cells, column) + for column in covariate_columns + }, + "adjacent_neighbor_overlap": adjacent_overlap, + "explained_variance_basis": "scaled_nonconstant_features", + }, + inputs={ + "reduction": reduction, + "neighbors": neighbors, + "feature_selection": feature_selection, + }, + execution_options={}, + invalidate_cache=False, + required_arrays=( + ArrayRequirement( + "component_variance", shape=(dimensions,), dtype=np.float64 + ), + ArrayRequirement( + "explained_variance_ratio", shape=(dimensions,), dtype=np.float64 + ), + ArrayRequirement( + "top_loading_feature_indices", shape=(dimensions, top_n), dtype=np.int64 + ), + ArrayRequirement( + "top_loading_values", shape=(dimensions, top_n), dtype=np.float64 + ), + ArrayRequirement( + "family_enrichment", + shape=(len(family_masks), dimensions), + dtype=np.float64, + ), + ArrayRequirement( + "covariate_association", + shape=(len(covariate_columns), dimensions), + dtype=np.float64, + ), + ArrayRequirement("adjacent_neighbor_overlap", shape=(1,), dtype=np.float64), + ), + required_attributes=( + AttributeRequirement("family_names", expected_types=(list,)), + AttributeRequirement("covariate_columns", expected_types=(list,)), + AttributeRequirement("covariate_roles", expected_types=(list,)), + AttributeRequirement("payload_fingerprint", expected_types=(str,)), + ), + ) + if planned.reused: + group = store.load_artifact(planned.ref) + if ( + fingerprint_stored_arrays(group, _PCA_DIAGNOSTIC_ARRAYS) + != group.attrs["payload_fingerprint"] + ): + raise ValueError("Stored PCA diagnostic payload fingerprint does not match") + return ( + planned.ref, + np.asarray( + as_zarr_array(group["component_variance"], name="component_variance")[:] + ), + np.asarray( + as_zarr_array( + group["explained_variance_ratio"], name="explained_variance_ratio" + )[:] + ), + np.asarray( + as_zarr_array( + group["top_loading_feature_indices"], + name="top_loading_feature_indices", + )[:] + ), + np.asarray( + as_zarr_array(group["top_loading_values"], name="top_loading_values")[:] + ), + np.asarray( + as_zarr_array(group["family_enrichment"], name="family_enrichment")[:] + ), + np.asarray( + as_zarr_array( + group["covariate_association"], name="covariate_association" + )[:] + ), + ) component_variance = _component_variance(coordinates) total_scaled_variance = _scaled_total_variance( store, @@ -562,38 +703,6 @@ def _write_pca_diagnostic( "covariate_association": associations, "adjacent_neighbor_overlap": overlap_array, } - planned = plan_artifact( - store.zw, - scope="assay", - assay=reduction.assay, - kind="feature_summary", - operation="diagnose_pca_representation", - parameters={ - "family_names": list(family_masks), - "covariate_columns": list(covariate_columns), - "covariate_roles": list(covariate_roles), - "top_loading_count": top_indices.shape[1], - "adjacent_neighbor_overlap": adjacent_overlap, - "explained_variance_basis": "scaled_nonconstant_features", - }, - inputs={ - "reduction": reduction, - "neighbors": neighbors, - "feature_selection": feature_selection, - }, - execution_options={}, - invalidate_cache=False, - required_arrays=tuple( - ArrayRequirement(name, shape=values.shape, dtype=values.dtype) - for name, values in payload.items() - ), - required_attributes=( - AttributeRequirement("family_names", expected_types=(list,)), - AttributeRequirement("covariate_columns", expected_types=(list,)), - AttributeRequirement("covariate_roles", expected_types=(list,)), - AttributeRequirement("payload_fingerprint", expected_types=(str,)), - ), - ) if not planned.reused: group = start_artifact(store.zw, planned) for name, values in payload.items(): @@ -1416,11 +1525,19 @@ def augment_cluster_evaluations( if alternative.shape != labels.shape: raise ValueError("Alternate-seed clusters do not align with the candidate") seed_stability = float(adjusted_rand_score(labels, alternative)) - graph = store.load_graph(graph_ref) - subsample_stability = _subsample_partition_stability( - graph, - labels, - evaluation.parameters.leidenResolution, + subsample_stability = _cached_candidate_metric( + ( + id(store), + "subsample_stability", + graph_ref, + clusters_ref, + evaluation.parameters.leidenResolution, + ), + lambda: _subsample_partition_stability( + store.load_graph(graph_ref), + labels, + evaluation.parameters.leidenResolution, + ), ) marker_ref = store.run_marker_search( @@ -1641,7 +1758,7 @@ def augment_cluster_evaluations( doublet_evidence.native_clusters ), } - if doublet_evidence is not None and doublet_evidence.scores + if doublet_evidence is not None else {} ), } diff --git a/scarf/agent/parameter_tuning/execution.py b/scarf/agent/parameter_tuning/execution.py index cb8ba692..1e1dc9c2 100644 --- a/scarf/agent/parameter_tuning/execution.py +++ b/scarf/agent/parameter_tuning/execution.py @@ -1,9 +1,14 @@ -from collections.abc import Sequence -from typing import Any +import hashlib +import json +from collections.abc import Callable, Iterator, Sequence +from contextlib import contextmanager +from contextvars import ContextVar +from typing import Any, cast import numpy as np from ...metrics import graph_connectivity +from ...metadata.rows import iter_metadata_column_blocks from ...storage.refs import ArtifactRef from ...storage.types import as_zarr_array from ...utils.logging import logger @@ -28,6 +33,52 @@ _RANDOM_SEED = 4444 _PCA_RANDOM_SEED = 4466 +_METRIC_CACHE: ContextVar[dict[tuple[Any, ...], Any] | None] = ContextVar( + "scarf_candidate_metric_cache", default=None +) + + +@contextmanager +def candidate_metric_cache() -> Iterator[None]: + """Reuse exact-input metrics only for the current orchestration stage.""" + token = _METRIC_CACHE.set({}) + try: + yield + finally: + _METRIC_CACHE.reset(token) + + +def _cached_candidate_metric[T](key: tuple[Any, ...], compute: Callable[[], T]) -> T: + cache = _METRIC_CACHE.get() + if cache is None: + return compute() + if key not in cache: + cache[key] = compute() + return cast(T, cache[key]) + + +def _metric_metadata_key(store: Any, column: str) -> str | None: + """Fingerprint live metric inputs so edits cannot reuse stale evidence.""" + if _METRIC_CACHE.get() is None: + return None + return _metadata_column_fingerprint(store.cells, column) + + +def _metadata_column_fingerprint(metadata: Any, column: str) -> str: + """Hash metadata values in bounded blocks, including scalar type identity.""" + digest = hashlib.sha256() + for block in iter_metadata_column_blocks(metadata, column): + digest.update(str(block.dtype).encode()) + digest.update(str(block.shape).encode()) + digest.update( + json.dumps( + [(type(value).__name__, repr(value)) for value in block.tolist()] + ).encode() + if block.dtype.hasobject + else block.tobytes() + ) + return digest.hexdigest() + def _final_graph_options( report: ParameterTuningReport, @@ -300,9 +351,9 @@ def _collect_cluster_structure_metrics( membership_group["values"], name="values", ) - mean, median, p10, by_cluster, sample_size = _bounded_membership_summary( - membership_values, - cluster_values, + mean, median, p10, by_cluster, sample_size = _cached_candidate_metric( + (id(store), "membership_summary", membership_ref, cluster_ref), + lambda: _bounded_membership_summary(membership_values, cluster_values), ) metrics.membershipStrengthMean = mean metrics.membershipStrengthMedian = median @@ -317,7 +368,10 @@ def _collect_cluster_structure_metrics( try: graph_group = store.load_artifact(graph_ref) graph_edges = as_zarr_array(graph_group["edges"], name="edges") - connectivity = float(graph_connectivity(graph_edges, cluster_values)) + connectivity = _cached_candidate_metric( + (id(store), "cluster_connectivity", graph_ref, cluster_ref), + lambda: float(graph_connectivity(graph_edges, cluster_values)), + ) if np.isfinite(connectivity): metrics.clusterConnectivity = connectivity evidence_ids.append(f"candidate:{candidate_id}:clusterConnectivity") @@ -369,11 +423,21 @@ def _collect_parameter_candidate_metrics( ) try: - graph_scores = store.metric_graph_silhouette( - neighbors_ref, - cluster_ref, - random_seed=_RANDOM_SEED, - sample_size=11, + graph_scores = _cached_candidate_metric( + ( + id(store), + "graph_silhouette", + neighbors_ref, + cluster_ref, + _RANDOM_SEED, + 11, + ), + lambda: store.metric_graph_silhouette( + neighbors_ref, + cluster_ref, + random_seed=_RANDOM_SEED, + sample_size=11, + ), ) if graph_scores is not None: finite_scores = np.asarray(graph_scores, dtype=float) @@ -386,15 +450,28 @@ def _collect_parameter_candidate_metrics( if candidate.reductionMethod == "pca": try: - separability = store.metric_cluster_separability( - reduction_ref, - {cluster_column: cluster_ref}, - random_seed=_RANDOM_SEED, + + def separability_values() -> dict[str, Any]: + separability = store.metric_cluster_separability( + reduction_ref, + {cluster_column: cluster_ref}, + random_seed=_RANDOM_SEED, + ) + table = separability.clustering_scores + rows = table.loc[table["clustering"] == cluster_column] + return dict(rows.iloc[0]) if len(rows) else {} + + row = _cached_candidate_metric( + ( + id(store), + "cluster_separability", + reduction_ref, + cluster_ref, + _RANDOM_SEED, + ), + separability_values, ) - table = separability.clustering_scores - rows = table.loc[table["clustering"] == cluster_column] - if len(rows): - row = rows.iloc[0] + if row: for field_name, column_name, evidence_name in ( ("pcaSilhouette", "silhouette_score", "pcaSilhouette"), ("macroF1", "macro_f1_mean", "macroF1"), @@ -411,10 +488,20 @@ def _collect_parameter_candidate_metrics( for column in deps.batchColumns: try: score = float( - store.metric_proportional_batch_mixing( - column, - neighbors_ref, - perplexity=perplexity, + _cached_candidate_metric( + ( + id(store), + "batch_mixing", + neighbors_ref, + column, + _metric_metadata_key(store, column), + perplexity, + ), + lambda: store.metric_proportional_batch_mixing( + column, + neighbors_ref, + perplexity=perplexity, + ), ) ) if np.isfinite(score): @@ -427,11 +514,22 @@ def _collect_parameter_candidate_metrics( scores: dict[str, float] = {} try: clisi = float( - store.metric_clisi( - column, - neighbors_ref, - perplexity=None, - scale=True, + _cached_candidate_metric( + ( + id(store), + "clisi", + neighbors_ref, + column, + _metric_metadata_key(store, column), + None, + True, + ), + lambda: store.metric_clisi( + column, + neighbors_ref, + perplexity=None, + scale=True, + ), ) ) if np.isfinite(clisi): @@ -441,9 +539,18 @@ def _collect_parameter_candidate_metrics( warnings.append(f"cLISI for {column!r} unavailable: {exc}") try: connectivity = float( - store.metric_graph_connectivity( - column, - graph_ref, + _cached_candidate_metric( + ( + id(store), + "protected_connectivity", + graph_ref, + column, + _metric_metadata_key(store, column), + ), + lambda: store.metric_graph_connectivity( + column, + graph_ref, + ), ) ) if np.isfinite(connectivity): diff --git a/scarf/agent/report/generator.py b/scarf/agent/report/generator.py index 3cfa2d2a..916aa0cf 100644 --- a/scarf/agent/report/generator.py +++ b/scarf/agent/report/generator.py @@ -23,7 +23,6 @@ from .plots import _collect_final_artifacts, _collect_hvg_plots from .rendering import ( _render_analysis_document, - _render_index_document, _render_technical_document, ) @@ -47,8 +46,8 @@ def generate_agent_report( ) -> Path: """Generate a local HTML report for one completed automated workflow. - The report directory contains a landing page, an analysis summary, and - technical details. The returned path points to the landing ``index.html``. + The returned ``index.html`` opens the analysis and its decisions directly, + with a secondary link to the technical details. Existing derived report files may be replaced; immutable agent and orchestration records are only read. """ @@ -138,15 +137,15 @@ def generate_agent_report( "defaultFeatureInventories": default_feature_inventories, } documents = ( - ("analysis.html", _render_analysis_document(payload)), ("technical.html", _render_technical_document(payload)), - ("index.html", _render_index_document(payload)), + ("index.html", _render_analysis_document(payload)), ) destination = report_dir / "index.html" for filename, document in documents: written = _write_report_page(report_dir, filename, document) if filename == "index.html": destination = written + (report_dir / "analysis.html").unlink(missing_ok=True) logger.info( f"Generated HTML report for agent workflow {workflow_run_id}: {destination}" ) diff --git a/scarf/agent/report/rendering.py b/scarf/agent/report/rendering.py index 679c7757..8b2c703b 100644 --- a/scarf/agent/report/rendering.py +++ b/scarf/agent/report/rendering.py @@ -236,34 +236,13 @@ text-transform: uppercase; } .metric-value { font-size: .95rem; font-weight: 400; overflow-wrap: anywhere; } -.report-choice-grid, .summary-grid, .interpretation-grid { +.summary-grid, .interpretation-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr)); gap: 1rem; min-width: 0; max-width: 100%; } -.report-choice { - display: flex; - min-width: 0; - min-height: 14rem; - flex-direction: column; - border: 1px solid var(--black); - padding: 1.5rem; - color: var(--black); - text-decoration: none; -} -.report-choice:hover, .report-choice:focus-visible { - border-color: var(--blue); -} -.report-choice h2 { margin-bottom: .75rem; } -.report-choice p { max-width: 36rem; } -.report-choice-action { - margin-top: auto; - padding-top: 1.5rem; - color: var(--blue); - font-weight: 400; -} .summary-card, .interpretation-card { min-width: 0; border: 1px solid var(--black); @@ -1159,9 +1138,8 @@ def _render_metrics(metrics: Sequence[tuple[str, Any]]) -> str: def _render_report_navigation(active_page: str) -> str: links = ( - ("index", "index.html", "Report home"), - ("analysis", "analysis.html", "Analysis summary"), - ("technical", "technical.html", "Technical details"), + ("analysis", "index.html", "Analysis summary"), + ("technical", "technical.html", "Methods and evidence"), ) return ''.format( "".join( @@ -1574,15 +1552,12 @@ def _render_filtering_evidence( "state": "selected" if is_selected else "rejected", "metrics": metrics, "reason": ( - "Preserved every reviewed cell and all recorded study groups." - if is_selected - else ( - f"Removed {removed:,} additional cells without stronger " - "support." - if removed - else "Produced the same retained cell set without improving " - "the selected rule." + str( + cell_qc.get("rationale") + or "Selected the registered QC profile shown above." ) + if is_selected + else "An alternative registered QC profile with the measured retention shown above." ), } ) @@ -2222,7 +2197,7 @@ def _render_batch_evidence( outcome=outcome, introduction=( "Selection required measured technical improvement without material " - "loss of protected tissue, T2D, donor, or sex structure. A diagnostic " + "loss of the recorded protected study structure. A diagnostic " "run could still be completed when the design was not licensed for " "corrected-result selection." ), @@ -2692,54 +2667,6 @@ def _analysis_limitations(payload: Mapping[str, Any]) -> list[str]: return limitations -def _render_index_document(payload: Mapping[str, Any]) -> str: - workflow_result = _mapping(payload.get("workflowResult")) - plan = _mapping(workflow_result.get("preprocessingPlan")) - cluster_counts = _mapping(payload.get("clusterCounts")) - total_cells = sum(int(value) for value in cluster_counts.values()) - objective, _organisms, _tissues = _study_overview(payload) - assays = _report_assays(plan) - metrics = _render_metrics( - ( - ("Cells analyzed", total_cells or None), - ("Cell groups", len(cluster_counts) or None), - ("Data analyzed", _format_text_list(assays) or None), - ) - ) - body = f"""

    Completed analysis

    -

    Choose the level of detail.

    -

    {html.escape(objective)}

    - {metrics} - -
    - -
    - - -""" - return _render_report_shell( - title="Scarf analysis report", - active_page="index", - body=body, - ) - - def _render_analysis_document(payload: Mapping[str, Any]) -> str: reports = _mapping(payload.get("reports")) workflow_result = _mapping(payload.get("workflowResult")) diff --git a/tests/test_agent_beginner.py b/tests/test_agent_beginner.py new file mode 100644 index 00000000..8fbeee30 --- /dev/null +++ b/tests/test_agent_beginner.py @@ -0,0 +1,383 @@ +"""Beginner RNA entry point and exact completed-result access.""" + +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest + +pytest.importorskip("pydantic_ai") + +from scarf.agent import analyze_rna +from scarf.agent.orchestrator import api +from scarf.agent.orchestrator.models import ( + AutomatedWorkflowConfig, + AutomatedWorkflowRequest, + AutomatedWorkflowResult, + OrchestrationRequestRecord, + artifact_model_to_ref, +) +from scarf.agent.types import ArtifactReferenceModel + + +def _completed_result(root: Path) -> AutomatedWorkflowResult: + result = AutomatedWorkflowResult.get_example() + assert result.finalAnalysis is not None and result.workflowRun is not None + final = result.finalAnalysis.model_copy( + update={ + "handoffId": "", + "umap": ArtifactReferenceModel( + assay="RNA", kind="embedding", artifactId="6" * 64 + ), + "markers": ArtifactReferenceModel( + assay="RNA", kind="marker_table", artifactId="7" * 64 + ), + } + ).with_handoff_id() + values = result.model_dump(mode="json") + values.update( + zarrPath=str(root), + finalAnalysis=final.model_dump(mode="json"), + finalHandoffId=final.handoffId, + ) + values["workflowRun"]["workspace"] = "analysis" + return AutomatedWorkflowResult.model_validate(values) + + +def test_analyze_rna_passes_one_request_and_effective_budget( + monkeypatch: pytest.MonkeyPatch, +) -> None: + called: dict[str, Any] = {} + outcome = AutomatedWorkflowResult(status="abstained", notes=["Missing context"]) + + class Orchestrator: + def __init__(self, model: Any, *, config: AutomatedWorkflowConfig) -> None: + called.update(model=model, config=config) + + def run(self, request: AutomatedWorkflowRequest) -> AutomatedWorkflowResult: + called["request"] = request + return outcome + + monkeypatch.setattr(api, "AgentOrchestrator", Orchestrator) + model = object() + result = analyze_rna( + Path("study.h5ad"), + model=model, + study_context="Human blood, one donor.", + study_objective="Identify stable populations.", + assay="counts", + zarr_path=Path("study.zarr"), + max_candidates=40, + ) + assert result is outcome + assert called["model"] is model + assert called["config"].inputPolicy == "unattended" + assert called["config"].maxCandidateEvaluations == 40 + request = called["request"] + assert request.sourcePath == "study.h5ad" + assert request.zarrPath == "study.zarr" + assert request.primaryAssay == request.markerAssay == "counts" + assert request.analysisAssays == ["counts"] + assert request.ingestDirections == {} + + +@pytest.mark.parametrize( + "obsolete", + [ + "maxCandidateBranches", + "primaryInitialCandidates", + "secondaryInitialCandidates", + "integrationResolutionCandidates", + "maxGraphAssays", + "leidenSeeds", + "clusterSubsamples", + "clusterSubsampleFraction", + ], +) +def test_legacy_config_and_saved_requests_fail_explicitly(obsolete: str) -> None: + old_config = {obsolete: 1} + with pytest.raises(ValueError, match="Create a new single-RNA workflow"): + AutomatedWorkflowConfig.model_validate(old_config) + saved = OrchestrationRequestRecord.get_example().model_dump(mode="json") + saved["config"].update(old_config) + with pytest.raises(ValueError, match="cannot be resumed or regenerated"): + OrchestrationRequestRecord.model_validate(saved) + + +@pytest.mark.parametrize( + "field,values", + [ + ("hvgCandidateCounts", (1,)), + ("hvgCandidateCounts", (2, 1000)), + ("pcaCandidateDimensions", (1,)), + ("graphNeighborCandidates", (1,)), + ("leidenResolutionCandidates", (float("nan"),)), + ("leidenResolutionCandidates", (float("inf"),)), + ("leidenResolutionCandidates", (float("-inf"),)), + ], +) +def test_config_rejects_impossible_candidates_before_execution( + field: str, values: tuple[int | float, ...] +) -> None: + with pytest.raises(ValueError, match=field): + AutomatedWorkflowConfig.model_validate({field: values}) + + +def test_config_minimum_candidates_meet_the_sequential_planner_contract() -> None: + from scarf.agent.parameter_tuning.hvg import effective_hvg_candidate_counts + from scarf.agent.parameter_tuning.sequential import SequentialRnaTuningPlanner + + config = AutomatedWorkflowConfig( + hvgCandidateCounts=(3,), + pcaCandidateDimensions=(2,), + graphNeighborCandidates=(2,), + leidenResolutionCandidates=(0.25,), + ) + selected_features = effective_hvg_candidate_counts(3, config.hvgCandidateCounts) + planner = SequentialRnaTuningPlanner( + workflow_run_id="minimum-candidates", + assay="RNA", + n_cells=3, + n_features=selected_features[0], + harmony_authorized=False, + dimension_candidates=config.pcaCandidateDimensions, + neighbor_candidates=config.graphNeighborCandidates, + resolution_candidates=config.leidenResolutionCandidates, + ) + assert planner.dimensions == (2,) + assert planner.neighbors == (2,) + + +@pytest.mark.parametrize( + "routing", + [ + {"analysisAssays": ["RNA", "ADT"]}, + {"pairedAssays": ["RNA", "ADT"]}, + {"primaryAssay": "RNA", "analysisAssays": ["counts"]}, + {"primaryAssay": "RNA", "markerAssay": "ADT"}, + {"experimentalDirections": {"hypothesisTesting": {}}}, + ], +) +def test_request_rejects_unsupported_routing(routing: dict[str, Any]) -> None: + values = AutomatedWorkflowRequest.get_example().model_dump(mode="json") + with pytest.raises(ValueError): + AutomatedWorkflowRequest.model_validate({**values, **routing}) + + +def test_result_helpers_reopen_read_only_with_exact_refs_and_workspace( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import scarf.datastore.datastore as datastore_module + + result = _completed_result(tmp_path) + original = result.model_dump(mode="json") + opened: list[tuple[str, dict[str, Any]]] = [] + plotted: list[dict[str, Any]] = [] + markers: list[dict[str, Any]] = [] + plot_result, marker_table = object(), object() + + def open_store(path: str, **kwargs: Any) -> Any: + opened.append((path, kwargs)) + return SimpleNamespace( + plots=SimpleNamespace( + embedding=lambda **options: plotted.append(options) or plot_result + ), + get_markers=lambda **options: markers.append(options) or marker_table, + ) + + monkeypatch.setattr(datastore_module, "DataStore", open_store) + assert result.plot_embedding(frame="none") is plot_result + assert result.get_markers(group_id="2", min_score=0.5) is marker_table + assert all(path == str(tmp_path) for path, _ in opened) + assert all(options["zarr_mode"] == "r" for _, options in opened) + assert all(options["workspace"] == "analysis" for _, options in opened) + final = result.finalAnalysis + assert final is not None and final.umap is not None + assert final.clusters is not None and final.markers is not None + assert plotted == [ + { + "layout": artifact_model_to_ref(final.umap), + "color_by": artifact_model_to_ref(final.clusters), + "frame": "none", + } + ] + assert markers == [ + { + "marker": artifact_model_to_ref(final.markers), + "group_id": "2", + "min_score": 0.5, + "min_frac_exp": 0.2, + } + ] + assert result.model_dump(mode="json") == original + with pytest.raises(ValueError, match="completed analysis layout"): + result.plot_embedding(layout=artifact_model_to_ref(final.umap)) + + +@pytest.mark.parametrize("method", ["plot_embedding", "get_markers", "report"]) +def test_result_helpers_explain_noncompleted_outcome(method: str) -> None: + result = AutomatedWorkflowResult(notes=["Input file is missing"]) + with pytest.raises(RuntimeError, match="failed at ingest.*Input file is missing"): + getattr(result, method)() + + +def test_result_report_reuses_existing_path_and_generates_only_if_missing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import scarf.agent.report.generator as generator + + result = _completed_result(tmp_path) + assert result.workflowRun is not None + expected = ( + tmp_path + / "analysis/agents/runs" + / result.workflowRun.workflowRunId + / "report/index.html" + ) + generated: list[tuple[str, str, str | None]] = [] + + def generate(target: str, run_id: str, *, workspace: str | None) -> Path: + generated.append((target, run_id, workspace)) + expected.parent.mkdir(parents=True) + expected.write_text("Analysis", encoding="utf-8") + return expected + + monkeypatch.setattr(generator, "generate_agent_report", generate) + assert result.report() == expected + assert result.report() == expected + assert generated == [(str(tmp_path), result.workflowRun.workflowRunId, "analysis")] + + +@pytest.mark.parametrize("workspace", [None, "analysis"]) +def test_legacy_saved_config_blocks_resume_and_report_without_changing_artifacts( + tmp_path: Path, workspace: str | None +) -> None: + import hashlib + + import numpy as np + + from scarf.agent import generate_agent_report + from scarf.agent import record_io + from scarf.agent.data_enrichment.contracts import DataEnrichmentReport + from scarf.agent.orchestrator import AgentOrchestrator, journal + from scarf.agent.orchestrator.models import ( + AutomatedWorkflowResumeRequest, + FinalAnalysisHandoff, + NativeAnalysisHandoff, + ) + from scarf.agent.persistence.reports import ( + create_agent_workflow, + finalize_agent_workflow, + save_agent_report, + ) + from scarf.agent.persistence.contracts import AgentInvocation + from scarf.datastore.datastore import DataStore + from tests.agent_orchestrator_store import create_store + + path = create_store(tmp_path / "legacy.zarr", workspace=workspace) + store = DataStore( + str(path), + default_assay="RNA", + min_features_per_cell=-1, + mito_pattern="", + ribo_pattern="", + nthreads=2, + workspace=workspace, + ) + cells = store.snapshot_cell_selection() + features = store.select_all_features(from_assay="RNA") + normalized = store.run_normalization(cells, features) + original_values = np.asarray(store.load_artifact(normalized)["data"][:]) + workflow = create_agent_workflow(store, workflow_run_id="legacy-config") + prefix = journal._ensure_orchestration_store(store) + request = AutomatedWorkflowRequest( + sourcePath=str(path), + zarrPath=str(path), + studyContext="One human RNA sample.", + studyObjective="Inspect stable cell populations.", + workspace=workspace, + primaryAssay="RNA", + ) + old_config = AutomatedWorkflowConfig().model_dump(mode="json") + old_config.pop("maxCandidateEvaluations") + old_config["maxCandidateBranches"] = 24 + payload = OrchestrationRequestRecord( + workflowRunId=workflow.workflowRunId, + request=request, + requestSha256=journal._sha256_model(request), + ).model_dump(mode="json") + payload["config"] = old_config + payload["configSha256"] = hashlib.sha256( + record_io.canonical_json_bytes(old_config) + ).hexdigest() + payload["contentSha256"] = hashlib.sha256( + record_io.canonical_json_bytes( + {key: value for key, value in payload.items() if key != "contentSha256"} + ) + ).hexdigest() + request_key = journal._request_key(prefix, workflow.workflowRunId) + original_request = record_io.display_json_bytes(payload) + journal._write_key_once(store.zw, request_key, original_request) + + message = "start a new workflow.*Older saved request/config shapes" + with pytest.raises(ValueError, match=message): + AgentOrchestrator(object()).resume( + AutomatedWorkflowResumeRequest( + zarrPath=str(path), + workflowRunId=workflow.workflowRunId, + workspace=workspace, + ) + ) + save_agent_report( + store, + workflow.workflowRunId, + DataEnrichmentReport.get_example(), + invocation=AgentInvocation( + agentName="data_enrichment", inputs={"fromAssay": "RNA"} + ), + ) + workflow = finalize_agent_workflow( + store, workflow.workflowRunId, status="completed" + ) + final = FinalAnalysisHandoff( + workflowRunId=workflow.workflowRunId, + primaryAssay="RNA", + markerAssay="RNA", + cellSelection=ArtifactReferenceModel.from_artifact_ref(cells), + nativeAnalyses=[ + NativeAnalysisHandoff( + assay="RNA", + normalized=ArtifactReferenceModel.from_artifact_ref(normalized), + ) + ], + ).with_handoff_id() + terminal = AutomatedWorkflowResult( + status="completed", + currentStage="analysis_finalization", + zarrPath=str(path), + workflowRun=workflow, + reportReferences=list(workflow.reports), + finalAnalysis=final, + finalHandoffId=final.handoffId, + decisionRunId=workflow.workflowRunId, + ) + terminal.contentSha256 = journal._record_checksum(terminal) + journal._persist_terminal_result(store, prefix, workflow, terminal) + + with pytest.raises(ValueError, match=message): + generate_agent_report(path, workflow.workflowRunId, workspace=workspace) + + reopened = DataStore( + str(path), + default_assay="RNA", + min_features_per_cell=-1, + mito_pattern="", + ribo_pattern="", + nthreads=2, + zarr_mode="r", + workspace=workspace, + ) + assert record_io.read_key(reopened.zw, request_key) == original_request + np.testing.assert_array_equal( + reopened.load_artifact(normalized)["data"][:], original_values + ) diff --git a/tests/test_agent_exec.py b/tests/test_agent_exec.py index c516059a..e229566d 100644 --- a/tests/test_agent_exec.py +++ b/tests/test_agent_exec.py @@ -185,6 +185,7 @@ def test_agent_facade_exports_remain_stable() -> None: "WorkflowQuestion", "WorkflowStageAttempt", "WorkflowStageLink", + "analyze_rna", "characterize_covariates", "characterize_features", "check_runtime", diff --git a/tests/test_agent_orchestrator.py b/tests/test_agent_orchestrator.py index 428eae89..bd7a9b33 100644 --- a/tests/test_agent_orchestrator.py +++ b/tests/test_agent_orchestrator.py @@ -37,6 +37,7 @@ CovariateEvidence, ExperimentalContextDecision, ) +from scarf.agent.experimental_context.contracts import ContrastPlan from scarf.agent.parameter_tuning import ParameterTuningReport from scarf.agent.persistence import load_agent_record from scarf.agent.orchestrator import ( @@ -399,6 +400,7 @@ def test_orchestrator_package_preserves_the_public_facade() -> None: assert agent_module.AgentOrchestrator is orchestrator_module.AgentOrchestrator assert orchestrator_module.__all__ == [ "AgentOrchestrator", + "analyze_rna", "AssayPreprocessingPlan", "AutomatedPreprocessingPlan", "AutomatedWorkflowConfig", @@ -465,6 +467,12 @@ def test_rna_h5ad_completes_public_automated_workflow( model, state = _rna_workflow_model() phase_calls: list[str] = [] execute_parameter_phase = tuning_module.execute_parameter_phase + pca_diagnostic_calls: list[ArtifactRef] = [] + augment_pca = tuning_module.augment_pca_evaluations + + def track_pca_diagnostics(*args: Any, **kwargs: Any) -> Any: + pca_diagnostic_calls.append(kwargs["feature_selection"]) + return augment_pca(*args, **kwargs) def track_parameter_phase(*args: Any, **kwargs: Any) -> Any: phase_calls.append(kwargs["plan"].phase) @@ -475,15 +483,17 @@ def track_parameter_phase(*args: Any, **kwargs: Any) -> Any: "execute_parameter_phase", track_parameter_phase, ) + monkeypatch.setattr(tuning_module, "augment_pca_evaluations", track_pca_diagnostics) orchestrator = AgentOrchestrator( model, config=AutomatedWorkflowConfig( - primaryInitialCandidates=1, - secondaryInitialCandidates=1, + hvgCandidateCounts=(1000,), + pcaCandidateDimensions=(10,), + graphNeighborCandidates=(11,), + leidenResolutionCandidates=(0.75,), maxRefinedCandidatesPerAssay=0, maxHarmonyCandidatesPerAssay=0, - integrationResolutionCandidates=1, - maxCandidateBranches=1, + maxCandidateEvaluations=14, minClusterCells=2, ), ) @@ -499,6 +509,23 @@ def track_parameter_phase(*args: Any, **kwargs: Any) -> Any: markerAssay="RNA", analysisAssays=["RNA"], ) + finalize_stage = orchestrator.analysis_finalization_stage + + def finalize_with_unresolved_contrast(*args: Any, **kwargs: Any) -> Any: + kwargs["experimental"] = kwargs["experimental"].model_copy( + update={ + "contrastPlans": [ + ContrastPlan.get_blank().model_copy( + update={"coefficient": "condition", "status": "needsInput"} + ) + ] + } + ) + return finalize_stage(*args, **kwargs) + + monkeypatch.setattr( + orchestrator, "analysis_finalization_stage", finalize_with_unresolved_contrast + ) paused = orchestrator.run(request) assert paused.status == "needsInput" @@ -514,22 +541,38 @@ def track_parameter_phase(*args: Any, **kwargs: Any) -> Any: option_id for option_id in question.options if option_id != "pcaPrefix:defer" ) pca_calls_before_resume = phase_calls.count("pcaPrefix") - result = orchestrator.resume( - AutomatedWorkflowResumeRequest( - zarrPath=str(target), - workflowRunId=paused.workflowRun.workflowRunId, - answers={ - question.questionId: { - "decisionId": question.decisionId, - "optionId": selected_option, - "rationale": "Use the completed registered PCA evidence.", - } - }, - ) + pca_diagnostics_before_resume = list(pca_diagnostic_calls) + resume_request = AutomatedWorkflowResumeRequest( + zarrPath=str(target), + workflowRunId=paused.workflowRun.workflowRunId, + answers={ + question.questionId: { + "decisionId": question.decisionId, + "optionId": selected_option, + "rationale": "Use the completed registered PCA evidence.", + } + }, ) + editable = DataStore( + str(target), + default_assay="RNA", + min_features_per_cell=-1, + mito_pattern="", + ribo_pattern="", + ) + original_counts = editable.cells.fetch_all("RNA_nCounts") + changed_counts = original_counts.copy() + changed_counts[0] += 1 + editable.cells.insert("RNA_nCounts", changed_counts, overwrite=True) + with pytest.raises(ValueError, match="Tuning metadata changed"): + orchestrator.resume(resume_request) + assert phase_calls.count("pcaPrefix") == pca_calls_before_resume + editable.cells.insert("RNA_nCounts", original_counts, overwrite=True) + result = orchestrator.resume(resume_request) assert result.status == "completed", result.notes assert phase_calls.count("pcaPrefix") == pca_calls_before_resume + assert pca_diagnostic_calls == pca_diagnostics_before_resume assert state["pca_prompts"] == 1 assert result.currentStage == "analysis_finalization" assert result.workflowRun is not None @@ -573,6 +616,10 @@ def track_parameter_phase(*args: Any, **kwargs: Any) -> Any: assert final.embeddingInitialization is not None assert final.umap is not None assert final.markers is not None + assert final.statisticalTests == [] + assert final.analysisEvidence["contrastPlans"][0]["status"] == "needsInput" + assert final.analysisEvidence["contrastPlans"][0]["coefficient"] == "condition" + assert "hypothesisTests" not in final.analysisEvidence assert len(final.doubletScores) == 1 assert final.cellSelection.kind == "cell_selection" assert final.clusters.kind == "cluster_labels" diff --git a/tests/test_agent_orchestrator_journal_edges.py b/tests/test_agent_orchestrator_journal_edges.py index f7036336..7e5fe21a 100644 --- a/tests/test_agent_orchestrator_journal_edges.py +++ b/tests/test_agent_orchestrator_journal_edges.py @@ -156,7 +156,7 @@ def test_orchestration_model_validation_edges() -> None: "studyObjective": "objective", "pairedAssays": ["RNA", "RNA"], }, - "pairedAssays must be unique", + "pairedAssays is unsupported", ), ( { @@ -165,7 +165,7 @@ def test_orchestration_model_validation_edges() -> None: "studyObjective": "objective", "pairedAssays": ["RNA"], }, - "at least two", + "pairedAssays is unsupported", ), ) for values, message in invalid_requests: diff --git a/tests/test_agent_orchestrator_lifecycle.py b/tests/test_agent_orchestrator_lifecycle.py index 939ff9c2..fa6e7546 100644 --- a/tests/test_agent_orchestrator_lifecycle.py +++ b/tests/test_agent_orchestrator_lifecycle.py @@ -399,7 +399,7 @@ def test_resume_does_not_mutate_constructor_configuration( ) -> None: orchestrator, paused, path = _start_paused_workflow(tmp_path, monkeypatch) assert paused.workflowRun is not None - constructor_config = AutomatedWorkflowConfig(primaryInitialCandidates=2) + constructor_config = AutomatedWorkflowConfig(maxCandidateEvaluations=25) orchestrator.config = constructor_config completed = orchestrator.resume( diff --git a/tests/test_agent_orchestrator_stages.py b/tests/test_agent_orchestrator_stages.py index 0e3e9269..99dc8423 100644 --- a/tests/test_agent_orchestrator_stages.py +++ b/tests/test_agent_orchestrator_stages.py @@ -13,7 +13,6 @@ import scarf.agent.orchestrator.context as context_module import scarf.agent.orchestrator.journal as journal_module import scarf.agent.orchestrator.tuning as tuning_module -import scarf.agent.parameter_tuning.selection as parameter_tuning_selection from scarf.agent.orchestrator.preprocessing import PreprocessingStagesMixin from scarf.agent.config import AgentRunConfig from scarf.agent.config.agent_exec import ( @@ -39,36 +38,28 @@ AutomatedPreprocessingPlan, AutomatedWorkflowConfig, AutomatedWorkflowRequest, - PreprocessedAssayHandoff, WorkflowStageAttempt, - WorkflowStageLink, ) from scarf.agent.orchestrator.models import OrchestrationRequestRecord from scarf.agent.persistence import ( AgentInvocation, create_agent_workflow, - list_agent_reports, load_agent_record, save_agent_report, ) from scarf.agent.parameter_tuning import ( ArtifactRecord, - FinalGraphNeedsInput, - FinalGraphSelection, IntegrationCandidateEvaluation, IntegrationMetrics, ParameterCandidateEvaluation, ParameterTuningReport, - final_graph_options, finalize_parameter_tuning_selection, - select_final_parameter_graph, ) from scarf.agent.cell_quality.profiles import RegisteredCellQcProfile from scarf.agent.types import ( AgentRunInfo, ArtifactReferenceModel, BatchSafetyEvidence, - ExperimentalTuningHandoff, ) from scarf.agent.parameter_tuning.diagnostics import ( _select_capture_cells, @@ -256,7 +247,7 @@ def _planning_inputs( studyObjective="Discover stable RNA populations.", primaryAssay=primary_assay, markerAssay=marker_assay, - analysisAssays=analysis_assays or list(assays), + analysisAssays=analysis_assays or [], ) request_record = OrchestrationRequestRecord( workflowRunId="planning-test", @@ -716,7 +707,7 @@ def profile( ) -def test_preprocessing_plan_routes_supported_modalities_and_skips_others() -> None: +def test_preprocessing_plan_selects_rna_and_ignores_other_modalities() -> None: assays = { "peaks": ( "ATAC", @@ -750,25 +741,8 @@ def test_preprocessing_plan_routes_supported_modalities_and_skips_others() -> No routes["transcriptome"].featureMethod, routes["transcriptome"].reductionMethod, ) == ("graph", "hvg", "pca") - assert ( - routes["peaks"].role, - routes["peaks"].featureMethod, - routes["peaks"].reductionMethod, - routes["peaks"].reductionParameters["skipFirst"], - ) == ("graph", "prevalentPeaks", "lsi", True) - assert ( - routes["proteins"].role, - routes["proteins"].featureMethod, - routes["proteins"].reductionMethod, - ) == ("graph", "panel", "identity") - assert routes["tags"].role == "hto" - assert not routes["tags"].graphEligible - assert not routes["tags"].markerEligible - assert routes["tags"].reductionMethod == "none" - assert routes["tags"].normalizationParameters == {} - assert routes["custom"].role == "unsupported" - assert not routes["custom"].graphEligible - assert any("Unsupported assay 'custom'" in value for value in plan.limitations) + assert set(routes) == {"transcriptome"} + assert plan.pairedAssays == [] def test_converted_input_preserves_exact_selection_and_typed_qc() -> None: @@ -794,58 +768,6 @@ def test_converted_input_preserves_exact_selection_and_typed_qc() -> None: assert isinstance(plan.cellQc, CellQcPlan) -@pytest.mark.parametrize( - ("pairing_provenance", "explicit_pairing", "expected"), - [ - (None, [], []), - ("singleSourceSharedCellAxis", [], ["RNA", "ADT"]), - (None, ["RNA", "ADT"], ["RNA", "ADT"]), - ], -) -def test_multimodal_pairing_requires_persisted_or_explicit_provenance( - pairing_provenance: str | None, - explicit_pairing: list[str], - expected: list[str], -) -> None: - inputs = list( - _planning_inputs( - { - "RNA": ( - "RNA", - ["gene-1", "gene-2", "gene-3"], - ["A", "B", "C"], - ), - "ADT": ( - "ADT", - ["adt-1", "adt-2", "adt-3"], - ["CD3", "CD19", "CD45"], - ), - }, - primary_assay="RNA", - ) - ) - request_record = inputs[1] - inputs[1] = request_record.model_copy( - update={ - "request": request_record.request.model_copy( - update={"pairedAssays": explicit_pairing} - ) - } - ) - inputs[4] = inputs[4].model_copy( - update={ - "outputs": { - "format": "h5ad" if pairing_provenance else "zarr", - "pairingProvenance": pairing_provenance, - } - } - ) - - plan = AgentOrchestrator(object()).build_preprocessing_plan(*inputs) - - assert plan.pairedAssays == expected - - def test_percent_features_follow_deterministic_inspection_not_policy_lists( tmp_path: Path, ) -> None: @@ -984,7 +906,7 @@ def test_percent_features_follow_deterministic_inspection_not_policy_lists( assert "RNA_percentMito" not in store.cells.columns -def test_hto_demultiplexing_is_checkpointed_once_and_never_graph_bearing( +def test_hto_processing_is_not_executed_by_rna_workflow( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1062,6 +984,13 @@ def run_hto( ) orchestrator = AgentOrchestrator(object()) + with pytest.raises(ValueError, match="only the selected RNA assay"): + orchestrator._hto_stage( + store, workflow, request_record, [], enrichment, cell_selection + ) + enrichment = DataEnrichmentReport( + status="done", policies=[_modality_policy("RNA", "RNA")] + ) first = orchestrator._hto_stage( store, workflow, @@ -1080,121 +1009,10 @@ def run_hto( ) assert first == second - assert calls == 1 - assert first.outputs["operations"][0]["operation"] == "run_hto_demultiplexing" - assert first.artifacts["HTO_htoIdentity"].artifactId == identity_ref.artifact_id - assert "htoIdentityColumns" not in first.outputs - identity_source = NamedArtifactSource.model_validate( - first.outputs["htoIdentityArtifacts"][0] - ) - assert identity_source.name == "HTO_htoIdentity" - assert identity_source.artifact == first.artifacts[identity_source.name] - assert orchestrator._named_stage_artifacts( - first, - "htoIdentityArtifacts", - "hto_identity", - ) == [identity_source] - missing_source = first.model_copy( - update={ - "outputs": { - **first.outputs, - "htoIdentityArtifacts": [], - } - } - ) - with pytest.raises(ValueError, match="must name every persisted"): - orchestrator._named_stage_artifacts( - missing_source, - "htoIdentityArtifacts", - "hto_identity", - ) - assert all("graph" not in action for action in first.actions) - - enrichment_reference = save_agent_report( - store, - workflow.workflowRunId, - enrichment, - invocation=AgentInvocation( - agentName="data_enrichment", - inputs={"assays": ["HTO"]}, - ), - ) - captured_sources: list[NamedArtifactSource] = [] - context_example = ExperimentalContextResult.get_example() - context_report = context_example.model_copy( - update={ - "cellSelection": cell_selection, - "cellQc": CellQcPlan(), - "qcProfiles": [], - "qualityMetricArtifacts": [], - "htoIdentityColumns": [], - "htoIdentityArtifacts": [identity_source], - "decision": context_example.decision.model_copy( - update={"cellQc": CellQcPlan()} - ), - } - ) - - class CapturingContextAgent: - calls = 0 - - def __init__(self, *_args: Any, **_kwargs: Any) -> None: - self.config = AgentRunConfig() - - def run( - self, - *_args: Any, - quality_metric_artifacts: list[NamedArtifactSource], - hto_identity_artifacts: list[NamedArtifactSource], - **_kwargs: Any, - ) -> ExperimentalContextResult: - type(self).calls += 1 - assert quality_metric_artifacts == [] - captured_sources.extend(hto_identity_artifacts) - return context_report - - monkeypatch.setattr( - context_module, - "ExperimentalContextAgent", - CapturingContextAgent, - ) - context_outcome, _ = orchestrator.experimental_context_stage( - store, - workflow, - request_record, - [journal_module._parent_link(first)], - cell_selection, - enrichment_reference, - [], - [identity_source], - {}, - ) - - assert context_outcome.status == "done" - assert captured_sources == [identity_source] - assert context_outcome.artifacts[identity_source.name] == identity_source.artifact - context_record = load_agent_record( - store, - context_outcome.reportReferences[0], - ) - assert ( - context_record.invocation.artifacts[identity_source.name] - == identity_source.artifact - ) - reused_context, reused_report = orchestrator.experimental_context_stage( - store, - workflow, - request_record, - [journal_module._parent_link(first)], - cell_selection, - enrichment_reference, - [], - [identity_source], - {}, - ) - assert reused_context == context_outcome - assert reused_report == context_report - assert CapturingContextAgent.calls == 1 + assert first.status == "done" + assert calls == 0 + assert first.outputs["htoIdentityArtifacts"] == [] + assert all(ref.kind != "hto_identity" for ref in first.artifacts.values()) def test_selected_sample_mad_qc_passes_exact_artifact_sources() -> None: @@ -1297,424 +1115,11 @@ def auto_filter_cells(self, **kwargs: Any) -> ArtifactRef: assert operations[0]["artifactMetrics"] == [metric.model_dump(mode="json")] -@pytest.mark.parametrize( - ("assay_types", "explicit", "expected"), - [ - (["ATAC", "ADT", "RNA"], None, "RNA"), - (["ATAC", "ADT"], None, "ADT"), - (["ATAC"], None, "ATAC"), - (["RNA", "ADT", "ATAC"], "ATAC", "ATAC"), - ], -) -def test_marker_assay_precedence( - assay_types: list[str], - explicit: str | None, - expected: str, -) -> None: - assays = { - assay_type: ( - assay_type, - [f"{assay_type}-1", f"{assay_type}-2", f"{assay_type}-3"], - [f"{assay_type}-1", f"{assay_type}-2", f"{assay_type}-3"], - ) - for assay_type in assay_types - } - - plan = _build_plan( - assays, - primary_assay=assay_types[0], - marker_assay=explicit, - ) - - assert plan.primaryAssay == assay_types[0] - assert plan.markerAssay == expected - - -def test_adt_identity_limit_and_exact_observed_control_exclusion() -> None: - assays = { - "ADT": ( - "ADT", - ["adt-1", "control-id", "adt-2", "adt-3"], - [ - "CD3", - "Mouse IgG1 isotype control", - "control response protein", - "CD19", - ], - ) - } - controls = { - "ADT": [ - FeatureReference( - featureId="control-id", - featureName="Mouse IgG1 isotype control", - ) - ] - } - - identity = _build_plan( - assays, - controls=controls, - exclude_features={"ADT": ["adt-1"]}, - artificial_features={"ADT": ["adt-2"]}, - config=AutomatedWorkflowConfig(maxIdentityFeatures=3), - ).assays[0] - pca = _build_plan( - assays, - controls=controls, - exclude_features={"ADT": ["adt-1"]}, - artificial_features={"ADT": ["adt-2"]}, - config=AutomatedWorkflowConfig(maxIdentityFeatures=2), - ).assays[0] - - assert identity.exactExcludedFeatures == [ - "control-id", - "Mouse IgG1 isotype control", - ] - assert identity.reductionMethod == "identity" - assert identity.reductionParameters["dimensions"] == 3 - assert pca.reductionMethod == "pca" - assert pca.reductionParameters["dimensions"] == 2 - - -def test_atac_invalid_coordinates_are_limited_without_changing_lsi_route() -> None: - assays = { - "ATAC": ( - "ATAC", - ["chr1:1-20", "not-a-coordinate", "chr2:10-30"], - ["peak-1", "peak-2", "peak-3"], - ) - } - - plan = _build_plan(assays, peak_statuses={"ATAC": "invalid"}) - route = plan.assays[0] - - assert route.graphEligible - assert route.featureMethod == "prevalentPeaks" - assert route.reductionMethod == "lsi" - assert route.reductionParameters == {"dimensions": 50, "skipFirst": True} - assert route.limitations == [ - "ATAC feature coordinates are not uniformly valid chrom:start-end " - "intervals; the genome build remains unknown" - ] - - -@pytest.mark.parametrize( - ("method", "n_cells", "n_features", "expected_dimensions"), - [ - ("pca", 5, 3, {2}), - ("lsi", 6, 4, {3}), - ("identity", 4, 2, {2}), - ], -) -def test_initial_candidates_are_rank_valid( - method: str, - n_cells: int, - n_features: int, - expected_dimensions: set[int], -) -> None: - orchestrator = AgentOrchestrator(object()) - handoff = PreprocessedAssayHandoff( - assay="assay", - assayType="ADT" if method == "identity" else method.upper(), - reductionMethod=method, - normalized=ArtifactReferenceModel( - assay="assay", - kind="normalized", - artifactId="1" * 64, - ), - nCells=n_cells, - nFeatures=n_features, - ) - - candidates = orchestrator.initial_parameter_candidates( - "rank-test", - handoff, - count=5, - neighbors_k=n_cells - 1, - ) - - assert len(candidates) == 5 - assert {value.dimensions for value in candidates} == expected_dimensions - assert all(2 <= value.dimensions for value in candidates) - if method != "identity": - assert all(value.dimensions < min(n_cells, n_features) for value in candidates) - assert all(2 <= value.neighborsK < n_cells for value in candidates) - - -def test_initial_candidates_reject_fully_invalid_rank_or_neighbor_count() -> None: - orchestrator = AgentOrchestrator(object()) - rank_invalid = PreprocessedAssayHandoff( - assay="RNA", - assayType="RNA", - reductionMethod="pca", - normalized=ArtifactReferenceModel.get_example(), - nCells=4, - nFeatures=2, - ) - identity_invalid = rank_invalid.model_copy( - update={"assayType": "ADT", "reductionMethod": "identity", "nFeatures": 1} - ) - - with pytest.raises(ValueError, match="no rank-valid graph candidate"): - orchestrator.initial_parameter_candidates( - "rank-test", - rank_invalid, - count=3, - neighbors_k=3, - ) - with pytest.raises(ValueError, match="no rank-valid graph candidate"): - orchestrator.initial_parameter_candidates( - "rank-test", - identity_invalid, - count=3, - neighbors_k=3, - ) - with pytest.raises(ValueError, match="no rank-valid graph candidate"): - orchestrator.initial_parameter_candidates( - "rank-test", - rank_invalid.model_copy(update={"nFeatures": 3}), - count=3, - neighbors_k=4, - ) - - def test_parameter_tuning_rejects_more_than_one_refinement_candidate() -> None: with pytest.raises(ValueError, match="less than or equal to 1"): AutomatedWorkflowConfig(maxRefinedCandidatesPerAssay=2) -def test_final_selection_pause_exposes_exact_options_and_resumes_without_screen( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - path = create_store(tmp_path / "selection-resume.zarr") - store = DataStore(str(path), default_assay="RNA", min_features_per_cell=0) - workflow = create_agent_workflow(store, workflow_run_id="selection-resume") - enrichment = DataEnrichmentReport.get_example().model_copy( - update={ - "runInfo": AgentRunInfo( - agentName="data_enrichment", - runId=uuid.uuid4().hex, - ) - } - ) - experimental = ExperimentalContextResult.get_example().model_copy( - update={ - "runInfo": AgentRunInfo( - agentName="experimental_context", - runId=uuid.uuid4().hex, - ) - } - ) - enrichment_reference = save_agent_report( - store, - workflow.workflowRunId, - enrichment, - invocation=AgentInvocation( - agentName="data_enrichment", - inputs={"studyContext": "A final-selection resume test."}, - ), - ) - experimental_reference = save_agent_report( - store, - workflow.workflowRunId, - experimental, - invocation=AgentInvocation( - agentName="experimental_context", - inputs={"cellSelection": _cell_selection_model().model_dump(mode="json")}, - artifacts={"cellSelection": _cell_selection_model()}, - ), - ) - request_record = OrchestrationRequestRecord( - workflowRunId=workflow.workflowRunId, - request=AutomatedWorkflowRequest( - sourcePath=str(path), - zarrPath=str(path), - studyContext="A final-selection resume test.", - studyObjective="Discover stable RNA populations.", - ), - ) - plan = AutomatedPreprocessingPlan( - primaryAssay="RNA", - markerAssay="RNA", - assays=[ - AssayPreprocessingPlan.get_example(), - AssayPreprocessingPlan( - assay="ADT", - assayType="ADT", - role="graph", - graphEligible=True, - markerEligible=True, - featureMethod="panel", - reductionMethod="identity", - ), - ], - pairedAssays=["RNA", "ADT"], - ) - preprocessed = [ - PreprocessedAssayHandoff.get_example(), - PreprocessedAssayHandoff( - assay="ADT", - assayType="ADT", - cellSelection=_cell_selection_model(), - reductionMethod="identity", - normalized=ArtifactReferenceModel( - assay="ADT", - kind="normalized", - artifactId="2" * 64, - ), - nCells=100, - nFeatures=5, - ), - ] - integration = _eligible_integration() - calls = {"screen": 0, "promote": 0, "integrate": 0, "selection": 0} - - def selection_execution(**_kwargs: Any) -> Any: - return SimpleNamespace( - output=FinalGraphSelection( - status="needsInput", - rationale="The supplied evidence does not resolve the final graph.", - needsInput=FinalGraphNeedsInput( - question="An unconstrained provider question.", - options=["invented-option"], - ), - ), - runInfo=AgentRunInfo( - agentName="parameter_tuning_final_graph", - runId=uuid.uuid4().hex, - ), - ) - - monkeypatch.setattr( - parameter_tuning_selection, - "run_agent_sync", - selection_execution, - ) - - class CountingAgent: - config = AgentRunConfig() - - def run_batch(self, *_args: Any, **_kwargs: Any) -> ParameterTuningReport: - calls["screen"] += 1 - return _native_batch_report("RNA", "ADT") - - def promote(self, *_args: Any, **_kwargs: Any) -> None: - calls["promote"] += 1 - - def select_final( - self, - *, - report: ParameterTuningReport, - integration_evaluations: list[IntegrationCandidateEvaluation], - marker_assay: str, - ) -> ParameterTuningReport: - calls["selection"] += 1 - return select_final_parameter_graph( - model=object(), - report=report, - integration_evaluations=integration_evaluations, - marker_assay=marker_assay, - config=self.config, - ) - - monkeypatch.setattr( - tuning_module, - "ParameterTuningAgent", - lambda *_args, **_kwargs: CountingAgent(), - ) - orchestrator = AgentOrchestrator(object()) - monkeypatch.setattr( - orchestrator, - "_augment_legacy_scientific_evidence", - lambda _store, report, **_kwargs: report, - ) - - def evaluate_integrations(*_args: Any, **_kwargs: Any) -> list[Any]: - calls["integrate"] += 1 - return [integration] - - monkeypatch.setattr(orchestrator, "evaluate_integrations", evaluate_integrations) - real_validated_outcome = journal_module._validated_done_outcome - paused_attempts: list[WorkflowStageAttempt] = [] - - def validated_outcome( - target_store: Any, - prefix: str, - workflow_run_id: str, - stage: str, - record: OrchestrationRequestRecord, - parents: list[WorkflowStageLink], - *, - required_status: str = "done", - ) -> WorkflowStageAttempt | None: - if stage == "parameter_tuning": - if required_status == "needsInput" and paused_attempts: - return paused_attempts[-1] - return None - return real_validated_outcome( - target_store, - prefix, - workflow_run_id, - stage, - record, - parents, - required_status=required_status, - ) - - monkeypatch.setattr( - journal_module, - "_validated_done_outcome", - validated_outcome, - ) - paused, paused_report = orchestrator.parameter_tuning_stage( - store, - workflow, - request_record, - [], - plan, - preprocessed, - experimental, - enrichment_reference, - experimental_reference, - {}, - ) - paused_attempts.append(paused) - expected_options = sorted(final_graph_options(paused_report, [integration])) - - assert paused.status == "needsInput" - assert paused.needsInput is not None - assert paused.needsInput.questions[0].questionId == "finalGraphOptionId" - assert paused.needsInput.questions[0].options == expected_options - assert paused_report.needsInput is not None - assert paused_report.needsInput.options == expected_options - assert paused_report.finalSelection is not None - assert paused_report.finalSelection.needsInput is not None - assert paused_report.finalSelection.needsInput.options == expected_options - assert paused_report.totalCandidates == 3 - - completed, completed_report = orchestrator.parameter_tuning_stage( - store, - workflow, - request_record, - [], - plan, - preprocessed, - experimental, - enrichment_reference, - experimental_reference, - {"finalGraphOptionId": "native:RNA:baseline"}, - ) - - assert completed.status == "done" - assert completed_report.status == "done" - assert completed_report.finalSelection is not None - assert completed_report.finalSelection.selectedOptionId == "native:RNA:baseline" - assert completed_report.totalCandidates == 3 - assert calls == {"screen": 1, "promote": 2, "integrate": 1, "selection": 1} - - def test_integration_evaluations_contribute_to_final_candidate_count() -> None: report = _native_batch_report("RNA", "ADT") integration = _eligible_integration() @@ -1730,486 +1135,6 @@ def test_integration_evaluations_contribute_to_final_candidate_count() -> None: assert finalized.totalCandidates == 3 -def test_long_assay_names_produce_bounded_unique_candidate_ids() -> None: - orchestrator = AgentOrchestrator(object()) - prefix = "Very long assay name with punctuation / and spaces " + "x" * 120 - first = PreprocessedAssayHandoff( - assay=f"{prefix} one", - assayType="RNA", - reductionMethod="pca", - normalized=ArtifactReferenceModel.get_example(), - nCells=100, - nFeatures=50, - ) - second = first.model_copy(update={"assay": f"{prefix} two"}) - - first_ids = { - candidate.candidateId - for candidate in orchestrator.initial_parameter_candidates( - "long-name-test", - first, - count=5, - neighbors_k=11, - ) - } - second_ids = { - candidate.candidateId - for candidate in orchestrator.initial_parameter_candidates( - "long-name-test", - second, - count=5, - neighbors_k=11, - ) - } - - assert first_ids.isdisjoint(second_ids) - assert all(len(candidate_id) <= 64 for candidate_id in first_ids | second_ids) - assert all( - all( - character.isdigit() or character.islower() or character in {"_", "-"} - for character in candidate_id - ) - for candidate_id in first_ids | second_ids - ) - assert all( - len(f"{candidate_id}_harmony") <= 64 for candidate_id in first_ids | second_ids - ) - - -def test_single_integration_resolution_is_centered_and_workflow_unique() -> None: - report = _native_batch_report("RNA", "ADT") - primary_report = report.assayReports["RNA"] - primary_evaluation = primary_report.evaluations[0] - centered_evaluation = primary_evaluation.model_copy( - update={ - "parameters": primary_evaluation.parameters.model_copy( - update={"leidenResolution": 1.25} - ) - } - ) - primary_report = primary_report.model_copy( - update={"evaluations": [centered_evaluation]} - ) - report = report.model_copy( - update={ - "evaluations": [centered_evaluation], - "assayReports": { - **report.assayReports, - "RNA": primary_report, - }, - } - ) - - class IntegrationStore: - def __init__(self) -> None: - self.integration_calls: list[dict[str, Any]] = [] - self.cluster_calls: list[dict[str, Any]] = [] - - def load_artifact(self, reference: ArtifactRef) -> dict[str, np.ndarray]: - if reference.kind == "integrated_graph": - return {"modality_weights": np.full((4, 2), 0.5)} - return {"values": np.asarray([0, 0, 1, 1])} - - def integrate_assays( - self, - sources: list[ArtifactRef], - **kwargs: Any, - ) -> ArtifactRef: - self.integration_calls.append({"sources": sources, **kwargs}) - token = len(self.integration_calls) - return ArtifactRef( - scope="datastore", - kind="integrated_graph", - artifact_id=f"{100 + token:064x}", - ) - - def run_leiden_clustering( - self, - graph: ArtifactRef, - **kwargs: Any, - ) -> ArtifactRef: - self.cluster_calls.append({"graph": graph, **kwargs}) - token = len(self.cluster_calls) - return ArtifactRef( - scope="datastore", - kind="cluster_labels", - artifact_id=f"{200 + token:064x}", - ) - - store = IntegrationStore() - plan = AutomatedPreprocessingPlan( - primaryAssay="RNA", - markerAssay="RNA", - pairedAssays=["RNA", "ADT"], - ) - evaluations = AgentOrchestrator(object()).evaluate_integrations( - store, - "integration-center", - plan, - report, - ExperimentalTuningHandoff( - cellSelection=_cell_selection_model(), - batchAction="skip", - ), - AutomatedWorkflowConfig( - integrationResolutionCandidates=1, - minClusterCells=1, - ), - ) - - assert len(evaluations) == 2 - assert {value.method for value in evaluations} == {"snn", "wnn"} - assert {value.resolution for value in evaluations} == {1.25} - assert len(store.integration_calls) == 2 - assert all(call["invalidate_cache"] is True for call in store.integration_calls) - assert {call["method"] for call in store.integration_calls} == {"snn", "wnn"} - for call in store.integration_calls: - expected_kind = "connectivity_map" if call["method"] == "snn" else "neighbors" - assert {source.kind for source in call["sources"]} == {expected_kind} - assert "label" not in call - assert len(store.cluster_calls) == 2 - assert {call["resolution"] for call in store.cluster_calls} == {1.25} - - -def test_integration_requires_trusted_label_connectivity() -> None: - class IntegrationStore: - def load_artifact(self, reference: ArtifactRef) -> dict[str, np.ndarray]: - if reference.kind == "integrated_graph": - return {"modality_weights": np.full((4, 2), 0.5)} - return {"values": np.asarray([0, 0, 1, 1])} - - def integrate_assays( - self, - _sources: list[ArtifactRef], - **_kwargs: Any, - ) -> ArtifactRef: - return ArtifactRef( - scope="datastore", - kind="integrated_graph", - artifact_id="7" * 64, - ) - - def run_leiden_clustering( - self, - _graph: ArtifactRef, - **_kwargs: Any, - ) -> ArtifactRef: - return ArtifactRef( - scope="datastore", - kind="cluster_labels", - artifact_id="8" * 64, - ) - - def metric_graph_connectivity(self, *_args: Any, **_kwargs: Any) -> float: - raise ValueError("trusted label is unavailable") - - evaluations = AgentOrchestrator(object()).evaluate_integrations( - IntegrationStore(), - "integration-connectivity", - AutomatedPreprocessingPlan( - primaryAssay="RNA", - markerAssay="RNA", - pairedAssays=["RNA", "ADT"], - ), - _native_batch_report("RNA", "ADT"), - ExperimentalTuningHandoff( - cellSelection=_cell_selection_model(), - batchAction="skip", - preservationColumns=["trusted_cell_type"], - ), - AutomatedWorkflowConfig( - integrationResolutionCandidates=1, - minClusterCells=1, - ), - ) - - assert evaluations - assert all(not value.eligible for value in evaluations) - assert all( - any( - "trusted-label connectivity" in reason - for reason in value.eligibilityReasons - ) - for value in evaluations - ) - - -def test_integration_checkpoints_prevent_retry_execution( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - path = create_store(tmp_path / "integration-checkpoint.zarr") - store = DataStore( - str(path), - default_assay="RNA", - min_features_per_cell=-1, - mito_pattern="", - ribo_pattern="", - zarr_mode="r+", - ) - workflow = create_agent_workflow(store, workflow_run_id="integration-checkpoint") - request_record = OrchestrationRequestRecord( - workflowRunId=workflow.workflowRunId, - request=AutomatedWorkflowRequest( - sourcePath=str(path), - zarrPath=str(path), - studyContext="A deterministic integration retry test.", - studyObjective="Discover stable RNA populations.", - ), - config=AutomatedWorkflowConfig(), - ) - prefix = journal_module._ensure_orchestration_store(store) - started = journal_module._start_attempt( - store.zw, - prefix, - workflow.workflowRunId, - "parameter_tuning", - request_record, - [], - inputs={"semanticInput": "stable"}, - ) - integration_calls: list[dict[str, Any]] = [] - cluster_calls: list[dict[str, Any]] = [] - - def load_artifact(reference: ArtifactRef) -> dict[str, np.ndarray]: - if reference.kind == "integrated_graph": - return {"modality_weights": np.full((4, 2), 0.5)} - return {"values": np.asarray([0, 0, 1, 1])} - - def integrate_assays( - sources: list[ArtifactRef], - **kwargs: Any, - ) -> ArtifactRef: - integration_calls.append({"sources": sources, **kwargs}) - return ArtifactRef( - scope="datastore", - kind="integrated_graph", - artifact_id=f"{100 + len(integration_calls):064x}", - ) - - def cluster(graph: ArtifactRef, **kwargs: Any) -> ArtifactRef: - cluster_calls.append({"graph": graph, **kwargs}) - return ArtifactRef( - scope="datastore", - kind="cluster_labels", - artifact_id=f"{200 + len(cluster_calls):064x}", - ) - - monkeypatch.setattr(store, "load_artifact", load_artifact) - monkeypatch.setattr(store, "integrate_assays", integrate_assays) - monkeypatch.setattr(store, "run_leiden_clustering", cluster) - plan = AutomatedPreprocessingPlan( - primaryAssay="RNA", - markerAssay="RNA", - pairedAssays=["RNA", "ADT"], - ) - report = _native_batch_report("RNA", "ADT") - config = AutomatedWorkflowConfig( - integrationResolutionCandidates=1, - minClusterCells=1, - ) - first_actions: list[str] = [] - orchestrator = AgentOrchestrator(object()) - - first = orchestrator.evaluate_integrations( - store, - workflow.workflowRunId, - plan, - report, - ExperimentalTuningHandoff( - cellSelection=_cell_selection_model(), - batchAction="skip", - ), - config, - started=started, - actions=first_actions, - ) - retried = started.model_copy( - update={"attemptId": "retry-attempt", "startedAtNs": started.startedAtNs + 1} - ) - retry_actions: list[str] = [] - second = orchestrator.evaluate_integrations( - store, - workflow.workflowRunId, - plan, - report, - ExperimentalTuningHandoff( - cellSelection=_cell_selection_model(), - batchAction="skip", - ), - config, - started=retried, - actions=retry_actions, - ) - - assert second == first - assert len(integration_calls) == 2 - assert len(cluster_calls) == 2 - assert first_actions == [ - "checkpoint_integration:snn", - "checkpoint_integration:wnn", - ] - assert retry_actions == [ - "recover_integration_checkpoint:snn", - "recover_integration_checkpoint:wnn", - ] - checkpoint_reports = list_agent_reports( - store, - workflow.workflowRunId, - agent_name="parameter_tuning", - ) - assert len(checkpoint_reports) == 2 - assert all("_integration_" in value.agentRunId for value in checkpoint_reports) - - -def test_preprocess_atac_and_adt_feature_routes() -> None: - class Features: - def __init__(self, ids: list[str], names: list[str]) -> None: - self.N = len(ids) - self.values = {"ids": np.asarray(ids), "names": np.asarray(names)} - - def fetch_all(self, column: str) -> np.ndarray: - return self.values[column] - - class Store: - def __init__(self) -> None: - self.assays = { - "ATAC": SimpleNamespace( - feats=Features( - ["chr1:1-10", "chr1:20-30", "chr2:1-10", "chr2:20-30"], - ["peak1", "peak2", "peak3", "peak4"], - ) - ), - "ADT": SimpleNamespace( - feats=Features( - ["adt1", "adt2", "adt3", "adt4"], - ["CD3", "control", "CD19", "CD45"], - ) - ), - } - self.masks: list[np.ndarray] = [] - - def get_assay(self, assay: str) -> Any: - return self.assays[assay] - - @staticmethod - def select_prevalent_peaks(*_args: Any, **_kwargs: Any) -> ArtifactRef: - return ArtifactRef( - scope="assay", - assay="ATAC", - kind="feature_selection", - artifact_id="1" * 64, - ) - - def set_feature_selection( - self, *, from_assay: str, mask: np.ndarray, **_kwargs: Any - ) -> ArtifactRef: - self.masks.append(mask.copy()) - return ArtifactRef( - scope="assay", - assay=from_assay, - kind="feature_selection", - artifact_id=("2" if from_assay == "ADT" else "3") * 64, - ) - - @staticmethod - def run_normalization( - _selection: ArtifactRef, *, features: ArtifactRef, **_kwargs: Any - ) -> ArtifactRef: - return ArtifactRef( - scope="assay", - assay=features.assay, - kind="normalized", - artifact_id=("4" if features.assay == "ATAC" else "5") * 64, - ) - - def load_artifact(self, ref: ArtifactRef) -> dict[str, np.ndarray]: - selected = 3 if ref.assay == "ATAC" else int(self.masks[-1].sum()) - return {"values": np.asarray([True] * selected + [False] * (4 - selected))} - - store = Store() - selection = ArtifactRef( - scope="datastore", - kind="cell_selection", - artifact_id="c" * 64, - ) - selection_model = ArtifactReferenceModel.from_artifact_ref(selection) - orchestrator = AgentOrchestrator(object()) - actions: list[str] = [] - operations: list[dict[str, Any]] = [] - artifacts: dict[str, ArtifactReferenceModel] = {} - atac = AssayPreprocessingPlan( - assay="ATAC", - assayType="ATAC", - role="graph", - graphEligible=True, - markerEligible=True, - featureMethod="prevalentPeaks", - reductionMethod="lsi", - featureParameters={"topN": 10, "minCells": 1}, - normalizationParameters={"logTransform": False, "renormalizeSubset": False}, - ) - atac_handoff = orchestrator.preprocess_assay( - store, - atac, - cell_selection=selection, - cell_selection_model=selection_model, - active_cells=12, - actions=actions, - operations=operations, - artifacts=artifacts, - ) - assert atac_handoff.nFeatures == 3 - assert operations[0]["topN"] == 3 - - adt = AssayPreprocessingPlan( - assay="ADT", - assayType="ADT", - role="graph", - graphEligible=True, - markerEligible=True, - featureMethod="panel", - reductionMethod="identity", - exactExcludedFeatures=["control", "adt4"], - normalizationParameters={"logTransform": True, "renormalizeSubset": True}, - ) - adt_handoff = orchestrator.preprocess_assay( - store, - adt, - cell_selection=selection, - cell_selection_model=selection_model, - active_cells=12, - actions=actions, - operations=operations, - artifacts=artifacts, - ) - assert adt_handoff.nFeatures == 2 - np.testing.assert_array_equal(store.masks[-1], [True, False, True, False]) - - with pytest.raises(ValueError, match="fewer than two non-control"): - orchestrator.preprocess_assay( - store, - adt.model_copy(update={"exactExcludedFeatures": ["adt2", "adt3", "adt4"]}), - cell_selection=selection, - cell_selection_model=selection_model, - active_cells=12, - actions=[], - operations=[], - artifacts={}, - ) - with pytest.raises(ValueError, match="Unsupported feature route"): - orchestrator.preprocess_assay( - store, - adt.model_copy(update={"featureMethod": "none"}), - cell_selection=selection, - cell_selection_model=selection_model, - active_cells=12, - actions=[], - operations=[], - artifacts={}, - ) - - def test_capture_cell_selections_are_exact_and_idempotent(tmp_path: Path) -> None: path = create_store(tmp_path / "capture-selections.zarr") store = DataStore( @@ -2697,75 +1622,44 @@ def test_harmony_acceptance_requires_improvement_without_biological_loss( assert bool(reasons) is not expected -def test_preprocessing_plan_rejects_invalid_assay_routing() -> None: - orchestrator = AgentOrchestrator(object()) - unsupported = _planning_inputs( - {"custom": ("CRISPR", ["guide"], ["guide"])}, - ) - with pytest.raises(ValueError, match="No supported graph-bearing"): - orchestrator.build_preprocessing_plan(*unsupported) - - too_many = list( - _planning_inputs( - { - "RNA": ("RNA", ["g1", "g2", "g3"], ["G1", "G2", "G3"]), - "ADT": ("ADT", ["a1", "a2", "a3"], ["A1", "A2", "A3"]), - }, - config=AutomatedWorkflowConfig(maxGraphAssays=1), - ) - ) - with pytest.raises(ValueError, match="Too many graph-bearing"): - orchestrator.build_preprocessing_plan(*too_many) - - duplicate = list( - _planning_inputs( - { - "RNA1": ("RNA", ["g1", "g2", "g3"], ["G1", "G2", "G3"]), - "RNA2": ("RNA", ["g4", "g5", "g6"], ["G4", "G5", "G6"]), - } - ) - ) - request_record = duplicate[1] - duplicate[1] = request_record.model_copy( - update={ - "request": request_record.request.model_copy(update={"analysisAssays": []}) - } - ) - with pytest.raises(ValueError, match="same-kind biological assays"): - orchestrator.build_preprocessing_plan(*duplicate) - - base = list( - _planning_inputs({"RNA": ("RNA", ["g1", "g2", "g3"], ["G1", "G2", "G3"])}) - ) - request_record = base[1] - for updates, message in ( - ({"primaryAssay": "missing"}, "primaryAssay"), - ({"markerAssay": "missing"}, "markerAssay"), - ({"pairedAssays": ["RNA", "missing"]}, "non-graph assays"), - ): - values = list(base) - values[1] = request_record.model_copy( - update={"request": request_record.request.model_copy(update=updates)} - ) - with pytest.raises(ValueError, match=message): - orchestrator.build_preprocessing_plan(*values) - - paired = list( - _planning_inputs( +@pytest.mark.parametrize( + "assays,updates,message", + [ + ({"custom": ("CRISPR", ["g"], ["G"])}, {}, "requires one RNA"), + ( { - "RNA": ("RNA", ["g1", "g2", "g3"], ["G1", "G2", "G3"]), - "ADT": ("ADT", ["a1", "a2", "a3"], ["A1", "A2", "A3"]), + "RNA1": ("RNA", ["a", "b", "c"], ["A", "B", "C"]), + "RNA2": ("RNA", ["d", "e", "f"], ["D", "E", "F"]), }, - primary_assay="RNA", - ) - ) - request_record = paired[1] - paired[1] = request_record.model_copy( - update={ - "request": request_record.request.model_copy( - update={"pairedAssays": ["ADT"]} - ) - } + {}, + "found 2", + ), + ( + {"RNA": ("RNA", ["a", "b", "c"], ["A", "B", "C"])}, + {"primaryAssay": "missing"}, + "Unknown requested RNA", + ), + ( + {"RNA": ("RNA", ["a", "b", "c"], ["A", "B", "C"])}, + {"markerAssay": "missing"}, + "markerAssay", + ), + ( + {"RNA": ("RNA", ["a", "b", "c"], ["A", "B", "C"])}, + {"pairedAssays": ["RNA", "other"]}, + "pairedAssays", + ), + ], +) +def test_preprocessing_plan_rejects_invalid_assay_routing( + assays: dict[str, Any], + updates: dict[str, Any], + message: str, +) -> None: + inputs = list(_planning_inputs(assays)) + record = inputs[1] + inputs[1] = record.model_copy( + update={"request": record.request.model_copy(update=updates)} ) - with pytest.raises(ValueError, match="must include the primary"): - orchestrator.build_preprocessing_plan(*paired) + with pytest.raises(ValueError, match=message): + AgentOrchestrator(object()).build_preprocessing_plan(*inputs) diff --git a/tests/test_agent_report.py b/tests/test_agent_report.py index d583d86c..66632147 100644 --- a/tests/test_agent_report.py +++ b/tests/test_agent_report.py @@ -652,9 +652,8 @@ def test_public_report_generates_branded_readable_html_and_relative_plots( immutable_record.write_bytes(b'{"immutable":true}\n') report_path = generate_agent_report(root, "report-workflow") - analysis_path = report_path.with_name("analysis.html") + analysis_path = report_path technical_path = report_path.with_name("technical.html") - landing_markup = report_path.read_text(encoding="utf-8") analysis_markup = analysis_path.read_text(encoding="utf-8") technical_markup = technical_path.read_text(encoding="utf-8") @@ -663,18 +662,14 @@ def test_public_report_generates_branded_readable_html_and_relative_plots( assert analysis_path.is_file() assert technical_path.is_file() assert immutable_record.read_bytes() == b'{"immutable":true}\n' - for markup in (landing_markup, analysis_markup, technical_markup): + for markup in (analysis_markup, technical_markup): assert 'href="index.html"' in markup - assert 'href="analysis.html"' in markup + assert 'href="analysis.html"' not in markup assert 'href="technical.html"' in markup assert 'href="https://www.nygen.io/"' in markup assert ">Nygen Analytics" in markup - assert 'href="https://www.nygen.io/products/scarfweb"' in landing_markup - assert ( - "Distributed, secure infrastructure for intuitive secondary analysis, " - "browser-native." - ) in landing_markup - assert "Choose the level of detail" in landing_markup + assert "Choose the level of detail" not in analysis_markup + assert not report_path.with_name("analysis.html").exists() assert "Analysis decision tree" in analysis_markup assert '
    None: + experimental = { + "qcProfiles": [ + { + "profileId": "selected-qc", + "registeredProfile": "globalMad5", + "activeCells": 100, + "retainedCells": 90, + } + ] + } + plan = { + "cellQc": { + "profileId": "selected-qc", + "rationale": "Remove low-quality cells while retaining the study groups.", + } + } + markup = report_rendering._render_filtering_evidence(experimental, plan) + assert "90 of 100" in markup + assert "Removed: 10" in markup + assert plan["cellQc"]["rationale"] in markup + assert "Preserved every reviewed cell" not in markup + + def test_report_remains_available_when_optional_plots_fail( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -914,7 +933,7 @@ def test_report_remains_available_when_optional_plots_fail( ) report_path = generate_agent_report(root, "report-workflow") - analysis_markup = report_path.with_name("analysis.html").read_text(encoding="utf-8") + analysis_markup = report_path.read_text(encoding="utf-8") technical_markup = report_path.with_name("technical.html").read_text( encoding="utf-8" ) diff --git a/tests/test_agent_rna_workflow_scope.py b/tests/test_agent_rna_workflow_scope.py new file mode 100644 index 00000000..00dcf0bf --- /dev/null +++ b/tests/test_agent_rna_workflow_scope.py @@ -0,0 +1,258 @@ +"""RNA selection, early rejection, and immutable resume boundaries.""" + +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import numpy as np +import pytest +import zarr + +from scarf.agent.data_enrichment.contracts import DataEnrichmentReport +from scarf.agent.experimental_context.characterization import _SelectionBoundCells +from scarf.agent.experimental_context.contracts import ExperimentalContextDependencies +from scarf.agent.experimental_context.qc_evidence import ( + _offered_qc_profiles, + _qc_driver, +) +from scarf.agent.orchestrator import ( + AgentOrchestrator, + AutomatedWorkflowRequest, + AutomatedWorkflowResumeRequest, +) +from scarf.agent.orchestrator import context as context_module +from scarf.agent.orchestrator import journal, main as main_module +from scarf.agent.orchestrator.models import PreprocessedAssayHandoff, WorkflowStageName +from scarf.agent.orchestrator.rna import selected_rna_assay +from scarf.agent.persistence.contracts import AgentInvocation +from scarf.agent.persistence.reports import create_agent_workflow +from scarf.datastore.datastore import DataStore +from scarf.storage.budget import ResourceBudget +from scarf.storage.schema import create_zarr_count_assay +from scarf.storage.sharding import write_counts_t +from tests.agent_orchestrator_store import create_store + + +def _request(path: str = "study.zarr", **values: Any) -> AutomatedWorkflowRequest: + return AutomatedWorkflowRequest( + sourcePath=path, + studyContext="A human RNA study with independent donors.", + studyObjective="Find stable populations while preserving condition.", + **values, + ) + + +def _add_assay(path: Path, name: str, assay_type: str) -> None: + root = zarr.open_group(str(path), mode="r+") + values = np.asarray([[1, 3, 0], [0, 2, 5], [3, 1, 0], [1, 4, 2]], dtype=np.uint32) + ids = np.asarray([f"{name}-{index}" for index in range(3)]) + counts = create_zarr_count_assay( + root, + name, + None, + len(values), + feat_ids=ids, + feat_names=np.asarray(["MT-CO1", "GENE1", "GENE2"]), + dtype="uint32", + profile="fast_local", + ) + counts[:] = values + write_counts_t(counts, root[name], resources=ResourceBudget(1024**3, 2)) + root.attrs["assayTypes"] = {**dict(root.attrs["assayTypes"]), name: assay_type} + root[name].attrs["dataset_fingerprint"] = f"dataset-{name.lower()}" + + +@pytest.mark.parametrize( + ("fields", "message"), + [ + ({"analysisAssays": ["RNA", "ADT"]}, "at most one"), + ({"pairedAssays": ["RNA", "ADT"]}, "pairedAssays"), + ({"analysisAssays": ["RNA2"], "primaryAssay": "RNA"}, "primaryAssay"), + ({"primaryAssay": "RNA", "markerAssay": "ADT"}, "markerAssay"), + ({"experimentalDirections": {"hypothesisTesting": {}}}, "hypothesis testing"), + ], +) +def test_request_rejects_unsupported_routes( + fields: dict[str, Any], message: str +) -> None: + with pytest.raises(ValueError, match=message): + _request(**fields) + + +def test_selected_rna_uses_persisted_type_and_requires_unambiguous_selection() -> None: + types = {"protein": "ADT", "transcriptome": "RNA", "tags": "HTO"} + assert selected_rna_assay(_request(), types) == "transcriptome" + types["RNA2"] = "RNA" + with pytest.raises(ValueError, match="found 2"): + selected_rna_assay(_request(), types) + assert selected_rna_assay(_request(primaryAssay="RNA2"), types) == "RNA2" + assert selected_rna_assay(_request(analysisAssays=["RNA2"]), types) == "RNA2" + with pytest.raises(ValueError, match="RNA only"): + selected_rna_assay(_request(primaryAssay="protein"), types) + with pytest.raises(ValueError, match="markerAssay"): + selected_rna_assay(_request(markerAssay="RNA2"), {"transcriptome": "RNA"}) + + +def test_mixed_store_enriches_only_selected_second_rna( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + path = create_store(tmp_path / "mixed.zarr") + _add_assay(path, "RNA2", "RNA") + _add_assay(path, "HTO", "HTO") + inspected: list[list[str]] = [] + + def stop_after_selection( + _agent: Any, _store: Any, **kwargs: Any + ) -> DataEnrichmentReport: + inspected.append(kwargs["assays"]) + return DataEnrichmentReport( + status="failed", limitations=["Stopped after selection"] + ) + + monkeypatch.setattr(context_module.DataEnrichmentAgent, "run", stop_after_selection) + result = AgentOrchestrator(object()).run(_request(str(path), primaryAssay="RNA2")) + assert result.status == "failed" + assert inspected == [["RNA2"]] + assert result.workflowRun is not None + root = zarr.open_group(str(path), mode="r") + prefix = "agents/orchestrations" + record = journal._read_model( + root, + journal._request_key(prefix, result.workflowRun.workflowRunId), + main_module.OrchestrationRequestRecord, + ) + assert record.request.analysisAssays == ["RNA2"] + assert record.request.primaryAssay == record.request.markerAssay == "RNA2" + assert not journal._stage_outcomes( + root, prefix, result.workflowRun.workflowRunId, "hto_demultiplexing" + ) + + +def test_ambiguous_rna_store_is_rejected_before_writable_open( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + path = create_store(tmp_path / "ambiguous.zarr") + _add_assay(path, "RNA2", "RNA") + orchestrator = AgentOrchestrator(object()) + + def unexpected_open(*_args: Any, **_kwargs: Any) -> None: + pytest.fail("Unsupported RNA selection must not open the store for writes") + + monkeypatch.setattr(orchestrator, "open_store", unexpected_open) + result = orchestrator.run(_request(str(path))) + assert result.status == "failed" + assert "found 2" in result.notes[0] + assert "agents" not in zarr.open_group(str(path), mode="r") + + +def test_explicit_second_rna_drives_qc_profiles(tmp_path: Path) -> None: + path = create_store(tmp_path / "qc.zarr") + _add_assay(path, "RNA2", "RNA") + store = DataStore(str(path), default_assay="RNA", min_features_per_cell=-1) + assert _qc_driver(store, "RNA2") == ("RNA2", "RNA") + selection = store.snapshot_cell_selection() + deps = ExperimentalContextDependencies( + store=store, + cells=_SelectionBoundCells(store.zw, store.cells, selection), + qcAssay="RNA2", + cellSelection=selection, + ) + profiles = _offered_qc_profiles(deps) + assert profiles + assert {profile.driverAssay for profile in profiles} == {"RNA2"} + assert all( + not name.startswith("RNA_") + for profile in profiles + for name in profile.attributes + ) + + +def test_qc_driver_rejects_an_explicit_non_rna_non_atac_assay() -> None: + store = SimpleNamespace( + assay_names=["ADT"], zw=SimpleNamespace(attrs={"assayTypes": {"ADT": "ADT"}}) + ) + with pytest.raises(ValueError, match="RNA or ATAC"): + _qc_driver(store, "ADT") + + +def test_missing_h5ad_logs_failure_and_reason( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + messages: list[str] = [] + monkeypatch.setattr(main_module.logger, "error", messages.append) + path = tmp_path / "missing.h5ad" + result = AgentOrchestrator(object()).run(_request(str(path))) + assert result.status == "failed" + assert any("RNA analysis failed during ingest" in message for message in messages) + assert any("missing.h5ad" in message for message in messages) + + +@pytest.mark.parametrize( + ("route", "message"), + [ + ("hto", "Saved automatic HTO"), + ("handoff_assay", "Saved preprocessing"), + ("handoff_type", "Saved preprocessing"), + ("enrichment_modality", "Saved enrichment"), + ], +) +def test_resume_rejects_unsupported_saved_route_before_writable_open( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, route: str, message: str +) -> None: + path = create_store(tmp_path / "resume.zarr") + store = DataStore(str(path), default_assay="RNA", min_features_per_cell=-1) + orchestrator = AgentOrchestrator(object()) + workflow = create_agent_workflow(store) + record = orchestrator.initialize_request( + store, workflow, _request(str(path), zarrPath=str(path)) + ) + prefix = journal._ensure_orchestration_store(store) + stage: WorkflowStageName = ( + "hto_demultiplexing" + if route == "hto" + else "data_enrichment" + if route == "enrichment_modality" + else "preprocessing" + ) + started = journal._start_attempt( + store.zw, prefix, workflow.workflowRunId, stage, record, [] + ) + references = [] + outputs: dict[str, Any] = {} + if route == "hto": + outputs["htoIdentityArtifacts"] = [{"name": "HTO_identity"}] + elif route == "enrichment_modality": + report = DataEnrichmentReport.get_example() + report.policies[0].assayModality = "ADT" + _, reference = journal._save_stage_report( + store, + started, + report, + invocation=AgentInvocation(agentName="data_enrichment"), + expected_type=DataEnrichmentReport, + ) + references.append(reference) + else: + handoff = PreprocessedAssayHandoff( + assay="RNA2" if route == "handoff_assay" else "RNA", + assayType="ADT" if route == "handoff_type" else "RNA", + ) + outputs["assays"] = [handoff.model_dump(mode="json")] + outcome = journal._complete_attempt( + started, + status="done", + outputs=outputs, + report_references=references, + ) + journal._save_outcome(store.zw, prefix, outcome) + + def unexpected_open(*_args: Any, **_kwargs: Any) -> None: + pytest.fail("Unsupported saved analysis state must not open for writes") + + monkeypatch.setattr(orchestrator, "open_store", unexpected_open) + with pytest.raises(ValueError, match=message): + orchestrator.load_request_for_resume( + AutomatedWorkflowResumeRequest( + zarrPath=str(path), workflowRunId=workflow.workflowRunId + ) + ) diff --git a/tests/test_agent_tuning_reuse.py b/tests/test_agent_tuning_reuse.py new file mode 100644 index 00000000..3d15b8a6 --- /dev/null +++ b/tests/test_agent_tuning_reuse.py @@ -0,0 +1,221 @@ +"""Exact-input reuse and scientific gates for the automated RNA path.""" + +from collections import Counter +from types import SimpleNamespace +from typing import Any + +import numpy as np +import pytest +import zarr + +from scarf.agent.orchestrator import tuning +from scarf.agent.parameter_tuning import diagnostics, execution +from scarf.agent.parameter_tuning.contracts import ( + ArtifactRecord, + ParameterCandidate, + ParameterCandidateEvaluation, + ParameterMetrics, + ParameterTuningDependencies, +) +from scarf.agent.types import ArtifactReferenceModel +from scarf.storage.artifacts import fingerprint_stored_arrays +from tests.test_agent_parameter_tuning import _FakeStore, _artifact, _cell_selection + + +def test_stage_metric_reuse_tracks_artifacts_and_live_metadata( + monkeypatch: pytest.MonkeyPatch, +) -> None: + store = _FakeStore() + metadata = {"batch": np.asarray(["a", "b"], dtype=object)} + store.cells = metadata + monkeypatch.setattr( + execution, + "iter_metadata_column_blocks", + lambda cells, column: iter([cells[column]]), + ) + + def run(candidate_id: str) -> ParameterCandidateEvaluation: + candidate = ParameterCandidate(candidateId=candidate_id) + return execution.execute_parameter_candidate( + ParameterTuningDependencies( + store=store, + normalized=store.normalized, + normalizedShape=store.normalized_shape, + cellSelection=store.cell_selection, + fromAssay="RNA", + candidates={candidate_id: candidate}, + batchColumns=("batch",), + preservationColumns=("batch",), + ), + candidate_id, + ) + + def counts() -> Counter[str]: + return Counter(name for name, _args, _kwargs in store.calls) + + with execution.candidate_metric_cache(): + first = run("first") + same = run("another_phase") + assert first.status == same.status == "done" + assert first.metrics == same.metrics + assert all("another_phase" in value for value in same.evidenceIds) + assert counts()["metric_cluster_separability"] == 1 + assert counts()["metric_proportional_batch_mixing"] == 1 + + metadata["batch"][0] = "changed" + run("changed_metadata") + assert counts()["metric_proportional_batch_mixing"] == 2 + assert counts()["metric_clisi"] == 2 + assert counts()["metric_cluster_separability"] == 1 + + store._artifacts["neighbors"] = _artifact("neighbors", 70) + run("changed_neighbors") + assert counts()["metric_proportional_batch_mixing"] == 3 + assert counts()["metric_graph_silhouette"] == 2 + + store._artifacts["clusters"] = _artifact("cluster_labels", 71) + run("changed_clusters") + assert counts()["metric_cluster_separability"] == 2 + + with execution.candidate_metric_cache(): + run("new_stage") + assert counts()["metric_cluster_separability"] == 3 + assert counts()["metric_proportional_batch_mixing"] == 4 + + +def test_pca_diagnostic_reuse_precedes_numerical_work( + tmp_path: Any, + monkeypatch: pytest.MonkeyPatch, +) -> None: + root = zarr.open_group(str(tmp_path / "diagnostic.zarr"), mode="w") + reduction = root.create_group("reduction") + reduction.create_array("data", data=np.ones((4, 2))) + reduction.create_array("loadings", data=np.ones((3, 2))) + stored = root.create_group("diagnostic") + payload = { + "component_variance": np.asarray([2.0, 1.0]), + "explained_variance_ratio": np.asarray([0.6, 0.3]), + "top_loading_feature_indices": np.asarray([[0, 1, 2], [2, 1, 0]]), + "top_loading_values": np.ones((2, 3)), + "family_enrichment": np.ones((1, 2)), + "covariate_association": np.ones((1, 2)), + "adjacent_neighbor_overlap": np.asarray([0.8]), + } + for name, values in payload.items(): + stored.create_array(name, data=values) + stored.attrs["payload_fingerprint"] = fingerprint_stored_arrays( + stored, diagnostics._PCA_DIAGNOSTIC_ARRAYS + ) + pca_ref = _artifact("reduction", 2) + diagnostic_ref = _artifact("feature_summary", 3) + store = SimpleNamespace( + zw=root, + cells={}, + inspect_artifact=lambda ref: SimpleNamespace(parameters={"feat_scaling": True}), + load_artifact=lambda ref: reduction if ref == pca_ref else stored, + ) + planned: list[dict[str, Any]] = [] + + def reuse(*_args: Any, **kwargs: Any) -> Any: + planned.append(kwargs) + return SimpleNamespace(ref=diagnostic_ref, reused=True) + + def unexpected(*_args: Any, **_kwargs: Any) -> Any: + raise AssertionError("Persisted diagnostic must not recompute evidence") + + monkeypatch.setattr(diagnostics, "plan_artifact", reuse) + monkeypatch.setattr( + diagnostics, "_metadata_column_fingerprint", lambda *_: "current" + ) + for name in ( + "_component_variance", + "_scaled_total_variance", + "_top_loadings", + "_covariate_associations", + ): + monkeypatch.setattr(diagnostics, name, unexpected) + result = diagnostics._write_pca_diagnostic( + store, + ParameterCandidateEvaluation( + artifacts={ + "pca": ArtifactRecord.from_ref(pca_ref), + "neighbors": ArtifactRecord.from_ref(_artifact("neighbors", 4)), + }, + ), + feature_selection=_artifact("feature_selection", 5), + selected_indices=np.arange(3), + family_masks={"protected": np.asarray([True, False, False])}, + covariate_columns=("batch",), + covariate_roles=("technical",), + adjacent_overlap=0.8, + ) + assert result[0] == diagnostic_ref + np.testing.assert_array_equal(result[1], payload["component_variance"]) + assert planned[0]["parameters"]["covariate_fingerprints"] == {"batch": "current"} + + +@pytest.mark.parametrize("damage", ["none", "doublets", "protected", "selection"]) +def test_workflow_harmony_gate_keeps_matched_evidence_requirements(damage: str) -> None: + native = ParameterCandidateEvaluation( + candidateId="native", + status="done", + eligible=True, + cellSelection=ArtifactReferenceModel.from_artifact_ref(_cell_selection()), + parameters=ParameterCandidate(candidateId="native"), + metrics=ParameterMetrics( + batchMixing={"batch": 0.4}, + biologicalPreservation={"condition": {"clisi": 0.8}}, + markerCoherence=0.8, + doubletHighScoreConcentration=0.1, + ), + ) + corrected = native.model_copy(deep=True) + corrected.candidateId = "corrected" + corrected.parameters.candidateId = "corrected" + corrected.parameters.useHarmony = True + corrected.metrics.batchMixing = {"batch": 0.6} + if damage == "doublets": + corrected.metrics.doubletHighScoreConcentration = None + elif damage == "protected": + corrected.metrics.biologicalPreservation["condition"]["extra"] = 0.9 + elif damage == "selection": + corrected.cellSelection = ArtifactReferenceModel.from_artifact_ref( + _cell_selection(99) + ) + accepted, reasons = tuning.harmony_acceptance_gate( + native, + corrected, + batch_columns=["batch", "batch"], + protected_columns=["condition"], + independent_unit_columns=[], + require_doublet_evidence=True, + ) + assert accepted is (damage == "none") + assert bool(reasons) is (damage != "none") + + +def test_restore_doublets_keeps_frozen_artifacts_and_summaries() -> None: + evaluation = ParameterCandidateEvaluation( + artifacts={ + "doubletScore:0": ArtifactRecord.from_ref(_artifact("doublet_score", 20)), + "doubletCellSelection:0": ArtifactRecord.from_ref(_cell_selection()), + "doubletNativeGraph": ArtifactRecord.from_ref( + _artifact("connectivity_map", 21) + ), + "doubletNativeClusters": ArtifactRecord.from_ref( + _artifact("cluster_labels", 22) + ), + }, + metrics=ParameterMetrics( + doubletScoreByCapture={"captureA": {"p50": 0.1}}, + doubletScoreQuantiles={"p95": 0.4}, + doubletCaptureCoverage=1.0, + ), + ) + restored = diagnostics.restore_advisory_doublets( + evaluation, capture_column="capture" + ) + assert restored.scores == (_artifact("doublet_score", 20),) + assert restored.cell_selections == (_cell_selection(),) + assert restored.capture_values == ("captureA",) + assert restored.score_quantiles == {"p95": 0.4} diff --git a/tests/test_agent_work_budget.py b/tests/test_agent_work_budget.py new file mode 100644 index 00000000..2b82a331 --- /dev/null +++ b/tests/test_agent_work_budget.py @@ -0,0 +1,250 @@ +"""Workflow-wide reservations survive retries without charging reused passes.""" + +from types import SimpleNamespace +from pathlib import Path +from typing import Any + +import pytest +import zarr +from zarr.storage import MemoryStore + +from scarf.agent.orchestrator import journal +from scarf.agent.orchestrator import AgentOrchestrator, AutomatedWorkflowRequest +from scarf.agent.orchestrator.budget import reserve_candidate_pass +from scarf.agent.orchestrator.models import ( + AutomatedWorkflowConfig, + AutomatedPreprocessingPlan, + OrchestrationRequestRecord, + WorkflowStageName, +) +from scarf.agent.persistence.contracts import AgentWorkflowRun +from scarf.agent.persistence.reports import create_agent_workflow +from scarf.agent.experimental_context import ExperimentalContextResult +from scarf.agent.experimental_context.study import StudyContract +from scarf.datastore.datastore import DataStore +from tests.agent_orchestrator_store import create_store + + +_PREFIX = "agents/orchestrations" + + +def _context( + limit: int = 50, +) -> tuple[Any, AgentWorkflowRun, OrchestrationRequestRecord]: + store = SimpleNamespace(zw=zarr.group(store=MemoryStore())) + workflow = AgentWorkflowRun.get_example() + config = AutomatedWorkflowConfig(maxCandidateEvaluations=limit) + record = OrchestrationRequestRecord( + workflowRunId=workflow.workflowRunId, + config=config, + configSha256=journal._sha256_model(config), + requestSha256="a" * 64, + ) + return store, workflow, record + + +def _start( + store: Any, + workflow: AgentWorkflowRun, + record: OrchestrationRequestRecord, + stage: WorkflowStageName, + inputs: dict[str, Any], +) -> None: + journal._start_attempt( + store.zw, _PREFIX, workflow.workflowRunId, stage, record, [], inputs=inputs + ) + + +@pytest.mark.parametrize("limit", [1, 24]) +def test_budget_rejects_baseline_before_any_reservation_is_written(limit: int) -> None: + store, workflow, record = _context(limit) + with pytest.raises(ValueError, match=r"0 slots already reserved, 25 required"): + reserve_candidate_pass(store, _PREFIX, workflow, record, "preprocessing") + assert ( + journal._stage_starts( + store.zw, _PREFIX, workflow.workflowRunId, "preprocessing" + ) + == [] + ) + + +@pytest.mark.parametrize("limit,allow_revision", [(25, False), (49, False), (50, True)]) +def test_budget_admits_whole_passes_and_counts_interrupted_work( + limit: int, allow_revision: bool +) -> None: + store, workflow, record = _context(limit) + baseline = reserve_candidate_pass(store, _PREFIX, workflow, record, "preprocessing") + assert baseline["reserved"] == 25 + assert baseline["breakdown"]["hvg"] == 9 + # An interrupted start has no outcome but retains its reservation. + _start(store, workflow, record, "preprocessing", {"candidateBudget": baseline}) + # Starting the same logical pass again does not charge another 25 slots. + repeated = reserve_candidate_pass(store, _PREFIX, workflow, record, "preprocessing") + assert repeated == baseline + _start(store, workflow, record, "preprocessing", {"candidateBudget": repeated}) + if allow_revision: + revised = reserve_candidate_pass( + store, _PREFIX, workflow, record, "feature_policy_preprocessing" + ) + assert revised["logicalPass"] == "featureRevision" + _start( + store, + workflow, + record, + "feature_policy_preprocessing", + {"candidateBudget": revised}, + ) + assert ( + reserve_candidate_pass( + store, _PREFIX, workflow, record, "feature_policy_preprocessing" + ) + == revised + ) + else: + with pytest.raises(ValueError, match=r"25 slots already reserved, 25 required"): + reserve_candidate_pass( + store, _PREFIX, workflow, record, "feature_policy_preprocessing" + ) + + +def test_budget_does_not_charge_baseline_reuse_or_rejected_attempts() -> None: + store, workflow, record = _context(25) + baseline = reserve_candidate_pass(store, _PREFIX, workflow, record, "preprocessing") + _start(store, workflow, record, "preprocessing", {"candidateBudget": baseline}) + _start( + store, + workflow, + record, + "feature_policy_preprocessing", + {"baselineAttemptId": "reused-baseline"}, + ) + _start( + store, + workflow, + record, + "feature_policy_preprocessing", + {"candidateBudgetRejected": True}, + ) + assert ( + reserve_candidate_pass(store, _PREFIX, workflow, record, "preprocessing") + == baseline + ) + + +@pytest.mark.parametrize( + "mutation", ["count", "breakdown", "pass", "missing", "config"] +) +def test_budget_rejects_inconsistent_persisted_reservations(mutation: str) -> None: + store, workflow, record = _context() + reservation = reserve_candidate_pass( + store, _PREFIX, workflow, record, "preprocessing" + ) + _start(store, workflow, record, "preprocessing", {"candidateBudget": reservation}) + changed = {**reservation, "breakdown": dict(reservation["breakdown"])} + if mutation == "count": + changed["reserved"] = 1 + elif mutation == "breakdown": + changed["breakdown"]["hvg"] = 0 + elif mutation == "pass": + changed["logicalPass"] = "featureRevision" + elif mutation == "config": + record = record.model_copy(update={"configSha256": "b" * 64}) + inputs = {} if mutation == "missing" else {"candidateBudget": changed} + _start(store, workflow, record, "preprocessing", inputs) + with pytest.raises(ValueError, match=r"reservation.*differs|lacks its candidate"): + reserve_candidate_pass(store, _PREFIX, workflow, record, "preprocessing") + + +def test_budget_keeps_unused_reservations_and_requires_baseline_for_revision() -> None: + store, workflow, record = _context(25) + with pytest.raises(ValueError, match="requires a reserved baseline"): + reserve_candidate_pass( + store, _PREFIX, workflow, record, "feature_policy_preprocessing" + ) + + reservation = reserve_candidate_pass( + store, _PREFIX, workflow, record, "preprocessing" + ) + started = journal._start_attempt( + store.zw, + _PREFIX, + workflow.workflowRunId, + "preprocessing", + record, + [], + inputs={"candidateBudget": reservation}, + ) + journal._save_outcome( + store.zw, + _PREFIX, + journal._complete_attempt( + started, status="done", outputs={"candidateCount": 1} + ), + ) + with pytest.raises(ValueError, match="25 slots already reserved"): + reserve_candidate_pass( + store, _PREFIX, workflow, record, "feature_policy_preprocessing" + ) + + +@pytest.mark.parametrize( + "limit,stage", [(24, "preprocessing"), (25, "feature_policy_preprocessing")] +) +def test_preprocessing_budget_rejection_precedes_qc_and_candidate_execution( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + limit: int, + stage: WorkflowStageName, +) -> None: + store = DataStore(str(create_store(tmp_path / "budget.zarr")), default_assay="RNA") + workflow = create_agent_workflow(store) + orchestrator = AgentOrchestrator( + object(), config=AutomatedWorkflowConfig(maxCandidateEvaluations=limit) + ) + request = AutomatedWorkflowRequest( + sourcePath=str(store.zarr_loc), + zarrPath=str(store.zarr_loc), + studyContext="Budget admission regression.", + studyObjective="Compare RNA populations.", + primaryAssay="RNA", + markerAssay="RNA", + analysisAssays=["RNA"], + ) + record = orchestrator.initialize_request(store, workflow, request) + prefix = journal._ensure_orchestration_store(store) + if stage == "feature_policy_preprocessing": + reservation = reserve_candidate_pass( + store, prefix, workflow, record, "preprocessing" + ) + journal._start_attempt( + store.zw, + prefix, + workflow.workflowRunId, + "preprocessing", + record, + [], + inputs={"candidateBudget": reservation}, + ) + + def unexpected(*_args: Any, **_kwargs: Any) -> Any: + pytest.fail("Over-budget preprocessing must stop before numerical work") + + monkeypatch.setattr(orchestrator, "apply_cell_qc", unexpected) + monkeypatch.setattr(orchestrator, "preprocess_assay", unexpected) + outcome, handoffs, _ = orchestrator.preprocessing_stage( + store, + workflow, + record, + [], + AutomatedPreprocessingPlan.get_example(), + ExperimentalContextResult.get_example().model_copy( + update={"htoIdentityArtifacts": []} + ), + StudyContract.get_blank(), + {}, + stage_name=stage, + ) + assert outcome.status == "failed" + assert "Candidate budget exceeded" in (outcome.error or "") + assert handoffs == [] + assert outcome.inputs == {"candidateBudgetRejected": True} From 7919ea99752d5ef44ac7921bc8f7b034c773fcfa Mon Sep 17 00:00:00 2001 From: gautam8387 Date: Tue, 8 Sep 2026 00:07:42 +0200 Subject: [PATCH 13/21] remove bloat; remove grid sweeps; keep RNA only; fix plotting; reduce runtime; wip --- .../base.ipynb | 809 ++++ .../base.ipynb | 930 ---- docs/.jupyter_cache/global.db | Bin 36864 -> 36864 bytes docs/source/analysis_with_agents.md | 122 +- docs/source/developers/architecture.md | 25 +- docs/source/reference/api.md | 2 + docs/source/reference/api/agent.md | 88 + docs/source/toctree.yml | 2 + docs/source/tutorials/agent_workflow.md | 639 +-- scarf/agent/.env.example | 15 - scarf/agent/__init__.py | 233 +- scarf/agent/_plots.py | 282 ++ .../biological_interpretation/contracts.py | 179 - scarf/agent/cell_quality/profiles.py | 18 + scarf/agent/config/__init__.py | 4 - scarf/agent/config/agent_exec.py | 6 +- scarf/agent/data_enrichment/agent.py | 28 +- .../agent/data_enrichment/characterization.py | 8 - scarf/agent/data_enrichment/contracts.py | 242 - scarf/agent/data_enrichment/validation.py | 116 +- scarf/agent/decisions/kernel.py | 384 +- scarf/agent/decisions/rna.py | 1136 +---- scarf/agent/experimental_context/agent.py | 124 +- .../experimental_context/characterization.py | 18 +- .../agent/experimental_context/comparisons.py | 498 ++ scarf/agent/experimental_context/contracts.py | 258 +- .../agent/experimental_context/qc_evidence.py | 310 +- scarf/agent/experimental_context/study.py | 90 +- scarf/agent/experimental_context/tools.py | 54 +- .../agent/experimental_context/validation.py | 421 +- scarf/agent/hypotheses/__init__.py | 21 - scarf/agent/hypotheses/contracts.py | 141 - scarf/agent/hypotheses/execution.py | 221 - scarf/agent/ingest/common.py | 31 - scarf/agent/ingest/result.py | 14 - scarf/agent/orchestrator/__init__.py | 26 +- scarf/agent/orchestrator/api.py | 28 +- scarf/agent/orchestrator/budget.py | 281 +- scarf/agent/orchestrator/context.py | 133 +- scarf/agent/orchestrator/decisions.py | 1122 +---- scarf/agent/orchestrator/finalization.py | 992 +--- scarf/agent/orchestrator/journal.py | 1099 +++-- scarf/agent/orchestrator/main.py | 1240 ++--- scarf/agent/orchestrator/models.py | 524 +-- scarf/agent/orchestrator/preprocessing.py | 1702 +------ scarf/agent/orchestrator/rna.py | 90 +- scarf/agent/orchestrator/rna_tuning.py | 1846 ++++++++ scarf/agent/orchestrator/tuning.py | 4135 +---------------- scarf/agent/parameter_tuning/contracts.py | 237 +- scarf/agent/parameter_tuning/diagnostics.py | 363 +- scarf/agent/parameter_tuning/execution.py | 77 +- scarf/agent/parameter_tuning/hvg.py | 761 +-- scarf/agent/parameter_tuning/selection.py | 8 + scarf/agent/parameter_tuning/sequential.py | 1100 ----- scarf/agent/persistence/__init__.py | 47 - scarf/agent/persistence/contracts.py | 415 -- scarf/agent/persistence/decisions.py | 771 --- scarf/agent/persistence/reports.py | 1188 ----- scarf/agent/report/artifacts.py | 613 +-- scarf/agent/report/contracts.py | 244 +- scarf/agent/report/decision_tree.py | 1183 ----- scarf/agent/report/generator.py | 169 +- scarf/agent/report/plots.py | 853 +--- scarf/agent/report/rendering.py | 3256 +------------ scarf/agent/runtime.py | 116 - scarf/agent/types.py | 107 - tests/agent_examples.py | 1261 +++++ tests/agent_journal_store.py | 51 + tests/test_agent_analysis_plots.py | 171 + tests/test_agent_beginner.py | 349 +- tests/test_agent_biological_interpretation.py | 4 +- tests/test_agent_characterize_covariates.py | 3 +- tests/test_agent_characterize_features.py | 5 +- tests/test_agent_data_enrichment.py | 72 +- tests/test_agent_decide.py | 3 +- tests/test_agent_decision_kernel.py | 881 +--- tests/test_agent_decision_persistence.py | 965 ---- tests/test_agent_design_comparisons.py | 612 +++ tests/test_agent_exec.py | 162 +- tests/test_agent_experimental_context.py | 167 +- tests/test_agent_hvg_diagnostics.py | 92 - tests/test_agent_ingest.py | 76 +- tests/test_agent_ingest_manifest.py | 2 +- tests/test_agent_orchestrator.py | 437 +- .../test_agent_orchestrator_journal_edges.py | 1038 ++--- tests/test_agent_orchestrator_lifecycle.py | 1332 ++---- tests/test_agent_orchestrator_stages.py | 439 +- tests/test_agent_parameter_tuning.py | 66 +- tests/test_agent_population_support.py | 249 + tests/test_agent_provider_edges.py | 275 ++ tests/test_agent_qc_decision_evidence.py | 116 + tests/test_agent_report.py | 2090 ++------- tests/test_agent_report_persistence.py | 944 ---- tests/test_agent_rna_adaptive.py | 631 +++ tests/test_agent_rna_assessment_integrity.py | 362 ++ tests/test_agent_rna_decisions.py | 824 +--- tests/test_agent_rna_evidence_mode.py | 761 +++ tests/test_agent_rna_rare_population.py | 217 + tests/test_agent_rna_workflow_scope.py | 36 +- tests/test_agent_runtime.py | 147 - tests/test_agent_sequential_tuning.py | 348 -- tests/test_agent_tuning_diagnostics.py | 5 +- tests/test_agent_tuning_report_retry.py | 164 + tests/test_agent_tuning_reuse.py | 23 +- tests/test_agent_work_budget.py | 250 - tests/test_import_architecture.py | 13 +- tests/test_registered_qc_profiles.py | 168 +- 107 files changed, 14286 insertions(+), 33720 deletions(-) create mode 100644 docs/.jupyter_cache/executed/6f4bdeb21a5ca0ba2f47633bf5853ebc/base.ipynb delete mode 100644 docs/.jupyter_cache/executed/e470ea1bc7598db9f553a48c4f356d77/base.ipynb create mode 100644 docs/source/reference/api/agent.md delete mode 100644 scarf/agent/.env.example create mode 100644 scarf/agent/_plots.py create mode 100644 scarf/agent/experimental_context/comparisons.py delete mode 100644 scarf/agent/hypotheses/__init__.py delete mode 100644 scarf/agent/hypotheses/contracts.py delete mode 100644 scarf/agent/hypotheses/execution.py create mode 100644 scarf/agent/orchestrator/rna_tuning.py delete mode 100644 scarf/agent/parameter_tuning/sequential.py delete mode 100644 scarf/agent/persistence/__init__.py delete mode 100644 scarf/agent/persistence/contracts.py delete mode 100644 scarf/agent/persistence/decisions.py delete mode 100644 scarf/agent/persistence/reports.py delete mode 100644 scarf/agent/report/decision_tree.py delete mode 100644 scarf/agent/runtime.py create mode 100644 tests/agent_examples.py create mode 100644 tests/agent_journal_store.py create mode 100644 tests/test_agent_analysis_plots.py delete mode 100644 tests/test_agent_decision_persistence.py create mode 100644 tests/test_agent_design_comparisons.py create mode 100644 tests/test_agent_population_support.py create mode 100644 tests/test_agent_provider_edges.py create mode 100644 tests/test_agent_qc_decision_evidence.py delete mode 100644 tests/test_agent_report_persistence.py create mode 100644 tests/test_agent_rna_adaptive.py create mode 100644 tests/test_agent_rna_assessment_integrity.py create mode 100644 tests/test_agent_rna_evidence_mode.py create mode 100644 tests/test_agent_rna_rare_population.py delete mode 100644 tests/test_agent_runtime.py delete mode 100644 tests/test_agent_sequential_tuning.py create mode 100644 tests/test_agent_tuning_report_retry.py delete mode 100644 tests/test_agent_work_budget.py diff --git a/docs/.jupyter_cache/executed/6f4bdeb21a5ca0ba2f47633bf5853ebc/base.ipynb b/docs/.jupyter_cache/executed/6f4bdeb21a5ca0ba2f47633bf5853ebc/base.ipynb new file mode 100644 index 00000000..a9ef3b33 --- /dev/null +++ b/docs/.jupyter_cache/executed/6f4bdeb21a5ca0ba2f47633bf5853ebc/base.ipynb @@ -0,0 +1,809 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "267f8fb0", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
    Downloading bucket files18098007 / 18098007 complete18098007 / 18098007 complete
    " + ], + "text/plain": [ + "Downloading bucket files: 18098007 / 18098007 complete" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
    Downloading bytes18098007 / 18098007 complete18098007 / 18098007 complete
    " + ], + "text/plain": [ + "Downloading bytes: 18098007 / 18098007 complete" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "'data.h5'" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from pathlib import Path\n", + "from tempfile import TemporaryDirectory\n", + "\n", + "import pandas as pd\n", + "import scarf\n", + "from scarf.agent import analyze_rna\n", + "\n", + "scarf.configure_output(level=\"WARNING\", progress=False)\n", + "source_path = scarf.cytebase.connect(\"scarf_docs\").download(\n", + " \"tenx_5K_pbmc_rnaseq/data.h5\", destination=\"scarf_datasets\",\n", + ")[0]\n", + "teaching_directory = TemporaryDirectory(prefix=\"scarf-agent-teaching-\")\n", + "zarr_path = Path(teaching_directory.name) / \"analysis.zarr\"\n", + "study_context = (\n", + " \"Human 10x Genomics 5K PBMC 3-prime gene expression from peripheral blood, \"\n", + " \"collected from one healthy donor. No treatment comparison, trusted technical \"\n", + " \"batch column, paired modality, or independent replication metadata is available. \"\n", + " \"Do not invent missing design variables or report treatment effects.\"\n", + ")\n", + "source_path.name" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "2972c64f", + "metadata": { + "tags": [ + "remove-cell" + ] + }, + "outputs": [], + "source": [ + "import json\n", + "from typing import Any\n", + "\n", + "from IPython import get_ipython\n", + "from pydantic_ai.messages import (\n", + " ModelMessage,\n", + " ModelResponse,\n", + " ToolCallPart,\n", + " ToolReturnPart,\n", + ")\n", + "from pydantic_ai.models.function import AgentInfo, FunctionModel\n", + "\n", + "from scarf.agent.data_enrichment import (\n", + " AssayFeatureInspectionBatch,\n", + " DataEnrichmentReport,\n", + " FeatureSelectionPolicy,\n", + " StudyContextSummary,\n", + ")\n", + "from scarf.agent.experimental_context import (\n", + " BatchCorrectionPlan,\n", + " CovariateEvidence,\n", + " ExperimentalContextDecision,\n", + ")\n", + "\n", + "notebook_shell = get_ipython()\n", + "if notebook_shell is not None:\n", + " notebook_shell.run_line_magic(\"matplotlib\", \"inline\")\n", + "\n", + "def _prompt_text(messages: list[ModelMessage]) -> str:\n", + " values = []\n", + " for message in messages:\n", + " for part in message.parts:\n", + " content = getattr(part, \"content\", None)\n", + " if isinstance(content, str):\n", + " values.append(content)\n", + " elif isinstance(content, tuple):\n", + " values.extend(item for item in content if isinstance(item, str))\n", + " return \"\\n\".join(values)\n", + "\n", + "\n", + "def _tool_result(\n", + " messages: list[ModelMessage],\n", + " tool_name: str,\n", + " model_type: Any,\n", + ") -> Any:\n", + " for message in reversed(messages):\n", + " for part in reversed(message.parts):\n", + " if isinstance(part, ToolReturnPart) and part.tool_name == tool_name:\n", + " if isinstance(part.content, model_type):\n", + " return part.content\n", + " if isinstance(part.content, str):\n", + " return model_type.model_validate_json(part.content)\n", + " return model_type.model_validate(part.content)\n", + " raise AssertionError(f\"Missing tool return {tool_name!r}\")\n", + "\n", + "\n", + "def _tool_call(name: str, args: dict[str, Any] | None = None) -> ModelResponse:\n", + " return ModelResponse(parts=[ToolCallPart(tool_name=name, args=args or {})])\n", + "\n", + "\n", + "def _structured_output(info: AgentInfo, value: Any) -> ModelResponse:\n", + " payload = value.model_dump() if hasattr(value, \"model_dump\") else value\n", + " return _tool_call(info.output_tools[0].name, payload)\n", + "\n", + "\n", + "def _scripted_workflow_model() -> tuple[FunctionModel, dict[str, Any]]:\n", + " state = {\n", + " \"enrichment\": 0,\n", + " \"context\": 0,\n", + " \"parameter\": 0,\n", + " \"assessments\": [],\n", + " \"requests\": 0,\n", + " }\n", + "\n", + " async def reply(\n", + " messages: list[ModelMessage],\n", + " info: AgentInfo,\n", + " ) -> ModelResponse:\n", + " state[\"requests\"] += 1\n", + " tools = {tool.name for tool in info.function_tools}\n", + "\n", + " if (\n", + " \"inspect_assay_features_batch\" in tools\n", + " or state[\"enrichment\"] == 1\n", + " or any(\n", + " tool.parameters_json_schema.get(\"title\") == \"DataEnrichmentReport\"\n", + " for tool in info.output_tools\n", + " )\n", + " ):\n", + " if state[\"enrichment\"] == 0:\n", + " state[\"enrichment\"] = 1\n", + " return _tool_call(\"inspect_assay_features_batch\")\n", + "\n", + " batch = _tool_result(\n", + " messages,\n", + " \"inspect_assay_features_batch\",\n", + " AssayFeatureInspectionBatch,\n", + " )\n", + " policies = []\n", + " for inspection in batch.inspections:\n", + " species_observed = inspection.species != \"unknown\"\n", + " policy_evidence = list(inspection.evidenceIds)\n", + " if not species_observed:\n", + " policy_evidence.append(\"context:study\")\n", + " policies.append(\n", + " FeatureSelectionPolicy(\n", + " assay=inspection.assay,\n", + " species=(\n", + " inspection.species\n", + " if species_observed\n", + " else \"homo_sapiens\"\n", + " ),\n", + " speciesConfidence=\"high\" if species_observed else \"medium\",\n", + " speciesRationale=(\n", + " inspection.speciesReason\n", + " or \"The exact study paragraph identifies a human sample.\"\n", + " ),\n", + " excludeFamilies=[\n", + " family.family\n", + " for family in inspection.families\n", + " if family.count > 0 and family.defaultExclude is True\n", + " ],\n", + " protectFamilies=[\n", + " family.family\n", + " for family in inspection.families\n", + " if family.count > 0 and family.defaultExclude is False\n", + " ],\n", + " rationale=(\n", + " \"Exclude observed technical families and preserve \"\n", + " \"observed protected families.\"\n", + " ),\n", + " evidenceIds=list(dict.fromkeys(policy_evidence)),\n", + " )\n", + " )\n", + " state[\"enrichment\"] = 2\n", + " return _structured_output(\n", + " info,\n", + " DataEnrichmentReport(\n", + " status=\"done\",\n", + " studyContextSummary=StudyContextSummary(\n", + " organismReferences=[\"Human\"],\n", + " tissueReferences=[\"peripheral blood\"],\n", + " experimentalReferences=[\n", + " \"10x Genomics 5K PBMC 3-prime gene expression\"\n", + " ],\n", + " analysisIntentReferences=[\n", + " \"Discover stable major immune-cell populations.\"\n", + " ],\n", + " ),\n", + " policies=policies,\n", + " ),\n", + " )\n", + "\n", + " if tools.intersection(\n", + " {\n", + " \"inspect_cell_covariates\",\n", + " \"analyze_experimental_design\",\n", + " \"score_current_representation\",\n", + " }\n", + " ) or state[\"context\"] in {1, 2} or any(\n", + " tool.parameters_json_schema.get(\"title\") == \"ExperimentalContextDecision\"\n", + " for tool in info.output_tools\n", + " ):\n", + " if state[\"context\"] == 0:\n", + " state[\"context\"] = 1\n", + " return _tool_call(\"inspect_cell_covariates\")\n", + " if state[\"context\"] == 1:\n", + " state[\"context\"] = 2\n", + " return _tool_call(\n", + " \"analyze_experimental_design\",\n", + " {\n", + " \"column_domains\": {},\n", + " \"coefficients_of_interest\": [],\n", + " \"units_of_inference\": {},\n", + " \"batch_columns\": [],\n", + " },\n", + " )\n", + "\n", + " design = _tool_result(\n", + " messages,\n", + " \"analyze_experimental_design\",\n", + " CovariateEvidence,\n", + " )\n", + " profile = next(\n", + " value\n", + " for value in design.qcProfiles\n", + " if value.action == \"skip\"\n", + " )\n", + " evidence_id = profile.evidenceId\n", + " state[\"context\"] = 3\n", + " return _structured_output(\n", + " info,\n", + " ExperimentalContextDecision(\n", + " batchCorrection=BatchCorrectionPlan(\n", + " action=\"skip\",\n", + " rationale=\"No trusted technical batch column was supplied.\",\n", + " evidenceIds=[evidence_id],\n", + " ),\n", + " rationale=\"No experimental covariates were supplied.\",\n", + " evidenceIds=[evidence_id],\n", + " ),\n", + " )\n", + "\n", + " prompt = _prompt_text(messages)\n", + " if any(\n", + " tool.parameters_json_schema.get(\"title\") == \"TuningAction\"\n", + " for tool in info.output_tools\n", + " ):\n", + " evidence, _ = json.JSONDecoder().raw_decode(prompt[prompt.index(\"{\") :])\n", + " candidates = [\n", + " item for item in evidence[\"candidates\"]\n", + " if item[\"status\"] == \"done\" and item[\"eligible\"]\n", + " ]\n", + " if not candidates:\n", + " raise AssertionError(\"The teaching run has no supported partition\")\n", + "\n", + " def measured(item, name):\n", + " value = item[\"metrics\"].get(name)\n", + " return float(value) if value is not None else 0.0\n", + "\n", + " selected = max(\n", + " candidates,\n", + " key=lambda item: (\n", + " measured(item, \"seedStability\"),\n", + " measured(item, \"markerCoherence\"),\n", + " ),\n", + " )\n", + " metrics = selected[\"metrics\"]\n", + " genes = list(dict.fromkeys(\n", + " gene for names in metrics.get(\"topMarkerGenes\", {}).values()\n", + " for gene in names\n", + " ))[:8]\n", + " quantitative = (\n", + " f\"Compared {len(candidates)} observed partitions; selected resolution \"\n", + " f\"{selected['parameters']['leidenResolution']}, with seed stability \"\n", + " f\"{metrics.get('seedStability')} and marker coherence \"\n", + " f\"{metrics.get('markerCoherence')}.\"\n", + " )\n", + " qualitative = (\n", + " \"The saved marker preview contains \" + \", \".join(genes) + \".\"\n", + " if genes else \"The saved marker preview is empty; cell identities remain unresolved.\"\n", + " )\n", + " action = {\n", + " \"action\": \"accept\",\n", + " \"selectedCandidateId\": selected[\"candidateId\"],\n", + " \"correctionNeed\": \"notApplicable\",\n", + " \"assessedDomains\": evidence[\"assessedDomains\"],\n", + " \"evidenceIds\": [\n", + " f\"candidate:{selected['candidateId']}\",\n", + " *list(evidence[\"imageHashes\"])[:1],\n", + " \"studyContract\", \"qcPolicy\", \"samplingCoverage\", \"featureEvidence\",\n", + " ],\n", + " \"quantitativeFindings\": [quantitative],\n", + " \"qualitativeFindings\": [qualitative],\n", + " \"objectivePreservation\": (\n", + " \"Preserve the single-donor population structure and retain marker \"\n", + " \"uncertainty; no batch or treatment comparison is supported.\"\n", + " ),\n", + " \"rationale\": (\n", + " \"The teaching policy selects the observed partition with the \"\n", + " \"greatest seed stability, using marker coherence to break ties. \"\n", + " + quantitative\n", + " ),\n", + " }\n", + " state[\"assessments\"].append({\n", + " \"selection\": action,\n", + " \"alternatives\": [{\n", + " \"resolution\": item[\"parameters\"][\"leidenResolution\"],\n", + " \"clusters\": item[\"metrics\"].get(\"nClusters\"),\n", + " \"seed_stability\": item[\"metrics\"].get(\"seedStability\"),\n", + " \"marker_coherence\": item[\"metrics\"].get(\"markerCoherence\"),\n", + " \"selected\": item[\"candidateId\"] == selected[\"candidateId\"],\n", + " } for item in candidates],\n", + " })\n", + " return _structured_output(info, action)\n", + "\n", + " payload, _ = json.JSONDecoder().raw_decode(prompt[prompt.index(\"{\") :])\n", + " decision = payload[\"spec\"]\n", + " evidence_by_class = {}\n", + " evidence_class_by_id = {}\n", + " for item in payload[\"evidence\"][\"evidence\"]:\n", + " evidence_by_class.setdefault(\n", + " item[\"evidenceClass\"],\n", + " item[\"evidenceId\"],\n", + " )\n", + " evidence_class_by_id[item[\"evidenceId\"]] = item[\"evidenceClass\"]\n", + " preferred = decision.get(\"metricPreferredOptionId\")\n", + " selected = (\n", + " next(\n", + " option\n", + " for option in decision[\"options\"]\n", + " if option[\"optionId\"] == preferred\n", + " )\n", + " if preferred is not None\n", + " else next(\n", + " option\n", + " for option in decision[\"options\"]\n", + " if option[\"status\"] in {\"apply\", \"skip\"}\n", + " )\n", + " )\n", + " evidence_ids = list(selected.get(\"requiredEvidenceIds\", []))\n", + " cited_classes = {\n", + " evidence_class_by_id[evidence_id] for evidence_id in evidence_ids\n", + " }\n", + " for evidence_class in selected[\"requiredEvidenceClasses\"]:\n", + " if evidence_class not in cited_classes:\n", + " evidence_ids.append(evidence_by_class[evidence_class])\n", + " state[\"parameter\"] += 1\n", + " return _structured_output(\n", + " info,\n", + " dict(\n", + " selectedOptionId=selected[\"optionId\"],\n", + " evidenceIds=evidence_ids,\n", + " rationale=f\"Use the offered {selected['label']} policy with its required observed evidence.\",\n", + " confidence=\"high\",\n", + " ),\n", + " )\n", + "\n", + " return FunctionModel(reply), state" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "e8fa3bb4", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "WARNING: Minimum cell count (502) is lower than size factor multiplier (1000)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "WARNING: WARNING: Number of valid features is less than value of parameter `top_n`: 33538. Resetting `top_n` to 13822\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "WARNING: WARNING: Number of valid features is less than value of parameter `top_n`: 33538. Resetting `top_n` to 14096\n" + ] + }, + { + "data": { + "text/plain": [ + "{'status': 'completed'}" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "model, model_state = _scripted_workflow_model()\n", + "result = analyze_rna(\n", + " source_path,\n", + " model=model,\n", + " study_context=study_context,\n", + " study_objective=\"Discover stable major immune-cell populations.\",\n", + " zarr_path=zarr_path,\n", + ")\n", + "{\"status\": result.status}" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "01d20e4a", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    resolutionclustersseed_stabilitymarker_coherenceselected
    00.50110.9310201.000000True
    10.75140.8419351.000000False
    21.00140.7216691.000000False
    31.25180.8043430.833333False
    \n", + "
    " + ], + "text/plain": [ + " resolution clusters seed_stability marker_coherence selected\n", + "0 0.50 11 0.931020 1.000000 True\n", + "1 0.75 14 0.841935 1.000000 False\n", + "2 1.00 14 0.721669 1.000000 False\n", + "3 1.25 18 0.804343 0.833333 False" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "assessment = model_state[\"assessments\"][-1]\n", + "pd.DataFrame(assessment[\"alternatives\"])" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "4fcc998d", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'why': 'The teaching policy selects the observed partition with the greatest seed stability, using marker coherence to break ties. Compared 4 observed partitions; selected resolution 0.5, with seed stability 0.9310201021058004 and marker coherence 1.0.',\n", + " 'marker_evidence': ['The saved marker preview contains ALDH1A1, VCAN, CD163, S100A12, QPCT, CLEC4E, CD14, CYP27A1.'],\n", + " 'biology_to_preserve': 'Preserve the single-donor population structure and retain marker uncertainty; no batch or treatment comparison is supported.'}" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "selection = assessment[\"selection\"]\n", + "{\n", + " \"why\": selection[\"rationale\"],\n", + " \"marker_evidence\": selection[\"qualitativeFindings\"],\n", + " \"biology_to_preserve\": selection[\"objectivePreservation\"],\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "d6607595", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA48AAAJjCAYAAACsmCRCAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjEsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvctoD+AAAAAlwSFlzAAAPYQAAD2EBqD+naQABAABJREFUeJzs3XecXFd9///XvXd63d61RWXVe5flinshBmxiY8D0ksCXEiAmgfwSSIB0CMWExNRQQjHBYDA22HKTZVu9d+1KW7R9erl37r2/P1YaabSzTZItyfo8efBY78y5dTQaveec8zmKbds2QgghhBBCCCHEGNQLfQJCCCGEEEIIIS5+Eh6FEEIIIYQQQoxLwqMQQgghhBBCiHFJeBRCCCGEEEIIMS4Jj0IIIYQQQgghxiXhUQghhBBCCCHEuCQ8CiGEEEIIIYQYl4RHIYQQQgghhBDjkvAohBjhyJEjfOITn+DNb34zP/zhDy/06VwUnn32Wd773veO+vv58sQTT/DhD3/4vO/3YvBK3TNx6Xq13lcX+phCCPFaIeFRiPMknU7zzne+k7vuumtC7Xfv3s0DDzzA/fffz7e+9S1M0yx4Xtd1/vd//5d7772Xf/qnfyq6j66uLj7/+c9z//3382d/9mf87//+74j9TJZt29xwww309fVx9913s3DhwjHbv1auezzt7e38+te/HvX38+XQoUP87ne/O+/7vRic73u2bt063v/+95+3/V3MXqvX+mq9ry70MYUQ4rVCwqMQ58nHPvYx1q1bxy9+8Ytx2/7kJz9h6dKlJJNJbrzxRnbv3s2HPvSh/PPd3d1MmzaNX/ziF+zfv5/169eP2Ed7ezsLFixgw4YNXHfddUydOpUPfehDfOADHzin6zh+/DiHDh3iM5/5DHfffTfz5s0bs/1r5brFpaetrY1HH330Qp/Gq+JyulYhhBAXL8eFPgEhXgt+/vOfs379eh544IFxQ0x3dzfvfve7+dznPscnP/lJAO677z4GBgbybcLhMBs3bqS6upq77rqLXC43Yj+//vWvyWQy/OpXv8LhGH4r+/1+Pvaxj/Gf//mfqGrx74aOHz/ON7/5TQ4cOEB1dTXvfOc7mT9/PgDPP/88f//3fw/ARz7yEXw+H//yL/9Cc3PzJX/diUSC73znO7z88stUVVXx7ne/m9mzZxc8/9BDD7Fx40bKysq44447uP7668e8ptOZpslPfvITnn76aTRN46abbuLOO+8867Z79uzhf/7nf+js7GTlypW8733vQ9O0/PNjvY6maXLffffxmc98Jh/+//qv/5rBwUEefPBBYPj1+PCHP8x//dd/sXHjRh555BH+7M/+bMxjTsR49/l0v/jFL9i0aRNf+MIX8o9t2LCBBx98kO9973tj3qtNmzbx9a9/ncHBwXyv97333sub3vSmcV/LJ554gkceeYQPfOADfOtb36K7u5uHHnqIYDA46esZ63WY6DWePJ/R7v9o13rnnXdO+M/cudzr0+/FZN4jO3bs4O/+7u8A8Hq9tLa28sEPfpCKiopRt5mIi+m9LIQQlxvpeRTiHLW3t/PhD3+YH/3oR7jd7nHb//jHP8a2bf78z/+84PHy8vL8f/t8Pqqrq8fcz4wZM8hkMnR1deUfO3ToENOmTRs1QPX19bF48WJeeOEFrrvuOuLxOMuXL+f5558HoKmpidtuuw2AO+64g3vuuYfS0tJL/roHBwdZvnw53/3ud1mxYgV1dXW89a1vpaOjA4BIJMLKlSt58sknue6662hsbOStb30rX/7yl8e9rpM++9nP8pnPfIYFCxawdOlS/ud//od/+7d/O6u23d3d3H333dTU1LB8+XI+97nP8Zd/+Zf558d7HTVN4/Dhw/meKsMw+PKXv8x///d/c/z4cQD++Mc/smHDBkpLSzl06BDf/va3xzymYRjcddddPPHEE6Peg/Hu85n27NnDk08+WfBYR0cHv/rVr8a9V/X19axcuRKv18s999zDPffcw9y5cyf0Wh46dIiHHnqIN77xjUydOpU3v/nNRf8Mj3c9470OE73G8e7/aNc6mT9z53Kv4ezeI9XV1fnzvf7669m+fTvz589naGho1G3Gc7G9l4UQ4rJjCyHOmmEY9urVq+2vfOUrtm3b9ne+8x17vLfV29/+dnvFihX2hg0b7Pe97332e9/7Xvvb3/62ncvlirZ/05veZP/Jn/xJ0ef++7//225sbLRvueUWe/ny5fbVV19tHzp0aNRjf/SjH7Xnzp1rm6aZf+ytb32rvWrVqvzvBw4csAH7yJEjo+7nUrvuj33sY3ZLS4udyWTyj6VSKTuRSNi2bduf/OQn7de97nUF2zz22GO21+u1s9msbdu2/YMf/MCurq7OP3/m7wsXLrS/8Y1v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5cu1Zs0affvb39b3v/992WzzF6Y8+eSTsixLMzMzZdVQly9frp07d+oXv/hFWftHH310waWvAADgs4nwCACXmA0bNshms+mZZ57R7bffXrr+zDPPyOPxzJshPOvhhx/W448/rq9//esL3t+5c6e2b9+uHTt2lF3fvXu3HnroIf3sZz8rC6sAAODSQngEgEvMwMCAfvSjH+mrX/2qvve976mnp0cHDx7UL3/5S/30pz9VQ0PDgq+77bbbdNttty14b3h4WHv27NHDDz88797WrVuVSqW0e/fuf3tp6h/+8Adls1mdPHlShUJBu3btksvl0l133fVv9QcAAD56hEcAuAQ98sgjuvnmm/Xss89q79696ujo0PPPP6+NGzeW2rS1telLX/qSnE7ngn20t7eX7h84cEAPPPCArrnmmnntfD6ffvzjH2tiYqKs3w8yC/nnP/9ZiURC3d3dkopLZP1+P+ERAIBPEcNaqKoCAAAAAADn4agOAAAAAEBFhEcAAAAAQEWERwAAAABARYRHAAAAAEBFhEcAAAAAQEWERwAAAABARYRHAAAAAEBFhEcAAAAAQEWERwAAAABARYRHAAAAAEBFhEcAAAAAQEWERwAAAABARf8fsovwie56bycAAAAASUVORK5CYII=", + "text/plain": [ + "
    " + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "result.plot_embedding(figsize=(9, 6))" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "4ce21a43", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    group_idfeature_namescorefrac_exp
    01ALDH1A10.904730.49496
    11VCAN0.901040.98096
    53510CTSL0.775850.78667
    53610C1QA0.739800.52000
    97411SERPINF10.841460.91304
    97511LILRA40.832171.00000
    15302TNFRSF13B0.838730.71956
    15312IGHA10.824600.71956
    16333LRRN30.723250.54112
    16343NOG0.596620.26399
    16944KLRF10.854540.93539
    16954ADGRG10.793900.83989
    \n", + "
    " + ], + "text/plain": [ + " group_id feature_name score frac_exp\n", + "0 1 ALDH1A1 0.90473 0.49496\n", + "1 1 VCAN 0.90104 0.98096\n", + "535 10 CTSL 0.77585 0.78667\n", + "536 10 C1QA 0.73980 0.52000\n", + "974 11 SERPINF1 0.84146 0.91304\n", + "975 11 LILRA4 0.83217 1.00000\n", + "1530 2 TNFRSF13B 0.83873 0.71956\n", + "1531 2 IGHA1 0.82460 0.71956\n", + "1633 3 LRRN3 0.72325 0.54112\n", + "1634 3 NOG 0.59662 0.26399\n", + "1694 4 KLRF1 0.85454 0.93539\n", + "1695 4 ADGRG1 0.79390 0.83989" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "marker_table = result.get_markers()\n", + "marker_table.sort_values(\n", + " [\"group_id\", \"score\"], ascending=[True, False],\n", + ").groupby(\"group_id\", sort=True).head(2)[\n", + " [\"group_id\", \"feature_name\", \"score\", \"frac_exp\"]\n", + "].head(12)" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "8cb1bd3e", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'report': 'index.html', 'exists': True}" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "report_path = result.report()\n", + "{\"report\": report_path.name, \"exists\": report_path.is_file()}" + ] + } + ], + "metadata": { + "description": "Choose, explain, and execute RNA analysis settings with Scarf agents.", + "jupytext": { + "cell_metadata_filter": "tags", + "text_representation": { + "extension": ".md", + "format_name": "myst", + "format_version": 0.13, + "jupytext_version": "1.14.1" + } + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.0" + }, + "source_map": [ + 14, + 59, + 80, + 85, + 410, + 420, + 428, + 433, + 440, + 453, + 457, + 464, + 469, + 472 + ] + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/.jupyter_cache/executed/e470ea1bc7598db9f553a48c4f356d77/base.ipynb b/docs/.jupyter_cache/executed/e470ea1bc7598db9f553a48c4f356d77/base.ipynb deleted file mode 100644 index 44c025bd..00000000 --- a/docs/.jupyter_cache/executed/e470ea1bc7598db9f553a48c4f356d77/base.ipynb +++ /dev/null @@ -1,930 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 1, - "id": "0dbfd15d", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
    Downloading bucket files18098007 / 18098007 complete18098007 / 18098007 complete
    " - ], - "text/plain": [ - "Downloading bucket files: 18098007 / 18098007 complete" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "
    Downloading bytes18098007 / 18098007 complete18098007 / 18098007 complete
    " - ], - "text/plain": [ - "Downloading bytes: 18098007 / 18098007 complete" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/plain": [ - "{'source': 'data.h5', 'destination': 'agent_workflow.zarr'}" - ] - }, - "execution_count": 1, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "from contextlib import redirect_stdout\n", - "from io import StringIO\n", - "from pathlib import Path\n", - "\n", - "import scarf\n", - "from scarf.agent import (\n", - " AgentOrchestrator,\n", - " AgentRunConfig,\n", - " AutomatedWorkflowConfig,\n", - " AutomatedWorkflowRequest,\n", - " DecisionSelection,\n", - " load_agent_report,\n", - " load_agent_workflow,\n", - ")\n", - "\n", - "scarf.configure_output(level=\"WARNING\", progress=False)\n", - "\n", - "source_path = scarf.cytebase.connect(\"scarf_docs\").download(\n", - " \"tenx_5K_pbmc_rnaseq/data.h5\",\n", - " destination=\"scarf_datasets\",\n", - ")[0]\n", - "zarr_path = source_path.with_name(\"agent_workflow.zarr\")\n", - "\n", - "study_context = (\n", - " \"This is a human 10x Genomics 5K PBMC 3-prime gene-expression dataset \"\n", - " \"from peripheral blood collected from one healthy donor. The goal is \"\n", - " \"unsupervised identification and characterization of the major immune-cell \"\n", - " \"populations. No treatment comparison, technical batch covariate, paired \"\n", - " \"modality, or independent replication metadata is available. Do not invent \"\n", - " \"absent design variables or report treatment effects.\"\n", - ")\n", - "\n", - "{\"source\": source_path.name, \"destination\": zarr_path.name}" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "c574d07b", - "metadata": { - "tags": [ - "remove-cell" - ] - }, - "outputs": [], - "source": [ - "import json\n", - "from typing import Any\n", - "\n", - "from pydantic_ai.messages import (\n", - " ModelMessage,\n", - " ModelResponse,\n", - " ToolCallPart,\n", - " ToolReturnPart,\n", - ")\n", - "from pydantic_ai.models.function import AgentInfo, FunctionModel\n", - "\n", - "from scarf.agent.biological_interpretation import (\n", - " BiologicalInterpretationReport,\n", - " ClusterCompositionEvidence,\n", - " ClusterInterpretation,\n", - " ClusterMarkerBatchEvidence,\n", - ")\n", - "from scarf.agent.data_enrichment import (\n", - " AssayFeatureInspectionBatch,\n", - " DataEnrichmentReport,\n", - " FeatureSelectionPolicy,\n", - " StudyContextSummary,\n", - ")\n", - "from scarf.agent.experimental_context import (\n", - " BatchCorrectionPlan,\n", - " CovariateEvidence,\n", - " ExperimentalContextDecision,\n", - ")\n", - "\n", - "def _prompt_text(messages: list[ModelMessage]) -> str:\n", - " values = []\n", - " for message in messages:\n", - " for part in message.parts:\n", - " content = getattr(part, \"content\", None)\n", - " if isinstance(content, str):\n", - " values.append(content)\n", - " elif isinstance(content, tuple):\n", - " values.extend(item for item in content if isinstance(item, str))\n", - " return \"\\n\".join(values)\n", - "\n", - "\n", - "def _tool_result(\n", - " messages: list[ModelMessage],\n", - " tool_name: str,\n", - " model_type: Any,\n", - ") -> Any:\n", - " for message in reversed(messages):\n", - " for part in reversed(message.parts):\n", - " if isinstance(part, ToolReturnPart) and part.tool_name == tool_name:\n", - " if isinstance(part.content, model_type):\n", - " return part.content\n", - " if isinstance(part.content, str):\n", - " return model_type.model_validate_json(part.content)\n", - " return model_type.model_validate(part.content)\n", - " raise AssertionError(f\"Missing tool return {tool_name!r}\")\n", - "\n", - "\n", - "def _tool_call(name: str, args: dict[str, Any] | None = None) -> ModelResponse:\n", - " return ModelResponse(parts=[ToolCallPart(tool_name=name, args=args or {})])\n", - "\n", - "\n", - "def _structured_output(info: AgentInfo, value: Any) -> ModelResponse:\n", - " payload = value.model_dump() if hasattr(value, \"model_dump\") else value\n", - " return _tool_call(info.output_tools[0].name, payload)\n", - "\n", - "\n", - "def _scripted_workflow_model() -> tuple[FunctionModel, dict[str, int]]:\n", - " state = {\n", - " \"enrichment\": 0,\n", - " \"context\": 0,\n", - " \"parameter\": 0,\n", - " \"biology\": 0,\n", - " \"requests\": 0,\n", - " }\n", - "\n", - " async def reply(\n", - " messages: list[ModelMessage],\n", - " info: AgentInfo,\n", - " ) -> ModelResponse:\n", - " state[\"requests\"] += 1\n", - " tools = {tool.name for tool in info.function_tools}\n", - "\n", - " if \"inspect_assay_features_batch\" in tools or state[\"enrichment\"] == 1:\n", - " if state[\"enrichment\"] == 0:\n", - " state[\"enrichment\"] = 1\n", - " return _tool_call(\"inspect_assay_features_batch\")\n", - "\n", - " batch = _tool_result(\n", - " messages,\n", - " \"inspect_assay_features_batch\",\n", - " AssayFeatureInspectionBatch,\n", - " )\n", - " policies = []\n", - " for inspection in batch.inspections:\n", - " species_observed = inspection.species != \"unknown\"\n", - " policy_evidence = list(inspection.evidenceIds)\n", - " if not species_observed:\n", - " policy_evidence.append(\"context:study\")\n", - " policies.append(\n", - " FeatureSelectionPolicy(\n", - " assay=inspection.assay,\n", - " species=(\n", - " inspection.species\n", - " if species_observed\n", - " else \"homo_sapiens\"\n", - " ),\n", - " speciesConfidence=\"high\" if species_observed else \"medium\",\n", - " speciesRationale=(\n", - " inspection.speciesReason\n", - " or \"The exact study paragraph identifies a human sample.\"\n", - " ),\n", - " excludeFamilies=[\n", - " family.family\n", - " for family in inspection.families\n", - " if family.count > 0 and family.defaultExclude is True\n", - " ],\n", - " protectFamilies=[\n", - " family.family\n", - " for family in inspection.families\n", - " if family.count > 0 and family.defaultExclude is False\n", - " ],\n", - " rationale=(\n", - " \"Exclude observed technical families and preserve \"\n", - " \"observed protected families.\"\n", - " ),\n", - " evidenceIds=list(dict.fromkeys(policy_evidence)),\n", - " )\n", - " )\n", - " state[\"enrichment\"] = 2\n", - " return _structured_output(\n", - " info,\n", - " DataEnrichmentReport(\n", - " status=\"done\",\n", - " studyContextSummary=StudyContextSummary(\n", - " organismReferences=[\"human\"],\n", - " tissueReferences=[\"peripheral blood\"],\n", - " experimentalReferences=[\n", - " \"10x Genomics 5K PBMC 3-prime gene-expression dataset\"\n", - " ],\n", - " analysisIntentReferences=[\n", - " \"unsupervised identification and characterization of \"\n", - " \"the major immune-cell populations\"\n", - " ],\n", - " ),\n", - " policies=policies,\n", - " ),\n", - " )\n", - "\n", - " if tools.intersection(\n", - " {\n", - " \"inspect_cell_covariates\",\n", - " \"analyze_experimental_design\",\n", - " \"score_current_representation\",\n", - " }\n", - " ) or state[\"context\"] in {1, 2}:\n", - " if state[\"context\"] == 0:\n", - " state[\"context\"] = 1\n", - " return _tool_call(\"inspect_cell_covariates\")\n", - " if state[\"context\"] == 1:\n", - " state[\"context\"] = 2\n", - " return _tool_call(\n", - " \"analyze_experimental_design\",\n", - " {\n", - " \"column_domains\": {},\n", - " \"coefficients_of_interest\": [],\n", - " \"units_of_inference\": {},\n", - " \"batch_columns\": [],\n", - " },\n", - " )\n", - "\n", - " design = _tool_result(\n", - " messages,\n", - " \"analyze_experimental_design\",\n", - " CovariateEvidence,\n", - " )\n", - " profile = next(\n", - " value\n", - " for value in design.qcProfiles\n", - " if value.action == \"skip\"\n", - " )\n", - " evidence_id = profile.evidenceId\n", - " state[\"context\"] = 3\n", - " return _structured_output(\n", - " info,\n", - " ExperimentalContextDecision(\n", - " batchCorrection=BatchCorrectionPlan(\n", - " action=\"skip\",\n", - " rationale=\"No trusted technical batch column was supplied.\",\n", - " evidenceIds=[evidence_id],\n", - " ),\n", - " rationale=\"No experimental covariates were supplied.\",\n", - " evidenceIds=[evidence_id],\n", - " ),\n", - " )\n", - "\n", - " if tools.intersection(\n", - " {\"inspect_cluster_composition\", \"inspect_cluster_markers_batch\"}\n", - " ) or state[\"biology\"]:\n", - " if state[\"biology\"] == 0:\n", - " state[\"biology\"] = 1\n", - " return _tool_call(\"inspect_cluster_composition\")\n", - " if state[\"biology\"] == 1:\n", - " composition = _tool_result(\n", - " messages,\n", - " \"inspect_cluster_composition\",\n", - " ClusterCompositionEvidence,\n", - " )\n", - " state[\"biology\"] = 2\n", - " return _tool_call(\n", - " \"inspect_cluster_markers_batch\",\n", - " {\"cluster_ids\": list(composition.clusterCounts)},\n", - " )\n", - "\n", - " marker_batch = _tool_result(\n", - " messages,\n", - " \"inspect_cluster_markers_batch\",\n", - " ClusterMarkerBatchEvidence,\n", - " )\n", - " interpretations = []\n", - " for cluster in marker_batch.clusters:\n", - " if cluster.evidenceId and cluster.markers:\n", - " marker = cluster.markers[0]\n", - " marker_name = marker.featureName or marker.featureId\n", - " interpretations.append(\n", - " ClusterInterpretation(\n", - " clusterId=cluster.clusterId,\n", - " proposedIdentity=f\"{marker_name}-high RNA state\",\n", - " identityIsHypothesis=True,\n", - " confidence=\"low\",\n", - " rationale=(\n", - " \"The returned marker panel is led by \"\n", - " f\"{marker_name}.\"\n", - " ),\n", - " evidenceIds=[cluster.evidenceId],\n", - " )\n", - " )\n", - " state[\"biology\"] = 3\n", - " return _structured_output(\n", - " info,\n", - " BiologicalInterpretationReport(\n", - " status=\"done\",\n", - " clusterInterpretations=interpretations,\n", - " evidenceIds=[item.evidenceIds[0] for item in interpretations],\n", - " limitations=[\n", - " \"The scripted documentation model returns marker-linked \"\n", - " \"hypotheses, not validated cell identities.\"\n", - " ],\n", - " stopReason=(\n", - " \"Every cluster with returned marker evidence was reviewed.\"\n", - " ),\n", - " ),\n", - " )\n", - "\n", - " prompt = _prompt_text(messages)\n", - " if any(\n", - " tool.parameters_json_schema.get(\"title\")\n", - " == \"AnalysisVisualAdjudication\"\n", - " for tool in info.output_tools\n", - " ):\n", - " payload, _ = json.JSONDecoder().raw_decode(prompt[prompt.index(\"{\") :])\n", - " return _structured_output(\n", - " info,\n", - " {\n", - " \"status\": \"acceptable\",\n", - " \"selectedCandidateId\": payload[\"selectedCandidateId\"],\n", - " \"rationale\": (\n", - " \"The bounded diagnostic board agrees with the registered \"\n", - " \"numeric evidence.\"\n", - " ),\n", - " },\n", - " )\n", - "\n", - " decision, _ = json.JSONDecoder().raw_decode(prompt[prompt.index(\"{\") :])\n", - " evidence_by_class = {}\n", - " evidence_class_by_id = {}\n", - " for item in decision[\"evidence\"]:\n", - " evidence_by_class.setdefault(\n", - " item[\"evidenceClass\"],\n", - " item[\"evidenceId\"],\n", - " )\n", - " evidence_class_by_id[item[\"evidenceId\"]] = item[\"evidenceClass\"]\n", - " preferred = decision.get(\"metricPreferredOptionId\")\n", - " selected = (\n", - " next(\n", - " option\n", - " for option in decision[\"options\"]\n", - " if option[\"optionId\"] == preferred\n", - " )\n", - " if preferred is not None\n", - " else next(\n", - " option\n", - " for option in decision[\"options\"]\n", - " if option[\"status\"] in {\"apply\", \"skip\"}\n", - " )\n", - " )\n", - " evidence_ids = list(selected.get(\"requiredEvidenceIds\", []))\n", - " cited_classes = {\n", - " evidence_class_by_id[evidence_id] for evidence_id in evidence_ids\n", - " }\n", - " for evidence_class in selected[\"requiredEvidenceClasses\"]:\n", - " if evidence_class not in cited_classes:\n", - " evidence_ids.append(evidence_by_class[evidence_class])\n", - " state[\"parameter\"] += 1\n", - " return _structured_output(\n", - " info,\n", - " DecisionSelection(\n", - " selectedOptionId=selected[\"optionId\"],\n", - " evidenceIds=evidence_ids,\n", - " rationale=\"Select the registered metric-preferred option.\",\n", - " confidence=\"high\",\n", - " ),\n", - " )\n", - "\n", - " return FunctionModel(reply), state" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "9b066d3c", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'candidate_evaluation_limit': 14,\n", - " 'refinement_candidates': 0,\n", - " 'harmony_candidates': 0,\n", - " 'input_policy': 'unattended'}" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "model, model_state = _scripted_workflow_model()\n", - "config = AutomatedWorkflowConfig(\n", - " inputPolicy=\"unattended\",\n", - " maxRefinedCandidatesPerAssay=0,\n", - " maxHarmonyCandidatesPerAssay=0,\n", - " maxCandidateEvaluations=14,\n", - " hvgCandidateCounts=(1000,),\n", - " pcaCandidateDimensions=(20,),\n", - " graphNeighborCandidates=(21,),\n", - " leidenResolutionCandidates=(1.0,),\n", - " minClusterCells=2,\n", - " agentRunConfig=AgentRunConfig(\n", - " requestLimit=5,\n", - " toolCallLimit=5,\n", - " ),\n", - ")\n", - "orchestrator = AgentOrchestrator(model, config=config)\n", - "request = AutomatedWorkflowRequest(\n", - " sourcePath=str(source_path),\n", - " zarrPath=str(zarr_path),\n", - " studyContext=study_context,\n", - " studyObjective=\"Discover stable major immune-cell populations.\",\n", - " primaryAssay=\"RNA\",\n", - " markerAssay=\"RNA\",\n", - " analysisAssays=[\"RNA\"],\n", - " ingestDirections={\"overwrite\": True, \"defaultAssay\": \"RNA\"},\n", - ")\n", - "\n", - "{\n", - " \"candidate_evaluation_limit\": config.maxCandidateEvaluations,\n", - " \"refinement_candidates\": config.maxRefinedCandidatesPerAssay,\n", - " \"harmony_candidates\": config.maxHarmonyCandidatesPerAssay,\n", - " \"input_policy\": config.inputPolicy,\n", - "}" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "5c4749e6", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'status': 'completed',\n", - " 'stage': 'analysis_finalization',\n", - " 'primary_assay': 'RNA',\n", - " 'marker_assay': 'RNA',\n", - " 'cell_qc': 'skip',\n", - " 'routes': [{'assay': 'RNA', 'features': 'hvg', 'reduction': 'pca'}]}" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "with redirect_stdout(StringIO()):\n", - " result = orchestrator.run(request)\n", - "\n", - "if (\n", - " result.status != \"completed\"\n", - " or result.finalAnalysis is None\n", - " or result.preprocessingPlan is None\n", - " or result.workflowRun is None\n", - " or result.zarrPath is None\n", - "):\n", - " raise RuntimeError(f\"Unexpected workflow result: {result.status}, {result.notes}\")\n", - "\n", - "plan = result.preprocessingPlan\n", - "{\n", - " \"status\": result.status,\n", - " \"stage\": result.currentStage,\n", - " \"primary_assay\": plan.primaryAssay,\n", - " \"marker_assay\": plan.markerAssay,\n", - " \"cell_qc\": plan.cellQc.action,\n", - " \"routes\": [\n", - " {\n", - " \"assay\": assay.assay,\n", - " \"features\": assay.featureMethod,\n", - " \"reduction\": assay.reductionMethod,\n", - " }\n", - " for assay in plan.assays\n", - " ],\n", - "}" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "a0cebe8d", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'status': 'completed',\n", - " 'stage': 'analysis_finalization',\n", - " 'agent_reports': ['data_enrichment',\n", - " 'experimental_context',\n", - " 'parameter_tuning'],\n", - " 'model_requests': 12,\n", - " 'graph_method': 'native',\n", - " 'marker_assay': 'RNA'}" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "persisted_workflow = load_agent_workflow(\n", - " result.zarrPath,\n", - " result.workflowRun.workflowRunId,\n", - " workspace=result.workflowRun.workspace,\n", - ")\n", - "\n", - "{\n", - " \"status\": persisted_workflow.status,\n", - " \"stage\": result.currentStage,\n", - " \"agent_reports\": [ref.agentName for ref in result.reportReferences],\n", - " \"model_requests\": model_state[\"requests\"],\n", - " \"graph_method\": result.finalAnalysis.graphMethod,\n", - " \"marker_assay\": result.finalAnalysis.markerAssay,\n", - "}" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "7cff27cb", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'candidates': [{'assay': 'RNA',\n", - " 'candidate': 1,\n", - " 'dimensions': 20,\n", - " 'resolution': 1.0,\n", - " 'neighbors': 21,\n", - " 'eligible': True,\n", - " 'clusters': 11,\n", - " 'smallest_cluster': 30,\n", - " 'graph_silhouette': 0.3899432284072132},\n", - " {'assay': 'RNA',\n", - " 'candidate': 2,\n", - " 'dimensions': 20,\n", - " 'resolution': 1.0,\n", - " 'neighbors': 21,\n", - " 'eligible': True,\n", - " 'clusters': 11,\n", - " 'smallest_cluster': 30,\n", - " 'graph_silhouette': 0.3899432284072132},\n", - " {'assay': 'RNA',\n", - " 'candidate': 3,\n", - " 'dimensions': 20,\n", - " 'resolution': 1.0,\n", - " 'neighbors': 21,\n", - " 'eligible': True,\n", - " 'clusters': 11,\n", - " 'smallest_cluster': 30,\n", - " 'graph_silhouette': 0.3899432284072132},\n", - " {'assay': 'RNA',\n", - " 'candidate': 4,\n", - " 'dimensions': 20,\n", - " 'resolution': 1.0,\n", - " 'neighbors': 21,\n", - " 'eligible': True,\n", - " 'clusters': 11,\n", - " 'smallest_cluster': 30,\n", - " 'graph_silhouette': 0.3899432284072132}],\n", - " 'stop_reason': 'Four causal RNA parameter phases were selected.',\n", - " 'report_statuses': {'data_enrichment': 'done',\n", - " 'experimental_context': 'done',\n", - " 'parameter_tuning': 'done'}}" - ] - }, - "execution_count": 6, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "reports = {\n", - " reference.agentName: load_agent_report(result.zarrPath, reference)\n", - " for reference in result.reportReferences\n", - "}\n", - "parameter_report = reports[\"parameter_tuning\"]\n", - "\n", - "candidate_metrics = []\n", - "for assay, assay_report in parameter_report.assayReports.items():\n", - " for index, evaluation in enumerate(assay_report.evaluations, start=1):\n", - " candidate_metrics.append(\n", - " {\n", - " \"assay\": assay,\n", - " \"candidate\": index,\n", - " \"dimensions\": evaluation.parameters.dimensions,\n", - " \"resolution\": evaluation.parameters.leidenResolution,\n", - " \"neighbors\": evaluation.parameters.neighborsK,\n", - " \"eligible\": evaluation.eligible,\n", - " \"clusters\": evaluation.metrics.nClusters,\n", - " \"smallest_cluster\": evaluation.metrics.minClusterCells,\n", - " \"graph_silhouette\": evaluation.metrics.graphSilhouetteMedian,\n", - " }\n", - " )\n", - "\n", - "{\n", - " \"candidates\": candidate_metrics,\n", - " \"stop_reason\": parameter_report.stopReason,\n", - " \"report_statuses\": {\n", - " name: report.status for name, report in reports.items()\n", - " },\n", - "}" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "29890691", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "
    " - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "final = result.finalAnalysis\n", - "if (\n", - " final.cellSelection is None\n", - " or final.clusters is None\n", - " or final.umap is None\n", - " or final.markers is None\n", - "):\n", - " raise RuntimeError(\"The completed final handoff is missing required artifacts\")\n", - "\n", - "result.plot_embedding(\n", - " legend_loc=\"on_data\",\n", - " frame=\"none\",\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "4c706504", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
    \n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
    group_idfeature_namescorefrac_exp
    01MARC10.755610.36202
    11ALDH1A10.754780.56380
    1439110MAL0.357710.67361
    1439210ADTRP0.346100.18519
    2878211FCER1A0.820670.85437
    2878311FLT30.723760.68932
    431732MT-ND60.190070.44218
    431742HIST1H2BG0.186340.15873
    575643GNG110.843811.00000
    575653CLU0.835190.93333
    719554VPREB30.924190.77222
    719564IGHD0.899740.74444
    \n", - "
    " - ], - "text/plain": [ - " group_id feature_name score frac_exp\n", - "0 1 MARC1 0.75561 0.36202\n", - "1 1 ALDH1A1 0.75478 0.56380\n", - "14391 10 MAL 0.35771 0.67361\n", - "14392 10 ADTRP 0.34610 0.18519\n", - "28782 11 FCER1A 0.82067 0.85437\n", - "28783 11 FLT3 0.72376 0.68932\n", - "43173 2 MT-ND6 0.19007 0.44218\n", - "43174 2 HIST1H2BG 0.18634 0.15873\n", - "57564 3 GNG11 0.84381 1.00000\n", - "57565 3 CLU 0.83519 0.93333\n", - "71955 4 VPREB3 0.92419 0.77222\n", - "71956 4 IGHD 0.89974 0.74444" - ] - }, - "execution_count": 8, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "marker_table = result.get_markers(\n", - " group_id=None,\n", - " min_score=-1,\n", - " min_frac_exp=-1,\n", - ")\n", - "marker_table.sort_values(\n", - " [\"group_id\", \"score\"],\n", - " ascending=[True, False],\n", - ").groupby(\"group_id\", sort=True).head(2)[\n", - " [\"group_id\", \"feature_name\", \"score\", \"frac_exp\"]\n", - "].head(12)" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "bef7a115", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'report': 'agent_workflow.zarr/agents/runs//report/index.html',\n", - " 'exists': True,\n", - " 'final_artifact_kinds': {'selection': 'cell_selection',\n", - " 'clusters': 'cluster_labels',\n", - " 'umap': 'embedding',\n", - " 'markers': 'marker_table'}}" - ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "report_path = result.report()\n", - "display_path = str(report_path.relative_to(Path(result.zarrPath).parent)).replace(\n", - " result.workflowRun.workflowRunId,\n", - " \"\",\n", - ")\n", - "\n", - "{\n", - " \"report\": display_path,\n", - " \"exists\": report_path.is_file(),\n", - " \"final_artifact_kinds\": {\n", - " \"selection\": final.cellSelection.kind,\n", - " \"clusters\": final.clusters.kind,\n", - " \"umap\": final.umap.kind,\n", - " \"markers\": final.markers.kind,\n", - " },\n", - "}" - ] - } - ], - "metadata": { - "description": "Choose, explain, and execute RNA analysis settings with Scarf agents.", - "jupytext": { - "cell_metadata_filter": "tags", - "text_representation": { - "extension": ".md", - "format_name": "myst", - "format_version": 0.13, - "jupytext_version": "1.14.1" - } - }, - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.14.0" - }, - "source_map": [ - 14, - 100, - 134, - 140, - 458, - 469, - 504, - 512, - 541, - 551, - 566, - 579, - 610, - 623, - 637, - 642, - 654, - 667, - 684 - ] - }, - "nbformat": 4, - "nbformat_minor": 5 -} \ No newline at end of file diff --git a/docs/.jupyter_cache/global.db b/docs/.jupyter_cache/global.db index e73abe98273f7d8ea23244421f83d1089c098604..cfad6c47c749e2b17cd976f2e235b2b4be5ae93b 100644 GIT binary patch delta 2645 zcmeH}yK7WY5XMcg5O%X$2muRW1uF9Vp3!y+EdQrtt zDN#W0C+~H{I)HT3J_$@%eEV{H7(T(sgHuxG20<#7JI?4#jKcM?jk5rbK9nD1F;Iio zvY3hSv~*=96Z4}`QMhcMFyVA`Vrr=)wl;!r*(^>8(z~fW&I%BP+h%b}0xe!$%bXqZ z6X4)~eo;P2iF4FDy_`J_ryU!MVkU;(Oy3!6etkZ%Q9Cahrv&Y4U)PaC!y`R2gM+?m zEAtB#t!CZaEL3K7)uK11Qgy+Zg~}YQ&ct(3B_5&yRQ!s-I=?LCu#Ue>>G9H!nKLJg zU-I+0kHs6s3&qFzPlccP?}eeBjl$>L^FpcTcwsUBB3FO4dOo*Pzg9k6`dh5mw{%x) zjt9+j&`ex52hEgi|)X{GPmQEZbEPiG%J%cfzXf*|Z2u4pFF{ssvAT|fW; delta 2645 zcmeH}yK7WY5XMcg5H{H@gn)&xf*37wIggn$=LAGk1Z+~|VT@}`$g_wK1nolNM&g3z zAvlGCO?pApbXEpzv=XcY{{ZPau2aaVD`!qT+&_wn8D%sqEzsym(4ozCZG_@wCv z>PO5#E31rA#`UStr>LxdBc3b6_j_B9tka}X5+`D~FtVK+TWte6Q5%V~yf?C%8yknp zh{lOnUL38pwZ>`hC1Vpw;KO6JJcRZ}Q;@WYc;LOU_1sw7m|2VBMEL6YyScIQ03e1) z1T7yKuja-IKm{ck*9pHKZzopDS`@!Woe*EYP;I7wEX4CliJOGDXJVr*Hp+tiGes09 z68P=Jom}k6YDbbd5wd*qVyhUCdF{XmRc;XLxLj#FlLN1WPYqcjTJgKey*vgP9h6YH zC{BcU`O<1Gc7$NW1J+64W0T8maSUw51G-4S@SDlDGkWJq6er>>FI`^GoiYAW0i@E^ zN#LHTmFCre41z;1iW3R^YU4AX-Ahj@~XXglm zpPvnjh;|1294uON^F+Kh9nOHtjPs~8AStC0SESJK>XMbJ%HO6>9q*biyf0lVohv=; z`cVAd^|d(Iy;=NNcv>uXA1yAGK6O1S)LyKeE$r5=R1TK^7VG5=)6uNserMY6O#7Yb z*NF_-|I(QT|DiL5evNS;8S=Y7Zl5UR-Gt&)cEXIpED=H;+E{6eV^54DO<0O3k-%Rz zTEhV|=a5Kf%MB8DjK`a;Q5he_eMa7(5;2Acw_4*JGmLAF6L*0+@%(wKHCLk;D8!{k j$eajheze`rjEYPgmXbIUr}*@PCwVm>EdpZc64Ce*CkR_A diff --git a/docs/source/analysis_with_agents.md b/docs/source/analysis_with_agents.md index 61a013a8..71d46f77 100644 --- a/docs/source/analysis_with_agents.md +++ b/docs/source/analysis_with_agents.md @@ -97,83 +97,73 @@ Before the first mutating operation, make a short execution record containing: Update this record before changing the cohort, inputs, or decision criteria. This prospective boundary makes unintended writes and retrospective justifications visible. -`AgentOrchestrator` persists the immutable request, effective configuration, stage attempts, -agent-report handoffs, and artifact references. The caller still owns the scientific question and +`AgentOrchestrator` keeps one authoritative stage history containing the immutable request, +effective configuration, evidence, decisions, checks, and final artifact references. The caller still owns the scientific question and unit of inference. ### When to use the automated agent workflow -Use `analyze_rna` when the input is a supported dataset path and the caller can supply one -study-context paragraph and one study objective. The automated workflow supports one RNA assay; -other modalities may coexist in the store but are not analyzed. Automated multimodal integration -and hypothesis testing are deferred. Use the ordinary Scarf APIs for those analyses. -The orchestrator owns a fixed stage order: ingest, -Data Enrichment, Experimental Context, preprocessing planning and -execution, Parameter Tuning, feature-policy review with optional revised preprocessing and tuning, -analysis review, and analysis finalization. The model does not write exploratory code or choose -arbitrary `DataStore` calls. It selects only validated policies and candidate identifiers from -bounded evidence; executor-owned public operations create and pass exact immutable artifact -references. - -`BiologicalInterpretationAgent` is a separate bounded facade for interpreting finalized cluster -and marker artifacts. It is not an automatic stage of `AgentOrchestrator`. +Use `analyze_rna` with a dataset, a configured model, study context, and a study objective. +The workflow analyzes one RNA assay, even when other modalities coexist in the store. Pass +`assay` explicitly when several RNA assays exist. Automated integration, HTO assignment, and +biological significance or differential-expression hypothesis execution are outside this +workflow; ordinary Scarf APIs remain available for them. Experimental Context still explores +individual and joint covariate patterns and possible explanations of the study design. ```python from scarf.agent import analyze_rna result = analyze_rna( "study.h5ad", - zarr_path="study.zarr", model=model, - study_context="One paragraph describing the study and analysis intent.", + study_context="One paragraph describing the study design and metadata roles.", study_objective="Discover stable populations relevant to the study.", - max_candidates=50, + zarr_path="study.zarr", ) -if result.status != "completed": - raise RuntimeError(f"{result.status}: {'; '.join(result.notes)}") - result.plot_embedding() markers = result.get_markers() report_path = result.report() ``` -Pass `assay` when the store has multiple RNA assays. The result helpers reopen the saved -workspace read-only and use exact final artifacts. `report()` returns the existing local HTML -path or generates the missing view, without opening a browser. - -The parameter screen uses granular public operations for each authorized branch rather than -invoking `ds.pipeline.run()` for every candidate. This keeps normalization, reduction, neighbours, -graph, clustering, metrics, promotion, UMAP, and marker artifacts explicit and enforces their order -through lineage. `ds.pipeline.run()` remains the fixed baseline recipe described below. - -`analyze_rna` runs unattended; its `study_objective` is required. `max_candidates` limits reserved -candidate slots across the workflow. Before screening, each pass reserves every configured -alternative, including conditional candidates that may not execute. Defaults reserve 25 slots -for the baseline and another 25 if a feature-policy revision runs; the default limit of 50 admits -both passes. A smaller limit never shrinks the candidate lists. A pass larger than the remaining -budget fails before screening. This controls admission, not actual execution counts, elapsed time, -QC diagnostics, or provider usage. Use `AgentOrchestrator` with -`AutomatedWorkflowConfig` for explicit candidate lists, workspaces, and provider limits. -Its `inputPolicy="pause"` permits a running workflow to return -`needsInput`; resume only that exact workflow with `AutomatedWorkflowResumeRequest` and its -persisted question identifiers. `inputPolicy="unattended"` resolves bounded model deferrals through -registered policy and turns genuinely unresolved evidence into an explicit abstention or failure -instead of waiting for a person. It does not authorize invented metadata or unsafe batch -correction. `runConfoundedHarmonyDiagnostic=True` permits a matched diagnostic branch, but Harmony -still cannot be selected unless the design and preservation gates accept it. - -The repository notebooks `notebook/agent_workflow_new.ipynb` and -`notebook/agent_workflow_new_short.ipynb` demonstrate the unattended full-cohort and sampled smoke -test configurations. The short notebook creates a deterministic library-stratified H5AD beside -the source data and labels its result as a smoke test. A completed local workflow persists its -terminal result and then creates a replaceable HTML report. `generate_agent_report()` can -regenerate that derived view without training new analysis artifacts. - -Configuration compatibility is explicit: `maxCandidateEvaluations` replaces -`maxCandidateBranches`, and obsolete initial-candidate, integration, assay-count, and stability -controls are rejected. Saved workflows with the old configuration shape cannot resume or -regenerate reports in this release. Create a new workflow with explicit candidate lists; saved -analysis artifacts remain readable through ordinary Scarf artifact APIs. No records are migrated. +The beginner call returns a completed result or raises `AnalysisError`. Its result address is +available as `error.result` when work remains unresolved. There is no separate candidate-budget +argument on this interface. Advanced callers import the orchestrator and configuration from +`scarf.agent.orchestrator` for explicit workspaces, numerical limits, and resumable pauses. +See {doc}`reference/api/agent` for that boundary. + +The model first interprets observed study metadata and proposes at most eight objective-led +design comparisons, with one follow-up round of at most four. Comparisons may use a single +explanatory variable, a joint categorical group, or conditioning within categorical strata. +Continuous conditioning bins and regression adjustments are not invented. Continuous biological +variables without a supported preservation measure remain explicitly unresolved. + +Scarf starts from its RNA settings and four partitions of the same graph. The model reviews +quantitative diagnostics, marker and loading-gene evidence, and supplied images before accepting +or requesting one registered experiment. The model cannot generate executable analysis code or +arbitrary `DataStore` calls. Batch correction requires both an eligible design and measured need; +acceptance additionally requires a matched native/corrected comparison preserving protected +biology, including supported joint groups. Unsafe or unknown designs cannot license correction. + +Large inputs use an immutable uniform screening cohort of 50,000 cells, with one possible +enlargement to 100,000. Coverage and rare-population concerns can require a full-cohort baseline. +Screening selects settings; it does not replace the final QC-retained cohort. Selected settings +are executed and assessed on the full cohort. The default numerical limits admit at most 12 +screening evaluations per sample and 24 in total, four full-cohort graphs, eight full-cohort +partitions, and one targeted full-cohort repair. Reused exact work is not charged again. Limits +bound numerical work rather than promise elapsed time or provider cost. + +All saved execution and decisions belong to the orchestration stage history. Identical calls +reuse completed work or resume matching interrupted work; changed inputs and identity checks +prevent silent reuse of stale evidence. The result's plot and marker methods use the exact saved +workspace and artifacts read-only. The cluster map displays at most 50,000 cells while retaining +full counts and provenance. `report()` returns or regenerates one local analysis summary from +saved evidence, with no new model calls or numerical analysis. + +This release deliberately breaks the earlier agent imports and persistence contracts. The root +agent facade exports only `analyze_rna`, `AutomatedWorkflowResult`, and `AnalysisError`. Old agent +runs must be restarted; their numerical artifacts remain readable through ordinary Scarf APIs. +There are no implicit migrations. Standalone scientific agent APIs remain in their concrete +packages, such as `scarf.agent.biological_interpretation`. ### When to use the pipeline @@ -299,9 +289,10 @@ Classify the problem before retrying: In a granular workflow, retry the lowest failed stage. A failed pipeline run is not resumable; start a new run, which can reuse matching complete artifacts from the earlier attempt. An automated -agent workflow resumes only while it is running after `needsInput`. Failed and abandoned -orchestrations are terminal, while completed stages in a valid running workflow are checked and -reused on resume. +agent workflow validates its exact request and stage history before reusing completed work or +resuming an interruption. Explicit questions require grounded answers through the advanced +resume interface. A changed dataset, model identity, or configuration cannot be silently attached +to an older history. ## Progress and deterministic comparisons @@ -330,7 +321,8 @@ A useful handoff reports: Artifact provenance records how Scarf produced a result. It does not replace this study-level reasoning record. -For an automated run, `AutomatedWorkflowResult.finalAnalysis` provides the exact final artifact -handoff and `reportReferences` identifies the persisted agent reports. The generated local HTML -report is a replaceable presentation of those durable records, not an additional source of truth. +For an automated run, the result's plotting, markers, and report helpers resolve the exact final +artifacts from the authoritative stage history. The result is a small address, not a second saved +copy of requests, reports, decisions, or finalization state. The HTML report is a replaceable +presentation of that history. See {doc}`index` for the implemented methods and current boundaries. diff --git a/docs/source/developers/architecture.md b/docs/source/developers/architecture.md index b2664499..10d8d16b 100644 --- a/docs/source/developers/architecture.md +++ b/docs/source/developers/architecture.md @@ -134,21 +134,30 @@ ledger under `pipeline/runs`; it does not write live metadata. DataStore-owned p loading, and export consume narrow frozen-run views. Completed runs can be reopened by their immutable label or exact run ID. -`agent/` owns the evidence-bounded automated workflow. Its four public agent facades are -`data_enrichment`, `experimental_context`, `parameter_tuning`, and -`biological_interpretation`. Each package keeps contracts independent of its deterministic tools, -validation, and agent runner. Supporting responsibilities live in `decisions/`, `cell_quality/`, -`hypotheses/`, `persistence/`, and `report/`. `ingest/` and `orchestrator/` remain workflow owners. -Internal modules import these concrete owners rather than the broad `scarf.agent` facade, and -`agent/tools/` contains only infrastructure shared by more than one agent. +`agent/` owns the optional single-RNA workflow. Its lazy root facade exposes `analyze_rna`, +`AutomatedWorkflowResult`, and `AnalysisError`. Standalone scientific agent contracts and runners +remain in their concrete packages: `data_enrichment`, `experimental_context`, `parameter_tuning`, +and `biological_interpretation`. Internal modules import concrete owners rather than the root +facade. `agent/tools/` contains only infrastructure shared by more than one agent. + +The orchestration stage history is the sole owner of the immutable request, scientific evidence, +choices, checks, work reservations, recovery, and final artifact references. Checkpoints belong +to their exact stage inputs; they do not create a second workflow lifecycle. The result is a small +address that resolves this history. `report/` renders one replaceable analysis page from saved +evidence. It does not call a provider or recompute scientific results. ### Presentation -`plotting/` is the only plotting package. +`plotting/` owns the reusable plotting APIs. It has no import dependency on `datastore`. The removed `scarf.plots`, `scarf.plotting._legacy`, and `DataStore.plot_*` APIs must not be restored. New plots should return the established plotting result types, accept documented data contracts, and use narrow adapters instead of adding storage-path knowledge. +The single-RNA agent keeps its bounded final-map display in `agent/_plots.py`. This limited report +view reads exact final artifact references, samples only displayed coordinates and labels, and +returns the existing public `PlotResult` and provenance types. It introduces no core plotting API +or module-load dependency from plotting to datastore. + `DataStore.plots` is a thin, store-bound accessor over the canonical store-first functions in `scarf.plotting`. The accessor imports concrete plot implementations only when a method is called, so this convenience namespace does not reverse the dependency from plotting to datastore. diff --git a/docs/source/reference/api.md b/docs/source/reference/api.md index 9ac38105..72c2ccb1 100644 --- a/docs/source/reference/api.md +++ b/docs/source/reference/api.md @@ -11,6 +11,7 @@ Public Scarf surfaces for analysts: - `scarf.plotting` - Documented integration metrics (`DataStore.metric_*`; `scarf.metrics` holds the underlying functions) - `MappingReference` / `MappingResult` for atlas-style mapping +- `scarf.agent.analyze_rna` and its completed result, with the optional agent dependency Inheritance helpers (`BaseDataStore`, `GraphDataStore`, `MappingDatastore`) are listed under {doc}`api/datastore` for completeness. Prefer calling methods on `DataStore`. @@ -24,6 +25,7 @@ Prefer calling methods on `DataStore`. | Graph construction | {doc}`api/graph_construction` | | Artifacts, lineage, and summaries | {doc}`api/artifacts` | | Analysis pipeline | {doc}`api/pipeline` | +| Agent analysis | {doc}`api/agent` | | Assays and metadata | {doc}`api/assays` | | Integration and metrics | {doc}`api/integration` | | Mapping | {doc}`api/mapping` | diff --git a/docs/source/reference/api/agent.md b/docs/source/reference/api/agent.md new file mode 100644 index 00000000..50d76dc0 --- /dev/null +++ b/docs/source/reference/api/agent.md @@ -0,0 +1,88 @@ +# Agent analysis API reference + +The optional `scarf[agent]` dependency provides a small interface for selecting and explaining +RNA analysis settings. The root `scarf.agent` facade exports three objects: + +```{eval-rst} +.. autofunction:: scarf.agent.analyze_rna + +.. autoclass:: scarf.agent.AutomatedWorkflowResult + :members: plot_embedding, get_markers, report + +.. autoexception:: scarf.agent.AnalysisError +``` + +## Start an analysis + +```python +from scarf.agent import analyze_rna + +result = analyze_rna( + "study.zarr", + model=model, + study_context="Human blood from one healthy donor, with no treatment comparison.", + study_objective="Identify stable major immune-cell populations.", +) +result.plot_embedding() +markers = result.get_markers() +report_path = result.report() +``` + +The four required inputs are the source, configured Pydantic AI model, study context, and study +objective. `assay` selects an RNA assay when several exist. `zarr_path` selects the destination +when converting a supported input file. Other modalities in an existing store are ignored. +Automated integration, HTO assignment, and biological significance or differential-expression +hypothesis execution are outside this workflow. Experimental Context still explores individual +and joint covariate patterns and possible explanations of the study design. + +The beginner call returns only after completion. If it cannot complete, it raises `AnalysisError`, +whose `result` provides the status, notes, and exact saved address for investigation or advanced +resume. An identical repeated call reuses a completed analysis or resumes matching interrupted +work. Input and model identity checks prevent attaching changed analysis intent to saved work. + +## Use the result + +`plot_embedding()` displays the exact final UMAP colored by its clusters and returns Scarf's +established `PlotResult`. It accepts `figsize`, `show`, `seed`, and `max_points` from 1 to 50,000. +Sampling affects only display; cluster counts and final marker statistics describe the complete +QC-retained population. The image provenance records the workspace, artifact references, +population counts, and display sampling. No live metadata is created or overwritten. + +`get_markers()` returns the saved marker table using Scarf's standard `group_id`, `min_score`, +and `min_frac_exp` filters. `report()` returns the local `index.html` path and can regenerate the +single-page summary from saved evidence. It does not open a browser or rerun the analysis. + +The result stores a small address and outcome, including `zarrPath`, `workspace`, and +`workflowRunId`. Requests, scientific reports, decision histories, and final artifacts remain +owned by the orchestration journal. They are not repeated as public result fields. + +## Advanced control + +```python +from scarf.agent.orchestrator import ( + AgentOrchestrator, + AutomatedWorkflowConfig, + AutomatedWorkflowRequest, +) + +runner = AgentOrchestrator(model, config=AutomatedWorkflowConfig(inputPolicy="pause")) +result = runner.run(AutomatedWorkflowRequest( + sourcePath="study.zarr", + workspace="analysis", + studyContext="The observed study design and metadata roles.", + studyObjective="The biological structure that must be preserved.", +)) +``` + +The advanced interface exposes numerical limits, provider limits, existing-store workspaces, and +explicit pauses. Inspect its returned status and questions before continuing. Defaults allow +50,000 screening cells, one enlargement to 100,000, 12 evaluations per screen and 24 across +screens, four full-cohort graphs, eight full-cohort partitions, and one targeted full-cohort +repair. These counts bound distinct admitted work, including failed attempts; exact reuse does +not spend another slot. They do not bound every QC, marker, I/O, or provider cost. + +The previous agent workflow records, result fields, candidate-budget aliases, and root imports +are unsupported. Old agent runs must be restarted. Numerical artifacts remain accessible through +the ordinary Scarf artifact APIs; no saved records are silently migrated. + +For a complete executable example, see {doc}`../../tutorials/agent_workflow`. diff --git a/docs/source/toctree.yml b/docs/source/toctree.yml index 41d1d136..c711f64f 100644 --- a/docs/source/toctree.yml +++ b/docs/source/toctree.yml @@ -112,6 +112,8 @@ subtrees: title: Artifacts and lineage - file: reference/api/pipeline title: Pipeline + - file: reference/api/agent + title: Agent analysis - file: reference/api/import_export title: Import and export - file: reference/api/assays diff --git a/docs/source/tutorials/agent_workflow.md b/docs/source/tutorials/agent_workflow.md index 99246c87..f50de6e1 100644 --- a/docs/source/tutorials/agent_workflow.md +++ b/docs/source/tutorials/agent_workflow.md @@ -17,125 +17,72 @@ kernelspec: # Choose and explain RNA analysis settings -Scarf computes evidence about your data, the agent chooses between bounded alternatives, and -Scarf executes the selected settings. Start with a dataset, study context, and a configured -Pydantic AI model: +Give Scarf a dataset, a configured Pydantic AI model, a study-context paragraph, and an objective. +Scarf computes the evidence, the agent evaluates a few consequential choices, and Scarf executes +the selected analysis. ```python from scarf.agent import analyze_rna result = analyze_rna( "study.h5ad", - zarr_path="study.zarr", model=model, - study_context="Human blood from one healthy donor, with no treatment comparison.", + study_context="Human blood from one healthy donor; no treatment comparison.", study_objective="Identify stable major immune-cell populations.", - max_candidates=50, + zarr_path="study.zarr", ) -if result.status != "completed": - raise RuntimeError(f"{result.status}: {'; '.join(result.notes)}") - result.plot_embedding() markers = result.get_markers() report_path = result.report() ``` -This release analyzes one RNA assay. Stores may contain other modalities; pass `assay="counts"` -when more than one RNA assay is available. Automated multimodal integration and hypothesis testing -are outside this workflow. Markers are descriptive evidence. The optional agent dependency is -installed with `uv pip install "scarf[agent]"`. - -`max_candidates` limits reserved candidate slots across the initial analysis and any feature-policy -revision. Each pass reserves all configured alternatives before screening, including conditional -candidates that may not execute. The defaults reserve 25 slots for the baseline and another 25 -if a feature-policy revision runs. The default limit of 50 admits both passes; a smaller limit -never shrinks the candidate lists. An insufficient remaining budget stops admission of that pass. -The limit does not count actual executions or bound runtime or provider tokens. The result -methods use the completed analysis directly and reopen its store read-only; they do not retrain -UMAP or copy results into live metadata. `report()` returns a local path without opening a browser. - -The executable example below uses the advanced `AgentOrchestrator` interface to keep a teaching -run small and reproducible. That interface also supports explicit candidate lists, workspaces, -provider limits, and resumable checkpoints. - -Repository developers can also run `notebook/agent_workflow_new.ipynb` on the full abdominal -adipose cohort or `notebook/agent_workflow_new_short.ipynb` on its reproducible 2,000-cell smoke -sample. Both use unattended input policy and keep runtime files beside the notebooks. - -```{mermaid} -flowchart LR - A[Input dataset and study context] --> B[Ingest] - B --> C[Data Enrichment] - C --> E[Experimental Context] - E --> F[Preprocessing plan] - F --> G[RNA preprocessing] - G --> H[Parameter Tuning] - H --> I[Feature-policy review] - I --> J[Optional revised preprocessing and tuning] - J --> K[Analysis review] - K --> L[UMAP, clusters, and markers] - L --> M[Persisted reports and local HTML] -``` - -The committed documentation build uses one scripted Pydantic AI `FunctionModel`. It exercises the -real tools, validators, preprocessing, candidate execution, finalization, persistence, and report -generation without an API key. It does not assign biological identities; that remains a separate -`BiologicalInterpretationAgent` call after finalization. A live-provider configuration is shown at -the end. +`analyze_rna` returns a completed result or raises `AnalysisError`. You do not need to inspect a +status field to catch an unsuccessful beginner run. Install the optional dependency with +`uv pip install "scarf[agent]"`. -## 1. Download the raw teaching dataset +This release analyzes one RNA assay. Other modalities can remain in the store; they do not enter +this workflow. Pass `assay="RNA2"` when several RNA assays exist. Automated integration, HTO +assignment, and biological significance or differential-expression hypothesis execution are +outside this workflow. Experimental Context still explores covariate patterns and possible +explanations of the study design. -Install the optional agent dependencies before running this workflow outside the documentation -environment: +The result opens its exact saved workspace and artifacts read-only. Its cluster-map helper shows +at most 50,000 cells with full population counts. Marker statistics use the complete selected +cohort. `report()` returns a local HTML path without opening a browser or rerunning an analysis. +The {doc}`../reference/api/agent` page describes the small public interface and advanced controls. -```console -uv pip install "scarf[agent]" -``` +## A reproducible teaching analysis -The documentation run converts a raw H5 file into a separate teaching store. The explicit -`overwrite` direction is safe here because `agent_workflow.zarr` is a disposable derived target -owned by this tutorial. Omit it in ordinary work unless replacing that exact destination is -intentional. +The executable example uses the real analysis operations and a local scripted `FunctionModel`. +The script chooses among observed partitions by seed stability, then marker coherence. This makes +the example reproducible without an API key. It is a teaching policy, not a substitute for a model +that interprets the supplied diagnostic images and study-specific biology. ```{code-cell} ipython3 -from contextlib import redirect_stdout -from io import StringIO from pathlib import Path +from tempfile import TemporaryDirectory +import pandas as pd import scarf -from scarf.agent import ( - AgentOrchestrator, - AgentRunConfig, - AutomatedWorkflowConfig, - AutomatedWorkflowRequest, - DecisionSelection, - load_agent_report, - load_agent_workflow, -) +from scarf.agent import analyze_rna scarf.configure_output(level="WARNING", progress=False) - source_path = scarf.cytebase.connect("scarf_docs").download( - "tenx_5K_pbmc_rnaseq/data.h5", - destination="scarf_datasets", + "tenx_5K_pbmc_rnaseq/data.h5", destination="scarf_datasets", )[0] -zarr_path = source_path.with_name("agent_workflow.zarr") - +teaching_directory = TemporaryDirectory(prefix="scarf-agent-teaching-") +zarr_path = Path(teaching_directory.name) / "analysis.zarr" study_context = ( - "This is a human 10x Genomics 5K PBMC 3-prime gene-expression dataset " - "from peripheral blood collected from one healthy donor. The goal is " - "unsupervised identification and characterization of the major immune-cell " - "populations. No treatment comparison, technical batch covariate, paired " - "modality, or independent replication metadata is available. Do not invent " - "absent design variables or report treatment effects." + "Human 10x Genomics 5K PBMC 3-prime gene expression from peripheral blood, " + "collected from one healthy donor. No treatment comparison, trusted technical " + "batch column, paired modality, or independent replication metadata is available. " + "Do not invent missing design variables or report treatment effects." ) - -{"source": source_path.name, "destination": zarr_path.name} +source_path.name ``` -The hidden setup below routes each model request by its available tools. Every structured response -is assembled from the exact tool result, so a fabricated assay, feature family, candidate, cluster, -or evidence identifier still fails the production validator. +The hidden provider fixture assembles responses from actual tool results. Invented assay names, +feature families, candidates, and evidence identifiers still fail the production validators. ```{code-cell} ipython3 :tags: [remove-cell] @@ -143,6 +90,7 @@ or evidence identifier still fails the production validator. import json from typing import Any +from IPython import get_ipython from pydantic_ai.messages import ( ModelMessage, ModelResponse, @@ -151,12 +99,6 @@ from pydantic_ai.messages import ( ) from pydantic_ai.models.function import AgentInfo, FunctionModel -from scarf.agent.biological_interpretation import ( - BiologicalInterpretationReport, - ClusterCompositionEvidence, - ClusterInterpretation, - ClusterMarkerBatchEvidence, -) from scarf.agent.data_enrichment import ( AssayFeatureInspectionBatch, DataEnrichmentReport, @@ -169,6 +111,10 @@ from scarf.agent.experimental_context import ( ExperimentalContextDecision, ) +notebook_shell = get_ipython() +if notebook_shell is not None: + notebook_shell.run_line_magic("matplotlib", "inline") + def _prompt_text(messages: list[ModelMessage]) -> str: values = [] for message in messages: @@ -206,12 +152,12 @@ def _structured_output(info: AgentInfo, value: Any) -> ModelResponse: return _tool_call(info.output_tools[0].name, payload) -def _scripted_workflow_model() -> tuple[FunctionModel, dict[str, int]]: +def _scripted_workflow_model() -> tuple[FunctionModel, dict[str, Any]]: state = { "enrichment": 0, "context": 0, "parameter": 0, - "biology": 0, + "assessments": [], "requests": 0, } @@ -222,7 +168,14 @@ def _scripted_workflow_model() -> tuple[FunctionModel, dict[str, int]]: state["requests"] += 1 tools = {tool.name for tool in info.function_tools} - if "inspect_assay_features_batch" in tools or state["enrichment"] == 1: + if ( + "inspect_assay_features_batch" in tools + or state["enrichment"] == 1 + or any( + tool.parameters_json_schema.get("title") == "DataEnrichmentReport" + for tool in info.output_tools + ) + ): if state["enrichment"] == 0: state["enrichment"] = 1 return _tool_call("inspect_assay_features_batch") @@ -274,14 +227,13 @@ def _scripted_workflow_model() -> tuple[FunctionModel, dict[str, int]]: DataEnrichmentReport( status="done", studyContextSummary=StudyContextSummary( - organismReferences=["human"], + organismReferences=["Human"], tissueReferences=["peripheral blood"], experimentalReferences=[ - "10x Genomics 5K PBMC 3-prime gene-expression dataset" + "10x Genomics 5K PBMC 3-prime gene expression" ], analysisIntentReferences=[ - "unsupervised identification and characterization of " - "the major immune-cell populations" + "Discover stable major immune-cell populations." ], ), policies=policies, @@ -294,7 +246,10 @@ def _scripted_workflow_model() -> tuple[FunctionModel, dict[str, int]]: "analyze_experimental_design", "score_current_representation", } - ) or state["context"] in {1, 2}: + ) or state["context"] in {1, 2} or any( + tool.parameters_json_schema.get("title") == "ExperimentalContextDecision" + for tool in info.output_tools + ): if state["context"] == 0: state["context"] = 1 return _tool_call("inspect_cell_covariates") @@ -335,87 +290,84 @@ def _scripted_workflow_model() -> tuple[FunctionModel, dict[str, int]]: ), ) - if tools.intersection( - {"inspect_cluster_composition", "inspect_cluster_markers_batch"} - ) or state["biology"]: - if state["biology"] == 0: - state["biology"] = 1 - return _tool_call("inspect_cluster_composition") - if state["biology"] == 1: - composition = _tool_result( - messages, - "inspect_cluster_composition", - ClusterCompositionEvidence, - ) - state["biology"] = 2 - return _tool_call( - "inspect_cluster_markers_batch", - {"cluster_ids": list(composition.clusterCounts)}, - ) - - marker_batch = _tool_result( - messages, - "inspect_cluster_markers_batch", - ClusterMarkerBatchEvidence, - ) - interpretations = [] - for cluster in marker_batch.clusters: - if cluster.evidenceId and cluster.markers: - marker = cluster.markers[0] - marker_name = marker.featureName or marker.featureId - interpretations.append( - ClusterInterpretation( - clusterId=cluster.clusterId, - proposedIdentity=f"{marker_name}-high RNA state", - identityIsHypothesis=True, - confidence="low", - rationale=( - "The returned marker panel is led by " - f"{marker_name}." - ), - evidenceIds=[cluster.evidenceId], - ) - ) - state["biology"] = 3 - return _structured_output( - info, - BiologicalInterpretationReport( - status="done", - clusterInterpretations=interpretations, - evidenceIds=[item.evidenceIds[0] for item in interpretations], - limitations=[ - "The scripted documentation model returns marker-linked " - "hypotheses, not validated cell identities." - ], - stopReason=( - "Every cluster with returned marker evidence was reviewed." - ), - ), - ) - prompt = _prompt_text(messages) if any( - tool.parameters_json_schema.get("title") - == "AnalysisVisualAdjudication" + tool.parameters_json_schema.get("title") == "TuningAction" for tool in info.output_tools ): - payload, _ = json.JSONDecoder().raw_decode(prompt[prompt.index("{") :]) - return _structured_output( - info, - { - "status": "acceptable", - "selectedCandidateId": payload["selectedCandidateId"], - "rationale": ( - "The bounded diagnostic board agrees with the registered " - "numeric evidence." - ), - }, + evidence, _ = json.JSONDecoder().raw_decode(prompt[prompt.index("{") :]) + candidates = [ + item for item in evidence["candidates"] + if item["status"] == "done" and item["eligible"] + ] + if not candidates: + raise AssertionError("The teaching run has no supported partition") + + def measured(item, name): + value = item["metrics"].get(name) + return float(value) if value is not None else 0.0 + + selected = max( + candidates, + key=lambda item: ( + measured(item, "seedStability"), + measured(item, "markerCoherence"), + ), ) - - decision, _ = json.JSONDecoder().raw_decode(prompt[prompt.index("{") :]) + metrics = selected["metrics"] + genes = list(dict.fromkeys( + gene for names in metrics.get("topMarkerGenes", {}).values() + for gene in names + ))[:8] + quantitative = ( + f"Compared {len(candidates)} observed partitions; selected resolution " + f"{selected['parameters']['leidenResolution']}, with seed stability " + f"{metrics.get('seedStability')} and marker coherence " + f"{metrics.get('markerCoherence')}." + ) + qualitative = ( + "The saved marker preview contains " + ", ".join(genes) + "." + if genes else "The saved marker preview is empty; cell identities remain unresolved." + ) + action = { + "action": "accept", + "selectedCandidateId": selected["candidateId"], + "correctionNeed": "notApplicable", + "assessedDomains": evidence["assessedDomains"], + "evidenceIds": [ + f"candidate:{selected['candidateId']}", + *list(evidence["imageHashes"])[:1], + "studyContract", "qcPolicy", "samplingCoverage", "featureEvidence", + ], + "quantitativeFindings": [quantitative], + "qualitativeFindings": [qualitative], + "objectivePreservation": ( + "Preserve the single-donor population structure and retain marker " + "uncertainty; no batch or treatment comparison is supported." + ), + "rationale": ( + "The teaching policy selects the observed partition with the " + "greatest seed stability, using marker coherence to break ties. " + + quantitative + ), + } + state["assessments"].append({ + "selection": action, + "alternatives": [{ + "resolution": item["parameters"]["leidenResolution"], + "clusters": item["metrics"].get("nClusters"), + "seed_stability": item["metrics"].get("seedStability"), + "marker_coherence": item["metrics"].get("markerCoherence"), + "selected": item["candidateId"] == selected["candidateId"], + } for item in candidates], + }) + return _structured_output(info, action) + + payload, _ = json.JSONDecoder().raw_decode(prompt[prompt.index("{") :]) + decision = payload["spec"] evidence_by_class = {} evidence_class_by_id = {} - for item in decision["evidence"]: + for item in payload["evidence"]["evidence"]: evidence_by_class.setdefault( item["evidenceClass"], item["evidenceId"], @@ -445,10 +397,10 @@ def _scripted_workflow_model() -> tuple[FunctionModel, dict[str, int]]: state["parameter"] += 1 return _structured_output( info, - DecisionSelection( + dict( selectedOptionId=selected["optionId"], evidenceIds=evidence_ids, - rationale="Select the registered metric-preferred option.", + rationale=f"Use the offered {selected['label']} policy with its required observed evidence.", confidence="high", ), ) @@ -457,305 +409,122 @@ def _scripted_workflow_model() -> tuple[FunctionModel, dict[str, int]]: ``` -## 2. Configure a bounded teaching search - -This run uses one HVG count and singleton PCA, neighbor, and clustering-resolution lists, with -no refinement or Harmony. Each tuning pass still evaluates four stage candidates: PCA, the native -correction baseline, neighbors, and clustering. Including the HVG screen and selection evaluations, -the executor reserves seven evaluations per pass. The limit of fourteen permits a second pass -after a feature-policy revision. This is a small sequential search. QC comparisons, stability -diagnostics, provider requests, and finalization have separate costs. - ```{code-cell} ipython3 model, model_state = _scripted_workflow_model() -config = AutomatedWorkflowConfig( - inputPolicy="unattended", - maxRefinedCandidatesPerAssay=0, - maxHarmonyCandidatesPerAssay=0, - maxCandidateEvaluations=14, - hvgCandidateCounts=(1000,), - pcaCandidateDimensions=(20,), - graphNeighborCandidates=(21,), - leidenResolutionCandidates=(1.0,), - minClusterCells=2, - agentRunConfig=AgentRunConfig( - requestLimit=5, - toolCallLimit=5, - ), -) -orchestrator = AgentOrchestrator(model, config=config) -request = AutomatedWorkflowRequest( - sourcePath=str(source_path), - zarrPath=str(zarr_path), - studyContext=study_context, - studyObjective="Discover stable major immune-cell populations.", - primaryAssay="RNA", - markerAssay="RNA", - analysisAssays=["RNA"], - ingestDirections={"overwrite": True, "defaultAssay": "RNA"}, +result = analyze_rna( + source_path, + model=model, + study_context=study_context, + study_objective="Discover stable major immune-cell populations.", + zarr_path=zarr_path, ) - -{ - "candidate_evaluation_limit": config.maxCandidateEvaluations, - "refinement_candidates": config.maxRefinedCandidatesPerAssay, - "harmony_candidates": config.maxHarmonyCandidatesPerAssay, - "input_policy": config.inputPolicy, -} -``` - -## 3. Run without an interactive checkpoint - -The unattended policy lets registered rules resolve model deferrals. Genuine unresolved evidence -becomes an explicit abstention or failure rather than a pause. The documentation captures the -normal report-path printout so its output does not contain a random workflow identifier. - -```{code-cell} ipython3 -with redirect_stdout(StringIO()): - result = orchestrator.run(request) - -if ( - result.status != "completed" - or result.finalAnalysis is None - or result.preprocessingPlan is None - or result.workflowRun is None - or result.zarrPath is None -): - raise RuntimeError(f"Unexpected workflow result: {result.status}, {result.notes}") - -plan = result.preprocessingPlan -{ - "status": result.status, - "stage": result.currentStage, - "primary_assay": plan.primaryAssay, - "marker_assay": plan.markerAssay, - "cell_qc": plan.cellQc.action, - "routes": [ - { - "assay": assay.assay, - "features": assay.featureMethod, - "reduction": assay.reductionMethod, - } - for assay in plan.assays - ], -} +{"status": result.status} ``` -The workflow persists the exact preprocessing plan, decisions, report handoffs, and final artifact -references before returning. - -## 4. Inspect the persisted workflow +## See what was chosen and why -The returned workflow identity resolves the durable record. Reopening it does not execute an -analysis stage. +The starting graph is compared at four clustering resolutions: 0.5, 0.75, 1.0, and 1.25. +These partitions share the same cells, features, and graph. The table below contains the exact +observations offered to the scripted provider, followed by its recorded explanation. ```{code-cell} ipython3 -persisted_workflow = load_agent_workflow( - result.zarrPath, - result.workflowRun.workflowRunId, - workspace=result.workflowRun.workspace, -) - -{ - "status": persisted_workflow.status, - "stage": result.currentStage, - "agent_reports": [ref.agentName for ref in result.reportReferences], - "model_requests": model_state["requests"], - "graph_method": result.finalAnalysis.graphMethod, - "marker_assay": result.finalAnalysis.markerAssay, -} +assessment = model_state["assessments"][-1] +pd.DataFrame(assessment["alternatives"]) ``` -The single scripted provider handles every model-driven orchestrator stage. Deterministic -operations, such as RNA preprocessing, candidate execution, promotion, UMAP, clustering, -marker search, and persistence, do not require separate model requests. - -## 5. Review parameter evidence and agent reports - -The parameter agent receives executor-produced metrics for candidates that have already run. It -does not generate Scarf code. Each candidate follows the explicit reduction, optional Harmony, -ANN, neighbours, connectivity, Leiden, and metric chain. The final selected branch is replayed with -state updates and checked against the evaluated immutable references. - ```{code-cell} ipython3 -reports = { - reference.agentName: load_agent_report(result.zarrPath, reference) - for reference in result.reportReferences -} -parameter_report = reports["parameter_tuning"] - -candidate_metrics = [] -for assay, assay_report in parameter_report.assayReports.items(): - for index, evaluation in enumerate(assay_report.evaluations, start=1): - candidate_metrics.append( - { - "assay": assay, - "candidate": index, - "dimensions": evaluation.parameters.dimensions, - "resolution": evaluation.parameters.leidenResolution, - "neighbors": evaluation.parameters.neighborsK, - "eligible": evaluation.eligible, - "clusters": evaluation.metrics.nClusters, - "smallest_cluster": evaluation.metrics.minClusterCells, - "graph_silhouette": evaluation.metrics.graphSilhouetteMedian, - } - ) - +selection = assessment["selection"] { - "candidates": candidate_metrics, - "stop_reason": parameter_report.stopReason, - "report_statuses": { - name: report.status for name, report in reports.items() - }, + "why": selection["rationale"], + "marker_evidence": selection["qualitativeFindings"], + "biology_to_preserve": selection["objectivePreservation"], } ``` -This teaching run demonstrates the successive parameter decisions with one option per stage. -The default configuration compares explicit HVG, PCA, neighbor, and resolution lists and may execute one evidence-driven -refinement. Harmony is added only when the exact Experimental Context handoff authorizes a matched -comparison. +A live model can keep the observed settings or request one registered comparison to resolve a +specific concern. It must explain the expected improvement and which biology should be preserved. +A metric rank alone does not authorize correction or deletion of a biological program. Batch +correction requires both a supported design and a matched comparison of native and corrected +representations. Confounded technical and biological variables cannot license correction. -## 6. Plot the exact final UMAP and inspect markers +## Inspect the analysis -The result uses its final UMAP and cluster artifacts directly. Display options are forwarded to -Scarf's plotting API. Exact artifact references remain available in `result.finalAnalysis` for -advanced workflows and Biological Interpretation. +The result fixes the saved layout and cluster labels. Display options include `figsize`, `show`, +`seed`, and a lower `max_points` display cap; they change only the picture. ```{code-cell} ipython3 -final = result.finalAnalysis -if ( - final.cellSelection is None - or final.clusters is None - or final.umap is None - or final.markers is None -): - raise RuntimeError("The completed final handoff is missing required artifacts") - -result.plot_embedding( - legend_loc="on_data", - frame="none", -) +result.plot_embedding(figsize=(9, 6)) ``` -UMAP is a presentation artifact. The tuning agent compares graph and metadata metrics, not visual -appearance, and the orchestrator does not train several UMAPs to choose the most attractive one. - ```{code-cell} ipython3 -marker_table = result.get_markers( - group_id=None, - min_score=-1, - min_frac_exp=-1, -) +marker_table = result.get_markers() marker_table.sort_values( - ["group_id", "score"], - ascending=[True, False], + ["group_id", "score"], ascending=[True, False], ).groupby("group_id", sort=True).head(2)[ ["group_id", "feature_name", "score", "frac_exp"] ].head(12) ``` -Marker scores are cell-level descriptive evidence. They are not replicate-aware differential -expression, and the scripted identities remain hypotheses. - -## 7. Find the local HTML report - -A completed local workflow first persists its terminal result and then writes a replaceable HTML -view under `agents/runs//report/index.html`. `result.report()` returns that path, -generating the view from saved results if it is missing. It opens directly on the analysis and -does not train another UMAP. Advanced callers can use `generate_agent_report()` to explicitly -regenerate an existing view. +These markers describe clusters. Replicate-aware differential expression and validated cell +identities require additional analysis. ```{code-cell} ipython3 report_path = result.report() -display_path = str(report_path.relative_to(Path(result.zarrPath).parent)).replace( - result.workflowRun.workflowRunId, - "", -) - -{ - "report": display_path, - "exists": report_path.is_file(), - "final_artifact_kinds": { - "selection": final.cellSelection.kind, - "clusters": final.clusters.kind, - "umap": final.umap.kind, - "markers": final.markers.kind, - }, -} +{"report": report_path.name, "exists": report_path.is_file()} ``` -## Pauses, failures, and other input formats +The single report page opens on the final map, population counts, and decisions. Alternatives and +recorded measurements sit beside each choice; marker findings and material limitations remain +visible. There is no separate technical-report application. -With `inputPolicy="pause"`, `needsInput` keeps the workflow running. Inspect every returned -question and supply only grounded answers through `AutomatedWorkflowResumeRequest`. -`inputPolicy="unattended"` returns an explicit abstention or failure when evidence cannot be -resolved safely. `failed` and `abandoned` are terminal. An ingest ambiguity can occur before a -persisted workflow exists; update `ingestDirections` and call `run()` again in that case. A running -workflow can also be finalized as abandoned with `orchestrator.cancel()`. +## Large datasets and saved work -For another new local H5 or H5AD input, provide a destination that does not yet exist: +Above 50,000 retained cells, candidate settings are screened on an immutable uniform sample. +Insufficient representation can trigger one enlargement to 100,000 cells. The sample is a tuning +cohort, not a new final cohort: the selected settings are executed and assessed on all QC-retained +cells before finalization. Sample measurements do not prove that rare populations or batch +correction will transfer. If sample coverage is inadequate, the workflow assesses a bounded +full-cohort baseline instead of deleting poorly represented groups. -```python -request = AutomatedWorkflowRequest( - sourcePath="study.h5ad", - zarrPath="study.zarr", - studyContext="One paragraph describing the study, design, and analysis intent.", - studyObjective="Discover stable populations relevant to the study.", -) -result = AgentOrchestrator( - model, - config=AutomatedWorkflowConfig(inputPolicy="unattended"), -).run(request) -if result.status != "completed": - raise RuntimeError(f"{result.status}: {'; '.join(result.notes)}") -``` +The default advanced limits permit 12 candidate evaluations per screening sample, 24 across +screening samples, four full-cohort graphs, eight full-cohort partitions, and one targeted repair. +They count distinct admitted work, including failed attempts. Reuse of a complete exact artifact +does not spend another slot. These limits do not promise an elapsed time: ingest, QC, diagnostics, +markers, and one final UMAP also have costs. -For an existing Zarr input, omit `zarrPath` or set it to the same location. Its current `I` -selection is preserved and snapshotted. A workspace may be supplied only for an existing Zarr -input. +One orchestration history owns the request, evidence, decisions, and final artifact references. +An identical call reuses a completed result or resumes matching interrupted work. Changed data, +metadata roles, model identity, or configuration cannot silently reinterpret that history. Older +agent runs with the previous saved-state contract must be restarted; their numerical artifacts +remain readable through the ordinary Scarf APIs. -## Use a live model +## Failure handling and advanced control -Replace the scripted model with one supported Pydantic AI model. Keep credentials in environment -variables and never place them in a notebook or datastore: +Use the exception's result address when an unattended analysis needs investigation: ```python -import os +from scarf.agent import AnalysisError, analyze_rna -from pydantic_ai.models.openai import OpenAIChatModel -from pydantic_ai.providers.openai import OpenAIProvider - -model = OpenAIChatModel( - os.environ["SCARF_AGENT_MODEL"], - provider=OpenAIProvider( - base_url=os.environ["SCARF_AGENT_BASE_URL"], - api_key=os.environ["SCARF_AGENT_API_KEY"], - ), -) - -orchestrator = AgentOrchestrator( - model, - config=AutomatedWorkflowConfig( - inputPolicy="unattended", - runConfoundedHarmonyDiagnostic=True, - ), -) -result = orchestrator.run( - AutomatedWorkflowRequest( - sourcePath="study.h5ad", - zarrPath="study.zarr", - studyContext=( - "Human single-cell study with three biological replicates per " - "condition; donor is the unit of inference and library is technical." - ), - studyObjective=( - "Discover stable populations while preserving the condition structure." - ), +try: + result = analyze_rna( + "study.zarr", model=model, + study_context="The observed study design and metadata roles.", + study_objective="The biological structure that should be retained.", ) -) -if result.status != "completed": - raise RuntimeError(f"{result.status}: {'; '.join(result.notes)}") +except AnalysisError as error: + print(error) + print(error.result.notes) + raise ``` -Provider output remains provisional. Scarf validates evidence identifiers, operations, artifact -lineage, and resume state, but it cannot establish that a biologically plausible interpretation is -true. +Advanced callers can import `AgentOrchestrator` and its request/configuration models from +`scarf.agent.orchestrator`, set an existing-store workspace, and use `inputPolicy="pause"` for +explicit questions. The advanced result still carries status and resume information. Supply only +grounded answers to the saved questions. A work limit pauses or fails the analysis; it does not +turn an unsupported candidate into an accepted result. + +For live analysis, replace the `FunctionModel` with your configured Pydantic AI model and use the +same `analyze_rna` call. Scarf sends diagnostic images when the model supports them. Other models +assess the structured marker, loading-gene, and numerical evidence, with that limitation recorded +in the report. Credentials belong in the provider configuration, not in a study paragraph or +saved analysis record. diff --git a/scarf/agent/.env.example b/scarf/agent/.env.example deleted file mode 100644 index 9ead698e..00000000 --- a/scarf/agent/.env.example +++ /dev/null @@ -1,15 +0,0 @@ -# Copy to .env and fill in values: -# cp scarf/agent/.env.example scarf/agent/.env -# -# Local Ollama (no API key needed): -# OLLAMA_BASE_URL=http://localhost:11434/v1 -# OLLAMA_MODEL=qwen3.5:4b -# -# Ollama Cloud: -# OLLAMA_BASE_URL=https://ollama.com/v1 -# OLLAMA_API_KEY=your-key-here -# OLLAMA_MODEL=qwen3.5:4b - -OLLAMA_BASE_URL=http://localhost:11434/v1 -OLLAMA_MODEL=qwen3.5:4b -# OLLAMA_API_KEY= diff --git a/scarf/agent/__init__.py b/scarf/agent/__init__.py index 3f2eb219..27c425e4 100644 --- a/scarf/agent/__init__.py +++ b/scarf/agent/__init__.py @@ -1,215 +1,22 @@ -"""Optional grounded decision helpers for Scarf workflows.""" +"""Optional automated RNA analysis with a small, lazy public interface.""" -from .biological_interpretation import ( - BiologicalContext, - BiologicalInterpretationAgent, - BiologicalInterpretationReport, -) -from .experimental_context.characterization import characterize_covariates -from .experimental_context.contracts import CovariateCharacterization -from .data_enrichment.characterization import ( - FeatureCharacterization, - characterize_features, -) -from .config import AgentRunConfig -from . import _deps as _deps -from .config.agent_exec import run_agent, run_agent_sync -from .data_enrichment import ( - DataEnrichmentAgent, - DataEnrichmentContext, - DataEnrichmentReport, - StudyContextSummary, -) -from .decisions.selection import DecisionValidationError, decide -from .decisions.kernel import ( - DecisionEvidence, - DecisionOption, - DecisionRecord, - DecisionSelection, - DecisionSpec, - DecisionWorkflowRun, - DeterministicDecisionAuditor, - EvidenceBundle, - PendingDecision, - ProtectedVariableEffect, - RevisionRequest, - VerificationCheck, - VerificationRecord, -) -from .experimental_context import ( - CellQcPlan, - ExperimentalContextAgent, - ExperimentalContextResult, - NamedArtifactSource, -) -from .ingest import ( - DatasetManifest, - DatasetManifestDecision, - IngestResult, - detect_format, - ingest, - inspect_h5ad_manifest, -) -from .orchestrator import ( - AgentOrchestrator, - analyze_rna, - AssayPreprocessingPlan, - AutomatedPreprocessingPlan, - AutomatedWorkflowConfig, - AutomatedWorkflowRequest, - AutomatedWorkflowResult, - AutomatedWorkflowResumeRequest, - FinalAnalysisHandoff, - NativeAnalysisHandoff, - PreprocessedAssayHandoff, - WorkflowNeedsInput, - WorkflowQuestion, - WorkflowStageAttempt, - WorkflowStageLink, -) -from .parameter_tuning import ( - FinalGraphSelection, - IntegrationCandidateEvaluation, - IntegrationMetrics, - ParameterCandidate, - ParameterSearchPlan, - ParameterTuningAgent, - ParameterTuningAssayInput, - ParameterTuningReport, - get_default_parameter_candidates, - tune_parameters, -) -from .persistence import ( - AgentInvocation, - AgentName, - AgentPersistenceTarget, - AgentReport, - AgentReportLink, - AgentReportRecord, - AgentReportReference, - AgentReportType, - AgentTerminalStatus, - AgentWorkflowRun, - AgentWorkflowStatus, - create_agent_workflow, - finalize_agent_workflow, - list_agent_reports, - list_agent_workflows, - load_agent_record, - load_agent_report, - load_agent_workflow, - save_agent_report, -) -from .report import generate_agent_report -from .runtime import check_runtime, load_env -from .experimental_context.study import StudyContract -from .types import ( - BatchSafetyEvidence, - Decision, - EvidenceItem, - ExperimentalBiologyHandoff, - ExperimentalTuningHandoff, - NeedsInput, - StageResult, - StageStatus, - TuningBiologyHandoff, -) +from importlib import import_module +from typing import Any -__all__ = [ - "AgentInvocation", - "AgentOrchestrator", - "AgentRunConfig", - "AgentName", - "AgentPersistenceTarget", - "AgentReport", - "AgentReportLink", - "AgentReportRecord", - "AgentReportReference", - "AgentReportType", - "AgentTerminalStatus", - "AgentWorkflowRun", - "AgentWorkflowStatus", - "AssayPreprocessingPlan", - "AutomatedPreprocessingPlan", - "AutomatedWorkflowConfig", - "AutomatedWorkflowRequest", - "AutomatedWorkflowResult", - "AutomatedWorkflowResumeRequest", - "BatchSafetyEvidence", - "BiologicalContext", - "BiologicalInterpretationAgent", - "BiologicalInterpretationReport", - "CellQcPlan", - "CovariateCharacterization", - "DataEnrichmentAgent", - "DataEnrichmentContext", - "DataEnrichmentReport", - "Decision", - "DecisionEvidence", - "DecisionOption", - "DecisionRecord", - "DecisionSelection", - "DecisionSpec", - "DecisionWorkflowRun", - "DecisionValidationError", - "DeterministicDecisionAuditor", - "DatasetManifest", - "DatasetManifestDecision", - "EvidenceItem", - "EvidenceBundle", - "ExperimentalBiologyHandoff", - "ExperimentalContextAgent", - "ExperimentalContextResult", - "ExperimentalTuningHandoff", - "FinalAnalysisHandoff", - "FinalGraphSelection", - "FeatureCharacterization", - "IngestResult", - "IntegrationCandidateEvaluation", - "IntegrationMetrics", - "NativeAnalysisHandoff", - "NamedArtifactSource", - "NeedsInput", - "PendingDecision", - "ParameterCandidate", - "ParameterSearchPlan", - "ParameterTuningAssayInput", - "ParameterTuningAgent", - "ParameterTuningReport", - "PreprocessedAssayHandoff", - "ProtectedVariableEffect", - "RevisionRequest", - "StageResult", - "StageStatus", - "StudyContextSummary", - "StudyContract", - "TuningBiologyHandoff", - "WorkflowNeedsInput", - "WorkflowQuestion", - "WorkflowStageAttempt", - "WorkflowStageLink", - "VerificationCheck", - "VerificationRecord", - "characterize_covariates", - "analyze_rna", - "characterize_features", - "check_runtime", - "create_agent_workflow", - "decide", - "detect_format", - "get_default_parameter_candidates", - "generate_agent_report", - "ingest", - "inspect_h5ad_manifest", - "load_env", - "finalize_agent_workflow", - "list_agent_reports", - "list_agent_workflows", - "load_agent_record", - "load_agent_report", - "load_agent_workflow", - "run_agent", - "run_agent_sync", - "save_agent_report", - "tune_parameters", -] +__all__ = ["analyze_rna", "AutomatedWorkflowResult", "AnalysisError"] + +for _export in __all__: + globals().pop(_export, None) + + +def __getattr__(name: str) -> Any: + if name not in __all__: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + module = ".orchestrator.api" if name == "analyze_rna" else ".orchestrator.models" + value = getattr(import_module(module, __name__), name) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__)) diff --git a/scarf/agent/_plots.py b/scarf/agent/_plots.py new file mode 100644 index 00000000..1714e39c --- /dev/null +++ b/scarf/agent/_plots.py @@ -0,0 +1,282 @@ +"""Bounded views of the exact artifacts selected by an agent analysis.""" + +import hashlib +from collections import Counter +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any + +import numpy as np + +from ..graph.feature_projection import graph_cell_selection +from ..storage.refs import ArtifactRef +from ..storage.selections import validate_stored_selection_integrity +from ..storage.types import as_zarr_array + +if TYPE_CHECKING: + from ..datastore.datastore import DataStore + from ..plotting import PlotResult + + +DISPLAY_CELLS = 50_000 +DISPLAY_BLOCK_ROWS = 100_000 + + +def cluster_counts(store: "DataStore", clusters: ArtifactRef) -> dict[str, int]: + """Count every saved cluster label while retaining only one row block.""" + values = as_zarr_array(store.load_artifact(clusters)["values"], name="values") + if values.ndim != 1: + raise ValueError("Cluster labels must be one-dimensional") + counts: Counter[str] = Counter() + for start in range(0, values.shape[0], DISPLAY_BLOCK_ROWS): + labels, frequencies = np.unique( + np.asarray(values[start : start + DISPLAY_BLOCK_ROWS]).astype(str), + return_counts=True, + ) + counts.update(dict(zip(labels.tolist(), frequencies.tolist(), strict=True))) + return dict(sorted(counts.items())) + + +def _selection_input(store: "DataStore", ref: ArtifactRef) -> ArtifactRef: + status = store.inspect_artifact(ref) + if not status.complete: + raise ValueError("The selected analysis artifact is incomplete") + raw = (status.inputs or {}).get("cell_selection") + if not isinstance(raw, Mapping): + raise ValueError("The selected artifact has no frozen cell selection") + return ArtifactRef.from_dict(raw) + + +def _sample_quotas(counts: Mapping[str, int], maximum: int) -> dict[str, int]: + """Allocate a proportional display, keeping at least one cell per cluster.""" + total = sum(counts.values()) + if total <= maximum: + return dict(counts) + if len(counts) > maximum: + raise RuntimeError("There are more clusters than the display cell limit") + remaining = maximum - len(counts) + available = total - len(counts) + shares = { + label: remaining * (count - 1) / available for label, count in counts.items() + } + quotas = {label: 1 + int(share) for label, share in shares.items()} + remainder = maximum - sum(quotas.values()) + for label in sorted(shares, key=lambda key: (-(shares[key] % 1), key))[:remainder]: + quotas[label] += 1 + return quotas + + +def _sample_cluster_rows( + values: Any, + counts: Mapping[str, int], + *, + maximum: int, + seed: int, + block_rows: int = DISPLAY_BLOCK_ROWS, +) -> tuple[np.ndarray, np.ndarray]: + """Keep bounded per-cluster priority samples, independent of read boundaries.""" + quotas = _sample_quotas(counts, maximum) + rng = np.random.Generator(np.random.PCG64(seed)) + priorities = {label: np.empty(0, dtype=np.float64) for label in counts} + rows = {label: np.empty(0, dtype=np.int64) for label in counts} + for start in range(0, int(values.shape[0]), block_rows): + labels = np.asarray(values[start : start + block_rows]).astype(str) + keys = rng.random(len(labels)) + for label in np.unique(labels): + positions = np.flatnonzero(labels == label) + combined_keys = np.concatenate((priorities[label], keys[positions])) + combined_rows = np.concatenate((rows[label], positions + start)) + quota = quotas[label] + if len(combined_rows) > quota: + keep = np.argpartition(combined_keys, quota - 1)[:quota] + combined_keys = combined_keys[keep] + combined_rows = combined_rows[keep] + priorities[label] = combined_keys + rows[label] = combined_rows + selected = np.concatenate(list(rows.values())) + labels = np.concatenate( + [np.full(len(rows[label]), label, dtype=object) for label in counts] + ) + order = np.argsort(selected) + return selected[order], labels[order] + + +def plot_final_umap( + store: "DataStore", + *, + umap: ArtifactRef, + clusters: ArtifactRef, + cell_selection: ArtifactRef, + graph: ArtifactRef, + max_points: int = DISPLAY_CELLS, + seed: int = 0, + figsize: tuple[float, float] = (9, 6), + show: bool = True, +) -> "PlotResult": + """Draw a bounded categorical map from one completed analysis. + + Population counts always include every cell. Display sampling changes no + saved selection, coordinates, labels, markers, or other analysis result. + """ + from ..plotting import CategoricalScale, LegendSpec, PlotProvenance, PlotResult + + if ( + isinstance(max_points, bool) + or not isinstance(max_points, int) + or not 1 <= max_points <= DISPLAY_CELLS + ): + raise ValueError(f"max_points must be an integer from 1 to {DISPLAY_CELLS}") + if umap.kind != "embedding" or clusters.kind != "cluster_labels": + raise ValueError("The final map requires embedding and cluster-label artifacts") + if umap.assay != clusters.assay or umap.scope != clusters.scope: + raise ValueError("The final UMAP and clusters must belong to the same assay") + for ref in (umap, clusters): + if _selection_input(store, ref) != cell_selection: + raise ValueError( + "Final artifacts must share the exact frozen cell selection" + ) + if graph_cell_selection(store.zw, graph) != cell_selection: + raise ValueError("The final graph must use the exact frozen cell selection") + embedding_status = store.inspect_artifact(umap) + if embedding_status.operation != "run_umap": + raise ValueError("The final UMAP must be a saved run_umap artifact") + for ref in (umap, clusters): + raw_graph = (store.inspect_artifact(ref).inputs or {}).get("graph") + if ( + not isinstance(raw_graph, Mapping) + or ArtifactRef.from_dict(raw_graph) != graph + ): + raise ValueError("The final UMAP and clusters must use the selected graph") + selection = validate_stored_selection_integrity( + store.zw, + cell_selection, + kind="cell_selection", + scope="datastore", + assay=None, + table_path="cellData", + ) + coordinates = as_zarr_array(store.load_artifact(umap)["values"], name="values") + labels = as_zarr_array(store.load_artifact(clusters)["values"], name="values") + total = selection.selected_count + if coordinates.shape != (total, 2) or labels.shape != (total,): + raise ValueError( + "Final coordinates and labels must align with the frozen selection" + ) + if np.dtype(coordinates.dtype).kind not in "fiu": + raise TypeError("Final UMAP coordinates must be numeric") + if total == 0: + raise ValueError("The final selection contains no cells") + counts = cluster_counts(store, clusters) + rows, sampled_labels = _sample_cluster_rows( + labels, counts, maximum=max_points, seed=seed + ) + sampled_coordinates = np.empty((len(rows), 2), dtype=np.float64) + for start in range(0, total, DISPLAY_BLOCK_ROWS): + left, right = np.searchsorted(rows, [start, start + DISPLAY_BLOCK_ROWS]) + if left == right: + continue + block = np.asarray(coordinates[start : start + DISPLAY_BLOCK_ROWS]) + if not np.isfinite(block).all(): + raise ValueError("Final UMAP coordinates must be finite") + sampled_coordinates[left:right] = block[rows[left:right] - start] + + import matplotlib.pyplot as plt + import pandas as pd + from matplotlib.colors import to_hex + from matplotlib.lines import Line2D + + categories = tuple(counts) + cmap = plt.get_cmap( + "tab10" + if len(categories) <= 10 + else "tab20" + if len(categories) <= 20 + else "gist_rainbow" + ) + palette = { + label: to_hex( + cmap(index if len(categories) <= 20 else index / (len(categories) - 1)) + ) + for index, label in enumerate(categories) + } + figure, axis = plt.subplots(figsize=figsize, constrained_layout=True) + try: + order = np.random.Generator(np.random.PCG64(seed)).permutation(len(rows)) + axis.scatter( + sampled_coordinates[order, 0], + sampled_coordinates[order, 1], + color=[palette[label] for label in sampled_labels[order]], + s=3, + alpha=0.65, + linewidths=0, + rasterized=True, + ) + axis.set(xlabel="UMAP 1", ylabel="UMAP 2", xticks=[], yticks=[]) + axis.spines[["top", "right"]].set_visible(False) + if len(categories) <= 40: + axis.legend( + handles=[ + Line2D( + [], + [], + color=palette[label], + marker="o", + linestyle="", + label=f"{label} ({counts[label]:,})", + ) + for label in categories + ], + title="Cluster (all cells)", + loc="center left", + bbox_to_anchor=(1, 0.5), + frameon=False, + fontsize=8, + ) + else: + for label in categories: + center = np.median(sampled_coordinates[sampled_labels == label], axis=0) + axis.annotate(str(label), center, fontsize=7) + caption = ( + f"{len(rows):,} of {total:,} cells shown; cluster counts use all cells." + ) + axis.set_title(caption, fontsize=10) + result = PlotResult( + figure=figure, + axes={"clusters": axis}, + tables={ + "cluster_counts": pd.DataFrame( + {"cluster": list(counts), "cells": list(counts.values())} + ) + }, + legends=(LegendSpec(kind="categorical", label="Cluster (all cells)"),), + scales=(CategoricalScale(order=categories, palette=palette),), + provenance=PlotProvenance( + assay=umap.assay, + n_cells=len(rows), + notes=("Final saved UMAP; display sampling only.",), + extras={ + "layout": umap.to_dict(), + "color_artifacts": [clusters.to_dict()], + "cell_selection": cell_selection.to_dict(), + "graph": graph.to_dict(), + "workspace": store.workspace, + "input_n_cells": total, + "cluster_counts": counts, + "display_sampling": { + "method": "proportional_clusters_with_minimum_one", + "max_points": max_points, + "seed": seed, + "generator": "PCG64", + "row_sha256": hashlib.sha256( + rows.astype(" "BiologicalContext": return cls() - @classmethod - def get_example(cls) -> "BiologicalContext": - return cls( - organism="Homo sapiens", - studyContext=( - "Human lung samples were profiled after drug or vehicle treatment." - ), - tissue="lung", - cellTypeReferences=["alveolar macrophage", "T cell"], - experimentalDetails=["drug and vehicle groups"], - treatmentQuestion="Which populations respond selectively to treatment?", - ) - class ConditionClusterSummary(AgentDataModel): """Aggregate cluster abundance for one condition without sample identifiers.""" @@ -60,19 +47,6 @@ class ConditionClusterSummary(AgentDataModel): cellCount: int = 0 evidenceId: str = "" - @classmethod - def get_example(cls) -> "ConditionClusterSummary": - return cls( - condition="treated", - clusterId="3", - nSamples=4, - meanFraction=0.18, - minFraction=0.12, - maxFraction=0.25, - cellCount=180, - evidenceId="composition:RNA_cluster:condition:treated:cluster:3", - ) - class ClusterCompositionEvidence(AgentDataModel): """Bounded deterministic evidence about cluster sizes and conditions.""" @@ -87,43 +61,6 @@ class ClusterCompositionEvidence(AgentDataModel): evidenceIds: list[str] = Field(default_factory=list) warnings: list[str] = Field(default_factory=list) - @classmethod - def get_example(cls) -> "ClusterCompositionEvidence": - summary = ConditionClusterSummary.get_example() - reference_summary = ConditionClusterSummary( - condition="control", - clusterId=summary.clusterId, - nSamples=4, - meanFraction=0.11, - minFraction=0.08, - maxFraction=0.15, - cellCount=110, - evidenceId="composition:RNA_cluster:condition:control:cluster:3", - ) - return cls( - clusterArtifact=ArtifactReferenceModel( - assay="RNA", - kind="cluster_labels", - artifactId="b" * 64, - ), - cellSelection=ArtifactReferenceModel( - scope="datastore", - assay=None, - kind="cell_selection", - artifactId="c" * 64, - ), - totalCells=1000, - clusterCounts={"0": 520, "1": 300, "3": 180}, - sampleColumn="sample", - conditionColumn="treatment", - conditionSummaries=[reference_summary, summary], - evidenceIds=[ - "composition:RNA_cluster:counts", - reference_summary.evidenceId, - summary.evidenceId, - ], - ) - class MarkerFeature(AgentDataModel): """One observed marker feature and its available Scarf statistics.""" @@ -140,20 +77,6 @@ class MarkerFeature(AgentDataModel): auc: float | None = None adjustedPvalue: float | None = None - @classmethod - def get_example(cls) -> "MarkerFeature": - return cls( - featureId="ENSG00000173372", - featureName="C1QA", - featureIndex=123, - score=0.83, - foldChange=3.4, - fractionExpressed=0.76, - fractionExpressedRest=0.18, - auc=0.91, - adjustedPvalue=0.001, - ) - class ClusterMarkerEvidence(AgentDataModel): """Bounded markers for one exact cluster label.""" @@ -164,19 +87,6 @@ class ClusterMarkerEvidence(AgentDataModel): evidenceId: str = "" warnings: list[str] = Field(default_factory=list) - @classmethod - def get_example(cls) -> "ClusterMarkerEvidence": - return cls( - clusterId="3", - markers=[MarkerFeature.get_example()], - markerArtifact=ArtifactReferenceModel( - assay="RNA", - kind="marker_table", - artifactId="a" * 64, - ), - evidenceId="markers:RNA_cluster:cluster:3", - ) - class ClusterMarkerBatchEvidence(AgentDataModel): """Markers for all model-selected clusters returned by one tool call.""" @@ -189,11 +99,6 @@ class ClusterMarkerBatchEvidence(AgentDataModel): def get_blank(cls) -> "ClusterMarkerBatchEvidence": return cls() - @classmethod - def get_example(cls) -> "ClusterMarkerBatchEvidence": - cluster = ClusterMarkerEvidence.get_example() - return cls(clusters=[cluster], evidenceIds=[cluster.evidenceId]) - class ClusterInterpretation(AgentDataModel): """One evidence-linked cluster interpretation or hypothesis.""" @@ -205,17 +110,6 @@ class ClusterInterpretation(AgentDataModel): rationale: str = "" evidenceIds: list[str] = Field(default_factory=list) - @classmethod - def get_example(cls) -> "ClusterInterpretation": - return cls( - clusterId="3", - proposedIdentity="alveolar macrophage-like", - identityIsHypothesis=True, - confidence="medium", - rationale="Observed marker pattern is consistent with the proposed identity.", - evidenceIds=["markers:RNA_cluster:cluster:3"], - ) - class TreatmentObservation(AgentDataModel): """Descriptive treatment observation with no unsupported causal claim.""" @@ -228,20 +122,6 @@ class TreatmentObservation(AgentDataModel): isDescriptiveOnly: Literal[True] = True evidenceIds: list[str] = Field(default_factory=list) - @classmethod - def get_example(cls) -> "TreatmentObservation": - return cls( - clusterId="3", - referenceCondition="control", - comparisonCondition="treated", - direction="higher", - observation="Cluster 3 has a higher mean fraction in treated samples.", - evidenceIds=[ - "composition:RNA_cluster:condition:control:cluster:3", - "composition:RNA_cluster:condition:treated:cluster:3", - ], - ) - class FollowUpRecommendation(AgentDataModel): """A bounded next analysis tied to an observed uncertainty.""" @@ -252,32 +132,12 @@ class FollowUpRecommendation(AgentDataModel): requiredInputs: list[str] = Field(default_factory=list) evidenceIds: list[str] = Field(default_factory=list) - @classmethod - def get_example(cls) -> "FollowUpRecommendation": - return cls( - question="Is the abundance difference reproducible across donors?", - operation="sample-level differential abundance", - rationale="Current evidence is descriptive and requires independent replicates.", - requiredInputs=["sample", "condition", "donor"], - evidenceIds=[ - "composition:RNA_cluster:condition:control:cluster:3", - "composition:RNA_cluster:condition:treated:cluster:3", - ], - ) - class BiologicalInterpretationNeedsInput(AgentDataModel): question: str = "" requiredInputs: list[str] = Field(default_factory=list) evidenceIds: list[str] = Field(default_factory=list) - @classmethod - def get_example(cls) -> "BiologicalInterpretationNeedsInput": - return cls( - question="Provide an exact marker artifact or authorize marker search.", - requiredInputs=["markerArtifact"], - ) - class BiologicalInterpretationReport(AgentDataModel): """Structured, evidence-grounded biological review.""" @@ -296,33 +156,6 @@ class BiologicalInterpretationReport(AgentDataModel): needsInput: BiologicalInterpretationNeedsInput | None = None runInfo: AgentRunInfo = Field(default_factory=AgentRunInfo) - @classmethod - def get_example(cls) -> "BiologicalInterpretationReport": - interpretation = ClusterInterpretation.get_example() - observation = TreatmentObservation.get_example() - follow_up = FollowUpRecommendation.get_example() - return cls( - status="done", - clusterInterpretations=[interpretation], - treatmentObservations=[observation], - followUps=[follow_up], - clusterArtifact=ClusterCompositionEvidence.get_example().clusterArtifact, - markerArtifact=ClusterMarkerEvidence.get_example().markerArtifact, - graphAssay="RNA", - markerAssay="RNA", - evidenceIds=sorted( - { - *interpretation.evidenceIds, - *observation.evidenceIds, - *follow_up.evidenceIds, - } - ), - limitations=[ - "Cell identities remain hypotheses until independently validated." - ], - stopReason="The requested clusters were reviewed.", - ) - class BiologicalInterpretationDependencies(AgentDataModel): """Runtime state available only to biological interpretation tools.""" @@ -369,15 +202,3 @@ class BiologicalInterpretationDependencies(AgentDataModel): default=None, exclude=True, ) - - @classmethod - def get_example(cls) -> "BiologicalInterpretationDependencies": - return cls( - cluster=object(), - fromAssay="RNA", - graphAssay="RNA", - markerAssay="RNA", - markerAssayType="RNA", - sampleColumn="sample", - conditionColumn="treatment", - ) diff --git a/scarf/agent/cell_quality/profiles.py b/scarf/agent/cell_quality/profiles.py index 4636da7c..1a4cddd3 100644 --- a/scarf/agent/cell_quality/profiles.py +++ b/scarf/agent/cell_quality/profiles.py @@ -24,6 +24,24 @@ "captureMad3Sensitivity", "pooledReferenceMad5", ] +type CellQualityProfile = ( + RegisteredCellQcProfile | Literal["coreGlobalGaussian", "coreSampleMad3"] +) + + +def cell_qc_policy( + action: str, registered_profile: RegisteredCellQcProfile | None +) -> CellQualityProfile | None: + """Name the exact filtering route shared by selection, execution, and resume.""" + if registered_profile is not None: + return registered_profile + if action == "globalGaussian": + return "coreGlobalGaussian" + if action == "sampleMad": + return "coreSampleMad3" + return None + + type AutoFilterAction = Literal["globalGaussian", "sampleMad"] type QcMetricRole = Literal[ "count", diff --git a/scarf/agent/config/__init__.py b/scarf/agent/config/__init__.py index bff4ed87..a491577c 100644 --- a/scarf/agent/config/__init__.py +++ b/scarf/agent/config/__init__.py @@ -89,10 +89,6 @@ def with_limits( ) return type(self).model_validate(values) - @classmethod - def get_example(cls) -> "AgentRunConfig": - return cls(requestLimit=9, toolCallLimit=5, outputTokenLimit=2048) - def get_model_settings( config: AgentRunConfig | None = None, diff --git a/scarf/agent/config/agent_exec.py b/scarf/agent/config/agent_exec.py index 5eae366e..66f6a89e 100644 --- a/scarf/agent/config/agent_exec.py +++ b/scarf/agent/config/agent_exec.py @@ -331,7 +331,7 @@ def _execution_result( ), ) usage = execution.runInfo.usage - logger.info( + logger.debug( f"Agent {name or 'unnamed'} completed in " f"{execution.runInfo.durationSeconds:.2f}s: requests={usage.requests}, " f"tool_calls={usage.toolCalls}, input_tokens={usage.inputTokens}, " @@ -366,7 +366,7 @@ async def execute() -> AgentExecutionResult: run_config = config or AgentRunConfig() agent_name = name or "unnamed" usage_limits = get_usage_limits(run_config) - logger.info( + logger.debug( f"Starting agent {agent_name}: model={_model_name(model)}, " f"tools={len(tools)}, request_limit={run_config.requestLimit}, " f"tool_call_limit={run_config.toolCallLimit}, retries={run_config.retries}, " @@ -450,7 +450,7 @@ async def run_agent_async( run_config = config or AgentRunConfig() agent_name = name or "unnamed" usage_limits = get_usage_limits(run_config) - logger.info( + logger.debug( f"Starting agent {agent_name}: model={_model_name(model)}, " f"tools={len(tools)}, request_limit={run_config.requestLimit}, " f"tool_call_limit={run_config.toolCallLimit}, retries={run_config.retries}, " diff --git a/scarf/agent/data_enrichment/agent.py b/scarf/agent/data_enrichment/agent.py index aa12257e..7ab7d696 100644 --- a/scarf/agent/data_enrichment/agent.py +++ b/scarf/agent/data_enrichment/agent.py @@ -21,8 +21,7 @@ ) from .validation import ( _SUPPORTED_SPECIES, - deterministic_data_enrichment_report, - pending_data_enrichment_report, + failed_data_enrichment_report, validate_data_enrichment_report, ) @@ -104,10 +103,8 @@ def __init__( model: Any, *, config: AgentRunConfig | None = None, - unattended: bool = False, ) -> None: self.model = model - self.unattended = unattended self.config = (config or AgentRunConfig()).with_limits( request_limit=8, tool_call_limit=5, @@ -249,31 +246,12 @@ def run( ), ) except (UnexpectedModelBehavior, UsageLimitExceeded) as exc: - if set(deps.inspections) != set(deps.assays): - raise model_name = getattr(self.model, "model_name", type(self.model).__name__) - if self.unattended: - return deterministic_data_enrichment_report( - deps, - error=exc, - model_name=str(model_name), - ) - return pending_data_enrichment_report( - deps, - error=exc, - model_name=str(model_name), + return failed_data_enrichment_report( + deps, error=exc, model_name=str(model_name) ) report = DataEnrichmentReport.model_validate(execution.output) report = validate_data_enrichment_report(deps, report) - if self.unattended and report.status == "needsInput": - model_name = getattr(self.model, "model_name", type(self.model).__name__) - return deterministic_data_enrichment_report( - deps, - error=RuntimeError( - "The model returned an unresolved data-enrichment policy" - ), - model_name=str(model_name), - ) report.runInfo = execution.runInfo logger.info( "Data Enrichment Agent completed: " diff --git a/scarf/agent/data_enrichment/characterization.py b/scarf/agent/data_enrichment/characterization.py index 9c733aaf..428c8faf 100644 --- a/scarf/agent/data_enrichment/characterization.py +++ b/scarf/agent/data_enrichment/characterization.py @@ -82,14 +82,6 @@ class FeatureCharacterization(AgentDataModel): def get_blank(cls) -> "FeatureCharacterization": return cls(status="failed") - @classmethod - def get_example(cls) -> "FeatureCharacterization": - return cls( - status="done", - notes=["Feature identity and families were characterized."], - assays=[{"assay": "RNA", "species": "homo_sapiens"}], - ) - def _bounded_context(study_context: str | None) -> str: text = (study_context or "").strip() diff --git a/scarf/agent/data_enrichment/contracts.py b/scarf/agent/data_enrichment/contracts.py index b7c32129..935fd71e 100644 --- a/scarf/agent/data_enrichment/contracts.py +++ b/scarf/agent/data_enrichment/contracts.py @@ -3,7 +3,6 @@ from pathlib import Path from typing import Any, Literal -from ...features.variability import DEFAULT_HVG_BLACKLIST from .._deps import AGENT_INSTALL_HINT from ..types import AgentDataModel, AgentRunInfo, StageStatus @@ -27,19 +26,6 @@ class DataEnrichmentContext(AgentDataModel): def get_blank(cls) -> "DataEnrichmentContext": return cls() - @classmethod - def get_example(cls) -> "DataEnrichmentContext": - return cls( - studyContext="Single-cell profiling of treated lung tissue", - studyObjective=( - "Discover stable populations while preserving treatment effects." - ), - organismHint="human", - tissueReferences=["lung"], - cellTypeReferences=["alveolar macrophage", "T cell"], - experimentalDetails=["CRISPR perturbation", "10x 3 prime RNA-seq"], - ) - class StudyContextSummary(AgentDataModel): """Verbatim, evidence-backed references extracted from the study context.""" @@ -58,25 +44,6 @@ class StudyContextSummary(AgentDataModel): def get_blank(cls) -> "StudyContextSummary": return cls() - @classmethod - def get_example(cls) -> "StudyContextSummary": - return cls( - studyContext=( - "Single-cell profiling of treated human lung tests whether " - "treatment changes alveolar macrophage states." - ), - studyObjective=( - "Discover populations while preserving the treatment comparison." - ), - organismReferences=["human"], - tissueReferences=["lung"], - cellTypeReferences=["alveolar macrophage"], - experimentalReferences=["treated"], - hypothesisReferences=["treatment changes alveolar macrophage states"], - analysisIntentReferences=["Single-cell profiling"], - evidenceIds=["context:study"], - ) - class AdtControlEvidence(AgentDataModel): """One exact observed ADT feature carrying an explicit control token.""" @@ -95,15 +62,6 @@ def get_blank(cls) -> "AdtControlEvidence": evidenceId="", ) - @classmethod - def get_example(cls) -> "AdtControlEvidence": - return cls( - featureId="Mouse-IgG1-Control", - featureName="Mouse IgG1 isotype control", - matchedToken="isotype", - evidenceId="assay:ADT:adtControl:Mouse-IgG1-Control", - ) - class HtoTagEvidence(AgentDataModel): """One exact feature from an assay persisted with the HTO type.""" @@ -116,14 +74,6 @@ class HtoTagEvidence(AgentDataModel): def get_blank(cls) -> "HtoTagEvidence": return cls(featureId="", featureName="", evidenceId="") - @classmethod - def get_example(cls) -> "HtoTagEvidence": - return cls( - featureId="HTO-1", - featureName="Sample tag 1", - evidenceId="assay:HTO:htoTag:HTO-1", - ) - class AtacCoordinateEvidence(AgentDataModel): """Validation evidence for exact ATAC feature IDs as genomic intervals.""" @@ -142,16 +92,6 @@ class AtacCoordinateEvidence(AgentDataModel): def get_blank(cls) -> "AtacCoordinateEvidence": return cls() - @classmethod - def get_example(cls) -> "AtacCoordinateEvidence": - return cls( - status="valid", - totalFeatures=2, - validFeatures=2, - validExamples=["chr1:100-200", "chr2:300-450"], - evidenceId="assay:ATAC:atacCoordinates", - ) - class AssayModalityEvidence(AgentDataModel): """Bounded deterministic routing evidence for one persisted assay type.""" @@ -176,21 +116,6 @@ class AssayModalityEvidence(AgentDataModel): def get_blank(cls) -> "AssayModalityEvidence": return cls() - @classmethod - def get_example(cls) -> "AssayModalityEvidence": - control = AdtControlEvidence.get_example() - return cls( - assayType="ADT", - modality="ADT", - typeSource="persisted", - graphEligible=True, - markerEligible=True, - adtControls=[control], - totalObservedFeatures=20, - reportedFeatures=1, - evidenceIds=["assay:ADT:modality", control.evidenceId], - ) - class FeatureFamilyEvidence(AgentDataModel): """One observed feature family from deterministic Scarf analysis.""" @@ -212,18 +137,6 @@ class FeatureFamilyEvidence(AgentDataModel): def get_blank(cls) -> "FeatureFamilyEvidence": return cls(family="", evidenceId="") - @classmethod - def get_example(cls) -> "FeatureFamilyEvidence": - return cls( - family="mitochondrial", - species="homo_sapiens", - method="chromosome", - count=2, - examples=["MT-CO1", "MT-CYB"], - defaultExclude=True, - evidenceId="assay:RNA:family:mitochondrial", - ) - class DefaultHvgFamilyEvidence(AgentDataModel): """One case-insensitive family within Scarf's default HVG blacklist.""" @@ -239,16 +152,6 @@ class DefaultHvgFamilyEvidence(AgentDataModel): def get_blank(cls) -> "DefaultHvgFamilyEvidence": return cls() - @classmethod - def get_example(cls) -> "DefaultHvgFamilyEvidence": - return cls( - family="mitochondrial", - pattern="^MT-", - count=2, - examples=["MT-CO1", "MT-CYB"], - evidenceId="assay:RNA:scarfDefaultHvg:family:mitochondrial", - ) - class RnaFeatureInventoryEvidence(AgentDataModel): """Exact name-column matches for Scarf's default HVG blacklist.""" @@ -268,20 +171,6 @@ class RnaFeatureInventoryEvidence(AgentDataModel): def get_blank(cls) -> "RnaFeatureInventoryEvidence": return cls() - @classmethod - def get_example(cls) -> "RnaFeatureInventoryEvidence": - family = DefaultHvgFamilyEvidence.get_example() - evidence_id = "assay:RNA:scarfDefaultHvg:combined" - return cls( - totalFeatures=20_000, - blacklist=DEFAULT_HVG_BLACKLIST, - matchCount=2, - examples=["MT-CO1", "MT-CYB"], - families=[family], - evidenceId=evidence_id, - evidenceIds=[evidence_id, family.evidenceId], - ) - class ExogenousFeatureEvidence(AgentDataModel): """One bounded candidate for an artificial or exogenous feature.""" @@ -296,16 +185,6 @@ class ExogenousFeatureEvidence(AgentDataModel): def get_blank(cls) -> "ExogenousFeatureEvidence": return cls(featureId="", featureName="", evidenceId="") - @classmethod - def get_example(cls) -> "ExogenousFeatureEvidence": - return cls( - featureId="ERCC-00002", - featureName="ERCC-00002", - score=4, - classification="potentialExogenous", - evidenceId="assay:RNA:exogenous:ERCC-00002", - ) - class AssayFeatureInspection(AgentDataModel): """Bounded read-only inspection returned to the model.""" @@ -329,38 +208,6 @@ class AssayFeatureInspection(AgentDataModel): def get_blank(cls) -> "AssayFeatureInspection": return cls(assay="") - @classmethod - def get_example(cls) -> "AssayFeatureInspection": - family = FeatureFamilyEvidence.get_example() - default_inventory = RnaFeatureInventoryEvidence.get_example() - modality = AssayModalityEvidence( - assayType="RNA", - modality="RNA", - typeSource="persisted", - graphEligible=True, - markerEligible=True, - totalObservedFeatures=20_000, - evidenceIds=["assay:RNA:modality"], - ) - return cls( - assay="RNA", - assayKind="RNAassay", - identity={"nFeatures": 20_000, "nDuplicateIds": 0}, - species="homo_sapiens", - speciesMethod="ensemblPrefix", - speciesReason="Most feature IDs carry the ENSG prefix", - families=[family], - defaultFeatureInventory=default_inventory, - modalityEvidence=modality, - evidenceIds=[ - "assay:RNA:identity", - "assay:RNA:species", - family.evidenceId, - *default_inventory.evidenceIds, - *modality.evidenceIds, - ], - ) - class AssayFeatureInspectionBatch(AgentDataModel): """All requested assay inspections returned by one model tool call.""" @@ -372,14 +219,6 @@ class AssayFeatureInspectionBatch(AgentDataModel): def get_blank(cls) -> "AssayFeatureInspectionBatch": return cls() - @classmethod - def get_example(cls) -> "AssayFeatureInspectionBatch": - inspection = AssayFeatureInspection.get_example() - return cls( - inspections=[inspection], - evidenceIds=list(inspection.evidenceIds), - ) - class FeatureReference(AgentDataModel): """An exact feature identifier and name observed in one assay.""" @@ -391,10 +230,6 @@ class FeatureReference(AgentDataModel): def get_blank(cls) -> "FeatureReference": return cls(featureId="", featureName="") - @classmethod - def get_example(cls) -> "FeatureReference": - return cls(featureId="ENSG00000198727", featureName="MT-CYB") - class FeatureMatch(AgentDataModel): """Resolution of one proposed feature against an assay.""" @@ -408,15 +243,6 @@ class FeatureMatch(AgentDataModel): def get_blank(cls) -> "FeatureMatch": return cls(query="", status="absent") - @classmethod - def get_example(cls) -> "FeatureMatch": - return cls( - query="MT-CYB", - status="present", - matches=[FeatureReference.get_example()], - evidenceIds=["assay:RNA:feature:ENSG00000198727"], - ) - class FeatureLookupResult(AgentDataModel): """Bounded result from exact feature lookup.""" @@ -429,15 +255,6 @@ class FeatureLookupResult(AgentDataModel): def get_blank(cls) -> "FeatureLookupResult": return cls(assay="") - @classmethod - def get_example(cls) -> "FeatureLookupResult": - match = FeatureMatch.get_example() - return cls( - assay="RNA", - results=[match], - evidenceIds=list(match.evidenceIds), - ) - class FeatureLookupBatch(AgentDataModel): """Exact feature lookups for every requested assay in one tool result.""" @@ -449,11 +266,6 @@ class FeatureLookupBatch(AgentDataModel): def get_blank(cls) -> "FeatureLookupBatch": return cls() - @classmethod - def get_example(cls) -> "FeatureLookupBatch": - lookup = FeatureLookupResult.get_example() - return cls(lookups=[lookup], evidenceIds=list(lookup.evidenceIds)) - class FeatureSelectionPolicy(AgentDataModel): """Grounded feature policy proposed for one assay.""" @@ -504,28 +316,6 @@ def validate_non_conflicting_policy(self) -> "FeatureSelectionPolicy": def get_blank(cls) -> "FeatureSelectionPolicy": return cls(assay="") - @classmethod - def get_example(cls) -> "FeatureSelectionPolicy": - return cls( - assay="RNA", - species="homo_sapiens", - organismName="human", - speciesConfidence="high", - speciesRationale="Gene IDs and study context agree", - excludeFamilies=["mitochondrial", "ribosomal"], - protectFamilies=["cellCycle", "sex"], - artificialFeatures=["ERCC-00002"], - tissueReferences=["lung"], - cellTypeReferences=["alveolar macrophage"], - experimentalReferences=["ERCC spike-in"], - assayType="RNA", - assayModality="RNA", - graphEligible=True, - markerEligible=True, - rationale="Use technical families for feature-selection exclusions", - evidenceIds=["assay:RNA:species", "assay:RNA:family:mitochondrial"], - ) - class DataEnrichmentToolCall(AgentDataModel): """Compact audit record for one read-only model tool call.""" @@ -538,14 +328,6 @@ class DataEnrichmentToolCall(AgentDataModel): def get_blank(cls) -> "DataEnrichmentToolCall": return cls(name="", assay="") - @classmethod - def get_example(cls) -> "DataEnrichmentToolCall": - return cls( - name="inspect_assay_features", - assay="RNA", - evidenceIds=["assay:RNA:identity", "assay:RNA:species"], - ) - class DataEnrichmentReport(AgentDataModel): """Final grounded report from :class:`DataEnrichmentAgent`.""" @@ -576,20 +358,6 @@ def validate_status(self) -> "DataEnrichmentReport": def get_blank(cls) -> "DataEnrichmentReport": return cls(status="failed", limitations=["No agent result was produced"]) - @classmethod - def get_example(cls) -> "DataEnrichmentReport": - policy = FeatureSelectionPolicy.get_example() - inspection = AssayFeatureInspection.get_example() - return cls( - status="done", - policies=[policy], - inspections=[inspection], - studyContextSummary=StudyContextSummary.get_example(), - evidenceIds=list(policy.evidenceIds), - toolCalls=[DataEnrichmentToolCall.get_example()], - runInfo=AgentRunInfo.get_example(), - ) - class DataEnrichmentDependencies(AgentDataModel): """Hidden runtime state supplied to read-only enrichment tools.""" @@ -614,13 +382,3 @@ class DataEnrichmentDependencies(AgentDataModel): @classmethod def get_blank(cls) -> "DataEnrichmentDependencies": return cls() - - @classmethod - def get_example(cls) -> "DataEnrichmentDependencies": - return cls( - context=DataEnrichmentContext.get_example(), - assays=["RNA"], - cacheDir=Path("/tmp/scarf-gene-reference"), - allowDownload=False, - evidenceIds={"context:organism", "context:tissue:0"}, - ) diff --git a/scarf/agent/data_enrichment/validation.py b/scarf/agent/data_enrichment/validation.py index 80687783..a7eca3cd 100644 --- a/scarf/agent/data_enrichment/validation.py +++ b/scarf/agent/data_enrichment/validation.py @@ -4,7 +4,6 @@ from ...features.gene_reference import species_registry from ...utils.logging import logger -from .._deps import AGENT_INSTALL_HINT from ..types import AgentRunInfo from .contracts import ( DataEnrichmentContext, @@ -15,11 +14,6 @@ StudyContextSummary, ) -try: - from pydantic_ai import UnexpectedModelBehavior, UsageLimitExceeded -except ImportError as exc: - raise ImportError(AGENT_INSTALL_HINT) from exc - _SUPPORTED_SPECIES = species_registry() @@ -285,109 +279,25 @@ def validate_data_enrichment_report( return report -def pending_data_enrichment_report( +def failed_data_enrichment_report( deps: DataEnrichmentDependencies, *, - error: UnexpectedModelBehavior | UsageLimitExceeded, + error: Exception, model_name: str, ) -> DataEnrichmentReport: - """Pause after deterministic inspection when no valid policy was selected.""" - if set(deps.inspections) != set(deps.assays): - raise error - error_detail = str(error).replace("\n", " ").strip()[:500] - report = DataEnrichmentReport( - status="needsInput", - studyContextSummary=StudyContextSummary.get_blank(), - unresolvedQuestions=[ - "The Data Enrichment agent did not produce a validated feature policy. " - "Provide explicit organism and representation-feature intent." + """Retain observed inspection evidence without selecting a policy after failure.""" + context = _ground_study_context_summary(deps.context, StudyContextSummary()) + return DataEnrichmentReport( + status="failed", + inspections=[ + deps.inspections[name] for name in deps.assays if name in deps.inspections ], + studyContextSummary=context, + toolCalls=list(deps.toolCalls), + evidenceIds=sorted({*deps.evidenceIds, *context.evidenceIds}), limitations=[ "No scientific feature policy was selected after model failure.", - error_detail, - ], - runInfo=AgentRunInfo( - agentName="data_enrichment_needs_input", - modelName=model_name, - ), - ) - validated = validate_data_enrichment_report(deps, report) - logger.warning( - "Data Enrichment paused without a scientific selection: " - f"assays={len(validated.inspections)}, evidence={len(validated.evidenceIds)}, " - f"reason={error_detail}" - ) - return validated - - -def deterministic_data_enrichment_report( - deps: DataEnrichmentDependencies, - *, - error: Exception, - model_name: str, -) -> DataEnrichmentReport: - """Use inspected feature evidence when an unattended model run is invalid.""" - if set(deps.inspections) != set(deps.assays): - raise error - policies = [] - for assay in deps.assays: - inspection = deps.inspections[assay] - evidence_ids = list(inspection.evidenceIds) - if not evidence_ids: - raise ValueError(f"Assay {assay!r} has no deterministic feature evidence") - policies.append( - FeatureSelectionPolicy( - assay=assay, - species=( - inspection.species - if inspection.species in {*_SUPPORTED_SPECIES, "unknown"} - else "unknown" - ), - speciesConfidence=( - "high" if inspection.species in _SUPPORTED_SPECIES else "unknown" - ), - speciesRationale=( - inspection.speciesReason - or "Feature inspection did not resolve a supported species." - ), - excludeFamilies=[ - item.family - for item in inspection.families - if item.defaultExclude is True - ], - protectFamilies=[ - item.family - for item in inspection.families - if item.defaultExclude is False - ], - rationale=( - "Use the exact observed default-exclusion families as the " - "initial representation-sensitivity policy." - ), - evidenceIds=evidence_ids, - ) - ) - summary = StudyContextSummary( - organismReferences=( - [deps.context.organismHint] if deps.context.organismHint else [] - ), - tissueReferences=list(deps.context.tissueReferences), - cellTypeReferences=list(deps.context.cellTypeReferences), - experimentalReferences=list(deps.context.experimentalDetails), - ) - error_detail = str(error).replace("\n", " ").strip()[:500] - report = DataEnrichmentReport( - status="done", - policies=policies, - studyContextSummary=summary, - limitations=[ - "The model feature-policy output was invalid; the workflow used only " - "deterministic assay inspection evidence.", - error_detail, + str(error).replace("\n", " ").strip()[:500], ], - runInfo=AgentRunInfo( - agentName="data_enrichment_deterministic", - modelName=model_name, - ), + runInfo=AgentRunInfo(agentName="data_enrichment_failed", modelName=model_name), ) - return validate_data_enrichment_report(deps, report) diff --git a/scarf/agent/decisions/kernel.py b/scarf/agent/decisions/kernel.py index 0ff52f97..6c682ccc 100644 --- a/scarf/agent/decisions/kernel.py +++ b/scarf/agent/decisions/kernel.py @@ -354,48 +354,6 @@ def validate_override(self) -> "DecisionSelection": return self -class PendingDecision(DecisionKernelModel): - """One unresolved checkpoint persisted without fabricating a selection.""" - - questionId: str - decisionId: str - definitionVersion: int = Field(ge=1, strict=True) - evidenceBundleId: str - evidenceBundleSha256: str - offeredOptionIds: list[str] = Field(min_length=1) - availableEvidenceIds: list[str] = Field(default_factory=list) - reason: str = Field(min_length=1, max_length=2000) - createdAtNs: int = Field(default=0, ge=0, strict=True) - - @field_validator("questionId", "decisionId", "evidenceBundleId") - @classmethod - def validate_ids(cls, value: str, info: object) -> str: - field_name = getattr(info, "field_name", "identifier") - return _validate_identifier(value, field_name) - - @field_validator("offeredOptionIds", "availableEvidenceIds") - @classmethod - def validate_id_lists(cls, value: list[str], info: object) -> list[str]: - field_name = getattr(info, "field_name", "identifiers") - for item in value: - _validate_identifier(item, f"{field_name} item") - return _validate_unique(value, field_name) - - @field_validator("reason") - @classmethod - def validate_reason(cls, value: str) -> str: - if value != value.strip(): - raise ValueError("reason must not contain surrounding whitespace") - return value - - @field_validator("evidenceBundleSha256") - @classmethod - def validate_evidence_bundle_sha256(cls, value: str) -> str: - if _SHA256_PATTERN.fullmatch(value) is None: - raise ValueError("evidenceBundleSha256 must be a lowercase SHA-256 digest") - return value - - class DecisionRecord(DecisionKernelModel): """One durable rule, agent, or human selection from an exact option set.""" @@ -420,8 +378,6 @@ class DecisionRecord(DecisionKernelModel): promptSha256: str | None = None modelName: str | None = None softwareSha256: str | None = None - verificationId: str | None = None - supersedes: str | None = None createdAtNs: int = Field(default=0, ge=0, strict=True) @field_validator( @@ -429,8 +385,6 @@ class DecisionRecord(DecisionKernelModel): "decisionId", "evidenceBundleId", "selectedOptionId", - "verificationId", - "supersedes", "overrideOfOptionId", ) @classmethod @@ -509,8 +463,6 @@ def validate_references(self) -> "DecisionRecord": raise ValueError("overrideOfOptionId must reference an offered option") if self.overrideOfOptionId == self.selectedOptionId: raise ValueError("overrideOfOptionId must differ from selectedOptionId") - if self.supersedes == self.recordId: - raise ValueError("A DecisionRecord cannot supersede itself") return self @@ -542,325 +494,6 @@ def validate_summary(cls, value: str) -> str: return value -class VerificationRecord(DecisionKernelModel): - """Deterministic verification result for exactly one decision record.""" - - verificationId: str - decisionRecordId: str - status: VerificationStatus - checks: list[VerificationCheck] = Field(min_length=1) - createdAtNs: int = Field(default=0, ge=0, strict=True) - - @field_validator("verificationId", "decisionRecordId") - @classmethod - def validate_ids(cls, value: str, info: object) -> str: - field_name = getattr(info, "field_name", "identifier") - return _validate_identifier(value, field_name) - - @model_validator(mode="after") - def validate_aggregate_status(self) -> "VerificationRecord": - check_ids = [check.checkId for check in self.checks] - _validate_unique(check_ids, "VerificationRecord check IDs") - statuses = {check.status for check in self.checks} - if self.status == "passed" and statuses != {"passed"}: - raise ValueError("passed verification requires every check to pass") - if self.status == "failed" and "failed" not in statuses: - raise ValueError("failed verification requires a failed check") - if self.status == "inconclusive" and ( - "failed" in statuses or "inconclusive" not in statuses - ): - raise ValueError( - "inconclusive verification requires an inconclusive check and no failures" - ) - return self - - -class RevisionRequest(DecisionKernelModel): - """A bounded request to supersede one decision using exact audit evidence.""" - - revisionId: str - targetDecisionRecordId: str - verificationId: str - replacementOptionId: str - reason: str = Field(min_length=1, max_length=2000) - evidenceBundleId: str | None = None - evidenceBundleSha256: str | None = None - availableEvidenceIds: list[str] = Field(default_factory=list) - evidenceIds: list[str] = Field(default_factory=list) - invalidatesDecisionRecordIds: list[str] = Field(default_factory=list) - createdAtNs: int = Field(default=0, ge=0, strict=True) - - @field_validator( - "revisionId", - "targetDecisionRecordId", - "verificationId", - "replacementOptionId", - "evidenceBundleId", - ) - @classmethod - def validate_ids(cls, value: str | None, info: object) -> str | None: - if value is None: - return None - field_name = getattr(info, "field_name", "identifier") - return _validate_identifier(value, field_name) - - @field_validator( - "availableEvidenceIds", - "evidenceIds", - "invalidatesDecisionRecordIds", - ) - @classmethod - def validate_id_lists(cls, value: list[str], info: object) -> list[str]: - field_name = getattr(info, "field_name", "identifiers") - for item in value: - _validate_identifier(item, f"{field_name} item") - return _validate_unique(value, field_name) - - @field_validator("evidenceBundleSha256") - @classmethod - def validate_evidence_bundle_sha256(cls, value: str | None) -> str | None: - if value is not None and _SHA256_PATTERN.fullmatch(value) is None: - raise ValueError("evidenceBundleSha256 must be a lowercase SHA-256 digest") - return value - - @field_validator("reason") - @classmethod - def validate_reason(cls, value: str) -> str: - if value != value.strip(): - raise ValueError("reason must not contain surrounding whitespace") - return value - - @model_validator(mode="after") - def validate_evidence_bundle(self) -> "RevisionRequest": - if (self.evidenceBundleId is None) != (self.evidenceBundleSha256 is None): - raise ValueError( - "Revision evidence bundle ID and checksum must be provided together" - ) - if self.evidenceBundleId is None and ( - self.availableEvidenceIds or self.evidenceIds - ): - raise ValueError( - "Revision evidence inventory requires an exact evidence bundle" - ) - if not set(self.evidenceIds).issubset(self.availableEvidenceIds): - raise ValueError( - "Revision evidence must reference its exact available inventory" - ) - return self - - -class DecisionWorkflowRun(DecisionKernelModel): - """Versioned, acyclic ledger for one bounded decision workflow.""" - - recordType: Literal["decisionWorkflowRun"] = "decisionWorkflowRun" - formatVersion: Literal[2] = 2 - workflowRunId: str - status: DecisionWorkflowStatus = "running" - decisionRecords: list[DecisionRecord] = Field(default_factory=list) - verificationRecords: list[VerificationRecord] = Field(default_factory=list) - revisionRequests: list[RevisionRequest] = Field(default_factory=list) - maxRevisions: int = Field(default=2, ge=0, le=2, strict=True) - pendingDecision: PendingDecision | None = None - finalHandoffId: str | None = None - limitations: list[str] = Field(default_factory=list) - unresolvedClaims: list[str] = Field(default_factory=list) - - @field_validator("workflowRunId", "finalHandoffId") - @classmethod - def validate_ids(cls, value: str | None, info: object) -> str | None: - if value is None: - return None - field_name = getattr(info, "field_name", "identifier") - return _validate_identifier(value, field_name) - - @model_validator(mode="after") - def validate_ledger(self) -> "DecisionWorkflowRun": - if len(self.revisionRequests) > self.maxRevisions: - raise ValueError("Decision workflow exceeds its configured revision limit") - - records: dict[str, DecisionRecord] = {} - record_positions: dict[str, int] = {} - active_by_decision: dict[str, str] = {} - for position, record in enumerate(self.decisionRecords): - if record.recordId in records: - raise ValueError("decisionRecords must have unique recordId values") - if record.decisionId in active_by_decision: - expected_parent = active_by_decision[record.decisionId] - if record.supersedes != expected_parent: - raise ValueError( - "Repeated decisions must supersede the current active record" - ) - elif record.supersedes is not None: - raise ValueError( - "supersedes must reference an earlier matching decision" - ) - if record.supersedes is not None: - parent = records.get(record.supersedes) - if parent is None or parent.decisionId != record.decisionId: - raise ValueError( - "supersedes must reference an earlier record for the same decision" - ) - records[record.recordId] = record - record_positions[record.recordId] = position - active_by_decision[record.decisionId] = record.recordId - - verifications: dict[str, VerificationRecord] = {} - verified_records: set[str] = set() - for verification in self.verificationRecords: - if verification.verificationId in verifications: - raise ValueError( - "verificationRecords must have unique verificationId values" - ) - if verification.decisionRecordId not in records: - raise ValueError("Verification must reference an exact decision record") - if verification.decisionRecordId in verified_records: - raise ValueError("A decision record may have only one verification") - linked_record = records[verification.decisionRecordId] - if linked_record.verificationId != verification.verificationId: - raise ValueError( - "Decision and verification references must agree exactly" - ) - verifications[verification.verificationId] = verification - verified_records.add(verification.decisionRecordId) - - revision_ids: set[str] = set() - revised_targets: set[str] = set() - revisions_by_target: dict[str, RevisionRequest] = {} - for revision in self.revisionRequests: - if revision.revisionId in revision_ids: - raise ValueError("revisionRequests must have unique revisionId values") - if revision.targetDecisionRecordId in revised_targets: - raise ValueError("A decision record may be revised only once") - target = records.get(revision.targetDecisionRecordId) - if target is None: - raise ValueError("Revision must reference an exact decision record") - revision_verification = verifications.get(revision.verificationId) - if ( - revision_verification is None - or revision_verification.decisionRecordId - != revision.targetDecisionRecordId - ): - raise ValueError( - "Revision must reference the target decision's verification" - ) - if revision_verification.status == "passed" and ( - revision.evidenceBundleId is None or not revision.evidenceIds - ): - raise ValueError( - "Revising a passed decision requires exact downstream evidence" - ) - if revision.replacementOptionId == target.selectedOptionId: - raise ValueError("Revision replacement must change the selected option") - if revision.evidenceBundleId == target.evidenceBundleId and ( - revision.evidenceBundleSha256 != target.evidenceBundleSha256 - or revision.availableEvidenceIds != target.availableEvidenceIds - ): - raise ValueError( - "Revision evidence must match the target bundle exactly" - ) - for invalidated_id in revision.invalidatesDecisionRecordIds: - if invalidated_id not in records: - raise ValueError( - "Revision invalidation must reference an exact decision record" - ) - if ( - record_positions[invalidated_id] - <= record_positions[target.recordId] - ): - raise ValueError( - "Revision invalidation may reference only downstream decisions" - ) - revision_ids.add(revision.revisionId) - revised_targets.add(revision.targetDecisionRecordId) - revisions_by_target[revision.targetDecisionRecordId] = revision - - invalidated_record_ids = { - record_id - for revision in self.revisionRequests - for record_id in revision.invalidatesDecisionRecordIds - } - for record in self.decisionRecords: - if record.supersedes is None: - continue - matching_revision = revisions_by_target.get(record.supersedes) - if ( - matching_revision is None - and record.supersedes not in invalidated_record_ids - ): - raise ValueError("A superseding decision requires a revision request") - if ( - matching_revision is not None - and matching_revision.replacementOptionId != record.selectedOptionId - ): - raise ValueError( - "A superseding decision must select the requested replacement option" - ) - - active_record_ids = set(active_by_decision.values()).difference( - invalidated_record_ids - ) - active_records = [records[record_id] for record_id in active_record_ids] - if self.status == "completed": - if self.finalHandoffId is None: - raise ValueError("completed workflows require finalHandoffId") - if self.pendingDecision is not None: - raise ValueError( - "completed workflows cannot contain a pending decision" - ) - for record in active_records: - verification_id = record.verificationId - active_verification = ( - verifications.get(verification_id) - if verification_id is not None - else None - ) - if record.status in {"defer", "abstain"} or ( - active_verification is None - or active_verification.status != "passed" - ): - raise ValueError( - "completed workflows require every active decision to pass" - ) - elif self.finalHandoffId is not None: - raise ValueError("Only completed workflows may reference a final handoff") - - if self.status == "needsInput" and ( - self.pendingDecision is None - and not any(record.status == "defer" for record in active_records) - ): - raise ValueError( - "needsInput workflows require a pending or active defer decision" - ) - if self.status != "needsInput" and self.pendingDecision is not None: - raise ValueError("Only needsInput workflows may contain a pending decision") - if self.status == "abstained" and not any( - record.status == "abstain" for record in active_records - ): - raise ValueError("abstained workflows require an active abstain decision") - return self - - def invalidated_decision_record_ids(self) -> set[str]: - """Return records invalidated by accepted revision requests.""" - return { - record_id - for revision in self.revisionRequests - for record_id in revision.invalidatesDecisionRecordIds - } - - def active_decision_records(self) -> list[DecisionRecord]: - """Return active records in their original transition order.""" - superseded = { - record.supersedes - for record in self.decisionRecords - if record.supersedes is not None - } - invalidated = self.invalidated_decision_record_ids() - inactive = superseded | invalidated - return [ - record for record in self.decisionRecords if record.recordId not in inactive - ] - - class DeterministicDecisionAuditor: """Cross-check a decision against its authoritative spec and evidence bundle.""" @@ -872,7 +505,7 @@ def audit( record: DecisionRecord, *, created_at_ns: int = 0, - ) -> VerificationRecord: + ) -> list[VerificationCheck]: """Return a deterministic verification without repairing invalid output.""" checks: list[VerificationCheck] = [] evidence_sha256 = ( @@ -1016,16 +649,7 @@ def add_check( record.overrideEvidenceIds, ) - verification_status: VerificationStatus = ( - "failed" if any(check.status == "failed" for check in checks) else "passed" - ) - return VerificationRecord( - verificationId=f"verification:{record.recordId}", - decisionRecordId=record.recordId, - status=verification_status, - checks=checks, - createdAtNs=created_at_ns, - ) + return checks __all__ = [ @@ -1037,16 +661,12 @@ def add_check( "DecisionSource", "DecisionSpec", "DecisionStatus", - "DecisionWorkflowRun", "DecisionWorkflowStatus", "DeterministicDecisionAuditor", "EvidenceBundle", "EvidenceClass", - "PendingDecision", "ProtectedVariableEffect", "ProtectedVariableEffectStatus", - "RevisionRequest", "VerificationCheck", - "VerificationRecord", "VerificationStatus", ] diff --git a/scarf/agent/decisions/rna.py b/scarf/agent/decisions/rna.py index d82fb9c8..8866b7ea 100644 --- a/scarf/agent/decisions/rna.py +++ b/scarf/agent/decisions/rna.py @@ -5,6 +5,7 @@ from pydantic import ConfigDict, Field, field_validator, model_validator +from ..cell_quality.profiles import CellQualityProfile from ..types import AgentDataModel from .kernel import ( DecisionOption, @@ -13,82 +14,11 @@ DecisionStatus, DeterministicDecisionAuditor, EvidenceBundle, - VerificationRecord, + VerificationCheck, ) -type RnaDecisionCheckpoint = Literal[ - "qcGrouping", - "cellQuality", - "featurePolicy", - "hvgRanking", - "hvgCount", - "pcaPrefix", - "correctionLicense", - "correctionNeed", - "correctionOutcome", - "graphK", - "clusterPartition", -] -type RnaWorkflowNode = Literal[ - "qcGrouping", - "cellQuality", - "featurePolicy", - "hvgRanking", - "hvgCount", - "pcaPrefix", - "correctionLicense", - "correctionNeed", - "correctionOutcome", - "graphK", - "clusterPartition", - "finalize", -] -type DecisionTerminalStatus = Literal["needsInput", "abstained"] +type RnaDecisionCheckpoint = Literal["qcGrouping", "cellQuality"] type QcGroupingMode = Literal["global", "physicalCapture", "pooledReference"] -type HvgRankingMode = Literal["global", "batchAware"] -type CellQualityProfile = Literal[ - "retainWithFlags", - "globalMad5", - "captureMad5", - "captureMad3Sensitivity", - "pooledReferenceMad5", -] -type ConditionalGeneFamily = Literal[ - "mitochondrial", - "ribosomal", - "mitoribosomal", - "histone", - "hla", - "h2", - "hemoglobin", - "immuneReceptor", - "cellCycle", - "stress", - "dissociation", - "sexLinked", -] -type CorrectionLicense = Literal[ - "safe", - "unsafeConfounded", - "indeterminate", - "notApplicable", -] -type CorrectionNeed = Literal["needed", "notNeeded", "indeterminate"] - -_CHECKPOINT_ORDER: tuple[RnaWorkflowNode, ...] = ( - "qcGrouping", - "cellQuality", - "featurePolicy", - "hvgRanking", - "hvgCount", - "pcaPrefix", - "correctionLicense", - "correctionNeed", - "correctionOutcome", - "graphK", - "clusterPartition", - "finalize", -) class RnaDecisionGateError(ValueError): @@ -128,6 +58,18 @@ def validate_profile(self) -> "CellQualityExecutorPayload": if self.groupByCapture or self.pooledReference or self.sensitivityOnly: raise ValueError("retainWithFlags cannot enable filtering modes") return self + if self.profile in {"coreGlobalGaussian", "coreSampleMad3"}: + if any(value is not None for value in thresholds): + raise ValueError( + "Core profiles use exact core bounds, not one-sided MAD fields" + ) + if self.groupByCapture != (self.profile == "coreSampleMad3"): + raise ValueError("The core sample profile requires capture grouping") + if self.pooledReference or self.sensitivityOnly: + raise ValueError( + "Core profiles cannot enable registered filtering modes" + ) + return self if any(value is None for value in thresholds): raise ValueError("Filtering profiles require all three MAD thresholds") if self.profile.startswith("captureMad") != self.groupByCapture: @@ -146,104 +88,6 @@ class QcGroupingExecutorPayload(RnaRegistryModel): groupingMode: QcGroupingMode -class HvgRankingExecutorPayload(RnaRegistryModel): - """Exact variability-ranking route used to construct HVG candidates.""" - - operation: Literal["hvgRanking"] = "hvgRanking" - rankingMode: HvgRankingMode - - -class HvgExecutorPayload(RnaRegistryModel): - """Exact HVG count and ranking mode owned by the executor.""" - - operation: Literal["hvgSelection"] = "hvgSelection" - topN: int = Field(ge=1, strict=True) - rankingMode: HvgRankingMode - - -class FeaturePolicyExecutorPayload(RnaRegistryModel): - """Exact conditional family policy for representation features.""" - - operation: Literal["featurePolicy"] = "featurePolicy" - policy: Literal[ - "keepAll", - "excludeScarfDefaults", - "excludeEligibleBundle", - ] - excludedFamilies: list[ConditionalGeneFamily] = Field(default_factory=list) - useScarfDefaultBlacklist: bool = Field(default=False, strict=True) - - @model_validator(mode="after") - def validate_policy(self) -> "FeaturePolicyExecutorPayload": - if len(self.excludedFamilies) != len(set(self.excludedFamilies)): - raise ValueError("excludedFamilies must not contain duplicates") - if self.policy == "keepAll" and ( - self.excludedFamilies or self.useScarfDefaultBlacklist - ): - raise ValueError("keepAll cannot exclude gene families") - if self.policy == "excludeScarfDefaults": - if self.excludedFamilies or not self.useScarfDefaultBlacklist: - raise ValueError( - "excludeScarfDefaults requires only the Scarf default blacklist" - ) - if self.policy == "excludeEligibleBundle" and not self.excludedFamilies: - raise ValueError("excludeEligibleBundle requires gene families") - if self.policy == "excludeEligibleBundle" and self.useScarfDefaultBlacklist: - raise ValueError( - "excludeEligibleBundle cannot silently add the Scarf defaults" - ) - return self - - -class PcaPrefixExecutorPayload(RnaRegistryModel): - """Exact PCA prefix owned by the executor.""" - - operation: Literal["pcaPrefix"] = "pcaPrefix" - dimensions: int = Field(ge=2, le=50, strict=True) - - -class CorrectionLicensePayload(RnaRegistryModel): - """Deterministic correction authorization derived from design evidence.""" - - operation: Literal["correctionLicense"] = "correctionLicense" - license: CorrectionLicense - - -class CorrectionNeedPayload(RnaRegistryModel): - """Observed need for correction in an uncorrected representation.""" - - operation: Literal["correctionNeed"] = "correctionNeed" - need: CorrectionNeed - - -class CorrectionOutcomeExecutorPayload(RnaRegistryModel): - """Exact native or Harmony representation route.""" - - operation: Literal["correctionOutcome"] = "correctionOutcome" - outcome: Literal["retainNative", "acceptHarmony"] - useHarmony: bool = Field(strict=True) - - @model_validator(mode="after") - def validate_outcome(self) -> "CorrectionOutcomeExecutorPayload": - if (self.outcome == "acceptHarmony") != self.useHarmony: - raise ValueError("acceptHarmony and useHarmony must agree") - return self - - -class GraphExecutorPayload(RnaRegistryModel): - """Exact graph neighborhood size owned by the executor.""" - - operation: Literal["graphK"] = "graphK" - neighborsK: int = Field(ge=2, le=41, strict=True) - - -class ClusterExecutorPayload(RnaRegistryModel): - """Exact Leiden resolution owned by the executor.""" - - operation: Literal["clusterResolution"] = "clusterResolution" - leidenResolution: float = Field(gt=0, le=1.5, strict=True) - - class NoExecutionPayload(RnaRegistryModel): """Typed terminal or pause outcome with no analytical operation.""" @@ -255,18 +99,7 @@ class NoExecutionPayload(RnaRegistryModel): type RnaOptionPayload = Annotated[ - QcGroupingExecutorPayload - | CellQualityExecutorPayload - | HvgRankingExecutorPayload - | HvgExecutorPayload - | FeaturePolicyExecutorPayload - | PcaPrefixExecutorPayload - | CorrectionLicensePayload - | CorrectionNeedPayload - | CorrectionOutcomeExecutorPayload - | GraphExecutorPayload - | ClusterExecutorPayload - | NoExecutionPayload, + QcGroupingExecutorPayload | CellQualityExecutorPayload | NoExecutionPayload, Field(discriminator="operation"), ] @@ -321,149 +154,6 @@ def executor_option(self, option_id: str) -> RnaExecutorOption: raise KeyError(f"Unknown option ID for {self.spec.decisionId}: {option_id}") -class RnaDecisionTransition(RnaRegistryModel): - """One option-status transition in the fixed acyclic RNA graph.""" - - fromCheckpoint: RnaDecisionCheckpoint - onStatus: DecisionStatus - toCheckpoint: RnaWorkflowNode | None = None - terminalStatus: DecisionTerminalStatus | None = None - - @model_validator(mode="after") - def validate_destination(self) -> "RnaDecisionTransition": - if (self.toCheckpoint is None) == (self.terminalStatus is None): - raise ValueError( - "A transition requires exactly one checkpoint or terminal destination" - ) - return self - - -class RnaDecisionTransitionGraph(RnaRegistryModel): - """Ordered graph that rejects cycles and ambiguous transitions.""" - - orderedNodes: tuple[RnaWorkflowNode, ...] = _CHECKPOINT_ORDER - transitions: list[RnaDecisionTransition] = Field(default_factory=list) - - @model_validator(mode="after") - def validate_graph(self) -> "RnaDecisionTransitionGraph": - if self.orderedNodes != _CHECKPOINT_ORDER: - raise ValueError("orderedNodes must use the v1 RNA checkpoint order") - positions = {node: position for position, node in enumerate(self.orderedNodes)} - triggers: set[tuple[RnaDecisionCheckpoint, DecisionStatus]] = set() - for transition in self.transitions: - trigger = (transition.fromCheckpoint, transition.onStatus) - if trigger in triggers: - raise ValueError( - "Transitions must have unique checkpoint/status triggers" - ) - triggers.add(trigger) - if transition.toCheckpoint is not None and ( - positions[transition.toCheckpoint] - <= positions[transition.fromCheckpoint] - ): - raise ValueError("RNA decision transitions must point strictly forward") - return self - - def resolve( - self, checkpoint: RnaDecisionCheckpoint, status: DecisionStatus - ) -> tuple[RnaWorkflowNode | None, DecisionTerminalStatus | None]: - """Resolve one exact checkpoint/status transition.""" - for transition in self.transitions: - if ( - transition.fromCheckpoint == checkpoint - and transition.onStatus == status - ): - return transition.toCheckpoint, transition.terminalStatus - raise KeyError(f"No RNA transition for {checkpoint}/{status}") - - -def _transition( - checkpoint: RnaDecisionCheckpoint, - status: DecisionStatus, - *, - to: RnaWorkflowNode | None = None, - terminal: DecisionTerminalStatus | None = None, -) -> RnaDecisionTransition: - return RnaDecisionTransition( - fromCheckpoint=checkpoint, - onStatus=status, - toCheckpoint=to, - terminalStatus=terminal, - ) - - -RNA_DECISION_TRANSITION_GRAPH = RnaDecisionTransitionGraph( - transitions=[ - _transition("qcGrouping", "apply", to="cellQuality"), - _transition("qcGrouping", "defer", terminal="needsInput"), - _transition("cellQuality", "apply", to="featurePolicy"), - _transition("cellQuality", "skip", to="featurePolicy"), - _transition("cellQuality", "defer", terminal="needsInput"), - _transition("featurePolicy", "apply", to="hvgRanking"), - _transition("featurePolicy", "skip", to="hvgRanking"), - _transition("featurePolicy", "defer", terminal="needsInput"), - _transition("hvgRanking", "apply", to="hvgCount"), - _transition("hvgRanking", "defer", terminal="needsInput"), - _transition("hvgCount", "apply", to="pcaPrefix"), - _transition("hvgCount", "defer", terminal="needsInput"), - _transition("pcaPrefix", "apply", to="correctionLicense"), - _transition("pcaPrefix", "defer", terminal="needsInput"), - _transition("correctionLicense", "apply", to="correctionNeed"), - _transition("correctionLicense", "skip", to="correctionOutcome"), - _transition("correctionLicense", "defer", terminal="needsInput"), - _transition("correctionNeed", "apply", to="correctionOutcome"), - _transition("correctionNeed", "skip", to="correctionOutcome"), - _transition("correctionNeed", "defer", terminal="needsInput"), - _transition("correctionOutcome", "apply", to="graphK"), - _transition("correctionOutcome", "skip", to="graphK"), - _transition("correctionOutcome", "defer", terminal="needsInput"), - _transition("graphK", "apply", to="clusterPartition"), - _transition("graphK", "defer", terminal="needsInput"), - _transition("clusterPartition", "apply", to="finalize"), - _transition("clusterPartition", "defer", terminal="needsInput"), - _transition("clusterPartition", "abstain", terminal="abstained"), - ] -) - - -class RnaDecisionRegistry(RnaRegistryModel): - """Ordered definitions for one concrete RNA decision run.""" - - definitions: list[RnaDecisionDefinition] = Field(default_factory=list) - transitionGraph: RnaDecisionTransitionGraph = RNA_DECISION_TRANSITION_GRAPH - - @model_validator(mode="after") - def validate_definitions(self) -> "RnaDecisionRegistry": - decision_ids = [definition.spec.decisionId for definition in self.definitions] - checkpoints = [definition.checkpoint for definition in self.definitions] - if len(decision_ids) != len(set(decision_ids)): - raise ValueError("Registry decision IDs must be unique") - if len(checkpoints) != len(set(checkpoints)): - raise ValueError("Registry checkpoints must be unique") - positions = { - node: position - for position, node in enumerate(self.transitionGraph.orderedNodes) - } - if checkpoints != sorted(checkpoints, key=positions.__getitem__): - raise ValueError("Registry definitions must follow RNA checkpoint order") - for definition in self.definitions: - for option in definition.spec.options: - try: - self.transitionGraph.resolve(definition.checkpoint, option.status) - except KeyError as exc: - raise ValueError( - f"No transition for {definition.checkpoint}/{option.status}" - ) from exc - return self - - def definition(self, checkpoint: RnaDecisionCheckpoint) -> RnaDecisionDefinition: - """Return the exact definition registered for a checkpoint.""" - for definition in self.definitions: - if definition.checkpoint == checkpoint: - return definition - raise KeyError(f"No RNA decision definition for {checkpoint}") - - class CompiledRnaDecision(RnaRegistryModel): """Verified executor handoff kept separate from agent-authored records.""" @@ -472,7 +162,7 @@ class CompiledRnaDecision(RnaRegistryModel): selectedOptionId: str status: DecisionStatus executorPayload: RnaOptionPayload - verification: VerificationRecord + checks: list[VerificationCheck] def compile_rna_decision( @@ -490,16 +180,12 @@ def compile_rna_decision( created_at_ns=created_at_ns, ) failed_checks = [ - check.checkId for check in verification.checks if check.status == "failed" + check.checkId for check in verification if check.status == "failed" ] if failed_checks: raise RnaDecisionCompilationError( "Decision failed deterministic verification: " + ", ".join(failed_checks) ) - if record.verificationId != verification.verificationId: - raise RnaDecisionCompilationError( - "DecisionRecord must reference its deterministic verification ID" - ) executor_option = definition.executor_option(record.selectedOptionId) return CompiledRnaDecision( decisionRecordId=record.recordId, @@ -507,7 +193,7 @@ def compile_rna_decision( selectedOptionId=record.selectedOptionId, status=record.status, executorPayload=executor_option.payload, - verification=verification, + checks=verification, ) @@ -538,9 +224,6 @@ def _build_definition( visible_options: list[DecisionOption], executor_options: list[RnaExecutorOption], baseline_option_id: str | None, - metric_preferred_option_id: str | None = None, - require_override_evidence: bool = False, - allowed_sources: list[Literal["rule", "agent", "human"]] | None = None, ) -> RnaDecisionDefinition: return RnaDecisionDefinition( checkpoint=checkpoint, @@ -552,9 +235,6 @@ def _build_definition( evidenceBundleId=evidence_bundle_id, options=visible_options, baselineOptionId=baseline_option_id, - metricPreferredOptionId=metric_preferred_option_id, - requireIndependentOverrideEvidence=require_override_evidence, - allowedSources=allowed_sources or ["rule", "agent", "human"], ), executorOptions=executor_options, ) @@ -678,6 +358,30 @@ def build_cell_quality_decision( sensitivityOnly=False, ), ), + "coreGlobalGaussian": ( + "cellQuality:coreGlobalGaussian", + "apply", + "Scarf default global filter", + "Use Scarf's default global Gaussian quantiles (0.01 and 0.99).", + CellQualityExecutorPayload( + profile="coreGlobalGaussian", + groupByCapture=False, + pooledReference=False, + sensitivityOnly=False, + ), + ), + "coreSampleMad3": ( + "cellQuality:coreSampleMad3", + "apply", + "Scarf sample-aware default", + "Use Scarf's exact sample-aware filter with three scaled MADs within proven physical captures.", + CellQualityExecutorPayload( + profile="coreSampleMad3", + groupByCapture=True, + pooledReference=False, + sensitivityOnly=False, + ), + ), "globalMad5": ( "cellQuality:globalMad5", "apply", @@ -762,775 +466,31 @@ def build_cell_quality_decision( checkpoint="cellQuality", decision_id="cellQuality", evidence_bundle_id=evidence_bundle_id, - question="Which registered cell-quality profile preserves valid biology?", + question="Which cell-quality policy preserves valid biology?", visible_options=visible, executor_options=executor, baseline_option_id=( - "cellQuality:retainWithFlags" + "cellQuality:coreGlobalGaussian" + if "coreGlobalGaussian" in available_profiles + else "cellQuality:retainWithFlags" if "retainWithFlags" in available_profiles else profiles[0][0] ), ) -def build_hvg_ranking_decision( - *, - evidence_bundle_id: str, - batch_aware_eligible: bool, -) -> RnaDecisionDefinition: - """Build exact global and, when licensed, technical-group HVG rankings.""" - rows: list[tuple[str, str, str, HvgRankingMode]] = [ - ( - "hvgRanking:global", - "Global variability ranking", - "Rank genes by corrected variability across all selected cells.", - "global", - ) - ] - if batch_aware_eligible: - rows.append( - ( - "hvgRanking:batchAware", - "Technical-group recurrence ranking", - "Rank genes by recurrence and within-group rank across valid groups.", - "batchAware", - ) - ) - visible = [ - DecisionOption( - optionId=option_id, - status="apply", - label=label, - description=description, - requiredEvidenceClasses=["technical"], - ) - for option_id, label, description, _mode in rows - ] - executor = [ - RnaExecutorOption( - checkpoint="hvgRanking", - optionId=option_id, - payload=HvgRankingExecutorPayload(rankingMode=mode), - ) - for option_id, _label, _description, mode in rows - ] - defer_visible, defer_executor = _defer_option("hvgRanking", "hvgRanking:defer") - visible.append(defer_visible) - executor.append(defer_executor) - return _build_definition( - checkpoint="hvgRanking", - decision_id="hvgRanking", - evidence_bundle_id=evidence_bundle_id, - question="Which registered variability ranking is supported by the design?", - visible_options=visible, - executor_options=executor, - baseline_option_id="hvgRanking:global", - ) - - -def _bounded_options( - *, - maximum: int, - fixed: list[tuple[str, str, int]], - maximum_id: str, - maximum_label: str, -) -> list[tuple[str, str, int]]: - if maximum < 1: - raise ValueError("maximum must be positive") - bounded = [item for item in fixed if item[2] <= maximum] - fixed_values = {value for _option_id, _label, value in bounded} - if maximum < fixed[-1][2] and maximum not in fixed_values: - bounded.append((maximum_id, maximum_label, maximum)) - return bounded - - -def _baseline_for_value(options: list[tuple[str, str, int]], desired_value: int) -> str: - return min(options, key=lambda item: (abs(item[2] - desired_value), item[2]))[0] - - -def build_hvg_count_decision( - *, - evidence_bundle_id: str, - eligible_feature_count: int, - ranking_mode: HvgRankingMode, - valid_technical_groups: int = 0, - candidate_counts: Sequence[int] | None = None, -) -> RnaDecisionDefinition: - """Build capped HVG counts with the ranking route fixed by capabilities.""" - if eligible_feature_count < 2: - raise RnaDecisionGateError("HVG selection requires at least two eligible genes") - if ranking_mode == "batchAware" and valid_technical_groups < 2: - raise RnaDecisionGateError( - "Batch-aware HVGs require at least two valid technical groups" - ) - if candidate_counts is None: - candidates = _bounded_options( - maximum=eligible_feature_count, - fixed=[ - ("hvgCount:focused", "Focused HVG set", 1000), - ("hvgCount:standard", "Standard HVG set", 2000), - ("hvgCount:broad", "Broad HVG set", 4000), - ], - maximum_id="hvgCount:allEligible", - maximum_label="All eligible genes", - ) - else: - effective_counts: list[int] = [] - for value in candidate_counts: - if isinstance(value, bool) or not isinstance(value, int) or value < 1: - raise RnaDecisionGateError( - "HVG candidate counts must be positive integers" - ) - effective = min(value, eligible_feature_count) - if effective not in effective_counts: - effective_counts.append(effective) - if not effective_counts: - raise RnaDecisionGateError("At least one HVG candidate is required") - known = { - 1000: ("hvgCount:focused", "Focused HVG set"), - 2000: ("hvgCount:standard", "Standard HVG set"), - 4000: ("hvgCount:broad", "Broad HVG set"), - } - candidates = [ - ( - known.get(value, (f"hvgCount:n{value}", f"{value} HVGs"))[0], - known.get(value, (f"hvgCount:n{value}", f"{value} HVGs"))[1], - value, - ) - for value in effective_counts - ] - visible = [ - DecisionOption( - optionId=option_id, - status="apply", - label=label, - description="Use this registered HVG count for representation diagnostics.", - requiredEvidenceClasses=["technical"], - ) - for option_id, label, _top_n in candidates - ] - executor = [ - RnaExecutorOption( - checkpoint="hvgCount", - optionId=option_id, - payload=HvgExecutorPayload(topN=top_n, rankingMode=ranking_mode), - ) - for option_id, _label, top_n in candidates - ] - defer_visible, defer_executor = _defer_option("hvgCount", "hvgCount:defer") - visible.append(defer_visible) - executor.append(defer_executor) - baseline = _baseline_for_value(candidates, min(2000, eligible_feature_count)) - return _build_definition( - checkpoint="hvgCount", - decision_id="hvgCount", - evidence_bundle_id=evidence_bundle_id, - question="Which registered HVG count retains reproducible signal?", - visible_options=visible, - executor_options=executor, - baseline_option_id=baseline, - ) - - -def build_feature_policy_decision( - *, - evidence_bundle_id: str, - proposed_exclusion_families: list[ConditionalGeneFamily], - dominant_families: list[ConditionalGeneFamily], - protected_families: list[ConditionalGeneFamily], - scarf_default_eligible: bool = False, -) -> RnaDecisionDefinition: - """Build exact representation-only feature-policy alternatives.""" - for field_name, values in ( - ("proposed_exclusion_families", proposed_exclusion_families), - ("dominant_families", dominant_families), - ("protected_families", protected_families), - ): - if len(values) != len(set(values)): - raise RnaDecisionGateError(f"{field_name} must not contain duplicates") - proposed = set(proposed_exclusion_families) - if not proposed.issubset(dominant_families): - raise RnaDecisionGateError( - "Conditional exclusion requires observed dominance evidence" - ) - protected_overlap = proposed.intersection(protected_families) - if protected_overlap: - blocked = ", ".join(sorted(protected_overlap)) - raise RnaDecisionGateError( - f"Objective-protected gene families cannot be excluded: {blocked}" - ) - - visible = [ - DecisionOption( - optionId="featurePolicy:keepAll", - status="skip", - label="Keep conditional families", - description="Keep all conditional biological gene families in representation.", - requiredEvidenceClasses=["technical"], - ) - ] - executor = [ - RnaExecutorOption( - checkpoint="featurePolicy", - optionId="featurePolicy:keepAll", - payload=FeaturePolicyExecutorPayload(policy="keepAll", excludedFamilies=[]), - ) - ] - if scarf_default_eligible: - visible.append( - DecisionOption( - optionId="featurePolicy:excludeScarfDefaults", - status="apply", - label="Use the Scarf default blacklist", - description=( - "Exclude the exact core Scarf default blacklist from " - "representation only." - ), - requiredEvidenceClasses=["technical"], - ) - ) - executor.append( - RnaExecutorOption( - checkpoint="featurePolicy", - optionId="featurePolicy:excludeScarfDefaults", - payload=FeaturePolicyExecutorPayload( - policy="excludeScarfDefaults", - useScarfDefaultBlacklist=True, - ), - ) - ) - if proposed_exclusion_families: - visible.append( - DecisionOption( - optionId="featurePolicy:excludeEligibleBundle", - status="apply", - label="Exclude eligible nuisance bundle", - description=( - "Exclude the one deterministic nuisance-family bundle from " - "representation only." - ), - requiredEvidenceClasses=["technical"], - ) - ) - executor.append( - RnaExecutorOption( - checkpoint="featurePolicy", - optionId="featurePolicy:excludeEligibleBundle", - payload=FeaturePolicyExecutorPayload( - policy="excludeEligibleBundle", - excludedFamilies=proposed_exclusion_families, - ), - ) - ) - defer_visible, defer_executor = _defer_option( - "featurePolicy", "featurePolicy:defer" - ) - visible.append(defer_visible) - executor.append(defer_executor) - return _build_definition( - checkpoint="featurePolicy", - decision_id="featurePolicy", - evidence_bundle_id=evidence_bundle_id, - question="Should the eligible nuisance-family bundle leave representation?", - visible_options=visible, - executor_options=executor, - baseline_option_id="featurePolicy:keepAll", - ) - - -def build_pca_prefix_decision( - *, - evidence_bundle_id: str, - matrix_rank: int, - candidate_dimensions: Sequence[int] | None = None, -) -> RnaDecisionDefinition: - """Build PCA prefixes capped by rank and by the v1 fifty-PC limit.""" - maximum = min(matrix_rank, 50) - if maximum < 2: - raise RnaDecisionGateError("PCA requires matrix rank of at least two") - if candidate_dimensions is None: - candidates = _bounded_options( - maximum=maximum, - fixed=[ - ("pcaPrefix:short", "Short PCA prefix", 10), - ("pcaPrefix:standard", "Standard PCA prefix", 20), - ("pcaPrefix:extended", "Extended PCA prefix", 30), - ("pcaPrefix:maximum", "Maximum PCA prefix", 50), - ], - maximum_id="pcaPrefix:maximumAvailable", - maximum_label="Maximum available PCA prefix", - ) - else: - values: list[int] = [] - for value in candidate_dimensions: - if isinstance(value, bool) or not isinstance(value, int) or value < 2: - raise RnaDecisionGateError( - "PCA candidate dimensions must be integers of at least two" - ) - effective = min(value, maximum) - if effective not in values: - values.append(effective) - known = { - 10: ("pcaPrefix:short", "Short PCA prefix"), - 20: ("pcaPrefix:standard", "Standard PCA prefix"), - 30: ("pcaPrefix:extended", "Extended PCA prefix"), - 50: ("pcaPrefix:maximum", "Maximum PCA prefix"), - } - candidates = [ - ( - known.get(value, (f"pcaPrefix:n{value}", f"{value} PCs"))[0], - known.get(value, (f"pcaPrefix:n{value}", f"{value} PCs"))[1], - value, - ) - for value in values - ] - if not candidates: - raise RnaDecisionGateError("At least one PCA candidate is required") - visible = [ - DecisionOption( - optionId=option_id, - status="apply", - label=label, - description="Use this registered prefix of the single computed PCA.", - requiredEvidenceClasses=["geometric", "technical"], - ) - for option_id, label, _dimensions in candidates - ] - executor = [ - RnaExecutorOption( - checkpoint="pcaPrefix", - optionId=option_id, - payload=PcaPrefixExecutorPayload(dimensions=dimensions), - ) - for option_id, _label, dimensions in candidates - ] - defer_visible, defer_executor = _defer_option("pcaPrefix", "pcaPrefix:defer") - visible.append(defer_visible) - executor.append(defer_executor) - baseline = _baseline_for_value(candidates, min(20, maximum)) - return _build_definition( - checkpoint="pcaPrefix", - decision_id="pcaPrefix", - evidence_bundle_id=evidence_bundle_id, - question="What is the smallest registered PCA prefix that stabilizes topology?", - visible_options=visible, - executor_options=executor, - baseline_option_id=baseline, - ) - - -def build_correction_license_decision( - *, evidence_bundle_id: str, license: CorrectionLicense -) -> RnaDecisionDefinition: - """Record the one correction license authorized by deterministic design checks.""" - statuses: dict[CorrectionLicense, DecisionStatus] = { - "safe": "apply", - "unsafeConfounded": "skip", - "indeterminate": "defer", - "notApplicable": "skip", - } - option_id = f"correctionLicense:{license}" - visible = [ - DecisionOption( - optionId=option_id, - status=statuses[license], - label="Correction design license", - description="Use the exact correction license produced by design validation.", - requiredEvidenceClasses=["design"], - ) - ] - executor = [ - RnaExecutorOption( - checkpoint="correctionLicense", - optionId=option_id, - payload=CorrectionLicensePayload(license=license), - ) - ] - return _build_definition( - checkpoint="correctionLicense", - decision_id="correctionLicense", - evidence_bundle_id=evidence_bundle_id, - question="Does the experimental design authorize batch correction?", - visible_options=visible, - executor_options=executor, - baseline_option_id=option_id, - allowed_sources=["rule"], - ) - - -def build_correction_need_decision( - *, evidence_bundle_id: str, license: CorrectionLicense -) -> RnaDecisionDefinition: - """Build correction-need options only after a safe design license.""" - if license != "safe": - raise RnaDecisionGateError( - "Correction need is evaluated only after a safe correction license" - ) - rows: list[tuple[str, DecisionStatus, str, CorrectionNeed]] = [ - ( - "correctionNeed:needed", - "apply", - "Technical separation is present within comparable populations.", - "needed", - ), - ( - "correctionNeed:notNeeded", - "skip", - "The native representation does not show material technical separation.", - "notNeeded", - ), - ( - "correctionNeed:indeterminate", - "defer", - "Available evidence cannot distinguish technical and protected structure.", - "indeterminate", - ), - ] - visible = [ - DecisionOption( - optionId=option_id, - status=status, - label=need, - description=description, - requiredEvidenceClasses=["batchRemoval", "biologicalConservation"] - if need != "indeterminate" - else ["design"], - ) - for option_id, status, description, need in rows - ] - executor = [ - RnaExecutorOption( - checkpoint="correctionNeed", - optionId=option_id, - payload=CorrectionNeedPayload(need=need), - ) - for option_id, _status, _description, need in rows - ] - return _build_definition( - checkpoint="correctionNeed", - decision_id="correctionNeed", - evidence_bundle_id=evidence_bundle_id, - question="Does the native representation show a licensed need for correction?", - visible_options=visible, - executor_options=executor, - baseline_option_id="correctionNeed:notNeeded", - ) - - -def build_correction_outcome_decision( - *, - evidence_bundle_id: str, - license: CorrectionLicense, - need: CorrectionNeed | None = None, - harmony_eligible: bool = True, -) -> RnaDecisionDefinition: - """Build a native baseline and offer Harmony only when licensed and needed.""" - if license == "indeterminate": - raise RnaDecisionGateError( - "Indeterminate correction license must resolve before outcome comparison" - ) - if license == "safe" and need is None: - raise RnaDecisionGateError( - "A safe correction license requires an evaluated correction need" - ) - if license != "safe" and need is not None: - raise RnaDecisionGateError( - "Correction need must not bypass an unsafe or inapplicable license" - ) - if need == "indeterminate": - raise RnaDecisionGateError( - "Indeterminate correction need must resolve before outcome comparison" - ) - - visible = [ - DecisionOption( - optionId="correctionOutcome:retainNative", - status="skip", - label="Retain native representation", - description="Keep the mandatory uncorrected representation baseline.", - requiredEvidenceClasses=["biologicalConservation"], - ) - ] - executor = [ - RnaExecutorOption( - checkpoint="correctionOutcome", - optionId="correctionOutcome:retainNative", - payload=CorrectionOutcomeExecutorPayload( - outcome="retainNative", useHarmony=False - ), - ) - ] - offer_harmony = license == "safe" and need == "needed" and harmony_eligible - if offer_harmony: - visible.append( - DecisionOption( - optionId="correctionOutcome:acceptHarmony", - status="apply", - label="Accept Harmony", - description="Use the matched Harmony representation branch.", - requiredEvidenceClasses=[ - "batchRemoval", - "biologicalConservation", - "protectedVariablePreservation", - ], - ) - ) - executor.append( - RnaExecutorOption( - checkpoint="correctionOutcome", - optionId="correctionOutcome:acceptHarmony", - payload=CorrectionOutcomeExecutorPayload( - outcome="acceptHarmony", useHarmony=True - ), - ) - ) - defer_visible, defer_executor = _defer_option( - "correctionOutcome", "correctionOutcome:indeterminate" - ) - visible.append(defer_visible) - executor.append(defer_executor) - allowed_sources: list[Literal["rule", "agent", "human"]] = ( - ["rule", "agent", "human"] if offer_harmony else ["rule"] - ) - return _build_definition( - checkpoint="correctionOutcome", - decision_id="correctionOutcome", - evidence_bundle_id=evidence_bundle_id, - question="Should the verified final representation remain native or use Harmony?", - visible_options=visible, - executor_options=executor, - baseline_option_id="correctionOutcome:retainNative", - allowed_sources=allowed_sources, - ) - - -def build_graph_k_decision( - *, - evidence_bundle_id: str, - n_cells: int, - candidate_neighbors: Sequence[int] | None = None, -) -> RnaDecisionDefinition: - """Build graph scales capped by the selected cell count.""" - maximum = min(n_cells - 1, 41) - if maximum < 2: - raise RnaDecisionGateError("Graph construction requires at least three cells") - if candidate_neighbors is None: - candidates = _bounded_options( - maximum=maximum, - fixed=[ - ("graphScale:local", "Local graph", 11), - ("graphScale:balanced", "Balanced graph", 21), - ("graphScale:broad", "Broad graph", 41), - ], - maximum_id="graphScale:maximumAvailable", - maximum_label="Maximum available graph", - ) - else: - values: list[int] = [] - for value in candidate_neighbors: - if isinstance(value, bool) or not isinstance(value, int) or value < 2: - raise RnaDecisionGateError( - "Graph candidates must be integers of at least two" - ) - effective = min(value, maximum) - if effective not in values: - values.append(effective) - known = { - 11: ("graphScale:local", "Local graph"), - 21: ("graphScale:balanced", "Balanced graph"), - 41: ("graphScale:broad", "Broad graph"), - } - candidates = [ - ( - known.get(value, (f"graphScale:k{value}", f"{value}-neighbor graph"))[ - 0 - ], - known.get(value, (f"graphScale:k{value}", f"{value}-neighbor graph"))[ - 1 - ], - value, - ) - for value in values - ] - if not candidates: - raise RnaDecisionGateError("At least one graph candidate is required") - visible = [ - DecisionOption( - optionId=option_id, - status="apply", - label=label, - description="Use this registered neighborhood scale for graph diagnostics.", - requiredEvidenceClasses=["geometric"], - ) - for option_id, label, _neighbors in candidates - ] - executor = [ - RnaExecutorOption( - checkpoint="graphK", - optionId=option_id, - payload=GraphExecutorPayload(neighborsK=neighbors), - ) - for option_id, _label, neighbors in candidates - ] - defer_visible, defer_executor = _defer_option("graphK", "graphScale:defer") - visible.append(defer_visible) - executor.append(defer_executor) - baseline = _baseline_for_value(candidates, min(21, maximum)) - return _build_definition( - checkpoint="graphK", - decision_id="graphK", - evidence_bundle_id=evidence_bundle_id, - question="Which registered graph scale is stable and locally informative?", - visible_options=visible, - executor_options=executor, - baseline_option_id=baseline, - ) - - -def build_cluster_partition_decision( - *, - evidence_bundle_id: str, - metric_preferred_option_id: str, - resolution_candidates: Sequence[float] | None = None, -) -> RnaDecisionDefinition: - """Build fixed Leiden resolutions plus explicit defer and abstain outcomes.""" - default_rows: list[tuple[str, str, float]] = [ - ("clusterResolution:veryCoarse", "Very coarse partition", 0.25), - ("clusterResolution:coarse", "Coarse partition", 0.5), - ("clusterResolution:balanced", "Balanced partition", 0.75), - ("clusterResolution:detailed", "Detailed partition", 1.0), - ("clusterResolution:fine", "Fine partition", 1.25), - ("clusterResolution:veryFine", "Very fine partition", 1.5), - ] - if resolution_candidates is None: - rows = default_rows - else: - known = { - resolution: (option_id, label) - for option_id, label, resolution in default_rows - } - rows = [] - seen: set[float] = set() - for raw in resolution_candidates: - resolution = float(raw) - if not 0 < resolution <= 1.5 or resolution in seen: - raise RnaDecisionGateError( - "Cluster resolutions must be unique values in (0, 1.5]" - ) - seen.add(resolution) - token = str(resolution).replace(".", "p") - option_id, label = known.get( - resolution, - ( - f"clusterResolution:r{token}", - f"Leiden resolution {resolution:g}", - ), - ) - rows.append((option_id, label, resolution)) - if not rows: - raise RnaDecisionGateError("At least one cluster resolution is required") - resolution_ids = [option_id for option_id, _label, _resolution in rows] - if metric_preferred_option_id not in resolution_ids: - raise RnaDecisionGateError( - "metric_preferred_option_id must be a registered resolution option" - ) - baseline_option_id = min( - rows, - key=lambda item: (abs(item[2] - 0.75), item[2]), - )[0] - visible = [ - DecisionOption( - optionId=option_id, - status="apply", - label=label, - description="Use this registered Leiden resolution.", - requiredEvidenceClasses=["geometric"], - ) - for option_id, label, _resolution in rows - ] - executor = [ - RnaExecutorOption( - checkpoint="clusterPartition", - optionId=option_id, - payload=ClusterExecutorPayload(leidenResolution=resolution), - ) - for option_id, _label, resolution in rows - ] - defer_visible, defer_executor = _defer_option( - "clusterPartition", "clusterPartition:defer" - ) - visible.append(defer_visible) - executor.append(defer_executor) - visible.append( - DecisionOption( - optionId="clusterPartition:abstain", - status="abstain", - label="Abstain from discrete clustering", - description="Do not claim a defensible discrete partition.", - ) - ) - executor.append( - RnaExecutorOption( - checkpoint="clusterPartition", - optionId="clusterPartition:abstain", - payload=NoExecutionPayload(reasonCode="scientificAbstention"), - ) - ) - return _build_definition( - checkpoint="clusterPartition", - decision_id="clusterPartition", - evidence_bundle_id=evidence_bundle_id, - question="Which registered partition is scientifically defensible?", - visible_options=visible, - executor_options=executor, - baseline_option_id=baseline_option_id, - metric_preferred_option_id=metric_preferred_option_id, - require_override_evidence=True, - ) - - __all__ = [ - "CellQualityProfile", "CellQualityExecutorPayload", - "ClusterExecutorPayload", + "QcGroupingExecutorPayload", "CompiledRnaDecision", - "ConditionalGeneFamily", - "CorrectionLicense", - "CorrectionLicensePayload", - "CorrectionNeed", - "CorrectionNeedPayload", - "CorrectionOutcomeExecutorPayload", - "DecisionTerminalStatus", - "FeaturePolicyExecutorPayload", - "GraphExecutorPayload", - "HvgExecutorPayload", - "HvgRankingExecutorPayload", - "HvgRankingMode", "NoExecutionPayload", - "PcaPrefixExecutorPayload", - "QcGroupingExecutorPayload", - "QcGroupingMode", - "RNA_DECISION_TRANSITION_GRAPH", "RnaDecisionCheckpoint", "RnaDecisionCompilationError", "RnaDecisionDefinition", "RnaDecisionGateError", - "RnaDecisionRegistry", - "RnaDecisionTransition", - "RnaDecisionTransitionGraph", "RnaExecutorOption", "RnaOptionPayload", - "RnaWorkflowNode", "build_cell_quality_decision", - "build_cluster_partition_decision", - "build_correction_license_decision", - "build_correction_need_decision", - "build_correction_outcome_decision", - "build_feature_policy_decision", - "build_graph_k_decision", - "build_hvg_count_decision", - "build_hvg_ranking_decision", - "build_pca_prefix_decision", "build_qc_grouping_decision", "compile_rna_decision", "require_option_evidence", diff --git a/scarf/agent/experimental_context/agent.py b/scarf/agent/experimental_context/agent.py index 46dc7023..82f9aa53 100644 --- a/scarf/agent/experimental_context/agent.py +++ b/scarf/agent/experimental_context/agent.py @@ -13,7 +13,7 @@ from ..config import AgentRunConfig from ..config.agent_exec import run_agent_sync from ..tools import artifact_reference, core_artifact_reference -from ..types import AgentRunInfo, StageStatus +from ..types import StageStatus from .characterization import _SelectionBoundCells, characterize_covariates from .contracts import ( CellQcPlan, @@ -36,9 +36,7 @@ score_current_representation, ) from .validation import ( - _deterministic_experimental_context_decision, failed_experimental_context_result, - pending_experimental_context_result, validate_experimental_context, ) @@ -46,7 +44,7 @@ from ...datastore.pipeline_run import PipelineRun try: - from pydantic_ai import ModelRetry, Tool, UnexpectedModelBehavior + from pydantic_ai import Tool, UnexpectedModelBehavior except ImportError as exc: raise ImportError(AGENT_INSTALL_HINT) from exc @@ -61,13 +59,11 @@ def __init__( model: Any, *, config: AgentRunConfig | None = None, - unattended: bool = False, ) -> None: self.model = model - self.unattended = unattended self.config = (config or AgentRunConfig()).with_limits( request_limit=9, - tool_call_limit=5, + tool_call_limit=6, output_token_limit=32768, timeout_seconds=600.0, ) @@ -78,19 +74,38 @@ def __init__( provided read-only tools and return the structured decision schema. Call inspect_cell_covariates exactly once. Then call - analyze_experimental_design exactly once with all explicit domains, - all biological coefficients, every unit of inference, and the complete - exact batch-column set being considered. You may call + analyze_experimental_design with all explicit domains, all biological + coefficients, every unit of inference, and the complete exact batch + column set. Nominate up to eight comparisons that explain the study + objective: single variables, two-column joint effects, or associations + within categorical strata. A comparison uses at most three observed + columns and a justified observation/independent unit. You may make one + follow-up call with at most four new or revised comparisons after + reading the first evidence. Never split the exact batch-column set. + A donor can carry biological variation and also be the explicitly + declared independent unit; these roles do not conflict. Keep its + biological protection when specifying its unit role. Do not replace + independent donors with cells or samples to obtain a supported result. + Protect objective-relevant pairs of categorical biological variables + with protectCombination. The tools report unsupported designs, + missingness, replication, and sparse strata; do not turn these into + negative findings. Explain unsupported requested comparisons in the + final rationale; a proposed comparison is not a completed analysis. + Continuous conditioning and expression hypothesis + testing are unsupported. You may call score_current_representation at most once when an exact supplied graph - can add evidence. Do not split metadata, coefficients, or batch columns across - calls, and do not repeat a tool call. Pass batch_columns as a JSON array, - including when the array contains exactly one column. Each tool is - removed after it succeeds, so include the complete decision context in - its single call. + can add evidence. Pass batch_columns as a JSON array, including a + singleton. Capture proposals must name an exact observed column and + quote the study statement identifying it as a physical capture. An + optional reference pool also needs an exact quote identifying the + observed reference captures. Sample uniqueness is not capture proof. + Leave unresolved capture provenance explicit. Copy the supported + capture and protected combinations into the final decision. The tools return bounded cell-QC profiles projected against the exact shared cell selection. Do not choose a profile and leave cellQc blank. - A later audited checkpoint selects one registered profile. Never author + A later audited checkpoint compares the Scarf default with eligible + alternatives and selects one exact policy. Never author or alter numeric quality bounds. RNA is the preferred QC driver and ATAC is the fallback. ADT and HTO never drive automatic cell filtering. An exact HTO identity artifact may be used as grouping evidence. It is @@ -108,6 +123,11 @@ def __init__( LISI evaluates a representation; it does not identify which metadata column is a batch. Recommend evaluateHarmony, not application, because Parameter Tuning must compare exact uncorrected and corrected artifacts. + Current Harmony preservation metrics support categorical biology. + A continuous protected variable has unavailable preservation evidence. + Keep this limitation explicit; it does not prove correction unnecessary. + Later matched acceptance must reject unavailable required protection, + and unresolved essential evidence requires input or abstention. Cite only evidenceIds returned by tools. Ask for input when study design cannot be resolved. The study objective is authoritative: use @@ -327,59 +347,13 @@ def run( ) except UnexpectedModelBehavior as exc: model_name = getattr(self.model, "model_name", type(self.model).__name__) - if self.unattended: - try: - decision = _deterministic_experimental_context_decision(deps) - except ( - ModelRetry, - RuntimeError, - TypeError, - ValueError, - ) as fallback_exc: - return failed_experimental_context_result( - deps, - error=exc, - fallback_error=fallback_exc, - model_name=str(model_name), - ) - run_info = AgentRunInfo( - agentName="experimental_context_deterministic", - modelName=str(model_name), - ) - else: - return pending_experimental_context_result( - deps, - error=exc, - model_name=str(model_name), - ) - else: - decision = ExperimentalContextDecision.model_validate(execution.output) - run_info = execution.runInfo - if self.unattended and ( - decision.needsInput or decision.batchCorrection.action == "needsInput" - ): - try: - decision = _deterministic_experimental_context_decision(deps) - except (ModelRetry, RuntimeError, TypeError, ValueError) as fallback_exc: - model_name = getattr( - self.model, "model_name", type(self.model).__name__ - ) - return failed_experimental_context_result( - deps, - error=RuntimeError( - "The model returned an unresolved experimental-context decision" - ), - fallback_error=fallback_exc, - model_name=str(model_name), - ) - run_info = AgentRunInfo( - agentName="experimental_context_deterministic", - modelName=getattr( - self.model, - "model_name", - type(self.model).__name__, - ), + return failed_experimental_context_result( + deps, + error=exc, + model_name=str(model_name), ) + decision = ExperimentalContextDecision.model_validate(execution.output) + run_info = execution.runInfo characterization = deps.characterization if characterization is None: characterization = characterize_covariates( @@ -421,6 +395,18 @@ def run( htoIdentityArtifacts=deps.htoIdentityArtifacts, batchSafety=list(deps.batchSafety.values()), currentRepresentation=deps.currentRepresentation, - notes=[*characterization.notes, *decision.needsInput], + notes=[ + *characterization.notes, + *decision.needsInput, + *( + [ + "Matched preservation evidence is unavailable for continuous variables: " + + ", ".join(decision.unsupportedProtection) + + ". A safe design does not by itself authorize correction." + ] + if decision.unsupportedProtection + else [] + ), + ], runInfo=run_info, ) diff --git a/scarf/agent/experimental_context/characterization.py b/scarf/agent/experimental_context/characterization.py index de1ba014..f1b52304 100644 --- a/scarf/agent/experimental_context/characterization.py +++ b/scarf/agent/experimental_context/characterization.py @@ -1515,9 +1515,18 @@ def _column_records( dropped_profile = run.profiles.get(name) record = { "name": name, - "kind": "continuous", + "kind": dropped_profile.kind + if dropped_profile is not None + else "continuous", "domain": "ignore", - "summary": f"dropped before triage ({_DROP_REASONS[reason]})", + "summary": ( + f"excluded from design roles ({_DROP_REASONS[reason]})" + + ( + f"; {dropped_profile.summary}" + if dropped_profile is not None + else "" + ) + ), "aliases": [], "nRows": ( dropped_profile.digest.nRows if dropped_profile is not None else 0 @@ -1620,6 +1629,11 @@ def characterize_covariates( continue varying.append(name) candidates = varying + for name, reason in dropped: + if reason == "dropAssayStat" and name not in profiles: + profiles[name] = _profile_column( + bound_store, name, cell_key=cell_key, kind="continuous" + ) candidates, aliases, alias_notes = _collapse_ontology_aliases( bound_store, candidates, diff --git a/scarf/agent/experimental_context/comparisons.py b/scarf/agent/experimental_context/comparisons.py new file mode 100644 index 00000000..c4082beb --- /dev/null +++ b/scarf/agent/experimental_context/comparisons.py @@ -0,0 +1,498 @@ +"""Bounded, objective-led comparisons of metadata on independent study units.""" + +import hashlib +import json +import re +from collections.abc import Sequence +from typing import Any, Literal, cast + +import numpy as np +import pandas as pd + +from ...metrics.association import association_pair, coefficient_estimability +from .. import record_io +from .contracts import ( + CaptureProposal, + CovariateCharacterization, + CovariateComparison, + CovariateProposal, + ExperimentalContextDependencies, +) + +DESIGN_ROUND_LIMITS = (8, 4) +MAX_COMBINATIONS = 32 +MAX_STRATA = 16 + + +def combination_labels(cells: Any, columns: Sequence[str]) -> np.ndarray: + """Encode exact, already selection-aligned metadata without writing columns.""" + if len(columns) != 2 or len(set(columns)) != 2: + raise ValueError("A protected combination requires two distinct columns") + arrays = [np.asarray(cells.fetch(column)) for column in columns] + if any(array.ndim != 1 or array.shape != arrays[0].shape for array in arrays): + raise ValueError("Combination columns must align to the same cell selection") + return _tuple_labels(arrays) + + +def _tuple_labels(arrays: Sequence[np.ndarray]) -> np.ndarray: + output: list[str] = [] + for row in zip(*arrays, strict=True): + encoded = [] + for value in row: + value = value.item() if isinstance(value, np.generic) else value + if pd.isna(value) or isinstance(value, float) and not np.isfinite(value): + raise ValueError("Combination columns contain missing values") + if isinstance(value, bytes): + value = value.decode("utf-8") + encoded.append([type(value).__name__, value]) + output.append(json.dumps(encoded, ensure_ascii=False, separators=(",", ":"))) + return np.asarray(output, dtype=str) + + +def _proposal_key(proposal: CovariateProposal) -> str: + return hashlib.sha256( + record_io.canonical_json_bytes(proposal.model_dump(exclude={"rationale"})) + ).hexdigest() + + +def _association( + frame: pd.DataFrame, response: str, explanatory: str, kinds: dict[str, Any] +) -> dict[str, Any]: + if len(frame) < 4: + return {"status": "notComputed", "reason": "fewerThanFourIndependentUnits"} + for column in (response, explanatory): + if kinds[column] == "categorical" and ( + frame[column].nunique() < 2 or frame[column].value_counts().min() < 2 + ): + return { + "status": "notComputed", + "reason": "categoricalGroupsRequireTwoIndependentUnits", + } + return association_pair( + frame[response].to_numpy(), + frame[explanatory].to_numpy(), + leftKind=kinds[response], + rightKind=kinds[explanatory], + ) + + +def compare_covariates( + cells: Any, + characterization: CovariateCharacterization, + proposal: CovariateProposal, + *, + selection_identity: dict[str, Any], +) -> CovariateComparison: + """Compute descriptive evidence; unsupported designs remain explicit.""" + evidence: dict[str, Any] = {} + reasons: list[str] = [] + records = {record["name"]: record for record in characterization.columns} + columns = [proposal.response, *proposal.explanatoryColumns] + if proposal.conditionedOn is not None: + columns.append(proposal.conditionedOn) + unit = proposal.observationUnit + independent = proposal.independentUnit or unit + requested = list(dict.fromkeys([*columns, unit, independent])) + evidence["columnKinds"] = { + name: records.get(name, {}).get("kind") for name in requested + } + evidence["columnDomains"] = { + name: records.get(name, {}).get("domain") for name in requested + } + declared_units = { + ( + record.get("observationUnit"), + record.get("independentUnit") or record.get("observationUnit"), + ) + for record in characterization.coefficients + } + declared_pair = (unit, independent) in declared_units + evidence["unitRoles"] = { + "observationUnit": unit, + "independentUnit": independent, + "declaredInCharacterization": declared_pair, + } + if any(name not in cells.columns or name not in records for name in requested): + reasons.append("unknownObservedColumn") + elif any( + records[name].get("kind") != "categorical" for name in {unit, independent} + ): + reasons.append("observationAndIndependentUnitsMustBeCategorical") + elif any( + records[name].get("domain") not in {"design", "technical"} + and not (declared_pair and records[name].get("domain") == "biological") + for name in {unit, independent} + ): + reasons.append("observationAndIndependentUnitsMustBeDesignOrTechnical") + if not reasons: + kinds = {name: records[name].get("kind", "categorical") for name in columns} + values = {name: np.asarray(cells.fetch(name)) for name in requested} + frame = pd.DataFrame(values) + evidence["cells"] = len(frame) + evidence["missingCellsByColumn"] = {} + complete = np.ones(len(frame), dtype=bool) + for name in requested: + valid = frame[name].notna().to_numpy() + if kinds.get(name) == "continuous": + numeric = pd.to_numeric(frame[name], errors="coerce") + valid = valid & np.isfinite(numeric.to_numpy(dtype=float)) + frame[name] = numeric + evidence["missingCellsByColumn"][name] = int((~valid).sum()) + complete &= valid + evidence["missingCells"] = int((~complete).sum()) + frame = frame.loc[complete] + if frame.empty: + reasons.append("noCompleteObservations") + else: + grouped = frame.groupby(unit, sort=False, observed=True) + constant = [*proposal.explanatoryColumns, independent] + if proposal.conditionedOn is not None: + constant.append(proposal.conditionedOn) + if kinds[proposal.response] == "categorical": + constant.append(proposal.response) + if any(grouped[name].nunique().gt(1).any() for name in set(constant)): + reasons.append("explanatoryColumnsMustBeConstantWithinObservationUnit") + if grouped.ngroups >= len(frame): + reasons.append("observationUnitIsCellIdentifier") + design = grouped[requested].first().reset_index(drop=True) + if kinds[proposal.response] == "continuous": + design[proposal.response] = ( + grouped[proposal.response].median().to_numpy() + ) + evidence["responseAggregation"] = "medianPerObservationUnit" + evidence["observationUnits"] = len(design) + if independent != unit: + grouped_independent = design.groupby( + independent, sort=False, observed=True + ) + constant = [*proposal.explanatoryColumns] + if proposal.conditionedOn is not None: + constant.append(proposal.conditionedOn) + if kinds[proposal.response] == "categorical": + constant.append(proposal.response) + if any( + grouped_independent[name].nunique().gt(1).any() for name in constant + ): + reasons.append("withinIndependentUnitComparisonsAreUnsupported") + reduced = grouped_independent[columns].first().reset_index(drop=True) + if kinds[proposal.response] == "continuous": + reduced[proposal.response] = ( + grouped_independent[proposal.response].median().to_numpy() + ) + evidence["independentAggregation"] = "medianOfObservationMedians" + design = reduced + evidence["independentUnits"] = len(design) + evidence["unitOfComparison"] = independent + if len(design) < 4: + reasons.append("fewerThanFourIndependentUnits") + if any( + kinds[name] == "categorical" + and design[name].nunique() > MAX_COMBINATIONS + for name in columns + ): + reasons.append("moreThanThirtyTwoCategoricalLevels") + if not reasons: + if proposal.conditionedOn is not None: + condition = proposal.conditionedOn + if kinds[condition] != "categorical": + reasons.append("continuousConditioningIsUnsupported") + elif design[condition].nunique() > MAX_STRATA: + reasons.append("moreThanSixteenConditioningStrata") + else: + strata: list[dict[str, Any]] = [] + for label, subset in design.groupby( + condition, sort=False, observed=True + ): + result = _association( + subset, + proposal.response, + proposal.explanatoryColumns[0], + kinds, + ) + strata.append( + { + "stratum": str(label), + "independentUnits": len(subset), + "association": result, + } + ) + evidence["strata"] = strata + if any( + row["association"].get("status") != "ok" for row in strata + ): + reasons.append("unsupportedConditioningStrata") + else: + evidence["singleAssociations"] = { + name: _association(design, proposal.response, name, kinds) + for name in proposal.explanatoryColumns + } + if any( + value.get("status") != "ok" + for value in evidence["singleAssociations"].values() + ): + reasons.append("unsupportedSingleAssociation") + if len(proposal.explanatoryColumns) == 2: + evidence["jointEstimability"] = coefficient_estimability( + design[proposal.response].to_numpy(), + coefficientKind=cast( + Literal["categorical", "continuous"], + kinds[proposal.response], + ), + technicals={ + name: design[name].to_numpy() + for name in proposal.explanatoryColumns + }, + technicalKinds={ + name: kinds[name] + for name in proposal.explanatoryColumns + }, + ) + if all( + kinds[name] == "categorical" + for name in proposal.explanatoryColumns + ): + labels = _tuple_labels( + [ + design[name].to_numpy() + for name in proposal.explanatoryColumns + ] + ) + if len(np.unique(labels)) > MAX_COMBINATIONS: + reasons.append("moreThanThirtyTwoJointGroups") + else: + design = design.assign(_joint=labels) + evidence["jointGroupCounts"] = { + str(key): int(value) + for key, value in design["_joint"] + .value_counts() + .items() + } + evidence["jointAssociation"] = _association( + design, + proposal.response, + "_joint", + {**kinds, "_joint": "categorical"}, + ) + evidence["jointGroupEstimability"] = ( + coefficient_estimability( + design[proposal.response].to_numpy(), + coefficientKind=cast( + Literal["categorical", "continuous"], + kinds[proposal.response], + ), + technicals={"joint": labels}, + technicalKinds={"joint": "categorical"}, + ) + ) + if evidence["jointAssociation"].get("status") != "ok": + reasons.append("unsupportedJointAssociation") + if proposal.protectCombination: + if any( + records.get(name, {}).get("domain") != "biological" + or records.get(name, {}).get("kind") != "categorical" + for name in proposal.explanatoryColumns + ): + reasons.append("protectedCombinationsMustBeCategoricalBiology") + evidence_id = ( + "designComparison:" + + hashlib.sha256( + record_io.canonical_json_bytes( + { + "selection": selection_identity, + "proposal": proposal.model_dump(), + "evidence": evidence, + "reasons": reasons, + } + ) + ).hexdigest() + ) + return CovariateComparison( + proposal=proposal, + status="unsupported" if reasons else "computed", + evidence=evidence, + reasons=list(dict.fromkeys(reasons)), + evidenceId=evidence_id, + ) + + +def evaluate_proposals( + deps: ExperimentalContextDependencies, + characterization: CovariateCharacterization, + proposals: Sequence[CovariateProposal], +) -> None: + """Consume one bounded round, retaining comparisons from earlier rounds.""" + if deps.designRounds >= len(DESIGN_ROUND_LIMITS): + raise ValueError("Design comparison permits at most two evidence rounds") + if len(proposals) > DESIGN_ROUND_LIMITS[deps.designRounds]: + raise ValueError( + "Design comparison permits eight initial and four follow-up proposals" + ) + deps.designRounds += 1 + previous = {_proposal_key(item.proposal): item for item in deps.comparisons} + records = {record["name"]: record for record in characterization.columns} + for proposal in proposals: + key = _proposal_key(proposal) + prior = previous.get(key) + if prior is not None and any( + records.get(column, {}).get(field) != value + for field, recorded in ( + ("kind", "columnKinds"), + ("domain", "columnDomains"), + ) + for column, value in prior.evidence.get(recorded, {}).items() + ): + del previous[key] + if key not in previous: + comparison = compare_covariates( + deps.cells, + characterization, + proposal, + selection_identity=deps.cellSelection.to_dict(), + ) + deps.comparisons.append(comparison) + previous[key] = comparison + deps.protectedCombinations = [] + for comparison in deps.comparisons: + if comparison.proposal.protectCombination: + columns = sorted(comparison.proposal.explanatoryColumns) + if any( + records.get(column, {}).get("domain") != "biological" + or records.get(column, {}).get("kind") != "categorical" + for column in columns + ): + continue + if columns not in deps.protectedCombinations: + deps.protectedCombinations.append(columns) + characterization.comparisons = list(deps.comparisons) + characterization.captureProvenance = deps.captureProposal + + +def accept_capture_proposal( + deps: ExperimentalContextDependencies, + characterization: CovariateCharacterization, + proposal: CaptureProposal, +) -> None: + """Require an observed capture and exact supporting study statements.""" + study = f"{deps.studyContext}\n{deps.studyObjective}" + quote = proposal.provenanceQuote + records = {record["name"]: record for record in characterization.columns} + record = records.get(proposal.column, {}) + if ( + proposal.column not in deps.cells.columns + or record.get("kind") != "categorical" + or record.get("domain") not in {"design", "technical"} + ): + raise ValueError( + "Capture proposal must name an observed categorical design column" + ) + if ( + quote not in study + or proposal.column not in quote + or "capture" not in quote.lower() + or re.search(r"\b(?:not|unknown|unresolved|uncertain)\b", quote, re.I) + ): + raise ValueError( + "Capture proposal requires an exact study quote identifying that column as a physical capture" + ) + directed = deps.directions.get("physicalCaptureColumn") + if directed is not None and directed != proposal.column: + raise ValueError( + "Capture proposal conflicts with the caller's physical capture" + ) + references = proposal.referenceCaptures + if references: + labels = np.asarray(deps.cells.fetch(proposal.column)).astype(str) + reference_quote = proposal.referenceProvenanceQuote + if ( + len(references) < 2 + or len(references) != len(set(references)) + or not set(references).issubset(set(labels)) + ): + raise ValueError( + "Reference pool requires at least two distinct observed captures" + ) + if ( + not reference_quote + or reference_quote not in study + or any(name not in reference_quote for name in references) + or not any( + word in reference_quote.lower() + for word in ("reference", "baseline", "control") + ) + or re.search( + r"\b(?:not|unknown|unresolved|uncertain)\b", reference_quote, re.I + ) + ): + raise ValueError( + "Reference captures require an exact study quote supporting their baseline role" + ) + from .qc_evidence import ( + _directed_capture_source, + _directed_pooled_reference_captures, + ) + + proposed_deps = deps.model_copy(update={"captureProposal": proposal}) + _directed_capture_source(proposed_deps) + _directed_pooled_reference_captures(proposed_deps) + deps.captureProposal = proposal + characterization.captureProvenance = proposal + + +def canonical_design_choices( + deps: ExperimentalContextDependencies, decision: Any +) -> dict[str, Any]: + """Expose only combinations and capture choices supported by tool evidence.""" + from .qc_evidence import ( + _directed_capture_source, + _directed_pooled_reference_captures, + ) + + combinations = sorted(deps.protectedCombinations) + records = { + record["name"]: record + for record in ( + deps.characterization.columns if deps.characterization is not None else [] + ) + } + if any( + records.get(column, {}).get("domain") != "biological" + or records.get(column, {}).get("kind") != "categorical" + for columns in combinations + for column in columns + ): + raise ValueError( + "Protected combinations must remain categorical biological columns" + ) + supplied = sorted(sorted(columns) for columns in decision.protectedCombinations) + if supplied and supplied != combinations: + raise ValueError( + "Protected combinations must match the evaluated objective-led proposals" + ) + capture = _directed_capture_source(deps) + capture_name = None + if capture is not None: + capture_name = capture[0] + if capture_name is None and capture[1] is not None: + capture_name = capture[1].name + references = list(_directed_pooled_reference_captures(deps) or ()) + if ( + decision.physicalCaptureColumn not in {None, capture_name} + or decision.pooledReferenceCaptures + and decision.pooledReferenceCaptures != references + ): + raise ValueError("Capture choices must match provenance-backed tool evidence") + return { + "protectedCombinations": combinations, + "physicalCaptureColumn": capture_name, + "pooledReferenceCaptures": references, + "unsupportedProtection": sorted( + { + column + for column in [ + *decision.coefficientsOfInterest, + *decision.batchCorrection.preserveColumns, + ] + if records.get(column, {}).get("kind") == "continuous" + } + ), + } diff --git a/scarf/agent/experimental_context/contracts.py b/scarf/agent/experimental_context/contracts.py index 6046d6d8..5b87f9fe 100644 --- a/scarf/agent/experimental_context/contracts.py +++ b/scarf/agent/experimental_context/contracts.py @@ -43,6 +43,50 @@ type ContrastStatus = Literal["licensed", "blocked", "needsInput"] +class CovariateProposal(AgentDataModel): + """One objective-led comparison of observed metadata, without expression tests.""" + + response: str + explanatoryColumns: list[str] = Field(min_length=1, max_length=2) + conditionedOn: str | None = None + observationUnit: str + independentUnit: str | None = None + rationale: str = Field(min_length=1) + protectCombination: bool = False + + @model_validator(mode="after") + def validate_columns(self) -> "CovariateProposal": + columns = [self.response, *self.explanatoryColumns] + if self.conditionedOn is not None: + columns.append(self.conditionedOn) + if len(columns) > 3 or len(columns) != len(set(columns)): + raise ValueError("A comparison requires at most three distinct columns") + if any(not value.strip() for value in [*columns, self.observationUnit]): + raise ValueError( + "Comparison columns and observation unit must be non-empty" + ) + if self.protectCombination and len(self.explanatoryColumns) != 2: + raise ValueError("A protected combination requires two explanatory columns") + return self + + +class CovariateComparison(AgentDataModel): + proposal: CovariateProposal + status: Literal["computed", "unsupported"] + evidence: dict[str, Any] = Field(default_factory=dict) + reasons: list[str] = Field(default_factory=list) + evidenceId: str + + +class CaptureProposal(AgentDataModel): + """An exact capture column and optional references supported by study prose.""" + + column: str + provenanceQuote: str = Field(min_length=1) + referenceCaptures: list[str] = Field(default_factory=list, max_length=32) + referenceProvenanceQuote: str = "" + + class CovariateCharacterization(AgentDataModel): status: StageStatus cellSelection: ArtifactReferenceModel | None = None @@ -60,24 +104,13 @@ class CovariateCharacterization(AgentDataModel): designStructures: list[dict[str, Any]] = Field(default_factory=list) pairedCoverage: list[dict[str, Any]] = Field(default_factory=list) coefficientEstimability: list[dict[str, Any]] = Field(default_factory=list) + comparisons: list[CovariateComparison] = Field(default_factory=list) + captureProvenance: CaptureProposal | None = None @classmethod def get_blank(cls) -> "CovariateCharacterization": return cls(status="failed") - @classmethod - def get_example(cls) -> "CovariateCharacterization": - return cls( - status="done", - cellSelection=ArtifactReferenceModel( - scope="datastore", - kind="cell_selection", - artifactId="c" * 64, - ), - notes=["Cell covariates and confounding were characterized."], - columns=[{"name": "batch", "domain": "technical"}], - ) - class InferenceUnit(AgentDataModel): """Observation and independent units for one biological coefficient.""" @@ -89,10 +122,6 @@ class InferenceUnit(AgentDataModel): def get_blank(cls) -> "InferenceUnit": return cls() - @classmethod - def get_example(cls) -> "InferenceUnit": - return cls(observationUnit="sample", independentUnit="donor") - class BatchCorrectionPlan(AgentDataModel): """A grounded recommendation about whether Harmony should be evaluated.""" @@ -108,28 +137,6 @@ class BatchCorrectionPlan(AgentDataModel): def get_blank(cls) -> "BatchCorrectionPlan": return cls(action="needsInput") - @classmethod - def get_example(cls) -> "BatchCorrectionPlan": - return cls( - action="evaluateHarmony", - batchColumns=["batch"], - preserveColumns=["cell_type", "treatment"], - metricsRequired=[ - "iLISI", - "cLISI", - "graphConnectivity", - ], - rationale=( - "Batch is technical and crossed with treatment, so compare an exact " - "Harmony candidate while protecting biological labels." - ), - evidenceIds=[ - "column:batch", - "estimability:treatment", - "batchEstimability:treatment:batch", - ], - ) - class NamedArtifactSource(AgentDataModel): """One semantic name bound to an exact immutable artifact.""" @@ -149,17 +156,6 @@ def validate_source(self) -> "NamedArtifactSource": def get_blank(cls) -> "NamedArtifactSource": return cls() - @classmethod - def get_example(cls) -> "NamedArtifactSource": - return cls( - name="RNA_percentMito", - artifact=ArtifactReferenceModel( - assay="RNA", - kind="quality_metric", - artifactId="1" * 64, - ), - ) - class QcMetricSourceEvidence(AgentDataModel): """One source-specific quality metric on the exact active cells.""" @@ -468,6 +464,7 @@ class CellQcProfileEvidence(AgentDataModel): activeCellsByCapture: dict[str, int] = Field(default_factory=dict) sampleRetainedCells: dict[str, int] = Field(default_factory=dict) retainedCellsByColumn: dict[str, dict[str, int]] = Field(default_factory=dict) + retainedCellsByCombination: dict[str, dict[str, int]] = Field(default_factory=dict) unsafeRetentionGroups: list[str] = Field(default_factory=list) flaggedCells: dict[str, int] = Field(default_factory=dict) metricFlaggedCells: dict[str, dict[str, int]] = Field(default_factory=dict) @@ -522,23 +519,6 @@ def validate_sources(self) -> "CellQcProfileEvidence": def get_blank(cls) -> "CellQcProfileEvidence": return cls() - @classmethod - def get_example(cls) -> "CellQcProfileEvidence": - return cls( - profileId="cellQc:RNA:globalMad5", - action="registeredMad", - registeredProfile="globalMad5", - driverAssay="RNA", - driverAssayType="RNA", - attributes=["RNA_nCounts", "RNA_nFeatures"], - artifactMetrics=[NamedArtifactSource.get_example()], - parameters={"nMads": 5.0}, - activeCells=100, - retainedCells=96, - retainedFraction=0.96, - evidenceId="qcProfile:cellQc:RNA:globalMad5", - ) - class CellQcPlan(AgentDataModel): """A validated selection from the bounded cell-QC profiles.""" @@ -572,23 +552,6 @@ def validate_sources(self) -> "CellQcPlan": def get_blank(cls) -> "CellQcPlan": return cls() - @classmethod - def get_example(cls) -> "CellQcPlan": - evidence = CellQcProfileEvidence.get_example() - return cls( - action=evidence.action, - registeredProfile=evidence.registeredProfile, - profileId=evidence.profileId, - driverAssay=evidence.driverAssay, - driverAssayType=evidence.driverAssayType, - sampleColumn=evidence.sampleColumn, - sampleArtifact=evidence.sampleArtifact, - attributes=evidence.attributes, - artifactMetrics=evidence.artifactMetrics, - rationale="Use the bounded global profile for the RNA assay.", - evidenceIds=[evidence.evidenceId], - ) - class ExperimentalContextDecision(AgentDataModel): """Model-authored choices that are revalidated against the datastore.""" @@ -596,6 +559,10 @@ class ExperimentalContextDecision(AgentDataModel): columnDomains: dict[str, ColumnDomain] = Field(default_factory=dict) coefficientsOfInterest: list[str] = Field(default_factory=list) unitsOfInference: dict[str, InferenceUnit] = Field(default_factory=dict) + protectedCombinations: list[list[str]] = Field(default_factory=list) + physicalCaptureColumn: str | None = None + pooledReferenceCaptures: list[str] = Field(default_factory=list) + unsupportedProtection: list[str] = Field(default_factory=list) batchCorrection: BatchCorrectionPlan = Field( default_factory=BatchCorrectionPlan.get_blank ) @@ -608,27 +575,6 @@ class ExperimentalContextDecision(AgentDataModel): def get_blank(cls) -> "ExperimentalContextDecision": return cls() - @classmethod - def get_example(cls) -> "ExperimentalContextDecision": - return cls( - columnDomains={ - "batch": "technical", - "sample": "design", - "donor": "design", - "treatment": "biological", - }, - coefficientsOfInterest=["treatment"], - unitsOfInference={"treatment": InferenceUnit.get_example()}, - batchCorrection=BatchCorrectionPlan.get_example(), - rationale="Treatment is the primary between-sample contrast.", - evidenceIds=[ - "column:batch", - "column:donor", - "column:sample", - "column:treatment", - ], - ) - class RepresentationEvaluation(AgentDataModel): """Bounded integration metrics for one exact graph representation.""" @@ -646,33 +592,6 @@ class RepresentationEvaluation(AgentDataModel): def get_blank(cls) -> "RepresentationEvaluation": return cls() - @classmethod - def get_example(cls) -> "RepresentationEvaluation": - return cls( - available=True, - assay="RNA", - cellSelection=ArtifactReferenceModel( - scope="datastore", - kind="cell_selection", - artifactId="c" * 64, - ), - neighbors=ArtifactReferenceModel( - assay="RNA", - kind="neighbors", - artifactId="a" * 64, - ), - connectivityMap=ArtifactReferenceModel( - assay="RNA", - kind="connectivity_map", - artifactId="b" * 64, - ), - metrics={"iLISI:batch": 0.71, "cLISI:cell_type": 0.94}, - evidenceIds=[ - "metric:iLISI:batch:assay:RNA:neighbors:example-neighbors", - "metric:cLISI:cell_type:assay:RNA:neighbors:example-neighbors", - ], - ) - class CovariateEvidence(AgentDataModel): """One deterministic covariate characterization returned by a tool.""" @@ -689,33 +608,6 @@ class CovariateEvidence(AgentDataModel): htoIdentityArtifacts: list[NamedArtifactSource] = Field(default_factory=list) evidenceIds: list[str] = Field(default_factory=list) - @classmethod - def get_example(cls) -> "CovariateEvidence": - return cls( - characterization=CovariateCharacterization( - status="done", - notes=["Example deterministic covariate characterization"], - ), - qcProfiles=[CellQcProfileEvidence.get_example()], - htoIdentityColumns=["sample_id"], - htoIdentityArtifacts=[ - NamedArtifactSource( - name="HTO_htoIdentity", - artifact=ArtifactReferenceModel( - assay="HTO", - kind="hto_identity", - artifactId="2" * 64, - ), - ) - ], - evidenceIds=[ - "column:batch", - CellQcProfileEvidence.get_example().evidenceId, - "htoIdentity:sample_id", - f"htoIdentityArtifact:HTO_htoIdentity:{'2' * 64}", - ], - ) - class ExperimentalContextResult(AgentDataModel): """Canonical experimental-context report returned to the caller.""" @@ -747,35 +639,6 @@ def get_blank(cls) -> "ExperimentalContextResult": characterization=CovariateCharacterization(status="needsInput"), ) - @classmethod - def get_example(cls) -> "ExperimentalContextResult": - representation = RepresentationEvaluation.get_example() - return cls( - status="done", - decision=ExperimentalContextDecision.get_example(), - characterization=CovariateCharacterization( - status="done", - notes=["Example deterministic design characterization"], - ), - cellSelection=representation.cellSelection, - qcProfiles=[CellQcProfileEvidence.get_example()], - qualityMetricArtifacts=[NamedArtifactSource.get_example()], - htoIdentityColumns=["sample_id"], - htoIdentityArtifacts=[ - NamedArtifactSource( - name="HTO_htoIdentity", - artifact=ArtifactReferenceModel( - assay="HTO", - kind="hto_identity", - artifactId="2" * 64, - ), - ) - ], - batchSafety=[BatchSafetyEvidence.get_example()], - currentRepresentation=representation, - runInfo=AgentRunInfo.get_example(), - ) - def to_parameter_tuning_handoff(self) -> ExperimentalTuningHandoff: """Return validated integration inputs for Parameter Tuning.""" if self.status != "done": @@ -902,6 +765,10 @@ class ExperimentalContextDependencies(AgentDataModel): directions: dict[str, Any] = Field(default_factory=dict) evidenceIds: set[str] = Field(default_factory=set) characterization: CovariateCharacterization | None = None + designRounds: int = 0 + comparisons: list[CovariateComparison] = Field(default_factory=list) + protectedCombinations: list[list[str]] = Field(default_factory=list) + captureProposal: CaptureProposal | None = None batchSafety: dict[str, BatchSafetyEvidence] = Field(default_factory=dict) qcProfiles: dict[str, CellQcProfileEvidence] = Field(default_factory=dict) qcMetricSources: list[QcMetricSourceEvidence] = Field(default_factory=list) @@ -919,16 +786,6 @@ class ExperimentalContextDependencies(AgentDataModel): def get_blank(cls) -> "ExperimentalContextDependencies": return cls() - @classmethod - def get_example(cls) -> "ExperimentalContextDependencies": - return cls( - studyContext="Case-control study with samples nested in donors.", - studyObjective=( - "Discover populations while preserving the case-control contrast." - ), - directions={"columnDomains": {"batch": "technical"}}, - ) - def characterization_evidence( characterization: CovariateCharacterization, @@ -952,4 +809,9 @@ def characterization_evidence( technical = pair.get("technical") if isinstance(technical, str): evidence_ids.add(f"confounding:{coefficient}:{technical}") + evidence_ids.update(item.evidenceId for item in characterization.comparisons) + if characterization.captureProvenance is not None: + evidence_ids.add( + f"captureProvenance:{characterization.captureProvenance.column}" + ) return evidence_ids diff --git a/scarf/agent/experimental_context/qc_evidence.py b/scarf/agent/experimental_context/qc_evidence.py index 4fa6ab00..02669372 100644 --- a/scarf/agent/experimental_context/qc_evidence.py +++ b/scarf/agent/experimental_context/qc_evidence.py @@ -1,5 +1,6 @@ """Experimental-context quality-control evidence assembly.""" +import json import math import re from collections.abc import Mapping, Sequence @@ -616,6 +617,7 @@ def _directed_capture_source( deps.directions.get("physicalCaptureColumn"), qc_directions.get("physicalCaptureColumn"), qc_directions.get("captureColumn"), + deps.captureProposal.column if deps.captureProposal is not None else None, ] specified = [value for value in candidates if value is not None] if not specified: @@ -656,6 +658,19 @@ def _directed_pooled_reference_captures( "pooledReferenceCaptures", deps.directions.get("pooledReferenceCaptures"), ) + proposed = ( + deps.captureProposal.referenceCaptures + if deps.captureProposal is not None + else [] + ) + if proposed: + if raw is not None and ( + not isinstance(raw, list | tuple) or list(raw) != proposed + ): + raise ValueError( + "Reference proposal conflicts with caller reference captures" + ) + raw = proposed if raw is None: return None if not isinstance(raw, list | tuple) or any( @@ -849,6 +864,45 @@ def _capture_design_safety( "preservesIndependentUnitCoverage": preserves_units, } ) + from .comparisons import combination_labels + + for columns in deps.protectedCombinations: + label = json.dumps(columns, separators=(",", ":")) + try: + combined = combination_labels(deps.cells, columns) + except ValueError: + safety.append( + { + "conditionColumns": columns, + "preservesConditionCoverage": False, + "preservesIndependentUnitCoverage": False, + "reason": "missingProtectedCombination", + } + ) + continue + joint_groups = np.unique(combined) + coverage = set(np.unique(combined[after])) == set(joint_groups) + units = { + record.get("independentUnit") or record.get("observationUnit") + for record in characterization.coefficients + if record.get("name") in columns + } + units.discard(None) + independent_safe = coverage and bool(units) + for unit in units: + values = np.asarray(deps.cells.fetch(unit)) + independent_safe = independent_safe and all( + len(np.unique(values[after & (combined == group)])) >= 2 + for group in joint_groups + ) + safety.append( + { + "conditionColumns": columns, + "combination": label, + "preservesConditionCoverage": coverage, + "preservesIndependentUnitCoverage": independent_safe, + } + ) return ( safety, bool(safety) and all(item["preservesConditionCoverage"] for item in safety), @@ -949,6 +1003,73 @@ def _capture_failure_models( return output +def _design_retention( + deps: ExperimentalContextDependencies, + characterization: CovariateCharacterization | None, + active: np.ndarray, + keep: np.ndarray, +) -> dict[str, Any]: + """Check exact categorical conditions, units, and protected joint groups.""" + cells = deps.cells if deps.cells is not None else deps.store.cells + retention_columns: list[str] = [] + if characterization is not None: + kinds = { + record["name"]: record.get("kind") for record in characterization.columns + } + for coefficient in characterization.coefficients: + name = coefficient.get("name") + for value in ( + name if kinds.get(name) == "categorical" else None, + coefficient.get("observationUnit"), + coefficient.get("independentUnit"), + ): + if isinstance(value, str) and value in cells.columns: + retention_columns.append(value) + retained_by_column: dict[str, dict[str, int]] = {} + unsafe_groups: list[str] = [] + retained = np.asarray(keep, dtype=bool) & np.asarray(active, dtype=bool) + for column in dict.fromkeys(retention_columns): + labels = np.asarray(cells.fetch(column)) + if labels.shape != retained.shape: + raise ValueError( + f"QC retention column {column!r} does not align with cellSelection" + ) + counts: dict[str, int] = {} + for raw_label in np.unique(labels[np.asarray(active, dtype=bool)]): + label = raw_label.item() if isinstance(raw_label, np.generic) else raw_label + key = label.decode("utf-8") if isinstance(label, bytes) else str(label) + count = int((retained & (labels == raw_label)).sum()) + counts[key] = count + if count == 0: + unsafe_groups.append(f"{column}={key}") + retained_by_column[column] = counts + from .comparisons import combination_labels + + retained_by_combination: dict[str, dict[str, int]] = {} + for columns in deps.protectedCombinations: + key = json.dumps(columns, separators=(",", ":")) + try: + labels = combination_labels(cells, columns) + except ValueError: + unsafe_groups.append(f"combination:{key}:missingValues") + continue + counts = { + str(label): int((retained & (labels == label)).sum()) + for label in np.unique(labels[np.asarray(active, dtype=bool)]) + } + retained_by_combination[key] = counts + unsafe_groups.extend( + f"combination:{key}={label}" + for label, count in counts.items() + if count == 0 + ) + return { + "retainedCellsByColumn": retained_by_column, + "retainedCellsByCombination": retained_by_combination, + "unsafeRetentionGroups": sorted(unsafe_groups), + } + + def _registered_profile_evidence( projection: RegisteredQcProjection, *, @@ -1007,35 +1128,6 @@ def _registered_profile_evidence( capture_labels=capture_labels, metric_sources=metric_sources, ) - cells = deps.cells if deps.cells is not None else deps.store.cells - retention_columns: list[str] = [] - if characterization is not None: - for coefficient in characterization.coefficients: - for value in ( - coefficient.get("name"), - coefficient.get("observationUnit"), - coefficient.get("independentUnit"), - ): - if isinstance(value, str) and value in cells.columns: - retention_columns.append(value) - retained_by_column: dict[str, dict[str, int]] = {} - unsafe_groups: list[str] = [] - retained = np.asarray(projection.keep, dtype=bool) & np.asarray(active, dtype=bool) - for column in dict.fromkeys(retention_columns): - labels = np.asarray(cells.fetch(column)) - if labels.shape != retained.shape: - raise ValueError( - f"QC retention column {column!r} does not align with cellSelection" - ) - counts: dict[str, int] = {} - for raw_label in np.unique(labels[np.asarray(active, dtype=bool)]): - label = raw_label.item() if isinstance(raw_label, np.generic) else raw_label - key = label.decode("utf-8") if isinstance(label, bytes) else str(label) - count = int((retained & (labels == raw_label)).sum()) - counts[key] = count - if count == 0: - unsafe_groups.append(f"{column}={key}") - retained_by_column[column] = counts return CellQcProfileEvidence( profileId=profile_id, action=action, @@ -1059,8 +1151,7 @@ def _registered_profile_evidence( ), activeCellsByCapture=projection.captureSizes, sampleRetainedCells=projection.retainedByCapture, - retainedCellsByColumn=retained_by_column, - unsafeRetentionGroups=sorted(unsafe_groups), + **_design_retention(deps, characterization, active, projection.keep), flaggedCells=projection.flagCounts, metricFlaggedCells=projection.metricFlagCounts, failedCaptureCandidates=list(projection.failedCaptureCandidates), @@ -1170,46 +1261,19 @@ def _global_qc_profile( return None metric_sources = list(metric_sources or []) source_concordance = list(source_concordance or []) - executable_values: dict[str, np.ndarray] = {} for name, values in values_by_attr.items(): selected = np.asarray(values)[active] if selected.size and np.all(selected == selected[0]): - attribute_notes.append(f"Ignored constant QC metric {name!r}") - continue + attribute_notes.append( + f"Scarf default global QC is unavailable: constant metric {name!r} produces non-finite Gaussian bounds" + ) + return None low, high = gaussian_quantile_bounds(selected, 0.01, 0.99) if not np.isfinite([low, high]).all(): attribute_notes.append( - f"Ignored QC metric {name!r} with non-finite Gaussian bounds" + f"Scarf default global QC is unavailable: metric {name!r} produces non-finite Gaussian bounds" ) - continue - executable_values[name] = values - if not executable_values: - return None - executable_names = set(executable_values) - metadata_names = set(metadata_attributes) - metadata_attributes = [ - name for name in metadata_attributes if name in executable_names - ] - artifact_metrics = [ - source - for source in artifact_metrics - if qc_metric_execution_name( - source.name, - artifact_id=source.artifact.artifactId, - collides_with_metadata=source.name in metadata_names, - ) - in executable_names - ] - metric_sources = [ - source for source in metric_sources if source.executionName in executable_names - ] - retained_source_ids = {source.sourceId for source in metric_sources} - source_concordance = [ - comparison - for comparison in source_concordance - if comparison.leftSourceId in retained_source_ids - and comparison.rightSourceId in retained_source_ids - ] + return None capture_column: str | None = None capture_artifact: NamedArtifactSource | None = None capture_labels: np.ndarray | None = None @@ -1218,7 +1282,7 @@ def _global_qc_profile( try: projection = project_auto_filter_profile( "globalGaussian", - values_by_metric=executable_values, + values_by_metric=values_by_attr, active=active, sample_labels=capture_labels, grouping_proven=capture is not None, @@ -1255,6 +1319,7 @@ def _global_qc_profile( retainedFraction=projection.retainedCells / active_cells, activeCellsByCapture=projection.captureSizes, sampleRetainedCells=projection.retainedByCapture, + **_design_retention(deps, characterization, active, projection.keep), flaggedCells=projection.flagCounts, metricFlaggedCells=projection.metricFlagCounts, failedCaptureCandidates=list(projection.failedCaptureCandidates), @@ -1398,6 +1463,7 @@ def _sample_qc_profiles( retainedFraction=projection.retainedCells / active_cells, activeCellsByCapture=projection.captureSizes, sampleRetainedCells=projection.retainedByCapture, + **_design_retention(deps, characterization, active, projection.keep), flaggedCells=projection.flagCounts, metricFlaggedCells=projection.metricFlagCounts, failedCaptureCandidates=( @@ -1435,11 +1501,9 @@ def _offered_qc_profiles( if driver is not None else ["No RNA or ATAC assay is eligible to drive automatic cell QC"] ) - registered_only = deps.directions.get("registeredQcOnly") is True - profiles = ( - [] - if registered_only - else [ + profiles: list[CellQcProfileEvidence] = [] + if driver is None or active_cells == 0: + profiles.append( CellQcProfileEvidence( profileId=skip_id, action="skip", @@ -1451,23 +1515,7 @@ def _offered_qc_profiles( notes=skip_notes, evidenceId=f"qcProfile:{skip_id}", ) - ] - ) - if driver is None or active_cells == 0: - if registered_only: - profiles.append( - CellQcProfileEvidence( - profileId=skip_id, - action="skip", - driverAssay=driver_assay, - driverAssayType=driver_type, - activeCells=active_cells, - retainedCells=active_cells, - retainedFraction=1.0 if active_cells else 0.0, - notes=skip_notes, - evidenceId=f"qcProfile:{skip_id}", - ) - ) + ) deps.qcProfiles = {profile.profileId: profile for profile in profiles} return profiles @@ -1503,59 +1551,57 @@ def _offered_qc_profiles( ) deps.qcMetricSources = metric_sources deps.qcSourceConcordance = source_concordance - if not registered_only: - profiles = [ - CellQcProfileEvidence( - profileId=skip_id, - action="skip", - driverAssay=driver_assay, - driverAssayType=driver_type, - captureColumn=capture_column, - captureArtifact=capture_artifact, - metricSources=metric_sources, - sourceConcordance=source_concordance, - activeCells=active_cells, - retainedCells=active_cells, - retainedFraction=1.0, - activeCellsByCapture=capture_sizes, - sampleRetainedCells=capture_sizes, - notes=[*skip_notes, *attribute_notes], - evidenceId=f"qcProfile:{skip_id}", - ) - ] + profiles = [ + CellQcProfileEvidence( + profileId=skip_id, + action="skip", + driverAssay=driver_assay, + driverAssayType=driver_type, + captureColumn=capture_column, + captureArtifact=capture_artifact, + metricSources=metric_sources, + sourceConcordance=source_concordance, + activeCells=active_cells, + retainedCells=active_cells, + retainedFraction=1.0, + activeCellsByCapture=capture_sizes, + sampleRetainedCells=capture_sizes, + notes=[*skip_notes, *attribute_notes], + evidenceId=f"qcProfile:{skip_id}", + ) + ] - if not registered_only: - global_profile = _global_qc_profile( + global_profile = _global_qc_profile( + deps, + driver, + active, + active_cells, + values_by_attr, + valid_metadata_attributes, + artifact_metrics, + attribute_notes, + characterization=characterization, + metric_sources=metric_sources, + source_concordance=source_concordance, + capture=capture, + ) + if global_profile is not None: + profiles.append(global_profile) + profiles.extend( + _sample_qc_profiles( deps, + characterization, driver, active, active_cells, values_by_attr, valid_metadata_attributes, artifact_metrics, - attribute_notes, - characterization=characterization, - metric_sources=metric_sources, - source_concordance=source_concordance, - capture=capture, - ) - if global_profile is not None: - profiles.append(global_profile) - profiles.extend( - _sample_qc_profiles( - deps, - characterization, - driver, - active, - active_cells, - values_by_attr, - valid_metadata_attributes, - artifact_metrics, - metric_sources, - source_concordance, - capture, - ) + metric_sources, + source_concordance, + capture, ) + ) profiles.extend( _registered_qc_profiles( deps, @@ -1571,5 +1617,7 @@ def _offered_qc_profiles( ) ) + for profile in profiles: + profile.notes = list(dict.fromkeys([*attribute_notes, *profile.notes])) deps.qcProfiles = {profile.profileId: profile for profile in profiles} return profiles diff --git a/scarf/agent/experimental_context/study.py b/scarf/agent/experimental_context/study.py index 1512d1a4..e3ab1bae 100644 --- a/scarf/agent/experimental_context/study.py +++ b/scarf/agent/experimental_context/study.py @@ -6,6 +6,7 @@ from pydantic import Field, model_validator from ..types import AgentDataModel +from .contracts import CovariateComparison type AuthorLabelPolicy = Literal["holdout", "preservation"] type ProcessingGoal = Literal[ @@ -33,6 +34,11 @@ class StudyContract(AgentDataModel): conditionColumns: list[str] = Field(default_factory=list) technicalBatchColumns: list[str] = Field(default_factory=list) protectedColumns: list[str] = Field(default_factory=list) + protectedCombinations: list[list[str]] = Field(default_factory=list) + unsupportedProtection: list[str] = Field(default_factory=list) + columnKinds: dict[str, Literal["categorical", "continuous"]] = Field( + default_factory=dict + ) authorLabelPolicy: AuthorLabelPolicy = "holdout" correctionLicense: CorrectionLicense = "notApplicable" allowedClaims: list[str] = Field(default_factory=list) @@ -62,43 +68,46 @@ def validate_contract(self) -> "StudyContract": raise ValueError("Technical batch columns cannot also be condition columns") if self.correctionLicense == "safe" and not self.technicalBatchColumns: raise ValueError("A safe correction license requires batch columns") + if any( + len(columns) != 2 + or len(set(columns)) != 2 + or not set(columns).issubset(self.protectedColumns) + for columns in self.protectedCombinations + ): + raise ValueError( + "Protected combinations require two distinct protected columns" + ) return self @classmethod def get_blank(cls) -> "StudyContract": return cls(studyContext="Study context", studyObjective="Study objective") - @classmethod - def get_example(cls) -> "StudyContract": - return cls( - studyContext="Treated and control blood samples from multiple donors.", - studyObjective=( - "Discover stable populations while preserving treatment-associated " - "structure." - ), - processingGoal="conditionPreservingDiscovery", - scientificQuestions=[ - "Discover stable populations while preserving treatment-associated " - "structure." - ], - physicalCaptureColumn="sample", - independentUnitColumns=["donor"], - conditionColumns=["treatment"], - technicalBatchColumns=["batch"], - protectedColumns=["treatment", "donor"], - correctionLicense="safe", - allowedClaims=["Describe reproducible population structure."], - unsupportedClaims=[ - "This workflow does not test differential-expression hypotheses." - ], - evidenceIds=["column:batch", "column:donor", "column:treatment"], - ) - def _unique(values: Iterable[str | None]) -> list[str]: return list(dict.fromkeys(value for value in values if value)) +def unsupported_comparison_limitations( + comparisons: Iterable[CovariateComparison], +) -> list[str]: + """Describe unsupported comparisons without implying a scientific finding.""" + + limitations = [] + for comparison in comparisons: + if comparison.status == "unsupported": + proposal = comparison.proposal + limitations.append( + f"Unresolved design comparison {comparison.evidenceId}: " + f"{proposal.response} against {', '.join(proposal.explanatoryColumns)} " + f"using observation unit {proposal.observationUnit!r} and " + f"independent unit {proposal.independentUnit or proposal.observationUnit!r}; " + f"reasons={', '.join(comparison.reasons) or 'unsupported evidence'}. " + "This comparison provides no supported association or absence finding." + ) + return limitations + + def build_study_contract( *, study_context: str, @@ -118,7 +127,18 @@ def build_study_contract( independent_units = _unique( unit.independentUnit for unit in decision.unitsOfInference.values() ) - protected = _unique([*conditions, *independent_units, *batch_plan.preserveColumns]) + protected = _unique( + [ + *conditions, + *independent_units, + *batch_plan.preserveColumns, + *( + column + for columns in decision.protectedCombinations + for column in columns + ), + ] + ) assessed_batch_columns = _unique( [ *batch_plan.batchColumns, @@ -154,7 +174,12 @@ def build_study_contract( *(item.evidenceId for item in experimental_result.batchSafety), ] ) - limitations = list(experimental_result.notes) + limitations = [ + *experimental_result.notes, + *unsupported_comparison_limitations( + experimental_result.characterization.comparisons + ), + ] if physical_capture_column is None: limitations.append( "Physical capture identity is unresolved; capture-aware doublet removal " @@ -175,6 +200,15 @@ def build_study_contract( conditionColumns=conditions, technicalBatchColumns=assessed_batch_columns, protectedColumns=protected, + protectedCombinations=[ + list(columns) for columns in decision.protectedCombinations + ], + unsupportedProtection=list(decision.unsupportedProtection), + columnKinds={ + record["name"]: record["kind"] + for record in experimental_result.characterization.columns + if record.get("kind") in {"categorical", "continuous"} + }, authorLabelPolicy=author_label_policy, correctionLicense=correction_license, allowedClaims=[ diff --git a/scarf/agent/experimental_context/tools.py b/scarf/agent/experimental_context/tools.py index 20776094..99956bd7 100644 --- a/scarf/agent/experimental_context/tools.py +++ b/scarf/agent/experimental_context/tools.py @@ -12,13 +12,20 @@ from ..tools import artifact_reference, core_artifact_reference from ..types import BatchSafetyEvidence, BatchSafetyStatus from .characterization import characterize_covariates +from .comparisons import ( + DESIGN_ROUND_LIMITS, + accept_capture_proposal, + evaluate_proposals, +) from .contracts import ( + CaptureProposal, ColumnDomain, ContrastPlan, ContrastStatus, ContrastTest, CovariateCharacterization, CovariateEvidence, + CovariateProposal, ExperimentalContextDependencies, InferenceUnit, RepresentationEvaluation, @@ -42,14 +49,14 @@ def _prepare_experimental_context_tool( ctx: RunContext[ExperimentalContextDependencies], tool_definition: ToolDefinition, ) -> ToolDefinition | None: - """Expose each context tool once and in its required dependency order.""" + """Expose inspection once and at most two ordered design evidence rounds.""" completed_calls = set(ctx.deps.toolCalls) if tool_definition.name == "inspect_cell_covariates": return None if tool_definition.name in completed_calls else tool_definition if tool_definition.name == "analyze_experimental_design": if ( "inspect_cell_covariates" not in completed_calls - or tool_definition.name in completed_calls + or ctx.deps.designRounds >= len(DESIGN_ROUND_LIMITS) ): return None return tool_definition @@ -357,6 +364,33 @@ def _batch_safety_evidence( safety_status = "safe" else: safety_status = "unsafe" + # Sparse descriptive associations cannot erase a computed design constraint. + joint_checks = [ + { + "evidenceId": comparison.evidenceId, + **comparison.evidence["jointGroupEstimability"], + } + for comparison in characterization.comparisons + if comparison.proposal.response == coefficient + and comparison.proposal.observationUnit == observation_unit + and comparison.proposal.conditionedOn is None + and all( + column_records.get(name, {}).get("kind") == kind + for name, kind in comparison.evidence.get("columnKinds", {}).items() + ) + and set(comparison.proposal.explanatoryColumns).issubset( + canonical_batch_columns + ) + and "jointGroupEstimability" in comparison.evidence + ] + if joint_checks: + estimability = {**estimability, "jointStratumChecks": joint_checks} + if any( + check.get("status") == "ok" + and check.get("coefficientEstimable") is False + for check in joint_checks + ): + safety_status = "unsafe" batch_token = ",".join(canonical_batch_columns) safety = BatchSafetyEvidence( coefficient=coefficient, @@ -381,6 +415,8 @@ async def analyze_experimental_design( coefficients_of_interest: list[str], units_of_inference: dict[str, InferenceUnit], batch_columns: list[str], + proposals: list[CovariateProposal] | None = None, + capture_proposal: CaptureProposal | None = None, ) -> CovariateEvidence: """Validate proposed domains and inference units and compute confounding. @@ -390,6 +426,8 @@ async def analyze_experimental_design( coefficients_of_interest: Biological columns representing study contrasts. units_of_inference: Observation and independent units for each coefficient. batch_columns: Exact technical columns proposed for Harmony evaluation. + proposals: Up to eight initial or four follow-up objective-led comparisons. + capture_proposal: Exact capture and baseline identities supported by study prose. """ logger.info( "Experimental Context design analysis started: " @@ -398,6 +436,12 @@ async def analyze_experimental_design( f"inferenceUnits={len(units_of_inference)}, " f"batchColumns={len(batch_columns)}" ) + if ctx.deps.designRounds >= len(DESIGN_ROUND_LIMITS): + raise ModelRetry("Design comparison permits at most two evidence rounds") + if len(proposals or ()) > DESIGN_ROUND_LIMITS[ctx.deps.designRounds]: + raise ModelRetry( + "Design comparison permits eight initial and four follow-up proposals" + ) directions = dict(ctx.deps.directions) directed_domains = dict(column_domains) directed_domains.update(dict(directions.get("columnDomains") or {})) @@ -509,6 +553,12 @@ async def analyze_experimental_design( # columns below are rejected. A bounded retry or resumed decision can reuse # the evidence without rescanning metadata or accepting an unsafe choice. ctx.deps.characterization = characterization + try: + evaluate_proposals(ctx.deps, characterization, proposals or ()) + if capture_proposal is not None: + accept_capture_proposal(ctx.deps, characterization, capture_proposal) + except ValueError as exc: + raise ModelRetry(str(exc)) from exc if not ctx.deps.htoIdentityColumns: ctx.deps.htoIdentityColumns = _hto_identity_columns(ctx.deps) qc_profiles = _offered_qc_profiles(ctx.deps, characterization) diff --git a/scarf/agent/experimental_context/validation.py b/scarf/agent/experimental_context/validation.py index 8a0a4567..3facf82e 100644 --- a/scarf/agent/experimental_context/validation.py +++ b/scarf/agent/experimental_context/validation.py @@ -1,197 +1,34 @@ -"""Experimental-context canonicalization and fallbacks.""" +"""Experimental-context canonicalization and explicit model failures.""" -from collections.abc import Mapping -from typing import Any, cast +from typing import Any from ...utils.logging import logger from .._deps import AGENT_INSTALL_HINT from ..tools import artifact_reference -from ..types import AgentRunInfo, BatchCorrectionAction, BatchSafetyEvidence +from ..types import AgentRunInfo, BatchSafetyEvidence from .characterization import characterize_covariates +from .comparisons import canonical_design_choices from .contracts import ( - BatchCorrectionPlan, CellQcPlan, - ColumnDomain, CovariateCharacterization, ExperimentalContextDecision, ExperimentalContextDependencies, ExperimentalContextResult, InferenceUnit, - IntegrationMetric, characterization_evidence, ) from .qc_evidence import ( - _artifact_evidence_id, _hto_artifact_map, - _hto_identity_columns, _offered_qc_profiles, ) -from .tools import _batch_safety_evidence, contrast_plans_from_characterization +from .tools import contrast_plans_from_characterization try: - from pydantic_ai import ModelRetry, UnexpectedModelBehavior + from pydantic_ai import ModelRetry except ImportError as exc: raise ImportError(AGENT_INSTALL_HINT) from exc -def _canonical_cell_qc_plan( - plan: CellQcPlan, - deps: ExperimentalContextDependencies, - characterization: CovariateCharacterization, -) -> CellQcPlan: - """Resolve one exact offered profile and reject model-authored parameters.""" - if not deps.qcProfiles: - _offered_qc_profiles(deps, characterization) - directed = deps.directions.get("cellQc") - direction_map = dict(directed) if isinstance(directed, Mapping) else {} - directed_profile_id = direction_map.get("profileId") - if directed_profile_id is not None and not isinstance(directed_profile_id, str): - raise ModelRetry("cellQc.profileId direction must be a string") - - has_directed_selector = any( - key in direction_map - for key in ( - "profileId", - "registeredProfile", - "action", - "sampleColumn", - "sampleArtifactName", - ) - ) - selected_id = directed_profile_id or ( - "" if has_directed_selector else plan.profileId - ) - if not selected_id: - requested_action = direction_map.get("action") - requested_registered_profile = direction_map.get("registeredProfile") - requested_sample = direction_map.get("sampleColumn") - requested_sample_artifact = direction_map.get("sampleArtifactName") - if requested_sample is not None and requested_sample_artifact is not None: - raise ModelRetry( - "cellQc directions cannot select both sampleColumn and " - "sampleArtifactName" - ) - if requested_sample_artifact is not None and not isinstance( - requested_sample_artifact, str - ): - raise ModelRetry("cellQc.sampleArtifactName must be a string") - if requested_action is not None and requested_action not in { - "skip", - "globalGaussian", - "sampleMad", - "registeredMad", - }: - raise ModelRetry(f"Unsupported cellQc.action {requested_action!r}") - if requested_registered_profile is not None and not isinstance( - requested_registered_profile, str - ): - raise ModelRetry("cellQc.registeredProfile must be a string") - if ( - requested_registered_profile is not None - and requested_registered_profile - not in { - "retainWithFlags", - "globalMad5", - "captureMad5", - "captureMad3Sensitivity", - "pooledReferenceMad5", - } - ): - raise ModelRetry( - f"Unsupported cellQc.registeredProfile {requested_registered_profile!r}" - ) - matches = [ - profile - for profile in deps.qcProfiles.values() - if (requested_action is None or profile.action == requested_action) - and ( - requested_registered_profile is None - or profile.registeredProfile == requested_registered_profile - ) - and (requested_sample is None or profile.sampleColumn == requested_sample) - and ( - requested_sample_artifact is None - or ( - profile.sampleArtifact is not None - and profile.sampleArtifact.name == requested_sample_artifact - ) - ) - ] - if requested_action is not None or requested_registered_profile is not None: - if len(matches) != 1: - raise ModelRetry( - "cellQc directions must identify exactly one offered profile" - ) - selected_id = matches[0].profileId - else: - global_profiles = [ - profile - for profile in deps.qcProfiles.values() - if profile.action == "globalGaussian" - ] - if global_profiles: - selected_id = global_profiles[0].profileId - else: - selected_id = next( - profile.profileId - for profile in deps.qcProfiles.values() - if profile.action == "skip" - ) - - profile = deps.qcProfiles.get(selected_id) - if profile is None: - raise ModelRetry( - f"Cell-QC profile {selected_id!r} was not offered by the evidence tool" - ) - model_selected = bool(plan.profileId) and not has_directed_selector - if model_selected: - expected_fields = { - "action": profile.action, - "registeredProfile": profile.registeredProfile, - "driverAssay": profile.driverAssay, - "driverAssayType": profile.driverAssayType, - "sampleColumn": profile.sampleColumn, - "sampleArtifact": profile.sampleArtifact, - "attributes": profile.attributes, - "artifactMetrics": profile.artifactMetrics, - } - mismatches = [ - name - for name, expected in expected_fields.items() - if getattr(plan, name) != expected - ] - if mismatches: - raise ModelRetry( - "Cell-QC plan must copy the selected offered profile exactly: " - f"{mismatches}" - ) - if profile.evidenceId not in plan.evidenceIds: - raise ModelRetry( - "Cell-QC plan must cite its exact profile retention evidence" - ) - rationale = plan.rationale.strip() - if not rationale: - rationale = ( - "Selected the caller-directed bounded cell-QC profile." - if direction_map - else "Selected the bounded default cell-QC profile." - ) - cited_evidence = plan.evidenceIds if model_selected else [] - return CellQcPlan( - action=profile.action, - registeredProfile=profile.registeredProfile, - profileId=profile.profileId, - driverAssay=profile.driverAssay, - driverAssayType=profile.driverAssayType, - sampleColumn=profile.sampleColumn, - sampleArtifact=profile.sampleArtifact, - attributes=profile.attributes, - artifactMetrics=profile.artifactMetrics, - rationale=rationale, - evidenceIds=sorted({*cited_evidence, profile.evidenceId}), - ) - - def _validate_batch_correction_plan( decision: ExperimentalContextDecision, deps: ExperimentalContextDependencies, @@ -278,9 +115,10 @@ def _validate_batch_correction_plan( raise ModelRetry( "evaluateHarmony requires iLISI or proportionalBatchMixing" ) - if plan.preserveColumns and not preservation_metrics.intersection( - plan.metricsRequired - ): + if any( + records.get(column, {}).get("kind") == "categorical" + for column in plan.preserveColumns + ) and not preservation_metrics.intersection(plan.metricsRequired): raise ModelRetry( "evaluateHarmony requires cLISI or graphConnectivity for preservation" ) @@ -310,10 +148,6 @@ def _validate_batch_correction_plan( raise ModelRetry( f"Preservation column {preserve_column!r} must be biological" ) - if record.get("kind") != "categorical": - raise ModelRetry( - f"Preservation column {preserve_column!r} must be categorical" - ) matched_safety: list[BatchSafetyEvidence] = [] if plan.action in {"evaluateHarmony", "unsafe"}: @@ -466,6 +300,8 @@ def validate_experimental_context( ) if characterization.status == "failed": raise ModelRetry("; ".join(characterization.notes)) + characterization.comparisons = list(deps.comparisons) + characterization.captureProvenance = deps.captureProposal deps.characterization = characterization deps.evidenceIds.update(characterization_evidence(characterization)) contrast_plans = contrast_plans_from_characterization(characterization) @@ -543,12 +379,17 @@ def validate_experimental_context( for coefficient in directions["coefficientsOfInterest"] if coefficient in coefficient_records } + try: + design_choices = canonical_design_choices(deps, decision) + except ValueError as exc: + raise ModelRetry(str(exc)) from exc validated = decision.model_copy( update={ "columnDomains": canonical_domains, "coefficientsOfInterest": list(directions["coefficientsOfInterest"]), "unitsOfInference": canonical_units, "cellQc": CellQcPlan.get_blank(), + **design_choices, } ) logger.debug( @@ -562,166 +403,18 @@ def validate_experimental_context( return validated -def _deterministic_experimental_context_decision( - deps: ExperimentalContextDependencies, -) -> ExperimentalContextDecision: - characterization = deps.characterization - if characterization is None or characterization.status == "failed": - raise ValueError("Deterministic covariate characterization is unavailable") - records: dict[str, dict[str, Any]] = {} - for record in characterization.columns: - name = record.get("name") - if isinstance(name, str): - records[name] = record - coefficient_records: dict[str, dict[str, Any]] = {} - for record in characterization.coefficients: - name = record.get("name") - if isinstance(name, str): - coefficient_records[name] = record - directions = dict(deps.directions) - raw_batch_columns = directions.get("batchColumns") - if raw_batch_columns is not None: - if not isinstance(raw_batch_columns, list) or any( - not isinstance(value, str) or not value.strip() - for value in raw_batch_columns - ): - raise ValueError( - "directions.batchColumns must be a list of exact metadata columns" - ) - if len(raw_batch_columns) != len(set(raw_batch_columns)): - raise ValueError("directions.batchColumns must be unique") - batch_columns = list(raw_batch_columns) - else: - candidates = sorted( - name - for name, record in records.items() - if record.get("domain") == "technical" - and record.get("kind") == "categorical" - ) - if "batch" in candidates: - batch_columns = ["batch"] - elif len(candidates) <= 1: - batch_columns = candidates - else: - raise ValueError( - "Multiple categorical technical columns remain without one exact " - "batch condition" - ) - - coefficients = [ - str(record["name"]) - for record in characterization.coefficients - if isinstance(record.get("name"), str) - ] - units = { - coefficient: InferenceUnit( - observationUnit=coefficient_records[coefficient].get("observationUnit"), - independentUnit=coefficient_records[coefficient].get("independentUnit"), - ) - for coefficient in coefficients - if coefficient in coefficient_records - } - batch_safety = _batch_safety_evidence( - deps, - characterization, - coefficients=coefficients, - batch_columns=batch_columns, - ) - unresolved_safety = [ - item.coefficient for item in batch_safety if item.status == "notComputed" - ] - if unresolved_safety: - raise ValueError( - "Batch estimability is unavailable for coefficients: " - f"{sorted(unresolved_safety)}" - ) - if batch_columns and any(item.status == "unsafe" for item in batch_safety): - action: BatchCorrectionAction = "unsafe" - elif batch_columns: - action = "evaluateHarmony" - else: - action = "skip" - categorical_coefficients = [ - coefficient - for coefficient in coefficients - if records[coefficient].get("kind") == "categorical" - ] - if action == "evaluateHarmony" and set(categorical_coefficients) != set( - coefficients - ): - raise ValueError( - "Harmony preservation requires categorical coefficients of interest" - ) - - known_evidence = sorted(characterization_evidence(characterization)) - batch_evidence = [ - *(f"column:{column}" for column in batch_columns), - *(item.evidenceId for item in batch_safety), - ] - if not batch_evidence: - batch_evidence = known_evidence[:1] - if not batch_evidence: - raise ValueError("No deterministic evidence supports a batch decision") - deps.evidenceIds.update(known_evidence) - deps.evidenceIds.update(batch_evidence) - if "analyze_experimental_design" not in deps.toolCalls: - deps.toolCalls.append("analyze_experimental_design") - column_domains = { - name: cast(ColumnDomain, record["domain"]) - for name, record in records.items() - if record.get("domain") - in {"biological", "technical", "design", "ignore", "unknown"} - } - metrics_required: list[IntegrationMetric] = [] - if action == "evaluateHarmony": - metrics_required = ["iLISI", "proportionalBatchMixing"] - if categorical_coefficients: - metrics_required.extend(["cLISI", "graphConnectivity"]) - plan = BatchCorrectionPlan( - action=action, - batchColumns=batch_columns if action != "skip" else [], - preserveColumns=( - categorical_coefficients if action == "evaluateHarmony" else [] - ), - metricsRequired=metrics_required, - rationale=( - "Evaluate the exact declared categorical technical batch condition " - "against the uncorrected representation." - if action == "evaluateHarmony" - else "The exact batch condition is confounded with the study design." - if action == "unsafe" - else "No exact categorical technical batch condition was available." - ), - evidenceIds=sorted(set(batch_evidence)), - ) - decision = ExperimentalContextDecision( - columnDomains=column_domains, - coefficientsOfInterest=coefficients, - unitsOfInference=units, - batchCorrection=plan, - rationale=( - "Deterministic covariate characterization resolved the study design " - "after the model tool call failed." - ), - evidenceIds=known_evidence, - ) - return validate_experimental_context(decision, deps) - - def failed_experimental_context_result( deps: ExperimentalContextDependencies, *, error: Exception, - fallback_error: Exception, model_name: str, ) -> ExperimentalContextResult: - """Fail unattended execution when deterministic design evidence is insufficient.""" + """Keep a failed model run from selecting an unsupported scientific default.""" characterization = deps.characterization or CovariateCharacterization( status="failed", notes=["Deterministic covariate characterization is unavailable."], ) model_detail = str(error).replace("\n", " ").strip()[:500] - fallback_detail = str(fallback_error).replace("\n", " ").strip()[:500] return ExperimentalContextResult( status="failed", decision=ExperimentalContextDecision( @@ -743,87 +436,9 @@ def failed_experimental_context_result( notes=[ "The model did not produce a validated experimental-context decision.", f"Model failure: {model_detail}", - f"Deterministic recovery failure: {fallback_detail}", ], runInfo=AgentRunInfo( agentName="experimental_context_failed", modelName=model_name, ), ) - - -def pending_experimental_context_result( - deps: ExperimentalContextDependencies, - *, - error: UnexpectedModelBehavior, - model_name: str, -) -> ExperimentalContextResult: - """Pause when the model exhausts its bounded decision budget.""" - characterization = deps.characterization - if characterization is None: - characterization = characterize_covariates( - deps.store, - cellSelection=deps.cellSelection, - studyContext=( - f"{deps.studyContext}\nStudy objective: {deps.studyObjective}" - ), - model=None, - directions=deps.directions, - groupingArtifacts=_hto_artifact_map(deps), - ) - deps.characterization = characterization - if not deps.htoIdentityColumns: - deps.htoIdentityColumns = _hto_identity_columns(deps) - qc_profiles = list(deps.qcProfiles.values()) - if not qc_profiles: - qc_profiles = _offered_qc_profiles(deps, characterization) - contrast_plans = contrast_plans_from_characterization(characterization) - deps.contrastPlans = {plan.coefficient: plan for plan in contrast_plans} - evidence_ids = characterization_evidence(characterization) - evidence_ids.update(profile.evidenceId for profile in qc_profiles) - evidence_ids.update(source.sourceId for source in deps.qcMetricSources) - evidence_ids.update(item.evidenceId for item in deps.qcSourceConcordance) - evidence_ids.update(plan.evidenceId for plan in contrast_plans) - evidence_ids.update(f"htoIdentity:{column}" for column in deps.htoIdentityColumns) - evidence_ids.update( - _artifact_evidence_id(source) for source in deps.htoIdentityArtifacts - ) - deps.evidenceIds.update(evidence_ids) - question = ( - "The Experimental Context agent could not produce a validated scientific " - "decision. Provide explicit metadata roles, units of inference, cell-QC " - "profile, and batch-correction intent before continuing." - ) - decision = ExperimentalContextDecision( - batchCorrection=BatchCorrectionPlan(action="needsInput"), - cellQc=CellQcPlan.get_blank(), - rationale="No scientific decision was selected.", - evidenceIds=sorted(evidence_ids), - needsInput=[question], - ) - error_detail = str(error).replace("\n", " ").strip()[:500] - logger.warning( - "Experimental Context paused without a scientific decision: " - f"reason={error_detail}" - ) - return ExperimentalContextResult( - status=("failed" if characterization.status == "failed" else "needsInput"), - decision=decision, - characterization=characterization, - cellSelection=artifact_reference(deps.cellSelection), - cellQc=CellQcPlan.get_blank(), - qcProfiles=qc_profiles, - qcMetricSources=deps.qcMetricSources, - qcSourceConcordance=deps.qcSourceConcordance, - contrastPlans=contrast_plans, - qualityMetricArtifacts=deps.qualityMetricArtifacts, - htoIdentityColumns=deps.htoIdentityColumns, - htoIdentityArtifacts=deps.htoIdentityArtifacts, - batchSafety=list(deps.batchSafety.values()), - currentRepresentation=deps.currentRepresentation, - notes=[*characterization.notes, question, error_detail], - runInfo=AgentRunInfo( - agentName="experimental_context_needs_input", - modelName=model_name, - ), - ) diff --git a/scarf/agent/hypotheses/__init__.py b/scarf/agent/hypotheses/__init__.py deleted file mode 100644 index bbb42bb5..00000000 --- a/scarf/agent/hypotheses/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Evidence-gated hypothesis contracts and execution.""" - -from .contracts import ( - ClusterSelectionContract, - FeaturePanelPurpose, - HypothesisContract, - HypothesisExecutionStatus, - HypothesisFeaturePanel, - HypothesisTestExecution, -) -from .execution import execute_hypothesis_contract - -__all__ = [ - "ClusterSelectionContract", - "FeaturePanelPurpose", - "HypothesisContract", - "HypothesisExecutionStatus", - "HypothesisFeaturePanel", - "HypothesisTestExecution", - "execute_hypothesis_contract", -] diff --git a/scarf/agent/hypotheses/contracts.py b/scarf/agent/hypotheses/contracts.py deleted file mode 100644 index 41e15ded..00000000 --- a/scarf/agent/hypotheses/contracts.py +++ /dev/null @@ -1,141 +0,0 @@ -"""Serializable contracts for evidence-gated hypothesis tests.""" - -from typing import Literal - -from .._deps import AGENT_INSTALL_HINT -from ..experimental_context.contracts import ContrastPlan -from ..types import AgentDataModel, ArtifactReferenceModel - -try: - from pydantic import Field, model_validator -except ImportError as exc: - raise ImportError(AGENT_INSTALL_HINT) from exc - - -type FeaturePanelPurpose = Literal["explicit", "exploratoryMarkers"] -type HypothesisExecutionStatus = Literal["executed", "blocked", "needsInput"] - -_NORMALIZED_EXPRESSION_SCOPE = ( - "Sample-level normalized-expression distribution testing. This is not a " - "raw-count pseudobulk differential-expression model." -) - - -class HypothesisFeaturePanel(AgentDataModel): - """Features kept under one explicit or exploratory provenance label.""" - - panelId: str = "" - purpose: FeaturePanelPurpose = "explicit" - features: list[str] = Field(default_factory=list) - sourceArtifact: ArtifactReferenceModel | None = None - evidenceIds: list[str] = Field(default_factory=list) - - @model_validator(mode="after") - def validate_panel(self) -> "HypothesisFeaturePanel": - if self.panelId != self.panelId.strip(): - raise ValueError("Feature panel ids cannot contain surrounding whitespace") - if any( - not feature.strip() or feature != feature.strip() - for feature in self.features - ): - raise ValueError("Feature names must be non-empty trimmed strings") - if len(self.features) != len(set(self.features)): - raise ValueError("Feature names must be unique within a panel") - if self.sourceArtifact is not None and not self.sourceArtifact.artifactId: - raise ValueError("Feature panel source artifacts must be exact") - if self.purpose == "exploratoryMarkers" and self.sourceArtifact is None: - raise ValueError( - "Exploratory marker panels require their exact source artifact" - ) - return self - - -class ClusterSelectionContract(AgentDataModel): - """An exact cluster artifact and labels used for a within-cluster test.""" - - clusterArtifact: ArtifactReferenceModel - include: list[str | int | float | bool] = Field(default_factory=list) - - @model_validator(mode="after") - def validate_selection(self) -> "ClusterSelectionContract": - if not self.clusterArtifact.artifactId: - raise ValueError("Cluster selection requires an exact artifact") - if not self.include: - raise ValueError("Cluster selection requires at least one label") - keys = [(type(value).__name__, repr(value)) for value in self.include] - if len(keys) != len(set(keys)): - raise ValueError("Cluster labels must be unique") - return self - - -class HypothesisContract(AgentDataModel): - """One immutable-input hypothesis family licensed by a contrast plan.""" - - contractId: str = "" - familyId: str = "" - contrast: ContrastPlan = Field(default_factory=ContrastPlan.get_blank) - cellSelection: ArtifactReferenceModel | None = None - groupingArtifact: ArtifactReferenceModel | None = None - clusterSelection: ClusterSelectionContract | None = None - featurePanels: list[HypothesisFeaturePanel] = Field(default_factory=list) - fromAssay: str | None = None - adjustment: Literal["fdr_bh"] = "fdr_bh" - evidenceIds: list[str] = Field(default_factory=list) - - @model_validator(mode="after") - def validate_contract(self) -> "HypothesisContract": - for name, value in ( - ("contractId", self.contractId), - ("familyId", self.familyId), - ): - if value != value.strip(): - raise ValueError(f"{name} cannot contain surrounding whitespace") - panel_ids = [panel.panelId for panel in self.featurePanels] - if len(panel_ids) != len(set(panel_ids)): - raise ValueError("Hypothesis feature panel ids must be unique") - if self.cellSelection is not None and ( - self.cellSelection.scope != "datastore" - or self.cellSelection.kind != "cell_selection" - or not self.cellSelection.artifactId - ): - raise ValueError( - "Hypothesis cellSelection must be an exact datastore selection" - ) - if self.groupingArtifact is not None and not self.groupingArtifact.artifactId: - raise ValueError("Hypothesis groupingArtifact must be exact") - return self - - -class HypothesisTestExecution(AgentDataModel): - """Executed artifact references or explicit reasons no test was run.""" - - contractId: str = "" - familyId: str = "" - status: HypothesisExecutionStatus = "blocked" - contrast: ContrastPlan = Field(default_factory=ContrastPlan.get_blank) - featurePanels: list[HypothesisFeaturePanel] = Field(default_factory=list) - testedFeatures: list[str] = Field(default_factory=list) - inputCellSelection: ArtifactReferenceModel | None = None - effectiveCellSelection: ArtifactReferenceModel | None = None - groupingArtifact: ArtifactReferenceModel | None = None - clusterArtifact: ArtifactReferenceModel | None = None - statisticalTestArtifact: ArtifactReferenceModel | None = None - adjustment: Literal["fdr_bh"] = "fdr_bh" - blockedReasons: list[str] = Field(default_factory=list) - claimScope: str = _NORMALIZED_EXPRESSION_SCOPE - evidenceIds: list[str] = Field(default_factory=list) - - @model_validator(mode="after") - def validate_execution(self) -> "HypothesisTestExecution": - if self.status == "executed": - if self.statisticalTestArtifact is None or self.blockedReasons: - raise ValueError( - "Executed hypothesis tests require an artifact and no block" - ) - elif not self.blockedReasons: - raise ValueError("Blocked hypothesis tests require explicit reasons") - return self - - @classmethod - def get_blank(cls) -> "HypothesisTestExecution": - return cls(blockedReasons=["hypothesisContractIsUnresolved"]) diff --git a/scarf/agent/hypotheses/execution.py b/scarf/agent/hypotheses/execution.py deleted file mode 100644 index f6d9d133..00000000 --- a/scarf/agent/hypotheses/execution.py +++ /dev/null @@ -1,221 +0,0 @@ -"""Execute licensed hypothesis contracts through Scarf statistical tests.""" - -from typing import Any - -from ...metadata.selection import CellField -from ...storage.refs import ArtifactRef -from ..tools import artifact_reference, core_artifact_reference -from .contracts import ( - HypothesisContract, - HypothesisExecutionStatus, - HypothesisTestExecution, -) - - -def _blocked_execution( - contract: HypothesisContract, - *, - reasons: list[str], - status: HypothesisExecutionStatus, - effective_selection: ArtifactRef | None = None, -) -> HypothesisTestExecution: - return HypothesisTestExecution( - contractId=contract.contractId, - familyId=contract.familyId, - status=status, - contrast=contract.contrast, - featurePanels=contract.featurePanels, - inputCellSelection=contract.cellSelection, - effectiveCellSelection=( - artifact_reference(effective_selection) - if effective_selection is not None - else contract.cellSelection - ), - groupingArtifact=contract.groupingArtifact, - clusterArtifact=( - contract.clusterSelection.clusterArtifact - if contract.clusterSelection is not None - else None - ), - blockedReasons=list(dict.fromkeys(reasons)), - evidenceIds=list( - dict.fromkeys( - [ - *contract.evidenceIds, - *contract.contrast.evidenceIds, - *( - evidence_id - for panel in contract.featurePanels - for evidence_id in panel.evidenceIds - ), - ] - ) - ), - ) - - -def execute_hypothesis_contract( - store: Any, - contract: HypothesisContract, - *, - invalidate_cache: bool = False, -) -> HypothesisTestExecution: - """Execute one fully licensed family through ``run_statistical_testing``.""" - if not isinstance(contract, HypothesisContract): - raise TypeError("contract must be a HypothesisContract") - contrast = contract.contrast - if contrast.status != "licensed": - status: HypothesisExecutionStatus = ( - "needsInput" if contrast.status == "needsInput" else "blocked" - ) - return _blocked_execution( - contract, - reasons=contrast.blockedReasons or ["contrastIsNotLicensed"], - status=status, - ) - safety_failures = [ - reason - for passed, reason in ( - (contrast.betweenUnitDesign, "coefficientIsNotBetweenUnit"), - (contrast.replicationPassed, "insufficientIndependentReplication"), - (contrast.estimabilityPassed, "coefficientIsNotEstimable"), - ( - contrast.pairBy is None or contrast.pairedCoveragePassed is True, - "pairedCoverageIsIncomplete", - ), - ) - if not passed - ] - if safety_failures: - return _blocked_execution( - contract, - reasons=safety_failures, - status="blocked", - ) - if contract.cellSelection is None: - return _blocked_execution( - contract, - reasons=["cellSelectionIsUnresolved"], - status="needsInput", - ) - if not contract.featurePanels: - return _blocked_execution( - contract, - reasons=["featurePanelIsUnresolved"], - status="needsInput", - ) - tested_features = list( - dict.fromkeys( - feature for panel in contract.featurePanels for feature in panel.features - ) - ) - if not tested_features: - return _blocked_execution( - contract, - reasons=["featurePanelIsEmpty"], - status="needsInput", - ) - - input_selection = core_artifact_reference(contract.cellSelection) - if not isinstance(input_selection, ArtifactRef): - raise TypeError("cellSelection must resolve to an ArtifactRef") - effective_selection = input_selection - try: - if contract.clusterSelection is not None: - cluster_artifact = core_artifact_reference( - contract.clusterSelection.clusterArtifact - ) - if not isinstance(cluster_artifact, ArtifactRef): - raise TypeError("clusterArtifact must resolve to an ArtifactRef") - effective_selection = store.select_cells( - cluster_artifact, - include=contract.clusterSelection.include, - cell_selection=input_selection, - invalidate_cache=invalidate_cache, - ) - - grouping = ( - core_artifact_reference(contract.groupingArtifact) - if contract.groupingArtifact is not None - else CellField(contrast.coefficient, kind="categorical") - ) - if not isinstance(grouping, ArtifactRef | CellField): - raise TypeError("grouping source could not be resolved") - if contrast.test is None or contrast.sampleBy is None: - return _blocked_execution( - contract, - reasons=["contrastExecutionFieldsAreUnresolved"], - status="needsInput", - effective_selection=effective_selection, - ) - result = store.run_statistical_testing( - tested_features, - grouping, - cell_selection=effective_selection, - groups=contrast.groupOrder, - test=contrast.test, - adjustment=contract.adjustment, - sample_by=contrast.sampleBy, - pair_by=contrast.pairBy, - sample_stat=contrast.sampleStatistic, - expression_cutoff=contrast.expressionCutoff, - from_assay=contract.fromAssay, - skip_save=False, - invalidate_cache=invalidate_cache, - ) - except (KeyError, TypeError, ValueError) as exc: - return _blocked_execution( - contract, - reasons=[f"coreRejected:{type(exc).__name__}:{exc}"], - status="blocked", - effective_selection=effective_selection, - ) - - artifact = getattr(result, "artifact", None) - if not isinstance(artifact, ArtifactRef): - raise RuntimeError( - "run_statistical_testing did not persist an exact result artifact" - ) - if getattr(result, "method", None) != contrast.test: - raise RuntimeError("Statistical test method differs from its contrast license") - if list(getattr(result, "group_order", ())) != contrast.groupOrder: - raise RuntimeError("Statistical group order differs from its contrast license") - if getattr(result, "sample_by", None) != contrast.sampleBy: - raise RuntimeError("Statistical sample unit differs from its contrast license") - if getattr(result, "pair_by", None) != contrast.pairBy: - raise RuntimeError("Statistical pair unit differs from its contrast license") - return HypothesisTestExecution( - contractId=contract.contractId, - familyId=contract.familyId, - status="executed", - contrast=contrast, - featurePanels=contract.featurePanels, - testedFeatures=tested_features, - inputCellSelection=contract.cellSelection, - effectiveCellSelection=artifact_reference(effective_selection), - groupingArtifact=contract.groupingArtifact, - clusterArtifact=( - contract.clusterSelection.clusterArtifact - if contract.clusterSelection is not None - else None - ), - statisticalTestArtifact=artifact_reference(artifact), - adjustment=contract.adjustment, - evidenceIds=list( - dict.fromkeys( - [ - *contract.evidenceIds, - contrast.evidenceId, - *contrast.evidenceIds, - *( - evidence_id - for panel in contract.featurePanels - for evidence_id in panel.evidenceIds - ), - ] - ) - ), - ) - - -__all__ = ["execute_hypothesis_contract"] diff --git a/scarf/agent/ingest/common.py b/scarf/agent/ingest/common.py index 3cc47da2..2d8f8078 100644 --- a/scarf/agent/ingest/common.py +++ b/scarf/agent/ingest/common.py @@ -250,36 +250,6 @@ def finish( ) accepted_actions = [*convert_actions, action] resolved_action_labels = list(action_labels) - workflow_run = None - if summary_mode != "r": - from ..persistence.reports import create_agent_workflow - - try: - workflow_run = create_agent_workflow(zarr_path) - except AGENT_PERSISTENCE_ERRORS as exc: - return IngestResult( - status="failed", - format=format_name, - zarrPath=zarr_path, - assayNames=assay_names, - summary=summary, - decision=decision, - actions=resolved_action_labels, - acceptedActions=accepted_actions, - notes=[ - *notes, - failure_note("create agent workflow", exc), - f"The converted Scarf store remains available at {zarr_path}", - ], - ) - resolved_action_labels.append("create_agent_workflow") - accepted_actions.append( - { - "op": "createAgentWorkflow", - "zarrPath": zarr_path, - "workflowRunId": workflow_run.workflowRunId, - } - ) return done( format_name=format_name, zarr_path=zarr_path, @@ -288,7 +258,6 @@ def finish( accepted_actions=accepted_actions, action_labels=resolved_action_labels, notes=notes, - workflow_run=workflow_run, decision=decision, ) diff --git a/scarf/agent/ingest/result.py b/scarf/agent/ingest/result.py index 27c9a2a6..246719f8 100644 --- a/scarf/agent/ingest/result.py +++ b/scarf/agent/ingest/result.py @@ -4,7 +4,6 @@ from typing import Any from .._deps import AGENT_INSTALL_HINT -from ..persistence.contracts import AgentWorkflowRun from ..types import AgentDataModel, Decision, NeedsInput, StageStatus try: @@ -17,7 +16,6 @@ class IngestResult(AgentDataModel): status: StageStatus format: str | None = None zarrPath: str | None = None - workflowRun: AgentWorkflowRun | None = None assayNames: list[str] = Field(default_factory=list) summary: dict[str, Any] | None = None decision: Decision | None = None @@ -30,16 +28,6 @@ class IngestResult(AgentDataModel): def get_blank(cls) -> "IngestResult": return cls(status="failed") - @classmethod - def get_example(cls) -> "IngestResult": - return cls( - status="done", - format="h5ad", - zarrPath="dataset.zarr", - workflowRun=AgentWorkflowRun.get_example(), - assayNames=["RNA"], - ) - def done( *, @@ -50,14 +38,12 @@ def done( accepted_actions: list[dict[str, Any]], action_labels: list[str], notes: list[str], - workflow_run: AgentWorkflowRun | None = None, decision: Decision | None = None, ) -> IngestResult: return IngestResult( status="done", format=format_name, zarrPath=zarr_path, - workflowRun=workflow_run, assayNames=assay_names, summary=summary, decision=decision, diff --git a/scarf/agent/orchestrator/__init__.py b/scarf/agent/orchestrator/__init__.py index f6cdc6b7..e18236a0 100644 --- a/scarf/agent/orchestrator/__init__.py +++ b/scarf/agent/orchestrator/__init__.py @@ -1,39 +1,15 @@ -"""Public facade for automated Scarf agent orchestration.""" +"""Advanced RNA workflow configuration and explicit resume.""" from .main import AgentOrchestrator -from .api import analyze_rna from .models import ( - AssayPreprocessingPlan, - AutomatedPreprocessingPlan, AutomatedWorkflowConfig, AutomatedWorkflowRequest, - AutomatedWorkflowResult, AutomatedWorkflowResumeRequest, - FinalAnalysisHandoff, - NativeAnalysisHandoff, - PreprocessedAssayHandoff, - WorkflowNeedsInput, - WorkflowQuestion, - WorkflowStageAttempt, - WorkflowStageLink, - artifact_model_to_ref, ) __all__ = [ "AgentOrchestrator", - "analyze_rna", - "AssayPreprocessingPlan", - "AutomatedPreprocessingPlan", "AutomatedWorkflowConfig", "AutomatedWorkflowRequest", - "AutomatedWorkflowResult", "AutomatedWorkflowResumeRequest", - "FinalAnalysisHandoff", - "NativeAnalysisHandoff", - "PreprocessedAssayHandoff", - "WorkflowNeedsInput", - "WorkflowQuestion", - "WorkflowStageAttempt", - "WorkflowStageLink", - "artifact_model_to_ref", ] diff --git a/scarf/agent/orchestrator/api.py b/scarf/agent/orchestrator/api.py index 63053092..fd3fb006 100644 --- a/scarf/agent/orchestrator/api.py +++ b/scarf/agent/orchestrator/api.py @@ -8,6 +8,7 @@ AutomatedWorkflowConfig, AutomatedWorkflowRequest, AutomatedWorkflowResult, + AnalysisError, ) @@ -19,26 +20,23 @@ def analyze_rna( study_objective: str, assay: str | None = None, zarr_path: str | Path | None = None, - max_candidates: int = 50, ) -> AutomatedWorkflowResult: """Choose and explain settings for one RNA assay, then execute them. ``source`` is a supported input file or an existing Zarr store. ``assay`` selects the RNA assay when the input contains more than one. The workflow - runs unattended and returns a structured outcome; check ``result.status`` - before consuming it. A completed result provides ``plot_embedding()``, + runs unattended and raises ``AnalysisError`` if essential evidence remains + unresolved or execution fails. A completed result provides ``plot_embedding()``, ``get_markers()``, and ``report()``. - ``max_candidates`` limits reserved candidate slots across the workflow. - Each pass reserves all configured alternatives before screening, including - conditional candidates that may not execute. Defaults reserve 25 slots for - the baseline and another 25 if a feature-policy revision runs. A limit of - 50 admits both passes; a smaller limit never shrinks the candidate lists. - This is admission control, not a count of actual executions or a wall-time - or provider-token limit. Use ``AgentOrchestrator`` and - ``AutomatedWorkflowConfig`` for explicit candidate lists, workspaces, - provider limits, and resumable pauses. + Work is bounded by the advanced orchestrator's screening and full-cohort + limits. Use that interface for explicit workspaces, execution limits, and + resumable pauses. An identical repeated call reuses or resumes exact work. """ + if model is None or isinstance(model, str) and not model.strip(): + raise ValueError( + "model must be a configured model or a non-empty model identifier" + ) request = AutomatedWorkflowRequest( sourcePath=str(source), zarrPath=str(zarr_path) if zarr_path is not None else None, @@ -50,6 +48,8 @@ def analyze_rna( ) config = AutomatedWorkflowConfig( inputPolicy="unattended", - maxCandidateEvaluations=max_candidates, ) - return AgentOrchestrator(model, config=config).run(request) + result = AgentOrchestrator(model, config=config).run(request) + if result.status != "completed": + raise AnalysisError(result) + return result diff --git a/scarf/agent/orchestrator/budget.py b/scarf/agent/orchestrator/budget.py index 23f7f343..5bd64ab4 100644 --- a/scarf/agent/orchestrator/budget.py +++ b/scarf/agent/orchestrator/budget.py @@ -1,111 +1,190 @@ -"""Conservative candidate admission using the existing stage journal.""" +"""Write-ahead admissions for bounded RNA experiments in the workflow journal.""" +import hashlib from typing import Any -from ...datastore.datastore import DataStore -from ...utils.logging import logger from .. import record_io -from ..persistence.contracts import AgentWorkflowRun from . import journal -from .models import ( - AutomatedWorkflowConfig, - OrchestrationRequestRecord, - WorkflowStageName, -) - - -_PASS_STAGES: dict[WorkflowStageName, str] = { - "preprocessing": "baseline", - "feature_policy_preprocessing": "featureRevision", -} - - -def candidate_pass_breakdown(config: AutomatedWorkflowConfig) -> dict[str, int]: - """Reserve every configured alternative, including conditional work.""" - return { - "hvg": 3 * len(config.hvgCandidateCounts), - "pca": len(config.pcaCandidateDimensions), - "nativeCorrection": 1, - "harmony": config.maxHarmonyCandidatesPerAssay, - "neighbors": len(config.graphNeighborCandidates), - "resolutions": len(config.leidenResolutionCandidates), - "refinement": config.maxRefinedCandidatesPerAssay, - } - - -def reserve_candidate_pass( - store: DataStore, - prefix: str, - workflow: AgentWorkflowRun, - request_record: OrchestrationRequestRecord, - stage_name: WorkflowStageName, -) -> dict[str, Any]: - """Admit a logical pass before its immutable started record is written. - - The caller persists the returned reservation in ``inputs.candidateBudget``. - Repeated attempts retain the same slots; conditional work does not refund - slots. This bounds candidate alternatives, not numerical work or wall time. - """ - if stage_name not in _PASS_STAGES: - raise ValueError("Candidate reservations require a preprocessing stage") - if workflow.workflowRunId != request_record.workflowRunId: - raise ValueError("Candidate budget belongs to a different workflow") - breakdown = candidate_pass_breakdown(request_record.config) - per_pass = sum(breakdown.values()) - admitted: set[str] = set() - for stage, logical_pass in _PASS_STAGES.items(): - expected = { - "logicalPass": logical_pass, - "reserved": per_pass, - "breakdown": breakdown, - } - for started in journal._stage_starts( - store.zw, prefix, workflow.workflowRunId, stage - ): - if ( - started.requestSha256 != request_record.requestSha256 - or started.configSha256 != request_record.configSha256 - ): - raise ValueError( - "Candidate reservation request/config identity differs" +from .models import AutomatedWorkflowConfig + + +class CandidateBudgetExceeded(ValueError): + """The next scientific experiment exceeds an explicit execution limit.""" + + +def candidate_identity(inputs: dict[str, Any], *, graph: bool = False) -> str: + """Identify exact numerical inputs, omitting resolution for graph reuse.""" + values = dict(inputs) + parameters = dict(values["parameters"]) + parameters.pop("candidateId", None) + if graph: + parameters.pop("leidenResolution", None) + values["parameters"] = parameters + return hashlib.sha256(record_io.canonical_json_bytes(values)).hexdigest() + + +class CandidateBudget: + """Reconstruct bounded admissions from fixed immutable journal slots.""" + + def __init__( + self, + store: Any, + prefix: str, + workflow_run_id: str, + config: AutomatedWorkflowConfig, + provenance: dict[str, Any], + ) -> None: + self.store = store + self.prefix = prefix + self.workflow_run_id = workflow_run_id + self.config = config + self.provenance = provenance + self.admissions: dict[str, list[dict[str, Any]]] = {} + for scope in ("sample0", "sample1", "full"): + limit = ( + config.maxFullPartitions + if scope == "full" + else config.maxScreeningEvaluations + ) + rows: list[dict[str, Any]] = [] + for slot in range(limit): + row = journal.load_checkpoint( + store, + prefix, + workflow_run_id, + self._key(scope, slot, "admission"), + inputs=provenance, ) - reservation = started.inputs.get("candidateBudget") - if reservation is None: - if started.inputs.get("candidateBudgetRejected") is True: + if row is None: continue - if stage == "feature_policy_preprocessing" and isinstance( - started.inputs.get("baselineAttemptId"), str + if ( + row.get("slot") != slot + or row.get("scope") != scope + or slot != len(rows) ): - continue - raise ValueError( - "Preprocessing history lacks its candidate reservation; " - "start a new workflow" - ) - if record_io.canonical_json_bytes( - reservation - ) != record_io.canonical_json_bytes(expected): - raise ValueError( - f"Persisted {logical_pass} candidate reservation differs " - "from the immutable workflow configuration" + raise ValueError("Candidate admission history is inconsistent") + rows.append(row) + self.admissions[scope] = rows + + @staticmethod + def _key(scope: str, slot: int, kind: str) -> str: + return f"parameter_tuning/{scope}/evaluation{slot}/{kind}" + + def admit(self, scope: str, inputs: dict[str, Any]) -> dict[str, Any]: + if scope not in self.admissions: + raise ValueError("Unknown candidate execution scope") + identity = candidate_identity(inputs) + rows = self.admissions[scope] + for row in rows: + if row["identity"] == identity: + return row + graph_identity = candidate_identity(inputs, graph=True) + if scope == "full": + if len(rows) >= self.config.maxFullPartitions: + raise CandidateBudgetExceeded("Full-cohort partition limit reached") + graphs = {row["graphIdentity"] for row in rows} | {graph_identity} + if len(graphs) > self.config.maxFullGraphs: + raise CandidateBudgetExceeded("Full-cohort graph limit reached") + else: + if len(rows) >= self.config.maxScreeningEvaluations: + raise CandidateBudgetExceeded("Screening candidate limit reached") + total = sum(len(self.admissions[name]) for name in ("sample0", "sample1")) + if total >= self.config.maxTotalScreeningEvaluations: + raise CandidateBudgetExceeded("Total screening candidate limit reached") + row = { + "slot": len(rows), + "scope": scope, + "identity": identity, + "graphIdentity": graph_identity, + "executionInputs": inputs, + } + journal.save_checkpoint( + self.store, + self.prefix, + self.workflow_run_id, + self._key(scope, len(rows), "admission"), + inputs=self.provenance, + outputs=row, + ) + rows.append(row) + return row + + def check_many( + self, scope: str, inputs: list[dict[str, Any]] + ) -> dict[str, dict[str, Any]]: + """Check room for a comparison without committing an admission.""" + existing = self.admissions[scope] + identities = {row["identity"] for row in existing} + pending = { + candidate_identity(value): value + for value in inputs + if candidate_identity(value) not in identities + } + total = len(existing) + len(pending) + if scope == "full": + graphs = {row["graphIdentity"] for row in existing} | { + candidate_identity(value, graph=True) for value in pending.values() + } + if ( + total > self.config.maxFullPartitions + or len(graphs) > self.config.maxFullGraphs + ): + raise CandidateBudgetExceeded( + "The full-cohort comparison exceeds the remaining graph/partition limit" ) - admitted.add(logical_pass) - logical_pass = _PASS_STAGES[stage_name] - if logical_pass == "featureRevision" and "baseline" not in admitted: - raise ValueError("Feature revision requires a reserved baseline pass") - total = per_pass * len(admitted | {logical_pass}) - limit = request_record.config.maxCandidateEvaluations - details = ", ".join(f"{name}={count}" for name, count in breakdown.items()) - if total > limit: - already_reserved = per_pass * len(admitted) - raise ValueError( - f"Candidate budget exceeded before {logical_pass}: " - f"{already_reserved} slots already reserved, {per_pass} required " - f"for this pass ({details}), workflow limit={limit}. " - "Increase maxCandidateEvaluations or explicitly reduce the candidate " - "lists in a new workflow. No candidate lists were truncated." + elif ( + total > self.config.maxScreeningEvaluations + or sum(len(self.admissions[name]) for name in ("sample0", "sample1")) + + len(pending) + > self.config.maxTotalScreeningEvaluations + ): + raise CandidateBudgetExceeded( + "The screening comparison exceeds the remaining candidate limit" + ) + return pending + + def admit_many(self, scope: str, inputs: list[dict[str, Any]]) -> None: + """Check a baseline or matched pair in full before its first computation.""" + for value in self.check_many(scope, inputs).values(): + self.admit(scope, value) + + def completed(self, admission: dict[str, Any]) -> dict[str, Any] | None: + return journal.load_checkpoint( + self.store, + self.prefix, + self.workflow_run_id, + self._key(admission["scope"], admission["slot"], "complete"), + inputs=admission, ) - logger.info( - f"Candidate work: {logical_pass} reserves {per_pass} slots ({details}); " - f"workflow reserved {total}/{limit}. Actual evaluations may be fewer." - ) - return {"logicalPass": logical_pass, "reserved": per_pass, "breakdown": breakdown} + + def complete(self, admission: dict[str, Any], output: dict[str, Any]) -> None: + journal.save_checkpoint( + self.store, + self.prefix, + self.workflow_run_id, + self._key(admission["scope"], admission["slot"], "complete"), + inputs=admission, + outputs=output, + ) + + def summary(self) -> dict[str, Any]: + """Count distinct reserved and completed comparisons, including reuse.""" + scopes = {} + for scope, rows in self.admissions.items(): + completed = [row for row in rows if self.completed(row) is not None] + scopes[scope] = { + state: { + "graphs": len({row["graphIdentity"] for row in entries}), + "partitions": len({row["identity"] for row in entries}), + } + for state, entries in (("reserved", rows), ("completed", completed)) + } + return { + "scopes": scopes, + "limits": { + "perScreen": self.config.maxScreeningEvaluations, + "totalScreens": self.config.maxTotalScreeningEvaluations, + "fullPartitions": self.config.maxFullPartitions, + "fullGraphs": self.config.maxFullGraphs, + }, + } diff --git a/scarf/agent/orchestrator/context.py b/scarf/agent/orchestrator/context.py index 71417323..4d5a6e14 100644 --- a/scarf/agent/orchestrator/context.py +++ b/scarf/agent/orchestrator/context.py @@ -21,14 +21,11 @@ from ..experimental_context.study import build_study_contract from ..ingest import IngestResult from ..ingest.manifest import DatasetManifest, is_author_label_column -from ..persistence.contracts import ( - AgentInvocation, - AgentReportReference, - AgentWorkflowRun, -) from ..types import AgentRunInfo, ArtifactReferenceModel from . import journal from .models import ( + WorkflowIdentity, + StageEvidenceReference, OrchestrationRequestRecord, OrchestrationResumeRecord, WorkflowNeedsInput, @@ -118,7 +115,7 @@ def record_ingest_stage( self, store: DataStore, prefix: str, - workflow: AgentWorkflowRun, + workflow: WorkflowIdentity, request_record: OrchestrationRequestRecord, ingest_result: IngestResult, dataset_manifest: DatasetManifest | None = None, @@ -132,7 +129,7 @@ def record_ingest_stage( [], ) if existing is not None: - logger.info( + logger.debug( f"Workflow {workflow.workflowRunId}: reusing persisted ingest stage" ) return existing @@ -184,7 +181,7 @@ def record_ingest_stage( notes=ingest_result.notes, ) journal._save_outcome(store.zw, prefix, outcome) - logger.info( + logger.debug( f"Workflow {workflow.workflowRunId}: ingest recorded " f"{len(ingest_result.assayNames)} assay(s)" ) @@ -193,7 +190,7 @@ def record_ingest_stage( def data_enrichment_stage( self, store: DataStore, - workflow: AgentWorkflowRun, + workflow: WorkflowIdentity, request_record: OrchestrationRequestRecord, parents: Sequence[WorkflowStageLink], cell_selection: ArtifactReferenceModel, @@ -212,7 +209,7 @@ def data_enrichment_stage( parents, ) if existing is not None: - logger.info( + logger.debug( f"Workflow {workflow.workflowRunId}: reusing Data Enrichment report" ) report = journal.load_stage_report(store, existing, DataEnrichmentReport) @@ -228,7 +225,7 @@ def data_enrichment_stage( return existing, report request = request_record.request selected_assays = [selected] - logger.info( + logger.debug( f"Workflow {workflow.workflowRunId}: Data Enrichment will inspect " f"{len(selected_assays)} assay(s)" ) @@ -277,7 +274,6 @@ def data_enrichment_stage( recovered = journal._recover_persisted_stage_report( store, started, - agent_name="data_enrichment", expected_type=DataEnrichmentReport, ) if recovered is not None: @@ -285,13 +281,12 @@ def data_enrichment_stage( report = cast(DataEnrichmentReport, recovered_report) actions.append("recover_persisted_data_enrichment_report") else: - logger.info( + logger.debug( f"Workflow {workflow.workflowRunId}: invoking Data Enrichment" ) agent = DataEnrichmentAgent( self.model, config=request_record.config.agentRunConfig, - unattended=request_record.config.inputPolicy == "unattended", ) report = agent.run( store, @@ -304,22 +299,10 @@ def data_enrichment_stage( store, started, report, - invocation=AgentInvocation( - agentName="data_enrichment", - inputs={ - "context": enrichment_context.model_dump(mode="json"), - "assays": selected_assays, - "cellSelection": cell_selection.model_dump(mode="json"), - "cacheDir": request_record.config.cacheDir, - "allowDownload": request_record.config.allowDownloads, - }, - artifacts={"cellSelection": cell_selection}, - runConfig=agent.config, - ), expected_type=DataEnrichmentReport, ) report = cast(DataEnrichmentReport, saved_report) - logger.info( + logger.debug( f"Workflow {workflow.workflowRunId}: Data Enrichment returned " f"status={report.status!r}, policies={len(report.policies)}, " f"inspections={len(report.inspections)}" @@ -387,10 +370,6 @@ def data_enrichment_stage( notes=report.limitations, ) journal._save_outcome(store.zw, prefix, outcome) - if outcome.status == "failed": - journal.finalize_failed( - store, workflow, outcome.error or "enrichment failed" - ) return outcome, report except Exception as exc: outcome = journal.finish_exception( @@ -405,10 +384,10 @@ def data_enrichment_stage( ) return outcome, DataEnrichmentReport.get_blank() - def _hto_stage( + def _rna_quality_metrics_stage( self, store: DataStore, - workflow: AgentWorkflowRun, + workflow: WorkflowIdentity, request_record: OrchestrationRequestRecord, parents: Sequence[WorkflowStageLink], enrichment: DataEnrichmentReport, @@ -430,7 +409,7 @@ def _hto_stage( store, prefix, workflow.workflowRunId, - "hto_demultiplexing", + "rna_quality_metrics", request_record, parents, ) @@ -449,17 +428,15 @@ def _hto_stage( raise ValueError( "Saved automatic HTO processing is unsupported; start a new RNA workflow." ) - logger.info( - f"Workflow {workflow.workflowRunId}: reusing RNA quality metrics" - ) + logger.info("Reusing RNA quality metrics") return existing cell_selection_ref = artifact_model_to_ref(cell_selection) - logger.info(f"Workflow {workflow.workflowRunId}: computing RNA quality metrics") + logger.info("Computing RNA quality metrics") started = journal._start_attempt( store.zw, prefix, workflow.workflowRunId, - "hto_demultiplexing", + "rna_quality_metrics", request_record, parents, inputs={ @@ -585,9 +562,7 @@ def _hto_stage( actions=actions, ) journal._save_outcome(store.zw, prefix, outcome) - logger.info( - f"Workflow {workflow.workflowRunId}: RNA quality metrics completed" - ) + logger.info("RNA quality metrics completed") return outcome except Exception as exc: return journal.finish_exception( @@ -604,11 +579,11 @@ def _hto_stage( def experimental_context_stage( self, store: DataStore, - workflow: AgentWorkflowRun, + workflow: WorkflowIdentity, request_record: OrchestrationRequestRecord, parents: Sequence[WorkflowStageLink], cell_selection: ArtifactReferenceModel, - enrichment_reference: AgentReportReference, + enrichment_reference: StageEvidenceReference, quality_metric_artifacts: Sequence[NamedArtifactSource], hto_identity_artifacts: Sequence[NamedArtifactSource], answers: Mapping[str, Any], @@ -635,7 +610,7 @@ def experimental_context_stage( parents, ) if existing is not None: - logger.info( + logger.debug( f"Workflow {workflow.workflowRunId}: reusing Experimental Context " "report" ) @@ -660,14 +635,6 @@ def experimental_context_stage( raise ValueError( "Persisted Experimental Context HTO artifacts are stale" ) - record = journal.load_agent_record( - store, - existing.reportReferences[0], - ) - if record.invocation.artifacts != context_artifacts: - raise ValueError( - "Persisted Experimental Context invocation artifacts are stale" - ) return existing, resolved_report cell_selection_ref = artifact_model_to_ref(cell_selection) paused = journal._validated_done_outcome( @@ -680,7 +647,6 @@ def experimental_context_stage( required_status="needsInput", ) directions = dict(request_record.request.experimentalDirections) - directions["registeredQcOnly"] = True supplied_directions = answers.get("experimentalDirections") if isinstance(supplied_directions, Mapping): directions.update(dict(supplied_directions)) @@ -754,7 +720,7 @@ def find_held_out_references(value: Any) -> None: }, resume_record=resume_record, ) - logger.info( + logger.debug( f"Workflow {workflow.workflowRunId}: Experimental Context will evaluate " f"{len(quality_metric_artifacts)} quality metric artifact(s) and " f"{len(hto_identity_artifacts)} HTO identity artifact(s)" @@ -779,20 +745,13 @@ def find_held_out_references(value: Any) -> None: recovered = journal._recover_persisted_stage_report( store, started, - agent_name="experimental_context", expected_type=ExperimentalContextResult, ) if recovered is not None: recovered_report, reference = recovered report = cast(ExperimentalContextResult, recovered_report) - recovered_record = journal.load_agent_record(store, reference) - if recovered_record.invocation.artifacts != context_artifacts: - raise ValueError( - "Recovered Experimental Context invocation artifacts are stale" - ) actions.append("recover_persisted_experimental_context_report") else: - parent_reports = [journal._report_link(enrichment_reference)] if unsafe_resolution == "skip" or no_inference_resolution: assert paused is not None if not paused.reportReferences: @@ -821,14 +780,6 @@ def find_held_out_references(value: Any) -> None: raise ValueError( "Paused Experimental Context exact inputs are stale" ) - paused_record = journal.load_agent_record( - store, - paused.reportReferences[0], - ) - if paused_record.invocation.artifacts != context_artifacts: - raise ValueError( - "Paused Experimental Context invocation artifacts are stale" - ) prior_plan = prior_report.decision.batchCorrection if no_inference_resolution: plan_updates: dict[str, Any] = { @@ -887,19 +838,14 @@ def find_held_out_references(value: Any) -> None: } ) actions.append(resolution_action) - parent_reports.append( - journal._report_link(paused.reportReferences[0]) - ) - run_config = paused_record.invocation.runConfig else: - logger.info( + logger.debug( f"Workflow {workflow.workflowRunId}: invoking Experimental " "Context" ) agent = ExperimentalContextAgent( self.model, config=request_record.config.agentRunConfig, - unattended=request_record.config.inputPolicy == "unattended", ) report = agent.run( store, @@ -911,35 +857,10 @@ def find_held_out_references(value: Any) -> None: quality_metric_artifacts=quality_metric_artifacts, hto_identity_artifacts=hto_identity_artifacts, ) - run_config = agent.config saved_report, reference = journal._save_stage_report( store, started, report, - invocation=AgentInvocation( - agentName="experimental_context", - parentReports=parent_reports, - inputs={ - "studyContext": request_record.request.studyContext, - "studyObjective": request_record.request.studyObjective, - "cellSelection": cell_selection.model_dump(mode="json"), - "directions": directions, - "qualityMetricArtifacts": [ - source.model_dump(mode="json") - for source in quality_metric_artifacts - ], - "htoIdentityArtifacts": [ - source.model_dump(mode="json") - for source in hto_identity_artifacts - ], - "unsafeResolution": unsafe_resolution, - "deterministicResolution": ( - actions[-1] if actions else None - ), - }, - artifacts=context_artifacts, - runConfig=run_config, - ), expected_type=ExperimentalContextResult, ) report = cast(ExperimentalContextResult, saved_report) @@ -957,7 +878,7 @@ def find_held_out_references(value: Any) -> None: raise ValueError( "Experimental Context returned different HTO identity artifacts" ) - logger.info( + logger.debug( f"Workflow {workflow.workflowRunId}: Experimental Context returned " f"status={report.status!r}, batchAction=" f"{report.decision.batchCorrection.action!r}" @@ -1005,7 +926,6 @@ def find_held_out_references(value: Any) -> None: ) elif ( report.decision.batchCorrection.action == "unsafe" - and not request_record.config.runConfoundedHarmonyDiagnostic and request_record.config.inputPolicy != "unattended" ): batch_plan = report.decision.batchCorrection @@ -1053,7 +973,8 @@ def find_held_out_references(value: Any) -> None: study_objective=request_record.request.studyObjective, experimental_result=report, author_label_policy=(request_record.request.authorLabelPolicy), - physical_capture_column=physical_capture, + physical_capture_column=report.decision.physicalCaptureColumn + or physical_capture, ) outcome = journal._complete_attempt( started, @@ -1081,10 +1002,6 @@ def find_held_out_references(value: Any) -> None: notes=report.notes, ) journal._save_outcome(store.zw, prefix, outcome) - if outcome.status == "failed": - journal.finalize_failed( - store, workflow, outcome.error or "context failed" - ) return outcome, report except Exception as exc: outcome = journal.finish_exception( diff --git a/scarf/agent/orchestrator/decisions.py b/scarf/agent/orchestrator/decisions.py index 503ff1ad..e934bd5f 100644 --- a/scarf/agent/orchestrator/decisions.py +++ b/scarf/agent/orchestrator/decisions.py @@ -1,269 +1,66 @@ -"""Shared resolution and persistence for RNA workflow decisions.""" +"""Constrained RNA choices persisted with their owning stage evidence.""" import hashlib import json import time -from collections.abc import Mapping, Sequence +from collections.abc import Mapping from dataclasses import dataclass from typing import Any -from pydantic_ai.exceptions import AgentRunError - +from ...utils.logging import logger from .. import record_io from ..config.agent_exec import run_agent_sync from ..decisions.kernel import ( DecisionRecord, DecisionSelection, DecisionSource, - DecisionWorkflowRun, EvidenceBundle, - PendingDecision, - RevisionRequest, ) from ..decisions.rna import ( CompiledRnaDecision, RnaDecisionDefinition, compile_rna_decision, ) -from ..persistence.decisions import ( - attach_audited_rna_decision, - load_latest_decision_workflow_snapshot, - pause_decision_workflow, - save_decision_workflow_snapshot, -) +from . import journal from .models import OrchestrationRequestRecord, WorkflowQuestion @dataclass(frozen=True, slots=True) class DecisionResolution: - """Runtime result of resolving or pausing one exact checkpoint.""" + """One validated choice or an explicit unresolved scientific question.""" - workflow: DecisionWorkflowRun record: DecisionRecord | None compiled: CompiledRnaDecision | None - snapshotSha256: str - - @property - def pending(self) -> PendingDecision | None: - return self.workflow.pendingDecision - - -@dataclass(frozen=True, slots=True) -class DecisionReconsideration: - """Result of one downstream-evidence review of an active decision.""" - - selection: DecisionSelection | None - resolution: DecisionResolution | None - revised: bool - snapshotSha256: str - question: WorkflowQuestion | None = None + checkpointSha256: str + pending: WorkflowQuestion | None = None def _sha256(value: object) -> str: return hashlib.sha256(record_io.canonical_json_bytes(value)).hexdigest() -def _software_sha256(definition: RnaDecisionDefinition) -> str: - return _sha256( - { - "controller": "rnaDecisionResolver", - "definition": definition.model_dump(mode="json"), - } - ) - - -def _selection_evidence_for_human( - definition: RnaDecisionDefinition, - evidence: EvidenceBundle, - option_id: str, -) -> list[str]: - option = definition.spec.option_by_id()[option_id] - required = set(option.requiredEvidenceClasses) - evidence_ids = [ - item.evidenceId - for item in evidence.evidence - if not required or item.evidenceClass in required - ] - return list(dict.fromkeys([*option.requiredEvidenceIds, *evidence_ids])) - - -def _selection_for_option( - definition: RnaDecisionDefinition, - evidence: EvidenceBundle, - option_id: str, - *, - rationale: str, -) -> DecisionSelection: - evidence_ids = _selection_evidence_for_human(definition, evidence, option_id) - override_of: str | None = None - override_evidence_ids: list[str] = [] - selected = definition.spec.option_by_id()[option_id] - if ( - definition.spec.requireIndependentOverrideEvidence - and definition.spec.metricPreferredOptionId is not None - and option_id != definition.spec.metricPreferredOptionId - and selected.status in {"apply", "skip"} - ): - override_of = definition.spec.metricPreferredOptionId - required_ids = set(selected.requiredEvidenceIds) - override_evidence_ids = [ - item.evidenceId - for item in evidence.evidence - if item.evidenceClass - in { - "markerCoherence", - "resamplingStability", - "crossUnitSupport", - "protectedVariablePreservation", - } - and (not required_ids or item.evidenceId in required_ids) - ] - evidence_ids = list(dict.fromkeys([*evidence_ids, *override_evidence_ids])) - return _validate_selection( - definition, - evidence, - DecisionSelection( - selectedOptionId=option_id, - evidenceIds=evidence_ids, - rationale=rationale, - confidence="notApplicable", - overrideOfOptionId=override_of, - overrideEvidenceIds=override_evidence_ids, - ), - ) - - -def _unattended_option_id(definition: RnaDecisionDefinition) -> str: - options = definition.spec.option_by_id() - ordered = [ - definition.spec.metricPreferredOptionId, - definition.spec.baselineOptionId, - *(option.optionId for option in definition.spec.options), - ] - for option_id in ordered: - if option_id is not None and options[option_id].status != "defer": - return option_id - raise ValueError("The registered decision has no non-deferred option") - - -def _unattended_selection( - definition: RnaDecisionDefinition, - evidence: EvidenceBundle, - *, - option_id: str | None = None, - reason: str, -) -> DecisionSelection: - if "rule" not in definition.spec.allowedSources: - raise ValueError( - "The registered decision does not allow deterministic resolution" - ) - selected_option_id = option_id or _unattended_option_id(definition) - return _selection_for_option( - definition, - evidence, - selected_option_id, - rationale=reason, - ) - - -def _validate_selection( - definition: RnaDecisionDefinition, - evidence: EvidenceBundle, - selection: DecisionSelection, -) -> DecisionSelection: - options = definition.spec.option_by_id() - if selection.selectedOptionId not in options: - raise ValueError("selectedOptionId is not an offered decision option") - available = evidence.evidence_by_id() - if not set(selection.evidenceIds).issubset(available): - raise ValueError("Decision selection cites unavailable evidence") - selected = options[selection.selectedOptionId] - cited_classes = { - available[evidence_id].evidenceClass for evidence_id in selection.evidenceIds - } - if not set(selected.requiredEvidenceClasses).issubset(cited_classes): - raise ValueError("Decision selection omits a required evidence class") - if not set(selected.requiredEvidenceIds).issubset(selection.evidenceIds): - raise ValueError("Decision selection omits option-specific evidence") - if selection.overrideOfOptionId is not None and ( - selection.overrideOfOptionId not in options - ): - raise ValueError("overrideOfOptionId is not an offered decision option") - if ( - definition.spec.requireIndependentOverrideEvidence - and definition.spec.metricPreferredOptionId is not None - and selection.selectedOptionId != definition.spec.metricPreferredOptionId - and selected.status in {"apply", "skip"} - ): - evidence_classes = { - available[evidence_id].evidenceClass - for evidence_id in selection.overrideEvidenceIds - if evidence_id in available - } - independent_classes = evidence_classes.intersection( - { - "markerCoherence", - "resamplingStability", - "crossUnitSupport", - "protectedVariablePreservation", - } - ) - if ( - selection.overrideOfOptionId != definition.spec.metricPreferredOptionId - or not set(selection.overrideEvidenceIds).issubset(selection.evidenceIds) - or ( - selected.requiredEvidenceIds - and not set(selection.overrideEvidenceIds).issubset( - selected.requiredEvidenceIds - ) - ) - or len(independent_classes) < 2 - ): - raise ValueError( - "A metric override requires two independent non-geometric " - "evidence classes" - ) - elif selection.overrideOfOptionId is not None or selection.overrideEvidenceIds: - raise ValueError( - "Override fields require an eligible metric-preferred override" - ) - return selection - - def _record_from_selection( - *, - workflow_run_id: str, definition: RnaDecisionDefinition, evidence: EvidenceBundle, selection: DecisionSelection, source: DecisionSource, model_name: str | None, prompt_sha256: str | None, - supersedes: str | None, created_at_ns: int, ) -> DecisionRecord: if evidence.contentSha256 is None: - raise ValueError("Decision evidence bundle requires a content checksum") - option = definition.spec.option_by_id()[selection.selectedOptionId] - identity = { - "workflowRunId": workflow_run_id, - "decisionId": definition.spec.decisionId, - "definitionVersion": definition.spec.definitionVersion, - "evidenceBundleId": evidence.bundleId, - "evidenceBundleSha256": evidence.contentSha256, - "selection": selection.model_dump(mode="json"), - "source": source, - "supersedes": supersedes, - } - record_id = f"decision:{definition.spec.decisionId}:{_sha256(identity)[:24]}" + raise ValueError("Decision evidence must have a content digest") + option = definition.spec.option_by_id().get(selection.selectedOptionId) + if option is None: + raise ValueError("Selected option is not offered") return DecisionRecord( - recordId=record_id, + recordId=f"decision:{definition.spec.decisionId}:{_sha256(selection.model_dump(mode='json'))[:24]}", decisionId=definition.spec.decisionId, definitionVersion=definition.spec.definitionVersion, evidenceBundleId=evidence.bundleId, evidenceBundleSha256=evidence.contentSha256, - offeredOptionIds=[offered.optionId for offered in definition.spec.options], - availableEvidenceIds=[item.evidenceId for item in evidence.evidence], + offeredOptionIds=[v.optionId for v in definition.spec.options], + availableEvidenceIds=[v.evidenceId for v in evidence.evidence], selectedOptionId=selection.selectedOptionId, status=option.status, source=source, @@ -273,346 +70,88 @@ def _record_from_selection( protectedVariableEffects=list(selection.protectedVariableEffects), overrideOfOptionId=selection.overrideOfOptionId, overrideEvidenceIds=list(selection.overrideEvidenceIds), - promptSha256=prompt_sha256, modelName=model_name, - softwareSha256=_software_sha256(definition), - verificationId=f"verification:{record_id}", - supersedes=supersedes, + promptSha256=prompt_sha256, createdAtNs=created_at_ns, + softwareSha256=_sha256(definition.model_dump(mode="json")), ) -def _active_record( - workflow: DecisionWorkflowRun, - decision_id: str, -) -> DecisionRecord | None: - matches = [ - record - for record in workflow.active_decision_records() - if record.decisionId == decision_id - ] - if len(matches) > 1: - raise ValueError( - f"Decision workflow has multiple active {decision_id!r} records" - ) - return matches[0] if matches else None - - -class DecisionStagesMixin: - """Resolve every rule, agent, and human choice through one ledger path.""" +def _validate_selection( + definition: RnaDecisionDefinition, + evidence: EvidenceBundle, + selection: DecisionSelection, +) -> DecisionSelection: + record = _record_from_selection( + definition, evidence, selection, "agent", None, None, 0 + ) + compile_rna_decision(definition, evidence, record) + return selection - model: Any - def _load_or_create_decision_workflow( - self, - store: Any, - request_record: OrchestrationRequestRecord, - ) -> tuple[DecisionWorkflowRun, str]: - try: - snapshot = load_latest_decision_workflow_snapshot( - store, - request_record.workflowRunId, - workspace=request_record.request.workspace, - ) - except KeyError: - workflow = DecisionWorkflowRun( - workflowRunId=request_record.workflowRunId, - maxRevisions=request_record.config.maxRevisions, - ) - snapshot = save_decision_workflow_snapshot( - store, - workflow, - workspace=request_record.request.workspace, - ) - if snapshot.workflow.maxRevisions != request_record.config.maxRevisions: - raise ValueError( - "Persisted decision revision limit differs from the request" - ) - return snapshot.workflow, snapshot.contentSha256 - - def _reconsider_rna_decision( - self, - store: Any, - request_record: OrchestrationRequestRecord, - definition: RnaDecisionDefinition, - evidence: EvidenceBundle, - answers: Mapping[str, Any], - *, - review_instructions: str | None = None, - visual_content: Sequence[Any] = (), - agent_selection: DecisionSelection | None = None, - agent_model_name: str | None = None, - ) -> DecisionReconsideration: - """Select from new evidence, then create a revision only when it changes.""" - evidence = ( - evidence - if evidence.contentSha256 is not None - else evidence.with_content_sha256() - ) - if evidence.contentSha256 is None: - raise RuntimeError("Reconsideration evidence checksum was not created") - workflow, snapshot_sha256 = self._load_or_create_decision_workflow( - store, - request_record, +def _human_selection( + definition: RnaDecisionDefinition, + evidence: EvidenceBundle, + answer: Mapping[str, Any], +) -> DecisionSelection: + if ( + set(answer) != {"decisionId", "optionId", "rationale"} + or answer["decisionId"] != definition.spec.decisionId + ): + raise ValueError( + "A decision answer must name this decisionId, optionId, and rationale" ) - target = _active_record(workflow, definition.spec.decisionId) - if target is None: - raise ValueError("Reconsideration requires an active target decision") - question_id = f"decision:{definition.spec.decisionId}Review" - raw_answer = answers.get(question_id) - if raw_answer is not None and agent_selection is not None: - raise ValueError( - "Decision reconsideration cannot combine human and agent selections" - ) - source: DecisionSource - model_name: str | None = None - if raw_answer is not None: - if not isinstance(raw_answer, Mapping): - raise ValueError("Human reconsideration answer must be a mapping") - if set(raw_answer) != {"decisionId", "optionId", "rationale"}: - raise ValueError( - "Human reconsideration answer requires decisionId, optionId, " - "and rationale" - ) - if raw_answer.get("decisionId") != definition.spec.decisionId: - raise ValueError("Human reconsideration answer has a stale decisionId") - option_id = raw_answer.get("optionId") - rationale = raw_answer.get("rationale") - if not isinstance(option_id, str) or not isinstance(rationale, str): - raise ValueError("Human reconsideration answer has invalid values") - selection = _validate_selection( - definition, - evidence, - DecisionSelection( - selectedOptionId=option_id, - evidenceIds=_selection_evidence_for_human( - definition, - evidence, - option_id, - ), - rationale=rationale.strip(), - confidence="notApplicable", - ), - ) - source = "human" - elif agent_selection is not None: - selection = _validate_selection( - definition, - evidence, - agent_selection, - ) - source = "agent" - model_name = agent_model_name - else: - payload = { - "decisionId": definition.spec.decisionId, - "studyObjective": request_record.request.studyObjective, - "question": definition.spec.question, - "baselineOptionId": definition.spec.baselineOptionId, - "options": [ - option.model_dump(mode="json") for option in definition.spec.options - ], - "evidence": [ - item.model_dump(mode="json") for item in evidence.evidence - ], + option = definition.spec.option_by_id().get(answer["optionId"]) + if option is None: + raise ValueError("Human answer does not select an offered option") + ids = [ + v.evidenceId + for v in evidence.evidence + if not option.requiredEvidenceClasses + or v.evidenceClass in option.requiredEvidenceClasses + ] + ids = list(dict.fromkeys([*option.requiredEvidenceIds, *ids])) + override = ( + definition.spec.requireIndependentOverrideEvidence + and definition.spec.metricPreferredOptionId is not None + and option.optionId != definition.spec.metricPreferredOptionId + and option.status in {"apply", "skip"} + ) + override_ids = ( + [ + v.evidenceId + for v in evidence.evidence + if v.evidenceClass + in { + "markerCoherence", + "resamplingStability", + "crossUnitSupport", + "protectedVariablePreservation", } - text_prompt = json.dumps(payload, indent=2, sort_keys=True) - user_prompt: Any = ( - [text_prompt, *visual_content] if visual_content else text_prompt + and ( + not option.requiredEvidenceIds + or v.evidenceId in option.requiredEvidenceIds ) - try: - execution = run_agent_sync( - model=self.model, - output_type=DecisionSelection, - system_prompt=( - review_instructions - or ( - "Reconsider the active decision using only the " - "new evidence and registered options. Cite every " - "required evidence ID and class. Keep the active option " - "unless the evidence supports a specific replacement. " - "Do not invent operations, parameters, artifacts, or " - "evidence." - ) - ), - user_prompt=user_prompt, - config=request_record.config.agentRunConfig, - name=f"decision_{definition.spec.decisionId}_review", - output_validator=lambda value: _validate_selection( - definition, - evidence, - value, - ), - ) - except AgentRunError: - if request_record.config.inputPolicy == "unattended": - selection = _unattended_selection( - definition, - evidence, - option_id=target.selectedOptionId, - reason=( - "The reconsideration model failed, so the unattended " - "workflow retained the active registered option." - ), - ) - source = "rule" - else: - return DecisionReconsideration( - selection=None, - resolution=None, - revised=False, - snapshotSha256=snapshot_sha256, - question=WorkflowQuestion( - questionId=question_id, - decisionId=definition.spec.decisionId, - question=definition.spec.question, - options=[ - option.optionId for option in definition.spec.options - ], - evidenceIds=[item.evidenceId for item in evidence.evidence], - ), - ) - else: - if not isinstance(execution.output, DecisionSelection): - raise TypeError( - "Decision reconsideration returned an unexpected output type" - ) - selection = _validate_selection(definition, evidence, execution.output) - source = "agent" - model_name = execution.runInfo.modelName + ] + if override + else [] + ) + return DecisionSelection( + selectedOptionId=option.optionId, + evidenceIds=list(dict.fromkeys([*ids, *override_ids])), + rationale=answer["rationale"], + confidence="notApplicable", + overrideOfOptionId=definition.spec.metricPreferredOptionId + if override + else None, + overrideEvidenceIds=override_ids, + ) - selected_status = definition.spec.option_by_id()[ - selection.selectedOptionId - ].status - if selected_status == "defer": - if request_record.config.inputPolicy == "unattended": - selection = _unattended_selection( - definition, - evidence, - option_id=target.selectedOptionId, - reason=( - "The reconsideration model deferred, so the unattended " - "workflow retained the active registered option." - ), - ) - source = "rule" - selected_status = definition.spec.option_by_id()[ - selection.selectedOptionId - ].status - else: - return DecisionReconsideration( - selection=selection, - resolution=None, - revised=False, - snapshotSha256=snapshot_sha256, - question=WorkflowQuestion( - questionId=question_id, - decisionId=definition.spec.decisionId, - question=definition.spec.question, - options=[option.optionId for option in definition.spec.options], - evidenceIds=[item.evidenceId for item in evidence.evidence], - ), - ) - if selection.selectedOptionId == target.selectedOptionId: - return DecisionReconsideration( - selection=selection, - resolution=None, - revised=False, - snapshotSha256=snapshot_sha256, - ) - if len(workflow.revisionRequests) >= workflow.maxRevisions: - if request_record.config.inputPolicy == "unattended": - retained = _unattended_selection( - definition, - evidence, - option_id=target.selectedOptionId, - reason=( - "The revision budget is exhausted, so the unattended " - "workflow retained the active registered option." - ), - ) - return DecisionReconsideration( - selection=retained, - resolution=None, - revised=False, - snapshotSha256=snapshot_sha256, - ) - return DecisionReconsideration( - selection=selection, - resolution=None, - revised=False, - snapshotSha256=snapshot_sha256, - question=WorkflowQuestion( - questionId=question_id, - decisionId=definition.spec.decisionId, - question=( - "The observed evidence supports changing this decision, " - "but the bounded revision budget is exhausted. Retain the " - "active option explicitly or stop the workflow." - ), - options=[target.selectedOptionId], - evidenceIds=[item.evidenceId for item in evidence.evidence], - ), - ) - target_position = workflow.decisionRecords.index(target) - active_ids = {record.recordId for record in workflow.active_decision_records()} - invalidated = [ - record.recordId - for record in workflow.decisionRecords[target_position + 1 :] - if record.recordId in active_ids - ] - revision_id = ( - "revision:" - f"{_sha256({'target': target.recordId, 'bundle': evidence.bundleId, 'option': selection.selectedOptionId})[:24]}" - ) - if target.verificationId is None: - raise ValueError("Reconsideration target lacks deterministic verification") - revision = RevisionRequest( - revisionId=revision_id, - targetDecisionRecordId=target.recordId, - verificationId=target.verificationId, - replacementOptionId=selection.selectedOptionId, - reason=selection.rationale, - evidenceBundleId=evidence.bundleId, - evidenceBundleSha256=evidence.contentSha256, - availableEvidenceIds=[item.evidenceId for item in evidence.evidence], - evidenceIds=list(selection.evidenceIds), - invalidatesDecisionRecordIds=invalidated, - createdAtNs=time.time_ns(), - ) - if source == "agent": - resolution = self._resolve_rna_decision( - store, - request_record, - definition, - evidence, - {}, - agent_selection=selection, - agent_model_name=model_name, - revision=revision, - ) - else: - resolution = self._resolve_rna_decision( - store, - request_record, - definition, - evidence, - { - f"decision:{definition.spec.decisionId}": { - "decisionId": definition.spec.decisionId, - "optionId": selection.selectedOptionId, - "rationale": selection.rationale, - } - }, - revision=revision, - ) - return DecisionReconsideration( - selection=selection, - resolution=resolution, - revised=True, - snapshotSha256=resolution.snapshotSha256, - ) +class DecisionStagesMixin: + """Resolve choices through scientific validation and one stage-owned checkpoint.""" + + model: Any def _resolve_rna_decision( self, @@ -625,406 +164,159 @@ def _resolve_rna_decision( rule_selection: DecisionSelection | None = None, agent_selection: DecisionSelection | None = None, agent_model_name: str | None = None, - revision: RevisionRequest | None = None, ) -> DecisionResolution: evidence = ( - evidence - if evidence.contentSha256 is not None - else evidence.with_content_sha256() + evidence if evidence.contentSha256 else evidence.with_content_sha256() ) - evidence_sha256 = evidence.contentSha256 - if evidence_sha256 is None: - raise RuntimeError("Decision evidence checksum was not created") - if evidence.decisionId != definition.spec.decisionId: - raise ValueError("Evidence does not match the decision definition") - if evidence.bundleId != definition.spec.evidenceBundleId: - raise ValueError("Evidence bundle identity does not match the definition") - if revision is not None and ( - revision.evidenceBundleId != evidence.bundleId - or revision.evidenceBundleSha256 != evidence_sha256 - or revision.availableEvidenceIds - != [item.evidenceId for item in evidence.evidence] + if ( + evidence.decisionId != definition.spec.decisionId + or evidence.bundleId != definition.spec.evidenceBundleId ): - raise ValueError( - "Revision does not reference the exact replacement evidence bundle" - ) - - workflow, snapshot_sha256 = self._load_or_create_decision_workflow( - store, - request_record, + raise ValueError("Decision and exact evidence identities differ") + if rule_selection is not None and agent_selection is not None: + raise ValueError("A decision cannot have two supplied owners") + decision_id = definition.spec.decisionId + stage = ( + "preprocessing_plan" + if decision_id in {"qcGrouping", "cellQuality"} + else "preprocessing" ) - supersedes = revision.targetDecisionRecordId if revision is not None else None - existing = _active_record(workflow, definition.spec.decisionId) - if existing is not None and revision is None: - if ( - existing.definitionVersion != definition.spec.definitionVersion - or existing.evidenceBundleId != evidence.bundleId - or existing.offeredOptionIds - != [option.optionId for option in definition.spec.options] - or existing.availableEvidenceIds - != [item.evidenceId for item in evidence.evidence] - ): - raise ValueError( - "Persisted decision does not match the current definition and evidence" - ) - persisted_verifications = [ - verification - for verification in workflow.verificationRecords - if verification.decisionRecordId == existing.recordId - ] - if len(persisted_verifications) != 1: - raise ValueError( - "Persisted decision lacks one exact verification record" - ) - persisted_verification = persisted_verifications[0] - compiled = compile_rna_decision( - definition, - evidence, - existing, - created_at_ns=persisted_verification.createdAtNs, + question_id = f"decision:{decision_id}" + answer = answers.get(question_id) + identity = { + "requestSha256": request_record.requestSha256, + "configSha256": request_record.configSha256, + "definition": definition.model_dump(mode="json"), + "evidence": evidence.model_dump(mode="json"), + "answer": answer, + "ruleSelection": rule_selection.model_dump(mode="json") + if rule_selection + else None, + "agentSelection": agent_selection.model_dump(mode="json") + if agent_selection + else None, + } + digest = _sha256(identity) + key = f"{stage}/decisions/{decision_id}/{digest}" + prefix = journal._ensure_orchestration_store(store) + saved = journal.load_checkpoint( + store, prefix, request_record.workflowRunId, key, identity + ) + if saved is not None: + record = DecisionRecord.model_validate(saved["record"]) + compiled = compile_rna_decision(definition, evidence, record) + if [v.model_dump(mode="json") for v in compiled.checks] != saved["checks"]: + raise ValueError("Saved decision checks differ from exact replay") + pending = ( + WorkflowQuestion.model_validate(saved["pending"]) + if saved.get("pending") + else None ) - if compiled.verification != persisted_verification: - raise ValueError( - "Persisted verification does not match deterministic replay" - ) return DecisionResolution( - workflow=workflow, - record=existing, - compiled=compiled, - snapshotSha256=snapshot_sha256, + record, None if pending else compiled, digest, pending ) - if existing is None and revision is None: - latest_matching = next( - ( - record - for record in reversed(workflow.decisionRecords) - if record.decisionId == definition.spec.decisionId - ), - None, - ) - if ( - latest_matching is not None - and latest_matching.recordId - in workflow.invalidated_decision_record_ids() - ): - supersedes = latest_matching.recordId - - question_id = f"decision:{definition.spec.decisionId}" - raw_answer = answers.get(question_id) - source: DecisionSource - prompt_sha256: str | None = None - model_name: str | None = None - selection: DecisionSelection - - if rule_selection is not None and agent_selection is not None: - raise ValueError("A decision cannot have both rule and agent selections") + source: DecisionSource = "agent" + model_name = agent_model_name + prompt_hash = None if rule_selection is not None: - if raw_answer is not None: + if answer is not None: raise ValueError("A rule-owned decision cannot accept a human answer") + selection = rule_selection source = "rule" - selection = _validate_selection(definition, evidence, rule_selection) elif agent_selection is not None: - if raw_answer is not None: + if answer is not None: raise ValueError( - "A preselected agent decision cannot accept a human answer" + "A supplied agent decision cannot accept a human answer" ) - source = "agent" - selection = _validate_selection(definition, evidence, agent_selection) - model_name = agent_model_name - elif raw_answer is not None: - if not isinstance(raw_answer, Mapping): + selection = agent_selection + elif answer is not None: + if not isinstance(answer, Mapping): raise ValueError("Human decision answer must be a mapping") - if set(raw_answer) != {"decisionId", "optionId", "rationale"}: - raise ValueError( - "Human decision answer requires decisionId, optionId, and rationale" - ) - if raw_answer.get("decisionId") != definition.spec.decisionId: - raise ValueError("Human decision answer has a stale decisionId") - option_id = raw_answer.get("optionId") - rationale = raw_answer.get("rationale") - if not isinstance(option_id, str) or not isinstance(rationale, str): - raise ValueError("Human decision answer has invalid values") - evidence_ids = _selection_evidence_for_human( - definition, evidence, option_id - ) - override_of: str | None = None - override_evidence_ids: list[str] = [] - if ( - definition.spec.requireIndependentOverrideEvidence - and definition.spec.metricPreferredOptionId is not None - and option_id != definition.spec.metricPreferredOptionId - and definition.spec.option_by_id()[option_id].status - in {"apply", "skip"} - ): - override_of = definition.spec.metricPreferredOptionId - selected_option = definition.spec.option_by_id()[option_id] - option_evidence_ids = set(selected_option.requiredEvidenceIds) - override_evidence_ids = [ - item.evidenceId - for item in evidence.evidence - if item.evidenceClass - in { - "markerCoherence", - "resamplingStability", - "crossUnitSupport", - "protectedVariablePreservation", - } - and ( - not option_evidence_ids - or item.evidenceId in option_evidence_ids - ) - ] - evidence_ids = list( - dict.fromkeys([*evidence_ids, *override_evidence_ids]) - ) - selection = _validate_selection( - definition, - evidence, - DecisionSelection( - selectedOptionId=option_id, - evidenceIds=evidence_ids, - rationale=rationale.strip(), - confidence="notApplicable", - overrideOfOptionId=override_of, - overrideEvidenceIds=override_evidence_ids, - ), - ) + selection = _human_selection(definition, evidence, answer) source = "human" - elif workflow.status == "needsInput" and workflow.pendingDecision is not None: - if workflow.pendingDecision.decisionId != definition.spec.decisionId: - raise ValueError( - "Decision workflow is paused at another exact checkpoint" - ) - if request_record.config.inputPolicy == "unattended": - selection = _unattended_selection( - definition, - evidence, - reason=( - "The unattended workflow resolved the persisted checkpoint " - "with the registered metric-preferred or baseline option." - ), - ) - source = "rule" - else: - return DecisionResolution( - workflow=workflow, - record=None, - compiled=None, - snapshotSha256=snapshot_sha256, - ) else: payload = { - "decisionId": definition.spec.decisionId, + "studyContext": request_record.request.studyContext, "studyObjective": request_record.request.studyObjective, "question": definition.spec.question, - "baselineOptionId": definition.spec.baselineOptionId, - "metricPreferredOptionId": definition.spec.metricPreferredOptionId, - "requireIndependentOverrideEvidence": ( - definition.spec.requireIndependentOverrideEvidence - ), - "options": [ - option.model_dump(mode="json") for option in definition.spec.options - ], - "evidence": [ - item.model_dump(mode="json") for item in evidence.evidence - ], + "spec": definition.spec.model_dump(mode="json"), + "evidence": evidence.model_dump(mode="json"), } - user_prompt = json.dumps(payload, indent=2, sort_keys=True) - prompt_sha256 = hashlib.sha256(user_prompt.encode()).hexdigest() - try: - execution = run_agent_sync( - model=self.model, - output_type=DecisionSelection, - system_prompt=( - "The task is to select one offered option from the supplied " - "evidence. A valid selection includes the option ID, every " - "option-specific evidence ID, the required evidence classes, " - "and a concise rationale. Numeric parameters and operations " - "are fixed by the executor. When independent override " - "evidence is required, a non-preferred option also identifies " - "the preferred option it overrides and cites two independent " - "non-geometric evidence classes." - ), - user_prompt=user_prompt, - config=request_record.config.agentRunConfig, - name=f"rna_{definition.spec.decisionId}_decision", - output_validator=lambda value: _validate_selection( - definition, evidence, value - ), - ) - except AgentRunError as exc: - if request_record.config.inputPolicy == "unattended": - selection = _unattended_selection( - definition, - evidence, - reason=( - "The bounded model run failed, so the unattended " - "workflow selected the registered metric-preferred or " - "baseline option." - ), - ) - source = "rule" - prompt_sha256 = None - model_name = None - else: - pending = PendingDecision( - questionId=question_id, - decisionId=definition.spec.decisionId, - definitionVersion=definition.spec.definitionVersion, - evidenceBundleId=evidence.bundleId, - evidenceBundleSha256=evidence_sha256, - offeredOptionIds=[ - option.optionId for option in definition.spec.options - ], - availableEvidenceIds=[ - item.evidenceId for item in evidence.evidence - ], - reason=( - "The bounded model run did not return a valid registered " - f"selection ({type(exc).__name__})." - ), - createdAtNs=time.time_ns(), - ) - paused = pause_decision_workflow(workflow, pending) - snapshot = save_decision_workflow_snapshot( - store, - paused, - workspace=request_record.request.workspace, - ) - return DecisionResolution( - workflow=snapshot.workflow, - record=None, - compiled=None, - snapshotSha256=snapshot.contentSha256, - ) - else: - if not isinstance(execution.output, DecisionSelection): - raise TypeError( - "RNA decision model returned an unexpected output type" - ) - selection = _validate_selection(definition, evidence, execution.output) - source = "agent" - model_name = execution.runInfo.modelName - - selected_option = definition.spec.option_by_id()[selection.selectedOptionId] - if selected_option.status == "defer": - if request_record.config.inputPolicy == "unattended": - selection = _unattended_selection( - definition, - evidence, - reason=( - "The model deferred, so the unattended workflow selected " - "the registered metric-preferred or baseline option." - ), - ) - selected_option = definition.spec.option_by_id()[ - selection.selectedOptionId - ] - source = "rule" - prompt_sha256 = None - model_name = None - else: - if workflow.status == "needsInput": - active_pending = workflow.pendingDecision - if active_pending is None or ( - active_pending.decisionId != definition.spec.decisionId - or active_pending.definitionVersion - != definition.spec.definitionVersion - or active_pending.evidenceBundleId != evidence.bundleId - or active_pending.evidenceBundleSha256 != evidence_sha256 - or active_pending.offeredOptionIds - != [option.optionId for option in definition.spec.options] - or active_pending.availableEvidenceIds - != [item.evidenceId for item in evidence.evidence] - ): - raise ValueError( - "Deferred answer does not match the exact pending checkpoint" - ) - return DecisionResolution( - workflow=workflow, - record=None, - compiled=None, - snapshotSha256=snapshot_sha256, - ) - pending = PendingDecision( - questionId=question_id, - decisionId=definition.spec.decisionId, - definitionVersion=definition.spec.definitionVersion, - evidenceBundleId=evidence.bundleId, - evidenceBundleSha256=evidence_sha256, - offeredOptionIds=[ - option.optionId for option in definition.spec.options - ], - availableEvidenceIds=[ - item.evidenceId for item in evidence.evidence - ], - reason=selection.rationale, - createdAtNs=time.time_ns(), - ) - paused = pause_decision_workflow(workflow, pending) - snapshot = save_decision_workflow_snapshot( - store, - paused, - workspace=request_record.request.workspace, - ) - return DecisionResolution( - workflow=snapshot.workflow, - record=None, - compiled=None, - snapshotSha256=snapshot.contentSha256, - ) - - created_at_ns = time.time_ns() + prompt = json.dumps(payload, indent=2, sort_keys=True) + prompt_hash = hashlib.sha256(prompt.encode()).hexdigest() + execution = run_agent_sync( + model=self.model, + output_type=DecisionSelection, + system_prompt=( + "Assess the offered settings against the study objective using the observed quantitative and qualitative evidence. " + "The objective identifies questions and biology to protect; it does not predetermine the answer. " + "Select only an offered option and cite its required evidence. Explain the scientific consequence. " + "Do not infer nuisance from gene-family names alone. Retain defaults only when evidence supports them. " + "Defer essential unresolved questions. Model failure or a work limit never justifies an unsupported choice. " + "For QC, distinguish retained group coverage from preserved biological structure: " + "group counts and absence of unsafe flags do not establish balanced retention, " + "cell validity, or preservation of marker programs. Compare retention fractions " + "and metric-specific flags where supplied; unmeasured effects remain unknown. " + "Within-capture QC does not require an independent biological unit or a healthy reference. " + "An override needs the independent evidence required by the supplied specification." + ), + user_prompt=prompt, + config=request_record.config.agentRunConfig, + name=f"rna_{decision_id}_decision", + output_validator=lambda value: _validate_selection( + definition, evidence, value + ), + ) + selection = execution.output + if not isinstance(selection, DecisionSelection): + raise TypeError("Decision model returned an unexpected result") + model_name = execution.runInfo.modelName record = _record_from_selection( - workflow_run_id=request_record.workflowRunId, - definition=definition, - evidence=evidence, - selection=selection, - source=source, - model_name=model_name, - prompt_sha256=prompt_sha256, - supersedes=supersedes, - created_at_ns=created_at_ns, - ) - compiled = compile_rna_decision( definition, evidence, - record, - created_at_ns=created_at_ns, + selection, + source, + model_name, + prompt_hash, + time.time_ns(), + ) + compiled = compile_rna_decision(definition, evidence, record) + pending = ( + WorkflowQuestion( + questionId=question_id, + decisionId=decision_id, + question=f"{definition.spec.question} {record.rationale}", + options=[v.optionId for v in definition.spec.options], + evidenceIds=[v.evidenceId for v in evidence.evidence], + planChecksum=digest, + ) + if record.status in {"defer", "abstain"} + else None ) - updated = attach_audited_rna_decision( - workflow, - record, - compiled, - revision=revision, + value = { + "stage": stage, + "checkpointSha256": digest, + "spec": definition.spec.model_dump(mode="json"), + "evidence": evidence.model_dump(mode="json"), + "record": record.model_dump(mode="json"), + "checks": [v.model_dump(mode="json") for v in compiled.checks], + "pending": pending.model_dump(mode="json") if pending else None, + } + journal.save_checkpoint( + store, prefix, request_record.workflowRunId, key, identity, value ) - snapshot = save_decision_workflow_snapshot( - store, - updated, - workspace=request_record.request.workspace, + option = definition.spec.option_by_id()[record.selectedOptionId] + logger.info( + f"{definition.spec.question} Selected {option.label}: {record.rationale}" ) return DecisionResolution( - workflow=snapshot.workflow, - record=record, - compiled=compiled, - snapshotSha256=snapshot.contentSha256, + record, None if pending else compiled, digest, pending ) @staticmethod def _pending_decision_question( - resolution: DecisionResolution, - definition: RnaDecisionDefinition, + resolution: DecisionResolution, definition: RnaDecisionDefinition ) -> WorkflowQuestion: - pending = resolution.pending - if pending is None: - raise ValueError("Decision resolution has no pending checkpoint") - return WorkflowQuestion( - questionId=pending.questionId, - decisionId=pending.decisionId, - question=definition.spec.question, - options=list(pending.offeredOptionIds), - evidenceIds=list(pending.availableEvidenceIds), - ) - - -__all__ = ["DecisionResolution", "DecisionStagesMixin"] + if resolution.pending is None: + raise ValueError("Decision has no pending question") + return resolution.pending diff --git a/scarf/agent/orchestrator/finalization.py b/scarf/agent/orchestrator/finalization.py index 266168a9..519c7472 100644 --- a/scarf/agent/orchestrator/finalization.py +++ b/scarf/agent/orchestrator/finalization.py @@ -1,69 +1,44 @@ -"""Final analysis and biological interpretation workflow stages.""" +"""Validate the selected full RNA analysis and compute its final layout.""" -import json -from collections.abc import Mapping, Sequence -from typing import Any, Literal, cast +from collections.abc import Sequence +from typing import Any from ...datastore.datastore import DataStore +from ...graph.feature_projection import graph_cell_selection from ...utils.logging import logger -from ..biological_interpretation.agent import BiologicalInterpretationAgent -from ..biological_interpretation.contracts import ( - BiologicalContext, - BiologicalInterpretationReport, -) -from ..data_enrichment.contracts import DataEnrichmentReport -from ..experimental_context.contracts import ExperimentalContextResult -from ..experimental_context.study import StudyContract -from ..parameter_tuning.agent import ParameterTuningAgent from ..parameter_tuning.contracts import ParameterTuningReport -from ..persistence.contracts import ( - AgentInvocation, - AgentReportReference, - AgentWorkflowRun, -) -from ..persistence.decisions import ( - complete_decision_workflow, - load_latest_decision_workflow_snapshot, - save_decision_workflow_snapshot, -) -from ..types import ArtifactReferenceModel, ExperimentalBiologyHandoff +from ..parameter_tuning.selection import promote_parameter_candidate +from ..types import ArtifactReferenceModel from . import journal from .models import ( AutomatedPreprocessingPlan, FinalAnalysisHandoff, - NativeAnalysisHandoff, OrchestrationRequestRecord, OrchestrationResumeRecord, PreprocessedAssayHandoff, - ReductionMethod, - WorkflowNeedsInput, - WorkflowQuestion, + StageEvidenceReference, + WorkflowIdentity, WorkflowStageAttempt, WorkflowStageLink, artifact_model_to_ref, ) +from .rna import selected_store_rna_assay, validate_rna_handoffs, validate_rna_plan class FinalizationStagesMixin: - """Finalize layouts, clusters, markers, and biological interpretation.""" - - model: Any + """Finish one full-cohort RNA representation using its exact saved artifacts.""" def analysis_finalization_stage( self, store: DataStore, - workflow: AgentWorkflowRun, + workflow: WorkflowIdentity, request_record: OrchestrationRequestRecord, parents: Sequence[WorkflowStageLink], plan: AutomatedPreprocessingPlan, preprocessed: Sequence[PreprocessedAssayHandoff], tuning_report: ParameterTuningReport, - tuning_reference: AgentReportReference, - study_contract: StudyContract, + tuning_reference: StageEvidenceReference, *, - experimental: ExperimentalContextResult | None = None, - analysis_review_evidence: Mapping[str, Any] | None = None, - answers: Mapping[str, Any] | None = None, resume_record: OrchestrationResumeRecord | None = None, ) -> tuple[WorkflowStageAttempt, FinalAnalysisHandoff]: prefix = journal._ensure_orchestration_store(store) @@ -76,28 +51,10 @@ def analysis_finalization_stage( parents, ) if existing is not None: - logger.info( - f"Workflow {workflow.workflowRunId}: reusing finalized analysis" - ) - handoff = FinalAnalysisHandoff.model_validate( + logger.info("Reusing the validated final RNA analysis") + return existing, FinalAnalysisHandoff.model_validate( existing.outputs["finalAnalysis"] ) - persisted = journal.load_final_analysis_handoff( - store, - prefix, - workflow.workflowRunId, - handoff.handoffId, - ) - if persisted != handoff: - raise ValueError("Finalization outcome and handoff journal differ") - return existing, handoff - if tuning_report.cellSelection is None: - raise ValueError("Parameter Tuning lacks an exact cell selection") - cell_selection = tuning_report.cellSelection - if any(value.cellSelection != cell_selection for value in preprocessed): - raise ValueError( - "Finalization inputs do not share the selected tuning cells" - ) started = journal._start_attempt( store.zw, prefix, @@ -110,317 +67,185 @@ def analysis_finalization_stage( "preprocessedAssays": [ value.model_dump(mode="json") for value in preprocessed ], - "cellSelection": cell_selection.model_dump(mode="json"), - "finalClusters": ( - tuning_report.finalClusterArtifact.model_dump(mode="json") - if tuning_report.finalClusterArtifact is not None - else None - ), - "analysisReviewEvidence": dict(analysis_review_evidence or {}), }, resume_record=resume_record, ) - artifacts: dict[str, ArtifactReferenceModel] = {"cellSelection": cell_selection} - actions: list[str] = [] + artifacts: dict[str, ArtifactReferenceModel] = {} operations: list[dict[str, Any]] = [] - logger.info( - f"Workflow {workflow.workflowRunId}: finalizing " - f"{len(preprocessed)} native analysis route(s) and markers from " - f"assay {plan.markerAssay!r}" - ) try: - if ( - tuning_report.status != "done" - or tuning_report.finalClusterArtifact is None - ): - raise ValueError("Parameter Tuning has no finalized cluster branch") - if plan.primaryAssay != plan.markerAssay or len(preprocessed) != 1: + assay_name = selected_store_rna_assay(store, request_record.request) + validate_rna_plan(plan, assay_name) + validate_rna_handoffs(preprocessed, assay_name) + handoff = preprocessed[0] + cells = handoff.cellSelection + if cells is None or cells != tuning_report.cellSelection: raise ValueError( - "Decision-driven v1 finalization requires exactly one RNA assay" + "Finalization requires the original full-cohort selection, not sampled cells" ) - if tuning_report.recommendedIntegrationId is not None: + if handoff.normalized is None or handoff.markerFeatures is None: raise ValueError( - "Decision-driven v1 cannot finalize an integrated SNN or WNN graph" + "Finalization requires exact normalized and marker features" ) - preprocessed_assay = preprocessed[0] - if preprocessed_assay.assayType != "RNA": - raise ValueError("Automated finalization supports RNA only") - if preprocessed_assay.assay != plan.primaryAssay: - raise ValueError("The final RNA assay does not match preprocessing") if ( - preprocessed_assay.normalized is None - or preprocessed_assay.markerFeatures is None + tuning_report.status != "done" + or tuning_report.recommendedIntegrationId is not None + or tuning_report.assayReports ): raise ValueError( - "Finalization requires exact normalized and marker features" + "Finalization requires a completed single-RNA recommendation" ) - tuning_agent = ParameterTuningAgent( - self.model, - config=request_record.config.agentRunConfig, - ) - native_analyses, native_umaps = self.finalize_native_analyses( + if journal.read_stage_evidence( + store, tuning_reference + ) != tuning_report.model_dump(mode="json"): + raise ValueError("Final tuning differs from its committed evidence") + selected = promote_parameter_candidate( store, - tuning_agent, - request_record, - tuning_report, - {preprocessed_assay.assay: preprocessed_assay}, - artifacts, - actions, - operations, + report=tuning_report, + normalized=artifact_model_to_ref(handoff.normalized), ) - if len(native_analyses) != 1: - raise ValueError("Decision-driven v1 requires one native analysis") - graph_method, final_graph, final_initialization, final_umap = ( - self.finalize_selected_graph( - store, - plan, - tuning_report, - native_analyses, - native_umaps, - actions, - operations, + if selected.parameters.reductionMethod != "pca": + raise ValueError( + "RNA finalization requires the selected PCA representation" ) - ) - native = native_analyses[0] - if native.clusters is None: - raise ValueError("Selected native analysis lacks clusters") - final_clusters = native.clusters - if artifact_model_to_ref(final_clusters) != artifact_model_to_ref( - tuning_report.finalClusterArtifact - ): + selected_artifacts = { + name: ArtifactReferenceModel.model_validate( + value.model_dump(mode="json") + ) + for name, value in selected.artifacts.items() + } + required = {"pca", "connectivityMap", "clusters", "markerTable"} + if not required.issubset(selected_artifacts): raise ValueError( - "Finalization changed the selected cluster artifact identity" + f"Final candidate lacks required artifacts: {sorted(required - selected_artifacts.keys())}" ) - - assay_report = tuning_report.assayReports.get( - plan.primaryAssay, - tuning_report, + graph = selected_artifacts["connectivityMap"] + clusters = selected_artifacts["clusters"] + markers = selected_artifacts["markerTable"] + if tuning_report.finalClusterArtifact is None or artifact_model_to_ref( + clusters + ) != artifact_model_to_ref(tuning_report.finalClusterArtifact): + raise ValueError("Finalization changed the selected cluster artifact") + cells_ref = artifact_model_to_ref(cells) + graph_ref = artifact_model_to_ref(graph) + if graph_cell_selection(store.zw, graph_ref) != cells_ref: + raise ValueError( + "Selected graph does not contain the full-cohort selection" + ) + for label, ref in (("clusters", clusters), ("markers", markers)): + status = store.inspect_artifact(artifact_model_to_ref(ref)) + if not status.complete or ref.assay != assay_name: + raise ValueError( + f"Final {label} are incomplete or belong to another assay" + ) + inputs = status.inputs or {} + if inputs.get("cell_selection") != cells_ref.to_dict(): + raise ValueError(f"Final {label} use a different cell selection") + parent_key, parent = ( + ("graph", graph) if label == "clusters" else ("clusters", clusters) + ) + if inputs.get(parent_key) != artifact_model_to_ref(parent).to_dict(): + raise ValueError( + f"Final {label} do not match the selected {parent_key}" + ) + for ref in selected_artifacts.values(): + store.load_artifact(artifact_model_to_ref(ref)) + initialization_ref = store.build_embedding_initialization( + artifact_model_to_ref(selected_artifacts["pca"]), + n_centroids=min(1000, handoff.nCells), + rand_state=4466, + invalidate_cache=False, + ) + umap_ref = store.run_umap( + graph_ref, + initialization_ref, + parallel=False, + random_seed=4444, + invalidate_cache=False, ) - selected = next( - ( - evaluation - for evaluation in assay_report.evaluations - if evaluation.candidateId == assay_report.recommendedCandidateId - ), - None, + initialization = ArtifactReferenceModel.from_artifact_ref( + initialization_ref ) - if selected is None or selected.status != "done" or not selected.eligible: - raise ValueError("Final tuning selected an ineligible RNA candidate") - marker_record = selected.artifacts.get("markerTable") - if marker_record is None: - marker_ref = store.run_marker_search( - artifact_model_to_ref(final_clusters), - from_assay=plan.markerAssay, - features=artifact_model_to_ref(preprocessed_assay.markerFeatures), - invalidate_cache=False, - ) - marker_model = ArtifactReferenceModel.from_artifact_ref(marker_ref) - actions.append("run_final_marker_search") - operations.append( + umap = ArtifactReferenceModel.from_artifact_ref(umap_ref) + operations.extend( + [ { - "operation": "run_marker_search", - "clusters": final_clusters.model_dump(mode="json"), - "features": preprocessed_assay.markerFeatures.model_dump( - mode="json" - ), - "artifact": marker_model.model_dump(mode="json"), - } - ) - else: - marker_model = ArtifactReferenceModel.model_validate( - marker_record.model_dump() - ) - store.load_artifact(artifact_model_to_ref(marker_model)) - actions.append("reuse_selected_marker_table") - artifacts["markers"] = marker_model - - limitations = list( - dict.fromkeys([*plan.limitations, *tuning_report.limitations]) + "operation": "build_embedding_initialization", + "artifact": initialization.model_dump(mode="json"), + }, + {"operation": "run_umap", "artifact": umap.model_dump(mode="json")}, + ] ) doublet_scores = [ - ArtifactReferenceModel.model_validate(artifact.model_dump()) - for name, artifact in sorted(selected.artifacts.items()) + value + for name, value in sorted(selected_artifacts.items()) if name.startswith("doubletScore:") ] - doublet_score_selections = [ - ArtifactReferenceModel.model_validate(artifact.model_dump()) - for name, artifact in sorted(selected.artifacts.items()) + doublet_selections = [ + value + for name, value in sorted(selected_artifacts.items()) if name.startswith("doubletCellSelection:") ] - if len(doublet_scores) != len(doublet_score_selections): - raise ValueError( - "Advisory doublet scores lack exact cell-selection lineage" - ) + if len(doublet_scores) != len(doublet_selections): + raise ValueError("Advisory doublet scores lack exact selection lineage") + limitations = list( + dict.fromkeys([*plan.limitations, *tuning_report.limitations]) + ) doublet_limitations = [ warning for warning in selected.warnings if "doublet" in warning.lower() or "physical capture identity" in warning.lower() ] - limitations.extend(doublet_limitations) - if doublet_scores: - for index, doublet_model in enumerate(doublet_scores): - store.load_artifact(artifact_model_to_ref(doublet_model)) - artifacts[f"doubletScore{index}"] = doublet_model - doublet_selection = doublet_score_selections[index] - store.load_artifact(artifact_model_to_ref(doublet_selection)) - artifacts[f"doubletScoreSelection{index}"] = doublet_selection - actions.append("reuse_advisory_doublet_scores") - operations.append( - { - "operation": "reuse_advisory_doublet_scores", - "artifacts": [ - value.model_dump(mode="json") for value in doublet_scores - ], - "cellSelections": [ - value.model_dump(mode="json") - for value in doublet_score_selections - ], - } - ) - elif any( + if not doublet_scores and not any( warning.startswith("Advisory doublet scoring was not run for assay ") for warning in doublet_limitations ): - actions.append("record_unavailable_advisory_doublet_scores") - operations.append( - { - "operation": "record_unavailable_advisory_doublet_scores", - "limitations": doublet_limitations, - } - ) - else: raise ValueError( "Selected cluster evidence lacks advisory doublet scores" ) - - final_analysis = FinalAnalysisHandoff( + limitations.extend(doublet_limitations) + artifacts.update(selected_artifacts) + artifacts.update( + cellSelection=cells, + normalized=handoff.normalized, + graph=graph, + clusters=clusters, + markers=markers, + markerFeatures=handoff.markerFeatures, + embeddingInitialization=initialization, + umap=umap, + ) + final = FinalAnalysisHandoff( workflowRunId=workflow.workflowRunId, - primaryAssay=plan.primaryAssay, - markerAssay=plan.markerAssay, - cellSelection=cell_selection, - nativeAnalyses=native_analyses, - graph=final_graph, - graphMethod=graph_method, - clusters=final_clusters, - embeddingInitialization=final_initialization, - umap=final_umap, - markerFeatures=preprocessed_assay.markerFeatures, - markers=marker_model, + primaryAssay=assay_name, + markerAssay=assay_name, + cellSelection=cells, + graph=graph, + clusters=clusters, + embeddingInitialization=initialization, + umap=umap, + markerFeatures=handoff.markerFeatures, + markers=markers, doubletScores=doublet_scores, - doubletScoreSelections=doublet_score_selections, - doubletEvidence={ - "scoreQuantiles": dict(selected.metrics.doubletScoreQuantiles), - "scoreByCapture": { - capture: dict(summary) - for capture, summary in ( - selected.metrics.doubletScoreByCapture.items() - ) - }, - "captureCoverage": (selected.metrics.doubletCaptureCoverage), - "maximumClusterConcentration": ( - selected.metrics.doubletHighScoreConcentration - ), - "policy": ( - "scoreAndFlagWithoutRemoval" - if doublet_scores - else "unavailable" - ), - "limitations": doublet_limitations, - }, - markerEvidence={ - "coherence": selected.metrics.markerCoherence, - "specificityMedian": (selected.metrics.markerSpecificityMedian), - "specificityByCluster": dict( - selected.metrics.markerSpecificityByCluster - ), - "aucByCluster": dict(selected.metrics.markerAucByCluster), - "topFeaturesByCluster": { - cluster: list(features) - for cluster, features in ( - selected.metrics.topMarkerGenes.items() - ) - }, - "defaultAndContextFamilyEnrichment": dict( - selected.metrics.markerFamilyEnrichment - ), - "protectedFamilies": list(selected.metrics.protectedMarkerFamilies), - }, - analysisEvidence={ - "analysisReview": dict(analysis_review_evidence or {}), - **( - { - "contrastPlans": [ - value.model_dump(mode="json") - for value in getattr( - experimental, - "contrastPlans", - [], - ) - ] - } - if experimental is not None - else {} - ), - }, - parameterReport=tuning_reference, + doubletScoreSelections=doublet_selections, limitations=list(dict.fromkeys(limitations)), - ).with_handoff_id() - journal.save_final_analysis_handoff(store, prefix, final_analysis) - actions.append("persist_final_analysis_handoff") - operations.append( - { - "operation": "persist_final_analysis_handoff", - "handoffId": final_analysis.handoffId, - "artifacts": { - name: value.model_dump(mode="json") - for name, value in artifacts.items() - }, - } ) - - decision_snapshot = load_latest_decision_workflow_snapshot( - store, - workflow.workflowRunId, - workspace=request_record.request.workspace, - ) - decision_workflow = decision_snapshot.workflow - if decision_workflow.status == "completed": - if decision_workflow.finalHandoffId != final_analysis.handoffId: - raise ValueError( - "Completed decision ledger references another final handoff" - ) - completed_snapshot = decision_snapshot - else: - completed_workflow = complete_decision_workflow( - decision_workflow, - final_analysis.handoffId, - ) - completed_snapshot = save_decision_workflow_snapshot( - store, - completed_workflow, - workspace=request_record.request.workspace, - ) outcome = journal._complete_attempt( started, status="done", artifacts=artifacts, outputs={ - "finalAnalysis": final_analysis.model_dump(mode="json"), - "handoffId": final_analysis.handoffId, - "decisionSnapshotSha256": (completed_snapshot.contentSha256), + "finalAnalysis": final.model_dump(mode="json"), "operations": operations, }, - actions=actions, - notes=final_analysis.limitations, + actions=["reuse_validated_full_cohort", "run_final_umap"], + notes=final.limitations, ) journal._save_outcome(store.zw, prefix, outcome) logger.info( - f"Workflow {workflow.workflowRunId}: finalized " - f"handoff={final_analysis.handoffId!r}, " - f"markerAssay={plan.markerAssay!r}" + f"Final RNA analysis: {selected.metrics.nClusters} populations; descriptive markers saved" ) - return outcome, final_analysis + return outcome, final except Exception as exc: outcome = journal.finish_exception( store, @@ -429,543 +254,6 @@ def analysis_finalization_stage( started, exc, artifacts=artifacts, - actions=actions, - outputs={ - "operations": operations, - }, + outputs={"operations": operations}, ) return outcome, FinalAnalysisHandoff.get_blank() - - def finalize_native_analyses( - self, - store: DataStore, - agent: ParameterTuningAgent, - request_record: OrchestrationRequestRecord, - tuning_report: ParameterTuningReport, - preprocessed_by_assay: Mapping[str, PreprocessedAssayHandoff], - artifacts: dict[str, ArtifactReferenceModel], - actions: list[str], - operations: list[dict[str, Any]], - ) -> tuple[ - list[NativeAnalysisHandoff], - dict[ - str, - tuple[ArtifactReferenceModel, ArtifactReferenceModel], - ], - ]: - native_handoffs: list[NativeAnalysisHandoff] = [] - native_umaps: dict[ - str, - tuple[ArtifactReferenceModel, ArtifactReferenceModel], - ] = {} - for assay, assay_report in tuning_report.assayReports.items(): - logger.info( - f"Finalizing native analysis for assay {assay!r} with candidate " - f"{assay_report.recommendedCandidateId!r}" - ) - preprocessed_assay = preprocessed_by_assay[assay] - normalized = preprocessed_assay.normalized - if normalized is None or preprocessed_assay.cellSelection is None: - raise ValueError( - f"Assay {assay!r} lacks normalization or cell selection" - ) - native_selection = preprocessed_assay.cellSelection.model_dump(mode="json") - promoted = agent.promote( - store, - report=assay_report, - normalized=artifact_model_to_ref(normalized), - identity_feature_limit=request_record.config.maxIdentityFeatures, - ) - reduction_key = promoted.parameters.reductionMethod - reduction_record = promoted.artifacts[reduction_key] - reduction_ref = artifact_model_to_ref( - ArtifactReferenceModel.model_validate(reduction_record.model_dump()) - ) - initialization_ref = store.build_embedding_initialization( - reduction_ref, - n_centroids=min(1000, preprocessed_assay.nCells), - rand_state=4466, - invalidate_cache=False, - ) - graph_record = promoted.artifacts["connectivityMap"] - graph_ref = artifact_model_to_ref( - ArtifactReferenceModel.model_validate(graph_record.model_dump()) - ) - umap_ref = store.run_umap( - graph_ref, - initialization_ref, - parallel=False, - random_seed=4444, - invalidate_cache=False, - ) - promoted_artifacts = { - name: ArtifactReferenceModel.model_validate(value.model_dump()) - for name, value in promoted.artifacts.items() - } - umap_model = ArtifactReferenceModel.from_artifact_ref(umap_ref) - initialization_model = ArtifactReferenceModel.from_artifact_ref( - initialization_ref - ) - native_umaps[assay] = (initialization_model, umap_model) - operations.extend( - [ - { - "operation": "promote_parameter_candidate", - "assay": assay, - "candidate": promoted.parameters.model_dump(mode="json"), - "normalized": normalized.model_dump(mode="json"), - "cellSelection": native_selection, - "identityFeatureLimit": ( - request_record.config.maxIdentityFeatures - ), - "artifacts": { - name: value.model_dump(mode="json") - for name, value in promoted_artifacts.items() - }, - }, - { - "operation": "build_embedding_initialization", - "assay": assay, - "reduction": ArtifactReferenceModel.model_validate( - reduction_record.model_dump() - ).model_dump(mode="json"), - "cellSelection": native_selection, - "nCentroids": min(1000, preprocessed_assay.nCells), - "randomSeed": 4466, - "invalidateCache": False, - "artifact": initialization_model.model_dump(mode="json"), - }, - { - "operation": "run_umap", - "assay": assay, - "graph": promoted_artifacts["connectivityMap"].model_dump( - mode="json" - ), - "initialization": initialization_model.model_dump(mode="json"), - "cellSelection": native_selection, - "parallel": False, - "randomSeed": 4444, - "invalidateCache": False, - "artifact": umap_model.model_dump(mode="json"), - }, - ] - ) - native_handoffs.append( - NativeAnalysisHandoff( - assay=assay, - reductionMethod=cast( - ReductionMethod, promoted.parameters.reductionMethod - ), - featureSelection=preprocessed_assay.graphFeatures, - markerFeatures=preprocessed_assay.markerFeatures, - normalized=normalized, - reduction=promoted_artifacts[reduction_key], - batchCorrection=promoted_artifacts.get("harmony"), - annIndex=promoted_artifacts["annIndex"], - embeddingInitialization=initialization_model, - neighbors=promoted_artifacts["neighbors"], - graph=promoted_artifacts["connectivityMap"], - clusters=promoted_artifacts["clusters"], - umap=umap_model, - ) - ) - artifacts.update( - { - f"{assay}_{name}": value - for name, value in { - **promoted_artifacts, - "embeddingInitialization": initialization_model, - "umap": umap_model, - }.items() - } - ) - actions.extend([f"promote_native:{assay}", f"run_native_umap:{assay}"]) - logger.info(f"Finalized native UMAP and clusters for assay {assay!r}") - return native_handoffs, native_umaps - - def finalize_selected_graph( - self, - store: DataStore, - plan: AutomatedPreprocessingPlan, - tuning_report: ParameterTuningReport, - native_handoffs: Sequence[NativeAnalysisHandoff], - native_umaps: Mapping[ - str, - tuple[ArtifactReferenceModel, ArtifactReferenceModel], - ], - actions: list[str], - operations: list[dict[str, Any]], - ) -> tuple[ - Literal["native", "snn", "wnn"], - ArtifactReferenceModel, - ArtifactReferenceModel, - ArtifactReferenceModel, - ]: - if tuning_report.cellSelection is None: - raise ValueError("Final graph selection lacks an exact cell selection") - if tuning_report.recommendedIntegrationId is not None: - raise ValueError( - "Automated RNA finalization cannot use an integrated graph" - ) - graph_assay = tuning_report.graphAssay - if graph_assay is None: - raise ValueError("Native final selection lacks graphAssay") - native = next(value for value in native_handoffs if value.assay == graph_assay) - if native.graph is None: - raise ValueError("Native final selection lacks graph artifact") - final_initialization, final_umap = native_umaps[graph_assay] - logger.info( - f"Reusing native graph and UMAP from assay {graph_assay!r} as final" - ) - return "native", native.graph, final_initialization, final_umap - - def biological_interpretation_stage( - self, - store: DataStore, - workflow: AgentWorkflowRun, - request_record: OrchestrationRequestRecord, - parents: Sequence[WorkflowStageLink], - enrichment: DataEnrichmentReport, - experimental: ExperimentalContextResult, - tuning_report: ParameterTuningReport, - final_analysis: FinalAnalysisHandoff, - enrichment_reference: AgentReportReference, - experimental_reference: AgentReportReference, - tuning_reference: AgentReportReference, - answers: Mapping[str, Any], - *, - resume_record: OrchestrationResumeRecord | None = None, - ) -> WorkflowStageAttempt: - prefix = journal._ensure_orchestration_store(store) - existing = journal._validated_done_outcome( - store, - prefix, - workflow.workflowRunId, - "biological_interpretation", - request_record, - parents, - ) - if existing is not None: - logger.info( - f"Workflow {workflow.workflowRunId}: reusing Biological " - "Interpretation report" - ) - journal.load_stage_report(store, existing, BiologicalInterpretationReport) - return existing - requested_coefficient = answers.get("primaryCoefficient") - if not isinstance(requested_coefficient, str) or not requested_coefficient: - directed = request_record.request.experimentalDirections.get( - "primaryCoefficient" - ) - requested_coefficient = directed if isinstance(directed, str) else None - coefficients = list(experimental.decision.coefficientsOfInterest) - logger.info( - f"Workflow {workflow.workflowRunId}: Biological Interpretation has " - f"{len(coefficients)} validated coefficient option(s)" - ) - if final_analysis.cellSelection is None: - raise ValueError("Final analysis lacks an exact cell selection") - started = journal._start_attempt( - store.zw, - prefix, - workflow.workflowRunId, - "biological_interpretation", - request_record, - parents, - inputs={ - "studyContext": request_record.request.studyContext, - "studyContextSummary": enrichment.studyContextSummary.model_dump( - mode="json" - ), - "experimentalContextReport": experimental_reference.model_dump( - mode="json" - ), - "finalAnalysis": final_analysis.model_dump(mode="json"), - "cellSelection": final_analysis.cellSelection.model_dump(mode="json"), - "primaryCoefficient": requested_coefficient, - "biologicalInterpretation": answers.get("biologicalInterpretation"), - }, - resume_record=resume_record, - ) - if requested_coefficient is None and len(coefficients) > 1: - logger.info( - f"Workflow {workflow.workflowRunId}: Biological Interpretation " - "requires a primary coefficient selection" - ) - outcome = journal._complete_attempt( - started, - status="needsInput", - artifacts={"cellSelection": final_analysis.cellSelection}, - needs_input=WorkflowNeedsInput( - questions=[ - WorkflowQuestion( - questionId="primaryCoefficient", - question=( - "Which validated coefficient should constrain " - "treatment observations?" - ), - options=coefficients, - evidenceIds=list(experimental.decision.evidenceIds), - ) - ] - ), - notes=[ - "Cluster identities can be interpreted after one treatment " - "coefficient is selected" - ], - ) - journal._save_outcome(store.zw, prefix, outcome) - return outcome - try: - experimental_handoff: ExperimentalBiologyHandoff | None = None - if coefficients: - experimental_handoff = experimental.to_biological_handoff( - requested_coefficient - ).model_copy(update={"cellSelection": final_analysis.cellSelection}) - tuning_handoff = tuning_report.to_biological_handoff() - if tuning_handoff.cellSelection != final_analysis.cellSelection: - raise ValueError( - "Biological handoffs do not share the final cell selection" - ) - marker_policy = next( - ( - value - for value in enrichment.policies - if value.assay == final_analysis.markerAssay - ), - None, - ) - summary = enrichment.studyContextSummary - biological_context = BiologicalContext( - organism=( - marker_policy.organismName - if marker_policy is not None - and marker_policy.organismName != "unknown" - else "" - ), - studyContext=request_record.request.studyContext, - tissue=", ".join(summary.tissueReferences), - cellTypeReferences=list(summary.cellTypeReferences), - experimentalDetails=[ - *summary.experimentalReferences, - *final_analysis.limitations, - ], - treatmentQuestion=str( - request_record.request.experimentalDirections.get( - "treatmentQuestion", "" - ) - ), - ) - biological_answer = answers.get("biologicalInterpretation") - if isinstance(biological_answer, str) and biological_answer.strip(): - biological_context = biological_context.model_copy( - update={ - "experimentalDetails": [ - *biological_context.experimentalDetails, - biological_answer.strip(), - ] - } - ) - elif isinstance(biological_answer, Mapping): - biological_context = biological_context.model_copy( - update={ - "experimentalDetails": [ - *biological_context.experimentalDetails, - json.dumps(dict(biological_answer), sort_keys=True), - ] - } - ) - if ( - final_analysis.clusters is None - or final_analysis.markers is None - or final_analysis.markerFeatures is None - ): - raise ValueError("Final analysis lacks clusters or marker artifacts") - recovered = journal._recover_persisted_stage_report( - store, - started, - agent_name="biological_interpretation", - expected_type=BiologicalInterpretationReport, - ) - if recovered is not None: - recovered_report, reference = recovered - report = cast(BiologicalInterpretationReport, recovered_report) - recovery_actions = [ - "recover_persisted_biological_interpretation_report" - ] - else: - recovery_actions = [] - logger.info( - f"Workflow {workflow.workflowRunId}: invoking Biological " - "Interpretation" - ) - agent = BiologicalInterpretationAgent( - self.model, - config=request_record.config.agentRunConfig, - ) - report = agent.run( - store, - cluster=artifact_model_to_ref(final_analysis.clusters), - biological_context=biological_context, - from_assay=final_analysis.markerAssay, - graph_assay=tuning_handoff.graphAssay, - marker_assay_type=( - marker_policy.assayModality - if marker_policy is not None - else None - ), - tuning_handoff=tuning_handoff, - experimental_handoff=experimental_handoff, - marker=artifact_model_to_ref(final_analysis.markers), - marker_features=artifact_model_to_ref( - final_analysis.markerFeatures - ), - allow_marker_search=False, - ) - if marker_policy is not None and marker_policy.assayModality == "ATAC": - atac_limitation = ( - "ATAC peak markers are descriptive, so all cell identities " - "remain low-confidence hypotheses." - ) - report = report.model_copy( - update={ - "clusterInterpretations": [ - value.model_copy( - update={ - "identityIsHypothesis": True, - "confidence": "low", - } - ) - for value in report.clusterInterpretations - ], - "limitations": list( - dict.fromkeys([*report.limitations, atac_limitation]) - ), - } - ) - parent_reports = [ - journal._report_link(enrichment_reference), - journal._report_link(experimental_reference), - journal._report_link(tuning_reference), - ] - saved_report, reference = journal._save_stage_report( - store, - started, - report, - invocation=AgentInvocation( - agentName="biological_interpretation", - parentReports=parent_reports, - inputs={ - "biologicalContext": biological_context.model_dump( - mode="json" - ), - "cellSelection": ( - final_analysis.cellSelection.model_dump(mode="json") - ), - "markerAssay": final_analysis.markerAssay, - "graphAssay": tuning_handoff.graphAssay, - "markerAssayType": ( - marker_policy.assayModality - if marker_policy is not None - else None - ), - "allowMarkerSearch": False, - "studyContextSummary": summary.model_dump(mode="json"), - "experimentalContextReport": ( - experimental_reference.model_dump(mode="json") - ), - }, - artifacts={ - "cellSelection": final_analysis.cellSelection, - "clusters": final_analysis.clusters, - "markers": final_analysis.markers, - "markerFeatures": final_analysis.markerFeatures, - }, - runConfig=agent.config, - experimentalBiologyHandoff=experimental_handoff, - tuningBiologyHandoff=tuning_handoff, - ), - expected_type=BiologicalInterpretationReport, - ) - report = cast(BiologicalInterpretationReport, saved_report) - logger.info( - f"Workflow {workflow.workflowRunId}: Biological Interpretation " - f"returned status={report.status!r}, clusters=" - f"{len(report.clusterInterpretations)}, treatments=" - f"{len(report.treatmentObservations)}" - ) - if report.status == "needsInput": - needs_input = report.needsInput - assert needs_input is not None - outcome = journal._complete_attempt( - started, - status="needsInput", - report_references=[reference], - artifacts={ - "cellSelection": final_analysis.cellSelection, - "clusters": final_analysis.clusters, - "markers": final_analysis.markers, - }, - actions=recovery_actions, - needs_input=WorkflowNeedsInput( - questions=[ - WorkflowQuestion( - questionId="biologicalInterpretation", - question=needs_input.question, - options=list(needs_input.requiredInputs), - evidenceIds=list(needs_input.evidenceIds), - ) - ] - ), - notes=report.limitations, - ) - elif report.status == "failed": - outcome = journal._complete_attempt( - started, - status="failed", - report_references=[reference], - artifacts={ - "cellSelection": final_analysis.cellSelection, - "clusters": final_analysis.clusters, - "markers": final_analysis.markers, - }, - actions=recovery_actions, - error="; ".join(report.limitations) - or "Biological Interpretation failed", - ) - else: - outcome = journal._complete_attempt( - started, - status="done", - report_references=[reference], - artifacts={ - "cellSelection": final_analysis.cellSelection, - "clusters": final_analysis.clusters, - "markers": final_analysis.markers, - }, - actions=recovery_actions, - outputs={ - "clusterCount": len(report.clusterInterpretations), - "treatmentObservationCount": len(report.treatmentObservations), - }, - notes=report.limitations, - ) - journal._save_outcome(store.zw, prefix, outcome) - logger.info( - f"Workflow {workflow.workflowRunId}: Biological Interpretation " - f"outcome status={outcome.status!r}" - ) - if outcome.status == "failed": - journal.finalize_failed( - store, workflow, outcome.error or "biology failed" - ) - return outcome - except Exception as exc: - return journal.finish_exception( - store, - prefix, - workflow, - started, - exc, - artifacts={"cellSelection": final_analysis.cellSelection}, - ) diff --git a/scarf/agent/orchestrator/journal.py b/scarf/agent/orchestrator/journal.py index 94ec1b3e..28dee0c6 100644 --- a/scarf/agent/orchestrator/journal.py +++ b/scarf/agent/orchestrator/journal.py @@ -1,10 +1,12 @@ -"""Immutable orchestration journal and stage lifecycle operations.""" +"""One immutable RNA workflow history containing stage evidence and decisions.""" import hashlib +import json import re import time import uuid from collections.abc import Mapping, Sequence +from pathlib import Path from typing import Any, Literal, cast import zarr @@ -15,34 +17,16 @@ from ...utils.logging import logger from .. import record_io from ..experimental_context.study import StudyContract -from ..ingest.manifest import DatasetManifest -from ..persistence.contracts import ( - AgentInvocation, - AgentName, - AgentReportLink, - AgentReportReference, - AgentWorkflowRun, -) -from ..persistence.reports import ( - AgentReport, - finalize_agent_workflow, - list_agent_reports, - load_agent_record, - load_agent_report, - load_agent_workflow, - save_agent_report, -) from ..types import AgentDataModel, ArtifactReferenceModel from .models import ( - _ORCHESTRATION_FORMAT, - _ORCHESTRATION_VERSION, _STAGE_ORDER, - AutomatedPreprocessingPlan, + AutomatedWorkflowConfig, AutomatedWorkflowResult, - AutomatedWorkflowStatus, FinalAnalysisHandoff, OrchestrationRequestRecord, OrchestrationResumeRecord, + StageEvidenceReference, + WorkflowIdentity, WorkflowNeedsInput, WorkflowStageAttempt, WorkflowStageLink, @@ -50,6 +34,12 @@ artifact_model_to_ref, ) +_INCOMPATIBLE = ( + "Unsupported saved agent workflow. Start a new RNA workflow with this release; " + "older requests cannot be resumed or regenerated. Existing analysis artifacts " + "remain accessible through Scarf's artifact APIs." +) + def _sha256_model(value: AgentDataModel) -> str: return hashlib.sha256( @@ -80,53 +70,100 @@ def _list_keys(group: zarr.Group, prefix: str) -> list[str]: return record_io.list_keys(group, prefix) -def _orchestration_prefix(store: DataStore) -> str: - root_path = str(getattr(store.zw, "path", "")).strip("/") - return record_io.join_key(root_path, "agents", "orchestrations") +def _checkpoint_key(prefix: str, workflow_run_id: str, key: str) -> str: + parts = key.split("/") + if not parts or any( + re.fullmatch(r"[A-Za-z0-9_.:-]+", part) is None or part in {".", ".."} + for part in parts + ): + raise ValueError("Checkpoint keys must contain safe, non-empty path components") + if re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,127}", workflow_run_id) is None: + raise ValueError("Invalid workflow identifier") + return record_io.join_key(prefix, workflow_run_id, "checkpoints", key + ".json") -def _ensure_orchestration_store(store: DataStore) -> str: - if "agents" not in store.zw: - raise RuntimeError("Create the agent workflow before orchestration records") - agents = store.zw["agents"] - if not isinstance(agents, zarr.Group): - raise ValueError("The agents namespace must be a Zarr group") - if "orchestrations" not in agents: - candidate_prefix = _orchestration_prefix(store) - if _list_keys(store.zw, candidate_prefix): - raise ValueError( - "A non-Zarr object already occupies the orchestrations namespace" - ) - agents.create_group( - "orchestrations", - attributes={ - "format": _ORCHESTRATION_FORMAT, - "format_version": _ORCHESTRATION_VERSION, - }, +def read_checkpoint( + store: DataStore, + prefix: str, + workflow_run_id: str, + key: str, +) -> dict[str, Any] | None: + """Read an exact journal checkpoint with its validated inputs and outputs.""" + raw = record_io.read_key(store.zw, _checkpoint_key(prefix, workflow_run_id, key)) + if raw is None: + return None + value = json.loads(raw) + if not isinstance(value, dict) or set(value) != { + "inputs", + "outputs", + "contentSha256", + }: + raise ValueError( + "Unsupported RNA checkpoint contract; start a new workflow. Existing analysis artifacts remain accessible." ) - logger.info("Initialized the automated workflow orchestration journal") - node = agents["orchestrations"] - if not isinstance(node, zarr.Group): - raise ValueError("The orchestrations namespace must be a Zarr group") - if ( - node.attrs.get("format") != _ORCHESTRATION_FORMAT - or node.attrs.get("format_version") != _ORCHESTRATION_VERSION - ): - raise ValueError("Unrecognized orchestration persistence format") - return _orchestration_prefix(store) + payload = {"inputs": value["inputs"], "outputs": value["outputs"]} + digest = hashlib.sha256(record_io.canonical_json_bytes(payload)).hexdigest() + if value["contentSha256"] != digest: + raise ValueError("RNA checkpoint checksum does not match its contents") + if not isinstance(value["inputs"], dict) or not isinstance(value["outputs"], dict): + raise ValueError("RNA checkpoint inputs and outputs must be mappings") + return value -def _request_key(prefix: str, workflow_run_id: str) -> str: - return record_io.join_key(prefix, workflow_run_id, "request.json") +def load_checkpoint( + store: DataStore, + prefix: str, + workflow_run_id: str, + key: str, + inputs: Mapping[str, Any] | None, +) -> dict[str, Any] | None: + """Read committed outputs only when the exact scientific inputs agree.""" + value = read_checkpoint(store, prefix, workflow_run_id, key) + if value is None: + return None + if inputs is not None and record_io.canonical_json_bytes( + inputs + ) != record_io.canonical_json_bytes(value["inputs"]): + raise ValueError(f"Checkpoint {key!r} has different scientific inputs") + return cast(dict[str, Any], value["outputs"]) -def _resume_key(prefix: str, workflow_run_id: str, resume_id: str) -> str: - return record_io.join_key( - prefix, - workflow_run_id, - "resumes", - f"{resume_id}.json", - ) +def save_checkpoint( + store: DataStore, + prefix: str, + workflow_run_id: str, + key: str, + inputs: Mapping[str, Any], + outputs: Mapping[str, Any], +) -> dict[str, Any]: + """Commit evidence or admission before its dependent work; exact replay is idempotent.""" + payload = {"inputs": dict(inputs), "outputs": dict(outputs)} + value = { + **payload, + "contentSha256": hashlib.sha256( + record_io.canonical_json_bytes(payload) + ).hexdigest(), + } + path = _checkpoint_key(prefix, workflow_run_id, key) + try: + _write_key_once(store.zw, path, record_io.display_json_bytes(value)) + except FileExistsError: + existing = load_checkpoint(store, prefix, workflow_run_id, key, inputs) + if existing != outputs: + raise ValueError( + f"Checkpoint {key!r} already contains a different outcome" + ) from None + return existing + return dict(outputs) + + +def _orchestration_prefix(store: DataStore) -> str: + root_path = str(getattr(store.zw, "path", "")).strip("/") + return record_io.join_key(root_path, "agents", "orchestrations") + + +def _request_key(prefix: str, workflow_run_id: str) -> str: + return record_io.join_key(prefix, workflow_run_id, "request.json") def _stage_prefix( @@ -151,68 +188,6 @@ def _stage_key( ) -def _result_key(prefix: str, workflow_run_id: str) -> str: - return record_io.join_key(prefix, workflow_run_id, "result.json") - - -def _handoff_key( - prefix: str, - workflow_run_id: str, - handoff_id: str, -) -> str: - marker, separator, digest = handoff_id.partition(":") - if ( - marker != "handoff" - or separator != ":" - or len(digest) != 64 - or any(value not in "0123456789abcdef" for value in digest) - ): - raise ValueError("handoff_id must contain a lowercase SHA-256 digest") - return record_io.join_key( - prefix, - workflow_run_id, - "handoffs", - f"{digest}.json", - ) - - -def save_final_analysis_handoff( - store: DataStore, - prefix: str, - handoff: FinalAnalysisHandoff, -) -> FinalAnalysisHandoff: - handoff = FinalAnalysisHandoff.model_validate(handoff.model_dump(mode="json")) - if not handoff.handoffId: - raise ValueError("Final analysis handoff requires its content identity") - key = _handoff_key(prefix, handoff.workflowRunId, handoff.handoffId) - payload = record_io.display_json_bytes(handoff.model_dump(mode="json")) - stored = record_io.read_key(store.zw, key) - if stored is None: - try: - _write_key_once(store.zw, key, payload) - except FileExistsError: - stored = record_io.read_key(store.zw, key) - if stored != payload: - raise - elif stored != payload: - raise FileExistsError("Final handoff identity has conflicting content") - return handoff - - -def load_final_analysis_handoff( - store: DataStore, - prefix: str, - workflow_run_id: str, - handoff_id: str, -) -> FinalAnalysisHandoff: - key = _handoff_key(prefix, workflow_run_id, handoff_id) - value = _read_model(store.zw, key, FinalAnalysisHandoff) - handoff = cast(FinalAnalysisHandoff, value) - if handoff.workflowRunId != workflow_run_id or handoff.handoffId != handoff_id: - raise ValueError("Final handoff identity does not match its journal key") - return handoff - - def _read_model( group: zarr.Group, key: str, @@ -250,7 +225,7 @@ def _complete_attempt( started: WorkflowStageAttempt, *, status: Literal["done", "needsInput", "abstained", "failed"], - report_references: Sequence[AgentReportReference] = (), + report_references: Sequence[StageEvidenceReference] = (), artifacts: Mapping[str, ArtifactReferenceModel] | None = None, outputs: Mapping[str, Any] | None = None, actions: Sequence[str] = (), @@ -287,15 +262,12 @@ def _start_attempt( ) -> WorkflowStageAttempt: attempt_inputs = dict(inputs or {}) if resume_record is not None: - attempt_inputs["resumeLineage"] = { - "resumeId": resume_record.resumeId, - "answeredAttempt": ( - resume_record.answeredAttempt.model_dump(mode="json") - if resume_record.answeredAttempt is not None - else None - ), - "questionIds": list(resume_record.questionIds), - } + attempt_inputs["resumeAnswers"] = dict(resume_record.answers) + attempt_inputs["answeredAttempt"] = ( + resume_record.answeredAttempt.model_dump(mode="json") + if resume_record.answeredAttempt is not None + else None + ) attempt = WorkflowStageAttempt( workflowRunId=workflow_run_id, stage=stage, @@ -319,10 +291,7 @@ def _start_attempt( ), attempt, ) - logger.info( - f"Workflow {workflow_run_id}: started stage={stage!r} " - f"attempt={attempt.attemptId}" - ) + logger.info(f"{stage.replace('_', ' ').capitalize()}: started") return attempt @@ -347,51 +316,17 @@ def _save_outcome( if outcome.completedAtNs is not None else 0.0 ) - details = ( - f"reports={len(outcome.reportReferences)}, " - f"artifacts={len(outcome.artifacts)}, actions={len(outcome.actions)}" - ) + label = outcome.stage.replace("_", " ").capitalize() if outcome.status == "failed": - logger.error( - f"Stage {outcome.stage!r} failed: " - f"{outcome.error or 'unknown error'} ({elapsed_seconds:.1f}s)" - ) - elif outcome.status == "needsInput": - question_count = ( - len(outcome.needsInput.questions) if outcome.needsInput is not None else 0 - ) - logger.info( - f"Workflow {outcome.workflowRunId}: stage={outcome.stage!r} paused " - f"for {question_count} input question(s) ({details}; " - f"{elapsed_seconds:.1f}s)" - ) - elif outcome.status == "abstained": - logger.info( - f"Workflow {outcome.workflowRunId}: stage={outcome.stage!r} " - f"abstained ({details}; {elapsed_seconds:.1f}s)" - ) + logger.error(f"{label}: {outcome.error} ({elapsed_seconds:.1f}s)") + elif outcome.status in {"needsInput", "abstained"}: + reasons = "; ".join(outcome.notes) + if outcome.needsInput is not None: + reasons = "; ".join(q.question for q in outcome.needsInput.questions) + logger.warning(f"{label}: {outcome.status}: {reasons}") else: - logger.info( - f"Workflow {outcome.workflowRunId}: completed stage={outcome.stage!r} " - f"({details}; {elapsed_seconds:.1f}s)" - ) - if ( - outcome.status == "done" - and "reuse_baseline_preprocessing" not in outcome.actions - ): - assays = outcome.outputs.get("assays") - if isinstance(assays, list): - count = sum( - len(assay.get("featureCandidateEvaluations", [])) - for assay in assays - if isinstance(assay, Mapping) - ) - logger.info(f"HVG comparison: {count} actual candidate evaluations.") - if "candidateCount" in outcome.outputs: - logger.info( - "Parameter tuning: " - f"{outcome.outputs['candidateCount']} actual candidate evaluations." - ) + logger.info(f"{label}: completed ({elapsed_seconds:.1f}s)") + logger.debug(f"Workflow {outcome.workflowRunId}, attempt {outcome.attemptId}") def _stage_outcomes( @@ -572,61 +507,6 @@ def _resume_answer_errors( return errors -def _validated_resume_record( - store: DataStore, - prefix: str, - workflow_run_id: str, - resume_id: str, -) -> OrchestrationResumeRecord: - record = cast( - OrchestrationResumeRecord, - _read_model( - store.zw, - _resume_key(prefix, workflow_run_id, resume_id), - OrchestrationResumeRecord, - ), - ) - if record.workflowRunId != workflow_run_id or record.resumeId != resume_id: - raise ValueError("Persisted resume record identity does not match its path") - if record.contentSha256 != _record_checksum(record): - raise ValueError("Persisted resume record checksum does not match its content") - if record.answeredAttempt is None: - if record.questionIds or record.answers: - raise ValueError( - "A resume without an answered attempt cannot contain question answers" - ) - return record - matches = [ - outcome - for outcome in _stage_outcomes( - store.zw, - prefix, - workflow_run_id, - record.answeredAttempt.stage, - ) - if outcome.attemptId == record.answeredAttempt.attemptId - and outcome.contentSha256 == record.answeredAttempt.contentSha256 - ] - if len(matches) != 1 or matches[0].status != "needsInput": - raise ValueError( - "Persisted resume record does not cite one paused stage attempt" - ) - answered_outcome = matches[0] - assert answered_outcome.needsInput is not None - expected_question_ids = [ - question.questionId for question in answered_outcome.needsInput.questions - ] - if record.questionIds != expected_question_ids: - raise ValueError("Persisted resume record question IDs are stale") - answer_errors = _resume_answer_errors(answered_outcome, record.answers) - if answer_errors: - raise ValueError( - "Persisted resume record contains invalid answers: " - + "; ".join(answer_errors) - ) - return record - - def _validated_done_outcome( store: DataStore, prefix: str, @@ -727,7 +607,7 @@ def _stage_outcome_resolves( for reference in outcome.reportReferences: if reference.workflowRunId != workflow_run_id: raise ValueError("Stage report belongs to a different workflow") - load_agent_report(store, reference) + read_stage_evidence(store, reference) for artifact_reference in outcome.artifacts.values(): store.load_artifact(artifact_model_to_ref(artifact_reference)) metadata_columns = outcome.outputs.get("metadataColumns", []) @@ -749,19 +629,10 @@ def _parent_link(outcome: WorkflowStageAttempt) -> WorkflowStageLink: ) -def _safe_label(value: str) -> str: - clean = re.sub(r"[^A-Za-z0-9_]+", "_", value).strip("_") - return clean or "assay" - - -def _report_link(reference: AgentReportReference) -> AgentReportLink: - return AgentReportLink.from_reference(reference) - - def _stage_execution_id(started: WorkflowStageAttempt) -> str: """Return the stable identity of one logical agent-stage invocation.""" inputs = dict(started.inputs) - inputs.pop("resumeLineage", None) + inputs.pop("answeredAttempt", None) payload = { "workflowRunId": started.workflowRunId, "stage": started.stage, @@ -776,55 +647,106 @@ def _stage_execution_id(started: WorkflowStageAttempt) -> str: return f"orchestrator_{started.stage}_{digest[:40]}" -def _stage_invocation( - started: WorkflowStageAttempt, - invocation: AgentInvocation, -) -> AgentInvocation: - execution_id = _stage_execution_id(started) - inputs = dict(invocation.inputs) - observed = inputs.get("orchestrationExecutionId") - if observed is not None and observed != execution_id: - raise ValueError("Agent invocation has a conflicting orchestration identity") - inputs["orchestrationExecutionId"] = execution_id - return invocation.model_copy(update={"inputs": inputs}) +def _ensure_orchestration_store(store: DataStore) -> str: + """Initialize only the journal namespace, never a second workflow ledger.""" + agents = store.zw.require_group("agents") + if "orchestrations" not in agents: + agents.create_group( + "orchestrations", + attributes={"format": "scarf_agent_orchestrations", "format_version": 2}, + ) + node = agents["orchestrations"] + if not isinstance(node, zarr.Group) or dict(node.attrs) != { + "format": "scarf_agent_orchestrations", + "format_version": 2, + }: + raise ValueError(_INCOMPATIBLE) + return _orchestration_prefix(store) + + +def read_request( + group: zarr.Group, prefix: str, workflow_run_id: str +) -> OrchestrationRequestRecord: + try: + value = cast( + OrchestrationRequestRecord, + _read_model( + group, _request_key(prefix, workflow_run_id), OrchestrationRequestRecord + ), + ) + except (ValueError, TypeError) as exc: + raise ValueError(_INCOMPATIBLE) from exc + if ( + value.workflowRunId != workflow_run_id + or value.requestSha256 != _sha256_model(value.request) + or value.configSha256 != _sha256_model(value.config) + or value.contentSha256 != _record_checksum(value) + ): + raise ValueError("RNA workflow request identity or checksum is invalid") + return value + + +def open_analysis_store( + target: str | Path, workflow_run_id: str, *, workspace: str | None = None +) -> DataStore: + """Open the journal's selected RNA assay read-only, before resolving any result.""" + from ...storage.schema import validate_workspace_name + + validate_workspace_name(workspace) + root = zarr.open_group(str(target), mode="r") + active = root if workspace is None else root[workspace] + if not isinstance(active, zarr.Group): + raise ValueError("Analysis workspace is not a group") + prefix = record_io.join_key(active.path, "agents", "orchestrations") + record = read_request(active, prefix, workflow_run_id) + if record.request.zarrPath is None: + raise ValueError("Saved workflow has no store path") + if ( + record.request.workspace != workspace + or Path(record.request.zarrPath).resolve() != Path(target).resolve() + ): + raise ValueError("Analysis address does not match the saved request") + if not record.request.primaryAssay: + raise ValueError("Saved analysis has no selected RNA assay") + return DataStore( + str(target), + workspace=workspace, + default_assay=record.request.primaryAssay, + zarr_mode="r", + min_features_per_cell=-1, + mito_pattern="", + ribo_pattern="", + ) + + +def _report_checkpoint(started: WorkflowStageAttempt) -> str: + return f"{started.stage}/report/{_stage_execution_id(started)}" def _recover_persisted_stage_report( store: DataStore, started: WorkflowStageAttempt, *, - agent_name: AgentName, expected_type: type[AgentDataModel], -) -> tuple[AgentDataModel, AgentReportReference] | None: - """Recover a report committed before its stage outcome was persisted.""" - execution_id = _stage_execution_id(started) - matches = [ - reference - for reference in list_agent_reports( - store, - started.workflowRunId, - agent_name=agent_name, - ) - if reference.agentRunId == execution_id - ] - if not matches: +) -> tuple[AgentDataModel, StageEvidenceReference] | None: + key = _report_checkpoint(started) + inputs = {"execution": _stage_execution_id(started)} + data = load_checkpoint( + store, _orchestration_prefix(store), started.workflowRunId, key, inputs + ) + if data is None: return None - if len(matches) != 1: - raise ValueError("A logical stage execution has multiple persisted reports") - reference = matches[0] - record = load_agent_record(store, reference) - if record.invocation.inputs.get("orchestrationExecutionId") != execution_id: - raise ValueError("Persisted stage report has a stale execution identity") - report = load_agent_report(store, reference) - if not isinstance(report, expected_type): - raise TypeError( - f"Persisted {agent_name!r} report is not {expected_type.__name__}" - ) - for artifact in record.invocation.artifacts.values(): - store.load_artifact(artifact_model_to_ref(artifact)) - logger.info( - f"Workflow {started.workflowRunId}: recovered {agent_name!r} report for " - f"stage={started.stage!r}" + if ( + set(data) != {"reportType", "report"} + or data["reportType"] != expected_type.__name__ + ): + raise ValueError("Stage evidence has a different scientific result type") + report = expected_type.model_validate(data["report"]) + reference = StageEvidenceReference( + workflowRunId=started.workflowRunId, + stage=started.stage, + key=key, + contentSha256=_sha256_model(report), ) return report, reference @@ -834,142 +756,74 @@ def _save_stage_report( started: WorkflowStageAttempt, report: AgentDataModel, *, - invocation: AgentInvocation, expected_type: type[AgentDataModel], -) -> tuple[AgentDataModel, AgentReportReference]: - """Persist a stage report under its stable logical execution identity.""" - tagged_invocation = _stage_invocation(started, invocation) - try: - reference = save_agent_report( - store, - started.workflowRunId, - cast(AgentReport, report), - invocation=tagged_invocation, - agent_run_id=_stage_execution_id(started), - ) - logger.info( - f"Workflow {started.workflowRunId}: persisted " - f"{tagged_invocation.agentName!r} report for stage={started.stage!r}" - ) - return report, reference - except FileExistsError: - recovered = _recover_persisted_stage_report( - store, - started, - agent_name=tagged_invocation.agentName, - expected_type=expected_type, - ) - if recovered is None: - raise - logger.debug( - f"Workflow {started.workflowRunId}: reused concurrently persisted " - f"{tagged_invocation.agentName!r} report" - ) - return recovered - - -def _load_terminal_result( - store: DataStore, - prefix: str, - workflow: AgentWorkflowRun, -) -> AutomatedWorkflowResult | None: - raw = record_io.read_key( - store.zw, - _result_key(prefix, workflow.workflowRunId), + attempt_owned: bool = False, +) -> tuple[AgentDataModel, StageEvidenceReference]: + report = expected_type.model_validate(report.model_dump(mode="json")) + # Revisable stages recover individual evidence checkpoints across attempts; + # their aggregate reports can change as additional evidence is completed. + key = ( + f"{started.stage}/report/attempt_{started.attemptId}" + if attempt_owned + else _report_checkpoint(started) ) - if raw is None: - return None - try: - result = AutomatedWorkflowResult.model_validate_json(raw) - except ValueError as exc: - raise ValueError("Malformed automated workflow result") from exc - if result.contentSha256 != _record_checksum(result): - raise ValueError("Automated workflow result checksum is invalid") - if result.workflowRun is None: - raise ValueError("Terminal workflow result is missing its workflow identity") - stored_workflow = result.workflowRun - identity_fields = ( - "workflowRunId", - "workspace", - "status", - "finalizedAtNs", - "finalizationMessage", - "analysisStore", - "datasetFingerprints", + data = { + "reportType": expected_type.__name__, + "report": report.model_dump(mode="json"), + } + save_checkpoint( + store, + _orchestration_prefix(store), + started.workflowRunId, + key, + {"execution": _stage_execution_id(started)}, + data, + ) + return report, StageEvidenceReference( + workflowRunId=started.workflowRunId, + stage=started.stage, + key=key, + contentSha256=_sha256_model(report), ) - if any( - getattr(stored_workflow, field) != getattr(workflow, field) - for field in identity_fields - ): - raise ValueError("Automated workflow result has stale workflow metadata") - if result.status != workflow.status: - raise ValueError("Automated workflow result has a stale terminal status") - if result.reportReferences != workflow.reports: - raise ValueError("Automated workflow result has stale report references") - return result -def _persist_terminal_result( - store: DataStore, - prefix: str, - workflow: AgentWorkflowRun, - result: AutomatedWorkflowResult, -) -> AutomatedWorkflowResult: - existing = _load_terminal_result(store, prefix, workflow) - if existing is not None: - logger.debug( - f"Workflow {workflow.workflowRunId}: reused terminal result " - f"status={workflow.status!r}" - ) - return existing - if result.contentSha256 != _record_checksum(result): - raise ValueError("Terminal result must carry its exact content checksum") - try: - _write_model_once( - store.zw, - _result_key(prefix, workflow.workflowRunId), - result, - ) - except FileExistsError: - pass - persisted = _load_terminal_result(store, prefix, workflow) - if persisted is None: - raise RuntimeError("Terminal workflow result was not persisted") - logger.info( - f"Workflow {workflow.workflowRunId}: persisted terminal result " - f"status={workflow.status!r}" +def read_stage_evidence( + store: DataStore, reference: StageEvidenceReference +) -> dict[str, Any]: + if not reference.key.startswith(reference.stage + "/report/"): + raise ValueError("Evidence checkpoint is not owned by its declared stage") + value = load_checkpoint( + store, + _orchestration_prefix(store), + reference.workflowRunId, + reference.key, + None, ) - return persisted + if value is None or set(value) != {"reportType", "report"}: + raise ValueError("Stage evidence checkpoint is missing or malformed") + report = value["report"] + if ( + not isinstance(report, dict) + or hashlib.sha256(record_io.canonical_json_bytes(report)).hexdigest() + != reference.contentSha256 + ): + raise ValueError("Stage evidence does not match its exact reference") + return report def load_stage_report( - store: DataStore, - outcome: WorkflowStageAttempt, - expected_type: type[AgentDataModel], + store: DataStore, outcome: WorkflowStageAttempt, expected_type: type[AgentDataModel] ) -> AgentDataModel: - references = list(outcome.reportReferences) - if outcome.stage == "parameter_tuning" and len(references) > 1: - execution_id = _stage_execution_id(outcome) - references = [ - reference - for reference in references - if reference.agentRunId == execution_id - ] - if len(references) != 1: - raise ValueError( - f"Stage {outcome.stage!r} must have exactly one report reference" - ) - report = load_agent_report(store, references[0]) - if not isinstance(report, expected_type): - raise TypeError( - f"Stage {outcome.stage!r} report is not {expected_type.__name__}" - ) - return report + if len(outcome.reportReferences) != 1: + raise ValueError("A scientific stage must own exactly one evidence report") + return expected_type.model_validate( + read_stage_evidence(store, outcome.reportReferences[0]) + ) def failed_stage( store: DataStore, - workflow: AgentWorkflowRun, + workflow: WorkflowIdentity, request_record: OrchestrationRequestRecord, stage: WorkflowStageName, parents: Sequence[WorkflowStageLink], @@ -978,9 +832,6 @@ def failed_stage( artifacts: Mapping[str, ArtifactReferenceModel] | None = None, resume_record: OrchestrationResumeRecord | None = None, ) -> WorkflowStageAttempt: - logger.error( - f"Workflow {workflow.workflowRunId}: stage={stage!r} failed validation" - ) prefix = _ensure_orchestration_store(store) started = _start_attempt( store.zw, @@ -992,20 +843,16 @@ def failed_stage( resume_record=resume_record, ) outcome = _complete_attempt( - started, - status="failed", - artifacts=artifacts, - error=error, + started, status="failed", artifacts=artifacts, error=error ) _save_outcome(store.zw, prefix, outcome) - finalize_failed(store, workflow, error) return outcome def finish_exception( store: DataStore, prefix: str, - workflow: AgentWorkflowRun, + workflow: WorkflowIdentity, started: WorkflowStageAttempt, exc: BaseException, *, @@ -1014,30 +861,10 @@ def finish_exception( outputs: Mapping[str, Any] | None = None, notes: Sequence[str] = (), ) -> WorkflowStageAttempt: - if is_retryable_model_error(exc): - status_code = getattr(exc, "status_code", "unknown") - logger.warning( - f"Workflow {workflow.workflowRunId}: stage={started.stage!r} was " - f"interrupted by retryable model HTTP status {status_code}; leaving " - "the workflow running for recovery" - ) - raise exc error = f"{type(exc).__name__}: {exc}" - logger.error( - f"Workflow {workflow.workflowRunId}: stage={started.stage!r} raised " - f"{type(exc).__name__}; details were persisted in the stage outcome" - ) - execution_id = _stage_execution_id(started) - report_references = [ - reference - for reference in list_agent_reports(store, workflow.workflowRunId) - if reference.agentRunId == execution_id - or reference.agentRunId.startswith(f"{execution_id}_integration_") - ] outcome = _complete_attempt( started, status="failed", - report_references=report_references, artifacts=artifacts, outputs=outputs, actions=actions, @@ -1045,142 +872,298 @@ def finish_exception( error=error, ) _save_outcome(store.zw, prefix, outcome) - finalize_failed(store, workflow, error) return outcome -def is_retryable_model_error(exc: BaseException) -> bool: - """Return whether a provider HTTP failure should leave the workflow resumable.""" - try: - from pydantic_ai import ModelHTTPError - except ImportError: - return False - return isinstance(exc, ModelHTTPError) and ( - exc.status_code == 429 or 500 <= exc.status_code <= 599 - ) - - -def finalize_failed( - store: DataStore, - workflow: AgentWorkflowRun, - message: str, -) -> None: - current = load_agent_workflow(store, workflow.workflowRunId) - if current.status == "running": - logger.warning(f"Finalizing workflow {workflow.workflowRunId} as failed") - finalize_agent_workflow( - store, - workflow.workflowRunId, - status="failed", - message=message, - ) - - -def all_report_references( - store: DataStore, - prefix: str, - workflow_run_id: str, -) -> list[AgentReportReference]: - references: dict[tuple[str, str], AgentReportReference] = {} - for stage in _STAGE_ORDER: - for outcome in _stage_outcomes(store.zw, prefix, workflow_run_id, stage): - for reference in outcome.reportReferences: - if reference.workflowRunId != workflow_run_id: - raise ValueError("Stage report belongs to a different workflow") - load_agent_report(store, reference) - references[(reference.agentName, reference.agentRunId)] = reference - return sorted( - references.values(), - key=lambda value: (value.createdAtNs, value.agentName, value.agentRunId), - ) - - def paused_or_failed_result( store: DataStore, - workflow: AgentWorkflowRun, + workflow: WorkflowIdentity, request_record: OrchestrationRequestRecord, outcome: WorkflowStageAttempt, *, - dataset_manifest: DatasetManifest | None = None, - preprocessing_plan: AutomatedPreprocessingPlan | None = None, study_contract: StudyContract | None = None, - final_analysis: FinalAnalysisHandoff | None = None, ) -> AutomatedWorkflowResult: - prefix = _ensure_orchestration_store(store) - current = load_agent_workflow(store, workflow.workflowRunId) - unattended_pause = ( - request_record.config.inputPolicy == "unattended" - and outcome.status == "needsInput" - ) - if (outcome.status == "failed" or unattended_pause) and current.status == "running": - current = finalize_agent_workflow( - store, - workflow.workflowRunId, - status="failed", - message=( - outcome.error - or ( - "The unattended workflow encountered an unresolved decision." - if unattended_pause - else "A workflow stage failed." - ) - ), - ) - elif outcome.status == "abstained" and current.status == "running": - current = finalize_agent_workflow( - store, - workflow.workflowRunId, - status="abstained", - message=( - outcome.notes[0] - if outcome.notes - else "The available data do not support a defensible result" - ), - ) - status: AutomatedWorkflowStatus = ( - "failed" - if unattended_pause - else "needsInput" + status: Literal["needsInput", "abstained", "failed"] = ( + "needsInput" if outcome.status == "needsInput" else "abstained" if outcome.status == "abstained" else "failed" ) - result = AutomatedWorkflowResult( + questions = ( + [q.question for q in outcome.needsInput.questions] if outcome.needsInput else [] + ) + return AutomatedWorkflowResult( status=status, currentStage=outcome.stage, zarrPath=str(store.zarr_loc), - workflowRun=current, - reportReferences=list(current.reports), - datasetManifest=dataset_manifest, - preprocessingPlan=preprocessing_plan, - studyContract=study_contract, - finalAnalysis=final_analysis, - decisionRunId=request_record.workflowRunId, - needsInput=None if unattended_pause else outcome.needsInput, - unresolvedClaims=( - [question.question for question in outcome.needsInput.questions] - if unattended_pause and outcome.needsInput is not None - else [] - ), - notes=[ - *outcome.notes, - *( - [ - "The unattended workflow stopped because a stage returned " - "an unresolved decision." - ] - if unattended_pause - else [] - ), - *([outcome.error] if outcome.error else []), - ], + workspace=workflow.workspace, + workflowRunId=workflow.workflowRunId, + needsInput=outcome.needsInput, + notes=[*outcome.notes, *([outcome.error] if outcome.error else []), *questions], + limitations=list(study_contract.limitations) if study_contract else [], + unresolvedClaims=questions, ) - result = result.model_copy(update={"contentSha256": _record_checksum(result)}) - if status in {"failed", "abstained"}: - return _persist_terminal_result(store, prefix, current, result) - logger.info( - f"Workflow {workflow.workflowRunId}: returning needsInput at " - f"stage={outcome.stage!r}" + + +def _analysis_review_views( + store: DataStore, + prefix: str, + workflow_run_id: str, + stages: list[dict[str, Any]], + config: AutomatedWorkflowConfig, +) -> list[dict[str, Any]]: + """Derive bounded scientific views from the active exact review checkpoints.""" + views: list[dict[str, Any]] = [] + seen: set[str] = set() + for stage in stages: + if stage["stage"] != "parameter_tuning": + continue + history = stage["outputs"].get("tuningEvidence", {}).get("history", []) + for entry in history: + if "review" not in entry: + continue + key = entry.get("checkpointKey", "") + match = re.fullmatch( + r"parameter_tuning/(sample0|sample1|full)/review([0-9]+)(?:/answer)?", + key, + ) + if match is None or match[1] != entry.get("scope"): + raise ValueError("Analysis review has an invalid checkpoint address") + if key in seen: + raise ValueError("Analysis review repeats a checkpoint") + seen.add(key) + limit = ( + config.maxFullPartitions + if match[1] == "full" + else config.maxScreeningEvaluations + ) + if int(match[2]) > limit: + raise ValueError("Analysis review exceeds its declared work allowance") + value = read_checkpoint(store, prefix, workflow_run_id, key) + if value is None: + raise ValueError("Analysis review checkpoint is missing") + if value["contentSha256"] != entry.get("checkpointSha256"): + raise ValueError("Analysis review does not match its exact checkpoint") + inputs, outputs = value["inputs"], value["outputs"] + if ( + inputs.get("scope") != entry["scope"] + or outputs.get("action") != entry["review"] + or inputs.get("imageHashes") != entry.get("imageHashes") + or inputs.get("evidenceMode") != entry.get("evidenceMode") + or inputs.get("visualInspection") != entry.get("visualInspection") + ): + raise ValueError("Analysis review evidence bindings differ") + mode, inspection = ( + inputs.get("evidenceMode"), + inputs.get("visualInspection"), + ) + if ( + mode not in {"visual", "structured"} + or inspection != ("available" if mode == "visual" else "unavailable") + or bool(inputs.get("imageHashes")) != (mode == "visual") + ): + raise ValueError( + "Analysis review has inconsistent evidence availability" + ) + candidates = inputs.get("candidates", []) + settings = inputs.get("settings", {}) + features = inputs.get("featureEvidence", {}) + candidate_ids = [item["candidateId"] for item in candidates] + if ( + not 0 < len(candidates) <= limit + or len(set(candidate_ids)) != len(candidate_ids) + or set(settings) != set(candidate_ids) + or set(features) != set(candidate_ids) + or entry["review"].get("selectedCandidateId") not in candidate_ids + ): + raise ValueError("Analysis review candidate evidence does not align") + for candidate in candidates: + setting = settings[candidate["candidateId"]] + if setting.get("parameters") != candidate.get( + "parameters" + ) or setting.get("features") != candidate.get("artifacts", {}).get( + "graphFeatures" + ): + raise ValueError( + "Analysis review settings do not match its artifacts" + ) + views.append( + { + "scope": entry["scope"], + "evidenceMode": mode, + "visualInspection": inspection, + **entry["review"], + "candidates": [ + { + name: item[name] + for name in ("candidateId", "parameters", "metrics") + } + for item in candidates + ], + "settings": { + identity: { + name: setting.get(name) + for name in ( + "hvgCount", + "ranking", + "rankingColumn", + "features", + "eligibleFeatures", + ) + } + for identity, setting in settings.items() + }, + "featureEvidence": { + identity: { + name: evidence.get(name) + for name in ( + "selectedGenes", + "eligibleGenes", + "families", + "topSelectedGenes", + ) + } + for identity, evidence in features.items() + }, + } + ) + return views + + +def analysis_snapshot(store: DataStore, workflow_run_id: str) -> dict[str, Any]: + """Validate one journal and derive its status, final artifacts, and report view.""" + prefix = _orchestration_prefix(store) + request = read_request(store.zw, prefix, workflow_run_id) + stages: list[dict[str, Any]] = [] + parents: list[WorkflowStageLink] = [] + final: dict[str, Any] | None = None + status = "running" + for stage in _STAGE_ORDER: + outcomes = _stage_outcomes(store.zw, prefix, workflow_run_id, stage) + matching = [v for v in outcomes if v.parentAttempts == parents] + if not matching: + break + outcome = matching[-1] + if not _stage_outcome_resolves( + store, prefix, workflow_run_id, request, outcome + ): + raise ValueError( + f"Stage {stage!r} contains unresolved artifact or evidence references" + ) + stage_view = outcome.model_dump(mode="json") + stage_view["report"] = ( + read_stage_evidence(store, outcome.reportReferences[0]) + if outcome.reportReferences + else None + ) + stage_view["decisions"] = [] + stages.append(stage_view) + if outcome.status != "done": + status = outcome.status + break + parents = [_parent_link(outcome)] + if stage == "analysis_finalization": + resolved = FinalAnalysisHandoff.model_validate( + outcome.outputs["finalAnalysis"] + ) + if ( + resolved.workflowRunId != workflow_run_id + or resolved.primaryAssay != request.request.primaryAssay + or resolved.markerAssay != resolved.primaryAssay + ): + raise ValueError("Final analysis belongs to a different RNA workflow") + for name, kind in { + "cellSelection": "cell_selection", + "graph": "connectivity_map", + "clusters": "cluster_labels", + "umap": "embedding", + "embeddingInitialization": "embedding_initialization", + "markerFeatures": "feature_selection", + "markers": "marker_table", + }.items(): + ref = getattr(resolved, name) + if ( + ref is None + or ref.kind != kind + or ref != outcome.artifacts.get(name) + ): + raise ValueError( + f"Final analysis lacks its validated {name} artifact" + ) + if name != "cellSelection" and ref.assay != resolved.primaryAssay: + raise ValueError(f"Final {name} belongs to another assay") + inputs = outcome.inputs.get("preprocessedAssays", []) + assert resolved.cellSelection is not None + if len(inputs) != 1 or inputs[0].get( + "cellSelection" + ) != resolved.cellSelection.model_dump(mode="json"): + raise ValueError( + "Final analysis does not use the full preprocessing cohort" + ) + final = resolved.model_dump(mode="json") + status = "completed" + # Decisions belong to this same history and retain their offered evidence. + checkpoint_prefix = record_io.join_key(prefix, workflow_run_id, "checkpoints") + decisions: list[dict[str, Any]] = [] + for path in _list_keys(store.zw, checkpoint_prefix): + if "/decisions/" not in path or not path.endswith(".json"): + continue + key = path[len(checkpoint_prefix) + 1 : -5] + value = load_checkpoint(store, prefix, workflow_run_id, key, None) + if value is not None and "record" in value: + decisions.append(value) + + def contains(value: Any, digest: str) -> bool: + if isinstance(value, Mapping): + return any(contains(v, digest) for v in value.values()) + if isinstance(value, list): + return any(contains(v, digest) for v in value) + return bool(value == digest) + + for value in decisions: + digest = value.get("checkpointSha256") + if not isinstance(digest, str): + raise ValueError("Decision checkpoint has no input identity") + owner = next( + ( + stage + for stage in stages + if stage["stage"] == value.get("stage") + and ( + contains(stage["inputs"], digest) + or contains(stage["outputs"], digest) + ) + ), + None, + ) + if owner is not None: + owner["decisions"].append(value) + reviews = _analysis_review_views( + store, prefix, workflow_run_id, stages, request.config ) - return result + if status == "completed": + assert final is not None + tuning = next(stage for stage in stages if stage["stage"] == "parameter_tuning") + report = tuning["report"] + full_reviews = [value for value in reviews if value["scope"] == "full"] + if ( + not full_reviews + or full_reviews[-1]["action"] != "accept" + or full_reviews[-1]["selectedCandidateId"] + != report.get("recommendedCandidateId") + or report.get("finalClusterArtifact") != final["clusters"] + ): + raise ValueError( + "Final analysis lacks its exact full-cohort acceptance evidence" + ) + return { + "runId": workflow_run_id, + "status": status, + "request": request.request.model_dump(mode="json"), + "stages": stages, + "finalAnalysis": final, + "modelIdentity": request.modelIdentity, + "analysisReviews": reviews, + "config": request.config.model_dump(mode="json"), + } diff --git a/scarf/agent/orchestrator/main.py b/scarf/agent/orchestrator/main.py index 70a3f42f..c1dc0101 100644 --- a/scarf/agent/orchestrator/main.py +++ b/scarf/agent/orchestrator/main.py @@ -1,6 +1,6 @@ -"""Public controller for resumable automated Scarf agent workflows.""" +"""Controller for one resumable RNA analysis with checkpoint-owned state.""" -import os +import hashlib import time import uuid from collections.abc import Mapping @@ -11,43 +11,30 @@ from ...datastore.datastore import DataStore from ...datastore.summary import summarize_zarr_readonly -from ...storage.stores import zarr_root_path from ...utils.logging import logger from .. import record_io from ..experimental_context.study import StudyContract from ..ingest import IngestResult, detect_format, ingest from ..ingest.manifest import DatasetManifest, inspect_h5ad_manifest -from ..persistence.contracts import AgentWorkflowRun -from ..persistence.decisions import load_latest_decision_workflow_snapshot -from ..persistence.reports import ( - create_agent_workflow, - finalize_agent_workflow, - load_agent_report, - load_agent_workflow, -) from . import journal from .context import ContextStagesMixin from .finalization import FinalizationStagesMixin from .models import ( - _RUN_ID_PATTERN, _STAGE_ORDER, - AutomatedPreprocessingPlan, AutomatedWorkflowConfig, AutomatedWorkflowRequest, AutomatedWorkflowResult, AutomatedWorkflowResumeRequest, - AutomatedWorkflowStatus, - FinalAnalysisHandoff, OrchestrationRequestRecord, OrchestrationResumeRecord, + WorkflowIdentity, WorkflowNeedsInput, WorkflowQuestion, - WorkflowStageAttempt, - WorkflowStageName, ) from .preprocessing import PreprocessingStagesMixin from .rna import ( selected_rna_assay, + selected_store_rna_assay, validate_rna_directions, validate_rna_request_fields, validate_saved_rna_history, @@ -55,30 +42,77 @@ from .tuning import TuningStagesMixin -def _generate_completed_report( - store: DataStore, - workflow: AgentWorkflowRun, -) -> None: - """Generate a local report without changing the completed workflow result.""" - if workflow.status != "completed": - return - try: - if zarr_root_path(store.z) is None: - return - from ..report.generator import generate_agent_report +def _model_identity(model: Any) -> str: + from ..config.agent_exec import _model_name + + settings = getattr(model, "settings", None) or {} + provider = getattr(model, "provider", None) + profile = getattr(model, "profile", None) + image_input = getattr(model, "supports_image_input", None) + if not isinstance(image_input, bool) and isinstance(profile, Mapping): + image_input = profile.get("supports_image_input") + identity = { + "settings": settings, + "system": getattr(model, "system", None), + "provider": getattr(provider, "name", None), + "baseUrl": str(getattr(provider, "base_url", "")), + "supportsImageInput": image_input if isinstance(image_input, bool) else None, + } + digest = hashlib.sha256(record_io.canonical_json_bytes(identity)).hexdigest() + return f"{type(model).__module__}.{type(model).__qualname__}:{_model_name(model)}:{digest}" + + +def _submitted_identity(request: AutomatedWorkflowRequest) -> str: + value = request.model_dump(mode="json") + value["sourcePath"] = str(Path(request.sourcePath).resolve()) + if request.zarrPath is not None: + value["zarrPath"] = str(Path(request.zarrPath).resolve()) + return hashlib.sha256(record_io.canonical_json_bytes(value)).hexdigest() + + +def _source_identity(path: str) -> dict[str, Any]: + source = Path(path).resolve() + if source.is_file(): + stat = source.stat() + return { + "path": str(source), + "bytes": stat.st_size, + "modifiedNs": stat.st_mtime_ns, + } + return {"path": str(source)} - report_path = generate_agent_report(store, workflow.workflowRunId) - relative_path = os.path.relpath(report_path, start=Path.cwd()) - logger.info( - f"Workflow {workflow.workflowRunId}: local HTML report saved to " - f"{relative_path}" - ) - print(f"Agent workflow report: {relative_path}") - except Exception as exc: - logger.warning( - f"Workflow {workflow.workflowRunId}: local HTML report generation " - f"failed ({type(exc).__name__}: {exc})" - ) + +def _data_identity( + store: DataStore, + assay_name: str, + *, + columns: list[str] | None = None, + feature_columns: list[str] | None = None, +) -> dict[str, Any]: + """Fingerprint the selected assay and original metadata in bounded blocks.""" + from ..parameter_tuning.execution import _metadata_column_fingerprint + + assay = store.get_assay(assay_name) + digest = hashlib.sha256() + digest.update(str(assay.rawData.shape).encode()) + digest.update(str(assay.rawData.dtype).encode()) + for block in assay.rawData.stream_blocks(nthreads=1, prefetch=1): + digest.update(block.tobytes(order="C")) + names = sorted(columns if columns is not None else store.cells.columns) + feature_names = sorted( + feature_columns if feature_columns is not None else assay.feats.columns + ) + return { + "assay": assay_name, + "countsSha256": digest.hexdigest(), + "featureMetadata": { + name: _metadata_column_fingerprint(assay.feats, name) + for name in feature_names + }, + "metadata": { + name: _metadata_column_fingerprint(store.cells, name) for name in names + }, + } class AgentOrchestrator( @@ -87,34 +121,35 @@ class AgentOrchestrator( TuningStagesMixin, FinalizationStagesMixin, ): - """Run one bounded, persisted single-RNA analysis workflow.""" + """Run bounded RNA analysis; the journal owns all durable state.""" def __init__( - self, - model: Any, - *, - config: AutomatedWorkflowConfig | None = None, + self, model: Any, *, config: AutomatedWorkflowConfig | None = None ) -> None: self.model = model self.config = config or AutomatedWorkflowConfig() def run(self, request: AutomatedWorkflowRequest) -> AutomatedWorkflowResult: - """Ingest the request and continue until completion or a persisted pause.""" - result = self._run(request) - if result.status == "failed": + try: + result = self._run(request) + except Exception as exc: + result = AutomatedWorkflowResult(notes=[f"{type(exc).__name__}: {exc}"]) + if result.status != "completed": logger.error( - f"RNA analysis failed during {result.currentStage}: " - + "; ".join( - result.notes or ["See the saved stage outcome for details."] - ) + f"RNA analysis {result.status} during {result.currentStage}: " + + "; ".join(result.notes) ) return result def _run(self, request: AutomatedWorkflowRequest) -> AutomatedWorkflowResult: + submitted = request try: validate_rna_request_fields(request) except ValueError as exc: return AutomatedWorkflowResult(notes=[str(exc)]) + reused = self._reuse_or_resume(request) + if reused is not None: + return reused format_name = detect_format(request.sourcePath) dataset_manifest: DatasetManifest | None = None logger.info( @@ -178,7 +213,6 @@ def _run(self, request: AutomatedWorkflowRequest) -> AutomatedWorkflowResult: return AutomatedWorkflowResult( status="failed", currentStage="ingest", - datasetManifest=dataset_manifest, notes=[ "experimentalDirections.batchColumns must be a list " "of exact observation-column names" @@ -190,7 +224,6 @@ def _run(self, request: AutomatedWorkflowRequest) -> AutomatedWorkflowResult: return AutomatedWorkflowResult( status="failed", currentStage="ingest", - datasetManifest=dataset_manifest, notes=[ "experimentalDirections.batchColumns must include the " "CELLxGENE uns/batch_condition columns" @@ -205,7 +238,6 @@ def _run(self, request: AutomatedWorkflowRequest) -> AutomatedWorkflowResult: return AutomatedWorkflowResult( status="abstained", currentStage="ingest", - datasetManifest=dataset_manifest, limitations=list(dataset_manifest.priorFiltering.limitations), unresolvedClaims=[manifest_decision.summary], notes=[ @@ -216,7 +248,6 @@ def _run(self, request: AutomatedWorkflowRequest) -> AutomatedWorkflowResult: return AutomatedWorkflowResult( status="needsInput", currentStage="ingest", - datasetManifest=dataset_manifest, needsInput=WorkflowNeedsInput( questions=[ WorkflowQuestion( @@ -237,7 +268,6 @@ def _run(self, request: AutomatedWorkflowRequest) -> AutomatedWorkflowResult: return AutomatedWorkflowResult( status="abstained", currentStage="ingest", - datasetManifest=dataset_manifest, limitations=list(dataset_manifest.priorFiltering.limitations), unresolvedClaims=[manifest_decision.summary], notes=[ @@ -346,33 +376,18 @@ def _run(self, request: AutomatedWorkflowRequest) -> AutomatedWorkflowResult: ) store = self.open_store(ingest_result.zarrPath, effective_request) except (OSError, KeyError, RuntimeError, TypeError, ValueError) as exc: - terminal = ( - finalize_agent_workflow( - ingest_result.zarrPath, - ingest_result.workflowRun.workflowRunId, - status="failed", - message=str(exc), - workspace=request.workspace, - ) - if ingest_result.workflowRun is not None - else None - ) return AutomatedWorkflowResult( - zarrPath=ingest_result.zarrPath, - workflowRun=terminal, - notes=[str(exc)], + zarrPath=ingest_result.zarrPath, notes=[str(exc)] ) ignored = [name for name in store.assay_names if name != selected] logger.info( f"RNA analysis: selected assay {selected!r}" + (f"; ignored other assays {ignored}" if ignored else "") ) - workflow = ingest_result.workflowRun or create_agent_workflow(store) - logger.info( - f"Continuing automated workflow {workflow.workflowRunId} with " - f"{len(store.assay_names)} datastore assays" + workflow = WorkflowIdentity(uuid.uuid4().hex, effective_request.workspace) + request_record = self.initialize_request( + store, workflow, effective_request, submitted ) - request_record = self.initialize_request(store, workflow, effective_request) prefix = journal._ensure_orchestration_store(store) self.record_ingest_stage( store, @@ -389,299 +404,247 @@ def _run(self, request: AutomatedWorkflowRequest) -> AutomatedWorkflowResult: answers={}, ) - def resume( - self, - request: AutomatedWorkflowResumeRequest, - ) -> AutomatedWorkflowResult: - """Resume a running workflow after validating its immutable request.""" - result = self._resume(request) - if result.status == "failed": - logger.error( - f"RNA analysis failed during {result.currentStage}: " - + "; ".join( - result.notes or ["See the saved stage outcome for details."] - ) + def _reuse_or_resume( + self, request: AutomatedWorkflowRequest + ) -> AutomatedWorkflowResult | None: + source = Path(request.sourcePath) + fmt = detect_format(request.sourcePath) + if request.zarrPath is not None: + destination = Path(request.zarrPath) + elif fmt == "zarr": + destination = source + elif source.is_dir(): + destination = source.with_name(source.name + ".zarr") + else: + source_stem = ( + source.with_suffix("") if source.suffix.lower() == ".gz" else source ) - return result - - def _resume( - self, - request: AutomatedWorkflowResumeRequest, - ) -> AutomatedWorkflowResult: - directions = request.answers.get("experimentalDirections") - if isinstance(directions, Mapping): - validate_rna_directions(directions) - logger.info( - f"Resuming automated workflow {request.workflowRunId} with " - f"{len(request.answers)} answer field(s)" - ) - record, store = self.load_request_for_resume(request) - workflow = load_agent_workflow( - store, - request.workflowRunId, - workspace=request.workspace, - ) - if workflow.status != "running": - logger.warning( - f"Automated workflow {workflow.workflowRunId} cannot resume from " - f"status={workflow.status!r}" + destination = source_stem.with_suffix(".zarr") + if not destination.exists(): + return None + root = zarr.open_group(str(destination), mode="r") + active = root if request.workspace is None else root[request.workspace] + if not isinstance(active, zarr.Group): + raise ValueError("Requested workspace is not a group") + prefix = record_io.join_key(active.path, "agents", "orchestrations") + matches = [] + for key in journal._list_keys(active, prefix): + if not key.endswith("/request.json"): + continue + identifier = key.rsplit("/", 2)[-2] + try: + saved = journal.read_request(active, prefix, identifier) + except ValueError: + continue + if saved.inputIdentity.get("userRequestSha256") == _submitted_identity( + request + ): + matches.append(saved) + if len(matches) > 1: + raise ValueError( + "Several workflows match this request; use an exact advanced resume identifier" ) - prefix = journal._ensure_orchestration_store(store) - if journal._load_terminal_result(store, prefix, workflow) is not None: - raise RuntimeError( - f"Cannot resume a workflow with status {workflow.status!r}" + if matches: + saved = matches[0] + if saved.config != self.config or saved.modelIdentity != _model_identity( + self.model + ): + raise ValueError( + "The destination contains this request with different model or execution settings; use a new destination or exact advanced workflow" ) - return self.repair_terminal_result(store, workflow, record) - prefix = journal._ensure_orchestration_store(store) - outcomes = [ - outcome - for stage in _STAGE_ORDER - for outcome in journal._stage_outcomes( - store.zw, prefix, workflow.workflowRunId, stage - ) - ] - starts = [ - started - for stage in _STAGE_ORDER - for started in journal._stage_starts( - store.zw, prefix, workflow.workflowRunId, stage - ) - ] - completed_attempt_ids = { - (outcome.stage, outcome.attemptId) for outcome in outcomes - } - interrupted_starts = [ - started - for started in starts - if (started.stage, started.attemptId) not in completed_attempt_ids - ] - logger.info( - f"Workflow {workflow.workflowRunId} resume scan found " - f"{len(outcomes)} outcome(s) and {len(interrupted_starts)} " - "interrupted attempt(s)" - ) - latest_outcome = ( - max( - outcomes, - key=lambda value: ( - _STAGE_ORDER.index(value.stage), - value.startedAtNs, - value.attemptId, - ), - ) - if outcomes - else None - ) - latest_interrupted = ( - max( - interrupted_starts, - key=lambda value: ( - _STAGE_ORDER.index(value.stage), - value.startedAtNs, - value.attemptId, - ), - ) - if interrupted_starts - else None - ) - interrupted_lineage = ( - latest_interrupted.inputs.get("resumeLineage") - if latest_interrupted is not None - else None - ) - interrupted_answers_latest_pause = bool( - latest_interrupted is not None - and latest_outcome is not None - and latest_interrupted.stage == latest_outcome.stage - and isinstance(interrupted_lineage, Mapping) - and interrupted_lineage.get("answeredAttempt") - == journal._parent_link(latest_outcome).model_dump(mode="json") - ) - active_interrupted = ( - latest_interrupted - if latest_interrupted is not None - and ( - latest_outcome is None - or _STAGE_ORDER.index(latest_interrupted.stage) - > _STAGE_ORDER.index(latest_outcome.stage) - or interrupted_answers_latest_pause - or ( - latest_interrupted.stage == latest_outcome.stage - and ( - latest_interrupted.startedAtNs, - latest_interrupted.attemptId, - ) - > (latest_outcome.startedAtNs, latest_outcome.attemptId) + return self.resume( + AutomatedWorkflowResumeRequest( + zarrPath=str(destination.resolve()), + workspace=request.workspace, + workflowRunId=saved.workflowRunId, ) ) - else None - ) - latest_paused = ( - latest_outcome - if latest_outcome is not None - and latest_outcome.status == "needsInput" - and active_interrupted is None - else None - ) - effective_answers = dict(request.answers) - inherited_resume: OrchestrationResumeRecord | None = None - if active_interrupted is not None: - lineage = active_interrupted.inputs.get("resumeLineage") - if lineage is not None: - if not isinstance(lineage, Mapping): - raise ValueError("Interrupted stage resumeLineage is malformed") - inherited_resume_id = lineage.get("resumeId") - if ( - not isinstance(inherited_resume_id, str) - or _RUN_ID_PATTERN.fullmatch(inherited_resume_id) is None - ): - raise ValueError("Interrupted stage resumeId is malformed") - inherited_resume = journal._validated_resume_record( - store, - prefix, - workflow.workflowRunId, - inherited_resume_id, - ) - expected_answered_attempt = ( - inherited_resume.answeredAttempt.model_dump(mode="json") - if inherited_resume.answeredAttempt is not None - else None - ) - if ( - lineage.get("answeredAttempt") != expected_answered_attempt - or lineage.get("questionIds") != inherited_resume.questionIds - ): - raise ValueError( - "Interrupted stage resumeLineage does not match its resume record" - ) - if request.answers and request.answers != inherited_resume.answers: - raise ValueError( - "Cannot change answers for an in-flight logical invocation" - ) - effective_answers = dict(inherited_resume.answers) - answered_attempt = ( - journal._parent_link(latest_paused) if latest_paused is not None else None - ) - question_ids = ( - [question.questionId for question in latest_paused.needsInput.questions] - if latest_paused is not None and latest_paused.needsInput is not None - else [] - ) - if inherited_resume is not None: - answered_attempt = inherited_resume.answeredAttempt - question_ids = list(inherited_resume.questionIds) - elif latest_paused is None and request.answers: - raise ValueError( - "Cannot provide resume answers: no active persisted questions" + if fmt != "zarr": + raise FileExistsError( + "The destination exists without an exactly matching RNA request; choose a different destination" ) - resume_record = OrchestrationResumeRecord( + return None + + def initialize_request( + self, + store: DataStore, + workflow: WorkflowIdentity, + request: AutomatedWorkflowRequest, + submitted: AutomatedWorkflowRequest | None = None, + ) -> OrchestrationRequestRecord: + prefix = journal._ensure_orchestration_store(store) + if request.primaryAssay is None: + raise ValueError("RNA selection must be resolved before saving the request") + identity = { + "userRequestSha256": _submitted_identity(submitted or request), + "source": _source_identity(request.sourcePath), + "data": _data_identity(store, request.primaryAssay), + } + record = OrchestrationRequestRecord( workflowRunId=workflow.workflowRunId, - resumeId=uuid.uuid4().hex, createdAtNs=time.time_ns(), - answeredAttempt=answered_attempt, - questionIds=question_ids, - answers=effective_answers, + request=request, + config=self.config, + requestSha256=journal._sha256_model(request), + configSha256=journal._sha256_model(self.config), + modelIdentity=_model_identity(self.model), + inputIdentity=identity, ) - resume_record = resume_record.model_copy( - update={"contentSha256": journal._record_checksum(resume_record)} + record = record.model_copy( + update={"contentSha256": journal._record_checksum(record)} ) journal._write_model_once( - store.zw, - journal._resume_key(prefix, workflow.workflowRunId, resume_record.resumeId), - resume_record, - ) - logger.info( - f"Persisted resume {resume_record.resumeId} for workflow " - f"{workflow.workflowRunId} (questions={len(question_ids)})" + store.zw, journal._request_key(prefix, workflow.workflowRunId), record ) - if latest_paused is not None: - answer_errors = journal._resume_answer_errors( - latest_paused, effective_answers - ) - if answer_errors: - logger.warning( - f"Resume answers for workflow {workflow.workflowRunId} did not " - "satisfy the persisted questions" - ) - result = journal.paused_or_failed_result( - store, - workflow, - record, - latest_paused, - ) - result = result.model_copy( - update={"notes": [*result.notes, *answer_errors]} - ) - return result.model_copy( - update={"contentSha256": journal._record_checksum(result)} - ) - return self._continue( + return record + + def load_request_for_resume( + self, request: AutomatedWorkflowResumeRequest + ) -> tuple[OrchestrationRequestRecord, DataStore]: + store = journal.open_analysis_store( + request.zarrPath, request.workflowRunId, workspace=request.workspace + ) + prefix = journal._orchestration_prefix(store) + record = journal.read_request(store.zw, prefix, request.workflowRunId) + if record.modelIdentity != _model_identity(self.model): + raise ValueError("Resume model differs from the saved workflow") + if record.config != self.config: + raise ValueError("Resume execution settings differ from the saved workflow") + selected = selected_store_rna_assay(store, record.request) + validate_saved_rna_history(store, prefix, request.workflowRunId, selected) + expected = record.inputIdentity + if expected["source"] != _source_identity(record.request.sourcePath): + raise ValueError("Source input has changed since this workflow was started") + observed = _data_identity( store, - workflow, - record, - answers=effective_answers, - resume_record=resume_record, + selected, + columns=list(expected["data"]["metadata"]), + feature_columns=list(expected["data"]["featureMetadata"]), ) + if observed != expected["data"]: + raise ValueError( + "Selected RNA data or relevant metadata changed; start a new analysis" + ) + return record, self.open_store(request.zarrPath, record.request) - def cancel( - self, - request: AutomatedWorkflowResumeRequest, - *, - message: str = "Automated workflow cancelled by the caller", + def resume( + self, request: AutomatedWorkflowResumeRequest + ) -> AutomatedWorkflowResult: + try: + result = self._resume(request) + except Exception as exc: + result = AutomatedWorkflowResult( + zarrPath=request.zarrPath, + workspace=request.workspace, + workflowRunId=request.workflowRunId, + notes=[f"{type(exc).__name__}: {exc}"], + ) + if result.status != "completed": + logger.error( + f"RNA analysis {result.status} during {result.currentStage}: " + + "; ".join(result.notes) + ) + return result + + def _resume( + self, request: AutomatedWorkflowResumeRequest ) -> AutomatedWorkflowResult: - """Finalize one running automated workflow as abandoned.""" - logger.info(f"Cancelling automated workflow {request.workflowRunId}") record, store = self.load_request_for_resume(request) - workflow = load_agent_workflow( - store, - request.workflowRunId, - workspace=request.workspace, - ) - if workflow.status != "running": - prefix = journal._ensure_orchestration_store(store) - if ( - workflow.status == "abandoned" - and journal._load_terminal_result(store, prefix, workflow) is None - ): - return self.repair_terminal_result(store, workflow, record) - raise RuntimeError( - f"Cannot cancel a workflow with status {workflow.status!r}" + workflow = WorkflowIdentity(record.workflowRunId, record.request.workspace) + snapshot = journal.analysis_snapshot(store, workflow.workflowRunId) + if snapshot["status"] == "completed": + if request.answers: + raise ValueError( + "A completed analysis cannot accept new decision answers" + ) + result = AutomatedWorkflowResult( + status="completed", + currentStage="analysis_finalization", + zarrPath=request.zarrPath, + workspace=request.workspace, + workflowRunId=request.workflowRunId, ) - terminal = finalize_agent_workflow( - store, - workflow.workflowRunId, - status="abandoned", - message=message, - ) - prefix = journal._ensure_orchestration_store(store) - observed = [ - outcome + final = snapshot["finalAnalysis"] + result = result.model_copy( + update={"limitations": list(final.get("limitations", []))} + ) + try: + result.report() + except Exception as exc: + return result.model_copy( + update={ + "status": "failed", + "currentStage": "report", + "notes": [str(exc)], + } + ) + return result + stages = snapshot["stages"] + latest = stages[-1] if stages else None + prefix = journal._orchestration_prefix(store) + starts = [ + value for stage in _STAGE_ORDER - for outcome in journal._stage_outcomes( + for value in journal._stage_starts( store.zw, prefix, workflow.workflowRunId, stage ) ] - current_stage: WorkflowStageName = ( - max(observed, key=lambda value: value.startedAtNs).stage - if observed - else "ingest" - ) - result = AutomatedWorkflowResult( - status="abandoned", - currentStage=current_stage, - zarrPath=str(store.zarr_loc), - workflowRun=terminal, - reportReferences=list(terminal.reports), - notes=[message], - ) - result = result.model_copy( - update={"contentSha256": journal._record_checksum(result)} + latest_start = ( + max(starts, key=lambda value: value.startedAtNs) if starts else None ) - logger.info( - f"Automated workflow {workflow.workflowRunId} was abandoned at " - f"stage={current_stage!r}" + answers = dict(request.answers) + resume_record = None + if latest is not None and latest["status"] == "needsInput": + from .models import WorkflowStageAttempt + + outcome = WorkflowStageAttempt.model_validate( + {k: v for k, v in latest.items() if k not in {"report", "decisions"}} + ) + answered = journal._parent_link(outcome) + if ( + not answers + and latest_start is not None + and latest_start.startedAtNs > outcome.startedAtNs + ): + if latest_start.inputs.get("answeredAttempt") == answered.model_dump( + mode="json" + ): + answers = dict(latest_start.inputs.get("resumeAnswers", {})) + if not answers and outcome.stage != "parameter_tuning": + return journal.paused_or_failed_result(store, workflow, record, outcome) + if answers: + errors = journal._resume_answer_errors(outcome, answers) + if errors: + raise ValueError("; ".join(errors)) + assert outcome.needsInput is not None + resume_record = OrchestrationResumeRecord( + workflowRunId=workflow.workflowRunId, + answeredAttempt=answered, + answers=answers, + questionIds=[q.questionId for q in outcome.needsInput.questions], + ) + # Tuning replays committed actions and budgets. With no answer it + # preserves a scientific defer, but retries an uncommitted assessment. + elif answers: + raise ValueError("Resume answers require an exact pending stage") + elif latest_start is not None and latest_start.inputs.get("resumeAnswers"): + from .models import WorkflowStageLink + + answers = dict(latest_start.inputs["resumeAnswers"]) + resume_record = OrchestrationResumeRecord( + workflowRunId=workflow.workflowRunId, + answeredAttempt=WorkflowStageLink.model_validate( + latest_start.inputs["answeredAttempt"] + ), + answers=answers, + questionIds=list(answers), + ) + directions = answers.get("experimentalDirections") + if isinstance(directions, Mapping): + validate_rna_directions(directions) + return self._continue( + store, workflow, record, answers=answers, resume_record=resume_record ) - return journal._persist_terminal_result(store, prefix, terminal, result) def open_store( self, @@ -702,304 +665,55 @@ def open_store( workspace=request.workspace, ) - def initialize_request( - self, - store: DataStore, - workflow: AgentWorkflowRun, - request: AutomatedWorkflowRequest, - ) -> OrchestrationRequestRecord: - prefix = journal._ensure_orchestration_store(store) - request_checksum = journal._sha256_model(request) - config_checksum = journal._sha256_model(self.config) - record = OrchestrationRequestRecord( - workflowRunId=workflow.workflowRunId, - createdAtNs=time.time_ns(), - request=request, - config=self.config, - requestSha256=request_checksum, - configSha256=config_checksum, - ) - record = record.model_copy( - update={"contentSha256": journal._record_checksum(record)} - ) - journal._write_model_once( - store.zw, - journal._request_key(prefix, workflow.workflowRunId), - record, - ) - logger.debug( - f"Persisted immutable request for workflow {workflow.workflowRunId}" - ) - return record - - def load_request_for_resume( - self, - request: AutomatedWorkflowResumeRequest, - ) -> tuple[OrchestrationRequestRecord, DataStore]: - logger.debug(f"Loading immutable request for workflow {request.workflowRunId}") - root = zarr.open_group(request.zarrPath, mode="r") - active = root if request.workspace is None else root[request.workspace] - if not isinstance(active, zarr.Group): - raise ValueError("The requested workspace is not a Zarr group") - root_path = str(getattr(active, "path", "")).strip("/") - prefix = record_io.join_key(root_path, "agents", "orchestrations") - record = cast( - OrchestrationRequestRecord, - journal._read_model( - active, - journal._request_key(prefix, request.workflowRunId), - OrchestrationRequestRecord, - ), - ) - if record.workflowRunId != request.workflowRunId: - raise ValueError("Stored orchestration request has a different workflow") - if ( - Path(cast(str, record.request.zarrPath)).resolve() - != Path(request.zarrPath).resolve() - ): - raise ValueError("Resume zarrPath does not match the stored request") - if record.request.workspace != request.workspace: - raise ValueError("Resume workspace does not match the stored request") - if record.requestSha256 != journal._sha256_model(record.request): - raise ValueError("Stored orchestration request checksum is invalid") - if record.configSha256 != journal._sha256_model(record.config): - raise ValueError("Stored orchestration config checksum is invalid") - if record.contentSha256 != journal._record_checksum(record): - raise ValueError("Stored orchestration request envelope is invalid") - summary = summarize_zarr_readonly(request.zarrPath, workspace=request.workspace) - selected = selected_rna_assay( - record.request, - {assay.name: assay.assay_type for assay in summary.assays}, - ) - validate_saved_rna_history(active, prefix, request.workflowRunId, selected) - store = self.open_store(request.zarrPath, record.request) - return record, store - - def repair_terminal_result( + def _continue( self, store: DataStore, - workflow: AgentWorkflowRun, + workflow: WorkflowIdentity, request_record: OrchestrationRequestRecord, + *, + answers: Mapping[str, Any], + resume_record: OrchestrationResumeRecord | None = None, ) -> AutomatedWorkflowResult: - """Load or reconstruct the JSON result after terminal finalization.""" - prefix = journal._ensure_orchestration_store(store) - existing = journal._load_terminal_result(store, prefix, workflow) - if existing is not None: - logger.debug( - f"Loaded terminal result for workflow {workflow.workflowRunId}" - ) - return existing - - logger.warning( - f"Repairing missing terminal result for workflow {workflow.workflowRunId}" - ) - - observed = [ - outcome - for stage in _STAGE_ORDER - for outcome in journal._stage_outcomes( - store.zw, - prefix, - workflow.workflowRunId, - stage, - ) - ] - if not observed: - raise RuntimeError("Terminal workflow has no persisted stage outcomes") - by_identity = { - (outcome.stage, outcome.attemptId): outcome for outcome in observed - } - - def validated_chain( - terminal: WorkflowStageAttempt, - ) -> dict[WorkflowStageName, WorkflowStageAttempt] | None: - chain: dict[WorkflowStageName, WorkflowStageAttempt] = {} - current = terminal - while True: - stage_index = _STAGE_ORDER.index(current.stage) - if current.stage in chain: - raise ValueError("Stage lineage contains a cycle") - if not journal._stage_outcome_resolves( - store, - prefix, - workflow.workflowRunId, - request_record, - current, - ): - return None - chain[current.stage] = current - if stage_index == 0: - if current.parentAttempts: - raise ValueError("The ingest stage cannot have a parent") - return chain - if len(current.parentAttempts) != 1: - raise ValueError("Every post-ingest stage must have one parent") - parent_link = current.parentAttempts[0] - if ( - workflow.status != "abandoned" - and parent_link.stage != _STAGE_ORDER[stage_index - 1] - ): - raise ValueError("Terminal stage lineage skips a workflow stage") - if _STAGE_ORDER.index(parent_link.stage) >= stage_index: - raise ValueError("Stage lineage does not move toward ingest") - parent = by_identity.get((parent_link.stage, parent_link.attemptId)) - if ( - parent is None - or parent.status != "done" - or parent.contentSha256 != parent_link.contentSha256 - ): - return None - current = parent - - if workflow.status == "completed": - terminal_candidates = [ - outcome - for outcome in observed - if outcome.stage == "analysis_finalization" and outcome.status == "done" - ] - elif workflow.status == "failed": - terminal_candidates = [ - outcome for outcome in observed if outcome.status == "failed" - ] - else: - terminal_candidates = list(observed) - terminal_candidates.sort( - key=lambda value: (value.startedAtNs, value.attemptId), - reverse=True, - ) - terminal_outcome: WorkflowStageAttempt | None = None - validated_done: dict[WorkflowStageName, WorkflowStageAttempt] = {} - for candidate in terminal_candidates: - chain = validated_chain(candidate) - if chain is not None: - terminal_outcome = candidate - validated_done = { - stage: outcome - for stage, outcome in chain.items() - if outcome.status == "done" - } - break - if terminal_outcome is None: - raise RuntimeError( - "Terminal automated workflow lacks one valid persisted stage chain" - ) - - preprocessing_plan: AutomatedPreprocessingPlan | None = None - plan_outcome = validated_done.get("preprocessing_plan") - if plan_outcome is not None and "preprocessingPlan" in plan_outcome.outputs: - preprocessing_plan = AutomatedPreprocessingPlan.model_validate( - plan_outcome.outputs["preprocessingPlan"] - ) - feature_preprocessing = validated_done.get("feature_policy_preprocessing") - if ( - feature_preprocessing is not None - and "resolvedPreprocessingPlan" in feature_preprocessing.outputs - ): - preprocessing_plan = AutomatedPreprocessingPlan.model_validate( - feature_preprocessing.outputs["resolvedPreprocessingPlan"] - ) - - dataset_manifest: DatasetManifest | None = None - ingest_stage = validated_done.get("ingest") - if ingest_stage is not None and ingest_stage.outputs.get("datasetManifest"): - dataset_manifest = DatasetManifest.model_validate( - ingest_stage.outputs["datasetManifest"] - ) - - study_contract: StudyContract | None = None - context_outcome = validated_done.get("experimental_context") - if context_outcome is not None and "studyContract" in context_outcome.outputs: - study_contract = StudyContract.model_validate( - context_outcome.outputs["studyContract"] - ) - - final_analysis: FinalAnalysisHandoff | None = None - finalization_outcome = validated_done.get("analysis_finalization") - if ( - finalization_outcome is not None - and "finalAnalysis" in finalization_outcome.outputs - ): - final_analysis = FinalAnalysisHandoff.model_validate( - finalization_outcome.outputs["finalAnalysis"] - ) - persisted_handoff = journal.load_final_analysis_handoff( - store, - prefix, - workflow.workflowRunId, - final_analysis.handoffId, - ) - if persisted_handoff != final_analysis: - raise ValueError("Final handoff journal content differs from outcome") - - for reference in workflow.reports: - load_agent_report(store, reference) - notes = [workflow.finalizationMessage] if workflow.finalizationMessage else [] - verification_summary: list[str] = [] - decision_run_id: str | None = None - if workflow.status == "completed": - decision_snapshot = load_latest_decision_workflow_snapshot( + try: + return self._execute_stages( store, - workflow.workflowRunId, - workspace=request_record.request.workspace, + workflow, + request_record, + answers=answers, + resume_record=resume_record, ) - if ( - final_analysis is None - or decision_snapshot.workflow.status != "completed" - or decision_snapshot.workflow.finalHandoffId != final_analysis.handoffId - ): - raise ValueError( - "Terminal orchestration and decision ledger do not resolve" + except Exception as exc: + prefix = journal._orchestration_prefix(store) + starts = [ + value + for stage in _STAGE_ORDER + for value in journal._stage_starts( + store.zw, prefix, workflow.workflowRunId, stage ) - decision_run_id = workflow.workflowRunId - verification_by_record = { - value.decisionRecordId: value - for value in decision_snapshot.workflow.verificationRecords - } - verification_summary = [ - ( - f"{record.decisionId}: " - f"{len(verification_by_record[record.recordId].checks)} " - f"deterministic checks passed ({record.source})." - ) - for record in decision_snapshot.workflow.active_decision_records() ] - result = AutomatedWorkflowResult( - status=cast(AutomatedWorkflowStatus, workflow.status), - currentStage=terminal_outcome.stage, - zarrPath=str(store.zarr_loc), - workflowRun=workflow, - reportReferences=list(workflow.reports), - datasetManifest=dataset_manifest, - preprocessingPlan=preprocessing_plan, - studyContract=study_contract, - finalAnalysis=final_analysis, - finalHandoffId=( - final_analysis.handoffId if final_analysis is not None else None - ), - decisionRunId=decision_run_id, - verificationSummary=verification_summary, - notes=notes, - ) - result = result.model_copy( - update={"contentSha256": journal._record_checksum(result)} - ) - persisted = journal._persist_terminal_result(store, prefix, workflow, result) - _generate_completed_report(store, workflow) - return persisted + latest = ( + max(starts, key=lambda value: value.startedAtNs) if starts else None + ) + return AutomatedWorkflowResult( + currentStage=latest.stage if latest else "ingest", + zarrPath=str(store.zarr_loc), + workspace=workflow.workspace, + workflowRunId=workflow.workflowRunId, + notes=[f"{type(exc).__name__}: {exc}"], + ) - def _continue( + def _execute_stages( self, store: DataStore, - workflow: AgentWorkflowRun, + workflow: WorkflowIdentity, request_record: OrchestrationRequestRecord, *, answers: Mapping[str, Any], resume_record: OrchestrationResumeRecord | None = None, ) -> AutomatedWorkflowResult: """Continue the stage machine from the latest validated checkpoint.""" - logger.info(f"Running stage sequence for workflow {workflow.workflowRunId}") + logger.debug(f"Running stage sequence for workflow {workflow.workflowRunId}") prefix = journal._ensure_orchestration_store(store) - self._load_or_create_decision_workflow(store, request_record) ingest_outcome = journal._validated_done_outcome( store, prefix, @@ -1010,11 +724,6 @@ def _continue( ) if ingest_outcome is None: raise RuntimeError("The persisted ingest stage is missing") - dataset_manifest = ( - DatasetManifest.model_validate(ingest_outcome.outputs["datasetManifest"]) - if ingest_outcome.outputs.get("datasetManifest") is not None - else None - ) cell_selection = ingest_outcome.artifacts.get("cellSelection") if cell_selection is None or cell_selection.kind != "cell_selection": raise RuntimeError( @@ -1037,11 +746,10 @@ def _continue( workflow, request_record, enrichment_outcome, - dataset_manifest=dataset_manifest, ) parents = [journal._parent_link(enrichment_outcome)] - hto_outcome = self._hto_stage( + quality_outcome = self._rna_quality_metrics_stage( store, workflow, request_record, @@ -1050,25 +758,24 @@ def _continue( cell_selection, resume_record=resume_record, ) - if hto_outcome.status != "done": + if quality_outcome.status != "done": return journal.paused_or_failed_result( store, workflow, request_record, - hto_outcome, - dataset_manifest=dataset_manifest, + quality_outcome, ) quality_metric_artifacts = self._named_stage_artifacts( - hto_outcome, + quality_outcome, "qualityMetricArtifacts", "quality_metric", ) hto_identity_artifacts = self._named_stage_artifacts( - hto_outcome, + quality_outcome, "htoIdentityArtifacts", "hto_identity", ) - parents = [journal._parent_link(hto_outcome)] + parents = [journal._parent_link(quality_outcome)] context_outcome, experimental = self.experimental_context_stage( store, @@ -1088,7 +795,6 @@ def _continue( workflow, request_record, context_outcome, - dataset_manifest=dataset_manifest, ) study_contract = StudyContract.model_validate( context_outcome.outputs["studyContract"] @@ -1113,8 +819,6 @@ def _continue( workflow, request_record, plan_outcome, - dataset_manifest=dataset_manifest, - preprocessing_plan=preprocessing_plan, study_contract=study_contract, ) parents = [journal._parent_link(plan_outcome)] @@ -1140,8 +844,6 @@ def _continue( workflow, request_record, preprocessing_outcome, - dataset_manifest=dataset_manifest, - preprocessing_plan=preprocessing_plan, study_contract=study_contract, ) parents = [journal._parent_link(preprocessing_outcome)] @@ -1166,157 +868,33 @@ def _continue( workflow, request_record, tuning_outcome, - dataset_manifest=dataset_manifest, - preprocessing_plan=preprocessing_plan, - study_contract=study_contract, - ) - baseline_preprocessing_outcome = preprocessing_outcome - baseline_preprocessed = list(preprocessed) - baseline_tuning_outcome = tuning_outcome - baseline_tuning_report = tuning_report - parents = [journal._parent_link(baseline_tuning_outcome)] - - ( - feature_review_outcome, - preprocessing_plan, - feature_policy_revised, - ) = self.feature_policy_review_stage( - store, - workflow, - request_record, - parents, - preprocessing_plan, - baseline_tuning_report, - answers, - resume_record=resume_record, - ) - if feature_review_outcome.status != "done": - return journal.paused_or_failed_result( - store, - workflow, - request_record, - feature_review_outcome, - dataset_manifest=dataset_manifest, - preprocessing_plan=preprocessing_plan, - study_contract=study_contract, - ) - parents = [journal._parent_link(feature_review_outcome)] - - if feature_policy_revised: - ( - feature_preprocessing_outcome, - preprocessed, - preprocessing_plan, - ) = self.preprocessing_stage( - store, - workflow, - request_record, - parents, - preprocessing_plan, - experimental, - study_contract, - answers, - resume_record=resume_record, - stage_name="feature_policy_preprocessing", - ) - else: - ( - feature_preprocessing_outcome, - preprocessed, - preprocessing_plan, - ) = self.reuse_feature_policy_preprocessing_stage( - store, - workflow, - request_record, - parents, - preprocessing_plan, - baseline_preprocessing_outcome, - baseline_preprocessed, - resume_record=resume_record, - ) - if feature_preprocessing_outcome.status != "done": - return journal.paused_or_failed_result( - store, - workflow, - request_record, - feature_preprocessing_outcome, - dataset_manifest=dataset_manifest, - preprocessing_plan=preprocessing_plan, - study_contract=study_contract, - ) - parents = [journal._parent_link(feature_preprocessing_outcome)] - - if feature_policy_revised: - tuning_outcome, tuning_report = self.parameter_tuning_stage( - store, - workflow, - request_record, - parents, - preprocessing_plan, - preprocessed, - experimental, - enrichment_outcome.reportReferences[0], - context_outcome.reportReferences[0], - answers, - study_contract=study_contract, - resume_record=resume_record, - stage_name="feature_policy_tuning", - ) - tuning_reference = ( - tuning_outcome.reportReferences[0] - if tuning_outcome.reportReferences - else baseline_tuning_outcome.reportReferences[0] - ) - else: - tuning_outcome, tuning_report = self.reuse_feature_policy_tuning_stage( - store, - workflow, - request_record, - parents, - baseline_tuning_outcome, - baseline_tuning_report, - resume_record=resume_record, - ) - tuning_reference = baseline_tuning_outcome.reportReferences[0] - if tuning_outcome.status != "done": - return journal.paused_or_failed_result( - store, - workflow, - request_record, - tuning_outcome, - dataset_manifest=dataset_manifest, - preprocessing_plan=preprocessing_plan, study_contract=study_contract, ) parents = [journal._parent_link(tuning_outcome)] - - ( - analysis_review_outcome, - tuning_report, - tuning_reference, - ) = self.analysis_review_stage( - store, - workflow, - request_record, - parents, - preprocessing_plan, - tuning_report, - tuning_reference, - study_contract, - answers, - resume_record=resume_record, + tuning_reference = tuning_outcome.reportReferences[0] + selected = next( + ( + value + for value in tuning_report.evaluations + if value.candidateId == tuning_report.recommendedCandidateId + ), + None, ) - if analysis_review_outcome.status != "done": - return journal.paused_or_failed_result( - store, - workflow, - request_record, - analysis_review_outcome, - dataset_manifest=dataset_manifest, - preprocessing_plan=preprocessing_plan, - study_contract=study_contract, + if selected is None: + raise ValueError("Full-cohort tuning did not select an evaluated candidate") + preprocessed = [ + value.model_copy( + update={ + "normalized": selected.artifacts.get( + "normalized", value.normalized + ), + "graphFeatures": selected.artifacts.get( + "graphFeatures", value.graphFeatures + ), + } ) - parents = [journal._parent_link(analysis_review_outcome)] + for value in preprocessed + ] finalization_outcome, final_analysis = self.analysis_finalization_stage( store, @@ -1327,10 +905,6 @@ def _continue( preprocessed, tuning_report, tuning_reference, - study_contract, - experimental=experimental, - analysis_review_evidence=analysis_review_outcome.outputs, - answers=answers, resume_record=resume_record, ) if finalization_outcome.status != "done": @@ -1339,70 +913,32 @@ def _continue( workflow, request_record, finalization_outcome, - dataset_manifest=dataset_manifest, - preprocessing_plan=preprocessing_plan, study_contract=study_contract, ) - terminal = finalize_agent_workflow( - store, - workflow.workflowRunId, - status="completed", - message="Decision-driven Scarf analysis completed", - ) - decision_snapshot = load_latest_decision_workflow_snapshot( - store, - workflow.workflowRunId, - workspace=request_record.request.workspace, - ) - if ( - decision_snapshot.workflow.status != "completed" - or decision_snapshot.workflow.finalHandoffId != final_analysis.handoffId - ): - raise ValueError( - "Completed orchestration and decision handoff identities differ" - ) - verification_by_record = { - value.decisionRecordId: value - for value in decision_snapshot.workflow.verificationRecords - } - verification_summary = [ - ( - f"{record.decisionId}: " - f"{len(verification_by_record[record.recordId].checks)} " - f"deterministic checks passed ({record.source})." - ) - for record in decision_snapshot.workflow.active_decision_records() - ] completed = AutomatedWorkflowResult( status="completed", currentStage="analysis_finalization", zarrPath=str(store.zarr_loc), - workflowRun=terminal, - reportReferences=list(terminal.reports), - datasetManifest=dataset_manifest, - preprocessingPlan=preprocessing_plan, - studyContract=study_contract, - finalAnalysis=final_analysis, - finalHandoffId=final_analysis.handoffId, - decisionRunId=workflow.workflowRunId, - verificationSummary=verification_summary, - limitations=list(study_contract.limitations), - unresolvedClaims=list(study_contract.unsupportedClaims), - notes=["Decision-driven RNA analysis completed"], - ) - completed = completed.model_copy( - update={"contentSha256": journal._record_checksum(completed)} - ) - logger.info( - f"Automated workflow {workflow.workflowRunId} completed with " - f"{len(terminal.reports)} report(s)" - ) - persisted = journal._persist_terminal_result( - store, - prefix, - terminal, - completed, + workspace=workflow.workspace, + workflowRunId=workflow.workflowRunId, + limitations=list(final_analysis.limitations), + notes=["RNA analysis completed"], ) - _generate_completed_report(store, terminal) - return persisted + from ..report.generator import generate_agent_report + + try: + path = generate_agent_report(store, workflow.workflowRunId) + except Exception as exc: + logger.error(f"Analysis report failed: {type(exc).__name__}: {exc}") + return completed.model_copy( + update={ + "status": "failed", + "currentStage": "report", + "notes": [ + f"Report generation failed: {exc}; the validated analysis is saved and can be resumed." + ], + } + ) + logger.info(f"Completed RNA analysis. Report saved to {path}") + return completed diff --git a/scarf/agent/orchestrator/models.py b/scarf/agent/orchestrator/models.py index 6b16d834..9b40ad00 100644 --- a/scarf/agent/orchestrator/models.py +++ b/scarf/agent/orchestrator/models.py @@ -1,22 +1,19 @@ """Public data models for resumable automated agent workflows.""" -import hashlib -import math import re from collections.abc import Mapping from pathlib import Path +from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Literal from pydantic import Field, field_validator, model_validator from ...storage.refs import ArtifactRef -from .. import record_io +from ..cell_quality.profiles import cell_qc_policy from ..config import AgentRunConfig from ..decisions.rna import CellQualityExecutorPayload from ..experimental_context.contracts import CellQcPlan -from ..experimental_context.study import AuthorLabelPolicy, StudyContract -from ..ingest.manifest import DatasetManifest -from ..persistence.contracts import AgentReportReference, AgentWorkflowRun +from ..experimental_context.study import AuthorLabelPolicy from ..types import AgentDataModel, ArtifactReferenceModel if TYPE_CHECKING: @@ -39,41 +36,65 @@ type WorkflowStageName = Literal[ "ingest", "data_enrichment", - "hto_demultiplexing", + "rna_quality_metrics", "experimental_context", "preprocessing_plan", "preprocessing", "parameter_tuning", - "feature_policy_review", - "feature_policy_preprocessing", - "feature_policy_tuning", - "analysis_review", "analysis_finalization", - "biological_interpretation", + "report", ] type AssayRole = Literal["graph", "hto", "unsupported"] type ReductionMethod = Literal["pca", "lsi", "identity", "none"] _RUN_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,127}$") _SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") -_ORCHESTRATION_FORMAT = "scarf_agent_orchestrations" -_ORCHESTRATION_VERSION = 2 _STAGE_ORDER: tuple[WorkflowStageName, ...] = ( "ingest", "data_enrichment", - "hto_demultiplexing", + "rna_quality_metrics", "experimental_context", "preprocessing_plan", "preprocessing", "parameter_tuning", - "feature_policy_review", - "feature_policy_preprocessing", - "feature_policy_tuning", - "analysis_review", "analysis_finalization", ) +@dataclass(frozen=True) +class WorkflowIdentity: + """Runtime address of a workflow whose state belongs to its journal.""" + + workflowRunId: str + workspace: str | None = None + + +class StageEvidenceReference(AgentDataModel): + """Exact evidence checkpoint owned by an orchestration stage.""" + + workflowRunId: str + stage: WorkflowStageName + key: str + contentSha256: str + + +class AnalysisError(RuntimeError): + """An unattended analysis could not produce a supported completed result.""" + + def __init__(self, result: "AutomatedWorkflowResult") -> None: + self.result = result + detail = "; ".join(result.notes) or "Required scientific evidence is unresolved" + address = ( + f" Resume workflow {result.workflowRunId!r} in {result.zarrPath!r}" + f" (workspace={result.workspace!r})." + if result.workflowRunId and result.zarrPath + else "" + ) + super().__init__( + f"RNA analysis {result.status} during {result.currentStage}: {detail}.{address}" + ) + + class WorkflowQuestion(AgentDataModel): """One stable question that can be answered by a resume request.""" @@ -88,14 +109,6 @@ class WorkflowQuestion(AgentDataModel): def get_blank(cls) -> "WorkflowQuestion": return cls() - @classmethod - def get_example(cls) -> "WorkflowQuestion": - return cls( - questionId="approvePlanChecksum", - question="Approve this preprocessing plan?", - planChecksum="0" * 64, - ) - class WorkflowNeedsInput(AgentDataModel): """All questions blocking the next workflow stage.""" @@ -106,10 +119,6 @@ class WorkflowNeedsInput(AgentDataModel): def get_blank(cls) -> "WorkflowNeedsInput": return cls() - @classmethod - def get_example(cls) -> "WorkflowNeedsInput": - return cls(questions=[WorkflowQuestion.get_example()]) - class WorkflowStageLink(AgentDataModel): """Immutable identity of one completed parent stage attempt.""" @@ -136,10 +145,6 @@ def validate_checksum(cls, value: str) -> str: def get_blank(cls) -> "WorkflowStageLink": return cls() - @classmethod - def get_example(cls) -> "WorkflowStageLink": - return cls(stage="ingest", attemptId="attempt-1", contentSha256="0" * 64) - class WorkflowStageAttempt(AgentDataModel): """Append-only record for one orchestration stage attempt.""" @@ -153,7 +158,7 @@ class WorkflowStageAttempt(AgentDataModel): requestSha256: str = "" configSha256: str = "" parentAttempts: list[WorkflowStageLink] = Field(default_factory=list) - reportReferences: list[AgentReportReference] = Field(default_factory=list) + reportReferences: list[StageEvidenceReference] = Field(default_factory=list) artifacts: dict[str, ArtifactReferenceModel] = Field(default_factory=dict) inputs: dict[str, Any] = Field(default_factory=dict) outputs: dict[str, Any] = Field(default_factory=dict) @@ -179,20 +184,6 @@ def validate_lifecycle(self) -> "WorkflowStageAttempt": def get_blank(cls) -> "WorkflowStageAttempt": return cls() - @classmethod - def get_example(cls) -> "WorkflowStageAttempt": - return cls( - workflowRunId="workflow-1", - stage="ingest", - attemptId="attempt-1", - status="done", - startedAtNs=1, - completedAtNs=2, - requestSha256="0" * 64, - configSha256="1" * 64, - contentSha256="2" * 64, - ) - class AssayPreprocessingPlan(AgentDataModel): """Exact allowlisted preprocessing route for one assay.""" @@ -205,9 +196,6 @@ class AssayPreprocessingPlan(AgentDataModel): featureMethod: Literal["hvg", "prevalentPeaks", "panel", "none"] = "none" reductionMethod: ReductionMethod = "none" featureParameters: dict[str, Any] = Field(default_factory=dict) - normalizationParameters: dict[str, Any] = Field(default_factory=dict) - reductionParameters: dict[str, Any] = Field(default_factory=dict) - exactExcludedFeatures: list[str] = Field(default_factory=list) evidenceIds: list[str] = Field(default_factory=list) limitations: list[str] = Field(default_factory=list) @@ -215,19 +203,6 @@ class AssayPreprocessingPlan(AgentDataModel): def get_blank(cls) -> "AssayPreprocessingPlan": return cls() - @classmethod - def get_example(cls) -> "AssayPreprocessingPlan": - return cls( - assay="RNA", - assayType="RNA", - role="graph", - graphEligible=True, - markerEligible=True, - featureMethod="hvg", - reductionMethod="pca", - featureParameters={"topN": 1000, "minCells": 20}, - ) - class AutomatedPreprocessingPlan(AgentDataModel): """Dataset-wide preprocessing plan produced before selection changes.""" @@ -246,10 +221,11 @@ class AutomatedPreprocessingPlan(AgentDataModel): def validate_cell_quality_payload(self) -> "AutomatedPreprocessingPlan": if ( self.cellQualityPayload is not None - and self.cellQc.registeredProfile != self.cellQualityPayload.profile + and cell_qc_policy(self.cellQc.action, self.cellQc.registeredProfile) + != self.cellQualityPayload.profile ): raise ValueError( - "cellQualityPayload must match the selected registered QC profile" + "cellQualityPayload must match the exact selected QC policy" ) return self @@ -257,20 +233,6 @@ def validate_cell_quality_payload(self) -> "AutomatedPreprocessingPlan": def get_blank(cls) -> "AutomatedPreprocessingPlan": return cls() - @classmethod - def get_example(cls) -> "AutomatedPreprocessingPlan": - return cls( - primaryAssay="RNA", - markerAssay="RNA", - cellSelection=ArtifactReferenceModel( - scope="datastore", - kind="cell_selection", - artifactId="c" * 64, - ), - assays=[AssayPreprocessingPlan.get_example()], - planChecksum="0" * 64, - ) - class PreprocessedAssayHandoff(AgentDataModel): """Exact normalized input and selections handed to Parameter Tuning.""" @@ -296,64 +258,16 @@ class PreprocessedAssayHandoff(AgentDataModel): def get_blank(cls) -> "PreprocessedAssayHandoff": return cls() - @classmethod - def get_example(cls) -> "PreprocessedAssayHandoff": - return cls( - assay="RNA", - assayType="RNA", - cellSelection=ArtifactReferenceModel( - scope="datastore", - kind="cell_selection", - artifactId="c" * 64, - ), - reductionMethod="pca", - graphFeatures=ArtifactReferenceModel.get_example(), - markerFeatures=ArtifactReferenceModel.get_example(), - normalized=ArtifactReferenceModel( - assay="RNA", kind="normalized", artifactId="1" * 64 - ), - nCells=100, - nFeatures=1000, - ) - - -class NativeAnalysisHandoff(AgentDataModel): - """Selected immutable native analysis chain for one assay.""" - - assay: str = "" - reductionMethod: ReductionMethod = "none" - featureSelection: ArtifactReferenceModel | None = None - markerFeatures: ArtifactReferenceModel | None = None - normalized: ArtifactReferenceModel | None = None - reduction: ArtifactReferenceModel | None = None - batchCorrection: ArtifactReferenceModel | None = None - annIndex: ArtifactReferenceModel | None = None - embeddingInitialization: ArtifactReferenceModel | None = None - neighbors: ArtifactReferenceModel | None = None - graph: ArtifactReferenceModel | None = None - clusters: ArtifactReferenceModel | None = None - umap: ArtifactReferenceModel | None = None - - @classmethod - def get_blank(cls) -> "NativeAnalysisHandoff": - return cls() - - @classmethod - def get_example(cls) -> "NativeAnalysisHandoff": - return cls(assay="RNA", reductionMethod="pca") - class FinalAnalysisHandoff(AgentDataModel): - """Replayable final analysis used by Biological Interpretation.""" + """Exact final RNA artifacts validated by the concluding journal checkpoint.""" - handoffId: str = "" workflowRunId: str = "" primaryAssay: str = "" markerAssay: str = "" cellSelection: ArtifactReferenceModel | None = None - nativeAnalyses: list[NativeAnalysisHandoff] = Field(default_factory=list) graph: ArtifactReferenceModel | None = None - graphMethod: Literal["native", "snn", "wnn"] = "native" + graphMethod: Literal["native"] = "native" clusters: ArtifactReferenceModel | None = None embeddingInitialization: ArtifactReferenceModel | None = None umap: ArtifactReferenceModel | None = None @@ -361,64 +275,12 @@ class FinalAnalysisHandoff(AgentDataModel): markers: ArtifactReferenceModel | None = None doubletScores: list[ArtifactReferenceModel] = Field(default_factory=list) doubletScoreSelections: list[ArtifactReferenceModel] = Field(default_factory=list) - doubletEvidence: dict[str, Any] = Field(default_factory=dict) - markerEvidence: dict[str, Any] = Field(default_factory=dict) - statisticalTests: list[ArtifactReferenceModel] = Field(default_factory=list) - analysisEvidence: dict[str, Any] = Field(default_factory=dict) - parameterReport: AgentReportReference | None = None limitations: list[str] = Field(default_factory=list) - @model_validator(mode="after") - def validate_handoff_id(self) -> "FinalAnalysisHandoff": - if not self.handoffId: - return self - expected = self._content_handoff_id() - if self.handoffId != expected: - raise ValueError("handoffId does not match the final artifact handoff") - return self - - def _content_handoff_id(self) -> str: - digest = hashlib.sha256( - record_io.canonical_json_bytes( - self.model_dump(mode="json", exclude={"handoffId"}) - ) - ).hexdigest() - return f"handoff:{digest}" - - def with_handoff_id(self) -> "FinalAnalysisHandoff": - values = self.model_dump(mode="json") - values["handoffId"] = self._content_handoff_id() - return FinalAnalysisHandoff.model_validate(values) - @classmethod def get_blank(cls) -> "FinalAnalysisHandoff": return cls() - @classmethod - def get_example(cls) -> "FinalAnalysisHandoff": - return cls( - workflowRunId="workflow-1", - primaryAssay="RNA", - markerAssay="RNA", - cellSelection=ArtifactReferenceModel( - scope="datastore", - kind="cell_selection", - artifactId="c" * 64, - ), - nativeAnalyses=[NativeAnalysisHandoff.get_example()], - graph=ArtifactReferenceModel( - assay="RNA", kind="connectivity_map", artifactId="2" * 64 - ), - embeddingInitialization=ArtifactReferenceModel( - assay="RNA", - kind="embedding_initialization", - artifactId="5" * 64, - ), - clusters=ArtifactReferenceModel( - assay="RNA", kind="cluster_labels", artifactId="3" * 64 - ), - ).with_handoff_id() - class AutomatedWorkflowConfig(AgentDataModel): """Bounded execution policy for automated workflows.""" @@ -427,24 +289,14 @@ class AutomatedWorkflowConfig(AgentDataModel): default="pause", exclude_if=lambda value: value == "pause", ) - maxRefinedCandidatesPerAssay: int = Field(default=1, ge=0, le=1) - maxHarmonyCandidatesPerAssay: int = Field(default=1, ge=0, le=1) - runConfoundedHarmonyDiagnostic: bool = False - maxCandidateEvaluations: int = Field(default=50, ge=1) - minClusterCells: int = Field(default=20, ge=1) - maxIdentityFeatures: int = Field(default=64, ge=2) - hvgCandidateCounts: tuple[int, ...] = (1000, 2000, 4000) - pcaCandidateDimensions: tuple[int, ...] = (10, 20, 30, 50) - graphNeighborCandidates: tuple[int, ...] = (11, 21, 41) - leidenResolutionCandidates: tuple[float, ...] = ( - 0.25, - 0.5, - 0.75, - 1.0, - 1.25, - 1.5, - ) - maxRevisions: int = Field(default=2, ge=0, le=2) + screeningCells: int = Field(default=50_000, ge=20) + maxScreeningCells: int = Field(default=100_000, ge=20) + maxScreeningEvaluations: int = Field(default=12, ge=4) + maxTotalScreeningEvaluations: int = Field(default=24, ge=4) + maxFullGraphs: int = Field(default=4, ge=1) + maxFullPartitions: int = Field(default=8, ge=1) + maxFullRepairs: int = Field(default=1, ge=0, le=1) + randomSeed: int = Field(default=4444, ge=0) allowDownloads: bool = False cacheDir: str | None = None agentRunConfig: AgentRunConfig = Field(default_factory=AgentRunConfig) @@ -456,6 +308,17 @@ def reject_obsolete_configuration(cls, value: Any) -> Any: obsolete = sorted( set(value) & { + "maxRefinedCandidatesPerAssay", + "maxHarmonyCandidatesPerAssay", + "runConfoundedHarmonyDiagnostic", + "maxCandidateEvaluations", + "maxIdentityFeatures", + "minClusterCells", + "hvgCandidateCounts", + "pcaCandidateDimensions", + "graphNeighborCandidates", + "leidenResolutionCandidates", + "maxRevisions", "primaryInitialCandidates", "secondaryInitialCandidates", "integrationResolutionCandidates", @@ -471,7 +334,7 @@ def reject_obsolete_configuration(cls, value: Any) -> Any: "Unsupported legacy workflow configuration fields: " + ", ".join(obsolete) + ". Create a new single-RNA workflow configuration with " - "explicit candidate lists and maxCandidateEvaluations. " + "screening and full-cohort work limits. " "Saved workflows using these fields cannot be resumed or " "regenerated with this release; their analysis artifacts " "remain available through Scarf's artifact APIs." @@ -479,29 +342,12 @@ def reject_obsolete_configuration(cls, value: Any) -> Any: return value @model_validator(mode="after") - def validate_candidate_registry(self) -> "AutomatedWorkflowConfig": - integer_minimums = { - "hvgCandidateCounts": 3, - "pcaCandidateDimensions": 2, - "graphNeighborCandidates": 2, - } - for field_name, minimum in integer_minimums.items(): - values = getattr(self, field_name) - if not values or any(value < minimum for value in values): - raise ValueError( - f"{field_name} must contain integers of at least {minimum}" - ) - if len(values) != len(set(values)) or tuple(sorted(values)) != values: - raise ValueError(f"{field_name} must be sorted and unique") - resolutions = self.leidenResolutionCandidates - if ( - not resolutions - or any(not math.isfinite(value) or value <= 0 for value in resolutions) - or len(resolutions) != len(set(resolutions)) - or tuple(sorted(resolutions)) != resolutions - ): + def validate_work_limits(self) -> "AutomatedWorkflowConfig": + if self.maxScreeningCells < self.screeningCells: + raise ValueError("maxScreeningCells must be at least screeningCells") + if self.maxTotalScreeningEvaluations < self.maxScreeningEvaluations: raise ValueError( - "leidenResolutionCandidates must be finite, positive, sorted, and unique" + "Whole-workflow screening allowance must cover one screening population" ) return self @@ -509,10 +355,6 @@ def validate_candidate_registry(self) -> "AutomatedWorkflowConfig": def get_blank(cls) -> "AutomatedWorkflowConfig": return cls() - @classmethod - def get_example(cls) -> "AutomatedWorkflowConfig": - return cls() - class AutomatedWorkflowRequest(AgentDataModel): """Immutable request for one automated analysis.""" @@ -553,17 +395,6 @@ def get_blank(cls) -> "AutomatedWorkflowRequest": studyObjective="Discover stable population structure.", ) - @classmethod - def get_example(cls) -> "AutomatedWorkflowRequest": - return cls( - sourcePath="dataset.h5ad", - zarrPath="dataset.zarr", - studyContext="Single-cell profiling of treated human blood.", - studyObjective=( - "Discover stable populations while preserving treatment structure." - ), - ) - class AutomatedWorkflowResumeRequest(AgentDataModel): """Answers used to resume one running workflow.""" @@ -585,78 +416,72 @@ def validate_request(self) -> "AutomatedWorkflowResumeRequest": def get_blank(cls) -> "AutomatedWorkflowResumeRequest": return cls(zarrPath="dataset.zarr", workflowRunId="workflow-1") - @classmethod - def get_example(cls) -> "AutomatedWorkflowResumeRequest": - return cls( - zarrPath="dataset.zarr", - workflowRunId="workflow-1", - answers={"approvePlanChecksum": "0" * 64}, - ) - class AutomatedWorkflowResult(AgentDataModel): - """Bounded result of running or resuming an automated workflow.""" + """Small result address; scientific evidence remains in the stage journal.""" status: AutomatedWorkflowStatus = "failed" currentStage: WorkflowStageName = "ingest" zarrPath: str | None = None - workflowRun: AgentWorkflowRun | None = None - reportReferences: list[AgentReportReference] = Field(default_factory=list) - datasetManifest: DatasetManifest | None = None - preprocessingPlan: AutomatedPreprocessingPlan | None = None - studyContract: StudyContract | None = None - finalAnalysis: FinalAnalysisHandoff | None = None - finalHandoffId: str | None = None - decisionRunId: str | None = None - verificationSummary: list[str] = Field(default_factory=list) + workspace: str | None = None + workflowRunId: str | None = None + needsInput: WorkflowNeedsInput | None = None limitations: list[str] = Field(default_factory=list) unresolvedClaims: list[str] = Field(default_factory=list) - needsInput: WorkflowNeedsInput | None = None notes: list[str] = Field(default_factory=list) - contentSha256: str = "" - def _completed_analysis(self) -> FinalAnalysisHandoff: + def _analysis_store(self) -> "DataStore": + from .journal import open_analysis_store + if self.status != "completed": - detail = "; ".join(self.notes) - raise RuntimeError( - f"Analysis did not complete: {self.status} at {self.currentStage}" - + (f" ({detail})" if detail else "") - ) - if self.finalAnalysis is None or self.workflowRun is None or not self.zarrPath: + raise AnalysisError(self) + if self.zarrPath is None or self.workflowRunId is None: + raise RuntimeError("Completed analysis lacks its exact store and workflow") + return open_analysis_store( + self.zarrPath, self.workflowRunId, workspace=self.workspace + ) + + def _completed_analysis(self, store: "DataStore") -> FinalAnalysisHandoff: + from .journal import analysis_snapshot + + assert self.workflowRunId is not None + snapshot = analysis_snapshot(store, self.workflowRunId) + if snapshot["status"] != "completed": raise RuntimeError( - "Completed analysis is missing its store or final handoff" + "The referenced workflow has no validated final analysis" ) - return self.finalAnalysis - - def _analysis_store(self) -> "DataStore": - from ...datastore.datastore import DataStore - - final = self._completed_analysis() - assert self.workflowRun is not None and self.zarrPath is not None - return DataStore( - self.zarrPath, - default_assay=final.primaryAssay, - min_features_per_cell=-1, - mito_pattern="", - ribo_pattern="", - zarr_mode="r", - workspace=self.workflowRun.workspace, - ) + return FinalAnalysisHandoff.model_validate(snapshot["finalAnalysis"]) def plot_embedding(self, **kwargs: Any) -> "PlotResult": - """Plot the final UMAP, colored by the selected clusters by default. - - Display options are forwarded to ``DataStore.plots.embedding``. The - persisted layout is fixed; no new embedding is computed. - """ - final = self._completed_analysis() - if final.umap is None or final.clusters is None: - raise RuntimeError("Completed analysis is missing its UMAP or clusters") - if "layout" in kwargs or "run" in kwargs: - raise ValueError("plot_embedding uses the completed analysis layout") - kwargs.setdefault("color_by", artifact_model_to_ref(final.clusters)) - return self._analysis_store().plots.embedding( - layout=artifact_model_to_ref(final.umap), + """Display the saved cluster map with bounded reads and full-count provenance.""" + from .._plots import plot_final_umap + + store = self._analysis_store() + final = self._completed_analysis(store) + if ( + final.umap is None + or final.clusters is None + or final.cellSelection is None + or final.graph is None + ): + raise RuntimeError("Completed analysis lacks its final map artifacts") + for name in ( + "layout", + "run", + "color_by", + "umap", + "clusters", + "cell_selection", + "graph", + ): + if name in kwargs: + raise ValueError("plot_embedding uses the exact completed cluster map") + return plot_final_umap( + store, + umap=artifact_model_to_ref(final.umap), + clusters=artifact_model_to_ref(final.clusters), + cell_selection=artifact_model_to_ref(final.cellSelection), + graph=artifact_model_to_ref(final.graph), **kwargs, ) @@ -667,11 +492,12 @@ def get_markers( min_score: float = 0.25, min_frac_exp: float = 0.2, ) -> "pd.DataFrame": - """Read the final marker table with Scarf's standard marker filters.""" - final = self._completed_analysis() + """Load the final markers using Scarf's established marker filters.""" + store = self._analysis_store() + final = self._completed_analysis(store) if final.markers is None: - raise RuntimeError("Completed analysis is missing its marker table") - return self._analysis_store().get_markers( + raise RuntimeError("Completed analysis lacks its marker artifact") + return store.get_markers( marker=artifact_model_to_ref(final.markers), group_id=group_id, min_score=min_score, @@ -679,77 +505,25 @@ def get_markers( ) def report(self) -> Path: - """Return the local HTML report, generating it if it is missing.""" - from ..report.artifacts import _local_root + """Return or regenerate the compact report from saved evidence only.""" from ..report.generator import generate_agent_report - self._completed_analysis() - assert self.workflowRun is not None and self.zarrPath is not None - root = _local_root(self.zarrPath) - workspace = self.workflowRun.workspace - active_root = root if workspace is None else (root / workspace).resolve() - if not active_root.is_relative_to(root): - raise ValueError("Workflow workspace resolves outside the analysis store") - report_path = ( - active_root - / "agents" - / "runs" - / self.workflowRun.workflowRunId - / "report" - / "index.html" - ).resolve() - if not report_path.is_relative_to(active_root): - raise ValueError("Agent report path resolves outside the analysis store") - if report_path.is_file(): - return report_path - return generate_agent_report( - self.zarrPath, - self.workflowRun.workflowRunId, - workspace=workspace, - ) - - @model_validator(mode="after") - def validate_terminal_handoff(self) -> "AutomatedWorkflowResult": - if self.status != "completed": - return self - if self.finalAnalysis is None or not self.finalAnalysis.handoffId: - raise ValueError("Completed workflow results require a final handoff") - if self.finalHandoffId != self.finalAnalysis.handoffId: - raise ValueError("Result and final analysis handoff IDs must agree") - if ( - self.workflowRun is None - or self.decisionRunId != self.workflowRun.workflowRunId - ): - raise ValueError( - "Completed results require the matching decision workflow ID" - ) - return self + store = self._analysis_store() + self._completed_analysis(store) + assert self.workflowRunId is not None + return generate_agent_report(store, self.workflowRunId) @classmethod def get_blank(cls) -> "AutomatedWorkflowResult": return cls() - @classmethod - def get_example(cls) -> "AutomatedWorkflowResult": - workflow = AgentWorkflowRun.get_example() - final_analysis = FinalAnalysisHandoff.get_example() - return cls( - status="completed", - currentStage="analysis_finalization", - zarrPath="dataset.zarr", - workflowRun=workflow, - studyContract=StudyContract.get_example(), - finalAnalysis=final_analysis, - finalHandoffId=final_analysis.handoffId, - decisionRunId=workflow.workflowRunId, - ) - class OrchestrationRequestRecord(AgentDataModel): """Stored immutable request and effective configuration.""" recordType: Literal["automatedWorkflowRequest"] = "automatedWorkflowRequest" - formatVersion: Literal[2] = 2 + inputIdentity: dict[str, Any] + modelIdentity: str workflowRunId: str = "" createdAtNs: int = Field(default=0, ge=0) request: AutomatedWorkflowRequest = Field( @@ -762,47 +536,19 @@ class OrchestrationRequestRecord(AgentDataModel): configSha256: str = "" contentSha256: str = "" - @classmethod - def get_blank(cls) -> "OrchestrationRequestRecord": - return cls() - - @classmethod - def get_example(cls) -> "OrchestrationRequestRecord": - return cls( - workflowRunId="workflow-1", - createdAtNs=1, - request=AutomatedWorkflowRequest.get_example(), - config=AutomatedWorkflowConfig.get_example(), - requestSha256="0" * 64, - configSha256="1" * 64, - ) - class OrchestrationResumeRecord(AgentDataModel): - """One append-only set of answers supplied during resume.""" + """Runtime answers committed as inputs of their owning stage attempt.""" - recordType: Literal["automatedWorkflowResume"] = "automatedWorkflowResume" workflowRunId: str = "" - resumeId: str = "" - createdAtNs: int = Field(default=0, ge=0) answeredAttempt: WorkflowStageLink | None = None questionIds: list[str] = Field(default_factory=list) answers: dict[str, Any] = Field(default_factory=dict) - contentSha256: str = "" @classmethod def get_blank(cls) -> "OrchestrationResumeRecord": return cls() - @classmethod - def get_example(cls) -> "OrchestrationResumeRecord": - return cls( - workflowRunId="workflow-1", - resumeId="resume-1", - createdAtNs=1, - answers={"approvePlanChecksum": "0" * 64}, - ) - def artifact_model_to_ref(value: ArtifactReferenceModel) -> ArtifactRef: """Convert an agent artifact model to a validated core artifact reference.""" diff --git a/scarf/agent/orchestrator/preprocessing.py b/scarf/agent/orchestrator/preprocessing.py index 3bb182c9..2590c6fe 100644 --- a/scarf/agent/orchestrator/preprocessing.py +++ b/scarf/agent/orchestrator/preprocessing.py @@ -1,30 +1,22 @@ """Preprocessing planning and execution stages.""" import hashlib -import re from collections.abc import Mapping, Sequence -from typing import Any, cast +from typing import Any import numpy as np -from sklearn.metrics import adjusted_rand_score, normalized_mutual_info_score from ...assay import RNAassay from ...datastore.datastore import DataStore from ...datastore.summary import AssaySummary -from ...features.variability import DEFAULT_HVG_BLACKLIST from ...metadata.selection import NamedCellArtifact -from ...quality_control.cell_cycle_genes import ( - g2m_phase_genes, - g2m_phase_genes_mouse, - s_phase_genes, - s_phase_genes_mouse, -) from ...storage.refs import ArtifactRef from ...storage.selections import read_stored_selection_mask from ...storage.types import as_zarr_array from ...utils.logging import logger from .. import record_io -from ..cell_quality.execution import execute_registered_cell_qc +from ..cell_quality.execution import execute_auto_cell_qc, execute_registered_cell_qc +from ..cell_quality.profiles import CellQualityProfile, cell_qc_policy from ..data_enrichment.contracts import ( AssayFeatureInspection, DataEnrichmentReport, @@ -33,15 +25,8 @@ from ..decisions.kernel import DecisionEvidence, DecisionSelection, EvidenceBundle from ..decisions.rna import ( CellQualityExecutorPayload, - CellQualityProfile, - FeaturePolicyExecutorPayload, - HvgExecutorPayload, - HvgRankingExecutorPayload, QcGroupingExecutorPayload, build_cell_quality_decision, - build_feature_policy_decision, - build_hvg_count_decision, - build_hvg_ranking_decision, build_qc_grouping_decision, require_option_evidence, ) @@ -51,29 +36,12 @@ ExperimentalContextResult, ) from ..experimental_context.study import StudyContract -from ..parameter_tuning.agent import prepare_parameter_tuning_dependencies -from ..parameter_tuning.contracts import ( - ParameterCandidate, - ParameterCandidateEvaluation, -) -from ..parameter_tuning.diagnostics import ( - SCARF_DEFAULT_DIAGNOSTIC_FAMILIES, - augment_cluster_evaluations, - augment_pca_evaluations, -) from ..parameter_tuning.execution import ( candidate_metric_cache, - execute_parameter_candidate, -) -from ..parameter_tuning.hvg import ( - HvgRanking, - compare_hvg_ranking_to_default, - run_hvg_diagnostic_artifacts, ) -from ..persistence.contracts import AgentWorkflowRun +from .models import WorkflowIdentity from ..types import ArtifactReferenceModel from . import journal -from .budget import reserve_candidate_pass from .decisions import DecisionStagesMixin from .models import ( AssayPreprocessingPlan, @@ -101,35 +69,11 @@ class _DecisionNeedsInput(RuntimeError): def __init__( self, question: WorkflowQuestion, - snapshot_sha256: str, + checkpoint_sha256: str, ) -> None: super().__init__("A registered RNA decision requires human input") self.question = question - self.snapshotSha256 = snapshot_sha256 - - -def apply_feature_policy_to_plan( - plan: AutomatedPreprocessingPlan, - payload: FeaturePolicyExecutorPayload, -) -> AutomatedPreprocessingPlan: - assays: list[AssayPreprocessingPlan] = [] - for assay in plan.assays: - if assay.assay != plan.primaryAssay: - assays.append(assay) - continue - parameters = { - **assay.featureParameters, - "excludeFamilies": list(payload.excludedFamilies), - "useScarfDefaultBlacklist": payload.useScarfDefaultBlacklist, - } - assays.append(assay.model_copy(update={"featureParameters": parameters})) - updated = plan.model_copy(update={"assays": assays, "planChecksum": ""}) - checksum = hashlib.sha256( - record_io.canonical_json_bytes( - updated.model_dump(mode="json", exclude={"planChecksum"}) - ) - ).hexdigest() - return updated.model_copy(update={"planChecksum": checksum}) + self.checkpointSha256 = checkpoint_sha256 class PreprocessingStagesMixin(DecisionStagesMixin): @@ -200,16 +144,22 @@ def _decision_evidence_bundle( @staticmethod def _profile_is_safe(profile: CellQcProfileEvidence) -> bool: - if profile.unsafeRetentionGroups: + if profile.action == "sampleMad" and ( + profile.sampleColumn != profile.captureColumn + or profile.sampleArtifact != profile.captureArtifact + or (profile.captureColumn is None and profile.captureArtifact is None) + ): + return False + if profile.retainedCells == 0 or profile.unsafeRetentionGroups: return False if ( - profile.registeredProfile + profile.action == "sampleMad" + or profile.registeredProfile in { "captureMad5", "captureMad3Sensitivity", } - and profile.failedCaptureCandidates - ): + ) and profile.failedCaptureCandidates: return False if profile.registeredProfile == "pooledReferenceMad5" and set( profile.failedCaptureCandidates @@ -218,28 +168,74 @@ def _profile_is_safe(profile: CellQcProfileEvidence) -> bool: return True @staticmethod - def _profile_evidence(profile: CellQcProfileEvidence) -> DecisionEvidence: + def _profile_evidence( + profile: CellQcProfileEvidence, + retained_reference: CellQcProfileEvidence | None = None, + ) -> DecisionEvidence: def retention_range(values: Sequence[int]) -> str: if not values: return "no groups" ordered = sorted(int(value) for value in values) return ( f"{len(ordered)} groups, min/median/max=" - f"{ordered[0]}/{ordered[len(ordered) // 2]}/{ordered[-1]}" + f"{ordered[0]}/{float(np.median(ordered)):g}/{ordered[-1]}" ) capture_summary = retention_range(list(profile.sampleRetainedCells.values())) + capture_fractions = [ + row.retainedFraction for row in profile.captureFailureEvidence + ] + capture_fraction_summary = ( + f"min/median/max={min(capture_fractions):.1%}/" + f"{float(np.median(capture_fractions)):.1%}/{max(capture_fractions):.1%}" + if capture_fractions + else "not available" + ) + active_by_column = ( + retained_reference.retainedCellsByColumn + if retained_reference is not None + and retained_reference.retainedCells + == retained_reference.activeCells + == profile.activeCells + else {} + ) + + def design_retention(column: str, groups: Mapping[str, int]) -> str: + active = active_by_column.get(column, {}) + if not groups or any(not active.get(group) for group in groups): + return ( + retention_range(list(groups.values())) + "; fractions unavailable" + ) + if len(groups) <= 4: + return ", ".join( + f"{group}={retained}/{active[group]} ({retained / active[group]:.1%})" + for group, retained in sorted(groups.items()) + ) + fractions = [retained / active[group] for group, retained in groups.items()] + return ( + f"{retention_range(list(groups.values()))}, retained fractions " + f"min/median/max={min(fractions):.1%}/{float(np.median(fractions)):.1%}/" + f"{max(fractions):.1%}" + ) + design_summary = "; ".join( - f"{column}: {retention_range(list(groups.values()))}" + f"{column}: {design_retention(column, groups)}" for column, groups in sorted(profile.retainedCellsByColumn.items()) ) summary = ( - f"{profile.registeredProfile} retains " - f"{profile.retainedCells}/{profile.activeCells} active cells; " - f"capture retention={capture_summary}; design retention=" + f"{cell_qc_policy(profile.action, profile.registeredProfile) or profile.action} retains " + f"{profile.retainedCells}/{profile.activeCells} active cells " + f"({profile.retainedCells / profile.activeCells:.1%}); " + if profile.activeCells + else "No active cells; " + ) + ( + f"metric flags={profile.metricFlaggedCells or 'not available'} " + "(flags may overlap; high counts/features can be retained); " + f"capture retention={capture_summary}; capture retained fractions=" + f"{capture_fraction_summary}; design retention=" f"{design_summary or 'no groups'}; failed capture candidates=" f"{profile.failedCaptureCandidates}; unsafe retention groups=" - f"{profile.unsafeRetentionGroups}." + f"{profile.unsafeRetentionGroups}; limitations={profile.notes[:4]}." ) if len(summary) > 2_000: summary = f"{summary[:1_997].rstrip()}..." @@ -268,23 +264,26 @@ def _resolve_qc_grouping_decision( profiles = [ profile for profile in experimental.qcProfiles - if profile.registeredProfile is not None + if cell_qc_policy(profile.action, profile.registeredProfile) is not None ] if not profiles: - raise ValueError("RNA decision workflow requires registered QC evidence") + raise ValueError("RNA decision workflow requires executable QC evidence") safe_profiles = { - profile.registeredProfile: profile + cell_qc_policy(profile.action, profile.registeredProfile): profile for profile in profiles - if profile.registeredProfile is not None and self._profile_is_safe(profile) + if self._profile_is_safe(profile) } capture_eligible = bool( study_contract.physicalCaptureColumn is not None - and "captureMad5" in safe_profiles + and any( + policy in safe_profiles for policy in ("coreSampleMad3", "captureMad5") + ) ) pooled_eligible = bool( capture_eligible and "pooledReferenceMad5" in safe_profiles ) design_id = "evidence:qcGrouping:studyContract" + retained_reference = safe_profiles.get("retainWithFlags") evidence = [ DecisionEvidence( evidenceId=design_id, @@ -293,26 +292,46 @@ def _resolve_qc_grouping_decision( "The validated physical capture is " f"{study_contract.physicalCaptureColumn!r}; independent units=" f"{study_contract.independentUnitColumns}; conditions=" - f"{study_contract.conditionColumns}." + f"{study_contract.conditionColumns}. " + "Within-capture QC estimates technical quality boundaries; captures " + "need not be independent biological units or healthy references. " + "Healthy-reference provenance is required only for a pooled reference. " + "Compare global and capture five-MAD profiles when both are supplied; " + "different cutoff methods cannot isolate the effect of grouping." ), ) ] mode_profile: dict[str, CellQcProfileEvidence] = {} - global_profile = safe_profiles.get("globalMad5") or safe_profiles.get( - "retainWithFlags" + matched_mad = "globalMad5" in safe_profiles and "captureMad5" in safe_profiles + global_profile = ( + safe_profiles["globalMad5"] + if matched_mad + else safe_profiles.get("coreGlobalGaussian") + or safe_profiles.get("globalMad5") + or safe_profiles.get("retainWithFlags") ) if global_profile is None: raise ValueError("No safe global or retain-only QC profile is available") mode_profile["qcGrouping:global"] = global_profile - evidence.append(self._profile_evidence(global_profile)) + baseline = next( + (profile for profile in profiles if profile.action == "globalGaussian"), + None, + ) + if baseline is not None and baseline != global_profile: + evidence.append(self._profile_evidence(baseline, retained_reference)) + evidence.append(self._profile_evidence(global_profile, retained_reference)) if capture_eligible: - capture_profile = safe_profiles["captureMad5"] + capture_profile = ( + safe_profiles["captureMad5"] + if matched_mad + else safe_profiles.get("coreSampleMad3") or safe_profiles["captureMad5"] + ) mode_profile["qcGrouping:physicalCapture"] = capture_profile - evidence.append(self._profile_evidence(capture_profile)) + evidence.append(self._profile_evidence(capture_profile, retained_reference)) if pooled_eligible: pooled_profile = safe_profiles["pooledReferenceMad5"] mode_profile["qcGrouping:pooledReference"] = pooled_profile - evidence.append(self._profile_evidence(pooled_profile)) + evidence.append(self._profile_evidence(pooled_profile, retained_reference)) bundle = self._decision_evidence_bundle("qcGrouping", evidence) definition = build_qc_grouping_decision( evidence_bundle_id=bundle.bundleId, @@ -358,12 +377,12 @@ def _resolve_qc_grouping_decision( if resolution.compiled is None: raise _DecisionNeedsInput( self._pending_decision_question(resolution, definition), - resolution.snapshotSha256, + resolution.checkpointSha256, ) payload = resolution.compiled.executorPayload if not isinstance(payload, QcGroupingExecutorPayload): raise TypeError("QC-grouping decision compiled an unexpected payload") - return payload, resolution.snapshotSha256 + return payload, resolution.checkpointSha256 def _resolve_cell_quality_decision( self, @@ -376,38 +395,38 @@ def _resolve_cell_quality_decision( all_profiles = [ profile for profile in experimental.qcProfiles - if profile.registeredProfile is not None + if cell_qc_policy(profile.action, profile.registeredProfile) is not None ] + all_profiles.sort(key=lambda profile: profile.action != "globalGaussian") allowed_by_grouping: dict[str, set[CellQualityProfile]] = { - "global": {"retainWithFlags", "globalMad5"}, - "physicalCapture": {"retainWithFlags", "captureMad5"}, + "global": {"retainWithFlags", "coreGlobalGaussian", "globalMad5"}, + "physicalCapture": {"retainWithFlags", "coreSampleMad3", "captureMad5"}, "pooledReference": {"retainWithFlags", "pooledReferenceMad5"}, } allowed = allowed_by_grouping[grouping.groupingMode] profiles = [ profile for profile in all_profiles - if profile.registeredProfile in allowed and self._profile_is_safe(profile) + if cell_qc_policy(profile.action, profile.registeredProfile) in allowed + and self._profile_is_safe(profile) ] if not profiles: - raise ValueError("QC grouping has no safe registered profile") - evidence = [self._profile_evidence(profile) for profile in profiles] - if grouping.groupingMode == "physicalCapture": - sensitivity = next( - ( - profile - for profile in all_profiles - if profile.registeredProfile == "captureMad3Sensitivity" - ), - None, - ) - if sensitivity is not None: - evidence.append(self._profile_evidence(sensitivity)) + raise ValueError("QC grouping has no safe executable profile") + profiles.sort(key=lambda profile: profile.action != "globalGaussian") + retained_reference = next( + (p for p in all_profiles if p.registeredProfile == "retainWithFlags"), None + ) + evidence = [ + self._profile_evidence(profile, retained_reference) + for profile in all_profiles + ] + bundle = self._decision_evidence_bundle("cellQuality", evidence) available_profiles = [ - profile.registeredProfile + policy for profile in profiles - if profile.registeredProfile is not None + if (policy := cell_qc_policy(profile.action, profile.registeredProfile)) + is not None ] definition = build_cell_quality_decision( evidence_bundle_id=bundle.bundleId, @@ -416,7 +435,9 @@ def _resolve_cell_quality_decision( definition = require_option_evidence( definition, { - f"cellQuality:{profile.registeredProfile}": [profile.evidenceId] + f"cellQuality:{cell_qc_policy(profile.action, profile.registeredProfile)}": [ + profile.evidenceId + ] for profile in profiles }, ) @@ -430,7 +451,7 @@ def _resolve_cell_quality_decision( if resolution.compiled is None: raise _DecisionNeedsInput( self._pending_decision_question(resolution, definition), - resolution.snapshotSha256, + resolution.checkpointSha256, ) payload = resolution.compiled.executorPayload if not isinstance(payload, CellQualityExecutorPayload): @@ -439,7 +460,8 @@ def _resolve_cell_quality_decision( ( profile for profile in profiles - if profile.registeredProfile == payload.profile + if cell_qc_policy(profile.action, profile.registeredProfile) + == payload.profile ), None, ) @@ -458,122 +480,7 @@ def _resolve_cell_quality_decision( rationale=resolution.record.rationale, evidenceIds=list(resolution.record.evidenceIds), ) - return payload, plan, resolution.snapshotSha256 - - def _resolve_feature_policy_decision( - self, - store: DataStore, - request_record: OrchestrationRequestRecord, - plan: AutomatedPreprocessingPlan, - enrichment: DataEnrichmentReport, - answers: Mapping[str, Any], - ) -> tuple[FeaturePolicyExecutorPayload, str]: - policy = next( - ( - value - for value in enrichment.policies - if value.assay == plan.primaryAssay - ), - None, - ) - nominations = list(policy.excludeFamilies) if policy is not None else [] - protected = list(policy.protectFamilies) if policy is not None else [] - assay = store.get_assay(plan.primaryAssay) - default_match_count = len(assay.feats.grep(DEFAULT_HVG_BLACKLIST)) - default_families = { - "mitochondrial", - "ribosomal", - "mitoribosomal", - "cellCycle", - "hla", - "h2", - "histone", - "sexLinked", - } - default_eligible = bool( - default_match_count and not default_families.intersection(protected) - ) - evidence_id = f"evidence:featurePolicy:{plan.primaryAssay}:context" - default_evidence_id = ( - f"evidence:featurePolicy:{plan.primaryAssay}:scarfDefaults" - ) - bundle = self._decision_evidence_bundle( - "featurePolicy", - [ - DecisionEvidence( - evidenceId=evidence_id, - evidenceClass="technical", - summary=( - f"Data Enrichment nominated {sorted(nominations)} and " - f"protected {sorted(protected)}. No representation-dominance " - "evidence exists before the native PCA diagnostic." - ), - ), - DecisionEvidence( - evidenceId=default_evidence_id, - evidenceClass="technical", - summary=( - f"The exact core Scarf DEFAULT_HVG_BLACKLIST matches " - f"{default_match_count} features. It is an explicit " - "representation-only baseline, not an automatic winner; " - f"context-protected families are {sorted(protected)}." - ), - ), - ], - ) - definition = build_feature_policy_decision( - evidence_bundle_id=bundle.bundleId, - proposed_exclusion_families=[], - dominant_families=[], - protected_families=[], - scarf_default_eligible=default_eligible, - ) - requirements = {"featurePolicy:keepAll": [evidence_id, default_evidence_id]} - if default_eligible: - requirements["featurePolicy:excludeScarfDefaults"] = [ - default_evidence_id, - evidence_id, - ] - definition = require_option_evidence( - definition, - requirements, - ) - rule_selection = ( - None - if default_eligible - else DecisionSelection( - selectedOptionId="featurePolicy:keepAll", - evidenceIds=[evidence_id, default_evidence_id], - rationale=( - "Keep all graph-eligible features because the exact Scarf " - "default bundle conflicts with an objective-protected family." - ), - ) - ) - resolution = self._resolve_rna_decision( - store, - request_record, - definition, - bundle, - answers, - rule_selection=rule_selection, - ) - if resolution.compiled is None: - raise _DecisionNeedsInput( - self._pending_decision_question(resolution, definition), - resolution.snapshotSha256, - ) - payload = resolution.compiled.executorPayload - if not isinstance(payload, FeaturePolicyExecutorPayload): - raise TypeError("Feature-policy decision compiled an unexpected payload") - return payload, resolution.snapshotSha256 - - @staticmethod - def _apply_feature_policy_to_plan( - plan: AutomatedPreprocessingPlan, - payload: FeaturePolicyExecutorPayload, - ) -> AutomatedPreprocessingPlan: - return apply_feature_policy_to_plan(plan, payload) + return payload, plan, resolution.checkpointSha256 @staticmethod def _plan_with_selected_hvg_counts( @@ -605,7 +512,7 @@ def _plan_with_selected_hvg_counts( def preprocessing_plan_stage( self, store: DataStore, - workflow: AgentWorkflowRun, + workflow: WorkflowIdentity, request_record: OrchestrationRequestRecord, parents: Sequence[WorkflowStageLink], enrichment: DataEnrichmentReport, @@ -628,7 +535,7 @@ def preprocessing_plan_stage( parents, ) if existing is not None: - logger.info( + logger.debug( f"Workflow {workflow.workflowRunId}: reusing preprocessing plan" ) cached_plan = AutomatedPreprocessingPlan.model_validate( @@ -682,21 +589,13 @@ def preprocessing_plan_stage( ) validate_rna_plan(plan, selected) plan = plan.model_copy(update={"cellQualityPayload": cell_payload}) - feature_payload, feature_decision_snapshot = ( - self._resolve_feature_policy_decision( - store, - request_record, - plan, - enrichment, - answers, - ) - ) - plan = self._apply_feature_policy_to_plan(plan, feature_payload) + # The exact core feature policy is a provisional baseline. Its + # scientific assessment follows observed PCA and marker evidence. route_summary = ", ".join( f"{value.assay}:{value.featureMethod}/{value.reductionMethod}" for value in plan.assays ) - logger.info( + logger.debug( f"Workflow {workflow.workflowRunId}: preprocessing plan built " f"(primary={plan.primaryAssay!r}, marker={plan.markerAssay!r}, " f"routes=[{route_summary}])" @@ -710,7 +609,7 @@ def preprocessing_plan_stage( "cellSelection": experimental.cellSelection, **cell_qc_artifacts, }, - outputs={"decisionSnapshotSha256": pending.snapshotSha256}, + outputs={"decisionCheckpointSha256": pending.checkpointSha256}, error=( "The unattended preprocessing plan returned an unresolved " "registered decision" @@ -724,7 +623,7 @@ def preprocessing_plan_stage( "cellSelection": experimental.cellSelection, **cell_qc_artifacts, }, - outputs={"decisionSnapshotSha256": pending.snapshotSha256}, + outputs={"decisionCheckpointSha256": pending.checkpointSha256}, needs_input=WorkflowNeedsInput(questions=[pending.question]), notes=["A registered filtering decision requires input."], ) @@ -743,7 +642,7 @@ def preprocessing_plan_stage( }, ) return outcome, AutomatedPreprocessingPlan.get_blank() - logger.info( + logger.debug( f"Workflow {workflow.workflowRunId}: executing the evidence-bounded " "preprocessing plan" ) @@ -756,14 +655,12 @@ def preprocessing_plan_stage( }, outputs={ "preprocessingPlan": plan.model_dump(mode="json"), - "qcGroupingDecisionSnapshot": grouping_decision_snapshot, - "cellQualityDecisionSnapshot": cell_decision_snapshot, - "featurePolicyDecisionSnapshot": feature_decision_snapshot, + "qcGroupingDecisionCheckpoint": grouping_decision_snapshot, + "cellQualityDecisionCheckpoint": cell_decision_snapshot, }, actions=[ "audit_qc_grouping_decision", "audit_cell_quality_decision", - "audit_feature_policy_decision", "accept_evidence_bounded_preprocessing_plan", ], ) @@ -794,28 +691,11 @@ def build_preprocessing_plan( ) if policy is not None and policy.assayModality != "RNA": raise ValueError("Enrichment policy does not match the selected RNA assay") - selected_qc_profile = next( - ( - value - for value in experimental.qcProfiles - if value.profileId == cell_qc.profileId - ), - None, - ) - projected_cells = ( - selected_qc_profile.retainedCells - if selected_qc_profile is not None and selected_qc_profile.retainedCells > 0 - else store_summary.active_cells - ) assay_plan = self.build_assay_preprocessing_plan( - store, - request_record, selected, summary, policy, inspection, - "RNA", - min(20, max(1, projected_cells // 10)), ) if not assay_plan.graphEligible: raise ValueError("RNA requires at least three features for PCA") @@ -836,17 +716,12 @@ def build_preprocessing_plan( def build_assay_preprocessing_plan( self, - store: DataStore, - request_record: OrchestrationRequestRecord, assay_name: str, summary: AssaySummary, policy: FeatureSelectionPolicy | None, inspection: AssayFeatureInspection | None, - modality: str, - effective_min_cells: int, ) -> AssayPreprocessingPlan: - del store, request_record - if modality != "RNA" or summary.assay_type != "RNA": + if summary.assay_type != "RNA": raise ValueError("Automated preprocessing supports RNA only") evidence_ids = list(policy.evidenceIds) if policy is not None else [] graph_eligible = summary.total_features >= 3 @@ -860,13 +735,20 @@ def build_assay_preprocessing_plan( featureMethod="hvg" if graph_eligible else "none", reductionMethod="pca" if graph_eligible else "none", featureParameters={ - "topN": min(2000, summary.total_features), - "minCells": effective_min_cells, "excludeFamilies": [], + "useScarfDefaultBlacklist": True, "proposedExcludeFamilies": proposed_families, "protectFamilies": ( list(policy.protectFamilies) if policy is not None else [] ), + "protectFeatures": list(policy.protectFeatures) + if policy is not None + else [], + "proposedExcludeFeatures": list( + dict.fromkeys([*policy.excludeFeatures, *policy.artificialFeatures]) + ) + if policy is not None + else [], "species": ( inspection.species if inspection is not None else "unknown" ), @@ -877,14 +759,6 @@ def build_assay_preprocessing_plan( else None ), }, - normalizationParameters={ - "logTransform": True, - "renormalizeSubset": True, - }, - reductionParameters={"dimensions": min(50, summary.total_features - 1)}, - exactExcludedFeatures=( - list(policy.artificialFeatures) if policy is not None else [] - ), evidenceIds=evidence_ids, limitations=( [] @@ -896,7 +770,7 @@ def build_assay_preprocessing_plan( def preprocessing_stage( self, store: DataStore, - workflow: AgentWorkflowRun, + workflow: WorkflowIdentity, request_record: OrchestrationRequestRecord, parents: Sequence[WorkflowStageLink], plan: AutomatedPreprocessingPlan, @@ -915,26 +789,6 @@ def preprocessing_stage( validate_rna_plan(plan, selected) validate_rna_context(experimental, selected) prefix = journal._ensure_orchestration_store(store) - try: - candidate_budget = reserve_candidate_pass( - store, prefix, workflow, request_record, stage_name - ) - except ValueError as exc: - rejected = journal._start_attempt( - store.zw, - prefix, - workflow.workflowRunId, - stage_name, - request_record, - parents, - inputs={"candidateBudgetRejected": True}, - resume_record=resume_record, - ) - return ( - journal.finish_exception(store, prefix, workflow, rejected, exc), - [], - plan, - ) existing = journal._validated_done_outcome( store, prefix, @@ -944,7 +798,7 @@ def preprocessing_stage( parents, ) if existing is not None: - logger.info( + logger.debug( f"Workflow {workflow.workflowRunId}: reusing preprocessing artifacts" ) cached_handoffs = [ @@ -974,7 +828,6 @@ def preprocessing_stage( inputs={ "preprocessingPlan": plan.model_dump(mode="json"), "cellSelection": plan.cellSelection.model_dump(mode="json"), - "candidateBudget": candidate_budget, }, resume_record=resume_record, ) @@ -1045,7 +898,7 @@ def preprocessing_stage( for assay_plan in plan.assays: if not assay_plan.graphEligible: continue - logger.info( + logger.debug( f"Workflow {workflow.workflowRunId}: preprocessing assay " f"{assay_plan.assay!r} via {assay_plan.featureMethod}/" f"{assay_plan.reductionMethod}" @@ -1086,7 +939,7 @@ def preprocessing_stage( actions=actions, ) journal._save_outcome(store.zw, prefix, outcome) - logger.info( + logger.debug( f"Workflow {workflow.workflowRunId}: preprocessing produced " f"{len(handoffs)} graph-ready assay handoff(s)" ) @@ -1103,7 +956,7 @@ def preprocessing_stage( }, outputs={ "operations": operations, - "decisionSnapshotSha256": pending.snapshotSha256, + "decisionCheckpointSha256": pending.checkpointSha256, }, actions=actions, error=( @@ -1122,7 +975,7 @@ def preprocessing_stage( }, outputs={ "operations": operations, - "decisionSnapshotSha256": pending.snapshotSha256, + "decisionCheckpointSha256": pending.checkpointSha256, }, needs_input=WorkflowNeedsInput(questions=[pending.question]), actions=actions, @@ -1143,85 +996,6 @@ def preprocessing_stage( ) return outcome, [], plan - def reuse_feature_policy_preprocessing_stage( - self, - store: DataStore, - workflow: AgentWorkflowRun, - request_record: OrchestrationRequestRecord, - parents: Sequence[WorkflowStageLink], - plan: AutomatedPreprocessingPlan, - baseline_outcome: WorkflowStageAttempt, - baseline_handoffs: Sequence[PreprocessedAssayHandoff], - *, - resume_record: OrchestrationResumeRecord | None = None, - ) -> tuple[ - WorkflowStageAttempt, - list[PreprocessedAssayHandoff], - AutomatedPreprocessingPlan, - ]: - """Record deterministic reuse when feature review keeps the baseline.""" - prefix = journal._ensure_orchestration_store(store) - existing = journal._validated_done_outcome( - store, - prefix, - workflow.workflowRunId, - "feature_policy_preprocessing", - request_record, - parents, - ) - if existing is not None: - return ( - existing, - [ - PreprocessedAssayHandoff.model_validate(value) - for value in existing.outputs["assays"] - ], - AutomatedPreprocessingPlan.model_validate( - existing.outputs["resolvedPreprocessingPlan"] - ), - ) - baseline_plan = AutomatedPreprocessingPlan.model_validate( - baseline_outcome.outputs["resolvedPreprocessingPlan"] - ) - if baseline_plan != plan: - raise ValueError( - "A retained feature policy must reuse the exact baseline plan" - ) - started = journal._start_attempt( - store.zw, - prefix, - workflow.workflowRunId, - "feature_policy_preprocessing", - request_record, - parents, - inputs={ - "baselineAttemptId": baseline_outcome.attemptId, - "preprocessingPlan": plan.model_dump(mode="json"), - }, - resume_record=resume_record, - ) - outcome = journal._complete_attempt( - started, - status="done", - artifacts=dict(baseline_outcome.artifacts), - outputs={ - "assays": [ - value.model_dump(mode="json") for value in baseline_handoffs - ], - "cellSelection": baseline_outcome.outputs["cellSelection"], - "resolvedPreprocessingPlan": plan.model_dump(mode="json"), - "operations": [ - { - "operation": "reuse_baseline_preprocessing", - "attemptId": baseline_outcome.attemptId, - } - ], - }, - actions=["reuse_baseline_preprocessing"], - ) - journal._save_outcome(store.zw, prefix, outcome) - return outcome, list(baseline_handoffs), plan - def preprocess_assay( self, store: DataStore, @@ -1237,941 +1011,67 @@ def preprocess_assay( operations: list[dict[str, Any]], artifacts: dict[str, ArtifactReferenceModel], ) -> PreprocessedAssayHandoff: + """Prepare exact core defaults and feature evidence without a graph search.""" + del request_record, study_contract, answers assay = store.get_assay(assay_plan.assay) - min_cells = int(assay_plan.featureParameters.get("minCells", 1)) - marker_features: ArtifactRef - graph_feature_candidates: dict[str, ArtifactRef] = {} - normalized_candidates: dict[str, ArtifactRef] = {} - feature_candidate_evaluations: list[ParameterCandidateEvaluation] = [] - feature_candidate_agreement: dict[str, dict[str, float]] = {} - selected_feature_branch_key: str | None = None - if assay_plan.featureMethod == "hvg": - if request_record is None or study_contract is None: - raise ValueError( - "RNA HVG preprocessing requires request and study contracts" - ) - if not isinstance(assay, RNAassay): - raise TypeError("The RNA HVG route requires an RNAassay") - detected = store.select_detected_features( - cell_selection, - from_assay=assay_plan.assay, - min_cells=min_cells, - invalidate_cache=False, - ) - eligible_features = self.exclude_exact_features( - store, - assay_plan, - detected, - ) - marker_features = self.exclude_exact_features( - store, - assay_plan, - detected, - include_families=False, - ) - species = str(assay_plan.featureParameters.get("species", "unknown")) - cycle_genes = { - "homo_sapiens": (s_phase_genes, g2m_phase_genes), - "mus_musculus": (s_phase_genes_mouse, g2m_phase_genes_mouse), - }.get(species) - if cycle_genes is not None: - available_feature_names = set( - np.asarray(assay.feats.fetch_all("names")).astype(str) - ) - s_genes, g2m_genes = cycle_genes - s_coverage = sum( - value in available_feature_names for value in s_genes - ) / len(s_genes) - g2m_coverage = sum( - value in available_feature_names for value in g2m_genes - ) / len(g2m_genes) - if min(s_coverage, g2m_coverage) >= 0.8: - cell_cycle = store.run_cell_cycle_scoring( - cell_selection, - from_assay=assay_plan.assay, - s_genes=list(s_genes), - g2m_genes=list(g2m_genes), - invalidate_cache=False, - ) - artifacts[f"{assay_plan.assay}_cell_cycle"] = ( - ArtifactReferenceModel.from_artifact_ref(cell_cycle) - ) - actions.append(f"score_cell_cycle:{assay_plan.assay}") - operations.append( - { - "operation": "run_cell_cycle_scoring", - "assay": assay_plan.assay, - "species": species, - "sGeneCoverage": s_coverage, - "g2mGeneCoverage": g2m_coverage, - "artifact": ArtifactReferenceModel.from_artifact_ref( - cell_cycle - ).model_dump(mode="json"), - } - ) - technical_columns = list( - dict.fromkeys( - value - for value in ( - study_contract.physicalCaptureColumn, - *study_contract.technicalBatchColumns, - ) - if value is not None and value in store.cells.columns - ) - ) - technical_column = technical_columns[0] if technical_columns else None - diagnostics = run_hvg_diagnostic_artifacts( - store.zw, - assay, - cell_selection=cell_selection, - eligible_features=eligible_features, - all_features=store.select_all_features(from_assay=assay_plan.assay), - technical_group_column=technical_column, - min_group_cells=request_record.config.minClusterCells, - min_cells=min_cells, - n_bins=200, - lowess_frac=0.1, - invalidate_cache=False, - candidate_targets=request_record.config.hvgCandidateCounts, - ) - if not diagnostics: - raise ValueError("HVG diagnostics produced no registered ranking") - inventory = assay_plan.featureParameters.get("defaultFeatureInventory") - inventory_map = inventory if isinstance(inventory, Mapping) else {} - raw_default_families = inventory_map.get("families", []) - if not isinstance(raw_default_families, list): - raise ValueError("Default feature-family inventory is malformed") - default_family_patterns = { - str(value["family"]): str(value["pattern"]) - for value in raw_default_families - if isinstance(value, Mapping) - and isinstance(value.get("family"), str) - and isinstance(value.get("pattern"), str) - } - if not default_family_patterns: - raise ValueError( - "RNA preprocessing requires the deterministic Scarf default " - "feature-family inventory" - ) - registered_counts = sorted( - { - candidate.top_n - for value in diagnostics - for candidate in value.candidates - } - ) - scarf_default_hvgs: dict[int, ArtifactRef] = {} - for count in registered_counts: - default_ref = store.select_hvgs( - cell_selection, - from_assay=assay_plan.assay, - min_cells=min_cells, - top_n=count, - n_bins=200, - lowess_frac=0.1, - blacklist=DEFAULT_HVG_BLACKLIST, - show_plot=False, - invalidate_cache=False, - ) - scarf_default_hvgs[count] = default_ref - artifacts[f"{assay_plan.assay}_hvg_scarf_default_{count}"] = ( - ArtifactReferenceModel.from_artifact_ref(default_ref) - ) - operations.append( - { - "operation": "select_hvgs", - "assay": assay_plan.assay, - "policy": "scarfDefault", - "topN": count, - "blacklist": DEFAULT_HVG_BLACKLIST, - "artifact": ArtifactReferenceModel.from_artifact_ref( - default_ref - ).model_dump(mode="json"), - } - ) - feature_branches: list[tuple[str, ArtifactRef]] = [ - *( - (f"scarfDefault:{count}", reference) - for count, reference in sorted(scarf_default_hvgs.items()) - ), - *( - ( - f"{diagnostic.ranking_mode}:{candidate.top_n}", - candidate.features, - ) - for diagnostic in diagnostics - for candidate in diagnostic.candidates - ), - ] - nominated_families = cast( - list[str], - assay_plan.featureParameters.get("proposedExcludeFamilies", []), - ) - protected_families = cast( - list[str], - assay_plan.featureParameters.get("protectFamilies", []), - ) - diagnostic_families = list( - dict.fromkeys( - [ - *SCARF_DEFAULT_DIAGNOSTIC_FAMILIES, - *nominated_families, - ] - ) - ) - fixed_dimensions = min(20, active_cells - 1, min(registered_counts)) - fixed_neighbors = min(21, active_cells - 1) - if fixed_dimensions < 2 or fixed_neighbors < 2: - raise ValueError( - "HVG downstream comparison requires at least three active cells" - ) - for branch_key, feature_ref in feature_branches: - normalized_ref = store.run_normalization( - cell_selection, - features=feature_ref, - log_transform=cast( - bool, - assay_plan.normalizationParameters.get("logTransform"), - ), - renormalize_subset=cast( - bool, - assay_plan.normalizationParameters.get("renormalizeSubset"), - ), - invalidate_cache=False, - ) - graph_feature_candidates[branch_key] = feature_ref - normalized_candidates[branch_key] = normalized_ref - candidate_id = "hvg_" + branch_key.replace(":", "_") - parameter = ParameterCandidate( - candidateId=candidate_id, - reductionMethod="pca", - dimensions=fixed_dimensions, - neighborsK=fixed_neighbors, - leidenResolution=1.0, - useHarmony=False, - ) - dependencies, candidate_ids = prepare_parameter_tuning_dependencies( - store, - normalized=normalized_ref, - candidates=[parameter], - batch_columns=technical_columns, - preservation_columns=study_contract.protectedColumns, - max_candidates=1, - max_refined_candidates=0, - min_cluster_cells=request_record.config.minClusterCells, - identity_feature_limit=(request_record.config.maxIdentityFeatures), - ) - evaluation = execute_parameter_candidate( - dependencies, - candidate_ids[0], - ) - evaluation = augment_pca_evaluations( - store, - [evaluation], - feature_selection=feature_ref, - nominated_families=diagnostic_families, - protected_families=protected_families, - technical_columns=technical_columns, - batch_columns=technical_columns, - protected_columns=study_contract.protectedColumns, - qc_columns=[ - value - for value in ( - f"{assay_plan.assay}_nCounts", - f"{assay_plan.assay}_nFeatures", - f"{assay_plan.assay}_percentMito", - f"{assay_plan.assay}_percentRibo", - ) - if value in store.cells.columns - ], - )[0] - evaluation = augment_cluster_evaluations( - store, - [evaluation], - marker_assay=assay_plan.assay, - marker_features=marker_features, - independent_unit_columns=study_contract.independentUnitColumns, - technical_columns=technical_columns, - nominated_families=diagnostic_families, - protected_families=protected_families, - )[0] - feature_candidate_evaluations.append(evaluation) - artifacts[f"{assay_plan.assay}_{candidate_id}_features"] = ( - ArtifactReferenceModel.from_artifact_ref(feature_ref) - ) - artifacts[f"{assay_plan.assay}_{candidate_id}_normalized"] = ( - ArtifactReferenceModel.from_artifact_ref(normalized_ref) - ) - for artifact_name, artifact in evaluation.artifacts.items(): - artifacts[f"{assay_plan.assay}_{candidate_id}_{artifact_name}"] = ( - ArtifactReferenceModel.model_validate(artifact.model_dump()) - ) - completed_feature_candidates = [ - value - for value in feature_candidate_evaluations - if value.status == "done" - and value.eligible - and "clusters" in value.artifacts - and "neighbors" in value.artifacts - ] - labels_by_id: dict[str, np.ndarray] = {} - neighbors_by_id: dict[str, np.ndarray] = {} - for evaluation in completed_feature_candidates: - cluster_model = ArtifactReferenceModel.model_validate( - evaluation.artifacts["clusters"].model_dump() - ) - neighbor_model = ArtifactReferenceModel.model_validate( - evaluation.artifacts["neighbors"].model_dump() - ) - cluster_group = store.load_artifact( - artifact_model_to_ref(cluster_model) - ) - neighbor_group = store.load_artifact( - artifact_model_to_ref(neighbor_model) - ) - labels_by_id[evaluation.candidateId] = np.asarray( - as_zarr_array( - cluster_group["values"], - name="values", - )[:] - ) - neighbors_by_id[evaluation.candidateId] = np.asarray( - as_zarr_array( - neighbor_group["indices"], - name="indices", - )[:], - dtype=np.int64, - ) - for evaluation in completed_feature_candidates: - ari_values: list[float] = [] - nmi_values: list[float] = [] - neighbor_values: list[float] = [] - labels = labels_by_id[evaluation.candidateId] - neighbors = neighbors_by_id[evaluation.candidateId] - sample_rows = np.linspace( - 0, - len(neighbors) - 1, - min(2_000, len(neighbors)), - dtype=np.int64, - ) - for other in completed_feature_candidates: - if other.candidateId == evaluation.candidateId: - continue - other_labels = labels_by_id[other.candidateId] - other_neighbors = neighbors_by_id[other.candidateId] - if labels.shape != other_labels.shape: - raise ValueError("HVG candidate cluster artifacts do not align") - if neighbors.shape != other_neighbors.shape: - raise ValueError( - "HVG candidate neighbor artifacts do not align" - ) - ari_values.append(float(adjusted_rand_score(labels, other_labels))) - nmi_values.append( - float(normalized_mutual_info_score(labels, other_labels)) - ) - row_overlaps = [ - len(set(neighbors[row]).intersection(other_neighbors[row])) - / neighbors.shape[1] - for row in sample_rows - ] - neighbor_values.append(float(np.mean(row_overlaps))) - feature_candidate_agreement[evaluation.candidateId] = { - "minimumAri": min(ari_values, default=1.0), - "medianAri": float(np.median(ari_values)) if ari_values else 1.0, - "minimumNmi": min(nmi_values, default=1.0), - "medianNmi": float(np.median(nmi_values)) if nmi_values else 1.0, - "minimumNeighborOverlap": min(neighbor_values, default=1.0), - "medianNeighborOverlap": float(np.median(neighbor_values)) - if neighbor_values - else 1.0, - } - feature_names = np.asarray(assay.feats.fetch_all("names")).astype(str) - hvg_comparisons: dict[tuple[str, int], Any] = {} - ranking_evidence: list[DecisionEvidence] = [] - ranking_evidence_ids: dict[str, str] = {} - for candidate_ranking in diagnostics: - mode = candidate_ranking.ranking_mode - diagnostic_model = ArtifactReferenceModel.from_artifact_ref( - candidate_ranking.diagnostic - ) - artifacts[f"{assay_plan.assay}_hvg_{mode}_diagnostic"] = ( - diagnostic_model - ) - for candidate in candidate_ranking.candidates: - artifacts[ - f"{assay_plan.assay}_hvg_{mode}_candidate_{candidate.top_n}" - ] = ArtifactReferenceModel.from_artifact_ref(candidate.features) - evidence_id = ( - f"evidence:hvgRanking:{candidate_ranking.diagnostic.artifact_id}" - ) - ranking_evidence_ids[mode] = evidence_id - ranking_group = store.load_artifact(candidate_ranking.diagnostic) - ranking_values = np.asarray( - as_zarr_array( - ranking_group["ranking"], - name="ranking", - )[:], - dtype=np.int64, - ) - recurrence_values = np.asarray( - as_zarr_array( - ranking_group["recurrence"], - name="recurrence", - )[:], - dtype=np.int32, - ) - within_group_ranks = np.asarray( - as_zarr_array( - ranking_group["mean_within_group_rank"], - name="mean_within_group_rank", - )[:], - dtype=np.float64, - ) - corrected_variance_values = np.asarray( - as_zarr_array( - ranking_group["global_corrected_variance"], - name="global_corrected_variance", - )[:], - dtype=np.float64, - ) - eligible_values = np.asarray( - as_zarr_array( - ranking_group["eligible"], - name="eligible", - )[:], - dtype=bool, - ) - ranking_model = HvgRanking( - ranking_mode=mode, - eligible=eligible_values, - global_corrected_variance=corrected_variance_values, - recurrence=recurrence_values, - mean_within_group_rank=within_group_ranks, - ranking=ranking_values, - valid_group_count=len(candidate_ranking.valid_groups), - candidate_counts=tuple( - candidate.top_n for candidate in candidate_ranking.candidates - ), - ) - comparison_summaries: list[str] = [] - for candidate in candidate_ranking.candidates: - default_group = store.load_artifact( - scarf_default_hvgs[candidate.top_n] - ) - default_mask = np.asarray( - as_zarr_array( - default_group["values"], - name="values", - )[:], - dtype=bool, - ) - comparison = next( - value - for value in compare_hvg_ranking_to_default( - default_mask, - ranking_model, - feature_names=feature_names.tolist(), - default_family_patterns=default_family_patterns, - ) - if value.top_n == candidate.top_n - ) - hvg_comparisons[(mode, candidate.top_n)] = comparison - leakage = sum( - value.agent_selected_count - for value in comparison.default_family_leakage - ) - downstream = next( - value - for value in feature_candidate_evaluations - if value.candidateId == f"hvg_{mode}_{candidate.top_n}" - ) - agreement = feature_candidate_agreement.get( - downstream.candidateId, - {}, - ) - comparison_summaries.append( - f"top {candidate.top_n}: default overlap " - f"{comparison.agent_overlap_fraction:.1%}, Jaccard " - f"{comparison.jaccard:.3f}, default-family selections " - f"{leakage}, downstream marker coherence " - f"{downstream.metrics.markerCoherence}, cross-unit support " - f"{downstream.metrics.crossUnitSupport}, technical " - f"association {downstream.metrics.technicalAssociation}, " - f"agreement {agreement}" - ) - broad_count = max( - candidate.top_n for candidate in candidate_ranking.candidates - ) - broad_indices = ranking_values[:broad_count] - recurrence_summary = "" - if candidate_ranking.valid_groups: - selected_recurrence = recurrence_values[broad_indices] - selected_ranks = within_group_ranks[broad_indices] - finite_ranks = selected_ranks[np.isfinite(selected_ranks)] - median_rank = ( - f"{float(np.median(finite_ranks)):.3f}" - if finite_ranks.size - else "unavailable" - ) - recurrence_summary = ( - f" In the broad {broad_count}-gene candidate, mean technical-" - "group coverage is " - f"{float(selected_recurrence.mean()) / len(candidate_ranking.valid_groups):.1%}, " - f"{float((selected_recurrence >= 2).mean()):.1%} recur in at " - "least two groups, and the median normalized within-group " - f"rank is {median_rank}." - ) - if mode == "batchAware": - summary = ( - "The technical-group ranking uses recurrence and within-group " - f"rank across {len(candidate_ranking.valid_groups)} valid " - f"groups; excluded groups={candidate_ranking.excluded_groups}." - f"{recurrence_summary}" - ) - else: - summary = ( - "The global ranking orders every eligible gene by corrected " - "variability across the exact filtered cell selection." - f"{recurrence_summary}" - ) - summary += ( - " Exact Scarf-default comparisons: " - + "; ".join(comparison_summaries) - + "." - ) - ranking_evidence.append( - DecisionEvidence( - evidenceId=evidence_id, - evidenceClass="technical", - summary=summary, - artifactReferences=[ - diagnostic_model, - *[ - ArtifactReferenceModel.model_validate( - artifact.model_dump() - ) - for evaluation in feature_candidate_evaluations - if evaluation.candidateId.startswith(f"hvg_{mode}_") - for artifact in evaluation.artifacts.values() - ], - ], - ) - ) - ranking_bundle = self._decision_evidence_bundle( - "hvgRanking", - ranking_evidence, - ) - ranking_definition = build_hvg_ranking_decision( - evidence_bundle_id=ranking_bundle.bundleId, - batch_aware_eligible=any( - value.ranking_mode == "batchAware" for value in diagnostics - ), - ) - ranking_definition = require_option_evidence( - ranking_definition, - { - option.optionId: [ranking_evidence_ids[option.payload.rankingMode]] - for option in ranking_definition.executorOptions - if isinstance(option.payload, HvgRankingExecutorPayload) - }, - ) - ranking_options = [ - option - for option in ranking_definition.executorOptions - if isinstance(option.payload, HvgRankingExecutorPayload) - ] - ranking_rule_selection = ( - DecisionSelection( - selectedOptionId=ranking_options[0].optionId, - evidenceIds=list( - ranking_definition.spec.option_by_id()[ - ranking_options[0].optionId - ].requiredEvidenceIds - ), - rationale=( - "Use the only variability ranking licensed by the available " - "technical groups." - ), - ) - if len(ranking_options) == 1 - else None - ) - ranking_resolution = self._resolve_rna_decision( - store, - request_record, - ranking_definition, - ranking_bundle, - answers or {}, - rule_selection=ranking_rule_selection, - ) - if ranking_resolution.compiled is None: - raise _DecisionNeedsInput( - self._pending_decision_question( - ranking_resolution, - ranking_definition, - ), - ranking_resolution.snapshotSha256, - ) - ranking_payload = ranking_resolution.compiled.executorPayload - if not isinstance(ranking_payload, HvgRankingExecutorPayload): - raise TypeError("HVG-ranking decision compiled an unexpected payload") - diagnostic = next( - ( - value - for value in diagnostics - if value.ranking_mode == ranking_payload.rankingMode - ), - None, - ) - if diagnostic is None: - raise ValueError("Selected HVG ranking has no exact diagnostic") - diagnostic_model = ArtifactReferenceModel.from_artifact_ref( - diagnostic.diagnostic - ) - actions.extend( - [ - f"diagnose_hvg_candidates:{assay_plan.assay}", - f"audit_hvg_ranking:{assay_plan.assay}", - f"select_marker_features:{assay_plan.assay}", - ] - ) - artifacts[f"{assay_plan.assay}_hvg_diagnostic"] = diagnostic_model - for candidate in diagnostic.candidates: - artifacts[f"{assay_plan.assay}_hvg_candidate_{candidate.top_n}"] = ( - ArtifactReferenceModel.from_artifact_ref(candidate.features) - ) - diagnostic_group = store.load_artifact(diagnostic.diagnostic) - ranking = np.asarray( - as_zarr_array(diagnostic_group["ranking"], name="ranking")[:], - dtype=np.int64, - ) - corrected_variance = np.asarray( - as_zarr_array( - diagnostic_group["global_corrected_variance"], - name="global_corrected_variance", - )[:], - dtype=np.float64, - ) - eligible = np.asarray( - as_zarr_array(diagnostic_group["eligible"], name="eligible")[:], - dtype=bool, - ) - recurrence = np.asarray( - as_zarr_array( - diagnostic_group["recurrence"], - name="recurrence", - )[:], - dtype=np.int32, - ) - eligible_variance = float(corrected_variance[eligible].sum()) - candidate_evidence: list[DecisionEvidence] = [] - evidence_ids_by_count: dict[int, str] = {} - for candidate in diagnostic.candidates: - selected_indices = ranking[: candidate.top_n] - variance_fraction = ( - float(corrected_variance[selected_indices].sum()) - / eligible_variance - if eligible_variance > 0 - else 0.0 - ) - evidence_id = ( - f"evidence:hvg:{diagnostic.diagnostic.artifact_id}:" - f"top{candidate.top_n}" - ) - evidence_ids_by_count[candidate.top_n] = evidence_id - summary = ( - f"The {candidate.top_n}-gene {diagnostic.ranking_mode} candidate " - "captures " - f"{variance_fraction:.1%} of corrected variance across " - f"{diagnostic.eligible_feature_count} eligible genes." - ) - comparison = hvg_comparisons[(diagnostic.ranking_mode, candidate.top_n)] - downstream = next( - value - for value in feature_candidate_evaluations - if value.candidateId - == f"hvg_{diagnostic.ranking_mode}_{candidate.top_n}" - ) - default_downstream = next( - value - for value in feature_candidate_evaluations - if value.candidateId == f"hvg_scarfDefault_{candidate.top_n}" - ) - agreement = feature_candidate_agreement.get( - downstream.candidateId, - {}, - ) - leakage_by_family = { - value.family: value.agent_selected_count - for value in comparison.default_family_leakage - if value.agent_selected_count - } - summary += ( - " Compared with the exact Scarf-default selection, overlap is " - f"{comparison.agent_overlap_fraction:.1%}, Jaccard is " - f"{comparison.jaccard:.3f}, and selected default-family counts " - f"are {leakage_by_family}. The fixed downstream branch produced marker " - f"coherence {downstream.metrics.markerCoherence}, cross-unit " - f"support {downstream.metrics.crossUnitSupport}, technical " - f"association {downstream.metrics.technicalAssociation}, " - f"doublet concentration " - f"{downstream.metrics.doubletHighScoreConcentration}, and " - f"cross-candidate agreement {agreement}. The matched core " - "Scarf baseline produced marker coherence " - f"{default_downstream.metrics.markerCoherence}, cross-unit " - f"support {default_downstream.metrics.crossUnitSupport}, and " - "technical association " - f"{default_downstream.metrics.technicalAssociation}." - ) - if diagnostic.valid_groups: - replicated = recurrence[selected_indices] >= max( - 2, - (len(diagnostic.valid_groups) + 1) // 2, - ) - summary += ( - f" {float(replicated.mean()):.1%} of selected genes recur " - "across the registered technical-group rankings." - ) - candidate_evidence.append( - DecisionEvidence( - evidenceId=evidence_id, - evidenceClass="technical", - summary=summary, - artifactReferences=[ - diagnostic_model, - ArtifactReferenceModel.from_artifact_ref( - candidate.features - ), - *[ - ArtifactReferenceModel.model_validate( - artifact.model_dump() - ) - for artifact in downstream.artifacts.values() - ], - *[ - ArtifactReferenceModel.model_validate( - artifact.model_dump() - ) - for artifact in default_downstream.artifacts.values() - ], - ], - ) - ) - bundle = self._decision_evidence_bundle( - "hvgCount", - candidate_evidence, - ) - definition = build_hvg_count_decision( - evidence_bundle_id=bundle.bundleId, - eligible_feature_count=diagnostic.eligible_feature_count, - ranking_mode=diagnostic.ranking_mode, - valid_technical_groups=len(diagnostic.valid_groups), - candidate_counts=[ - candidate.top_n for candidate in diagnostic.candidates - ], - ) - definition = require_option_evidence( - definition, - { - option.optionId: [evidence_ids_by_count[option.payload.topN]] - for option in definition.executorOptions - if isinstance(option.payload, HvgExecutorPayload) - }, - ) - resolution = self._resolve_rna_decision( - store, - request_record, - definition, - bundle, - answers or {}, - ) - if resolution.compiled is None: - raise _DecisionNeedsInput( - self._pending_decision_question(resolution, definition), - resolution.snapshotSha256, - ) - hvg_payload = resolution.compiled.executorPayload - if not isinstance(hvg_payload, HvgExecutorPayload): - raise TypeError("HVG decision compiled an unexpected payload") - selected_candidate = next( - ( - candidate - for candidate in diagnostic.candidates - if candidate.top_n == hvg_payload.topN - ), - None, - ) - if selected_candidate is None: - raise ValueError( - "Selected HVG count has no exact persisted candidate artifact" - ) - graph_features = selected_candidate.features - selected_feature_branch_key = ( - f"{diagnostic.ranking_mode}:{selected_candidate.top_n}" - ) - actions.append(f"audit_hvg_count:{assay_plan.assay}") - operations.append( - { - "operation": "diagnose_hvg_candidates", - "assay": assay_plan.assay, - "cellSelection": cell_selection_model.model_dump(mode="json"), - "minCells": min_cells, - "technicalGroupColumn": technical_column, - "rankingMode": diagnostic.ranking_mode, - "validTechnicalGroups": list(diagnostic.valid_groups), - "excludedTechnicalGroups": list(diagnostic.excluded_groups), - "candidateCounts": [ - candidate.top_n for candidate in diagnostic.candidates - ], - "selectedTopN": hvg_payload.topN, - "rankingDecisionSnapshotSha256": ( - ranking_resolution.snapshotSha256 - ), - "countDecisionSnapshotSha256": resolution.snapshotSha256, - "invalidateCache": False, - "artifact": diagnostic_model.model_dump(mode="json"), - } - ) - operations.extend( - [ - { - "operation": "set_feature_selection", - "assay": assay_plan.assay, - "source": ArtifactReferenceModel.from_artifact_ref( - detected - ).model_dump(mode="json"), - "exactExcludedFeatures": list(assay_plan.exactExcludedFeatures), - "excludeFamilies": list( - assay_plan.featureParameters.get("excludeFamilies", []) - ), - "useScarfDefaultBlacklist": bool( - assay_plan.featureParameters.get( - "useScarfDefaultBlacklist", - False, - ) - ), - "artifact": ArtifactReferenceModel.from_artifact_ref( - eligible_features - ).model_dump(mode="json"), - }, - { - "operation": "select_detected_features", - "assay": assay_plan.assay, - "cellSelection": cell_selection_model.model_dump(mode="json"), - "minCells": min_cells, - "artifact": ArtifactReferenceModel.from_artifact_ref( - detected - ).model_dump(mode="json"), - }, - { - "operation": "set_feature_selection", - "assay": assay_plan.assay, - "source": ArtifactReferenceModel.from_artifact_ref( - detected - ).model_dump(mode="json"), - "exactExcludedFeatures": list(assay_plan.exactExcludedFeatures), - "excludeFamilies": [], - "artifact": ArtifactReferenceModel.from_artifact_ref( - marker_features - ).model_dump(mode="json"), - }, - ] - ) - artifacts.update( - { - f"{assay_plan.assay}_eligible_features": ( - ArtifactReferenceModel.from_artifact_ref(eligible_features) - ), - f"{assay_plan.assay}_detected_features": ( - ArtifactReferenceModel.from_artifact_ref(detected) - ), - } - ) - else: - raise ValueError(f"Unsupported feature route {assay_plan.featureMethod!r}") - normalized = ( - normalized_candidates[selected_feature_branch_key] - if selected_feature_branch_key is not None - and selected_feature_branch_key in normalized_candidates - else store.run_normalization( - cell_selection, - features=graph_features, - log_transform=cast( - bool, - assay_plan.normalizationParameters.get("logTransform"), - ), - renormalize_subset=cast( - bool, - assay_plan.normalizationParameters.get("renormalizeSubset"), - ), - invalidate_cache=False, - ) - ) - graph_feature_group = store.load_artifact(graph_features) - graph_feature_values = cast(Any, graph_feature_group["values"]) - selected_values = np.asarray(graph_feature_values[:], dtype=bool) - graph_features_model = ArtifactReferenceModel.from_artifact_ref(graph_features) - marker_features_model = ArtifactReferenceModel.from_artifact_ref( - marker_features + if not isinstance(assay, RNAassay): + raise TypeError("RNA preprocessing requires an RNAassay") + from ..parameter_tuning.hvg import core_hvg_evidence + + feature_refs = core_hvg_evidence( + store, assay=assay_plan.assay, cells=cell_selection ) - normalized_model = ArtifactReferenceModel.from_artifact_ref(normalized) - handoff = PreprocessedAssayHandoff( - assay=assay_plan.assay, - assayType=assay_plan.assayType, - cellSelection=cell_selection_model, - reductionMethod=assay_plan.reductionMethod, - graphFeatures=graph_features_model, - markerFeatures=marker_features_model, - normalized=normalized_model, - graphFeatureCandidates={ - key: ArtifactReferenceModel.from_artifact_ref(value) - for key, value in graph_feature_candidates.items() - }, - normalizedCandidates={ - key: ArtifactReferenceModel.from_artifact_ref(value) - for key, value in normalized_candidates.items() - }, - featureCandidateEvaluations=[ - { - **value.model_dump(mode="json"), - "agreement": feature_candidate_agreement.get( - value.candidateId, - {}, - ), - } - for value in feature_candidate_evaluations - ], - nCells=active_cells, - nFeatures=int(selected_values.sum()), + baseline = feature_refs["scarfDefault"] + selected_count = int( + np.asarray( + as_zarr_array(store.load_artifact(baseline)["values"], name="values")[ + : + ], + dtype=bool, + ).sum() ) + marker_features = store.select_all_features(from_assay=assay_plan.assay) + feature_models = { + key: ArtifactReferenceModel.from_artifact_ref(value) + for key, value in feature_refs.items() + } + marker_model = ArtifactReferenceModel.from_artifact_ref(marker_features) artifacts.update( { - f"{assay_plan.assay}_graph_features": graph_features_model, - f"{assay_plan.assay}_marker_features": marker_features_model, - f"{assay_plan.assay}_normalized": normalized_model, + f"{assay_plan.assay}_{key}": value + for key, value in feature_models.items() } ) - actions.append(f"normalize:{assay_plan.assay}") + artifacts[f"{assay_plan.assay}_marker_features"] = marker_model operations.append( { - "operation": "run_normalization", + "operation": "prepare_core_hvg_baseline", "assay": assay_plan.assay, "cellSelection": cell_selection_model.model_dump(mode="json"), - "features": graph_features_model.model_dump(mode="json"), - "logTransform": assay_plan.normalizationParameters.get("logTransform"), - "renormalizeSubset": assay_plan.normalizationParameters.get( - "renormalizeSubset" - ), - "invalidateCache": False, - "artifact": normalized_model.model_dump(mode="json"), + "requestedTopN": 1000, + "selectedTopN": selected_count, + "artifacts": { + key: value.model_dump(mode="json") + for key, value in feature_models.items() + }, } ) + actions.append(f"prepare_core_defaults:{assay_plan.assay}") logger.info( - f"Preprocessed assay {assay_plan.assay!r}: " - f"cells={handoff.nCells}, features={handoff.nFeatures}, " - f"reduction={handoff.reductionMethod!r}" + f"HVG baseline: core Scarf selected {selected_count:,} genes; " + "representation experiments will use the screening cells." + ) + return PreprocessedAssayHandoff( + assay=assay_plan.assay, + assayType="RNA", + cellSelection=cell_selection_model, + reductionMethod="pca", + graphFeatures=feature_models["scarfDefault"], + markerFeatures=marker_model, + graphFeatureCandidates=feature_models, + nCells=active_cells, + nFeatures=selected_count, ) - return handoff def apply_cell_qc( self, @@ -2186,11 +1086,15 @@ def apply_cell_qc( ) -> ArtifactRef: plan = selected_plan or experimental.cellQc if decision_payload is not None: - if plan.registeredProfile != decision_payload.profile: + if ( + cell_qc_policy(plan.action, plan.registeredProfile) + != decision_payload.profile + ): raise ValueError( "Cell-QC execution plan differs from its audited payload" ) expected_capture = decision_payload.profile in { + "coreSampleMad3", "captureMad5", "captureMad3Sensitivity", "pooledReferenceMad5", @@ -2322,57 +1226,36 @@ def apply_cell_qc( } ) return cell_selection - if plan.action == "globalGaussian": - if plan.sampleColumn is not None or sample_artifact is not None: - raise ValueError("globalGaussian QC cannot include a sample source") - result = store.auto_filter_cells( - attrs=plan.attributes, - artifact_metrics=artifact_metrics, - min_p=float(profile.parameters.get("minP", 0.01)), - max_p=float(profile.parameters.get("maxP", 0.99)), - cell_selection=cell_selection, - invalidate_cache=False, - ) - result_model = ArtifactReferenceModel.from_artifact_ref(result) - actions.append(f"cell_qc_global:{profile.profileId}") - operations.append( - { - "operation": "auto_filter_cells", - "profileId": profile.profileId, - "cellSelection": input_model.model_dump(mode="json"), - "attrs": list(plan.attributes), - "artifactMetrics": [ - source.model_dump(mode="json") - for source in plan.artifactMetrics - ], - "minP": float(profile.parameters.get("minP", 0.01)), - "maxP": float(profile.parameters.get("maxP", 0.99)), - "sampleColumn": None, - "invalidateCache": False, - "artifact": result_model.model_dump(mode="json"), - } - ) - return result - if plan.action == "sampleMad": - if (plan.sampleColumn is None) == (sample_artifact is None): - raise ValueError( - "sampleMad QC requires exactly one metadata or artifact " - "sample source" + if plan.action in {"globalGaussian", "sampleMad"}: + if not isinstance(profile.resolvedBounds, dict): + raise ValueError("Core cell-QC evidence requires exact named bounds") + capture_artifact = ( + NamedCellArtifact( + name=profile.captureArtifact.name, + artifact=artifact_model_to_ref(profile.captureArtifact.artifact), ) - result = store.auto_filter_cells( + if profile.captureArtifact is not None + else None + ) + result, diagnostic_flags = execute_auto_cell_qc( + store, + plan.action, + profile_parameters=profile.parameters, + expected_active_cells=profile.activeCells, + expected_retained_cells=profile.retainedCells, + expected_flag_counts=profile.flaggedCells, + expected_resolved_bounds=profile.resolvedBounds, attrs=plan.attributes, artifact_metrics=artifact_metrics, cell_selection=cell_selection, sample_column=plan.sampleColumn, sample_artifact=sample_artifact, - n_mads=float(profile.parameters.get("nMads", 3.0)), - min_cells_per_sample=int( - profile.parameters.get("minCellsPerSample", 20) - ), + capture_column=profile.captureColumn, + capture_artifact=capture_artifact, invalidate_cache=False, ) result_model = ArtifactReferenceModel.from_artifact_ref(result) - actions.append(f"cell_qc_sample_mad:{profile.profileId}") + actions.append(f"cell_qc_{plan.action}:{profile.profileId}") operations.append( { "operation": "auto_filter_cells", @@ -2383,124 +1266,21 @@ def apply_cell_qc( source.model_dump(mode="json") for source in plan.artifactMetrics ], - "minP": 0.01, - "maxP": 0.99, "sampleColumn": plan.sampleColumn, - "sampleArtifact": ( - None - if plan.sampleArtifact is None - else plan.sampleArtifact.model_dump(mode="json") - ), - "nMads": float(profile.parameters.get("nMads", 3.0)), - "minCellsPerSample": int( - profile.parameters.get("minCellsPerSample", 20) - ), + "sampleArtifact": plan.sampleArtifact.model_dump(mode="json") + if plan.sampleArtifact + else None, + "profileParameters": profile.parameters, + "resolvedBounds": profile.resolvedBounds, + "expectedRetainedCells": profile.retainedCells, + "diagnosticFlags": ArtifactReferenceModel.from_artifact_ref( + diagnostic_flags + ).model_dump(mode="json") + if diagnostic_flags + else None, "invalidateCache": False, "artifact": result_model.model_dump(mode="json"), } ) return result raise ValueError(f"Unsupported cell QC action {plan.action!r}") - - def rna_blacklist(self, plan: AssayPreprocessingPlan) -> str: - patterns: list[str] = ( - [DEFAULT_HVG_BLACKLIST] - if plan.featureParameters.get("useScarfDefaultBlacklist") is True - else [] - ) - families = set( - cast(list[str], plan.featureParameters.get("excludeFamilies", [])) - ) - if "mitochondrial" in families: - patterns.append(r"^(MT-|mt-)") - if "ribosomal" in families: - patterns.append(r"^(RPS|RPL)") - if "mitoribosomal" in families: - patterns.append(r"^(MRPS|MRPL)") - if "histone" in families: - patterns.append(r"^HIST") - if "hla" in families: - patterns.append(r"^HLA-") - if "h2" in families: - patterns.append(r"^H2-") - if "cellCycle" in families: - patterns.append(r"^CCN") - if "sexLinked" in families: - patterns.append( - r"^(XIST|DDX3Y|USP9Y|EIF1AY|KDM5D|SRY|ZFY|UTY|TMSB4Y|NLGN4Y)$" - ) - patterns.extend( - rf"^{re.escape(value)}$" for value in plan.exactExcludedFeatures if value - ) - return "|".join(patterns) if patterns else r"(?!)" - - def exclude_exact_features( - self, - store: DataStore, - plan: AssayPreprocessingPlan, - source: ArtifactRef, - *, - include_families: bool = True, - ) -> ArtifactRef: - families = ( - set(cast(list[str], plan.featureParameters.get("excludeFamilies", []))) - if include_families - else set() - ) - use_scarf_defaults = bool( - include_families - and plan.featureParameters.get("useScarfDefaultBlacklist") is True - ) - if not plan.exactExcludedFeatures and not families and not use_scarf_defaults: - return source - assay = store.get_assay(plan.assay) - source_group = store.load_artifact(source) - source_values = cast(Any, source_group["values"]) - mask = np.asarray(source_values[:], dtype=bool) - ids = np.asarray(assay.feats.fetch_all("ids")).astype(str) - names = np.asarray(assay.feats.fetch_all("names")).astype(str) - excluded = set(plan.exactExcludedFeatures) - mask &= ~np.isin(ids, list(excluded)) - mask &= ~np.isin(names, list(excluded)) - family_patterns: list[str] = [] - if "mitochondrial" in families: - family_patterns.append(r"^(MT-|mt-)") - if "ribosomal" in families: - family_patterns.append(r"^(RPS|RPL)") - if "mitoribosomal" in families: - family_patterns.append(r"^(MRPS|MRPL)") - if "histone" in families: - family_patterns.append(r"^HIST") - if "hla" in families: - family_patterns.append(r"^HLA-") - if "h2" in families: - family_patterns.append(r"^H2-") - if "cellCycle" in families: - family_patterns.append(r"^CCN") - if "sexLinked" in families: - family_patterns.append( - r"^(XIST|DDX3Y|USP9Y|EIF1AY|KDM5D|SRY|ZFY|UTY|TMSB4Y|NLGN4Y)$" - ) - if use_scarf_defaults: - family_patterns.append(DEFAULT_HVG_BLACKLIST) - if family_patterns: - technical = np.zeros(len(mask), dtype=bool) - combined = re.compile("|".join(family_patterns), re.IGNORECASE) - technical |= np.fromiter( - (combined.search(value) is not None for value in ids), - dtype=bool, - count=len(ids), - ) - technical |= np.fromiter( - (combined.search(value) is not None for value in names), - dtype=bool, - count=len(names), - ) - mask &= ~technical - if not mask.any(): - raise ValueError("Exact marker exclusions removed every feature") - return store.set_feature_selection( - from_assay=plan.assay, - mask=mask, - invalidate_cache=False, - ) diff --git a/scarf/agent/orchestrator/rna.py b/scarf/agent/orchestrator/rna.py index 9e096cf2..c07feaad 100644 --- a/scarf/agent/orchestrator/rna.py +++ b/scarf/agent/orchestrator/rna.py @@ -112,10 +112,12 @@ def validate_rna_handoffs(handoffs: Sequence[Any], selected: str) -> None: def validate_saved_rna_history( - root: Any, prefix: str, workflow_run_id: str, selected: str + store: Any, prefix: str, workflow_run_id: str, selected: str ) -> None: - """Reject incompatible saved routes before resume opens the store for writes.""" - from ..persistence.reports import load_agent_report + """Validate single-RNA ownership before opening resumed work for writes.""" + from ..data_enrichment.contracts import DataEnrichmentReport + from ..experimental_context.contracts import ExperimentalContextResult + from ..parameter_tuning.contracts import ParameterTuningReport from . import journal from .models import ( _STAGE_ORDER, @@ -123,12 +125,13 @@ def validate_saved_rna_history( PreprocessedAssayHandoff, ) - loaded_reports: set[tuple[str, str]] = set() for stage in _STAGE_ORDER: - for outcome in journal._stage_outcomes(root, prefix, workflow_run_id, stage): + for outcome in journal._stage_outcomes( + store.zw, prefix, workflow_run_id, stage + ): if outcome.outputs.get("htoIdentityArtifacts"): raise ValueError( - "Saved automatic HTO processing is unsupported; start a new RNA workflow." + "Saved automatic HTO processing is unsupported; start a new RNA workflow" ) for name in ("preprocessingPlan", "resolvedPreprocessingPlan"): if outcome.outputs.get(name): @@ -138,50 +141,41 @@ def validate_saved_rna_history( ), selected, ) - if stage in {"preprocessing", "feature_policy_preprocessing"} and ( - "assays" in outcome.outputs - ): + if stage == "preprocessing" and "assays" in outcome.outputs: validate_rna_handoffs( [ - PreprocessedAssayHandoff.model_validate(value) - for value in outcome.outputs["assays"] + PreprocessedAssayHandoff.model_validate(v) + for v in outcome.outputs["assays"] ], selected, ) - for reference in outcome.reportReferences: - if reference.agentName not in { - "data_enrichment", - "experimental_context", - "parameter_tuning", - }: - continue - identity = (reference.agentName, reference.agentRunId) - if identity in loaded_reports: - continue - loaded_reports.add(identity) - report = load_agent_report(root, reference) - if report.status != "done": - continue - if reference.agentName == "data_enrichment": - policies = getattr(report, "policies", []) - if ( - len(policies) != 1 - or policies[0].assay != selected - or policies[0].assayModality != "RNA" - ): - raise ValueError( - "Saved enrichment includes unsupported assays; start a new RNA workflow." - ) - elif reference.agentName == "experimental_context": - validate_rna_context(report, selected) - elif reference.agentName == "parameter_tuning": - assays = getattr(report, "assayReports", {}) - if ( - getattr(report, "recommendedIntegrationId", None) is not None - or set(assays) - {selected} - or getattr(report, "fromAssay", selected) != selected - ): - raise ValueError( - "Saved tuning includes unsupported assays or integration; " - "start a new RNA workflow." - ) + if not outcome.reportReferences: + continue + if stage == "data_enrichment": + report = DataEnrichmentReport.model_validate( + journal.read_stage_evidence(store, outcome.reportReferences[0]) + ) + if report.status == "done" and ( + len(report.policies) != 1 + or report.policies[0].assay != selected + or report.policies[0].assayModality != "RNA" + ): + raise ValueError("Saved enrichment includes unsupported assays") + elif stage == "experimental_context": + context = ExperimentalContextResult.model_validate( + journal.read_stage_evidence(store, outcome.reportReferences[0]) + ) + if context.status == "done": + validate_rna_context(context, selected) + elif stage == "parameter_tuning": + tuning = ParameterTuningReport.model_validate( + journal.read_stage_evidence(store, outcome.reportReferences[0]) + ) + if ( + tuning.recommendedIntegrationId is not None + or tuning.assayReports + or tuning.fromAssay != selected + ): + raise ValueError( + "Saved tuning includes unsupported assays or integration" + ) diff --git a/scarf/agent/orchestrator/rna_tuning.py b/scarf/agent/orchestrator/rna_tuning.py new file mode 100644 index 00000000..5c174604 --- /dev/null +++ b/scarf/agent/orchestrator/rna_tuning.py @@ -0,0 +1,1846 @@ +"""Objective-led RNA experiments on frozen screening and full-cohort cells.""" + +import hashlib +import json +import re +from collections.abc import Mapping, Sequence +from typing import Any, Literal + +import numpy as np +from pydantic import Field, create_model, model_validator + +from ...metadata.rows import read_metadata_rows_chunkwise +from ...storage.refs import ArtifactRef +from ...storage.selections import ( + read_stored_selection_indices, + resolve_generated_selection_artifact, +) +from ...utils.logging import logger +from .. import record_io +from ..config.agent_exec import ( + ImageInputUnsupportedError, + build_visual_evidence_prompt, + run_agent_sync, +) +from ..experimental_context.study import ( + StudyContract, + unsupported_comparison_limitations, +) +from ..experimental_context.contracts import CovariateComparison +from ..parameter_tuning.agent import prepare_parameter_tuning_dependencies +from ..parameter_tuning.contracts import ( + ArtifactRecord, + ParameterCandidate, + ParameterCandidateEvaluation, + ParameterTuningNeedsInput, + ParameterTuningReport, +) +from ..parameter_tuning.diagnostics import ( + SCARF_DEFAULT_DIAGNOSTIC_FAMILIES, + _family_mask, + _neighbor_overlap, + augment_cluster_evaluations, + augment_pca_evaluations, + population_support_evidence, + score_advisory_doublets, +) +from ..parameter_tuning.execution import execute_parameter_candidate +from ..parameter_tuning.hvg import ( + HvgGroupVariability, + aggregate_hvg_rankings, + rank_core_hvgs, +) +from ..parameter_tuning.selection import ( + finalize_parameter_tuning_selection, + harmony_acceptance_gate, +) +from ..types import AgentDataModel, ArtifactReferenceModel +from . import journal +from .budget import CandidateBudget, CandidateBudgetExceeded, candidate_identity +from .models import ( + AutomatedPreprocessingPlan, + OrchestrationRequestRecord, + PreprocessedAssayHandoff, + artifact_model_to_ref, +) + + +_DOMAINS = { + "qualityControl", + "featurePolicy", + "hvgRankingAndCount", + "pca", + "batchCorrection", + "neighbors", + "partition", + "rarePopulations", +} + +_STRUCTURED_VISUAL_LIMITATION = ( + "The model assessed structured marker, PCA loading and diagnostic evidence; " + "visual inspection was unavailable because the configured model does not accept images." +) + + +def _configured_image_input(model: Any) -> bool | None: + """Honor an explicit input capability without inferring it from image output.""" + declared = getattr(model, "supports_image_input", None) + if isinstance(declared, bool): + return declared + profile = getattr(model, "profile", None) + if isinstance(profile, Mapping): + declared = profile.get("supports_image_input") + if isinstance(declared, bool): + return declared + return None + + +class TuningAction(AgentDataModel): + """An assessment of observed evidence and at most one registered experiment.""" + + action: Literal["accept", "experiment", "enlarge", "defer"] = Field( + description=( + "Accept supported observed evidence, request one next experiment, " + "enlarge a screening sample, or defer an unresolved essential question." + ) + ) + selectedCandidateId: str = Field( + description=( + "Copy an observed candidate ID. An experiment must keep " + "currentCandidateId as its fixed baseline." + ) + ) + experimentId: str | None = Field( + default=None, + description=( + "For action=experiment, copy one exact key from experiments. " + "This requests the next operation; it has not yet run. Otherwise null." + ), + ) + correctionNeed: Literal["needed", "notNeeded", "uncertain", "notApplicable"] + assessedDomains: list[str] + evidenceIds: list[str] = Field( + min_length=1, + description=( + "Copy exact IDs from availableEvidenceIds, including the selected " + "candidate's anchor or one of its supplied diagnostic evidence IDs." + ), + ) + quantitativeFindings: list[str] = Field( + min_length=1, + description="Describe supplied observed measurements, not predicted results.", + ) + qualitativeFindings: list[str] = Field(min_length=1) + concern: str = "" + expectedImprovement: str = Field( + default="", + description="Predict what the requested experiment should improve and why.", + ) + objectivePreservation: str = Field(min_length=1) + rationale: str = Field( + min_length=1, + description=( + "Explain why this action follows from observed evidence. " + "Do not describe an unexecuted experiment as a completed result." + ), + ) + + @model_validator(mode="after") + def validate_action(self) -> "TuningAction": + if (self.action == "experiment") != (self.experimentId is not None): + raise ValueError("Only an experiment action names an experiment") + if self.action == "experiment" and ( + not self.concern.strip() or not self.expectedImprovement.strip() + ): + raise ValueError( + "An experiment needs an observed concern and expected improvement" + ) + if self.action == "accept" and set(self.assessedDomains) != _DOMAINS: + raise ValueError( + "Acceptance requires assessment of every scientific domain" + ) + return self + + +def _assessment_output_type( + candidate_ids: Sequence[str], experiment_ids: Sequence[str], *, scope: str +) -> type[TuningAction]: + """Constrain new model choices without changing the saved action contract.""" + if not candidate_ids: + raise ValueError("RNA assessment requires observed candidates") + actions = tuple( + action + for action in ("accept", "experiment", "enlarge", "defer") + if (action != "enlarge" or scope != "full") + and (action != "experiment" or experiment_ids) + ) + return create_model( + "ObservedRnaAssessment", + __base__=TuningAction, + action=( + Literal[actions], + Field(description=TuningAction.model_fields["action"].description), + ), + selectedCandidateId=( + Literal[tuple(dict.fromkeys(candidate_ids))], + Field( + description=TuningAction.model_fields["selectedCandidateId"].description + ), + ), + experimentId=( + Literal[tuple(dict.fromkeys(experiment_ids))] | None + if experiment_ids + else type(None), + Field( + default=None, + description=TuningAction.model_fields["experimentId"].description, + ), + ), + ) + + +class RnaSetting(AgentDataModel): + parameters: ParameterCandidate + features: ArtifactReferenceModel + eligibleFeatures: ArtifactReferenceModel + hvgCount: int = 1000 + ranking: Literal["global", "batchAware"] = "global" + rankingColumn: str | None = None + + +def uniform_screening_selection( + store: Any, parent: ArtifactRef, *, size: int, seed: int +) -> ArtifactRef: + """Select nested uniform prefixes without weighting study groups differently.""" + indices = read_stored_selection_indices( + store.zw, + parent, + kind="cell_selection", + scope="datastore", + assay=None, + table_path="cellData", + ).astype(np.int64, copy=False) + if size >= len(indices): + return parent + if size < 3: + raise ValueError("Screening requires at least three cells") + order = np.random.Generator(np.random.PCG64(seed)).permutation(len(indices)) + mask = np.zeros(store.cells.N, dtype=bool) + mask[indices[order[:size]]] = True + selection, _ = resolve_generated_selection_artifact( + store.zw, + scope="datastore", + kind="cell_selection", + values=mask, + row_ids=np.asarray(store.cells.fetch_all("ids")), + operation="agent_uniform_rna_screen", + parameters={"size": size, "seed": seed, "generator": "PCG64"}, + inputs={"parent_selection": parent}, + source_column="agent_screening", + ) + return selection + + +def screening_coverage( + store: Any, + parent: ArtifactRef, + sample: ArtifactRef, + columns: Sequence[str], + combinations: Sequence[Sequence[str]] = (), +) -> tuple[dict[str, Any], list[str]]: + """Report population and sample proportions without oversampling rare groups.""" + + def indices(selection: ArtifactRef) -> np.ndarray: + return read_stored_selection_indices( + store.zw, + selection, + kind="cell_selection", + scope="datastore", + assay=None, + table_path="cellData", + ).astype(np.int64, copy=False) + + full_indices, sample_indices = indices(parent), indices(sample) + evidence: dict[str, Any] = { + "populationCells": len(full_indices), + "screeningCells": len(sample_indices), + "sampling": "Uniform without replacement; no group oversampling or weights.", + "groups": {}, + } + concerns: list[str] = [] + grouped = {} + for column in dict.fromkeys(columns): + full = np.asarray( + read_metadata_rows_chunkwise(store.cells, column, full_indices) + ).astype(str) + sampled = np.asarray( + read_metadata_rows_chunkwise(store.cells, column, sample_indices) + ).astype(str) + grouped[column] = (full, sampled) + if combinations: + from ..experimental_context.characterization import _SelectionBoundCells + from ..experimental_context.comparisons import combination_labels + + full_cells = _SelectionBoundCells(store.zw, store.cells, parent) + sample_cells = _SelectionBoundCells(store.zw, store.cells, sample) + for group_columns in combinations: + key = "joint:" + json.dumps(list(group_columns), separators=(",", ":")) + grouped[key] = ( + combination_labels(full_cells, group_columns), + combination_labels(sample_cells, group_columns), + ) + for column, (full, sampled) in grouped.items(): + values, counts = np.unique(full, return_counts=True) + sample_values, sample_counts = np.unique(sampled, return_counts=True) + lookup = dict(zip(sample_values, sample_counts, strict=True)) + rows = [] + for value, count in zip(values, counts, strict=True): + observed = int(lookup.get(value, 0)) + rows.append( + { + "value": str(value), + "populationCells": int(count), + "screeningCells": observed, + "populationFraction": float(count / len(full)), + "screeningFraction": observed / len(sampled), + } + ) + if observed < min(int(count), 20): + concerns.append( + f"{column}={value}: {observed} sampled of {int(count)} cells" + ) + evidence["groups"][column] = rows + return evidence, concerns + + +class RnaTuningRun: + """Execute evidence-requested comparisons with one authoritative history.""" + + def __init__( + self, + owner: Any, + store: Any, + workflow: Any, + request: OrchestrationRequestRecord, + plan: AutomatedPreprocessingPlan, + handoff: PreprocessedAssayHandoff, + study: StudyContract, + answers: Mapping[str, Any], + provenance: dict[str, Any], + *, + design_comparisons: Sequence[CovariateComparison] = (), + ) -> None: + if ( + handoff.cellSelection is None + or handoff.graphFeatures is None + or handoff.markerFeatures is None + ): + raise ValueError( + "RNA tuning requires exact cells, graph genes and marker genes" + ) + self.owner, self.store, self.workflow = owner, store, workflow + self.request, self.plan, self.handoff, self.study = ( + request, + plan, + handoff, + study, + ) + self.answers, self.provenance = answers, provenance + self.design_comparisons = tuple(design_comparisons) + self.prefix = journal._ensure_orchestration_store(store) + self.cells = artifact_model_to_ref(handoff.cellSelection) + self.marker_features = artifact_model_to_ref(handoff.markerFeatures) + self.budget = CandidateBudget( + store, self.prefix, workflow.workflowRunId, request.config, provenance + ) + self.settings: dict[str, RnaSetting] = {} + self.history: list[dict[str, Any]] = [] + self.evaluations: dict[str, list[ParameterCandidateEvaluation]] = { + "sample0": [], + "sample1": [], + "full": [], + } + self.last_action: TuningAction | None = None + self.full_repairs = 0 + self.answer_consumed = False + self.scope_sizes: dict[str, int] = {} + self.feature_evidence_cache: dict[str, dict[str, Any]] = {} + self.neighbor_comparisons: dict[tuple[str, str], float] = {} + self.batch_columns = list(study.technicalBatchColumns) + self.coverage_columns = [ + value + for value in dict.fromkeys( + [ + study.physicalCaptureColumn, + *study.independentUnitColumns, + *study.conditionColumns, + *study.technicalBatchColumns, + *study.protectedColumns, + ] + ) + if value is not None and study.columnKinds.get(value) != "continuous" + ] + self.family_patterns = { + str(row["family"]): str(row["pattern"]) + for row in ( + plan.assays[0].featureParameters.get("defaultFeatureInventory") or {} + ).get("families", []) + if isinstance(row, Mapping) and "family" in row and "pattern" in row + } + + def baseline(self, resolution: float = 1.0) -> RnaSetting: + assert self.handoff.graphFeatures is not None + return RnaSetting( + parameters=ParameterCandidate( + candidateId="baseline", + dimensions=min(21, self.handoff.nFeatures - 1, self.handoff.nCells - 1), + neighborsK=min(11, self.handoff.nCells - 1), + leidenResolution=resolution, + useHarmony=False, + ), + features=self.handoff.graphFeatures, + eligibleFeatures=self.handoff.graphFeatureCandidates["eligibleDefault"], + hvgCount=self.handoff.nFeatures, + ) + + @staticmethod + def execution_inputs(cells: ArtifactRef, setting: RnaSetting) -> dict[str, Any]: + return { + "cells": cells.to_dict(), + "features": setting.features.model_dump(mode="json"), + "parameters": setting.parameters.model_dump(mode="json"), + } + + def execute( + self, scope: str, cells: ArtifactRef, setting: RnaSetting + ) -> ParameterCandidateEvaluation: + inputs = self.execution_inputs(cells, setting) + identity = candidate_identity(inputs) + parameters = setting.parameters.model_copy( + update={"candidateId": f"rna_{identity[:24]}"} + ) + setting = setting.model_copy(update={"parameters": parameters}) + self.settings[parameters.candidateId] = setting + admission = self.budget.admit(scope, inputs) + saved = self.budget.completed(admission) + if saved is not None: + evaluation = ParameterCandidateEvaluation.model_validate( + saved["evaluation"] + ) + for artifact in evaluation.artifacts.values(): + status = self.store.inspect_artifact(artifact_model_to_ref(artifact)) + if not status.exists or not status.complete: + raise ValueError( + "Saved candidate evidence is unavailable or incomplete" + ) + else: + features = artifact_model_to_ref(setting.features) + normalized = self.store.run_normalization( + cells, features=features, invalidate_cache=False + ) + deps, ids = prepare_parameter_tuning_dependencies( + self.store, + normalized=normalized, + candidates=[parameters], + batch_columns=self.batch_columns, + preservation_columns=self.study.protectedColumns, + pair_harmony_candidates=False, + max_candidates=1, + min_cluster_cells=1, + ) + deps.protectedCombinations = tuple( + tuple(columns) for columns in self.study.protectedCombinations + ) + deps.columnKinds = self.study.columnKinds + evaluation = execute_parameter_candidate(deps, ids[0]) + if evaluation.status != "done": + raise RuntimeError( + evaluation.error + or "Candidate execution failed; its admitted work can be retried on resume" + ) + evaluation.artifacts.update( + { + "normalized": ArtifactRecord.from_ref(normalized), + "graphFeatures": ArtifactRecord.from_ref(features), + } + ) + if evaluation.status == "done": + evaluation = augment_pca_evaluations( + self.store, + [evaluation], + feature_selection=features, + nominated_families=SCARF_DEFAULT_DIAGNOSTIC_FAMILIES, + protected_families=self.plan.assays[0].featureParameters.get( + "protectFamilies", [] + ), + technical_columns=self.batch_columns, + batch_columns=self.batch_columns, + protected_columns=self.study.protectedColumns, + qc_columns=self.plan.cellQc.attributes, + column_kinds=self.study.columnKinds, + )[0] + native = next( + ( + item + for item in self.evaluations[scope] + if not item.parameters.useHarmony + and self.settings[item.candidateId].features == setting.features + and item.parameters.model_dump( + exclude={"candidateId", "useHarmony"} + ) + == parameters.model_dump(exclude={"candidateId", "useHarmony"}) + ), + None, + ) + doublets = score_advisory_doublets( + self.store, + native or evaluation, + [ + item + for item in [*self.evaluations[scope], evaluation] + if item.artifacts.get("graphFeatures") + == evaluation.artifacts["graphFeatures"] + and item.cellSelection == evaluation.cellSelection + ], + assay=self.handoff.assay, + feature_selection=features, + capture_column=self.study.physicalCaptureColumn, + ) + evaluation = augment_cluster_evaluations( + self.store, + [evaluation], + marker_assay=self.handoff.assay, + marker_features=self.marker_features, + independent_unit_columns=self.study.independentUnitColumns, + technical_columns=self.batch_columns, + nominated_families=SCARF_DEFAULT_DIAGNOSTIC_FAMILIES, + protected_families=self.plan.assays[0].featureParameters.get( + "protectFamilies", [] + ), + doublet_evidence=doublets, + )[0] + evaluation = ParameterCandidateEvaluation.model_validate_json( + record_io.canonical_json_bytes(evaluation.model_dump(mode="json")) + ) + self.budget.complete( + admission, {"evaluation": evaluation.model_dump(mode="json")} + ) + if evaluation.candidateId not in { + item.candidateId for item in self.evaluations[scope] + }: + self.evaluations[scope].append(evaluation) + return evaluation + + def execute_matched( + self, scope: str, cells: ArtifactRef, setting: RnaSetting + ) -> ParameterCandidateEvaluation: + if setting.parameters.useHarmony: + if self.study.correctionLicense != "safe" or not self.batch_columns: + raise ValueError( + "Harmony requires a safe design license and approved batch columns" + ) + native = setting.model_copy( + update={ + "parameters": setting.parameters.model_copy( + update={"useHarmony": False} + ) + } + ) + self.budget.admit_many( + scope, + [ + self.execution_inputs(cells, native), + self.execution_inputs(cells, setting), + ], + ) + self.execute(scope, cells, native) + return self.execute(scope, cells, setting) + + def harmony_gate( + self, scope: str, selected: ParameterCandidateEvaluation + ) -> tuple[bool, list[str]]: + if not selected.parameters.useHarmony: + return True, [] + setting = self.settings[selected.candidateId] + native = next( + ( + item + for item in self.evaluations[scope] + if not item.parameters.useHarmony + and self.settings[item.candidateId].features == setting.features + and item.parameters.model_dump(exclude={"candidateId", "useHarmony"}) + == selected.parameters.model_dump(exclude={"candidateId", "useHarmony"}) + ), + None, + ) + return harmony_acceptance_gate( + native, + selected, + batch_columns=self.batch_columns, + protected_columns=[ + *self.study.protectedColumns, + *( + "joint:" + json.dumps(columns, separators=(",", ":")) + for columns in self.study.protectedCombinations + ), + ], + independent_unit_columns=self.study.independentUnitColumns, + require_doublet_evidence=True, + ) + + def experiments( + self, selected: ParameterCandidateEvaluation + ) -> dict[str, dict[str, Any]]: + """Offer individual interventions, without executing their numerical work.""" + setting = self.settings[selected.candidateId] + n_cells = next( + ( + self.scope_sizes[name] + for name, rows in self.evaluations.items() + if selected in rows and name in self.scope_sizes + ), + self.handoff.nCells, + ) + options: dict[str, dict[str, Any]] = {} + for field, values in ( + ("dimensions", (10, 21, 30, 50)), + ("neighborsK", (11, 21, 41)), + ("leidenResolution", (0.25, 0.5, 0.75, 1.0, 1.25, 1.5)), + ): + for value in values: + if value != getattr(setting.parameters, field): + if field == "dimensions" and value >= min( + setting.hvgCount, n_cells + ): + continue + if field == "neighborsK" and value >= n_cells: + continue + options[f"{field}:{value}"] = {"parameter": field, "value": value} + for count in (1000, 2000, 4000): + if count != setting.hvgCount: + options[f"hvgCount:{count}"] = {"parameter": "hvgCount", "value": count} + for column in self.batch_columns: + if self.study.columnKinds.get(column) == "continuous": + continue + if setting.ranking != "batchAware" or setting.rankingColumn != column: + options[f"hvgRanking:batchAware:{column}"] = { + "parameter": "hvgRanking", + "value": "batchAware", + "column": column, + } + if setting.ranking == "batchAware": + options["hvgRanking:global"] = { + "parameter": "hvgRanking", + "value": "global", + } + for family in self.family_patterns: + for operation in ("includeFamily", "excludeFamily"): + options[f"{operation}:{family}"] = { + "parameter": operation, + "value": family, + } + feature_policy = self.plan.assays[0].featureParameters + for feature in dict.fromkeys( + [ + *feature_policy.get("proposedExcludeFeatures", []), + *feature_policy.get("protectFeatures", []), + ] + ): + for operation in ("includeFeature", "excludeFeature"): + if operation == "excludeFeature" and feature in feature_policy.get( + "protectFeatures", [] + ): + continue + options[f"{operation}:{feature}"] = { + "parameter": operation, + "value": feature, + } + if self.study.correctionLicense == "safe" and self.batch_columns: + options["useHarmony:true"] = {"parameter": "useHarmony", "value": True} + if setting.parameters.useHarmony: + options["useHarmony:false"] = {"parameter": "useHarmony", "value": False} + return options + + def batch_ranking( + self, eligible: ArtifactRef, count: int, column: str + ) -> np.ndarray: + """Use core per-group variability on the same globally eligible genes.""" + if column not in self.batch_columns: + raise ValueError("Batch-aware ranking needs an approved technical column") + from ..experimental_context.characterization import _SelectionBoundCells + + cells = _SelectionBoundCells(self.store.zw, self.store.cells, self.cells) + values, counts = np.unique(cells.fetch(column), return_counts=True) + groups = [] + for value, n_cells in zip(values, counts, strict=True): + if n_cells < 20: + continue + selection = self.store.filter_cells( + [column], + [value], + [value], + cell_selection=self.cells, + keep_bounds=True, + invalidate_cache=False, + ) + reference = self.store.select_hvgs( + selection, + from_assay=self.handoff.assay, + top_n=self.store.get_assay(self.handoff.assay).feats.N, + min_cells=1, + max_cells=np.inf, + blacklist="", + show_plot=False, + invalidate_cache=False, + ) + status = self.store.inspect_artifact(reference) + summary = ArtifactRef.from_dict(dict(status.inputs["feature_summary"])) + group = self.store.load_artifact(reference) + detected = ( + np.asarray(self.store.load_artifact(summary)["normed_n"][:]) >= 20 + ) + groups.append( + HvgGroupVariability( + group_id=str(value), + cell_count=int(n_cells), + corrected_variance=np.asarray( + group["corrected_variance"][:], dtype=np.float64 + ), + detected_features=detected, + ) + ) + if len(groups) < 2: + raise ValueError( + "Batch-aware ranking lacks two groups with sufficient cells" + ) + mask = np.asarray(self.store.load_artifact(eligible)["values"][:], dtype=bool) + statistics = artifact_model_to_ref( + self.handoff.graphFeatureCandidates["eligibleAll"] + ) + variance = np.asarray( + self.store.load_artifact(statistics)["corrected_variance"][:], + dtype=np.float64, + ) + ranking = aggregate_hvg_rankings( + variance, + mask, + groups, + valid_group_count=len(groups), + candidate_targets=(count,), + ) + return ranking.ranking + + def apply_experiment( + self, selected: ParameterCandidateEvaluation, experiment: dict[str, Any] + ) -> RnaSetting: + setting = self.settings[selected.candidateId] + field, value = experiment["parameter"], experiment["value"] + if field in {"dimensions", "neighborsK", "leidenResolution", "useHarmony"}: + return setting.model_copy( + update={ + "parameters": setting.parameters.model_copy(update={field: value}) + } + ) + eligible = artifact_model_to_ref(setting.eligibleFeatures) + count = int(value) if field == "hvgCount" else setting.hvgCount + ranking_mode = value if field == "hvgRanking" else setting.ranking + ranking_column = ( + experiment.get("column") if field == "hvgRanking" else setting.rankingColumn + ) + if field in { + "includeFamily", + "excludeFamily", + "includeFeature", + "excludeFeature", + }: + mask = np.asarray( + self.store.load_artifact(eligible)["values"][:], dtype=bool + ) + names = np.asarray( + self.store.get_assay(self.handoff.assay).feats.fetch_all("names") + ).astype(str) + feature_ids = np.asarray( + self.store.get_assay(self.handoff.assay).feats.fetch_all("ids") + ).astype(str) + if field.endswith("Family"): + pattern = re.compile(self.family_patterns[value], flags=re.IGNORECASE) + family_mask = np.asarray( + [pattern.search(name) is not None for name in names] + ) + else: + family_mask = (names == value) | (feature_ids == value) + if not family_mask.any(): + raise ValueError( + "The nominated exact feature is absent from the assay" + ) + if field.startswith("include"): + all_eligible = artifact_model_to_ref( + self.handoff.graphFeatureCandidates["eligibleAll"] + ) + allowed = np.asarray( + self.store.load_artifact(all_eligible)["values"][:], dtype=bool + ) + mask |= allowed & family_mask + else: + policy = self.plan.assays[0].featureParameters + protected_mask = np.isin( + names, policy.get("protectFeatures", []) + ) | np.isin(feature_ids, policy.get("protectFeatures", [])) + for family in policy.get("protectFamilies", []): + family_protection = _family_mask(names, family) + if family_protection is not None: + protected_mask |= family_protection + elif family in self.family_patterns: + pattern = re.compile( + self.family_patterns[family], flags=re.IGNORECASE + ) + protected_mask |= np.asarray( + [pattern.search(name) is not None for name in names] + ) + if np.any(family_mask & protected_mask): + raise ValueError( + "An objective-protected feature or family cannot be excluded" + ) + mask &= ~family_mask + eligible = self.store.set_feature_selection( + from_assay=self.handoff.assay, mask=mask, invalidate_cache=False + ) + if ranking_mode == "batchAware" and ranking_column is None: + raise ValueError( + "Batch-aware ranking requires an explicit technical column" + ) + indices = ( + self.batch_ranking(eligible, count, ranking_column) + if ranking_mode == "batchAware" and ranking_column is not None + else None + ) + features = rank_core_hvgs( + self.store, + eligible=eligible, + statistics=artifact_model_to_ref( + self.handoff.graphFeatureCandidates["eligibleAll"] + ), + top_n=count, + ranking=indices, + ) + n_features = int( + np.asarray( + self.store.load_artifact(features)["values"][:], dtype=bool + ).sum() + ) + if n_features <= setting.parameters.dimensions: + raise ValueError( + "This feature experiment cannot retain the fixed PCA dimension" + ) + return setting.model_copy( + update={ + "features": ArtifactReferenceModel.from_artifact_ref(features), + "eligibleFeatures": ArtifactReferenceModel.from_artifact_ref(eligible), + "hvgCount": n_features, + "ranking": ranking_mode, + "rankingColumn": ranking_column, + } + ) + + def feature_evidence( + self, selected: ParameterCandidateEvaluation + ) -> dict[str, Any]: + """Summarize frozen core statistics without rerunning feature variability.""" + setting = self.settings[selected.candidateId] + key = setting.model_dump_json(exclude={"parameters"}) + if key in self.feature_evidence_cache: + return self.feature_evidence_cache[key] + mask = np.asarray( + self.store.load_artifact(artifact_model_to_ref(setting.features))["values"][ + : + ], + dtype=bool, + ) + eligible = np.asarray( + self.store.load_artifact(artifact_model_to_ref(setting.eligibleFeatures))[ + "values" + ][:], + dtype=bool, + ) + reference = artifact_model_to_ref( + self.handoff.graphFeatureCandidates["eligibleAll"] + ) + variance = np.asarray( + self.store.load_artifact(reference)["corrected_variance"][:], + dtype=np.float64, + ) + names = np.asarray( + self.store.get_assay(self.handoff.assay).feats.fetch_all("names") + ).astype(str) + indices = np.flatnonzero(eligible) + ranked = indices[np.lexsort((indices, -variance[indices]))] + families = {} + for family, pattern in self.family_patterns.items(): + expression = re.compile(pattern, flags=re.IGNORECASE) + membership = np.asarray( + [expression.search(name) is not None for name in names] + ) + families[family] = { + "eligibleGenes": int((eligible & membership).sum()), + "selectedGenes": int((mask & membership).sum()), + "selectedExamples": names[ + np.flatnonzero(mask & membership)[:8] + ].tolist(), + "excludedExamples": names[ + np.flatnonzero(~eligible & membership)[:8] + ].tolist(), + } + evidence = { + "statistics": reference.to_dict(), + "statisticsCells": self.cells.to_dict(), + "basis": "Core Scarf corrected variance on the full QC-retained cells; feature-axis summaries are descriptive, not a substitute for downstream comparisons.", + "selectedGenes": int(mask.sum()), + "eligibleGenes": int(eligible.sum()), + "ranking": setting.ranking, + "rankingColumn": setting.rankingColumn, + "globalRankLandmarks": [ + { + "rank": rank, + "gene": str(names[ranked[rank - 1]]), + "correctedVariance": float(variance[ranked[rank - 1]]), + } + for rank in (1, 500, 1000, 2000, 4000) + if rank <= len(ranked) + ], + "topSelectedGenes": names[ + [index for index in ranked if mask[index]][:20] + ].tolist(), + "families": families, + } + self.feature_evidence_cache[key] = evidence + return evidence + + def review( + self, + scope: str, + review_index: int, + selected: ParameterCandidateEvaluation, + coverage: dict[str, Any], + ) -> TuningAction: + from .tuning import _analysis_visual_content + + candidates = self.evaluations[scope] + key = f"parameter_tuning/{scope}/review{review_index}" + previous_review = journal.read_checkpoint( + self.store, + self.prefix, + self.workflow.workflowRunId, + key, + ) + candidate_evidence = [item.model_dump(mode="json") for item in candidates] + setting_evidence = { + item.candidateId: self.settings[item.candidateId].model_dump(mode="json") + for item in candidates + } + if previous_review is not None and ( + previous_review["inputs"].get("candidates") != candidate_evidence + or previous_review["inputs"].get("settings") != setting_evidence + ): + raise ValueError( + "Saved review has different candidate evidence or settings" + ) + experiments = ( + previous_review["inputs"]["experiments"] + if previous_review is not None + else self.experiments(selected) + ) + completed_experiments: dict[str, str] = {} + matched_comparisons = [] + current_setting = self.settings[selected.candidateId] + for candidate in candidates: + other = self.settings[candidate.candidateId] + if ( + candidate.candidateId == selected.candidateId + or candidate.cellSelection != selected.cellSelection + or other.features != current_setting.features + or other.parameters.reductionMethod + != current_setting.parameters.reductionMethod + ): + continue + changes = { + field: { + "current": getattr(current_setting.parameters, field), + "alternative": getattr(other.parameters, field), + } + for field in ( + "dimensions", + "neighborsK", + "leidenResolution", + "useHarmony", + ) + if getattr(current_setting.parameters, field) + != getattr(other.parameters, field) + } + if len(changes) != 1: + continue + matched_comparisons.append( + { + "currentCandidateId": selected.candidateId, + "alternativeCandidateId": candidate.candidateId, + "changedParameter": changes, + "basis": "Same frozen cells and graph features; all other analysis parameters match. Compare these exact candidates rather than mixing dimensions and resolution effects.", + } + ) + if previous_review is None and candidate.status == "done": + field, values = next(iter(changes.items())) + for experiment_id, experiment in experiments.items(): + if ( + experiment["parameter"] == field + and experiment["value"] == values["alternative"] + ): + completed_experiments[experiment_id] = candidate.candidateId + if previous_review is None: + experiments = { + key: value + for key, value in experiments.items() + if key not in completed_experiments + } + comparisons = ( + previous_review["inputs"].get("neighborComparisons", []) + if previous_review is not None + else [] + ) + selected_neighbors = selected.artifacts.get("neighbors") + for alternative in [] if previous_review is not None else candidates: + other_neighbors = alternative.artifacts.get("neighbors") + if ( + selected_neighbors is None + or other_neighbors is None + or selected_neighbors == other_neighbors + or selected.parameters.neighborsK != alternative.parameters.neighborsK + ): + continue + left_id, right_id = sorted( + (selected_neighbors.artifactId, other_neighbors.artifactId) + ) + pair = (left_id, right_id) + if pair not in self.neighbor_comparisons: + self.neighbor_comparisons[pair] = _neighbor_overlap( + self.store, + artifact_model_to_ref(selected_neighbors), + artifact_model_to_ref(other_neighbors), + ) + comparisons.append( + { + "leftCandidateId": selected.candidateId, + "rightCandidateId": alternative.candidateId, + "meanNeighborJaccard": self.neighbor_comparisons[pair], + "basis": "Same frozen cells and k; descriptive response to settings, not independent stability or evidence that correction is beneficial.", + } + ) + declared_image_input = _configured_image_input(self.owner.model) + capability_key = "parameter_tuning/structured_evidence" + capability_inputs = { + **self.provenance, + "configuredImageInput": declared_image_input, + } + capability = journal.load_checkpoint( + self.store, + self.prefix, + self.workflow.workflowRunId, + capability_key, + inputs=capability_inputs, + ) + if capability is None and declared_image_input is False: + capability = journal.save_checkpoint( + self.store, + self.prefix, + self.workflow.workflowRunId, + capability_key, + inputs=capability_inputs, + outputs={ + "evidenceMode": "structured", + "reason": "configuredImageInputUnsupported", + }, + ) + if capability is not None and capability.get("evidenceMode") != "structured": + raise ValueError("The recorded model capability is invalid") + mode = ( + previous_review["inputs"].get("evidenceMode") + if previous_review is not None + else "structured" + if capability is not None + else "visual" + ) + if mode not in {"visual", "structured"}: + raise ValueError( + "Saved review lacks an exact evidence mode; start a new workflow" + ) + visual_inspection = "available" if mode == "visual" else "unavailable" + images = [] + if previous_review is not None: + image_hashes = previous_review["inputs"].get("imageHashes", {}) + elif mode == "visual": + images = _analysis_visual_content( + self.store, + selected, + candidates, + qc_columns=self.plan.cellQc.attributes, + qc_artifact_metrics=[ + (item.name, item.artifact) + for item in self.plan.cellQc.artifactMetrics + ], + ) + image_hashes = { + image.identifier: hashlib.sha256(image.data).hexdigest() + for image in images + } + else: + image_hashes = {} + if not isinstance(image_hashes, dict) or bool(image_hashes) != ( + mode == "visual" + ): + raise ValueError("Review images do not match its evidence mode") + evidence_ids = ( + previous_review["inputs"]["availableEvidenceIds"] + if previous_review is not None + else list( + dict.fromkeys( + [ + *(f"candidate:{item.candidateId}" for item in candidates), + *(key for item in candidates for key in item.evidenceIds), + *self.study.evidenceIds, + *self.plan.cellQc.evidenceIds, + *(item.evidenceId for item in self.design_comparisons), + *image_hashes, + "studyContract", + "qcPolicy", + "samplingCoverage", + "featureEvidence", + "neighborComparisons", + "assessmentContext", + ] + ) + ) + ) + if not isinstance(evidence_ids, list) or any( + not isinstance(value, str) for value in evidence_ids + ): + raise ValueError("Review evidence IDs must be a list of strings") + evidence = { + "studyContract": self.study.model_dump(mode="json"), + "qcPolicy": self.plan.cellQc.model_dump(mode="json"), + "scope": scope, + "evidenceMode": mode, + "visualInspection": visual_inspection, + "configuredImageInput": declared_image_input, + "coverage": coverage, + "currentCandidateId": selected.candidateId, + "candidates": candidate_evidence, + "settings": setting_evidence, + "featureEvidence": { + item.candidateId: self.feature_evidence(item) for item in candidates + }, + "neighborComparisons": comparisons, + "harmonyGates": { + item.candidateId: self.harmony_gate(scope, item) + for item in candidates + if item.parameters.useHarmony + }, + "experiments": experiments, + "availableEvidenceIds": evidence_ids, + "imageHashes": image_hashes, + "assessedDomains": sorted(_DOMAINS), + "budget": { + "visibleEvaluations": { + name: len(rows) for name, rows in self.evaluations.items() + }, + "limits": self.budget.summary()["limits"], + }, + "fullRepairsUsed": self.full_repairs, + "pilotPopulationWarnings": { + item.candidateId: "This sampled partition includes fewer than 20 cells in a population. Assess its relevance and support; it is not evidence of an invalid biological group. Accepting this partition requires a larger sample or full-cohort assessment." + for item in candidates + if scope != "full" + and item.metrics.minClusterCells is not None + and item.metrics.minClusterCells < 20 + }, + "smallPopulationPolicy": "Small full-cohort groups require explicit marker, stability, graph and applicable independent-unit support assessment against the objective. Their size alone is neither proof of biology nor grounds for rejection. Defer when essential evidence is insufficient.", + } + if previous_review is None: + support_columns = list( + dict.fromkeys( + column + for column in ( + self.study.physicalCaptureColumn, + *self.study.independentUnitColumns, + ) + if column is not None + ) + ) + evidence["assessmentContext"] = { + "correctionPolicy": { + "license": self.study.correctionLicense, + "harmonyPermitted": self.study.correctionLicense == "safe" + and bool(self.batch_columns), + "nativeAcceptance": "A native descriptive analysis may be accepted when its required evidence supports the objective, while explicitly retaining confounding limitations. A Harmony gate is required only for accepting Harmony. Defer if the objective requires effects that the design cannot separate.", + }, + "matchedComparisons": matched_comparisons, + "alreadyEvaluatedExperiments": completed_experiments, + "designComparisons": [ + item.model_dump(mode="json") for item in self.design_comparisons + ], + "populationSupport": { + selected.candidateId: population_support_evidence( + self.store, selected, support_columns + ) + } + if support_columns + else {}, + "previousActions": [ + { + "scope": row["scope"], + **{ + name: row["review"][name] + for name in ( + "action", + "selectedCandidateId", + "experimentId", + "correctionNeed", + ) + }, + } + for row in self.history + if "review" in row + ], + } + elif "assessmentContext" in previous_review["inputs"]: + evidence["assessmentContext"] = previous_review["inputs"][ + "assessmentContext" + ] + + def validate(action: TuningAction, *, replay: bool = False) -> TuningAction: + by_id = {item.candidateId: item for item in candidates} + if action.selectedCandidateId not in by_id: + raise ValueError("Choose an observed candidate from the current cells") + unknown_ids = sorted(set(action.evidenceIds).difference(evidence_ids)) + if unknown_ids: + raise ValueError( + f"Assessment cited unknown evidence: {unknown_ids[:8]!r}. " + "Copy exact IDs from availableEvidenceIds; do not invent " + "suffixes or use artifact IDs as evidence IDs. The selected " + f"candidate anchor is 'candidate:{action.selectedCandidateId}'." + ) + if mode == "visual" and not set(action.evidenceIds).intersection( + image_hashes + ): + raise ValueError("Assessment must cite its actual visual evidence") + chosen = by_id[action.selectedCandidateId] + if not set(action.evidenceIds).intersection( + {f"candidate:{chosen.candidateId}", *chosen.evidenceIds} + ): + raise ValueError( + "Assessment must cite the selected numerical evidence: " + f"'candidate:{chosen.candidateId}' or one of that candidate's " + "supplied evidenceIds in availableEvidenceIds." + ) + if ( + action.experimentId is not None + and action.experimentId not in experiments + ): + raise ValueError( + f"Unknown experiment ID {action.experimentId!r}. " + f"For current candidate {selected.candidateId!r}, copy one exact " + f"key from experiments: {list(experiments)!r}. " + "Only action='experiment' may name a next operation." + ) + if ( + action.action == "experiment" + and action.selectedCandidateId != selected.candidateId + ): + raise ValueError( + "An offered experiment must use the current candidate as its fixed baseline" + ) + if ( + self.study.correctionLicense == "unsafeConfounded" + and action.correctionNeed in {"needed", "notNeeded"} + ): + message = ( + "The design confounds the batch columns with protected biology; their association cannot establish a removable technical effect. " + "Harmony is not permitted, and a PCA or feature experiment is not a substitute for Harmony. " + "Use notApplicable for the prohibited correction with an explicit confounding limitation, or uncertain and defer if an essential question cannot be resolved." + ) + if replay and action.action == "experiment": + self.history.append( + { + "scope": scope, + "reason": "A saved screening rationale claimed identifiable correction necessity despite the confounded design. Its numerical experiment remains in the audit history; that scientific claim must be reassessed from the supplied evidence.", + } + ) + else: + raise ValueError(message) + if ( + self.study.correctionLicense == "safe" + and action.correctionNeed == "notApplicable" + ): + raise ValueError( + "A safe correction design still requires an observed necessity assessment" + ) + if action.action == "accept": + if action.correctionNeed == "uncertain": + raise ValueError( + "Uncertain correction necessity requires further evidence or deferral before acceptance" + ) + if not chosen.eligible: + raise ValueError( + "The selected candidate failed required full-cell checks" + ) + accepted, reasons = self.harmony_gate(scope, chosen) + if not accepted: + raise ValueError("Harmony acceptance failed: " + "; ".join(reasons)) + required = ( + "seedStability", + "subsampleStability", + "markerCoherence", + "membershipStrengthMean", + "clusterConnectivity", + ) + if any(getattr(chosen.metrics, field) is None for field in required): + raise ValueError( + "Required stability, marker or graph evidence is missing" + ) + if ( + self.study.independentUnitColumns + and chosen.metrics.crossUnitSupport is None + ): + raise ValueError( + "Required independent-unit support evidence is missing" + ) + if chosen.parameters.useHarmony and action.correctionNeed != "needed": + raise ValueError( + "Accepting Harmony requires observed correction necessity" + ) + if self.study.unsupportedProtection and action.correctionNeed in { + "needed", + "uncertain", + }: + raise ValueError( + "Correction necessity remains unresolved because required matched biological protection is unsupported" + ) + if ( + not chosen.parameters.useHarmony + and action.correctionNeed == "needed" + ): + raise ValueError( + "Native acceptance leaves required correction unresolved; provide more evidence or defer" + ) + if self.study.correctionLicense == "safe" and action.correctionNeed in { + "needed", + "uncertain", + }: + chosen_setting = self.settings[chosen.candidateId] + has_comparison = any( + item.parameters.useHarmony + and item.status == "done" + and self.settings[item.candidateId].features + == chosen_setting.features + and item.parameters.model_dump( + exclude={"candidateId", "useHarmony"} + ) + == chosen.parameters.model_dump( + exclude={"candidateId", "useHarmony"} + ) + for item in candidates + ) + if not has_comparison: + raise ValueError( + "Safe but uncertain/needed correction requires a matched Harmony experiment" + ) + if self.study.correctionLicense == "indeterminate": + raise ValueError( + "Correction design authorization remains indeterminate" + ) + return action + + saved = journal.load_checkpoint( + self.store, self.prefix, self.workflow.workflowRunId, key, inputs=evidence + ) + pending_saved = saved is not None and saved["action"]["action"] == "defer" + answer = self.answers.get(key) + if ( + answer is None + and not self.answer_consumed + and (saved is None or pending_saved) + ): + answer = self.answers.get("parameter_tuning") + if pending_saved: + assert saved is not None + answer_inputs = {**evidence, "deferredAction": saved["action"]} + prior_answer = journal.load_checkpoint( + self.store, + self.prefix, + self.workflow.workflowRunId, + key + "/answer", + inputs=answer_inputs, + ) + if prior_answer is not None: + if ( + answer is not None + and TuningAction.model_validate(answer).model_dump(mode="json") + == prior_answer["action"] + ): + self.answer_consumed = True + key += "/answer" + evidence = answer_inputs + saved = prior_answer + answer = None + elif answer is not None: + key += "/answer" + evidence = answer_inputs + saved = None + if saved is not None: + action = validate(TuningAction.model_validate(saved["action"]), replay=True) + elif answer is not None: + action = validate(TuningAction.model_validate(answer)) + self.answer_consumed = True + else: + prompt = ( + "Assess this RNA analysis as a computational biologist against the exact study objective. " + "Start from Scarf defaults; keep them when observed quantitative evidence and biological interpretation support them. " + "Do not execute a search grid or favor a default solely because it is a default. " + "Assess every named scientific domain before acceptance. Explain observed marker programs and relevant PCA loading genes, " + "QC/capture retention, batch associations per PC and protected biological structure. " + "Family dominance alone never proves nuisance; inclusion, exclusion and HVG bans need evidence and objective justification. " + "If a specific concern warrants testing, choose exactly one offered experiment and state its expected improvement and " + "what objective-relevant biology must be preserved. Family policy remains revisable when later evidence warrants it. " + "PCA, HVG, k and resolution changes are separate comparisons. Do not equate a small sampled cluster with an artifact. " + "For a safe design license, needed or uncertain correction requires a matched Harmony experiment. " + "A notNeeded choice needs observed native batch/PC and biological evidence. Unsafe or unknown design is never authorization. " + "Only accepting a Harmony representation requires a matched Harmony gate; a native representation does not require one. " + "When correctionLicense is unsafeConfounded, do not infer correction necessity from batch mixing or PCA association. " + "Use notApplicable for the prohibited correction, retain the confounding limitation, and assess whether native descriptive population discovery satisfies the objective. " + "If essential effects remain inseparable, defer. Never request a different parameter change as a proxy for unavailable Harmony. " + "Screening estimates do not prove full-cohort transfer. " + "Findings describe observed results. The experimentId names the exact next operation and expectedImprovement predicts only that operation's effect. " + "Already evaluated settings are existing alternatives, not new experiments; use the supplied matchedComparisons to avoid mixing PCA effects with resolution effects. " + "For example, compare 10 versus 21 PCs at the same resolution rather than quoting the stability of another resolution. " + "A high scaled cLISI is local purity of the supplied label, not proof that a clinical phenotype or all cell types are preserved. " + "Graph connectivity is within-label connectivity. Low library mixing and high PCA association can reflect donor biology or cell composition; neither proves technical causality. " + "A QC association is correlation. Check featureEvidence for actual selected genes: marker-family enrichment cannot show that an excluded family drives PCA. " + "More retained cells, balanced group counts, or cross-unit support alone do not prove healthy cells or biological preservation. " + "Check proposed cell identities against the tissue context. Unexpected marker programs require capture/donor and provenance investigation; do not declare them ordinary tissue populations or assert contamination without evidence. " + "Detailed populationSupport is supplied for currentCandidateId only; do not claim to have compared unprovided distributions for alternatives. Inspect its capture/donor distribution and missing metadata. Broad support does not prove a biological identity; concentration alone does not prove contamination. " + "Unsupported design comparisons establish neither association nor absence; keep their unresolved requirements visible. " + "Previous actions are history, not scientific authority. Reassess their claims against the exact current evidence. " + "Request enlarge when sample evidence is insufficient; on full cells there is one targeted repair, then defer. " + "Copy evidence IDs exactly from availableEvidenceIds, including the selected candidate's anchor " + "or one of its supplied diagnostic evidence IDs. Do not invent IDs, suffixes or substitute artifact IDs. " + "Never accept merely because work limits are exhausted. " + ) + prompt += ( + "The supplied images are available for visual assessment. Cite actual image evidence IDs and connect the observed plots to the numerical evidence." + if mode == "visual" + else "Visual inspection is unavailable: no images were supplied. Do not claim to have seen, inspected or compared plots or images, and do not cite image IDs. Use qualitativeFindings to interpret the reported marker identities, PCA loading genes, feature families and structured diagnostic tables. State limitations and defer if the supplied evidence cannot resolve an essential question." + ) + try: + result = run_agent_sync( + model=self.owner.model, + output_type=_assessment_output_type( + [item.candidateId for item in candidates], + list(experiments), + scope=scope, + ), + system_prompt=prompt, + user_prompt=build_visual_evidence_prompt( + json.dumps(evidence, sort_keys=True), images + ) + if mode == "visual" + else json.dumps(evidence, sort_keys=True), + config=self.request.config.agentRunConfig, + name=f"rna_{scope}_assessment", + output_validator=validate, + ) + except ImageInputUnsupportedError: + if mode != "visual": + raise + journal.save_checkpoint( + self.store, + self.prefix, + self.workflow.workflowRunId, + capability_key, + inputs=capability_inputs, + outputs={ + "evidenceMode": "structured", + "reason": "providerRejectedImageInput", + }, + ) + logger.info( + "Analysis assessment: model rejected images; continuing with structured marker, PCA and diagnostic evidence." + ) + return self.review(scope, review_index, selected, coverage) + action = validate(result.output) + journal.save_checkpoint( + self.store, + self.prefix, + self.workflow.workflowRunId, + key, + inputs=evidence, + outputs={"action": action.model_dump(mode="json")}, + ) + self.history.append( + { + "scope": scope, + "evidenceMode": mode, + "visualInspection": visual_inspection, + "review": action.model_dump(mode="json"), + "imageHashes": image_hashes, + "checkpointKey": key, + "checkpointSha256": hashlib.sha256( + record_io.canonical_json_bytes( + { + "inputs": evidence, + "outputs": {"action": action.model_dump(mode="json")}, + } + ) + ).hexdigest(), + } + ) + self.last_action = action + operation = ( + f"experiment {action.experimentId} from {action.selectedCandidateId}" + if action.action == "experiment" + else f"{action.action} {action.selectedCandidateId}" + ) + logger.info(f"Analysis assessment ({operation}): {action.rationale}") + return action + + def assess_scope( + self, + scope: str, + cells: ArtifactRef, + initial: RnaSetting | None, + ) -> tuple[ + Literal["accept", "enlarge", "defer"], ParameterCandidateEvaluation | None + ]: + coverage, insufficient = screening_coverage( + self.store, + self.cells, + cells, + self.coverage_columns, + self.study.protectedCombinations, + ) + self.scope_sizes[scope] = coverage["screeningCells"] + self.history.append( + {"scope": scope, "coverage": coverage, "coverageConcerns": insufficient} + ) + if scope != "full" and insufficient: + return "enlarge", None + if initial is None: + settings = [ + self.baseline(resolution) for resolution in (0.5, 0.75, 1.0, 1.25) + ] + settings = [ + value.model_copy( + update={ + "parameters": value.parameters.model_copy( + update={ + "dimensions": min( + value.parameters.dimensions, + coverage["screeningCells"] - 1, + ), + "neighborsK": min( + value.parameters.neighborsK, + coverage["screeningCells"] - 1, + ), + } + ) + } + ) + for value in settings + ] + self.budget.admit_many( + scope, [self.execution_inputs(cells, value) for value in settings] + ) + baseline = [self.execute(scope, cells, value) for value in settings] + selected = next( + (item for item in baseline if item.parameters.leidenResolution == 1.0), + baseline[0], + ) + else: + selected = self.execute_matched(scope, cells, initial) + limit = ( + self.request.config.maxFullPartitions + if scope == "full" + else self.request.config.maxScreeningEvaluations + ) + for review_index in range(limit + 1): + action = self.review(scope, review_index, selected, coverage) + selected = next( + item + for item in self.evaluations[scope] + if item.candidateId == action.selectedCandidateId + ) + if action.action in {"accept", "enlarge", "defer"}: + if ( + action.action == "accept" + and scope != "full" + and selected.metrics.minClusterCells is not None + and selected.metrics.minClusterCells < 20 + ): + self.history.append( + { + "scope": scope, + "reason": "The selected sampled partition contains a small population requiring more cells for assessment; all its cells are retained.", + } + ) + return "enlarge", selected + return action.action, selected + assert action.experimentId is not None + experiment = self.experiments(selected)[action.experimentId] + if scope == "full" and experiment["parameter"] != "useHarmony": + if self.full_repairs >= self.request.config.maxFullRepairs: + raise CandidateBudgetExceeded( + "The allowed full-cohort repair has been used; scientific acceptance remains unresolved" + ) + self.full_repairs += 1 + if experiment["parameter"] in { + "hvgRanking", + "hvgCount", + "includeFamily", + "excludeFamily", + "includeFeature", + "excludeFeature", + }: + feature_key = ( + f"parameter_tuning/{scope}/review{review_index}/feature_experiment" + ) + feature_inputs = { + "baseline": self.settings[selected.candidateId].model_dump( + mode="json" + ), + "experiment": experiment, + "cells": self.cells.to_dict(), + } + saved_feature = journal.load_checkpoint( + self.store, + self.prefix, + self.workflow.workflowRunId, + feature_key, + inputs=feature_inputs, + ) + if saved_feature is None: + proposed = self.execution_inputs( + cells, self.settings[selected.candidateId] + ) + proposed["features"] = { + "requestedFeatureExperiment": feature_inputs + } + proposals = [proposed] + if selected.parameters.useHarmony: + proposals.append( + { + **proposed, + "parameters": { + **proposed["parameters"], + "useHarmony": False, + }, + } + ) + self.budget.check_many(scope, proposals) + setting = self.apply_experiment(selected, experiment) + journal.save_checkpoint( + self.store, + self.prefix, + self.workflow.workflowRunId, + feature_key, + inputs=feature_inputs, + outputs={"setting": setting.model_dump(mode="json")}, + ) + else: + setting = RnaSetting.model_validate(saved_feature["setting"]) + else: + setting = self.apply_experiment(selected, experiment) + next_selected = self.execute_matched(scope, cells, setting) + if next_selected.candidateId == selected.candidateId: + self.history.append( + { + "scope": scope, + "experiment": action.experimentId, + "result": "The intervention did not change the selected genes or numerical representation; exact artifacts were reused.", + } + ) + selected = next_selected + return "defer", selected + + def run(self) -> tuple[ParameterTuningReport, dict[str, Any]]: + selected: ParameterCandidateEvaluation | None = None + final_status = "defer" + reason = "Required scientific evidence remains unresolved." + try: + initial: RnaSetting | None = None + if self.handoff.nCells > self.request.config.screeningCells: + for index, size in enumerate( + ( + self.request.config.screeningCells, + self.request.config.maxScreeningCells, + ) + ): + sample = uniform_screening_selection( + self.store, + self.cells, + size=size, + seed=self.request.config.randomSeed, + ) + if sample == self.cells: + break + status, screened = self.assess_scope(f"sample{index}", sample, None) + if status == "accept" and screened is not None: + initial = self.settings[screened.candidateId] + break + if status == "defer": + return self.report( + None, + self.last_action.rationale + if self.last_action is not None + else reason, + ), self.summary() + else: + logger.info( + "Screening evidence remains insufficient; assessing the bounded full Scarf baseline." + ) + final_status, selected = self.assess_scope("full", self.cells, initial) + if final_status != "accept" and self.last_action is not None: + reason = self.last_action.rationale + except CandidateBudgetExceeded as exc: + reason = f"Scientific assessment paused: {exc}" + if final_status != "accept": + selected = None + return self.report(selected, reason), self.summary() + + def summary(self) -> dict[str, Any]: + budget = self.budget.summary() + diagnostic_artifacts: dict[str, set[ArtifactRef]] = { + "pcaDiagnostics": set(), + "stabilityClusters": set(), + "markerTable": set(), + "doubletScore": set(), + } + subsample_evaluations = {} + for scope, evaluations in self.evaluations.items(): + subsample_evaluations[scope] = len( + { + evaluation.candidateId + for evaluation in evaluations + if evaluation.status == "done" + and evaluation.metrics.subsampleStability is not None + } + ) + for evaluation in evaluations: + if evaluation.status != "done": + continue + for name, artifact in evaluation.artifacts.items(): + diagnostic = ( + "pcaDiagnostics" + if name == "representationDiagnostic" + else name.split(":", 1)[0] + ) + if diagnostic in diagnostic_artifacts: + diagnostic_artifacts[diagnostic].add( + artifact_model_to_ref(artifact) + ) + diagnostic_counts = { + name: len(refs) for name, refs in diagnostic_artifacts.items() + } + for scope, counts in budget["scopes"].items(): + if counts["reserved"]["partitions"]: + label = { + "sample0": "screening sample 1", + "sample1": "screening sample 2", + "full": "full cohort", + }[scope] + completed, reserved = counts["completed"], counts["reserved"] + logger.info( + f"Tuning {label}: {completed['graphs']}/{reserved['graphs']} graphs " + f"and {completed['partitions']}/{reserved['partitions']} partitions " + "completed/reserved." + ) + limits = budget["limits"] + logger.info( + f"Tuning limits: {limits['perScreen']} partitions per screen, " + f"{limits['totalScreens']} across screens; full cohort " + f"{limits['fullGraphs']} graphs and {limits['fullPartitions']} partitions." + ) + logger.info( + f"Diagnostic evidence: {diagnostic_counts['pcaDiagnostics']} PCA summaries, " + f"{diagnostic_counts['stabilityClusters']} alternate-seed partitions, " + f"{diagnostic_counts['markerTable']} marker tables, " + f"{diagnostic_counts['doubletScore']} doublet scores; " + f"{sum(subsample_evaluations.values())} subsample-stability evaluations. " + "Saved evidence may be reused; these are not computation counts." + ) + return { + "history": self.history, + "budget": budget, + "diagnosticEvidence": { + "uniqueArtifacts": diagnostic_counts, + "subsampleStabilityEvaluations": subsample_evaluations, + "interpretation": ( + "Counts describe saved diagnostic evidence used by these evaluations " + "and may include reused artifacts or metrics, not new computations." + ), + }, + "fullRepairs": self.full_repairs, + } + + def report( + self, selected: ParameterCandidateEvaluation | None, reason: str + ) -> ParameterTuningReport: + evaluations = self.evaluations["full"] + common: dict[str, Any] = { + "fromAssay": self.handoff.assay, + "cellSelection": self.handoff.cellSelection, + "evaluations": evaluations, + "totalCandidates": len(evaluations), + "markerAssay": self.handoff.assay, + "limitations": [ + "Screening comparisons describe their exact sampled cells; final artifacts and validation use the full QC-retained cohort.", + *self.study.limitations, + *unsupported_comparison_limitations(self.design_comparisons), + *( + [_STRUCTURED_VISUAL_LIMITATION] + if any( + row.get("evidenceMode") == "structured" for row in self.history + ) + else [] + ), + *( + f"Unsupported matched protection: {column}." + for column in self.study.unsupportedProtection + ), + *(str(row["reason"]) for row in self.history if "reason" in row), + *( + "Screening coverage concern: " + concern + for row in self.history + for concern in row.get("coverageConcerns", []) + ), + ], + } + common["limitations"] = list(dict.fromkeys(common["limitations"])) + if selected is None: + return ParameterTuningReport( + **common, + status="needsInput", + rationale=reason, + stopReason=reason, + needsInput=ParameterTuningNeedsInput(question=reason), + ) + assert self.last_action is not None + report = ParameterTuningReport( + **common, + status="done", + recommendedCandidateId=selected.candidateId, + selectedArtifacts=selected.artifacts, + confidence="medium", + rationale=self.last_action.rationale, + evidenceIds=self.last_action.evidenceIds, + stopReason="The objective-driven assessment accepted the full-cohort evidence.", + recommendedByAssay={self.handoff.assay: selected.candidateId}, + ) + return finalize_parameter_tuning_selection( + report, marker_assay=self.handoff.assay, native_assay=self.handoff.assay + ) diff --git a/scarf/agent/orchestrator/tuning.py b/scarf/agent/orchestrator/tuning.py index e03ee991..7414227e 100644 --- a/scarf/agent/orchestrator/tuning.py +++ b/scarf/agent/orchestrator/tuning.py @@ -1,104 +1,35 @@ """Sequential RNA parameter tuning and review stages.""" -import hashlib import io -import json -from collections.abc import Callable, Mapping, Sequence -from typing import Any, Literal, cast +from collections.abc import Mapping, Sequence +from typing import Any, cast import numpy as np -from pydantic import Field -from pydantic_ai.exceptions import AgentRunError from ...datastore.datastore import DataStore from ...metadata.rows import read_metadata_rows_chunkwise from ...storage.refs import ArtifactRef from ...storage.selections import read_stored_selection_indices from ...storage.types import as_zarr_array -from ...utils.logging import logger -from .. import record_io from ..config.agent_exec import ( ImageEvidence, - ImageInputUnsupportedError, - build_visual_evidence_prompt, - run_agent_sync, -) -from ..decisions.kernel import DecisionEvidence, DecisionSelection, EvidenceBundle -from ..decisions.rna import ( - ClusterExecutorPayload, - ConditionalGeneFamily, - CorrectionLicensePayload, - CorrectionNeedPayload, - CorrectionOutcomeExecutorPayload, - FeaturePolicyExecutorPayload, - GraphExecutorPayload, - PcaPrefixExecutorPayload, - build_cluster_partition_decision, - build_correction_license_decision, - build_correction_need_decision, - build_correction_outcome_decision, - build_feature_policy_decision, - build_graph_k_decision, - build_pca_prefix_decision, - require_option_evidence, ) from ..experimental_context.contracts import ExperimentalContextResult from ..experimental_context.study import StudyContract -from ..parameter_tuning.agent import ParameterTuningAgent from ..parameter_tuning.contracts import ( ParameterCandidateEvaluation, - ParameterSearchPlan, - ParameterTuningDependencies, ParameterTuningReport, ) -from ..parameter_tuning.diagnostics import ( - SCARF_DEFAULT_DIAGNOSTIC_FAMILIES, - augment_cluster_evaluations, - augment_pca_evaluations, - restore_advisory_doublets, - score_advisory_doublets, -) from ..parameter_tuning.execution import ( _metadata_column_fingerprint, candidate_metric_cache, ) -from ..parameter_tuning.prompts import ( - parameter_search_prompt, - parameter_search_system_prompt, - parameter_tuning_prompt, - parameter_tuning_system_prompt, -) -from ..parameter_tuning.selection import ( - harmony_acceptance_gate, - finalize_parameter_tuning_selection, - pending_parameter_tuning_report, - validate_parameter_tuning_report, -) -from ..parameter_tuning.sequential import ( - CorrectionNeedSelection, - ParameterPhaseEvidence, - ParameterPhasePlan, - ParameterPhaseSelection, - SequentialAssayTuningEvidence, - SequentialRnaTuningPlanner, - execute_parameter_phase, - execute_sequential_refinement, - prepare_sequential_refinement_dependencies, - sequential_evidence_to_report, - validate_parameter_phase_selection, - validate_sequential_refinement_plan, -) -from ..persistence.contracts import ( - AgentInvocation, - AgentReportReference, - AgentWorkflowRun, -) -from ..types import AgentDataModel, ArtifactReferenceModel, ExperimentalTuningHandoff +from .models import StageEvidenceReference, WorkflowIdentity +from ..types import ArtifactReferenceModel from . import journal -from .decisions import DecisionResolution, DecisionStagesMixin +from .decisions import DecisionStagesMixin from .models import ( AutomatedPreprocessingPlan, - AutomatedWorkflowConfig, OrchestrationRequestRecord, OrchestrationResumeRecord, PreprocessedAssayHandoff, @@ -109,336 +40,6 @@ WorkflowStageName, artifact_model_to_ref, ) -from .preprocessing import apply_feature_policy_to_plan - - -def _bounded_evidence_summary(summary: str) -> str: - """Keep prompt-facing evidence within the decision-kernel contract.""" - return summary if len(summary) <= 2_000 else f"{summary[:1_997].rstrip()}..." - - -def _stable_phase_evaluations( - evaluations: Sequence[ParameterCandidateEvaluation], -) -> tuple[ParameterCandidateEvaluation, ...]: - """Give fresh and restored evidence the same persisted mapping order.""" - return tuple( - ParameterCandidateEvaluation.model_validate_json( - record_io.canonical_json_bytes(evaluation.model_dump(mode="json")) - ) - for evaluation in evaluations - ) - - -def _cluster_review_values( - evaluation: ParameterCandidateEvaluation, -) -> tuple[dict[str, float], dict[str, float]]: - metrics = evaluation.metrics - maximize = { - "silhouette": metrics.graphSilhouetteMedian or 0.0, - "seedStability": metrics.seedStability or 0.0, - "subsampleStability": metrics.subsampleStability or 0.0, - "markerCoherence": metrics.markerCoherence or 0.0, - "markerSpecificity": metrics.markerSpecificityMedian or 0.0, - "crossUnitSupport": metrics.crossUnitSupport or 0.0, - "membershipStrength": metrics.membershipStrengthMean or 0.0, - "clusterConnectivity": metrics.clusterConnectivity or 0.0, - "minimumClusterFraction": metrics.minClusterFraction or 0.0, - } - minimize = { - "technicalAssociation": max( - metrics.technicalAssociation.values(), - default=0.0, - ), - "doubletConcentration": ( - metrics.doubletHighScoreConcentration - if metrics.doubletHighScoreConcentration is not None - else 1.0 - ), - "protectedAssociation": max( - metrics.protectedPcaAssociation.values(), - default=0.0, - ), - } - return maximize, minimize - - -def _changed_analysis_checkpoint( - candidate: ParameterCandidateEvaluation, - selected: ParameterCandidateEvaluation, -) -> ( - Literal[ - "pcaPrefix", - "correctionOutcome", - "graphK", - "clusterPartition", - ] - | None -): - changed = [ - checkpoint - for checkpoint, differs in ( - ( - "pcaPrefix", - candidate.parameters.dimensions != selected.parameters.dimensions, - ), - ( - "correctionOutcome", - candidate.parameters.useHarmony != selected.parameters.useHarmony, - ), - ( - "graphK", - candidate.parameters.neighborsK != selected.parameters.neighborsK, - ), - ( - "clusterPartition", - candidate.parameters.leidenResolution - != selected.parameters.leidenResolution, - ), - ) - if differs - ] - return cast(Any, changed[0]) if len(changed) == 1 else None - - -def _analysis_parameter_value( - checkpoint: str, - evaluation: ParameterCandidateEvaluation, -) -> int | float | bool: - if checkpoint == "pcaPrefix": - return evaluation.parameters.dimensions - if checkpoint == "correctionOutcome": - return evaluation.parameters.useHarmony - if checkpoint == "graphK": - return evaluation.parameters.neighborsK - if checkpoint == "clusterPartition": - return evaluation.parameters.leidenResolution - raise ValueError(f"Unknown analysis checkpoint {checkpoint!r}") - - -def _dominates_analysis_choice( - candidate: ParameterCandidateEvaluation, - selected: ParameterCandidateEvaluation, - *, - tolerance: float = 0.02, - material: float = 0.05, -) -> bool: - """Admit only a one-checkpoint alternative with independently better evidence.""" - checkpoint = _changed_analysis_checkpoint(candidate, selected) - if ( - candidate.candidateId == selected.candidateId - or candidate.status != "done" - or not candidate.eligible - or checkpoint is None - or (candidate.parameters.useHarmony and not selected.parameters.useHarmony) - ): - return False - candidate_max, candidate_min = _cluster_review_values(candidate) - selected_max, selected_min = _cluster_review_values(selected) - no_worse = all( - candidate_max[name] >= selected_max[name] - tolerance for name in candidate_max - ) and all( - candidate_min[name] <= selected_min[name] + tolerance for name in candidate_min - ) - independently_better = sum( - candidate_max[name] > selected_max[name] + material for name in candidate_max - ) + sum( - candidate_min[name] < selected_min[name] - material for name in candidate_min - ) - return no_worse and independently_better >= 2 - - -class AnalysisVisualAdjudication(AgentDataModel): - """Bounded interpretation of the supplied analysis diagnostics.""" - - status: Literal["acceptable", "concern"] = "concern" - selectedCandidateId: str = "" - featureLevelFindings: list[str] = Field(default_factory=list) - rationale: str = "" - - -_NUMERIC_REVIEW_CANDIDATE_LIMIT = 24 -_NUMERIC_REVIEW_METRICS = ( - "nClusters", - "minClusterCells", - "minClusterFraction", - "graphSilhouetteMedian", - "membershipStrengthMean", - "membershipStrengthP10", - "clusterConnectivity", - "seedStability", - "subsampleStability", - "markerCoherence", - "markerSpecificityMedian", - "crossUnitSupport", - "technicalAssociation", - "batchMixing", - "biologicalPreservation", - "qcPcaAssociation", - "doubletHighScoreConcentration", - "doubletScoreQuantiles", - "doubletCaptureCoverage", - "loadingFamilyEnrichment", - "markerFamilyEnrichment", - "paretoOptimal", - "dominatedByCandidateIds", - "dominatesCandidateIds", -) - - -def _numeric_analysis_review_payload( - study_objective: str, - selected: ParameterCandidateEvaluation, - candidates: Sequence[ParameterCandidateEvaluation], -) -> dict[str, Any]: - comparisons = [ - candidate - for candidate in candidates - if candidate.candidateId != selected.candidateId - and candidate.status == "done" - and candidate.eligible - and _changed_analysis_checkpoint(candidate, selected) is not None - ] - checkpoint_order = { - "pcaPrefix": 0, - "correctionOutcome": 1, - "graphK": 2, - "clusterPartition": 3, - } - comparisons.sort( - key=lambda candidate: ( - checkpoint_order[ - cast(str, _changed_analysis_checkpoint(candidate, selected)) - ], - float( - _analysis_parameter_value( - cast(str, _changed_analysis_checkpoint(candidate, selected)), - candidate, - ) - ), - candidate.candidateId, - ) - ) - - def compact_candidate( - candidate: ParameterCandidateEvaluation, - ) -> dict[str, Any]: - metrics = candidate.metrics.model_dump(mode="json", exclude_none=True) - return { - "candidateId": candidate.candidateId, - "changedCheckpoint": _changed_analysis_checkpoint(candidate, selected), - "parameters": candidate.parameters.model_dump(mode="json"), - "effectiveDimensions": candidate.effectiveDimensions, - "metrics": { - name: metrics[name] - for name in _NUMERIC_REVIEW_METRICS - if name in metrics and metrics[name] not in ({}, []) - }, - "warnings": list(candidate.warnings), - } - - return { - "studyObjective": study_objective, - "evidenceMode": "numeric", - "evidenceLimitation": ( - "The configured model did not accept image input. Spatial and visual " - "distribution patterns are unavailable for this review." - ), - "selectedCandidate": { - "candidateId": selected.candidateId, - "parameters": selected.parameters.model_dump(mode="json"), - "effectiveDimensions": selected.effectiveDimensions, - "metrics": selected.metrics.model_dump(mode="json", exclude_none=True), - "warnings": list(selected.warnings), - }, - "comparisonCandidates": [ - compact_candidate(candidate) - for candidate in comparisons[:_NUMERIC_REVIEW_CANDIDATE_LIMIT] - ], - "comparisonCandidateCount": len(comparisons), - "includedComparisonCandidateCount": min( - len(comparisons), - _NUMERIC_REVIEW_CANDIDATE_LIMIT, - ), - } - - -def _run_analysis_adjudication( - *, - model: Any, - config: AutomatedWorkflowConfig, - study_objective: str, - selected: ParameterCandidateEvaluation, - candidates: Sequence[ParameterCandidateEvaluation], - visual_content: Sequence[ImageEvidence], -) -> tuple[AnalysisVisualAdjudication, Literal["multimodal", "numeric"]]: - def validate(value: AnalysisVisualAdjudication) -> AnalysisVisualAdjudication: - if ( - value.selectedCandidateId != selected.candidateId - or not value.rationale.strip() - ): - raise ValueError( - "Analysis review must identify the exact selected candidate " - "and provide a rationale" - ) - return value - - visual_payload = { - "studyObjective": study_objective, - "selectedCandidateId": selected.candidateId, - "metrics": selected.metrics.model_dump(mode="json"), - "warnings": selected.warnings, - } - try: - execution = run_agent_sync( - model=model, - output_type=AnalysisVisualAdjudication, - system_prompt=( - "Adjudicate the bounded diagnostic board together with the supplied " - "exact metrics. Report only feature-level, partition-level, batch, " - "QC, and doublet findings. Do not assign cell types. Mark concern " - "only when an image shows a specific conflict with numeric evidence." - ), - user_prompt=build_visual_evidence_prompt( - json.dumps(visual_payload, indent=2, sort_keys=True), - visual_content, - ), - config=config.agentRunConfig, - name="analysis_visual_review", - output_validator=validate, - ) - mode: Literal["multimodal", "numeric"] = "multimodal" - except ImageInputUnsupportedError: - logger.info( - "The configured model does not accept image input; retrying analysis " - "review with exact numeric evidence" - ) - execution = run_agent_sync( - model=model, - output_type=AnalysisVisualAdjudication, - system_prompt=( - "Adjudicate the selected analysis using only the supplied exact " - "numeric evidence. Evaluate feature, partition, batch, QC, and " - "doublet measurements. Do not infer spatial patterns or cell types. " - "Mark concern only when a supplied measurement conflicts with the " - "selected analysis." - ), - user_prompt=json.dumps( - _numeric_analysis_review_payload( - study_objective, - selected, - candidates, - ), - indent=2, - sort_keys=True, - ), - config=config.agentRunConfig, - name="analysis_numeric_review", - output_validator=validate, - ) - mode = "numeric" - if not isinstance(execution.output, AnalysisVisualAdjudication): - raise TypeError("Analysis review returned an unexpected output type") - return execution.output, mode def _analysis_visual_content( @@ -525,6 +126,9 @@ def sampled_values(array: Any, selection: tuple[Any, ...]) -> np.ndarray: and candidate.parameters.dimensions == selected.parameters.dimensions and candidate.parameters.neighborsK == selected.parameters.neighborsK and candidate.parameters.useHarmony == selected.parameters.useHarmony + and candidate.artifacts.get("graphFeatures") + == selected.artifacts.get("graphFeatures") + and candidate.cellSelection == selected.cellSelection ), key=lambda value: ( value.metrics.markerCoherence or 0.0, @@ -722,7 +326,7 @@ def sampled_values(array: Any, selection: tuple[Any, ...]) -> np.ndarray: tuple[ParameterCandidateEvaluation, ParameterCandidateEvaluation] ] = [] candidates_by_parameters: dict[ - tuple[str, int, int, float], + tuple[str, int, int, float, str, str], dict[bool, ParameterCandidateEvaluation], ] = {} for candidate in candidates: @@ -733,6 +337,12 @@ def sampled_values(array: Any, selection: tuple[Any, ...]) -> np.ndarray: candidate.parameters.dimensions, candidate.parameters.neighborsK, candidate.parameters.leidenResolution, + candidate.artifacts["graphFeatures"].model_dump_json() + if "graphFeatures" in candidate.artifacts + else "", + candidate.cellSelection.model_dump_json() + if candidate.cellSelection is not None + else "", ) candidates_by_parameters.setdefault(key, {})[ candidate.parameters.useHarmony @@ -1102,3630 +712,167 @@ def tagged_gene(gene: str) -> str: class TuningStagesMixin(DecisionStagesMixin): - """Execute parameter searches, integration comparisons, and graph selection.""" - - model: Any - - @staticmethod - def _tuning_evidence_bundle( - decision_id: str, - evidence: list[DecisionEvidence], - ) -> EvidenceBundle: - digest = hashlib.sha256( - record_io.canonical_json_bytes( - [item.model_dump(mode="json") for item in evidence] - ) - ).hexdigest() - return EvidenceBundle( - bundleId=f"bundle:{decision_id}:{digest[:24]}", - decisionId=decision_id, - evidence=evidence, - ).with_content_sha256() - - @staticmethod - def _evaluation_artifacts( - evaluation: Any, - ) -> list[ArtifactReferenceModel]: - references: list[ArtifactReferenceModel] = [] - identities: set[tuple[str, str | None, str, str]] = set() - for name in sorted(evaluation.artifacts): - reference = ArtifactReferenceModel.model_validate( - evaluation.artifacts[name].model_dump() - ) - identity = ( - reference.scope, - reference.assay, - reference.kind, - reference.artifactId, - ) - if identity not in identities: - identities.add(identity) - references.append(reference) - return references - - def _analysis_candidate_evidence( - self, - checkpoint: str, - evaluation: ParameterCandidateEvaluation, - ) -> list[DecisionEvidence]: - metrics = evaluation.metrics - artifacts = self._evaluation_artifacts(evaluation) - summaries = { - "geometric": ( - f"candidate={evaluation.candidateId}; dimensions=" - f"{evaluation.parameters.dimensions}; neighbors=" - f"{evaluation.parameters.neighborsK}; resolution=" - f"{evaluation.parameters.leidenResolution}; silhouette=" - f"{metrics.graphSilhouetteMedian}; connectivity=" - f"{metrics.clusterConnectivity}; membership=" - f"{metrics.membershipStrengthMean}; minimum cluster fraction=" - f"{metrics.minClusterFraction}." - ), - "technical": ( - f"batch PC association={metrics.batchPcaAssociation}; technical " - f"PC association={metrics.technicalPcaAssociation}; QC PC " - f"association={metrics.qcPcaAssociation}; neighbour-prefix " - f"overlap={metrics.neighborPrefixOverlap}." - ), - "batchRemoval": ( - f"Harmony={evaluation.parameters.useHarmony}; batch mixing=" - f"{metrics.batchMixing}." - ), - "biologicalConservation": ( - f"biological preservation={metrics.biologicalPreservation}; " - f"marker coherence={metrics.markerCoherence}; marker specificity=" - f"{metrics.markerSpecificityMedian}; cross-unit support=" - f"{metrics.crossUnitSupport}." - ), - "protectedVariablePreservation": ( - f"protected PC association={metrics.protectedPcaAssociation}; " - f"protected marker families={metrics.protectedMarkerFamilies}." - ), - "markerCoherence": ( - f"marker coherence={metrics.markerCoherence}; specificity=" - f"{metrics.markerSpecificityMedian}; family enrichment=" - f"{metrics.markerFamilyEnrichment}." - ), - "resamplingStability": ( - f"seed ARI={metrics.seedStability}; subsample ARI=" - f"{metrics.subsampleStability}." - ), - "crossUnitSupport": ( - f"cross-unit support={metrics.crossUnitSupport}; technical " - f"association={metrics.technicalAssociation}." - ), - "qualityControl": ( - f"doublet concentration={metrics.doubletHighScoreConcentration}; " - f"doublet capture coverage={metrics.doubletCaptureCoverage}; " - f"score quantiles={metrics.doubletScoreQuantiles}." - ), - } - return [ - DecisionEvidence( - evidenceId=( - f"evidence:analysisReview:{checkpoint}:" - f"{evaluation.candidateId}:{evidence_class}" - ), - evidenceClass=cast(Any, evidence_class), - summary=_bounded_evidence_summary(summary), - artifactReferences=artifacts, - ) - for evidence_class, summary in summaries.items() - ] - - @staticmethod - def _payload_option_id( - definition: Any, - payload_type: type[Any], - field_name: str, - value: Any, - ) -> str: - matches = [ - option.optionId - for option in definition.executorOptions - if isinstance(option.payload, payload_type) - and getattr(option.payload, field_name) == value - ] - if len(matches) != 1: - raise ValueError( - f"Decision {definition.spec.decisionId!r} lacks one exact " - f"{field_name!r} option for {value!r}" - ) - return cast(str, matches[0]) - - def _restore_tuning_descendants( - self, - store: DataStore, - request_record: OrchestrationRequestRecord, - study_contract: StudyContract, - replacement: ParameterCandidateEvaluation, - *, - revised_checkpoint: str, - previous_options: Mapping[str, str], - model_name: str | None, - ) -> str: - """Recreate invalidated tuning decisions from one executed replacement.""" - order = ( - "pcaPrefix", - "correctionLicense", - "correctionNeed", - "correctionOutcome", - "graphK", - "clusterPartition", - ) - try: - start = order.index(revised_checkpoint) + 1 - except ValueError as exc: - raise ValueError( - f"Unknown revised tuning checkpoint {revised_checkpoint!r}" - ) from exc - latest_snapshot = "" - - def resolve( - definition: Any, - evidence: list[DecisionEvidence], - option_id: str, - *, - rule_owned: bool = False, - ) -> None: - nonlocal latest_snapshot - bundle = self._tuning_evidence_bundle( - definition.spec.decisionId, - evidence, - ) - if definition.spec.evidenceBundleId != bundle.bundleId: - raise ValueError("Successor definition has stale evidence identity") - selection = DecisionSelection( - selectedOptionId=option_id, - evidenceIds=[value.evidenceId for value in evidence], - rationale=( - "The upstream analysis decision changed, so this descendant " - "was recomputed from the exact executed replacement candidate." - ), - confidence="medium", - ) - resolved = self._resolve_rna_decision( - store, - request_record, - definition, - bundle, - {}, - **( - {"rule_selection": selection} - if rule_owned - else { - "agent_selection": selection, - "agent_model_name": model_name, - } - ), - ) - if resolved.compiled is None or resolved.record is None: - raise RuntimeError( - f"Successor {definition.spec.decisionId!r} did not resolve" - ) - latest_snapshot = resolved.snapshotSha256 - - artifacts = self._evaluation_artifacts(replacement) - generic = self._analysis_candidate_evidence( - "successor", - replacement, - ) - if "correctionLicense" in order[start:]: - license_evidence = [ - DecisionEvidence( - evidenceId=("evidence:analysisSuccessor:correctionLicense:design"), - evidenceClass="design", - summary=( - "The validated study contract licenses correction as " - f"{study_contract.correctionLicense!r} with technical " - f"columns {study_contract.technicalBatchColumns} and " - f"protected columns {study_contract.protectedColumns}." - ), - artifactReferences=artifacts, - ) - ] - license_bundle = self._tuning_evidence_bundle( - "correctionLicense", - license_evidence, - ) - license_definition = build_correction_license_decision( - evidence_bundle_id=license_bundle.bundleId, - license=cast(Any, study_contract.correctionLicense), - ) - resolve( - license_definition, - license_evidence, - f"correctionLicense:{study_contract.correctionLicense}", - rule_owned=True, - ) - - need_value: Literal["needed", "notNeeded"] | None = None - previous_need = previous_options.get("correctionNeed") - if study_contract.correctionLicense == "safe": - need_value = ( - "needed" - if previous_need == "correctionNeed:needed" - or replacement.parameters.useHarmony - else "notNeeded" - ) - if "correctionNeed" in order[start:] and need_value is not None: - need_evidence = [ - value - for value in generic - if value.evidenceClass in {"batchRemoval", "biologicalConservation"} - ] - need_bundle = self._tuning_evidence_bundle( - "correctionNeed", - need_evidence, - ) - need_definition = build_correction_need_decision( - evidence_bundle_id=need_bundle.bundleId, - license="safe", - ) - resolve( - need_definition, - need_evidence, - f"correctionNeed:{need_value}", - ) - - if "correctionOutcome" in order[start:]: - outcome_evidence = [ - value - for value in generic - if value.evidenceClass - in { - "batchRemoval", - "biologicalConservation", - "protectedVariablePreservation", - } - ] - outcome_bundle = self._tuning_evidence_bundle( - "correctionOutcome", - outcome_evidence, - ) - outcome_definition = build_correction_outcome_decision( - evidence_bundle_id=outcome_bundle.bundleId, - license=cast(Any, study_contract.correctionLicense), - need=need_value, - harmony_eligible=True, - ) - outcome_id = ( - "correctionOutcome:acceptHarmony" - if replacement.parameters.useHarmony - else "correctionOutcome:retainNative" - ) - resolve( - outcome_definition, - outcome_evidence, - outcome_id, - rule_owned=outcome_definition.spec.allowedSources == ["rule"], - ) - - if "graphK" in order[start:]: - graph_evidence = [ - value for value in generic if value.evidenceClass == "geometric" - ] - graph_bundle = self._tuning_evidence_bundle("graphK", graph_evidence) - graph_definition = build_graph_k_decision( - evidence_bundle_id=graph_bundle.bundleId, - n_cells=max( - replacement.parameters.neighborsK + 1, - replacement.metrics.minClusterCells or 3, - ), - candidate_neighbors=[replacement.parameters.neighborsK], - ) - graph_option = self._payload_option_id( - graph_definition, - GraphExecutorPayload, - "neighborsK", - replacement.parameters.neighborsK, - ) - resolve( - graph_definition, - graph_evidence, - graph_option, - ) - - if "clusterPartition" in order[start:]: - cluster_evidence = [ - value for value in generic if value.evidenceClass == "geometric" - ] - cluster_bundle = self._tuning_evidence_bundle( - "clusterPartition", - cluster_evidence, - ) - resolution = replacement.parameters.leidenResolution - known_ids = { - 0.25: "clusterResolution:veryCoarse", - 0.5: "clusterResolution:coarse", - 0.75: "clusterResolution:balanced", - 1.0: "clusterResolution:detailed", - 1.25: "clusterResolution:fine", - 1.5: "clusterResolution:veryFine", - } - preferred = known_ids.get( - resolution, - f"clusterResolution:r{str(resolution).replace('.', 'p')}", - ) - cluster_definition = build_cluster_partition_decision( - evidence_bundle_id=cluster_bundle.bundleId, - metric_preferred_option_id=preferred, - resolution_candidates=[resolution], - ) - resolve( - cluster_definition, - cluster_evidence, - preferred, - ) - return latest_snapshot - - @staticmethod - def _phase_from_resolution( - plan: ParameterPhasePlan, - evaluations: Sequence[Any], - resolution: DecisionResolution, - *, - payload_field: str, - payload_value: Any, - ) -> ParameterPhaseEvidence: - if resolution.compiled is None or resolution.record is None: - pending = resolution.pending - selection = ParameterPhaseSelection( - phase=plan.phase, - status="needsInput", - rationale=( - pending.reason - if pending is not None - else "The registered decision is unresolved." - ), - ) - return validate_parameter_phase_selection(plan, evaluations, selection) - selected = next( - ( - evaluation - for evaluation in evaluations - if getattr(evaluation.parameters, payload_field) == payload_value - and evaluation.status == "done" - and evaluation.eligible - ), - None, - ) - if selected is None: - raise ValueError( - "Audited RNA decision has no eligible exact candidate execution" - ) - selection = ParameterPhaseSelection( - phase=plan.phase, - status="selected", - selectedCandidateId=selected.candidateId, - evidenceIds=list(resolution.record.evidenceIds), - rationale=resolution.record.rationale, - ) - return validate_parameter_phase_selection(plan, evaluations, selection) + """Run bounded experiments and return validated full-cohort artifacts.""" - def _run_sequential_rna_tuning( + def parameter_tuning_stage( self, store: DataStore, - workflow: AgentWorkflowRun, + workflow: WorkflowIdentity, request_record: OrchestrationRequestRecord, + parents: Sequence[WorkflowStageLink], plan: AutomatedPreprocessingPlan, preprocessed: Sequence[PreprocessedAssayHandoff], - experimental_handoff: ExperimentalTuningHandoff, - study_contract: StudyContract, + experimental: ExperimentalContextResult, + enrichment_reference: StageEvidenceReference, + experimental_reference: StageEvidenceReference, answers: Mapping[str, Any], - prior: SequentialAssayTuningEvidence | None, - ) -> tuple[ParameterTuningReport, SequentialAssayTuningEvidence]: - if len(preprocessed) != 1 or plan.pairedAssays: - raise ValueError("Decision-driven v1 tuning accepts one RNA assay only") + *, + study_contract: StudyContract | None = None, + resume_record: OrchestrationResumeRecord | None = None, + stage_name: WorkflowStageName = "parameter_tuning", + ) -> tuple[WorkflowStageAttempt, ParameterTuningReport]: + from .rna_tuning import RnaTuningRun + + del enrichment_reference, experimental_reference + if len(preprocessed) != 1 or study_contract is None: + raise ValueError("RNA tuning requires one assay and a study contract") handoff = preprocessed[0] - if ( - handoff.assayType != "RNA" - or handoff.cellSelection is None - or handoff.normalized is None - or handoff.graphFeatures is None - or handoff.markerFeatures is None + if handoff.cellSelection is None: + raise ValueError("RNA tuning requires a frozen full-cohort selection") + columns = { + *study_contract.technicalBatchColumns, + *study_contract.protectedColumns, + *study_contract.independentUnitColumns, + *study_contract.conditionColumns, + *plan.cellQc.attributes, + } + if study_contract.physicalCaptureColumn is not None: + columns.add(study_contract.physicalCaptureColumn) + metadata_fingerprints = { + column: _metadata_column_fingerprint(store.cells, column) + for column in sorted(columns) + if column in store.cells.columns + } + feature_metadata = store.get_assay(plan.primaryAssay).feats + feature_fingerprints = { + column: _metadata_column_fingerprint(feature_metadata, column) + for column in ("ids", "names") + } + inputs = { + "preprocessedAssays": [handoff.model_dump(mode="json")], + "studyContract": study_contract.model_dump(mode="json"), + "metadataFingerprints": metadata_fingerprints, + "featureMetadataFingerprints": feature_fingerprints, + } + prefix = journal._ensure_orchestration_store(store) + for previous in journal._stage_starts( + store.zw, prefix, workflow.workflowRunId, stage_name ): - raise ValueError("Decision-driven v1 tuning requires normalized RNA") - if prior is not None and prior.assay != handoff.assay: - raise ValueError("Persisted sequential evidence belongs to another assay") - selected_cells = artifact_model_to_ref(handoff.cellSelection) - prior_phases = ( - {value.plan.phase: value for value in prior.phases} - if prior is not None - else {} - ) - - def phase_evaluations( - phase_plan: ParameterPhasePlan, - execute: Callable[[], Sequence[ParameterCandidateEvaluation]], - ) -> tuple[ParameterCandidateEvaluation, ...]: - persisted = prior_phases.get(phase_plan.phase) - if persisted is None: - return tuple(execute()) - if persisted.plan != phase_plan: - raise ValueError( - f"Persisted {phase_plan.phase!r} plan differs from the " - "current registered plan" - ) - for evaluation in persisted.evaluations: - if evaluation.cellSelection is not None and ( - artifact_model_to_ref(evaluation.cellSelection) != selected_cells - ): - raise ValueError("Persisted tuning evidence uses different cells") - for artifact in evaluation.artifacts.values(): - status = store.inspect_artifact(artifact_model_to_ref(artifact)) - if not status.exists or not status.complete: - raise ValueError( - "Persisted tuning evidence contains unavailable artifacts" - ) - logger.info( - f"Workflow {workflow.workflowRunId}: reusing persisted " - f"{phase_plan.phase} executor evidence" - ) - return tuple(persisted.evaluations) - - diagnostic_batch_candidates = ( - tuple(study_contract.technicalBatchColumns) - if study_contract.correctionLicense == "safe" - and experimental_handoff.batchAction == "evaluateHarmony" - else ( - ( - study_contract.physicalCaptureColumn, - *study_contract.technicalBatchColumns, - ) - if request_record.config.runConfoundedHarmonyDiagnostic - else tuple(study_contract.technicalBatchColumns) - ) - ) - diagnostic_batch_columns = [ - column - for column in dict.fromkeys(diagnostic_batch_candidates) - if column is not None - and column in store.cells.columns - and len(np.unique(store.cells.fetch(column, key="I"))) > 1 - ] - selectable_harmony = bool( - study_contract.correctionLicense == "safe" - and experimental_handoff.batchAction == "evaluateHarmony" - and diagnostic_batch_columns - and sorted(diagnostic_batch_columns) - == sorted(experimental_handoff.batchColumns) - ) - evaluate_harmony = bool( - diagnostic_batch_columns - and request_record.config.maxHarmonyCandidatesPerAssay == 1 - and ( - selectable_harmony - or request_record.config.runConfoundedHarmonyDiagnostic - ) - ) - tuning_handoff = experimental_handoff if selectable_harmony else None - planner = SequentialRnaTuningPlanner( - workflow_run_id=workflow.workflowRunId, - assay=handoff.assay, - n_cells=handoff.nCells, - n_features=handoff.nFeatures, - harmony_authorized=evaluate_harmony, - dimension_candidates=request_record.config.pcaCandidateDimensions, - neighbor_candidates=request_record.config.graphNeighborCandidates, - resolution_candidates=request_record.config.leidenResolutionCandidates, - ) - phase_evidence: list[ParameterPhaseEvidence] = [] - decision_sources: dict[ - str, - Literal["rule", "agent", "human"], - ] = {} - correction_need_selection: CorrectionNeedSelection | None = None - - def build_state( - *, - pending_resolution: DecisionResolution | None = None, - correction_license: str = "notApplicable", - final_candidate_id: str | None = None, - ) -> SequentialAssayTuningEvidence: - pending = ( - pending_resolution.pending if pending_resolution is not None else None - ) - if pending_resolution is not None and pending is None: + scientific_inputs = { + key: value + for key, value in previous.inputs.items() + if key not in {"resumeAnswers", "answeredAttempt"} + } + if scientific_inputs != inputs: raise ValueError( - "Pending tuning resolution lacks pending decision data" + "Tuning inputs changed since saved evidence was computed; " + "restore the original metadata or start a new workflow" ) - return SequentialAssayTuningEvidence.model_validate( - { - "assay": handoff.assay, - "phases": [ - value.model_dump(mode="json") for value in phase_evidence - ], - "correctionLicense": correction_license, - "correctionNeed": ( - correction_need_selection.model_dump(mode="json") - if correction_need_selection is not None - else None - ), - "decisionSources": decision_sources, - "pendingDecisionId": ( - pending.decisionId if pending is not None else None - ), - "pendingOptionIds": ( - pending.offeredOptionIds if pending is not None else [] - ), - "pendingEvidenceIds": ( - pending.availableEvidenceIds if pending is not None else [] - ), - "finalCandidateId": final_candidate_id, - } - ) - - def return_pending( - resolution: DecisionResolution, - *, - correction_license: str = "notApplicable", - ) -> tuple[ParameterTuningReport, SequentialAssayTuningEvidence]: - state = build_state( - pending_resolution=resolution, - correction_license=correction_license, - ) - return ( - sequential_evidence_to_report( - state, - marker_assay=plan.markerAssay, - ), - state, - ) - - normalized = artifact_model_to_ref(handoff.normalized) - assay_plan = next( - value for value in plan.assays if value.assay == handoff.assay - ) - nominated_families = cast( - list[str], - assay_plan.featureParameters.get("proposedExcludeFamilies", []), - ) - protected_families = cast( - list[str], - assay_plan.featureParameters.get("protectFamilies", []), + existing = journal._validated_done_outcome( + store, + prefix, + workflow.workflowRunId, + stage_name, + request_record, + parents, ) - diagnostic_families = list( - dict.fromkeys( - [ - *SCARF_DEFAULT_DIAGNOSTIC_FAMILIES, - *nominated_families, - ] + if existing is not None: + return existing, cast( + ParameterTuningReport, + journal.load_stage_report(store, existing, ParameterTuningReport), ) + started = journal._start_attempt( + store.zw, + prefix, + workflow.workflowRunId, + stage_name, + request_record, + parents, + inputs=inputs, + resume_record=resume_record, ) - pca_plan = planner.pca_prefix_phase() - raw_pca = phase_evaluations( - pca_plan, - lambda: execute_parameter_phase( - store, - normalized=normalized, - plan=pca_plan, - batch_columns=diagnostic_batch_columns, - preservation_columns=experimental_handoff.preservationColumns, - experimental_handoff=tuning_handoff, - min_cluster_cells=request_record.config.minClusterCells, - identity_feature_limit=request_record.config.maxIdentityFeatures, - ), + runner = RnaTuningRun( + self, + store, + workflow, + request_record, + plan, + handoff, + study_contract, + answers, + { + **inputs, + "requestSha256": request_record.requestSha256, + "configSha256": request_record.configSha256, + }, + design_comparisons=experimental.characterization.comparisons, ) - if "pcaPrefix" not in prior_phases: - raw_pca = augment_pca_evaluations( + try: + with candidate_metric_cache(): + report, evidence = runner.run() + saved_report, reference = journal._save_stage_report( store, - raw_pca, - feature_selection=artifact_model_to_ref(handoff.graphFeatures), - nominated_families=diagnostic_families, - protected_families=protected_families, - technical_columns=diagnostic_batch_columns, - batch_columns=diagnostic_batch_columns, - protected_columns=study_contract.protectedColumns, - qc_columns=[ - column - for column in plan.cellQc.attributes - if column in store.cells.columns - ], + started, + report, + expected_type=ParameterTuningReport, + attempt_owned=True, ) - pca_items: list[DecisionEvidence] = [] - pca_evaluations: list[ParameterCandidateEvaluation] = [] - eligible_pca_dimensions: list[int] = [] - pca_evidence_by_dimensions: dict[int, list[str]] = {} - for evaluation in _stable_phase_evaluations(raw_pca): - evidence_ids: list[str] = [] - if evaluation.status == "done" and evaluation.eligible: - eligible_pca_dimensions.append(evaluation.parameters.dimensions) - technical_id = f"evidence:pca:{evaluation.candidateId}:technical" - loading_preview = { - component: genes[:3] - for component, genes in sorted( - evaluation.metrics.topLoadingGenes.items(), - key=lambda item: int(item[0].removeprefix("PC")), - )[:10] - } - cumulative_variance = ( - evaluation.metrics.pcaCumulativeExplainedVarianceRatio[-1] - if evaluation.metrics.pcaCumulativeExplainedVarianceRatio - else None - ) - pca_summary = _bounded_evidence_summary( - f"The exact PCA candidate used " - f"{evaluation.effectiveDimensions} dimensions; " - f"first component variances=" - f"{evaluation.metrics.componentVariance[:10]}; " - f"first explained variance ratios=" - f"{evaluation.metrics.pcaExplainedVarianceRatio[:10]}; " - f"total cumulative explained variance={cumulative_variance}; " - f"top loading-gene preview={loading_preview}; " - "maximum default/context-family loading enrichment=" - f"{evaluation.metrics.loadingFamilyEnrichment}; " - "technical PC association=" - f"{evaluation.metrics.technicalPcaAssociation}; " - "protected PC association=" - f"{evaluation.metrics.protectedPcaAssociation}; " - f"QC PC association={evaluation.metrics.qcPcaAssociation}; " - f"warnings={evaluation.warnings}." - ) - pca_items.append( - DecisionEvidence( - evidenceId=technical_id, - evidenceClass="technical", - summary=pca_summary, - artifactReferences=self._evaluation_artifacts(evaluation), - ) - ) - evidence_ids.append(technical_id) - geometric_id = f"evidence:pca:{evaluation.candidateId}:geometric" - pca_items.append( - DecisionEvidence( - evidenceId=geometric_id, - evidenceClass="geometric", - summary=( - "PCA and graph silhouette diagnostics are " - f"{evaluation.metrics.pcaSilhouette} and " - f"{evaluation.metrics.graphSilhouetteMedian}; " - f"the registered graph produced " - f"{evaluation.metrics.nClusters} clusters; adjacent-prefix " - "neighbor overlap=" - f"{evaluation.metrics.neighborPrefixOverlap}." - ), - artifactReferences=self._evaluation_artifacts(evaluation), - ) - ) - evidence_ids.append(geometric_id) - pca_evidence_by_dimensions[evaluation.parameters.dimensions] = list( - evidence_ids - ) - else: - failure_id = f"evidence:pca:{evaluation.candidateId}:failure" - pca_items.append( - DecisionEvidence( - evidenceId=failure_id, - evidenceClass="other", - summary=_bounded_evidence_summary( - f"The candidate was not eligible: " - f"{evaluation.error or evaluation.eligibilityReasons}." - ), - artifactReferences=self._evaluation_artifacts(evaluation), - ) + report = cast(ParameterTuningReport, saved_report) + artifacts = { + f"{candidate.candidateId}:{name}": ArtifactReferenceModel.model_validate( + value.model_dump() ) - evidence_ids.append(failure_id) - pca_evaluations.append( - evaluation.model_copy( - update={ - "evidenceIds": list( - dict.fromkeys([*evaluation.evidenceIds, *evidence_ids]) + for candidate in report.evaluations + for name, value in candidate.artifacts.items() + } + pending = None + if report.status == "needsInput": + assert report.needsInput is not None + pending = WorkflowNeedsInput( + questions=[ + WorkflowQuestion( + questionId="parameter_tuning", + question=report.needsInput.question, + options=report.needsInput.options, + evidenceIds=report.needsInput.evidenceIds, ) - } + ] ) - ) - pca_bundle = self._tuning_evidence_bundle("pcaPrefix", pca_items) - pca_definition = build_pca_prefix_decision( - evidence_bundle_id=pca_bundle.bundleId, - matrix_rank=min(handoff.nCells, handoff.nFeatures) - 1, - candidate_dimensions=( - eligible_pca_dimensions - if eligible_pca_dimensions - else [candidate.dimensions for candidate in pca_plan.candidates] - ), - ) - pca_definition = require_option_evidence( - pca_definition, - { - option.optionId: pca_evidence_by_dimensions[option.payload.dimensions] - for option in pca_definition.executorOptions - if isinstance(option.payload, PcaPrefixExecutorPayload) - and option.payload.dimensions in pca_evidence_by_dimensions - }, - ) - pca_rule_selection = ( - DecisionSelection( - selectedOptionId="pcaPrefix:defer", - evidenceIds=[item.evidenceId for item in pca_bundle.evidence], - rationale=( - "No registered PCA candidate completed with the required " - "technical and geometric evidence." - ), - confidence="notApplicable", - ) - if not eligible_pca_dimensions - else None - ) - pca_resolution = self._resolve_rna_decision( - store, - request_record, - pca_definition, - pca_bundle, - answers, - rule_selection=pca_rule_selection, - ) - pca_payload = ( - pca_resolution.compiled.executorPayload - if pca_resolution.compiled is not None - else None - ) - if pca_payload is not None and not isinstance( - pca_payload, PcaPrefixExecutorPayload - ): - raise TypeError("PCA decision compiled an unexpected payload") - pca_phase = self._phase_from_resolution( - pca_plan, - pca_evaluations, - pca_resolution, - payload_field="dimensions", - payload_value=(pca_payload.dimensions if pca_payload is not None else -1), - ) - phase_evidence.append(pca_phase) - if pca_resolution.record is not None: - decision_sources["pcaPrefix"] = pca_resolution.record.source - selected_pca = pca_phase.selected_evaluation() - if selected_pca is None: - return return_pending(pca_resolution) - - license_evidence_id = "evidence:correctionLicense:studyContract" - license_bundle = self._tuning_evidence_bundle( - "correctionLicense", - [ - DecisionEvidence( - evidenceId=license_evidence_id, - evidenceClass="design", - summary=( - f"The StudyContract license is " - f"{study_contract.correctionLicense}; technical columns=" - f"{study_contract.technicalBatchColumns}; protected columns=" - f"{study_contract.protectedColumns}." - ), - ) - ], - ) - license_definition = build_correction_license_decision( - evidence_bundle_id=license_bundle.bundleId, - license=study_contract.correctionLicense, - ) - license_definition = require_option_evidence( - license_definition, - { - f"correctionLicense:{study_contract.correctionLicense}": [ - license_evidence_id - ] - }, - ) - license_resolution = self._resolve_rna_decision( - store, - request_record, - license_definition, - license_bundle, - answers, - rule_selection=DecisionSelection( - selectedOptionId=( - f"correctionLicense:{study_contract.correctionLicense}" - ), - evidenceIds=[license_evidence_id], - rationale="Apply the exact deterministic StudyContract license.", - ), - ) - if license_resolution.compiled is None: - return return_pending( - license_resolution, - correction_license=study_contract.correctionLicense, - ) - if license_resolution.record is not None: - decision_sources["correctionLicense"] = license_resolution.record.source - license_payload = license_resolution.compiled.executorPayload - if not isinstance(license_payload, CorrectionLicensePayload): - raise TypeError("Correction license compiled an unexpected payload") - - correction_need: str | None = None - if license_payload.license == "safe": - need_items = [ - DecisionEvidence( - evidenceId="evidence:correctionNeed:design", - evidenceClass="design", - summary=( - "The design license is safe, but an indeterminate choice " - "remains available if representation evidence is incomplete." - ), - ) - ] - if ( - selected_pca.metrics.batchMixing - or selected_pca.metrics.technicalPcaAssociation - ): - need_items.append( - DecisionEvidence( - evidenceId="evidence:correctionNeed:batch", - evidenceClass="batchRemoval", - summary=( - "Native representation batch-mixing metrics are " - f"{selected_pca.metrics.batchMixing}; per-PC technical " - "associations are " - f"{selected_pca.metrics.technicalPcaAssociation}." - ), - artifactReferences=self._evaluation_artifacts(selected_pca), - ) - ) - if ( - selected_pca.metrics.biologicalPreservation - or not study_contract.protectedColumns - ): - need_items.append( - DecisionEvidence( - evidenceId="evidence:correctionNeed:biology", - evidenceClass="biologicalConservation", - summary=( - "Native protected-variable diagnostics are " - f"{selected_pca.metrics.biologicalPreservation}; " - f"declared protected columns=" - f"{study_contract.protectedColumns}." - ), - artifactReferences=self._evaluation_artifacts(selected_pca), - ) - ) - need_bundle = self._tuning_evidence_bundle( - "correctionNeed", - need_items, - ) - need_definition = build_correction_need_decision( - evidence_bundle_id=need_bundle.bundleId, - license=license_payload.license, - ) - comparative_need_ids = [ - item.evidenceId - for item in need_items - if item.evidenceClass in {"batchRemoval", "biologicalConservation"} - ] - need_definition = require_option_evidence( - need_definition, - { - "correctionNeed:needed": comparative_need_ids, - "correctionNeed:notNeeded": comparative_need_ids, - "correctionNeed:indeterminate": ["evidence:correctionNeed:design"], - }, - ) - need_resolution = self._resolve_rna_decision( - store, - request_record, - need_definition, - need_bundle, - answers, - ) - if need_resolution.compiled is None: - pending_reason = ( - need_resolution.pending.reason - if need_resolution.pending is not None - else "Correction need remains unresolved." - ) - correction_need_selection = CorrectionNeedSelection( - status="needsInput", - selectedOptionId="correctionNeed:indeterminate", - rationale=pending_reason, - ) - return return_pending( - need_resolution, - correction_license=license_payload.license, - ) - if need_resolution.record is not None: - decision_sources["correctionNeed"] = need_resolution.record.source - need_payload = need_resolution.compiled.executorPayload - if not isinstance(need_payload, CorrectionNeedPayload): - raise TypeError("Correction need compiled an unexpected payload") - correction_need = need_payload.need - assert need_resolution.record is not None - need_option_id: Literal[ - "correctionNeed:needed", - "correctionNeed:notNeeded", - ] = ( - "correctionNeed:needed" - if need_payload.need == "needed" - else "correctionNeed:notNeeded" - ) - correction_need_selection = CorrectionNeedSelection( - status="selected", - selectedOptionId=need_option_id, - evidenceIds=list(need_resolution.record.evidenceIds), - rationale=need_resolution.record.rationale, - ) - - full_correction_plan = planner.batch_correction_phase(selected_pca.parameters) - correction_candidates = list(full_correction_plan.candidates) - if not evaluate_harmony: - correction_candidates = [ - candidate - for candidate in correction_candidates - if not candidate.useHarmony - ] - correction_plan = ParameterPhasePlan.model_validate( - { - **full_correction_plan.model_dump(mode="json"), - "candidates": [ - candidate.model_dump(mode="json") - for candidate in correction_candidates - ], - } - ) - correction_evaluations = list( - phase_evaluations( - correction_plan, - lambda: execute_parameter_phase( - store, - normalized=normalized, - plan=correction_plan, - batch_columns=diagnostic_batch_columns, - preservation_columns=experimental_handoff.preservationColumns, - experimental_handoff=tuning_handoff, - min_cluster_cells=request_record.config.minClusterCells, - identity_feature_limit=request_record.config.maxIdentityFeatures, - ), - ) - ) - correction_native = next( - ( - evaluation - for evaluation in correction_evaluations - if not evaluation.parameters.useHarmony - and evaluation.status == "done" - and evaluation.eligible - ), - None, - ) - if "batchCorrection" not in prior_phases: - correction_doublets = ( - score_advisory_doublets( - store, - correction_native, - correction_evaluations, - assay=handoff.assay, - feature_selection=artifact_model_to_ref(handoff.graphFeatures), - capture_column=study_contract.physicalCaptureColumn, - ) - if correction_native is not None - else None - ) - correction_evaluations = list( - augment_cluster_evaluations( - store, - correction_evaluations, - marker_assay=plan.markerAssay, - marker_features=artifact_model_to_ref(handoff.markerFeatures), - independent_unit_columns=study_contract.independentUnitColumns, - technical_columns=diagnostic_batch_columns, - nominated_families=diagnostic_families, - protected_families=protected_families, - doublet_evidence=correction_doublets, - ) - ) - correction_evaluations = list(_stable_phase_evaluations(correction_evaluations)) - native_evaluation = next( - ( - evaluation - for evaluation in correction_evaluations - if not evaluation.parameters.useHarmony - and evaluation.status == "done" - and evaluation.eligible - ), - None, - ) - harmony_evaluation = next( - ( - evaluation - for evaluation in correction_evaluations - if evaluation.parameters.useHarmony - and evaluation.status == "done" - and evaluation.eligible - ), - None, - ) - outcome_items: list[DecisionEvidence] = [] - native_biology_id: str | None = None - if native_evaluation is not None: - native_biology_id = "evidence:correctionOutcome:nativeBiology" - outcome_items.append( - DecisionEvidence( - evidenceId=native_biology_id, - evidenceClass="biologicalConservation", - summary=( - "Native protected-variable diagnostics are " - f"{native_evaluation.metrics.biologicalPreservation}; " - "cross-unit support is " - f"{native_evaluation.metrics.crossUnitSupport}; marker " - f"coherence is {native_evaluation.metrics.markerCoherence}." - ), - artifactReferences=self._evaluation_artifacts(native_evaluation), - ) - ) - - harmony_eligible, harmony_gate_reasons = harmony_acceptance_gate( - native_evaluation, - harmony_evaluation, - batch_columns=diagnostic_batch_columns, - protected_columns=study_contract.protectedColumns, - independent_unit_columns=study_contract.independentUnitColumns, - require_doublet_evidence=True, - ) - harmony_eligible = ( - harmony_eligible - and license_payload.license == "safe" - and correction_need == "needed" - ) - if harmony_evaluation is not None and not selectable_harmony: - harmony_gate_reasons = [ - *harmony_gate_reasons, - "Harmony was executed for diagnosis but is not licensed for selection.", - ] - harmony_evidence_ids: list[str] = [] - if harmony_evaluation is not None: - harmony_biology_id = "evidence:correctionOutcome:harmonyBiology" - outcome_items.append( - DecisionEvidence( - evidenceId=harmony_biology_id, - evidenceClass="biologicalConservation", - summary=( - "Harmony protected-variable diagnostics are " - f"{harmony_evaluation.metrics.biologicalPreservation}; " - "cross-unit support is " - f"{harmony_evaluation.metrics.crossUnitSupport}; marker " - f"coherence is {harmony_evaluation.metrics.markerCoherence}; " - f"acceptance gate findings are {harmony_gate_reasons}." - ), - artifactReferences=self._evaluation_artifacts(harmony_evaluation), - ) - ) - harmony_evidence_ids.append(harmony_biology_id) - batch_id = "evidence:correctionOutcome:batchRemoval" - outcome_items.append( - DecisionEvidence( - evidenceId=batch_id, - evidenceClass="batchRemoval", - summary=( - "Matched native and Harmony batch-mixing metrics are " - f"{native_evaluation.metrics.batchMixing if native_evaluation else {}} " - f"and {harmony_evaluation.metrics.batchMixing}; gate findings " - f"are {harmony_gate_reasons}." - ), - artifactReferences=self._evaluation_artifacts(harmony_evaluation), - ) - ) - harmony_evidence_ids.append(batch_id) - if harmony_eligible: - protected_id = "evidence:correctionOutcome:protectedPreservation" - outcome_items.append( - DecisionEvidence( - evidenceId=protected_id, - evidenceClass="protectedVariablePreservation", - summary=( - "Harmony improved at least one approved batch metric " - "beyond 0.05 and did not materially degrade protected, " - "cross-unit, graph-connectivity, or marker evidence." - ), - artifactReferences=self._evaluation_artifacts( - harmony_evaluation - ), - ) - ) - harmony_evidence_ids.append(protected_id) - - outcome_bundle = self._tuning_evidence_bundle( - "correctionOutcome", - outcome_items, - ) - outcome_definition = build_correction_outcome_decision( - evidence_bundle_id=outcome_bundle.bundleId, - license=license_payload.license, - need=( - cast(Any, correction_need) - if license_payload.license == "safe" - else None - ), - harmony_eligible=harmony_eligible, - ) - outcome_definition = require_option_evidence( - outcome_definition, - { - **( - {"correctionOutcome:retainNative": [native_biology_id]} - if native_biology_id is not None - else {} - ), - **( - {"correctionOutcome:acceptHarmony": harmony_evidence_ids} - if harmony_eligible - else {} - ), - }, - ) - native_rule = None - if not harmony_eligible: - native_rule = DecisionSelection( - selectedOptionId=( - "correctionOutcome:retainNative" - if native_biology_id is not None - else "correctionOutcome:indeterminate" - ), - evidenceIds=( - [native_biology_id] - if native_biology_id is not None - else [item.evidenceId for item in outcome_items] - ), - rationale=( - "Retain the mandatory native representation because Harmony " - "did not demonstrate both material batch improvement and " - "preserved biological evidence: " - f"{harmony_gate_reasons}." - if native_biology_id is not None - else "Native biological-conservation evidence is unavailable." - ), - ) - outcome_resolution = self._resolve_rna_decision( - store, - request_record, - outcome_definition, - outcome_bundle, - answers, - rule_selection=native_rule, - ) - outcome_payload = ( - outcome_resolution.compiled.executorPayload - if outcome_resolution.compiled is not None - else None - ) - if outcome_payload is not None and not isinstance( - outcome_payload, - CorrectionOutcomeExecutorPayload, - ): - raise TypeError("Correction outcome compiled an unexpected payload") - augmented_correction: list[ParameterCandidateEvaluation] = [] - for evaluation in correction_evaluations: - correction_extra = ( - [native_biology_id] - if not evaluation.parameters.useHarmony - and native_biology_id is not None - else harmony_evidence_ids - if evaluation.parameters.useHarmony - else [] - ) - augmented_correction.append( - evaluation.model_copy( - update={ - "evidenceIds": list( - dict.fromkeys([*evaluation.evidenceIds, *correction_extra]) - ) - } - ) - ) - correction_phase = self._phase_from_resolution( - correction_plan, - augmented_correction, - outcome_resolution, - payload_field="useHarmony", - payload_value=( - outcome_payload.useHarmony if outcome_payload is not None else False - ), - ) - phase_evidence.append(correction_phase) - if outcome_resolution.record is not None: - decision_sources["correctionOutcome"] = outcome_resolution.record.source - selected_correction = correction_phase.selected_evaluation() - if selected_correction is None: - return return_pending( - outcome_resolution, - correction_license=license_payload.license, - ) - - graph_plan = planner.graph_phase(selected_correction.parameters) - raw_graph = phase_evaluations( - graph_plan, - lambda: execute_parameter_phase( - store, - normalized=normalized, - plan=graph_plan, - batch_columns=( - experimental_handoff.batchColumns - if selected_correction.parameters.useHarmony - else [] - ), - preservation_columns=experimental_handoff.preservationColumns, - experimental_handoff=experimental_handoff, - min_cluster_cells=request_record.config.minClusterCells, - identity_feature_limit=request_record.config.maxIdentityFeatures, - ), - ) - graph_doublet_reference = next( - ( - evaluation - for evaluation in raw_graph - if evaluation.status == "done" and evaluation.eligible - ), - None, - ) - graph_doublet_evidence = ( - restore_advisory_doublets( - graph_doublet_reference, - capture_column=study_contract.physicalCaptureColumn, - ) - if "graphK" in prior_phases and graph_doublet_reference is not None - else score_advisory_doublets( - store, - graph_doublet_reference, - raw_graph, - assay=handoff.assay, - feature_selection=artifact_model_to_ref(handoff.graphFeatures), - capture_column=study_contract.physicalCaptureColumn, - ) - if graph_doublet_reference is not None - else None - ) - if "graphK" not in prior_phases: - raw_graph = augment_cluster_evaluations( - store, - raw_graph, - marker_assay=plan.markerAssay, - marker_features=artifact_model_to_ref(handoff.markerFeatures), - independent_unit_columns=study_contract.independentUnitColumns, - technical_columns=diagnostic_batch_columns, - nominated_families=diagnostic_families, - protected_families=protected_families, - doublet_evidence=graph_doublet_evidence, - ) - graph_items: list[DecisionEvidence] = [] - graph_evaluations: list[ParameterCandidateEvaluation] = [] - eligible_graph_values: list[int] = [] - graph_evidence_by_k: dict[int, list[str]] = {} - for evaluation in _stable_phase_evaluations(raw_graph): - graph_extra: list[str] = [] - if evaluation.status == "done" and evaluation.eligible: - eligible_graph_values.append(evaluation.parameters.neighborsK) - evidence_id = f"evidence:graph:{evaluation.candidateId}:geometry" - graph_items.append( - DecisionEvidence( - evidenceId=evidence_id, - evidenceClass="geometric", - summary=( - f"The graph has k={evaluation.parameters.neighborsK}, " - f"{evaluation.metrics.nClusters} clusters, silhouette " - f"{evaluation.metrics.graphSilhouetteMedian}, and " - f"minimum cluster size " - f"{evaluation.metrics.minClusterCells}." - ), - artifactReferences=self._evaluation_artifacts(evaluation), - ) - ) - graph_extra.append(evidence_id) - graph_summaries = ( - ( - "stability", - "resamplingStability", - ( - f"seed ARI={evaluation.metrics.seedStability}; " - f"subsample ARI={evaluation.metrics.subsampleStability}; " - "membership strength=" - f"{evaluation.metrics.membershipStrengthMean}; cluster " - f"connectivity={evaluation.metrics.clusterConnectivity}." - ), - ), - ( - "markers", - "markerCoherence", - ( - f"marker coherence={evaluation.metrics.markerCoherence}; " - "marker specificity=" - f"{evaluation.metrics.markerSpecificityMedian}; " - "default/context-family enrichment=" - f"{evaluation.metrics.markerFamilyEnrichment}; protected " - f"families={evaluation.metrics.protectedMarkerFamilies}." - ), - ), - ( - "support", - "crossUnitSupport", - ( - f"cross-unit support={evaluation.metrics.crossUnitSupport}; " - "technical association=" - f"{evaluation.metrics.technicalAssociation}." - ), - ), - ( - "doublets", - "qualityControl", - ( - "advisory doublet concentration=" - f"{evaluation.metrics.doubletHighScoreConcentration}; " - "score quantiles=" - f"{evaluation.metrics.doubletScoreQuantiles}." - ), - ), - ) - for suffix, evidence_class, summary in graph_summaries: - graph_evidence_id = ( - f"evidence:graph:{evaluation.candidateId}:{suffix}" - ) - graph_items.append( - DecisionEvidence( - evidenceId=graph_evidence_id, - evidenceClass=cast(Any, evidence_class), - summary=summary, - artifactReferences=self._evaluation_artifacts(evaluation), - ) - ) - graph_extra.append(graph_evidence_id) - graph_evidence_by_k[evaluation.parameters.neighborsK] = list( - graph_extra - ) - else: - evidence_id = f"evidence:graph:{evaluation.candidateId}:failure" - graph_items.append( - DecisionEvidence( - evidenceId=evidence_id, - evidenceClass="other", - summary=_bounded_evidence_summary( - f"The graph candidate was not eligible: " - f"{evaluation.error or evaluation.eligibilityReasons}." - ), - artifactReferences=self._evaluation_artifacts(evaluation), - ) - ) - graph_extra.append(evidence_id) - graph_evaluations.append( - evaluation.model_copy( - update={ - "evidenceIds": list( - dict.fromkeys([*evaluation.evidenceIds, *graph_extra]) - ) - } - ) - ) - graph_bundle = self._tuning_evidence_bundle("graphK", graph_items) - graph_definition = build_graph_k_decision( - evidence_bundle_id=graph_bundle.bundleId, - n_cells=handoff.nCells, - candidate_neighbors=( - eligible_graph_values - if eligible_graph_values - else [candidate.neighborsK for candidate in graph_plan.candidates] - ), - ) - graph_definition = require_option_evidence( - graph_definition, - { - option.optionId: graph_evidence_by_k[option.payload.neighborsK] - for option in graph_definition.executorOptions - if isinstance(option.payload, GraphExecutorPayload) - and option.payload.neighborsK in graph_evidence_by_k - }, - ) - graph_rule_selection = ( - DecisionSelection( - selectedOptionId="graphScale:defer", - evidenceIds=[item.evidenceId for item in graph_bundle.evidence], - rationale=( - "No registered graph candidate completed with geometric evidence." - ), - confidence="notApplicable", - ) - if not eligible_graph_values - else None - ) - graph_resolution = self._resolve_rna_decision( - store, - request_record, - graph_definition, - graph_bundle, - answers, - rule_selection=graph_rule_selection, - ) - graph_payload = ( - graph_resolution.compiled.executorPayload - if graph_resolution.compiled is not None - else None - ) - if graph_payload is not None and not isinstance( - graph_payload, GraphExecutorPayload - ): - raise TypeError("Graph decision compiled an unexpected payload") - graph_phase = self._phase_from_resolution( - graph_plan, - graph_evaluations, - graph_resolution, - payload_field="neighborsK", - payload_value=( - graph_payload.neighborsK if graph_payload is not None else -1 - ), - ) - phase_evidence.append(graph_phase) - if graph_resolution.record is not None: - decision_sources["graphK"] = graph_resolution.record.source - selected_graph = graph_phase.selected_evaluation() - if selected_graph is None: - return return_pending( - graph_resolution, - correction_license=license_payload.license, - ) - - if graph_doublet_evidence is None: - raise ValueError( - "Selected graph lacks the required advisory doublet evidence" - ) - doublet_evidence = graph_doublet_evidence - cluster_plan = planner.clustering_phase(selected_graph.parameters) - persisted_cluster = prior_phases.get(cluster_plan.phase) - raw_clusters = phase_evaluations( - cluster_plan, - lambda: execute_parameter_phase( - store, - normalized=normalized, - plan=cluster_plan, - batch_columns=( - experimental_handoff.batchColumns - if selected_graph.parameters.useHarmony - else [] - ), - preservation_columns=experimental_handoff.preservationColumns, - experimental_handoff=experimental_handoff, - min_cluster_cells=request_record.config.minClusterCells, - identity_feature_limit=request_record.config.maxIdentityFeatures, - ), - ) - if persisted_cluster is not None: - cluster_evaluations = list(raw_clusters) - else: - cluster_evaluations = list( - augment_cluster_evaluations( - store, - raw_clusters, - marker_assay=plan.markerAssay, - marker_features=artifact_model_to_ref(handoff.markerFeatures), - independent_unit_columns=study_contract.independentUnitColumns, - technical_columns=diagnostic_batch_columns, - nominated_families=diagnostic_families, - protected_families=protected_families, - doublet_evidence=doublet_evidence, - ) - ) - cluster_items: list[DecisionEvidence] = [] - scored: list[tuple[float, float, ParameterCandidateEvaluation]] = [] - eligible_cluster_values: list[float] = [] - augmented_clusters: list[ParameterCandidateEvaluation] = [] - cluster_evidence_by_resolution: dict[float, list[str]] = {} - for evaluation in _stable_phase_evaluations(cluster_evaluations): - cluster_extra: list[str] = [] - if evaluation.status == "done" and evaluation.eligible: - marker_auc_preview = dict( - list(evaluation.metrics.markerAucByCluster.items())[:20] - ) - marker_gene_preview = { - cluster: genes[:3] - for cluster, genes in list( - evaluation.metrics.topMarkerGenes.items() - )[:20] - } - eligible_cluster_values.append(evaluation.parameters.leidenResolution) - geometry_id = f"evidence:cluster:{evaluation.candidateId}:geometry" - stability_id = f"evidence:cluster:{evaluation.candidateId}:stability" - marker_id = f"evidence:cluster:{evaluation.candidateId}:markers" - cluster_items.extend( - [ - DecisionEvidence( - evidenceId=geometry_id, - evidenceClass="geometric", - summary=( - f"Resolution " - f"{evaluation.parameters.leidenResolution:g} " - f"has silhouette " - f"{evaluation.metrics.graphSilhouetteMedian}, " - f"{evaluation.metrics.nClusters} clusters, and " - f"minimum cluster size " - f"{evaluation.metrics.minClusterCells}; membership " - "strength=" - f"{evaluation.metrics.membershipStrengthMean}; " - f"cluster connectivity=" - f"{evaluation.metrics.clusterConnectivity}." - ), - artifactReferences=self._evaluation_artifacts(evaluation), - ), - DecisionEvidence( - evidenceId=stability_id, - evidenceClass="resamplingStability", - summary=( - f"Alternate-seed ARI is " - f"{evaluation.metrics.seedStability}; deterministic " - f"subsample ARI is " - f"{evaluation.metrics.subsampleStability}." - ), - artifactReferences=self._evaluation_artifacts(evaluation), - ), - DecisionEvidence( - evidenceId=marker_id, - evidenceClass="markerCoherence", - summary=_bounded_evidence_summary( - "The fraction of clusters with marker programs is " - f"{evaluation.metrics.markerCoherence}; nominated " - "family marker enrichment is " - f"{evaluation.metrics.markerFamilyEnrichment}; " - "median marker specificity is " - f"{evaluation.metrics.markerSpecificityMedian}; " - "per-cluster marker AUC preview is " - f"{marker_auc_preview}; top-feature preview is " - f"{marker_gene_preview}; " - "protected families observed among markers are " - f"{evaluation.metrics.protectedMarkerFamilies}." - ), - artifactReferences=self._evaluation_artifacts(evaluation), - ), - ] - ) - cluster_extra.extend([geometry_id, stability_id, marker_id]) - if evaluation.metrics.crossUnitSupport is not None: - support_id = ( - f"evidence:cluster:{evaluation.candidateId}:unitSupport" - ) - cluster_items.append( - DecisionEvidence( - evidenceId=support_id, - evidenceClass="crossUnitSupport", - summary=( - "The fraction of clusters represented in at least " - "two independent units is " - f"{evaluation.metrics.crossUnitSupport}." - ), - artifactReferences=self._evaluation_artifacts(evaluation), - ) - ) - cluster_extra.append(support_id) - if evaluation.metrics.biologicalPreservation: - protected_id = ( - f"evidence:cluster:{evaluation.candidateId}:protected" - ) - cluster_items.append( - DecisionEvidence( - evidenceId=protected_id, - evidenceClass="protectedVariablePreservation", - summary=( - "Protected-variable metrics are " - f"{evaluation.metrics.biologicalPreservation}." - ), - artifactReferences=self._evaluation_artifacts(evaluation), - ) - ) - cluster_extra.append(protected_id) - if evaluation.metrics.technicalAssociation: - technical_id = ( - f"evidence:cluster:{evaluation.candidateId}:technical" - ) - cluster_items.append( - DecisionEvidence( - evidenceId=technical_id, - evidenceClass="technical", - summary=( - "Cluster-to-technical association is " - f"{evaluation.metrics.technicalAssociation}." - ), - artifactReferences=self._evaluation_artifacts(evaluation), - ) - ) - cluster_extra.append(technical_id) - if evaluation.metrics.doubletHighScoreConcentration is not None: - doublet_id = f"evidence:cluster:{evaluation.candidateId}:doublet" - cluster_items.append( - DecisionEvidence( - evidenceId=doublet_id, - evidenceClass="qualityControl", - summary=( - "Maximum cluster enrichment for the top decile of " - "capture-aware advisory doublet scores is " - f"{evaluation.metrics.doubletHighScoreConcentration}." - ), - artifactReferences=self._evaluation_artifacts(evaluation), - ) - ) - cluster_extra.append(doublet_id) - geometry = ( - evaluation.metrics.graphSilhouetteMedian - if evaluation.metrics.graphSilhouetteMedian is not None - else -1.0 - ) - stability = ( - ( - evaluation.metrics.seedStability - + evaluation.metrics.subsampleStability - ) - / 2 - if evaluation.metrics.seedStability is not None - and evaluation.metrics.subsampleStability is not None - else -1.0 - ) - marker = evaluation.metrics.markerCoherence or 0.0 - marker_specificity = evaluation.metrics.markerSpecificityMedian or 0.0 - unit_support = evaluation.metrics.crossUnitSupport or 0.0 - membership = evaluation.metrics.membershipStrengthMean or 0.0 - connectivity = evaluation.metrics.clusterConnectivity or 0.0 - technical = max( - evaluation.metrics.technicalAssociation.values(), - default=0.0, - ) - doublet_penalty = max( - 0.0, - (evaluation.metrics.doubletHighScoreConcentration or 1.0) - 1.0, - ) - protected_penalty = float( - bool(evaluation.metrics.protectedMarkerFamilies) - ) - score = ( - geometry - + 0.25 * stability - + 0.2 * marker - + 0.15 * marker_specificity - + 0.1 * unit_support - + 0.1 * membership - + 0.1 * connectivity - - 0.1 * technical - - 0.1 * doublet_penalty - - 0.1 * protected_penalty - ) - scored.append( - ( - score, - -evaluation.parameters.leidenResolution, - evaluation, - ) - ) - cluster_evidence_by_resolution[ - evaluation.parameters.leidenResolution - ] = list(cluster_extra) - else: - failure_id = f"evidence:cluster:{evaluation.candidateId}:failure" - cluster_items.append( - DecisionEvidence( - evidenceId=failure_id, - evidenceClass="other", - summary=_bounded_evidence_summary( - f"The partition was not eligible: " - f"{evaluation.error or evaluation.eligibilityReasons}." - ), - artifactReferences=self._evaluation_artifacts(evaluation), - ) - ) - cluster_extra.append(failure_id) - augmented_clusters.append( - evaluation.model_copy( - update={ - "evidenceIds": list( - dict.fromkeys([*evaluation.evidenceIds, *cluster_extra]) - ) - } - ) - ) - - known_cluster_ids = { - 0.25: "clusterResolution:veryCoarse", - 0.5: "clusterResolution:coarse", - 0.75: "clusterResolution:balanced", - 1.0: "clusterResolution:detailed", - 1.25: "clusterResolution:fine", - 1.5: "clusterResolution:veryFine", - } - - def cluster_option_id(resolution: float) -> str: - return known_cluster_ids.get( - resolution, - f"clusterResolution:r{str(resolution).replace('.', 'p')}", - ) - - candidate_resolutions = ( - eligible_cluster_values - if eligible_cluster_values - else [candidate.leidenResolution for candidate in cluster_plan.candidates] - ) - preferred_resolution = ( - max(scored, key=lambda value: (value[0], value[1]))[ - 2 - ].parameters.leidenResolution - if scored - else candidate_resolutions[0] - ) - cluster_bundle = self._tuning_evidence_bundle( - "clusterPartition", - cluster_items, - ) - cluster_definition = build_cluster_partition_decision( - evidence_bundle_id=cluster_bundle.bundleId, - metric_preferred_option_id=cluster_option_id(preferred_resolution), - resolution_candidates=candidate_resolutions, - ) - cluster_definition = require_option_evidence( - cluster_definition, - { - option.optionId: cluster_evidence_by_resolution[ - option.payload.leidenResolution - ] - for option in cluster_definition.executorOptions - if isinstance(option.payload, ClusterExecutorPayload) - and option.payload.leidenResolution in cluster_evidence_by_resolution - }, - ) - cluster_rule_selection = ( - DecisionSelection( - selectedOptionId="clusterPartition:abstain", - evidenceIds=[item.evidenceId for item in cluster_bundle.evidence], - rationale=( - "No registered cluster partition completed with the required " - "independent evidence." - ), - confidence="notApplicable", - ) - if not eligible_cluster_values - else None - ) - cluster_resolution = self._resolve_rna_decision( - store, - request_record, - cluster_definition, - cluster_bundle, - answers, - rule_selection=cluster_rule_selection, - ) - if cluster_resolution.compiled is None: - cluster_phase = ParameterPhaseEvidence( - plan=cluster_plan, - evaluations=augmented_clusters, - selection=ParameterPhaseSelection( - phase="clusteringResolution", - status="needsInput", - rationale=( - cluster_resolution.pending.reason - if cluster_resolution.pending is not None - else "Cluster partition remains unresolved." - ), - ), - ) - phase_evidence.append(cluster_phase) - return return_pending( - cluster_resolution, - correction_license=license_payload.license, - ) - if cluster_resolution.record is not None: - decision_sources["clusterPartition"] = cluster_resolution.record.source - if cluster_resolution.record is not None and ( - cluster_resolution.record.status == "abstain" - ): - cluster_phase = ParameterPhaseEvidence( - plan=cluster_plan, - evaluations=augmented_clusters, - selection=ParameterPhaseSelection( - phase="clusteringResolution", - status="abstained", - evidenceIds=list(cluster_resolution.record.evidenceIds), - rationale=cluster_resolution.record.rationale, - ), - ) - phase_evidence.append(cluster_phase) - state = build_state( - correction_license=license_payload.license, - ) - return ( - sequential_evidence_to_report( - state, - marker_assay=plan.markerAssay, - ), - state, - ) - cluster_payload = cluster_resolution.compiled.executorPayload - if not isinstance(cluster_payload, ClusterExecutorPayload): - raise TypeError("Cluster decision compiled an unexpected payload") - cluster_phase = self._phase_from_resolution( - cluster_plan, - augmented_clusters, - cluster_resolution, - payload_field="leidenResolution", - payload_value=cluster_payload.leidenResolution, - ) - phase_evidence.append(cluster_phase) - selected_cluster = cluster_phase.selected_evaluation() - if selected_cluster is None: - raise RuntimeError("Completed cluster decision lacks an exact candidate") - state = build_state( - correction_license=license_payload.license, - final_candidate_id=selected_cluster.candidateId, - ) - if request_record.config.maxRefinedCandidatesPerAssay == 0: - return ( - sequential_evidence_to_report( - state, - marker_assay=plan.markerAssay, - ), - state, - ) - - refinement_deps, initial_candidate_ids = ( - prepare_sequential_refinement_dependencies( - store, - normalized=normalized, - evidence=state, - batch_columns=diagnostic_batch_columns, - preservation_columns=experimental_handoff.preservationColumns, - experimental_handoff=tuning_handoff, - min_cluster_cells=request_record.config.minClusterCells, - identity_feature_limit=request_record.config.maxIdentityFeatures, - ) - ) - completed_evaluations = [ - refinement_deps.evaluations[candidate_id] - for candidate_id in initial_candidate_ids - ] - try: - planning_execution = run_agent_sync( - model=self.model, - output_type=ParameterSearchPlan, - system_prompt=parameter_search_system_prompt(), - user_prompt=parameter_search_prompt( - from_assay=handoff.assay, - cell_selection=handoff.cellSelection, - evaluations=completed_evaluations, - batch_columns=diagnostic_batch_columns, - preservation_columns=experimental_handoff.preservationColumns, - harmony_authorized=selectable_harmony, - max_refined_candidates=1, - ), - deps_type=ParameterTuningDependencies, - deps=refinement_deps, - config=request_record.config.agentRunConfig, - name="sequential_parameter_refinement", - output_validator=lambda proposed: validate_sequential_refinement_plan( - proposed, - refinement_deps, - initial_candidate_ids, - ), - ) - except AgentRunError: - pending_plan = ParameterSearchPlan( - status="complete", - basedOnCandidateIds=[selected_cluster.candidateId], - rationale=( - "The required bounded refinement review could not be completed." - ), - evidenceIds=list(selected_cluster.evidenceIds), - stoppingCriteria=[ - "Obtain a grounded refinement decision before final selection." - ], - ) - return ( - pending_parameter_tuning_report( - refinement_deps, - search_plan=pending_plan, - agent_name="sequential_parameter_refinement_needs_input", - ), - state, - ) - if not isinstance(planning_execution.output, ParameterSearchPlan): - raise TypeError("Sequential refinement returned an unexpected output") - refinement = execute_sequential_refinement( - refinement_deps, - planning_execution.output.model_copy( - update={"runInfo": planning_execution.runInfo} - ), - initial_candidate_ids, - ) - if refinement.evaluation is not None: - refined_pca = augment_pca_evaluations( - store, - [refinement.evaluation], - feature_selection=artifact_model_to_ref(handoff.graphFeatures), - nominated_families=diagnostic_families, - protected_families=protected_families, - technical_columns=diagnostic_batch_columns, - batch_columns=diagnostic_batch_columns, - protected_columns=study_contract.protectedColumns, - qc_columns=[ - column - for column in plan.cellQc.attributes - if column in store.cells.columns - ], - ) - refined_cluster = augment_cluster_evaluations( - store, - refined_pca, - marker_assay=plan.markerAssay, - marker_features=artifact_model_to_ref(handoff.markerFeatures), - independent_unit_columns=study_contract.independentUnitColumns, - technical_columns=diagnostic_batch_columns, - nominated_families=diagnostic_families, - protected_families=protected_families, - doublet_evidence=doublet_evidence, - )[0] - refined_evidence_ids = [ - f"candidate:{refined_cluster.candidateId}:refinedPca", - f"candidate:{refined_cluster.candidateId}:refinedGraph", - f"candidate:{refined_cluster.candidateId}:refinedMarkers", - f"candidate:{refined_cluster.candidateId}:refinedUnitSupport", - f"candidate:{refined_cluster.candidateId}:refinedTechnical", - f"candidate:{refined_cluster.candidateId}:refinedDoublets", - ] - refined_cluster = refined_cluster.model_copy( - update={ - "evidenceIds": list( - dict.fromkeys( - [ - *refined_cluster.evidenceIds, - *refined_evidence_ids, - ] - ) - ) - } - ) - refinement_deps.evaluations[refined_cluster.candidateId] = refined_cluster - - if refinement.evaluation is None: - completed_report = sequential_evidence_to_report( - state, - marker_assay=plan.markerAssay, - ) - assay_report = completed_report.assayReports[handoff.assay].model_copy( - update={"searchPlan": refinement.plan} - ) - return ( - completed_report.model_copy( - update={ - "searchPlan": refinement.plan, - "assayReports": {handoff.assay: assay_report}, - "stopReason": ( - f"{completed_report.stopReason} Refinement review " - f"stopped because {refinement.plan.rationale}" - ), - } - ), - state, - ) - - selection_ids = [ - selected_cluster.candidateId, - refinement.evaluation.candidateId, - ] - selection_deps = refinement_deps.model_copy( - update={ - "candidates": { - candidate_id: refinement_deps.candidates[candidate_id] - for candidate_id in selection_ids - }, - "candidatePhases": { - candidate_id: refinement_deps.candidatePhases[candidate_id] - for candidate_id in selection_ids - }, - "evaluations": { - candidate_id: refinement_deps.evaluations[candidate_id] - for candidate_id in selection_ids - }, - "executionOrder": selection_ids, - "maxCandidates": len(selection_ids), - } - ) - selection_evaluations = [ - selection_deps.evaluations[candidate_id] for candidate_id in selection_ids - ] - try: - selection_execution = run_agent_sync( - model=self.model, - output_type=ParameterTuningReport, - system_prompt=parameter_tuning_system_prompt( - request_record.config.minClusterCells - ), - user_prompt=parameter_tuning_prompt( - from_assay=handoff.assay, - cell_selection=handoff.cellSelection, - evaluations=selection_evaluations, - batch_columns=diagnostic_batch_columns, - preservation_columns=experimental_handoff.preservationColumns, - search_plan=refinement.plan, - ), - deps_type=ParameterTuningDependencies, - deps=selection_deps, - config=request_record.config.agentRunConfig, - name="sequential_parameter_selection", - output_validator=lambda proposed: validate_parameter_tuning_report( - proposed, - selection_deps, - search_plan=refinement.plan, - ), - ) - except AgentRunError: - return ( - pending_parameter_tuning_report( - selection_deps, - search_plan=refinement.plan, - agent_name="sequential_parameter_selection_needs_input", - ), - state, - ) - if not isinstance(selection_execution.output, ParameterTuningReport): - raise TypeError("Sequential final selection returned an unexpected output") - selected_report = validate_parameter_tuning_report( - selection_execution.output, - selection_deps, - search_plan=refinement.plan, - ).model_copy(update={"runInfo": selection_execution.runInfo}) - complete_inventory = [ - refinement_deps.evaluations[candidate_id] - for candidate_id in refinement_deps.executionOrder - if candidate_id in refinement_deps.evaluations - ] - selected_report = selected_report.model_copy( - update={ - "evaluations": complete_inventory, - "totalCandidates": len(complete_inventory), - } - ) - assay_report = selected_report.model_copy(update={"assayReports": {}}) - selected_report = selected_report.model_copy( - update={"assayReports": {handoff.assay: assay_report}} - ) - return ( - finalize_parameter_tuning_selection( - selected_report, - marker_assay=plan.markerAssay, - native_assay=handoff.assay, - ), - state, - ) - - def feature_policy_review_stage( - self, - store: DataStore, - workflow: AgentWorkflowRun, - request_record: OrchestrationRequestRecord, - parents: Sequence[WorkflowStageLink], - plan: AutomatedPreprocessingPlan, - tuning_report: ParameterTuningReport, - answers: Mapping[str, Any], - *, - resume_record: OrchestrationResumeRecord | None = None, - ) -> tuple[WorkflowStageAttempt, AutomatedPreprocessingPlan, bool]: - """Review the baseline feature policy against PCA and marker evidence.""" - prefix = journal._ensure_orchestration_store(store) - existing = journal._validated_done_outcome( - store, - prefix, - workflow.workflowRunId, - "feature_policy_review", - request_record, - parents, - ) - if existing is not None: - return ( - existing, - AutomatedPreprocessingPlan.model_validate( - existing.outputs["preprocessingPlan"] - ), - bool(existing.outputs["revised"]), - ) - started = journal._start_attempt( - store.zw, - prefix, - workflow.workflowRunId, - "feature_policy_review", - request_record, - parents, - inputs={ - "preprocessingPlan": plan.model_dump(mode="json"), - "tuningReportSha256": hashlib.sha256( - record_io.canonical_json_bytes( - tuning_report.model_dump(mode="json") - ) - ).hexdigest(), - }, - resume_record=resume_record, - ) - try: - assay_plan = next( - value for value in plan.assays if value.assay == plan.primaryAssay - ) - assay_report = tuning_report.assayReports[plan.primaryAssay] - selected = next( - evaluation - for evaluation in assay_report.evaluations - if evaluation.candidateId == assay_report.recommendedCandidateId - ) - loading_evaluation = next( - ( - evaluation - for evaluation in assay_report.evaluations - if evaluation.status == "done" - and evaluation.eligible - and evaluation.parameters.dimensions - == selected.parameters.dimensions - and evaluation.metrics.loadingFamilyEnrichment - ), - None, - ) - aliases = { - "sex": "sexLinked", - "ribosomalProtein": "ribosomal", - "cellCycleCcn": "cellCycle", - "HLA": "hla", - "H2": "h2", - } - allowed_families = { - "mitochondrial", - "ribosomal", - "mitoribosomal", - "histone", - "hla", - "h2", - "hemoglobin", - "immuneReceptor", - "cellCycle", - "stress", - "dissociation", - "sexLinked", - } - nominated = [ - aliases.get(str(value), str(value)) - for value in cast( - list[str], - assay_plan.featureParameters.get( - "proposedExcludeFamilies", - [], - ), - ) - ] - protected = [ - aliases.get(str(value), str(value)) - for value in cast( - list[str], - assay_plan.featureParameters.get("protectFamilies", []), - ) - ] - nominated = [ - value for value in dict.fromkeys(nominated) if value in allowed_families - ] - protected = [ - value for value in dict.fromkeys(protected) if value in allowed_families - ] - loading_enrichment = ( - loading_evaluation.metrics.loadingFamilyEnrichment - if loading_evaluation is not None - else {} - ) - marker_enrichment = selected.metrics.markerFamilyEnrichment - normalized_loading: dict[str, float] = {} - normalized_markers: dict[str, float] = {} - for name, value in loading_enrichment.items(): - canonical = aliases.get(name, name) - normalized_loading[canonical] = max( - normalized_loading.get(canonical, 0.0), - value, - ) - for name, value in marker_enrichment.items(): - canonical = aliases.get(name, name) - normalized_markers[canonical] = max( - normalized_markers.get(canonical, 0.0), - value, - ) - eligible = [ - cast(ConditionalGeneFamily, family) - for family in nominated - if family not in protected - and ( - normalized_loading.get(family, 0.0) >= 2.0 - or normalized_markers.get(family, 0.0) >= 2.0 - ) - ] - scarf_default_families = { - "mitochondrial", - "ribosomal", - "mitoribosomal", - "cellCycle", - "hla", - "h2", - "histone", - "sexLinked", - } - default_dominant = any( - normalized_loading.get(family, 0.0) >= 2.0 - or normalized_markers.get(family, 0.0) >= 2.0 - for family in scarf_default_families - ) - current_default = ( - assay_plan.featureParameters.get("useScarfDefaultBlacklist") is True - ) - default_option = bool( - (current_default or default_dominant) - and not scarf_default_families.intersection(protected) - ) - loading_id = "evidence:featurePolicyReview:pcaLoadings" - marker_id = "evidence:featurePolicyReview:clusterMarkers" - protected_id = "evidence:featurePolicyReview:protectedFamilies" - evidence = [ - DecisionEvidence( - evidenceId=loading_id, - evidenceClass="technical", - summary=( - "Maximum top-loading family enrichments are " - f"{loading_enrichment}; the registered gate is 2.0." - ), - artifactReferences=( - self._evaluation_artifacts(loading_evaluation) - if loading_evaluation is not None - else [] - ), - ), - DecisionEvidence( - evidenceId=marker_id, - evidenceClass="markerCoherence", - summary=( - "Selected-partition family marker enrichments are " - f"{marker_enrichment}; the registered gate is 2.0." - ), - artifactReferences=self._evaluation_artifacts(selected), - ), - DecisionEvidence( - evidenceId=protected_id, - evidenceClass="protectedVariablePreservation", - summary=( - f"Protected families are {protected}; eligible nominated " - f"families after the veto are {eligible}." - ), - ), - ] - bundle = self._tuning_evidence_bundle("featurePolicy", evidence) - definition = build_feature_policy_decision( - evidence_bundle_id=bundle.bundleId, - proposed_exclusion_families=eligible, - dominant_families=eligible, - protected_families=[ - cast(ConditionalGeneFamily, value) for value in protected - ], - scarf_default_eligible=default_option, - ) - requirements: dict[str, list[str]] = { - "featurePolicy:keepAll": [loading_id, marker_id, protected_id] - } - if eligible: - requirements["featurePolicy:excludeEligibleBundle"] = [ - loading_id, - marker_id, - protected_id, - ] - if default_option: - requirements["featurePolicy:excludeScarfDefaults"] = [ - loading_id, - marker_id, - protected_id, - ] - definition = require_option_evidence(definition, requirements) - active_excluded = [ - cast(ConditionalGeneFamily, value) - for value in assay_plan.featureParameters.get( - "excludeFamilies", - [], - ) - if value in allowed_families - ] - active_payload = ( - FeaturePolicyExecutorPayload( - policy="excludeScarfDefaults", - useScarfDefaultBlacklist=True, - ) - if current_default - else FeaturePolicyExecutorPayload( - policy="excludeEligibleBundle", - excludedFamilies=active_excluded, - ) - if active_excluded - else FeaturePolicyExecutorPayload( - policy="keepAll", - excludedFamilies=[], - ) - ) - if eligible or default_option: - review = self._reconsider_rna_decision( - store, - request_record, - definition, - bundle, - answers, - review_instructions=( - "Reconsider the active graph-feature policy among the exact " - "registered keep-all, Scarf-default, and context-derived " - "alternatives. Cite every required evidence ID and class. " - "Any exclusion affects representation only, never marker " - "testing." - ), - ) - if review.question is not None: - outcome = journal._complete_attempt( - started, - status="needsInput", - outputs={ - "decisionSnapshotSha256": review.snapshotSha256, - "eligibleFamilies": list(eligible), - }, - needs_input=WorkflowNeedsInput(questions=[review.question]), - notes=[ - "Feature-policy review requires a registered selection." - ], - ) - journal._save_outcome(store.zw, prefix, outcome) - return outcome, plan, False - if review.selection is None: - raise RuntimeError("Feature-policy review lacks a selection") - review_selection = review.selection - selected_option_id = review.selection.selectedOptionId - revised = review.revised - snapshot_sha256 = review.snapshotSha256 - payload = ( - review.resolution.compiled.executorPayload - if review.resolution is not None - and review.resolution.compiled is not None - else active_payload - ) - else: - review_selection = DecisionSelection( - selectedOptionId="featurePolicy:keepAll", - evidenceIds=[loading_id, marker_id, protected_id], - rationale=( - "No nominated, unprotected family passed the registered " - "loading or marker-enrichment gate." - ), - confidence="notApplicable", - ) - selected_option_id = "featurePolicy:keepAll" - revised = False - _workflow, snapshot_sha256 = self._load_or_create_decision_workflow( - store, - request_record, - ) - payload = FeaturePolicyExecutorPayload( - policy=active_payload.policy, - excludedFamilies=list(active_payload.excludedFamilies), - useScarfDefaultBlacklist=(active_payload.useScarfDefaultBlacklist), - ) - if not isinstance(payload, FeaturePolicyExecutorPayload): - raise TypeError("Feature-policy review compiled an unexpected payload") - reviewed_plan = apply_feature_policy_to_plan(plan, payload) - artifacts: dict[str, ArtifactReferenceModel] = {} - if ( - loading_evaluation is not None - and "representationDiagnostic" in loading_evaluation.artifacts - ): - artifacts["pcaRepresentationDiagnostic"] = ( - ArtifactReferenceModel.model_validate( - loading_evaluation.artifacts[ - "representationDiagnostic" - ].model_dump() - ) - ) - if "markerTable" in selected.artifacts: - artifacts["clusterMarkerTable"] = ArtifactReferenceModel.model_validate( - selected.artifacts["markerTable"].model_dump() - ) - outcome = journal._complete_attempt( - started, - status="done", - artifacts=artifacts, - outputs={ - "preprocessingPlan": reviewed_plan.model_dump(mode="json"), - "revised": revised, - "selectedOptionId": selected_option_id, - "decisionSelection": review_selection.model_dump(mode="json"), - "evidenceBundle": bundle.model_dump(mode="json"), - "eligibleFamilies": list(eligible), - "decisionSnapshotSha256": snapshot_sha256, - }, - actions=[ - "review_feature_policy", - ("revise_feature_policy" if revised else "retain_feature_policy"), - ], - ) - journal._save_outcome(store.zw, prefix, outcome) - return outcome, reviewed_plan, revised - except Exception as exc: - outcome = journal.finish_exception( - store, - prefix, - workflow, - started, - exc, - ) - return outcome, plan, False - - def reuse_feature_policy_tuning_stage( - self, - store: DataStore, - workflow: AgentWorkflowRun, - request_record: OrchestrationRequestRecord, - parents: Sequence[WorkflowStageLink], - baseline_outcome: WorkflowStageAttempt, - baseline_report: ParameterTuningReport, - *, - resume_record: OrchestrationResumeRecord | None = None, - ) -> tuple[WorkflowStageAttempt, ParameterTuningReport]: - """Record deterministic reuse when no feature-policy revision occurred.""" - prefix = journal._ensure_orchestration_store(store) - existing = journal._validated_done_outcome( - store, - prefix, - workflow.workflowRunId, - "feature_policy_tuning", - request_record, - parents, - ) - if existing is not None: - return existing, baseline_report - started = journal._start_attempt( - store.zw, - prefix, - workflow.workflowRunId, - "feature_policy_tuning", - request_record, - parents, - inputs={ - "baselineAttemptId": baseline_outcome.attemptId, - "baselineReportReferences": [ - value.model_dump(mode="json") - for value in baseline_outcome.reportReferences - ], - }, - resume_record=resume_record, - ) - outcome = journal._complete_attempt( - started, - status="done", - artifacts=dict(baseline_outcome.artifacts), - outputs={ - "reusedBaselineAttemptId": baseline_outcome.attemptId, - "recommendedByAssay": dict(baseline_report.recommendedByAssay), - "operations": [ - { - "operation": "reuse_baseline_parameter_tuning", - "attemptId": baseline_outcome.attemptId, - } - ], - }, - actions=["reuse_baseline_parameter_tuning"], - ) - journal._save_outcome(store.zw, prefix, outcome) - return outcome, baseline_report - - def analysis_review_stage( - self, - store: DataStore, - workflow: AgentWorkflowRun, - request_record: OrchestrationRequestRecord, - parents: Sequence[WorkflowStageLink], - plan: AutomatedPreprocessingPlan, - tuning_report: ParameterTuningReport, - tuning_reference: AgentReportReference, - study_contract: StudyContract, - answers: Mapping[str, Any], - *, - resume_record: OrchestrationResumeRecord | None = None, - ) -> tuple[ - WorkflowStageAttempt, - ParameterTuningReport, - AgentReportReference, - ]: - """Reconsider one dominated analysis checkpoint through the revision ledger.""" - prefix = journal._ensure_orchestration_store(store) - existing = journal._validated_done_outcome( - store, - prefix, - workflow.workflowRunId, - "analysis_review", - request_record, - parents, - ) - if existing is not None: - if existing.reportReferences: - loaded = journal.load_stage_report( - store, - existing, - ParameterTuningReport, - ) - return ( - existing, - cast(ParameterTuningReport, loaded), - existing.reportReferences[0], - ) - return existing, tuning_report, tuning_reference - started = journal._start_attempt( - store.zw, - prefix, - workflow.workflowRunId, - "analysis_review", - request_record, - parents, - inputs={ - "parameterReport": tuning_reference.model_dump(mode="json"), - "studyContractSha256": hashlib.sha256( - record_io.canonical_json_bytes( - study_contract.model_dump(mode="json") - ) - ).hexdigest(), - "reviewPolicy": { - "maximumRevisions": request_record.config.maxRevisions, - "dominanceTolerance": 0.02, - "materialDifference": 0.05, - }, - }, - resume_record=resume_record, - ) - try: - assay_report = tuning_report.assayReports.get( - plan.primaryAssay, - tuning_report, - ) - selected = next( - ( - evaluation - for evaluation in assay_report.evaluations - if evaluation.candidateId == assay_report.recommendedCandidateId - ), - None, - ) - if selected is None: - raise ValueError("Analysis review lacks the selected tuning candidate") - visual_answer = answers.get("analysisVisualReview") - if visual_answer is not None: - if not isinstance(visual_answer, Mapping): - raise ValueError("analysisVisualReview answer must be a mapping") - visual_review = AnalysisVisualAdjudication.model_validate( - dict(visual_answer) - ) - adjudication_mode: Literal["multimodal", "numeric", "provided"] = ( - "provided" - ) - else: - try: - visual_content = _analysis_visual_content( - store, - selected, - assay_report.evaluations, - qc_columns=plan.cellQc.attributes, - qc_artifact_metrics=[ - (value.name, value.artifact) - for value in plan.cellQc.artifactMetrics - ], - ) - visual_review, adjudication_mode = _run_analysis_adjudication( - model=self.model, - config=request_record.config, - study_objective=request_record.request.studyObjective, - selected=selected, - candidates=assay_report.evaluations, - visual_content=visual_content, - ) - except (AgentRunError, RuntimeError, ValueError) as exc: - evidence_ids = list( - dict.fromkeys( - [ - *selected.evidenceIds, - *( - f"artifact:{value.artifactId}" - for value in self._evaluation_artifacts(selected) - ), - *( - f"artifact:{value.artifact.artifactId}" - for value in plan.cellQc.artifactMetrics - ), - ] - ) - ) - if request_record.config.inputPolicy == "unattended": - visual_review = AnalysisVisualAdjudication( - status="acceptable", - selectedCandidateId=selected.candidateId, - featureLevelFindings=[ - "Model adjudication was unavailable; deterministic " - "candidate gates remained authoritative." - ], - rationale=( - "The selected candidate already passed the registered " - "geometric, stability, marker, cross-unit, technical, " - "protected-variable, QC, and doublet gates. The " - "unattended workflow retained it after model review " - f"failed with {type(exc).__name__}." - ), - ) - adjudication_mode = "numeric" - else: - question = WorkflowQuestion( - questionId="analysisVisualReview", - question=( - "Analysis adjudication could not be completed. Review " - "the selected PCA, partition, marker, QC, and doublet " - "artifacts and provide an acceptable or concern result " - f"for candidate {selected.candidateId!r}. Cause: {exc}" - ), - options=["acceptable", "concern"], - evidenceIds=evidence_ids, - ) - outcome = journal._complete_attempt( - started, - status="needsInput", - outputs={ - "revised": False, - "reviewedCandidateId": selected.candidateId, - "visualEvidenceIds": evidence_ids, - }, - needs_input=WorkflowNeedsInput(questions=[question]), - actions=[ - "review_analysis_evidence", - "pause_visual_review", - ], - ) - journal._save_outcome(store.zw, prefix, outcome) - return outcome, tuning_report, tuning_reference - if visual_review.selectedCandidateId != selected.candidateId: - raise ValueError("Visual review references a stale selected candidate") - if not visual_review.rationale.strip(): - raise ValueError("Visual review rationale must be non-empty") - alternatives = [ - evaluation - for evaluation in assay_report.evaluations - if _dominates_analysis_choice(evaluation, selected) - ] - if not alternatives: - if visual_review.status == "concern": - concern_answer = answers.get("analysisVisualConcern") - if concern_answer == "stop": - outcome = journal._complete_attempt( - started, - status="abstained", - outputs={ - "revised": False, - "reviewedCandidateId": selected.candidateId, - "adjudicationMode": adjudication_mode, - "visualAdjudication": visual_review.model_dump( - mode="json" - ), - }, - actions=[ - "review_analysis_evidence", - "stop_on_visual_concern", - ], - ) - journal._save_outcome(store.zw, prefix, outcome) - return outcome, tuning_report, tuning_reference - if concern_answer == "retainSelected": - visual_review = visual_review.model_copy( - update={ - "status": "acceptable", - "rationale": ( - f"{visual_review.rationale} Human review " - "retained the exact selected partition." - ), - } - ) - elif concern_answer is not None: - raise ValueError( - "analysisVisualConcern must be retainSelected or stop" - ) - if visual_review.status == "concern": - if request_record.config.inputPolicy == "unattended": - visual_review = visual_review.model_copy( - update={ - "status": "acceptable", - "rationale": ( - f"{visual_review.rationale} No executed matched " - "alternative passed the deterministic dominance " - "gate, so the unattended workflow retained the " - "selected partition." - ), - } - ) - else: - question = WorkflowQuestion( - questionId="analysisVisualConcern", - question=( - "Visual adjudication found a concern, but no executed " - "matched alternative passed the deterministic " - "dominance gate. Decide whether to retain the selected " - "partition or stop for a revised analysis request." - ), - options=["retainSelected", "stop"], - evidenceIds=list(selected.evidenceIds), - ) - outcome = journal._complete_attempt( - started, - status="needsInput", - outputs={ - "revised": False, - "reviewedCandidateId": selected.candidateId, - "adjudicationMode": adjudication_mode, - "visualAdjudication": visual_review.model_dump( - mode="json" - ), - }, - needs_input=WorkflowNeedsInput(questions=[question]), - actions=[ - "review_analysis_evidence", - "pause_visual_concern", - ], - ) - journal._save_outcome(store.zw, prefix, outcome) - return outcome, tuning_report, tuning_reference - outcome = journal._complete_attempt( - started, - status="done", - outputs={ - "revised": False, - "reviewedCandidateId": selected.candidateId, - "adjudicationMode": adjudication_mode, - "stoppingReason": ( - "No eligible one-checkpoint alternative dominated the " - "selected candidate across geometric, stability, marker, " - "cross-unit, technical, protected-variable, and doublet " - "evidence." - ), - "visualAdjudication": visual_review.model_dump(mode="json"), - }, - actions=["review_analysis_evidence", "retain_selected_analysis"], - ) - journal._save_outcome(store.zw, prefix, outcome) - return outcome, tuning_report, tuning_reference - - best = max( - alternatives, - key=lambda evaluation: ( - sum(_cluster_review_values(evaluation)[0].values()) - - sum(_cluster_review_values(evaluation)[1].values()), - -evaluation.parameters.leidenResolution, - ), - ) - checkpoint = _changed_analysis_checkpoint(best, selected) - if checkpoint is None: - raise RuntimeError( - "Dominating analysis alternative lacks one exact checkpoint" - ) - raw_candidates = [ - selected, - *[ - value - for value in alternatives - if _changed_analysis_checkpoint(value, selected) == checkpoint - ], - ] - candidates_by_value: dict[Any, ParameterCandidateEvaluation] = {} - for candidate in raw_candidates: - value = _analysis_parameter_value(checkpoint, candidate) - current = candidates_by_value.get(value) - if current is None or ( - sum(_cluster_review_values(candidate)[0].values()) - - sum(_cluster_review_values(candidate)[1].values()) - > sum(_cluster_review_values(current)[0].values()) - - sum(_cluster_review_values(current)[1].values()) - ): - candidates_by_value[value] = candidate - candidates = list(candidates_by_value.values()) - evidence: list[DecisionEvidence] = [] - candidate_evidence: dict[str, list[str]] = {} - for evaluation in candidates: - values = self._analysis_candidate_evidence( - checkpoint, - evaluation, - ) - evidence.extend(values) - candidate_evidence[evaluation.candidateId] = [ - value.evidenceId for value in values - ] - best = max( - alternatives, - key=lambda evaluation: ( - sum(_cluster_review_values(evaluation)[0].values()) - - sum(_cluster_review_values(evaluation)[1].values()), - -evaluation.parameters.leidenResolution, - ), - ) - bundle = self._tuning_evidence_bundle(checkpoint, evidence) - if checkpoint == "pcaPrefix": - definition = build_pca_prefix_decision( - evidence_bundle_id=bundle.bundleId, - matrix_rank=max( - evaluation.parameters.dimensions for evaluation in candidates - ), - candidate_dimensions=[ - evaluation.parameters.dimensions for evaluation in candidates - ], - ) - option_for_candidate = { - evaluation.candidateId: self._payload_option_id( - definition, - PcaPrefixExecutorPayload, - "dimensions", - evaluation.parameters.dimensions, - ) - for evaluation in candidates - } - elif checkpoint == "correctionOutcome": - definition = build_correction_outcome_decision( - evidence_bundle_id=bundle.bundleId, - license="safe", - need="needed", - harmony_eligible=True, - ) - option_for_candidate = { - evaluation.candidateId: ( - "correctionOutcome:acceptHarmony" - if evaluation.parameters.useHarmony - else "correctionOutcome:retainNative" - ) - for evaluation in candidates - } - elif checkpoint == "graphK": - definition = build_graph_k_decision( - evidence_bundle_id=bundle.bundleId, - n_cells=max( - evaluation.parameters.neighborsK for evaluation in candidates - ) - + 1, - candidate_neighbors=[ - evaluation.parameters.neighborsK for evaluation in candidates - ], - ) - option_for_candidate = { - evaluation.candidateId: self._payload_option_id( - definition, - GraphExecutorPayload, - "neighborsK", - evaluation.parameters.neighborsK, - ) - for evaluation in candidates - } - else: - known_ids = { - 0.25: "clusterResolution:veryCoarse", - 0.5: "clusterResolution:coarse", - 0.75: "clusterResolution:balanced", - 1.0: "clusterResolution:detailed", - 1.25: "clusterResolution:fine", - 1.5: "clusterResolution:veryFine", - } - - def resolution_option(value: float) -> str: - return known_ids.get( - value, - f"clusterResolution:r{str(value).replace('.', 'p')}", - ) - - definition = build_cluster_partition_decision( - evidence_bundle_id=bundle.bundleId, - metric_preferred_option_id=resolution_option( - best.parameters.leidenResolution - ), - resolution_candidates=[ - evaluation.parameters.leidenResolution - for evaluation in candidates - ], - ) - option_for_candidate = { - evaluation.candidateId: resolution_option( - evaluation.parameters.leidenResolution - ) - for evaluation in candidates - } - requirements = { - option_for_candidate[evaluation.candidateId]: candidate_evidence[ - evaluation.candidateId - ] - for evaluation in candidates - } - definition = require_option_evidence(definition, requirements) - decision_workflow, _snapshot = self._load_or_create_decision_workflow( - store, - request_record, - ) - previous_options = { - record.decisionId: record.selectedOptionId - for record in decision_workflow.active_decision_records() - } - review = self._reconsider_rna_decision( - store, - request_record, - definition, - bundle, - answers, - review_instructions=( - f"Reconsider the active {checkpoint} decision only because an " - "executed one-checkpoint alternative passed the registered " - "dominance gate. Cite the option-specific geometric, technical, " - "stability, marker, protected-variable, cross-unit, and " - "quality-control evidence. Keep the current option unless at " - "least two independent evidence classes justify replacement." - ), - ) - if review.question is not None: - outcome = journal._complete_attempt( - started, - status="needsInput", - outputs={ - "revised": False, - "adjudicationMode": adjudication_mode, - "decisionSnapshotSha256": review.snapshotSha256, - "dominatingCandidateIds": [ - value.candidateId for value in alternatives - ], - }, - needs_input=WorkflowNeedsInput(questions=[review.question]), - actions=["review_analysis_evidence"], - ) - journal._save_outcome(store.zw, prefix, outcome) - return outcome, tuning_report, tuning_reference - if review.selection is None: - raise RuntimeError("Analysis review returned no selection") - replacement = next( - evaluation - for evaluation in candidates - if option_for_candidate[evaluation.candidateId] - == review.selection.selectedOptionId - ) - if not review.revised: - outcome = journal._complete_attempt( - started, - status="done", - outputs={ - "revised": False, - "reviewedCandidateId": selected.candidateId, - "adjudicationMode": adjudication_mode, - "decisionSelection": review.selection.model_dump(mode="json"), - "decisionSnapshotSha256": review.snapshotSha256, - "visualAdjudication": visual_review.model_dump(mode="json"), - }, - actions=["review_analysis_evidence", "retain_selected_analysis"], - ) - journal._save_outcome(store.zw, prefix, outcome) - return outcome, tuning_report, tuning_reference - - descendant_snapshot = self._restore_tuning_descendants( - store, - request_record, - study_contract, - replacement, - revised_checkpoint=checkpoint, - previous_options=previous_options, - model_name=( - tuning_report.runInfo.modelName - or assay_report.runInfo.modelName - or None - ), - ) - updated_assay = assay_report.model_copy( - update={ - "recommendedCandidateId": replacement.candidateId, - "selectedArtifacts": dict(replacement.artifacts), - "evidenceIds": list(review.selection.evidenceIds), - "rationale": review.selection.rationale, - "tradeoffs": [ - *assay_report.tradeoffs, - ( - "The bounded analysis review superseded " - f"{selected.candidateId!r} with " - f"{replacement.candidateId!r}." - ), - ], - } - ) - reports = dict(tuning_report.assayReports) - reports[plan.primaryAssay] = updated_assay - updated_report = tuning_report.model_copy( - update={ - "assayReports": reports, - "recommendedByAssay": { - **tuning_report.recommendedByAssay, - plan.primaryAssay: replacement.candidateId, - }, - **( - { - "recommendedCandidateId": replacement.candidateId, - "selectedArtifacts": dict(replacement.artifacts), - "evidenceIds": list(review.selection.evidenceIds), - "rationale": review.selection.rationale, - } - if tuning_report.fromAssay == plan.primaryAssay - else {} - ), - } - ) - final_selection = updated_report.finalSelection - if final_selection is not None: - final_selection = final_selection.model_copy( - update={ - "selectedOptionId": ( - f"native:{plan.primaryAssay}:{replacement.candidateId}" - ), - "nativeAssay": plan.primaryAssay, - "nativeCandidateId": replacement.candidateId, - "integrationId": None, - "evidenceIds": list(review.selection.evidenceIds), - "rationale": review.selection.rationale, - } - ) - updated_report = finalize_parameter_tuning_selection( - updated_report, - marker_assay=plan.markerAssay, - native_assay=plan.primaryAssay, - final_selection=final_selection, - ) - stage_artifacts = { - name: ArtifactReferenceModel.model_validate(value.model_dump()) - for name, value in replacement.artifacts.items() - } - saved, reference = journal._save_stage_report( - store, - started, - updated_report, - invocation=AgentInvocation( - agentName="parameter_tuning", - parentReports=[journal._report_link(tuning_reference)], - inputs={ - "selectedCandidateId": selected.candidateId, - "replacementCandidateId": replacement.candidateId, - "revisedCheckpoint": checkpoint, - "evidenceBundle": bundle.model_dump(mode="json"), - "adjudicationMode": adjudication_mode, - "visualAdjudication": visual_review.model_dump(mode="json"), - }, - artifacts=stage_artifacts, - runConfig=request_record.config.agentRunConfig, - ), - expected_type=ParameterTuningReport, - ) - updated_report = cast(ParameterTuningReport, saved) outcome = journal._complete_attempt( started, - status="done", + status=report.status, report_references=[reference], - artifacts=stage_artifacts, + artifacts=artifacts, outputs={ - "revised": True, - "selectedCandidateId": selected.candidateId, - "replacementCandidateId": replacement.candidateId, - "revisedCheckpoint": checkpoint, - "adjudicationMode": adjudication_mode, - "decisionSelection": review.selection.model_dump(mode="json"), - "decisionSnapshotSha256": ( - descendant_snapshot or review.snapshotSha256 - ), - "visualAdjudication": visual_review.model_dump(mode="json"), + "tuningEvidence": evidence, + "candidateCount": report.totalCandidates, }, + needs_input=pending, actions=[ - "review_analysis_evidence", - f"revise_{checkpoint}", - "recompute_invalidated_tuning_decisions", + "assess_rna_defaults", + "execute_evidence_requested_experiments", + "validate_full_cohort", ], ) journal._save_outcome(store.zw, prefix, outcome) - return outcome, updated_report, reference - except Exception as exc: - outcome = journal.finish_exception( - store, - prefix, - workflow, - started, - exc, - ) - return outcome, tuning_report, tuning_reference - - def parameter_tuning_stage( - self, - store: DataStore, - workflow: AgentWorkflowRun, - request_record: OrchestrationRequestRecord, - parents: Sequence[WorkflowStageLink], - plan: AutomatedPreprocessingPlan, - preprocessed: Sequence[PreprocessedAssayHandoff], - experimental: ExperimentalContextResult, - enrichment_reference: AgentReportReference, - experimental_reference: AgentReportReference, - answers: Mapping[str, Any], - *, - study_contract: StudyContract | None = None, - resume_record: OrchestrationResumeRecord | None = None, - stage_name: WorkflowStageName = "parameter_tuning", - ) -> tuple[WorkflowStageAttempt, ParameterTuningReport]: - prefix = journal._ensure_orchestration_store(store) - cell_selection = preprocessed[0].cellSelection if preprocessed else None - if cell_selection is None or any( - value.cellSelection != cell_selection for value in preprocessed - ): - raise ValueError("Preprocessed assays must share one exact cell selection") - experimental_handoff = experimental.to_parameter_tuning_handoff().model_copy( - update={"cellSelection": cell_selection} - ) - metadata_columns = { - *experimental_handoff.batchColumns, - *experimental_handoff.preservationColumns, - *plan.cellQc.attributes, - } - if study_contract is not None: - metadata_columns.update(study_contract.technicalBatchColumns) - metadata_columns.update(study_contract.protectedColumns) - metadata_columns.update(study_contract.independentUnitColumns) - if study_contract.physicalCaptureColumn is not None: - metadata_columns.add(study_contract.physicalCaptureColumn) - metadata_fingerprints = { - column: ( - _metadata_column_fingerprint(store.cells, column) - if column in store.cells.columns - else None - ) - for column in sorted(metadata_columns) - } - feature_metadata = store.get_assay(plan.primaryAssay).feats - feature_metadata_fingerprints = { - column: _metadata_column_fingerprint(feature_metadata, column) - for column in ("ids", "names") - } - existing = journal._validated_done_outcome( - store, - prefix, - workflow.workflowRunId, - stage_name, - request_record, - parents, - ) - if existing is not None: - if ( - existing.inputs.get("metadataFingerprints") != metadata_fingerprints - or existing.inputs.get("featureMetadataFingerprints") - != feature_metadata_fingerprints - ): - raise ValueError( - "Tuning metadata changed since the saved evidence was computed; " - "restore the original metadata or start a new workflow" - ) - logger.info( - f"Workflow {workflow.workflowRunId}: reusing Parameter Tuning report" - ) - report = journal.load_stage_report(store, existing, ParameterTuningReport) - return existing, cast(ParameterTuningReport, report) - paused = journal._validated_done_outcome( - store, - prefix, - workflow.workflowRunId, - stage_name, - request_record, - parents, - required_status="needsInput", - ) - prior_sequential: SequentialAssayTuningEvidence | None = None - if paused is not None and paused.outputs.get("sequentialEvidence") is not None: - prior_sequential = SequentialAssayTuningEvidence.model_validate( - paused.outputs["sequentialEvidence"] - ) - if paused is not None and ( - paused.inputs.get("metadataFingerprints") != metadata_fingerprints - or paused.inputs.get("featureMetadataFingerprints") - != feature_metadata_fingerprints - ): - raise ValueError( - "Tuning metadata changed since the saved evidence was computed; " - "restore the original metadata or start a new workflow" - ) - tuning_answer = answers.get("parameter_tuning") - started = journal._start_attempt( - store.zw, - prefix, - workflow.workflowRunId, - stage_name, - request_record, - parents, - inputs={ - "preprocessedAssays": [ - value.model_dump(mode="json") for value in preprocessed - ], - "experimentalTuningHandoff": experimental_handoff.model_dump( - mode="json" - ), - "cellSelection": cell_selection.model_dump(mode="json"), - "primaryAssay": plan.primaryAssay, - "markerAssay": plan.markerAssay, - "pairedAssays": plan.pairedAssays, - "finalGraphOptionId": answers.get("finalGraphOptionId"), - "parameterTuning": tuning_answer, - "studyObjective": request_record.request.studyObjective, - "metadataFingerprints": metadata_fingerprints, - "featureMetadataFingerprints": feature_metadata_fingerprints, - "resumeFromAttempt": (paused.attemptId if paused is not None else None), - }, - resume_record=resume_record, - ) - report = ParameterTuningReport.get_blank() - actions: list[str] = [] - candidate_payload: dict[str, list[dict[str, Any]]] = {} - logger.info( - f"Workflow {workflow.workflowRunId}: Parameter Tuning started for " - f"{len(preprocessed)} RNA assay(s)" - ) - try: - agent = ParameterTuningAgent( - self.model, - config=request_record.config.agentRunConfig, - ) - recovered = ( - None - if paused is not None - else journal._recover_persisted_stage_report( - store, - started, - agent_name="parameter_tuning", - expected_type=ParameterTuningReport, - ) - ) - if recovered is not None: - recovered_report, recovered_reference = recovered - report = cast(ParameterTuningReport, recovered_report) - if report.integrationEvaluations or report.recommendedIntegrationId: - raise ValueError( - "Saved tuning report contains unsupported integration" - ) - candidate_payload = { - assay: [ - evaluation.parameters.model_dump(mode="json") - for evaluation in assay_report.evaluations - ] - for assay, assay_report in report.assayReports.items() - } - actions.append("recover_persisted_parameter_tuning_report") - logger.info( - f"Workflow {workflow.workflowRunId}: recovering completed " - "Parameter Tuning provider result" - ) - return self.save_parameter_tuning_outcome( - store, - prefix, - workflow, - request_record, - started, - report, - plan, - preprocessed, - candidate_payload, - enrichment_reference, - experimental_reference, - experimental_handoff, - agent, - actions, - persisted_reference=recovered_reference, - ) - if len(preprocessed) != 1 or plan.pairedAssays: - raise ValueError("Automated parameter tuning requires one RNA assay") - if study_contract is None: - raise ValueError("Decision-driven RNA tuning requires a StudyContract") - with candidate_metric_cache(): - report, sequential_evidence = self._run_sequential_rna_tuning( - store, - workflow, - request_record, - plan, - preprocessed, - experimental_handoff, - study_contract, - answers, - prior_sequential, - ) - candidate_payload = { - sequential_evidence.assay: [ - evaluation.parameters.model_dump(mode="json") - for evaluation in report.evaluations - ] - } - actions.extend( - f"adjudicate_{phase.plan.phase}" for phase in sequential_evidence.phases - ) - if report.searchPlan is not None: - actions.append("review_parameter_refinement") - actions.extend( - f"execute_refined_candidate:{candidate.candidateId}" - for candidate in report.searchPlan.candidates - ) - return self.save_parameter_tuning_outcome( - store, - prefix, - workflow, - request_record, - started, - report, - plan, - preprocessed, - candidate_payload, - enrichment_reference, - experimental_reference, - experimental_handoff, - agent, - actions, - sequential_evidence=sequential_evidence, - ) + return outcome, report except Exception as exc: - failure_artifacts: dict[str, ArtifactReferenceModel] = { - "cellSelection": cell_selection - } - for assay, assay_report in report.assayReports.items(): - for evaluation in assay_report.evaluations: - for name, artifact in evaluation.artifacts.items(): - failure_artifacts[ - f"{assay}_{evaluation.parameters.candidateId}_{name}" - ] = ArtifactReferenceModel.model_validate(artifact.model_dump()) outcome = journal.finish_exception( store, prefix, workflow, started, exc, - artifacts=failure_artifacts, - actions=actions, - outputs={ - "candidatePlan": candidate_payload, - }, + outputs={"tuningEvidence": runner.summary()}, ) return outcome, ParameterTuningReport.get_blank() - - def save_parameter_tuning_outcome( - self, - store: DataStore, - prefix: str, - workflow: AgentWorkflowRun, - request_record: OrchestrationRequestRecord, - started: WorkflowStageAttempt, - report: ParameterTuningReport, - plan: AutomatedPreprocessingPlan, - preprocessed: Sequence[PreprocessedAssayHandoff], - candidate_payload: Mapping[str, list[dict[str, Any]]], - enrichment_reference: AgentReportReference, - experimental_reference: AgentReportReference, - experimental_handoff: ExperimentalTuningHandoff, - agent: ParameterTuningAgent, - actions: Sequence[str], - *, - prior_tuning_reference: AgentReportReference | None = None, - persisted_reference: AgentReportReference | None = None, - sequential_evidence: SequentialAssayTuningEvidence | None = None, - ) -> tuple[WorkflowStageAttempt, ParameterTuningReport]: - if experimental_handoff.cellSelection is None: - raise ValueError("Parameter tuning handoff lacks an exact cell selection") - if report.cellSelection != experimental_handoff.cellSelection: - raise ValueError("Parameter tuning report uses a different cell selection") - invocation_artifacts: dict[str, ArtifactReferenceModel] = {} - for value in preprocessed: - if value.normalized is not None: - invocation_artifacts[f"{value.assay}_normalized"] = value.normalized - invocation_artifacts["cellSelection"] = experimental_handoff.cellSelection - stage_artifacts = dict(invocation_artifacts) - for assay, assay_report in report.assayReports.items(): - for name, artifact in assay_report.selectedArtifacts.items(): - stage_artifacts[f"{assay}_{name}"] = ( - ArtifactReferenceModel.model_validate(artifact.model_dump()) - ) - if report.finalClusterArtifact is not None: - stage_artifacts["final_clusters"] = ArtifactReferenceModel.model_validate( - report.finalClusterArtifact.model_dump() - ) - if report.graphAssay is not None: - assay_reports = report.assayReports or {report.fromAssay: report} - graph_artifact = assay_reports[report.graphAssay].selectedArtifacts[ - "connectivityMap" - ] - stage_artifacts["final_graph"] = ArtifactReferenceModel.model_validate( - graph_artifact.model_dump() - ) - if persisted_reference is None: - saved_report, reference = journal._save_stage_report( - store, - started, - report, - invocation=AgentInvocation( - agentName="parameter_tuning", - parentReports=[ - journal._report_link(enrichment_reference), - journal._report_link(experimental_reference), - *( - [journal._report_link(prior_tuning_reference)] - if prior_tuning_reference is not None - else [] - ), - ], - inputs={ - "assays": dict(candidate_payload), - "primaryAssay": plan.primaryAssay, - "markerAssay": plan.markerAssay, - "cellSelection": ( - experimental_handoff.cellSelection.model_dump(mode="json") - if experimental_handoff.cellSelection is not None - else None - ), - "maxCandidateEvaluations": ( - request_record.config.maxCandidateEvaluations - ), - }, - artifacts=stage_artifacts, - runConfig=agent.config, - experimentalTuningHandoff=experimental_handoff, - ), - expected_type=ParameterTuningReport, - ) - report = cast(ParameterTuningReport, saved_report) - else: - reference = persisted_reference - stage_report_references = [reference] - operations: list[dict[str, Any]] = [] - for assay, assay_report in report.assayReports.items(): - for candidate_evaluation in assay_report.evaluations: - operations.append( - { - "operation": "execute_parameter_candidate", - "assay": assay, - "candidate": candidate_evaluation.parameters.model_dump( - mode="json" - ), - "phase": candidate_evaluation.phase, - "cellSelection": ( - experimental_handoff.cellSelection.model_dump(mode="json") - ), - "harmonyBatchColumns": list( - candidate_evaluation.harmonyBatchColumns - ), - "identityFeatureLimit": ( - request_record.config.maxIdentityFeatures - ), - "status": candidate_evaluation.status, - "artifacts": { - name: value.model_dump(mode="json") - for name, value in candidate_evaluation.artifacts.items() - }, - } - ) - if ( - report.status == "needsInput" - and request_record.config.inputPolicy == "unattended" - ): - outcome = journal._complete_attempt( - started, - status="failed", - report_references=stage_report_references, - artifacts=stage_artifacts, - outputs={ - "candidateCount": report.totalCandidates, - "sequentialEvidence": ( - sequential_evidence.model_dump(mode="json") - if sequential_evidence is not None - else None - ), - "operations": operations, - }, - actions=actions, - error=( - "The unattended Parameter Tuning stage returned an unresolved " - "decision" - ), - notes=report.limitations, - ) - elif report.status == "needsInput": - needs_input = report.needsInput - assert needs_input is not None - if ( - sequential_evidence is not None - and sequential_evidence.pendingDecisionId is None - and report.searchPlan is None - ): - raise ValueError( - "Sequential tuning needsInput lacks a pending decision ID" - ) - outcome = journal._complete_attempt( - started, - status="needsInput", - report_references=stage_report_references, - artifacts=stage_artifacts, - outputs={ - "candidateCount": report.totalCandidates, - "sequentialEvidence": ( - sequential_evidence.model_dump(mode="json") - if sequential_evidence is not None - else None - ), - "operations": operations, - }, - actions=actions, - needs_input=WorkflowNeedsInput( - questions=[ - WorkflowQuestion( - questionId=( - f"decision:{sequential_evidence.pendingDecisionId}" - if sequential_evidence is not None - and sequential_evidence.pendingDecisionId is not None - else "finalGraphOptionId" - if report.finalSelection is not None - and report.finalSelection.status == "needsInput" - else "parameter_tuning" - ), - decisionId=( - sequential_evidence.pendingDecisionId - if sequential_evidence is not None - else None - ), - question=needs_input.question, - options=list(needs_input.options), - evidenceIds=list(needs_input.evidenceIds), - ) - ] - ), - notes=report.limitations, - ) - elif report.status == "abstained": - outcome = journal._complete_attempt( - started, - status="abstained", - report_references=stage_report_references, - artifacts=stage_artifacts, - outputs={ - "candidateCount": report.totalCandidates, - "sequentialEvidence": ( - sequential_evidence.model_dump(mode="json") - if sequential_evidence is not None - else None - ), - "operations": operations, - }, - actions=actions, - notes=( - report.limitations - or ["No defensible discrete clustering partition was found."] - ), - ) - elif report.status == "failed": - outcome = journal._complete_attempt( - started, - status="failed", - report_references=stage_report_references, - artifacts=stage_artifacts, - outputs={ - "candidateCount": report.totalCandidates, - "sequentialEvidence": ( - sequential_evidence.model_dump(mode="json") - if sequential_evidence is not None - else None - ), - "operations": operations, - }, - actions=actions, - error="; ".join(report.limitations) or "Parameter Tuning failed", - ) - else: - outcome = journal._complete_attempt( - started, - status="done", - report_references=stage_report_references, - artifacts=stage_artifacts, - outputs={ - "candidateCount": report.totalCandidates, - "recommendedByAssay": report.recommendedByAssay, - "recommendedIntegrationId": report.recommendedIntegrationId, - "sequentialEvidence": ( - sequential_evidence.model_dump(mode="json") - if sequential_evidence is not None - else None - ), - "operations": operations, - }, - actions=actions, - notes=[*report.tradeoffs, *report.limitations], - ) - journal._save_outcome(store.zw, prefix, outcome) - logger.info( - f"Workflow {workflow.workflowRunId}: Parameter Tuning outcome " - f"status={outcome.status!r}, candidates={report.totalCandidates}" - ) - if outcome.status == "failed": - journal.finalize_failed(store, workflow, outcome.error or "tuning failed") - return outcome, report diff --git a/scarf/agent/parameter_tuning/contracts.py b/scarf/agent/parameter_tuning/contracts.py index 96bf2155..cc18a20d 100644 --- a/scarf/agent/parameter_tuning/contracts.py +++ b/scarf/agent/parameter_tuning/contracts.py @@ -44,15 +44,6 @@ def from_ref(cls, ref: Any) -> "ArtifactRecord": def get_blank(cls) -> "ArtifactRecord": return cls() - @classmethod - def get_example(cls) -> "ArtifactRecord": - return cls( - scope="assay", - kind="connectivity_map", - artifactId="a" * 64, - assay="RNA", - ) - class ParameterCandidate(AgentDataModel): """One exact, caller-authorized parameter candidate.""" @@ -71,17 +62,6 @@ class ParameterCandidate(AgentDataModel): def get_blank(cls) -> "ParameterCandidate": return cls() - @classmethod - def get_example(cls) -> "ParameterCandidate": - return cls( - candidateId="baseline", - reductionMethod="pca", - dimensions=21, - leidenResolution=1.0, - neighborsK=11, - useHarmony=False, - ) - class ParameterMetrics(AgentDataModel): """Bounded quality metrics for one candidate branch.""" @@ -141,22 +121,6 @@ class ParameterMetrics(AgentDataModel): def get_blank(cls) -> "ParameterMetrics": return cls() - @classmethod - def get_example(cls) -> "ParameterMetrics": - return cls( - nClusters=8, - minClusterCells=42, - minClusterFraction=0.021, - graphSilhouetteMedian=0.41, - pcaSilhouette=0.36, - macroF1=0.82, - weightedF1=0.86, - batchMixing={"batch": 0.73}, - biologicalPreservation={ - "cell_type": {"clisi": 0.88, "graphConnectivity": 0.91} - }, - ) - class ParameterCandidateEvaluation(AgentDataModel): """Execution record returned to the model for one candidate.""" @@ -182,35 +146,6 @@ class ParameterCandidateEvaluation(AgentDataModel): def get_blank(cls) -> "ParameterCandidateEvaluation": return cls() - @classmethod - def get_example(cls) -> "ParameterCandidateEvaluation": - candidate = ParameterCandidate.get_example() - return cls( - candidateId=candidate.candidateId, - status="done", - eligible=True, - parameters=candidate, - artifacts={ - "connectivityMap": ArtifactRecord.get_example(), - "clusters": ArtifactRecord( - assay="RNA", - kind="cluster_labels", - artifactId="b" * 64, - ), - }, - cellSelection=ArtifactReferenceModel( - scope="datastore", - assay=None, - kind="cell_selection", - artifactId="c" * 64, - ), - clusterColumn="RNA_agent_tuning_baseline", - clusterLabel="agent_tuning_baseline", - effectiveDimensions=21, - metrics=ParameterMetrics.get_example(), - evidenceIds=["candidate:baseline:clusters"], - ) - class IntegrationMetrics(AgentDataModel): """Metrics that are valid for an integrated graph comparison.""" @@ -227,17 +162,6 @@ class IntegrationMetrics(AgentDataModel): def get_blank(cls) -> "IntegrationMetrics": return cls() - @classmethod - def get_example(cls) -> "IntegrationMetrics": - return cls( - nClusters=8, - minClusterCells=37, - minClusterFraction=0.0185, - adjustedRandByAssay={"RNA": 0.71, "ADT": 0.63}, - normalizedMutualInformationByAssay={"RNA": 0.76, "ADT": 0.69}, - modalityWeightsValid=True, - ) - class IntegrationCandidateEvaluation(AgentDataModel): """One executor-produced SNN or WNN graph and cluster evaluation.""" @@ -262,35 +186,6 @@ class IntegrationCandidateEvaluation(AgentDataModel): def get_blank(cls) -> "IntegrationCandidateEvaluation": return cls() - @classmethod - def get_example(cls) -> "IntegrationCandidateEvaluation": - return cls( - integrationId="wnn_resolution_1", - method="wnn", - assays=["RNA", "ADT"], - status="done", - eligible=True, - cellSelection=ArtifactReferenceModel( - scope="datastore", - assay=None, - kind="cell_selection", - artifactId="c" * 64, - ), - graphArtifact=ArtifactRecord( - scope="datastore", - kind="integrated_graph", - artifactId="2" * 64, - ), - clusterArtifact=ArtifactRecord( - scope="datastore", - kind="cluster_labels", - artifactId="3" * 64, - ), - clusterColumn="agent_wnn_cluster", - metrics=IntegrationMetrics.get_example(), - evidenceIds=["integration:wnn_resolution_1:clusters"], - ) - class FinalGraphComparison(AgentDataModel): """Evidence-backed comparison against one eligible final graph option.""" @@ -303,17 +198,6 @@ class FinalGraphComparison(AgentDataModel): def get_blank(cls) -> "FinalGraphComparison": return cls() - @classmethod - def get_example(cls) -> "FinalGraphComparison": - return cls( - optionId="native:ADT:baseline", - summary="The RNA-native option better preserves the requested labels.", - evidenceIds=[ - "native:RNA:candidate:baseline:clusters", - "native:ADT:candidate:baseline:clusters", - ], - ) - class FinalGraphNeedsInput(AgentDataModel): """Concrete input needed before a final graph can be selected.""" @@ -326,13 +210,6 @@ class FinalGraphNeedsInput(AgentDataModel): def get_blank(cls) -> "FinalGraphNeedsInput": return cls() - @classmethod - def get_example(cls) -> "FinalGraphNeedsInput": - return cls( - question="Which biological signal must the final graph preserve?", - options=["cell_type", "condition"], - ) - class FinalGraphSelection(AgentDataModel): """Grounded choice among selected native, SNN, and WNN graph options.""" @@ -357,21 +234,6 @@ class FinalGraphSelection(AgentDataModel): def get_blank(cls) -> "FinalGraphSelection": return cls() - @classmethod - def get_example(cls) -> "FinalGraphSelection": - return cls( - status="done", - selectedOptionId="native:RNA:baseline", - graphMethod="native", - nativeAssay="RNA", - nativeCandidateId="baseline", - markerAssay="RNA", - confidence="medium", - rationale="The selected native graph has the strongest supported balance.", - evidenceIds=["native:RNA:candidate:baseline:clusters"], - runInfo=AgentRunInfo.get_example(), - ) - class CandidateComparison(AgentDataModel): """Evidence-backed comparison against one executed non-selected candidate.""" @@ -384,17 +246,6 @@ class CandidateComparison(AgentDataModel): def get_blank(cls) -> "CandidateComparison": return cls() - @classmethod - def get_example(cls) -> "CandidateComparison": - return cls( - candidateId="pca_15", - summary="The selected baseline retains larger minimum clusters.", - evidenceIds=[ - "candidate:baseline:clusters", - "candidate:pca_15:clusters", - ], - ) - class ParameterSearchPlan(AgentDataModel): """Validated proposal for one bounded refinement pass.""" @@ -425,31 +276,6 @@ class ParameterSearchPlan(AgentDataModel): def get_blank(cls) -> "ParameterSearchPlan": return cls() - @classmethod - def get_example(cls) -> "ParameterSearchPlan": - return cls( - status="refine", - candidates=[ - ParameterCandidate( - candidateId="refined_pca_18", - dimensions=18, - leidenResolution=1.0, - neighborsK=11, - useHarmony=False, - ) - ], - basedOnCandidateIds=["baseline", "pca_15"], - harmonyBatchColumns=[], - objectives=["Resolve the dimension tradeoff."], - rationale="The initial screen brackets a narrower dimension range.", - evidenceIds=[ - "candidate:baseline:clusters", - "candidate:pca_15:clusters", - ], - stoppingCriteria=["Run the proposed candidate once."], - runInfo=AgentRunInfo.get_example(), - ) - class ParameterTuningBatchSearchPlan(AgentDataModel): """One bounded refinement plan for every assay in a batched screen.""" @@ -461,10 +287,6 @@ class ParameterTuningBatchSearchPlan(AgentDataModel): def get_blank(cls) -> "ParameterTuningBatchSearchPlan": return cls() - @classmethod - def get_example(cls) -> "ParameterTuningBatchSearchPlan": - return cls(assayPlans={"RNA": ParameterSearchPlan.get_example()}) - class ParameterTuningNeedsInput(AgentDataModel): """User input required before tuning can produce a recommendation.""" @@ -477,14 +299,6 @@ class ParameterTuningNeedsInput(AgentDataModel): def get_blank(cls) -> "ParameterTuningNeedsInput": return cls() - @classmethod - def get_example(cls) -> "ParameterTuningNeedsInput": - return cls( - question="Which trusted biological label should be preserved?", - options=["cell_type", "none"], - evidenceIds=["candidate:baseline:batchMixing:batch"], - ) - class ParameterTuningReport(AgentDataModel): """Grounded recommendation over candidate branches actually executed.""" @@ -522,30 +336,6 @@ class ParameterTuningReport(AgentDataModel): def get_blank(cls) -> "ParameterTuningReport": return cls() - @classmethod - def get_example(cls) -> "ParameterTuningReport": - evaluation = ParameterCandidateEvaluation.get_example() - return cls( - status="done", - fromAssay="RNA", - cellSelection=evaluation.cellSelection, - evaluations=[evaluation], - recommendedCandidateId=evaluation.candidateId, - selectedArtifacts=dict(evaluation.artifacts), - confidence="medium", - rationale="The baseline balances separation and cluster size.", - evidenceIds=["candidate:baseline:clusters"], - tradeoffs=["Higher resolutions produced smaller clusters."], - limitations=["No trusted biological preservation label was supplied."], - stopReason="All authorized candidates were evaluated.", - recommendedByAssay={"RNA": evaluation.candidateId}, - totalCandidates=1, - graphAssay="RNA", - markerAssay="RNA", - finalSelection=FinalGraphSelection.get_example(), - runInfo=AgentRunInfo.get_example(), - ) - def to_biological_handoff( self, *, @@ -663,6 +453,10 @@ class ParameterTuningDependencies(AgentDataModel): candidatePhases: dict[str, CandidatePhase] = Field(default_factory=dict) batchColumns: tuple[str, ...] = () preservationColumns: tuple[str, ...] = () + protectedCombinations: tuple[tuple[str, ...], ...] = () + columnKinds: dict[str, Literal["categorical", "continuous"]] = Field( + default_factory=dict + ) harmonyAuthorized: bool = False maxCandidates: int = 5 minClusterCells: int = 20 @@ -675,17 +469,6 @@ class ParameterTuningDependencies(AgentDataModel): def get_blank(cls) -> "ParameterTuningDependencies": return cls() - @classmethod - def get_example(cls) -> "ParameterTuningDependencies": - candidate = ParameterCandidate.get_example() - return cls( - fromAssay="RNA", - normalizedShape=(1000, 2000), - candidates={candidate.candidateId: candidate}, - batchColumns=("batch",), - preservationColumns=("cell_type",), - ) - class ParameterTuningAssayInput(AgentDataModel): """One assay branch supplied to batched parameter tuning.""" @@ -705,18 +488,6 @@ class ParameterTuningAssayInput(AgentDataModel): def get_blank(cls) -> "ParameterTuningAssayInput": return cls() - @classmethod - def get_example(cls) -> "ParameterTuningAssayInput": - return cls( - normalized=ArtifactRecord( - assay="RNA", - kind="normalized", - artifactId="4" * 64, - ), - candidates=_default_parameter_candidates(), - experimentalHandoff=ExperimentalTuningHandoff(batchAction="skip"), - ) - def _default_parameter_candidates() -> list[ParameterCandidate]: """Return a small one-factor candidate set around Scarf defaults.""" diff --git a/scarf/agent/parameter_tuning/diagnostics.py b/scarf/agent/parameter_tuning/diagnostics.py index c4f9a1e5..1eff9f4f 100644 --- a/scarf/agent/parameter_tuning/diagnostics.py +++ b/scarf/agent/parameter_tuning/diagnostics.py @@ -1,15 +1,20 @@ """Deterministic representation and partition evidence for RNA decisions.""" import hashlib +from collections import Counter, defaultdict from collections.abc import Mapping, Sequence from dataclasses import dataclass, field from typing import Any, cast import numpy as np +import pandas as pd from sklearn.metrics import adjusted_rand_score, normalized_mutual_info_score from ...clustering.leiden import leiden_membership -from ...metadata.rows import read_metadata_rows_chunkwise +from ...metadata.rows import ( + read_metadata_missing_rows_chunkwise, + read_metadata_rows_chunkwise, +) from ...quality_control.cell_cycle_genes import ( g2m_phase_genes, g2m_phase_genes_mouse, @@ -410,25 +415,33 @@ def _aligned_metadata_values( values = np.asarray(read_metadata_rows_chunkwise(store.cells, column, indices)) if values.shape != (len(indices),): raise ValueError(f"Metadata column {column!r} does not align with PCA") + missing = read_metadata_missing_rows_chunkwise(store.cells, column, indices) + if missing is not None and np.any(missing): + values = values.astype(object) + values[missing] = None return values def _numeric_association(coordinates: Any, values: np.ndarray) -> np.ndarray: - numeric = np.asarray(values, dtype=np.float64) - if not np.isfinite(numeric).all(): - raise ValueError("Numeric PCA covariates must be finite") + numeric = pd.to_numeric(pd.Series(values), errors="coerce").to_numpy( + dtype=np.float64 + ) + valid = np.isfinite(numeric) n_rows, n_components = coordinates.shape total_x = np.zeros(n_components, dtype=np.float64) total_x2 = np.zeros(n_components, dtype=np.float64) total_xy = np.zeros(n_components, dtype=np.float64) - total_y = float(numeric.sum()) - total_y2 = float(np.square(numeric).sum()) + total_y = float(numeric[valid].sum()) + total_y2 = float(np.square(numeric[valid]).sum()) for start in range(0, n_rows, 65_536): block = np.asarray(coordinates[start : start + 65_536], dtype=np.float64) - y = numeric[start : start + len(block)] + selected = valid[start : start + len(block)] + y = numeric[start : start + len(block)][selected] + block = block[selected] total_x += block.sum(axis=0) total_x2 += np.square(block).sum(axis=0) total_xy += (block * y[:, None]).sum(axis=0) + n_rows = int(valid.sum()) numerator = n_rows * total_xy - total_x * total_y denominator = np.sqrt( np.maximum(n_rows * total_x2 - np.square(total_x), 0.0) @@ -446,19 +459,27 @@ def _numeric_association(coordinates: Any, values: np.ndarray) -> np.ndarray: def _categorical_association(coordinates: Any, values: np.ndarray) -> np.ndarray: - labels = values.astype(str) + valid = np.asarray(~pd.isna(values), dtype=bool) + labels = values[valid].astype(str) _levels, codes = np.unique(labels, return_inverse=True) n_rows, n_components = coordinates.shape totals = np.zeros(n_components, dtype=np.float64) totals_squared = np.zeros(n_components, dtype=np.float64) + if not len(codes): + return np.zeros(n_components, dtype=np.float64) + all_codes = np.full(len(values), -1, dtype=np.int64) + all_codes[valid] = codes group_sums = np.zeros((int(codes.max()) + 1, n_components), dtype=np.float64) group_counts = np.bincount(codes, minlength=group_sums.shape[0]).astype(np.float64) for start in range(0, n_rows, 65_536): block = np.asarray(coordinates[start : start + 65_536], dtype=np.float64) - block_codes = codes[start : start + len(block)] + selected = valid[start : start + len(block)] + block_codes = all_codes[start : start + len(block)][selected] + block = block[selected] totals += block.sum(axis=0) totals_squared += np.square(block).sum(axis=0) np.add.at(group_sums, block_codes, block) + n_rows = int(valid.sum()) grand_mean = totals / n_rows group_means = np.divide( group_sums, @@ -489,20 +510,45 @@ def _covariate_associations( coordinates: Any, columns: Sequence[str], roles: Sequence[str], + column_kinds: Mapping[str, str] | None = None, + support: dict[str, Any] | None = None, ) -> np.ndarray: if len(columns) != len(roles): raise ValueError("PCA covariate columns and roles must align") associations = np.zeros((len(columns), coordinates.shape[1]), dtype=np.float64) - for index, (column, role) in enumerate(zip(columns, roles, strict=True)): + from ..experimental_context.characterization import _infer_kind + + for index, column in enumerate(columns): values = _aligned_metadata_values(store, cell_selection, column) - if ( - role == "qc" - and values.dtype.kind in {"i", "u", "f"} - and len(np.unique(values)) > 10 - ): + kind = (column_kinds or {}).get(column) or _infer_kind(values) + if kind not in {"continuous", "categorical"}: + raise ValueError(f"Unknown covariate kind for {column!r}: {kind!r}") + valid = np.asarray(~pd.isna(values), dtype=bool) + if kind == "continuous": + numeric = pd.to_numeric(pd.Series(values), errors="coerce").to_numpy( + dtype=float + ) + valid &= np.isfinite(numeric) associations[index] = _numeric_association(coordinates, values) else: - associations[index] = _categorical_association(coordinates, values) + if len(pd.unique(values[valid])) < int(valid.sum()): + associations[index] = _categorical_association(coordinates, values) + levels = len(pd.unique(values[valid])) + if support is not None: + support[column] = { + "kind": kind, + "method": "absolutePearson" + if kind == "continuous" + else "correlationRatio", + "completeRows": int(valid.sum()), + "missingRows": int((~valid).sum()), + "levels": levels, + "status": "computed" + if int(valid.sum()) >= 2 + and levels >= 2 + and (kind == "continuous" or levels < int(valid.sum())) + else "notComputed", + } return associations @@ -534,6 +580,7 @@ def _write_pca_diagnostic( covariate_columns: Sequence[str], covariate_roles: Sequence[str], adjacent_overlap: float | None, + column_kinds: Mapping[str, str] | None = None, ) -> tuple[ ArtifactRef, np.ndarray, @@ -568,6 +615,11 @@ def _write_pca_diagnostic( "family_names": list(family_masks), "covariate_columns": list(covariate_columns), "covariate_roles": list(covariate_roles), + "covariate_kinds": { + column: (column_kinds or {}).get(column, "inferred") + for column in covariate_columns + }, + "covariate_method": "typedCompleteCaseAssociation", "top_loading_count": top_n, "family_mask_fingerprints": { family: hashlib.sha256( @@ -618,6 +670,7 @@ def _write_pca_diagnostic( AttributeRequirement("family_names", expected_types=(list,)), AttributeRequirement("covariate_columns", expected_types=(list,)), AttributeRequirement("covariate_roles", expected_types=(list,)), + AttributeRequirement("covariate_support", expected_types=(dict,)), AttributeRequirement("payload_fingerprint", expected_types=(str,)), ), ) @@ -674,6 +727,7 @@ def _write_pca_diagnostic( selected_indices, family_masks, ) + covariate_support: dict[str, Any] = {} associations = ( _covariate_associations( store, @@ -686,6 +740,8 @@ def _write_pca_diagnostic( coordinates, covariate_columns, covariate_roles, + column_kinds, + covariate_support, ) if evaluation.cellSelection is not None else np.zeros((len(covariate_columns), coordinates.shape[1]), dtype=np.float64) @@ -718,6 +774,7 @@ def _write_pca_diagnostic( group.attrs["family_names"] = list(family_masks) group.attrs["covariate_columns"] = list(covariate_columns) group.attrs["covariate_roles"] = list(covariate_roles) + group.attrs["covariate_support"] = covariate_support group.attrs["payload_fingerprint"] = fingerprint_stored_arrays( group, _PCA_DIAGNOSTIC_ARRAYS, @@ -745,6 +802,7 @@ def augment_pca_evaluations( protected_columns: Sequence[str], qc_columns: Sequence[str], batch_columns: Sequence[str] = (), + column_kinds: Mapping[str, str] | None = None, ) -> tuple[ParameterCandidateEvaluation, ...]: """Attach persisted PCA loading, variance, topology, and covariate evidence.""" selected_indices, selected_names = _selected_feature_names( @@ -824,6 +882,7 @@ def augment_pca_evaluations( covariate_columns=columns, covariate_roles=roles, adjacent_overlap=previous_by_id[evaluation.candidateId], + column_kinds=column_kinds, ) family_maxima = { family: float(family_enrichment[index].max(initial=0.0)) @@ -846,7 +905,15 @@ def augment_pca_evaluations( ] for component in range(top_indices.shape[0]) } - column_index = {column: index for index, column in enumerate(columns)} + support = dict(store.load_artifact(diagnostic).attrs["covariate_support"]) + column_index = { + column: index + for index, column in enumerate(columns) + if support.get(column, {}).get("status") == "computed" + } + unsupported_covariates = [ + column for column in columns if column not in column_index + ] component_associations = { role: { column: associations[column_index[column]].tolist() @@ -888,6 +955,13 @@ def augment_pca_evaluations( evaluation.model_copy( update={ "metrics": metrics, + "warnings": [ + *evaluation.warnings, + *( + f"PCA association for {column!r} is unavailable with {support.get(column, {}).get('completeRows', 0)} complete rows and {support.get(column, {}).get('levels', 0)} distinct values." + for column in unsupported_covariates + ), + ], "artifacts": { **evaluation.artifacts, "representationDiagnostic": artifact, @@ -1462,6 +1536,194 @@ def _cross_unit_support(labels: np.ndarray, units: np.ndarray) -> float | None: return float(np.mean(supported)) if supported else None +def population_support_evidence( + store: Any, + evaluation: ParameterCandidateEvaluation, + columns: Sequence[str], +) -> dict[str, Any]: + """Describe observed population support using exact cells and requested units. + + This is descriptive support, not a validation of population identity or + independent replication. Display limits do not change any count or fraction. + """ + if evaluation.status != "done" or evaluation.cellSelection is None: + raise ValueError("Population support requires completed, cell-bound evidence") + selection = ArtifactRef( + scope=evaluation.cellSelection.scope, + assay=evaluation.cellSelection.assay, + kind=evaluation.cellSelection.kind, + artifact_id=evaluation.cellSelection.artifactId, + ) + clusters = _artifact_ref(evaluation, "clusters") + status = store.inspect_artifact(clusters) + if not status.exists or not status.complete: + raise ValueError("Population support requires complete cluster evidence") + raw_selection = (status.inputs or {}).get("cell_selection") + if ( + not isinstance(raw_selection, Mapping) + or ArtifactRef.from_dict(dict(raw_selection)) != selection + ): + raise ValueError("Population support cluster and candidate cells differ") + if clusters.kind not in {"cluster_labels", "cluster_cut"}: + raise ValueError("Population support requires a clustering artifact") + indices = read_stored_selection_indices( + store.zw, + selection, + kind="cell_selection", + scope="datastore", + assay=None, + table_path="cellData", + ) + if not len(indices) or np.any(indices[1:] <= indices[:-1]): + raise ValueError("Population support requires distinct ordered selected cells") + labels = as_zarr_array( + store.load_artifact(clusters)[ + "values" if clusters.kind == "cluster_labels" else "labels" + ], + name="cluster labels", + ) + if labels.shape != indices.shape or labels.dtype.kind not in "iuf": + raise ValueError("Population labels do not align with the selected cells") + requested = list(dict.fromkeys(columns)) + selected_columns = requested[:2] + available = [column for column in selected_columns if column in store.cells.columns] + totals: Counter[int] = Counter() + group_totals: dict[str, Counter[tuple[str, Any]]] = { + column: Counter() for column in available + } + counts: dict[str, dict[int, Counter[tuple[str, Any]]]] = { + column: defaultdict(Counter) for column in available + } + for start in range(0, len(indices), 65_536): + stop = min(start + 65_536, len(indices)) + block = np.asarray(labels[start:stop]) + if not np.isfinite(block).all() or not np.equal(block, np.floor(block)).all(): + raise ValueError("Population labels must be finite integers") + population_ids = block.astype(np.int64) + unique, sizes = np.unique(population_ids, return_counts=True) + totals.update( + { + int(population): int(size) + for population, size in zip(unique, sizes, strict=True) + } + ) + rows = indices[start:stop] + for column in available: + values = np.asarray(read_metadata_rows_chunkwise(store.cells, column, rows)) + missing = read_metadata_missing_rows_chunkwise(store.cells, column, rows) + if ( + values.shape != rows.shape + or missing is not None + and missing.shape != rows.shape + ): + raise ValueError( + f"Population metadata {column!r} does not align with cells" + ) + for offset, (population, value) in enumerate( + zip(population_ids, values, strict=True) + ): + value = value.item() if isinstance(value, np.generic) else value + if ( + missing is not None + and missing[offset] + or pd.isna(value) + or isinstance(value, float) + and not np.isfinite(value) + ): + continue + if isinstance(value, bytes): + value = value.decode("utf-8") + if not isinstance(value, (str, int, float, bool)): + raise ValueError("Population unit labels must be scalar values") + if isinstance(value, str) and not value.strip(): + continue + key = (type(value).__name__, value) + group_totals[column][key] += 1 + counts[column][int(population)][key] += 1 + evidence_columns: dict[str, Any] = {} + for column in selected_columns: + if column not in available: + evidence_columns[column] = { + "status": "unavailable", + "reason": "Column is absent.", + } + continue + population_rows: list[dict[str, Any]] = [] + for population, size in totals.items(): + group_counts = counts[column][population] + ordered = sorted( + group_counts.items(), + key=lambda item: (-item[1], item[0][0], str(item[0][1])), + ) + displayed = ordered[:5] + covered = sum(group_counts.values()) + population_rows.append( + { + "cluster": str(population), + "cells": size, + "coveredCells": covered, + "missingCells": size - covered, + "coverageFraction": covered / size, + "supportingGroups": len(group_counts), + "groupsWithAtLeast5Cells": sum( + count >= 5 for count in group_counts.values() + ), + "largestGroupFraction": ordered[0][1] / size if ordered else None, + "topGroups": [ + { + "value": key[1], + "valueType": key[0], + "cells": count, + "fractionOfPopulation": count / size, + "fractionOfGroup": count / group_totals[column][key], + } + for key, count in displayed + ], + "omittedGroups": len(ordered) - len(displayed), + "omittedCells": sum(count for _, count in ordered[5:]), + } + ) + population_rows.sort( + key=lambda row: ( + -(row["largestGroupFraction"] or 0), + row["cells"], + row["cluster"], + ) + ) + covered = sum(group_totals[column].values()) + evidence_columns[column] = { + "status": "computed", + "observedGroups": len(group_totals[column]), + "coveredCells": covered, + "missingCells": len(indices) - covered, + "coverageFraction": covered / len(indices), + "populations": population_rows[:64], + "omittedPopulations": max(0, len(population_rows) - 64), + "omittedPopulationCells": sum(row["cells"] for row in population_rows[64:]), + } + return { + "candidateId": evaluation.candidateId, + "cellSelection": selection.to_dict(), + "clusters": clusters.to_dict(), + "selectedCells": len(indices), + "observedPopulations": len(totals), + "columns": evidence_columns, + "omittedColumns": requested[2:], + "displayLimits": { + "columns": 2, + "populationsPerColumn": 64, + "groupsPerPopulation": 5, + }, + "interpretation": ( + "Counts use all exact selected cells. Population fractions include cells with missing unit metadata; " + "group fractions use all selected cells with that unit value. Missing values are unassigned. " + "Displayed populations prioritize concentration in one group, then smaller size. " + "A group with at least five cells is a descriptive count, not a replication threshold. " + "Shared donor or capture support does not establish biological identity or rule out artifacts." + ), + } + + def _subsample_partition_stability( graph: Any, labels: np.ndarray, @@ -1540,25 +1802,30 @@ def augment_cluster_evaluations( ), ) - marker_ref = store.run_marker_search( - clusters_ref, - from_assay=marker_assay, - features=marker_features, - invalidate_cache=False, - ) - markers = store.get_markers( - marker_ref, - min_score=0.25, - min_frac_exp=0.2, - ) + cluster_count = len(np.unique(labels)) + marker_ref = None + markers = pd.DataFrame() + if cluster_count >= 2: + marker_ref = store.run_marker_search( + clusters_ref, + from_assay=marker_assay, + features=marker_features, + invalidate_cache=False, + ) + markers = store.get_markers( + marker_ref, + min_score=0.25, + min_frac_exp=0.2, + ) marker_groups = ( set(markers["group_id"].astype(str)) if "group_id" in markers.columns else set() ) - cluster_count = len(np.unique(labels)) marker_coherence = ( - float(len(marker_groups) / cluster_count) if cluster_count else 0.0 + float(len(marker_groups) / cluster_count) + if marker_ref is not None + else None ) marker_names = ( markers["feature_name"].astype(str).to_numpy() @@ -1616,7 +1883,7 @@ def augment_cluster_evaluations( ) background = float(mask.mean()) enrichment = marker_fraction / background if background > 0 else 0.0 - if family in nominated_families: + if marker_ref is not None and family in nominated_families: marker_family_enrichment[family] = enrichment if family in protected_families and marker_fraction > 0: protected_marker_families.append(family) @@ -1703,9 +1970,15 @@ def augment_cluster_evaluations( *evaluation.evidenceIds, f"candidate:{evaluation.candidateId}:seedStability", f"candidate:{evaluation.candidateId}:subsampleStability", - f"candidate:{evaluation.candidateId}:markerCoherence", - f"candidate:{evaluation.candidateId}:markerSpecificity", - f"candidate:{evaluation.candidateId}:markerFamilies", + *( + [ + f"candidate:{evaluation.candidateId}:markerCoherence", + f"candidate:{evaluation.candidateId}:markerSpecificity", + f"candidate:{evaluation.candidateId}:markerFamilies", + ] + if marker_ref is not None + else [] + ), *( [f"candidate:{evaluation.candidateId}:crossUnitSupport"] if cross_unit_support is not None @@ -1732,7 +2005,11 @@ def augment_cluster_evaluations( artifacts = { **evaluation.artifacts, "stabilityClusters": ArtifactRecord.from_ref(alternative_ref), - "markerTable": ArtifactRecord.from_ref(marker_ref), + **( + {"markerTable": ArtifactRecord.from_ref(marker_ref)} + if marker_ref is not None + else {} + ), **( { f"doubletScore:{index}": ArtifactRecord.from_ref(score) @@ -1766,6 +2043,21 @@ def augment_cluster_evaluations( evaluation.model_copy( update={ "metrics": metrics, + "eligible": evaluation.eligible and cluster_count >= 2, + "eligibilityReasons": list( + dict.fromkeys( + [ + *evaluation.eligibilityReasons, + *( + [ + "Marker contrasts require at least two populated clusters" + ] + if cluster_count < 2 + else [] + ), + ] + ) + ), "evidenceIds": list(dict.fromkeys(evidence_ids)), "artifacts": artifacts, "warnings": list( @@ -1790,6 +2082,7 @@ def augment_cluster_evaluations( "AdvisoryDoubletScores", "augment_cluster_evaluations", "augment_pca_evaluations", + "population_support_evidence", "resolve_native_doublet_inputs", "score_advisory_doublets", ] diff --git a/scarf/agent/parameter_tuning/execution.py b/scarf/agent/parameter_tuning/execution.py index 1e1dc9c2..126abc0f 100644 --- a/scarf/agent/parameter_tuning/execution.py +++ b/scarf/agent/parameter_tuning/execution.py @@ -8,7 +8,7 @@ import numpy as np from ...metrics import graph_connectivity -from ...metadata.rows import iter_metadata_column_blocks +from ...metadata.rows import iter_metadata_column_blocks, metadata_missing_mask from ...storage.refs import ArtifactRef from ...storage.types import as_zarr_array from ...utils.logging import logger @@ -77,6 +77,13 @@ def _metadata_column_fingerprint(metadata: Any, column: str) -> str: if block.dtype.hasobject else block.tobytes() ) + missing = metadata_missing_mask(metadata, column) + digest.update(b"missing:none" if missing is None else b"missing:present") + if missing is not None: + for start in range(0, len(missing), 65_536): + digest.update( + np.asarray(missing[start : start + 65_536], dtype=bool).tobytes() + ) return digest.hexdigest() @@ -511,6 +518,12 @@ def separability_values() -> dict[str, Any]: warnings.append(f"Batch mixing for {column!r} unavailable: {exc}") for column in deps.preservationColumns: + if deps.columnKinds.get(column) == "continuous": + warnings.append( + f"Matched graph preservation for continuous column {column!r} is unsupported; " + "PCA association is descriptive evidence only." + ) + continue scores: dict[str, float] = {} try: clisi = float( @@ -563,6 +576,54 @@ def separability_values() -> dict[str, Any]: if scores: metrics.biologicalPreservation[column] = scores + if deps.protectedCombinations: + from ...metrics import clisi_knn + from ..experimental_context.characterization import _SelectionBoundCells + from ..experimental_context.comparisons import combination_labels + + bound_cells = _SelectionBoundCells(store.zw, store.cells, deps.cellSelection) + neighbor_group = store.load_artifact(neighbors_ref) + graph_group = store.load_artifact(graph_ref) + for columns in deps.protectedCombinations: + name = "joint:" + json.dumps(list(columns), separators=(",", ":")) + metadata_key = tuple( + _metric_metadata_key(store, column) for column in columns + ) + labels = combination_labels(bound_cells, columns) + scores = _cached_candidate_metric( + ( + id(store), + "protected_combination", + neighbors_ref, + graph_ref, + columns, + metadata_key, + ), + lambda: { + "clisi": float( + clisi_knn( + as_zarr_array( + neighbor_group["distances"], name="distances" + ), + as_zarr_array(neighbor_group["indices"], name="indices"), + labels, + perplexity=None, + scale=True, + ) + ), + "graphConnectivity": float( + graph_connectivity( + as_zarr_array(graph_group["edges"], name="edges"), + labels, + ) + ), + }, + ) + if not all(np.isfinite(value) for value in scores.values()): + raise ValueError("Protected combination metrics must be finite") + metrics.biologicalPreservation[name] = scores + evidence_ids.append(f"candidate:{candidate_id}:{name}") + eligibility_reasons: list[str] = [] if n_clusters < 2: eligibility_reasons.append("fewer than two clusters") @@ -621,10 +682,10 @@ def execute_parameter_candidate( candidate = deps.candidates[candidate_id] deps.executionOrder.append(candidate_id) + logger.debug(f"Executing candidate {candidate_id!r} for {deps.fromAssay!r}") logger.info( - f"Running parameter candidate {candidate_id!r} for assay " - f"{deps.fromAssay!r}: method={candidate.reductionMethod}, " - f"dimensions={candidate.dimensions}, k={candidate.neighborsK}, " + f"Comparing settings for {deps.fromAssay}: {candidate.reductionMethod.upper()} " + f"dimensions={candidate.dimensions}, neighbors={candidate.neighborsK}, " f"resolution={candidate.leidenResolution}, " f"harmony={candidate.useHarmony}" ) @@ -776,11 +837,9 @@ def execute_parameter_candidate( warnings=warnings, ) logger.info( - f"Completed parameter candidate {candidate_id!r} for assay " - f"{deps.fromAssay!r}: eligible={evaluation.eligible}, " - f"clusters={metrics.nClusters}, " - f"minimum_cluster_cells={metrics.minClusterCells}, " - f"warnings={len(warnings)}" + f"Compared settings: {metrics.nClusters} clusters; " + f"smallest population has {metrics.minClusterCells} cells; " + f"{'ready for assessment' if evaluation.eligible else 'candidate checks unresolved'}." ) except (KeyError, TypeError, ValueError, RuntimeError) as exc: evaluation = ParameterCandidateEvaluation( diff --git a/scarf/agent/parameter_tuning/hvg.py b/scarf/agent/parameter_tuning/hvg.py index 495e2281..3a181708 100644 --- a/scarf/agent/parameter_tuning/hvg.py +++ b/scarf/agent/parameter_tuning/hvg.py @@ -1,51 +1,77 @@ -import re -from collections.abc import Callable, Iterable, Mapping, Sequence +"""Core-backed feature selection and bounded technical-group rank aggregation.""" + +from collections.abc import Iterable, Sequence from dataclasses import dataclass -from typing import Any, Literal +from typing import Any, Literal, cast import numpy as np -import zarr -from ...assay import RNAassay -from ...features.variability import DEFAULT_HVG_BLACKLIST, fit_lowess -from ...storage.arrays import create_zarr_dataset -from ...storage.artifact_writer import ( - ArrayRequirement, - AttributeRequirement, - finish_artifact, - plan_artifact, - start_artifact, -) -from ...storage.artifacts import ( - ArtifactRef, - artifact_group, - fingerprint_array, - fingerprint_stored_arrays, -) -from ...storage.feature_selection import ( - _feature_selection_plan, - _feature_selection_values, - _ordered_feature_ids_fingerprint, - _write_feature_selection, - read_feature_selection_indices, -) -from ...storage.selections import ( - read_stored_selection_indices, - snapshot_run_metadata, - validate_run_metadata_snapshot, -) -from ...storage.types import as_zarr_array +from ...storage.refs import ArtifactRef HVG_CANDIDATE_TARGETS = (1000, 2000, 4000) -_HVG_COMPARISON_EXAMPLE_LIMIT = 8 -_HVG_DIAGNOSTIC_ARRAYS = ( - "eligible", - "global_corrected_variance", - "recurrence", - "mean_within_group_rank", - "ranking", -) -_HVG_DIAGNOSTIC_VERSION = 1 + + +def core_hvg_evidence( + store: Any, + *, + assay: str, + cells: ArtifactRef, +) -> dict[str, ArtifactRef]: + """Obtain baseline, eligible universes and variability from core Scarf. + + These are feature-axis computations. Requesting all eligible genes does + not normalize, reduce or construct a graph for another candidate. + """ + feature_count = int(store.get_assay(assay).feats.N) + options = {"from_assay": assay, "show_plot": False, "invalidate_cache": False} + return { + "scarfDefault": store.select_hvgs(cells, top_n=1000, **options), + "eligibleDefault": store.select_hvgs(cells, top_n=feature_count, **options), + "eligibleAll": store.select_hvgs( + cells, top_n=feature_count, blacklist="", **options + ), + } + + +def rank_core_hvgs( + store: Any, + *, + eligible: ArtifactRef, + statistics: ArtifactRef, + top_n: int, + ranking: np.ndarray | None = None, +) -> ArtifactRef: + """Select a count on a frozen universe using the core variability payload.""" + mask = np.asarray(store.load_artifact(eligible)["values"][:], dtype=bool) + variance = np.asarray( + store.load_artifact(statistics)["corrected_variance"][:], dtype=np.float64 + ) + if mask.shape != variance.shape or not np.isfinite(variance).all(): + raise ValueError("Core HVG statistics do not align with eligible genes") + indices = np.flatnonzero(mask) + if ranking is None: + indices = indices[np.lexsort((indices, -variance[indices]))] + else: + ordered = np.asarray(ranking, dtype=np.int64) + if ordered.ndim != 1 or len(np.unique(ordered)) != len(ordered): + raise ValueError("HVG ranking must contain unique feature indices") + if np.any(ordered < 0) or np.any(ordered >= len(mask)): + raise ValueError("HVG ranking contains invalid feature indices") + indices = ordered[mask[ordered]] + if len(indices) != int(mask.sum()): + raise ValueError("HVG ranking must cover the exact eligible universe") + if isinstance(top_n, bool) or top_n < 3: + raise ValueError("RNA representation needs at least three requested genes") + selected = np.zeros(mask.shape, dtype=bool) + selected[indices[:top_n]] = True + if int(selected.sum()) < 3: + raise ValueError("Fewer than three eligible genes remain") + return cast( + ArtifactRef, + store.set_feature_selection( + from_assay=eligible.assay, mask=selected, invalidate_cache=False + ), + ) @dataclass(frozen=True, slots=True) @@ -86,60 +112,6 @@ def candidate_mask(self, top_n: int) -> np.ndarray: return values -@dataclass(frozen=True, slots=True) -class HvgDefaultFamilyLeakage: - """Default-family representation within one agent-ranked HVG candidate.""" - - family: str - pattern: str - inventory_count: int - scarf_default_selected_count: int - agent_selected_count: int - agent_only_count: int - agent_selected_fraction: float - examples: tuple[str, ...] - - -@dataclass(frozen=True, slots=True) -class HvgSelectionComparison: - """Overlap between one Scarf-default selection and one agent candidate.""" - - ranking_mode: Literal["global", "batchAware"] - top_n: int - scarf_default_blacklist: str - scarf_default_count: int - agent_count: int - overlap_count: int - union_count: int - scarf_default_only_count: int - agent_only_count: int - scarf_default_overlap_fraction: float - agent_overlap_fraction: float - jaccard: float - default_family_leakage: tuple[HvgDefaultFamilyLeakage, ...] - - -@dataclass(frozen=True, slots=True) -class HvgCandidateArtifact: - """One persisted candidate with its effective capped feature count.""" - - top_n: int - features: ArtifactRef - - -@dataclass(frozen=True, slots=True) -class HvgDiagnosticArtifacts: - """Persisted HVG diagnostic and its registered feature selections.""" - - diagnostic: ArtifactRef - ranking_mode: Literal["global", "batchAware"] - technical_group_column: str | None - valid_groups: tuple[str, ...] - excluded_groups: tuple[str, ...] - eligible_feature_count: int - candidates: tuple[HvgCandidateArtifact, ...] - - def effective_hvg_candidate_counts( eligible_feature_count: int, targets: Sequence[int] = HVG_CANDIDATE_TARGETS, @@ -167,150 +139,6 @@ def effective_hvg_candidate_counts( return tuple(resolved) -def compare_hvg_ranking_to_default( - scarf_default_selection: np.ndarray, - ranking: HvgRanking, - *, - feature_names: Sequence[Any], - default_family_patterns: Mapping[str, str], - max_examples: int = _HVG_COMPARISON_EXAMPLE_LIMIT, -) -> tuple[HvgSelectionComparison, ...]: - """Compare registered agent candidates with an exact Scarf-default selection.""" - scarf_default = np.asarray(scarf_default_selection, dtype=bool) - if scarf_default.ndim != 1: - raise ValueError("scarf_default_selection must be a one-dimensional mask") - if ranking.eligible.shape != scarf_default.shape: - raise ValueError("Scarf-default and agent feature axes must align") - if isinstance(feature_names, str | bytes): - raise TypeError("feature_names must be a sequence") - names = np.asarray( - ["" if value is None else str(value) for value in feature_names], - dtype=object, - ) - if names.shape != scarf_default.shape: - raise ValueError("feature_names must align with the feature-selection masks") - if isinstance(max_examples, bool) or not isinstance(max_examples, int): - raise TypeError("max_examples must be an integer") - if not 0 <= max_examples <= _HVG_COMPARISON_EXAMPLE_LIMIT: - raise ValueError( - f"max_examples must be between 0 and {_HVG_COMPARISON_EXAMPLE_LIMIT}" - ) - - family_masks: list[tuple[str, str, np.ndarray]] = [] - for family, pattern in sorted(default_family_patterns.items()): - if not isinstance(family, str) or not family: - raise ValueError("Default-family names must be non-empty strings") - if not isinstance(pattern, str) or not pattern: - raise ValueError("Default-family patterns must be non-empty strings") - compiled = re.compile(pattern.upper()) - mask = np.fromiter( - (compiled.match(name.upper()) is not None for name in names), - dtype=bool, - count=len(names), - ) - family_masks.append((family, pattern, mask)) - - scarf_default_count = int(scarf_default.sum()) - comparisons: list[HvgSelectionComparison] = [] - for top_n in ranking.candidate_counts: - agent = ranking.candidate_mask(top_n) - agent_count = int(agent.sum()) - if agent_count != top_n: - raise ValueError( - "Agent rankings must contain distinct indices for every candidate" - ) - overlap = scarf_default & agent - union = scarf_default | agent - overlap_count = int(overlap.sum()) - union_count = int(union.sum()) - leakage: list[HvgDefaultFamilyLeakage] = [] - for family, pattern, family_mask in family_masks: - selected = agent & family_mask - selected_names = sorted( - set(names[selected].tolist()), - key=lambda value: (value.casefold(), value), - ) - leakage.append( - HvgDefaultFamilyLeakage( - family=family, - pattern=pattern, - inventory_count=int(family_mask.sum()), - scarf_default_selected_count=int( - (scarf_default & family_mask).sum() - ), - agent_selected_count=int(selected.sum()), - agent_only_count=int((selected & ~scarf_default).sum()), - agent_selected_fraction=( - float(selected.sum()) / agent_count if agent_count else 0.0 - ), - examples=tuple(selected_names[:max_examples]), - ) - ) - comparisons.append( - HvgSelectionComparison( - ranking_mode=ranking.ranking_mode, - top_n=top_n, - scarf_default_blacklist=DEFAULT_HVG_BLACKLIST, - scarf_default_count=scarf_default_count, - agent_count=agent_count, - overlap_count=overlap_count, - union_count=union_count, - scarf_default_only_count=int((scarf_default & ~agent).sum()), - agent_only_count=int((agent & ~scarf_default).sum()), - scarf_default_overlap_fraction=( - overlap_count / scarf_default_count if scarf_default_count else 0.0 - ), - agent_overlap_fraction=( - overlap_count / agent_count if agent_count else 0.0 - ), - jaccard=overlap_count / union_count if union_count else 1.0, - default_family_leakage=tuple(leakage), - ) - ) - return tuple(comparisons) - - -def corrected_variance_from_summary( - summary: Mapping[str, np.ndarray], - *, - n_selected: int, - n_bins: int, - lowess_frac: float, -) -> np.ndarray: - """Derive LOWESS-corrected variability from feature-axis sufficient stats.""" - if isinstance(n_selected, bool) or not isinstance(n_selected, int): - raise TypeError("n_selected must be an integer") - if n_selected < 1: - raise ValueError("n_selected must be greater than 0") - required = ("normed_tot", "normed_n", "sigmas") - try: - normed_tot, normed_n, sigmas = ( - np.asarray(summary[name], dtype=np.float64) for name in required - ) - except KeyError as exc: - raise ValueError(f"RNA feature summary is missing {exc.args[0]!r}") from exc - shape = normed_tot.shape - if normed_tot.ndim != 1 or normed_n.shape != shape or sigmas.shape != shape: - raise ValueError("RNA feature-summary arrays must be aligned vectors") - if not all(np.isfinite(values).all() for values in (normed_tot, normed_n, sigmas)): - raise ValueError("RNA feature-summary arrays must contain only finite values") - - average = normed_tot / n_selected - corrected = np.zeros(shape, dtype=np.float64) - positive = (average > 0) & (sigmas > 0) - if positive.any(): - corrected[positive] = fit_lowess( - average[positive], - sigmas[positive], - n_bins, - lowess_frac, - bin_strategy="adaptive", - ) - if not np.isfinite(corrected).all() or (corrected < 0).any(): - raise ValueError("Corrected feature variability is invalid") - return corrected - - def aggregate_hvg_rankings( global_corrected_variance: np.ndarray, eligible_features: np.ndarray, @@ -418,456 +246,3 @@ def aggregate_hvg_rankings( valid_group_count=valid_group_count, candidate_counts=counts, ) - - -def _group_id(value: Any) -> str: - native = value.item() if isinstance(value, np.generic) else value - if isinstance(native, bool): - return f"bool:{str(native).lower()}" - if isinstance(native, int): - return f"int:{native}" - if isinstance(native, float): - if not np.isfinite(native): - raise ValueError("Non-finite technical-group values must be marked missing") - return f"float:{native.hex()}" - if isinstance(native, str): - return f"str:{native}" - raise TypeError( - "Technical-group values must be strings, booleans, integers, or floats" - ) - - -def _technical_groups( - root: zarr.Group, - snapshot: ArtifactRef, - column: str, - cell_indices: np.ndarray, - *, - min_group_cells: int, -) -> tuple[tuple[tuple[str, np.ndarray], ...], tuple[str, ...]]: - group = validate_run_metadata_snapshot( - root, - snapshot, - axis="cell", - assay=None, - table_path="cellData", - ordered_columns=(column,), - ) - values_array = as_zarr_array(group[column], name=column) - values = np.asarray(values_array[cell_indices]) - missing_name = values_array.attrs.get("missing_mask") - missing = ( - np.asarray( - as_zarr_array(group[missing_name], name=missing_name)[cell_indices], - dtype=bool, - ) - if isinstance(missing_name, str) - else np.zeros(len(cell_indices), dtype=bool) - ) - if values.dtype.kind == "f": - missing |= ~np.isfinite(values) - grouped: dict[str, list[int]] = {} - for cell_index, value, is_missing in zip( - cell_indices, - values, - missing, - strict=True, - ): - if is_missing: - continue - grouped.setdefault(_group_id(value), []).append(int(cell_index)) - valid: list[tuple[str, np.ndarray]] = [] - excluded: list[str] = [] - for group_id in sorted(grouped): - indices = grouped[group_id] - if len(indices) >= min_group_cells: - valid.append((group_id, np.asarray(indices, dtype=np.int64))) - else: - excluded.append(group_id) - return tuple(valid), tuple(excluded) - - -def _diagnostic_reuse_validator( - *, - n_features: int, - eligible_count: int, - ordered_feature_ids_fingerprint: str, -) -> Any: - def validate(_ref: ArtifactRef, group: zarr.Group) -> bool: - try: - if set(group.array_keys()) != set(_HVG_DIAGNOSTIC_ARRAYS): - return False - expected = { - "eligible": ((n_features,), np.dtype(bool)), - "global_corrected_variance": ((n_features,), np.dtype(np.float64)), - "recurrence": ((n_features,), np.dtype(np.int32)), - "mean_within_group_rank": ((n_features,), np.dtype(np.float64)), - "ranking": ((eligible_count,), np.dtype(np.int64)), - } - for name, (shape, dtype) in expected.items(): - array = as_zarr_array(group[name], name=name) - if array.shape != shape or np.dtype(array.dtype) != dtype: - return False - return group.attrs.get( - "ordered_feature_ids_fingerprint" - ) == ordered_feature_ids_fingerprint and group.attrs.get( - "payload_fingerprint" - ) == fingerprint_stored_arrays(group, _HVG_DIAGNOSTIC_ARRAYS) - except (KeyError, TypeError, ValueError): - return False - - return validate - - -def _ranking_mode_predicate( - mode: Literal["global", "batchAware"], -) -> Callable[[Any], bool]: - return lambda value: value == mode - - -def _write_hvg_diagnostic( - root: zarr.Group, - planned: Any, - ranking: HvgRanking, - *, - ordered_feature_ids_fingerprint: str, - valid_groups: tuple[str, ...], - excluded_groups: tuple[str, ...], -) -> None: - group = start_artifact(root, planned) - payload = { - "eligible": np.asarray(ranking.eligible, dtype=bool), - "global_corrected_variance": np.asarray( - ranking.global_corrected_variance, dtype=np.float64 - ), - "recurrence": np.asarray(ranking.recurrence, dtype=np.int32), - "mean_within_group_rank": np.asarray( - ranking.mean_within_group_rank, dtype=np.float64 - ), - "ranking": np.asarray(ranking.ranking, dtype=np.int64), - } - for name in _HVG_DIAGNOSTIC_ARRAYS: - values = payload[name] - chunks = (min(max(len(values), 1), 100_000),) - output = create_zarr_dataset(group, name, chunks, values.dtype, values.shape) - output[:] = values - group.attrs["ordered_feature_ids_fingerprint"] = ordered_feature_ids_fingerprint - group.attrs["payload_fingerprint"] = fingerprint_stored_arrays( - group, _HVG_DIAGNOSTIC_ARRAYS - ) - group.attrs["ranking_mode"] = ranking.ranking_mode - group.attrs["valid_groups"] = list(valid_groups) - group.attrs["excluded_groups"] = list(excluded_groups) - finish_artifact(group, planned) - - -def run_hvg_diagnostic_artifacts( - root: zarr.Group, - assay: RNAassay, - *, - cell_selection: ArtifactRef, - eligible_features: ArtifactRef, - all_features: ArtifactRef, - technical_group_column: str | None, - min_group_cells: int, - min_cells: int, - n_bins: int, - lowess_frac: float, - invalidate_cache: bool, - candidate_targets: Sequence[int] = HVG_CANDIDATE_TARGETS, -) -> tuple[HvgDiagnosticArtifacts, ...]: - """Run and persist global and eligible technical-group HVG rankings.""" - cell_indices = read_stored_selection_indices( - root, - cell_selection, - kind="cell_selection", - scope="datastore", - assay=None, - table_path="cellData", - ).astype(np.int64, copy=False) - if cell_indices.size == 0: - raise ValueError("cell_selection must select at least one cell") - n_features = int(assay.feats.N) - selected_feature_indices = read_feature_selection_indices( - root, - assay.name, - eligible_features, - ) - eligible_input = np.zeros(n_features, dtype=bool) - eligible_input[selected_feature_indices] = True - if not eligible_input.any(): - raise ValueError("eligible_features must select at least one feature") - if ( - len( - read_feature_selection_indices( - root, - assay.name, - all_features, - ) - ) - != n_features - ): - raise ValueError("all_features must select the complete feature universe") - - from ...assay.feature_summary import ensure_feature_summary, feature_summary_values - - global_summary_ref = ensure_feature_summary( - root, - assay, - cell_selection, - invalidate_cache=invalidate_cache, - ) - global_summary = feature_summary_values( - root, - global_summary_ref, - n_selected=len(cell_indices), - ) - global_corrected = corrected_variance_from_summary( - global_summary, - n_selected=len(cell_indices), - n_bins=n_bins, - lowess_frac=lowess_frac, - ) - detected_global = np.asarray(global_summary["normed_n"], dtype=np.float64) - eligible = eligible_input & (detected_global >= min_cells) - eligible_count = int(eligible.sum()) - candidate_counts = effective_hvg_candidate_counts( - eligible_count, - candidate_targets, - ) - - technical_snapshot: ArtifactRef | None = None - valid_group_rows: tuple[tuple[str, np.ndarray], ...] = () - excluded_groups: tuple[str, ...] = () - if technical_group_column is not None: - technical_snapshot = snapshot_run_metadata( - root, - table_path="cellData", - id_column="ids", - columns=(technical_group_column,), - axis="cell", - invalidate_cache=invalidate_cache, - ) - valid_group_rows, excluded_groups = _technical_groups( - root, - technical_snapshot, - technical_group_column, - cell_indices, - min_group_cells=min_group_cells, - ) - valid_groups = tuple(group_id for group_id, _indices in valid_group_rows) - ordered_feature_ids_fingerprint = _ordered_feature_ids_fingerprint(assay) - diagnostic_inputs: dict[str, Any] = { - "cell_selection": cell_selection, - "eligible_features": eligible_features, - "global_feature_summary": global_summary_ref, - } - if technical_snapshot is not None: - diagnostic_inputs["technical_group_snapshot"] = technical_snapshot - feature_indices = np.arange(n_features, dtype=np.int64) - group_variability: list[HvgGroupVariability] = [] - for group_id, group_cells in valid_group_rows: - summary = assay._compute_feature_summary(group_cells, feature_indices) - corrected = corrected_variance_from_summary( - summary, - n_selected=len(group_cells), - n_bins=n_bins, - lowess_frac=lowess_frac, - ) - group_variability.append( - HvgGroupVariability( - group_id=group_id, - cell_count=len(group_cells), - corrected_variance=corrected, - detected_features=( - np.asarray(summary["normed_n"], dtype=np.float64) >= min_cells - ), - ) - ) - sensitivity_ranking = aggregate_hvg_rankings( - global_corrected, - eligible, - group_variability, - valid_group_count=len(valid_group_rows), - candidate_targets=candidate_targets, - ) - eligible_indices = np.flatnonzero(eligible) - global_order = eligible_indices[ - np.lexsort((eligible_indices, -global_corrected[eligible_indices])) - ].astype(np.int64, copy=False) - global_ranking = HvgRanking( - ranking_mode="global", - eligible=sensitivity_ranking.eligible, - global_corrected_variance=sensitivity_ranking.global_corrected_variance, - recurrence=sensitivity_ranking.recurrence, - mean_within_group_rank=sensitivity_ranking.mean_within_group_rank, - ranking=global_order, - valid_group_count=sensitivity_ranking.valid_group_count, - candidate_counts=sensitivity_ranking.candidate_counts, - ) - rankings = [global_ranking] - if sensitivity_ranking.ranking_mode == "batchAware": - rankings.append(sensitivity_ranking) - - results: list[HvgDiagnosticArtifacts] = [] - for ranking in rankings: - parameters = { - "algorithm_version": _HVG_DIAGNOSTIC_VERSION, - "candidate_counts": list(candidate_counts), - "min_cells": min_cells, - "min_group_cells": min_group_cells, - "n_bins": n_bins, - "lowess_frac": lowess_frac, - "ranking_mode": ranking.ranking_mode, - "technical_group_column": technical_group_column, - } - planned = plan_artifact( - root, - scope="assay", - assay=assay.name, - kind="feature_summary", - operation="diagnose_hvg_candidates", - parameters=parameters, - inputs=diagnostic_inputs, - execution_options={"nthreads": assay.nthreads}, - invalidate_cache=invalidate_cache, - required_arrays=( - ArrayRequirement("eligible", shape=(n_features,), dtype=bool), - ArrayRequirement( - "global_corrected_variance", - shape=(n_features,), - dtype=np.float64, - ), - ArrayRequirement("recurrence", shape=(n_features,), dtype=np.int32), - ArrayRequirement( - "mean_within_group_rank", - shape=(n_features,), - dtype=np.float64, - ), - ArrayRequirement("ranking", shape=(eligible_count,), dtype=np.int64), - ), - required_attributes=( - AttributeRequirement( - "ordered_feature_ids_fingerprint", expected_types=(str,) - ), - AttributeRequirement("payload_fingerprint", expected_types=(str,)), - AttributeRequirement( - "ranking_mode", - expected_types=(str,), - predicate=_ranking_mode_predicate(ranking.ranking_mode), - ), - AttributeRequirement("valid_groups", expected_types=(list,)), - AttributeRequirement("excluded_groups", expected_types=(list,)), - ), - reuse_validator=_diagnostic_reuse_validator( - n_features=n_features, - eligible_count=eligible_count, - ordered_feature_ids_fingerprint=ordered_feature_ids_fingerprint, - ), - ) - if not planned.reused: - _write_hvg_diagnostic( - root, - planned, - ranking, - ordered_feature_ids_fingerprint=ordered_feature_ids_fingerprint, - valid_groups=valid_groups, - excluded_groups=excluded_groups, - ) - else: - diagnostic_group = artifact_group(root, planned.ref) - ranking = HvgRanking( - ranking_mode=ranking.ranking_mode, - eligible=np.asarray( - as_zarr_array(diagnostic_group["eligible"], name="eligible")[:], - dtype=bool, - ), - global_corrected_variance=np.asarray( - as_zarr_array( - diagnostic_group["global_corrected_variance"], - name="global_corrected_variance", - )[:], - dtype=np.float64, - ), - recurrence=np.asarray( - as_zarr_array( - diagnostic_group["recurrence"], - name="recurrence", - )[:], - dtype=np.int32, - ), - mean_within_group_rank=np.asarray( - as_zarr_array( - diagnostic_group["mean_within_group_rank"], - name="mean_within_group_rank", - )[:], - dtype=np.float64, - ), - ranking=np.asarray( - as_zarr_array(diagnostic_group["ranking"], name="ranking")[:], - dtype=np.int64, - ), - valid_group_count=len(valid_groups), - candidate_counts=candidate_counts, - ) - - candidates: list[HvgCandidateArtifact] = [] - for top_n in candidate_counts: - values = ranking.candidate_mask(top_n) - values_fingerprint = fingerprint_array(values) - selection_plan = _feature_selection_plan( - root, - assay=assay.name, - n_features=n_features, - ordered_feature_ids_fingerprint=ordered_feature_ids_fingerprint, - operation="set_feature_selection", - parameters={"values_fingerprint": values_fingerprint}, - inputs={ - "all_features": all_features, - }, - execution_options={"invalidate_cache": invalidate_cache}, - expected_payload_fingerprint=values_fingerprint, - invalidate_cache=invalidate_cache, - ) - if selection_plan.reused: - stored = np.asarray( - _feature_selection_values(root, selection_plan.ref), dtype=bool - ) - if not np.array_equal(stored, values): - selection_plan = selection_plan.invalidated(root) - _write_feature_selection( - root, - selection_plan, - ordered_feature_ids_fingerprint=ordered_feature_ids_fingerprint, - payload={"values": values}, - ) - candidates.append(HvgCandidateArtifact(top_n, selection_plan.ref)) - - results.append( - HvgDiagnosticArtifacts( - diagnostic=planned.ref, - ranking_mode=ranking.ranking_mode, - technical_group_column=technical_group_column, - valid_groups=valid_groups, - excluded_groups=excluded_groups, - eligible_feature_count=eligible_count, - candidates=tuple(candidates), - ) - ) - return tuple(results) - - -__all__ = [ - "HVG_CANDIDATE_TARGETS", - "HvgCandidateArtifact", - "HvgDefaultFamilyLeakage", - "HvgDiagnosticArtifacts", - "HvgGroupVariability", - "HvgRanking", - "HvgSelectionComparison", - "aggregate_hvg_rankings", - "compare_hvg_ranking_to_default", - "corrected_variance_from_summary", - "effective_hvg_candidate_counts", - "run_hvg_diagnostic_artifacts", -] diff --git a/scarf/agent/parameter_tuning/selection.py b/scarf/agent/parameter_tuning/selection.py index b8de4cf2..97d226cd 100644 --- a/scarf/agent/parameter_tuning/selection.py +++ b/scarf/agent/parameter_tuning/selection.py @@ -330,6 +330,14 @@ def harmony_acceptance_gate( if not native_scores or not harmony_scores: reasons.append(f"Protected comparison is missing for {column!r}.") continue + missing_metrics = sorted( + {"clisi", "graphConnectivity"} - (set(native_scores) & set(harmony_scores)) + ) + if missing_metrics: + reasons.append( + f"Required protected metrics {missing_metrics} are missing for {column!r}." + ) + continue if set(native_scores) != set(harmony_scores): reasons.append(f"Protected metrics do not align for {column!r}.") continue diff --git a/scarf/agent/parameter_tuning/sequential.py b/scarf/agent/parameter_tuning/sequential.py deleted file mode 100644 index bbce357e..00000000 --- a/scarf/agent/parameter_tuning/sequential.py +++ /dev/null @@ -1,1100 +0,0 @@ -"""Causal phase planning for RNA parameter adjudication. - -This module constructs executor-compatible candidate sets one scientific choice -at a time. It does not call a model and it never chooses a fallback candidate. -The orchestration layer can persist ``ParameterPhaseEvidence`` after requesting -an exact candidate ID from an agent or human. -""" - -import hashlib -import re -from collections.abc import Sequence -from typing import Any, Literal - -from pydantic import ConfigDict, Field, model_validator - -from ..tools import core_artifact_reference -from ..types import AgentDataModel, ExperimentalTuningHandoff -from .agent import execute_parameter_search_plan, prepare_parameter_tuning_dependencies -from .contracts import ( - ParameterCandidate, - ParameterCandidateEvaluation, - ParameterSearchPlan, - ParameterTuningDependencies, - ParameterTuningNeedsInput, - ParameterTuningReport, -) -from .execution import execute_parameter_candidate -from .selection import ( - annotate_candidate_dominance, - finalize_parameter_tuning_selection, - require_dominated_candidate_evidence, - validate_parameter_search_plan, -) - -type ParameterPhase = Literal[ - "pcaPrefix", - "batchCorrection", - "graphK", - "clusteringResolution", -] -type ParameterPhaseStatus = Literal["selected", "needsInput", "abstained"] -type ParameterDecisionSource = Literal["rule", "agent", "human"] -type VariedParameter = Literal[ - "dimensions", - "useHarmony", - "neighborsK", - "leidenResolution", -] - -_PHASE_ORDER: tuple[ParameterPhase, ...] = ( - "pcaPrefix", - "batchCorrection", - "graphK", - "clusteringResolution", -) -_VARIED_PARAMETER: dict[ParameterPhase, VariedParameter] = { - "pcaPrefix": "dimensions", - "batchCorrection": "useHarmony", - "graphK": "neighborsK", - "clusteringResolution": "leidenResolution", -} -_CANDIDATE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_]{0,63}$") - - -class SequentialTuningModel(AgentDataModel): - """Strict immutable base for sequential tuning records.""" - - model_config = ConfigDict(extra="forbid", frozen=True, validate_default=True) - - -class ParameterPhasePlan(SequentialTuningModel): - """One exact candidate set varying a single parameter.""" - - phase: ParameterPhase - assay: str = Field(min_length=1, max_length=256) - variedParameter: VariedParameter - basedOnCandidateId: str | None = None - candidates: list[ParameterCandidate] = Field(min_length=1) - - @model_validator(mode="after") - def validate_causal_candidate_set(self) -> "ParameterPhasePlan": - if self.variedParameter != _VARIED_PARAMETER[self.phase]: - raise ValueError("variedParameter does not match the tuning phase") - if (self.phase == "pcaPrefix") != (self.basedOnCandidateId is None): - raise ValueError("Only the PCA-prefix phase may omit basedOnCandidateId") - if self.basedOnCandidateId is not None and ( - _CANDIDATE_ID.fullmatch(self.basedOnCandidateId) is None - ): - raise ValueError("basedOnCandidateId is not a stable candidate ID") - candidate_ids = [candidate.candidateId for candidate in self.candidates] - if any(_CANDIDATE_ID.fullmatch(value) is None for value in candidate_ids): - raise ValueError("Candidate IDs must be stable non-empty identifiers") - if len(candidate_ids) != len(set(candidate_ids)): - raise ValueError("A parameter phase cannot contain duplicate candidate IDs") - if any(candidate.reductionMethod != "pca" for candidate in self.candidates): - raise ValueError("Sequential v1 tuning accepts RNA PCA candidates only") - - varied_values = [ - getattr(candidate, self.variedParameter) for candidate in self.candidates - ] - if len(varied_values) != len(set(varied_values)): - raise ValueError("A phase must vary its target parameter exactly once") - fixed_names = { - "dimensions", - "useHarmony", - "neighborsK", - "leidenResolution", - } - {self.variedParameter} - for field_name in fixed_names: - values = {getattr(candidate, field_name) for candidate in self.candidates} - if len(values) != 1: - raise ValueError( - f"Phase {self.phase!r} changes non-target parameter {field_name!r}" - ) - if self.phase == "pcaPrefix" and any( - candidate.useHarmony for candidate in self.candidates - ): - raise ValueError("PCA-prefix candidates must use the native representation") - if self.phase == "batchCorrection": - harmony_values = {candidate.useHarmony for candidate in self.candidates} - if False not in harmony_values or not harmony_values.issubset( - {False, True} - ): - raise ValueError( - "Batch-correction candidates must include the native baseline" - ) - return self - - def candidate_by_id(self) -> dict[str, ParameterCandidate]: - """Return this phase's exact executor candidates by ID.""" - return {candidate.candidateId: candidate for candidate in self.candidates} - - -class ParameterPhaseSelection(SequentialTuningModel): - """The only model-authored output for one tuning phase.""" - - phase: ParameterPhase - status: ParameterPhaseStatus - selectedCandidateId: str | None = None - evidenceIds: list[str] = Field(default_factory=list) - rationale: str = Field(min_length=1, max_length=4000) - - @model_validator(mode="after") - def validate_selection_shape(self) -> "ParameterPhaseSelection": - if (self.status == "selected") != (self.selectedCandidateId is not None): - raise ValueError("Only a selected phase may contain selectedCandidateId") - if self.selectedCandidateId is not None and ( - _CANDIDATE_ID.fullmatch(self.selectedCandidateId) is None - ): - raise ValueError("selectedCandidateId is not a stable candidate ID") - if len(self.evidenceIds) != len(set(self.evidenceIds)): - raise ValueError("evidenceIds must not contain duplicates") - if any(not value for value in self.evidenceIds): - raise ValueError("evidenceIds must contain non-empty values") - if self.status == "selected" and not self.evidenceIds: - raise ValueError("A selected phase must cite executor evidence") - if self.rationale != self.rationale.strip(): - raise ValueError("rationale must not contain surrounding whitespace") - return self - - -class ParameterPhaseEvidence(SequentialTuningModel): - """Executed evidence and one validated selection for a causal phase.""" - - plan: ParameterPhasePlan - evaluations: list[ParameterCandidateEvaluation] = Field(min_length=1) - selection: ParameterPhaseSelection - - @model_validator(mode="after") - def validate_execution_and_selection(self) -> "ParameterPhaseEvidence": - if self.selection.phase != self.plan.phase: - raise ValueError("Phase selection does not match its candidate plan") - candidates = self.plan.candidate_by_id() - evaluations = {value.candidateId: value for value in self.evaluations} - if len(evaluations) != len(self.evaluations): - raise ValueError("Phase evaluations contain duplicate candidate IDs") - if set(evaluations) != set(candidates): - raise ValueError("Phase evaluations must cover the exact candidate set") - cell_selections = [ - value.cellSelection - for value in self.evaluations - if value.cellSelection is not None - ] - if cell_selections and any( - value != cell_selections[0] for value in cell_selections[1:] - ): - raise ValueError("Phase evaluations must use one exact cell selection") - for candidate_id, evaluation in evaluations.items(): - if evaluation.parameters != candidates[candidate_id]: - raise ValueError( - f"Evaluation {candidate_id!r} changed its registered parameters" - ) - available_evidence = { - evidence_id - for evaluation in self.evaluations - for evidence_id in evaluation.evidenceIds - } - if not set(self.selection.evidenceIds).issubset(available_evidence): - raise ValueError("Selection cites evidence outside its phase evaluations") - if self.selection.status == "selected": - selected = evaluations.get(self.selection.selectedCandidateId or "") - if selected is None or selected.status != "done" or not selected.eligible: - raise ValueError( - "Selected candidate must be an eligible completed execution" - ) - if selected.cellSelection is None: - raise ValueError("Selected candidate lacks an exact cell selection") - selected_evidence = set(selected.evidenceIds) - if not selected_evidence.intersection(self.selection.evidenceIds): - raise ValueError( - "A selected phase must cite evidence from its selected candidate" - ) - if self.plan.phase in {"graphK", "clusteringResolution"}: - dominance_evaluations = { - evaluation.candidateId: evaluation - for evaluation in annotate_candidate_dominance(self.evaluations) - } - require_dominated_candidate_evidence( - dominance_evaluations[selected.candidateId], - self.selection.evidenceIds, - context=f"The {self.plan.phase} selection", - ) - if self.selection.status == "abstained" and self.plan.phase != ( - "clusteringResolution" - ): - raise ValueError("Only clustering may produce scientific abstention") - return self - - def selected_evaluation(self) -> ParameterCandidateEvaluation | None: - """Return the selected eligible evaluation, or None after a pause.""" - if self.selection.selectedCandidateId is None: - return None - return next( - value - for value in self.evaluations - if value.candidateId == self.selection.selectedCandidateId - ) - - -class CorrectionNeedSelection(SequentialTuningModel): - """Separate semantic decision about whether correction is needed.""" - - status: Literal["selected", "needsInput"] - selectedOptionId: Literal[ - "correctionNeed:needed", - "correctionNeed:notNeeded", - "correctionNeed:indeterminate", - ] - evidenceIds: list[str] = Field(default_factory=list) - rationale: str = Field(min_length=1, max_length=4000) - - @model_validator(mode="after") - def validate_need(self) -> "CorrectionNeedSelection": - is_indeterminate = self.selectedOptionId == "correctionNeed:indeterminate" - if (self.status == "needsInput") != is_indeterminate: - raise ValueError( - "Only correctionNeed:indeterminate may have needsInput status" - ) - if len(self.evidenceIds) != len(set(self.evidenceIds)): - raise ValueError("evidenceIds must not contain duplicates") - if self.status == "selected" and not self.evidenceIds: - raise ValueError("A correction-need decision must cite evidence") - if self.rationale != self.rationale.strip(): - raise ValueError("rationale must not contain surrounding whitespace") - return self - - -class SequentialAssayTuningEvidence(SequentialTuningModel): - """Ordered, persistable evidence for one RNA assay's four decisions.""" - - assay: str = Field(min_length=1, max_length=256) - phases: list[ParameterPhaseEvidence] = Field(min_length=1, max_length=4) - correctionLicense: Literal[ - "safe", "unsafeConfounded", "indeterminate", "notApplicable" - ] = "notApplicable" - correctionNeed: CorrectionNeedSelection | None = None - decisionSources: dict[str, ParameterDecisionSource] = Field(default_factory=dict) - pendingDecisionId: ( - Literal[ - "pcaPrefix", - "correctionLicense", - "correctionNeed", - "correctionOutcome", - "graphK", - "clusterPartition", - ] - | None - ) = None - pendingOptionIds: list[str] = Field(default_factory=list) - pendingEvidenceIds: list[str] = Field(default_factory=list) - finalCandidateId: str | None = None - - @model_validator(mode="after") - def validate_phase_lineage(self) -> "SequentialAssayTuningEvidence": - observed_order = tuple(item.plan.phase for item in self.phases) - if observed_order != _PHASE_ORDER[: len(observed_order)]: - raise ValueError("Sequential tuning phases are missing or out of order") - if any(item.plan.assay != self.assay for item in self.phases): - raise ValueError("Sequential tuning phases must use one assay") - valid_decision_ids = { - "pcaPrefix", - "correctionLicense", - "correctionNeed", - "correctionOutcome", - "graphK", - "clusterPartition", - } - if not set(self.decisionSources).issubset(valid_decision_ids): - raise ValueError("decisionSources contains an unknown RNA decision") - for field_name, values in ( - ("pendingOptionIds", self.pendingOptionIds), - ("pendingEvidenceIds", self.pendingEvidenceIds), - ): - if len(values) != len(set(values)) or any(not value for value in values): - raise ValueError( - f"{field_name} must contain unique non-empty identifiers" - ) - if self.pendingDecisionId is None: - if self.pendingOptionIds or self.pendingEvidenceIds: - raise ValueError( - "Pending option and evidence IDs require pendingDecisionId" - ) - elif not self.pendingOptionIds: - raise ValueError("A pending decision requires its exact offered options") - has_batch_phase = any( - item.plan.phase == "batchCorrection" for item in self.phases - ) - if self.correctionLicense == "safe" and ( - has_batch_phase or self.pendingDecisionId == "correctionNeed" - ): - if self.correctionNeed is None: - raise ValueError("A safe correction branch requires correctionNeed") - elif self.correctionNeed is not None: - raise ValueError( - "Correction need must be absent without a safe correction license" - ) - if self.pendingDecisionId == "correctionNeed" and ( - self.correctionNeed is None or self.correctionNeed.status != "needsInput" - ): - raise ValueError("pending correctionNeed requires an indeterminate need") - if self.pendingDecisionId == "correctionOutcome": - batch_phases = [ - item for item in self.phases if item.plan.phase == "batchCorrection" - ] - if ( - not batch_phases - or batch_phases[-1].selection.status != "needsInput" - or self.correctionNeed is None - or self.correctionNeed.selectedOptionId != "correctionNeed:needed" - ): - raise ValueError( - "pending correctionOutcome requires a needed correction and pause" - ) - for index, phase in enumerate(self.phases[1:], start=1): - previous = self.phases[index - 1] - previous_evaluation = previous.selected_evaluation() - if previous_evaluation is None: - raise ValueError("No phase may follow needsInput or abstained") - if phase.plan.basedOnCandidateId != previous_evaluation.candidateId: - raise ValueError("Phase basedOnCandidateId breaks selection lineage") - target = phase.plan.variedParameter - for candidate in phase.plan.candidates: - for field_name in ( - "dimensions", - "useHarmony", - "neighborsK", - "leidenResolution", - ): - if field_name == target: - continue - if getattr(candidate, field_name) != getattr( - previous_evaluation.parameters, - field_name, - ): - raise ValueError( - f"Phase {phase.plan.phase!r} does not preserve " - f"selected {field_name!r}" - ) - completed = ( - len(self.phases) == len(_PHASE_ORDER) - and self.phases[-1].selection.status == "selected" - ) - expected_final = ( - self.phases[-1].selection.selectedCandidateId if completed else None - ) - if self.finalCandidateId != expected_final: - raise ValueError("finalCandidateId requires four selected causal phases") - return self - - -class SequentialRefinementResult(SequentialTuningModel): - """One validated post-grid refinement disposition and optional execution.""" - - plan: ParameterSearchPlan - evaluation: ParameterCandidateEvaluation | None = None - - @model_validator(mode="after") - def validate_refinement_result(self) -> "SequentialRefinementResult": - if ( - not self.plan.basedOnCandidateIds - or not self.plan.evidenceIds - or not self.plan.rationale.strip() - or not self.plan.stoppingCriteria - ): - raise ValueError( - "Sequential refinement results require parents, evidence, " - "rationale, and stopping criteria" - ) - if self.plan.status == "complete": - if self.plan.candidates or self.evaluation is not None: - raise ValueError( - "A complete refinement review cannot contain an execution" - ) - return self - if len(self.plan.candidates) != 1 or self.evaluation is None: - raise ValueError( - "A refinement review must contain exactly one candidate execution" - ) - candidate = self.plan.candidates[0] - if ( - self.evaluation.candidateId != candidate.candidateId - or self.evaluation.parameters != candidate - or self.evaluation.phase != "refined" - ): - raise ValueError("Refinement execution does not match its validated plan") - return self - - -class SequentialRefinementSelection(SequentialTuningModel): - """Explicit final choice after an optional refinement execution.""" - - selectedCandidateId: str - evidenceIds: list[str] = Field(min_length=1) - rationale: str = Field(min_length=1, max_length=4000) - - @model_validator(mode="after") - def validate_selection(self) -> "SequentialRefinementSelection": - if _CANDIDATE_ID.fullmatch(self.selectedCandidateId) is None: - raise ValueError("selectedCandidateId is not a stable candidate ID") - if len(self.evidenceIds) != len(set(self.evidenceIds)): - raise ValueError("evidenceIds must not contain duplicates") - if any(not value for value in self.evidenceIds): - raise ValueError("evidenceIds must contain non-empty values") - if self.rationale != self.rationale.strip(): - raise ValueError("rationale must not contain surrounding whitespace") - return self - - -class SequentialRnaTuningPlanner: - """Construct fixed, rank-capped candidates for four causal RNA phases.""" - - def __init__( - self, - *, - workflow_run_id: str, - assay: str, - n_cells: int, - n_features: int, - harmony_authorized: bool, - matrix_rank: int | None = None, - dimension_candidates: Sequence[int] = (10, 20, 30, 50), - neighbor_candidates: Sequence[int] = (11, 21, 41), - resolution_candidates: Sequence[float] = ( - 0.25, - 0.5, - 0.75, - 1.0, - 1.25, - 1.5, - ), - ) -> None: - if not workflow_run_id or not assay: - raise ValueError("workflow_run_id and assay must be non-empty") - if isinstance(n_cells, bool) or not isinstance(n_cells, int) or n_cells < 3: - raise ValueError("Sequential tuning requires at least three cells") - if ( - isinstance(n_features, bool) - or not isinstance(n_features, int) - or n_features < 3 - ): - raise ValueError("Sequential tuning requires at least three features") - if not isinstance(harmony_authorized, bool): - raise TypeError("harmony_authorized must be a boolean") - maximum_rank = min(n_cells, n_features) - 1 - if matrix_rank is not None: - if ( - isinstance(matrix_rank, bool) - or not isinstance(matrix_rank, int) - or not 2 <= matrix_rank <= maximum_rank - ): - raise ValueError( - "matrix_rank must be between two and the shape-derived rank cap" - ) - maximum_rank = matrix_rank - self.workflow_run_id = workflow_run_id - self.assay = assay - self.n_cells = n_cells - self.n_features = n_features - self.harmony_authorized = harmony_authorized - self.dimensions = self._capped_integers( - dimension_candidates, - maximum=maximum_rank, - name="dimension_candidates", - ) - self.neighbors = self._capped_integers( - neighbor_candidates, - maximum=n_cells - 1, - name="neighbor_candidates", - ) - resolutions = tuple(float(value) for value in resolution_candidates) - if ( - not resolutions - or any(not 0 < value < float("inf") for value in resolutions) - or len(resolutions) != len(set(resolutions)) - ): - raise ValueError( - "resolution_candidates must be unique, finite, and positive" - ) - self.resolutions = resolutions - token = re.sub(r"[^A-Za-z0-9]+", "_", workflow_run_id).strip("_")[:12] - assay_token = re.sub(r"[^A-Za-z0-9]+", "_", assay).strip("_")[:12] - digest = hashlib.blake2b( - f"{workflow_run_id}\0{assay}".encode(), - digest_size=5, - ).hexdigest() - self.prefix = f"seq_{token or 'run'}_{assay_token or 'assay'}_{digest}" - - @staticmethod - def _capped_integers( - values: Sequence[int], - *, - maximum: int, - name: str, - ) -> tuple[int, ...]: - raw = tuple(values) - if not raw or any( - isinstance(value, bool) or not isinstance(value, int) or value < 2 - for value in raw - ): - raise ValueError(f"{name} must contain integers of at least two") - capped = tuple(dict.fromkeys(min(value, maximum) for value in raw)) - if not capped or any(value < 2 for value in capped): - raise ValueError(f"{name} has no rank-valid values") - return capped - - def pca_prefix_phase(self) -> ParameterPhasePlan: - """Vary only the bounded PCA prefix on the native representation.""" - audit_k = min(21, self.n_cells - 1) - return ParameterPhasePlan( - phase="pcaPrefix", - assay=self.assay, - variedParameter="dimensions", - candidates=[ - ParameterCandidate( - candidateId=f"{self.prefix}_pca_{dimensions}", - reductionMethod="pca", - dimensions=dimensions, - leidenResolution=1.0, - neighborsK=audit_k, - useHarmony=False, - ) - for dimensions in self.dimensions - ], - ) - - def batch_correction_phase( - self, - selected: ParameterCandidate, - ) -> ParameterPhasePlan: - """Compare matched native and Harmony representations when licensed.""" - self._require_selected_pca(selected) - methods = (False, True) if self.harmony_authorized else (False,) - return ParameterPhasePlan( - phase="batchCorrection", - assay=self.assay, - variedParameter="useHarmony", - basedOnCandidateId=selected.candidateId, - candidates=[ - selected.model_copy( - update={ - "candidateId": ( - f"{self.prefix}_correction_" - f"{'harmony' if use_harmony else 'native'}" - ), - "useHarmony": use_harmony, - } - ) - for use_harmony in methods - ], - ) - - def graph_phase(self, selected: ParameterCandidate) -> ParameterPhasePlan: - """Vary only graph neighbourhood size after representation selection.""" - self._require_selected_pca(selected) - return ParameterPhasePlan( - phase="graphK", - assay=self.assay, - variedParameter="neighborsK", - basedOnCandidateId=selected.candidateId, - candidates=[ - selected.model_copy( - update={ - "candidateId": f"{self.prefix}_graph_k{k}", - "neighborsK": k, - } - ) - for k in self.neighbors - ], - ) - - def clustering_phase( - self, - selected: ParameterCandidate, - ) -> ParameterPhasePlan: - """Vary only Leiden resolution on the selected graph configuration.""" - self._require_selected_pca(selected) - return ParameterPhasePlan( - phase="clusteringResolution", - assay=self.assay, - variedParameter="leidenResolution", - basedOnCandidateId=selected.candidateId, - candidates=[ - selected.model_copy( - update={ - "candidateId": ( - f"{self.prefix}_resolution_" - f"{str(resolution).replace('.', 'p')}" - ), - "leidenResolution": resolution, - } - ) - for resolution in self.resolutions - ], - ) - - @staticmethod - def _require_selected_pca(candidate: ParameterCandidate) -> None: - if not isinstance(candidate, ParameterCandidate): - raise TypeError("selected must be a ParameterCandidate") - if candidate.reductionMethod != "pca" or not candidate.candidateId: - raise ValueError("selected must be an exact RNA PCA candidate") - - -def validate_parameter_phase_selection( - plan: ParameterPhasePlan, - evaluations: Sequence[ParameterCandidateEvaluation], - selection: ParameterPhaseSelection, -) -> ParameterPhaseEvidence: - """Validate an ID-only selection against complete executor evidence.""" - return ParameterPhaseEvidence( - plan=plan, - evaluations=list(annotate_candidate_dominance(evaluations)), - selection=selection, - ) - - -def execute_parameter_phase( - store: Any, - *, - normalized: Any, - plan: ParameterPhasePlan, - batch_columns: Sequence[str] = (), - preservation_columns: Sequence[str] = (), - experimental_handoff: ExperimentalTuningHandoff | None = None, - min_cluster_cells: int = 20, - identity_feature_limit: int = 64, -) -> tuple[ParameterCandidateEvaluation, ...]: - """Execute one phase through the existing deterministic candidate executor.""" - deps, candidate_ids = prepare_parameter_tuning_dependencies( - store, - normalized=normalized, - candidates=plan.candidates, - batch_columns=batch_columns, - preservation_columns=preservation_columns, - experimental_handoff=experimental_handoff, - max_candidates=len(plan.candidates), - max_refined_candidates=0, - min_cluster_cells=min_cluster_cells, - identity_feature_limit=identity_feature_limit, - ) - expected_ids = tuple(candidate.candidateId for candidate in plan.candidates) - if tuple(candidate_ids) != expected_ids: - raise ValueError("Prepared executor candidate inventory changed the phase plan") - return annotate_candidate_dominance( - tuple(execute_parameter_candidate(deps, value) for value in candidate_ids) - ) - - -def _sequential_candidate_evaluations( - evidence: SequentialAssayTuningEvidence, -) -> tuple[ParameterCandidateEvaluation, ...]: - if evidence.finalCandidateId is None: - raise ValueError("Sequential refinement requires complete grid evidence") - evaluations = tuple( - evaluation for phase in evidence.phases for evaluation in phase.evaluations - ) - if not evaluations: - raise ValueError("Sequential refinement requires executed grid candidates") - candidate_ids = [evaluation.candidateId for evaluation in evaluations] - if len(candidate_ids) != len(set(candidate_ids)): - raise ValueError("Sequential grid candidate IDs must be unique") - if evidence.finalCandidateId not in set(candidate_ids): - raise ValueError("Sequential final candidate is not present in grid evidence") - return evaluations - - -def prepare_sequential_refinement_dependencies( - store: Any, - *, - normalized: Any, - evidence: SequentialAssayTuningEvidence, - batch_columns: Sequence[str] = (), - preservation_columns: Sequence[str] = (), - experimental_handoff: ExperimentalTuningHandoff | None = None, - min_cluster_cells: int = 20, - identity_feature_limit: int = 64, -) -> tuple[ParameterTuningDependencies, list[str]]: - """Prepare one refinement executor from already executed sequential candidates.""" - - evaluations = _sequential_candidate_evaluations(evidence) - candidates = [evaluation.parameters for evaluation in evaluations] - deps, candidate_ids = prepare_parameter_tuning_dependencies( - store, - normalized=normalized, - candidates=candidates, - batch_columns=batch_columns, - preservation_columns=preservation_columns, - experimental_handoff=experimental_handoff, - max_candidates=len(candidates), - max_refined_candidates=1, - min_cluster_cells=min_cluster_cells, - identity_feature_limit=identity_feature_limit, - pair_harmony_candidates=False, - ) - expected_ids = [candidate.candidateId for candidate in candidates] - if candidate_ids != expected_ids: - raise ValueError("Prepared refinement inventory changed the grid candidates") - for evaluation in evaluations: - if ( - evaluation.status == "done" - and core_artifact_reference(evaluation.cellSelection) != deps.cellSelection - ): - raise ValueError( - "Sequential grid evaluation does not match the normalized cell axis" - ) - deps.evaluations = { - evaluation.candidateId: evaluation for evaluation in evaluations - } - deps.executionOrder = list(candidate_ids) - return deps, candidate_ids - - -def _changed_refinement_parameters( - candidate: ParameterCandidate, - parent: ParameterCandidate, -) -> tuple[str, ...]: - tunable_fields = ( - "reductionMethod", - "dimensions", - "neighborsK", - "leidenResolution", - "useHarmony", - ) - return tuple( - field_name - for field_name in tunable_fields - if getattr(candidate, field_name) != getattr(parent, field_name) - ) - - -def validate_sequential_refinement_plan( - plan: ParameterSearchPlan, - deps: ParameterTuningDependencies, - initial_candidate_ids: Sequence[str], -) -> ParameterSearchPlan: - """Validate a no-refinement decision or one bounded sequential candidate.""" - - initial_ids = tuple(initial_candidate_ids) - if not initial_ids or len(initial_ids) != len(set(initial_ids)): - raise ValueError("Sequential refinement requires unique grid candidate IDs") - if set(deps.evaluations) != set(initial_ids): - raise ValueError("Refinement dependencies do not match the executed grid") - eligible_ids = { - candidate_id - for candidate_id in initial_ids - if deps.evaluations[candidate_id].status == "done" - and deps.evaluations[candidate_id].eligible - } - if not eligible_ids: - raise ValueError("Sequential refinement requires an eligible successful parent") - - validated = validate_parameter_search_plan( - plan, - deps, - initial_candidate_ids=initial_ids, - max_refined_candidates=1, - ) - parent_ids = tuple(validated.basedOnCandidateIds) - if not parent_ids: - raise ValueError("Sequential refinement must name its successful parent") - if len(parent_ids) != len(set(parent_ids)): - raise ValueError("Sequential refinement parent IDs must be unique") - if any(parent_id not in eligible_ids for parent_id in parent_ids): - raise ValueError( - "Sequential refinement parents must be eligible successful grid candidates" - ) - if any( - not any( - evidence_id.startswith(f"candidate:{parent_id}:") - for evidence_id in validated.evidenceIds - ) - for parent_id in parent_ids - ): - raise ValueError("Sequential refinement must cite each parent candidate") - - if validated.status == "complete": - if not validated.evidenceIds: - raise ValueError("A no-refinement decision requires observed evidence") - if not validated.rationale.strip(): - raise ValueError("A no-refinement decision requires a rationale") - if not validated.stoppingCriteria: - raise ValueError("A no-refinement decision requires a stopping criterion") - return validated - - candidate = validated.candidates[0] - if candidate.useHarmony: - raise ValueError( - "One-candidate sequential refinement cannot evaluate Harmony because " - "acceptance requires a newly parameter-matched native control" - ) - parent_changes = { - parent_id: _changed_refinement_parameters( - candidate, - deps.evaluations[parent_id].parameters, - ) - for parent_id in parent_ids - } - if not any( - len(changes) == 1 - and changes[0] in {"dimensions", "neighborsK", "leidenResolution"} - for changes in parent_changes.values() - ): - raise ValueError( - "The refinement candidate must vary one numeric parameter from a " - "cited parent" - ) - matched_mode = [ - deps.evaluations[candidate_id].parameters - for candidate_id in initial_ids - if ( - deps.evaluations[candidate_id].parameters.reductionMethod - == candidate.reductionMethod - and deps.evaluations[candidate_id].parameters.useHarmony - == candidate.useHarmony - ) - ] - for field_name in ("dimensions", "neighborsK", "leidenResolution"): - observed = [getattr(value, field_name) for value in matched_mode] - if not min(observed) <= getattr(candidate, field_name) <= max(observed): - raise ValueError( - f"Refined {field_name} must remain inside its observed " - "correction-mode envelope" - ) - if not validated.objectives: - raise ValueError("A refinement candidate requires an evidence-based objective") - return validated - - -def execute_sequential_refinement( - deps: ParameterTuningDependencies, - plan: ParameterSearchPlan, - initial_candidate_ids: Sequence[str], -) -> SequentialRefinementResult: - """Execute at most one validated post-grid sequential candidate.""" - - validated = validate_sequential_refinement_plan( - plan, - deps, - initial_candidate_ids, - ) - validated, evaluations = execute_parameter_search_plan( - deps, - validated, - initial_candidate_ids=initial_candidate_ids, - max_refined_candidates=1, - ) - return SequentialRefinementResult( - plan=validated, - evaluation=evaluations[0] if evaluations else None, - ) - - -def sequential_refinement_selection_candidates( - evidence: SequentialAssayTuningEvidence, - refinement: SequentialRefinementResult, -) -> tuple[ParameterCandidateEvaluation, ...]: - """Return the grid and optional refinement as one final-selection inventory.""" - - evaluations = list(_sequential_candidate_evaluations(evidence)) - if refinement.evaluation is not None: - if refinement.evaluation.candidateId in { - evaluation.candidateId for evaluation in evaluations - }: - raise ValueError("Refinement candidate duplicates a grid candidate ID") - evaluations.append(refinement.evaluation) - return annotate_candidate_dominance(evaluations) - - -def sequential_evidence_to_report( - evidence: SequentialAssayTuningEvidence, - *, - marker_assay: str | None = None, - refinement: SequentialRefinementResult | None = None, - refinement_selection: SequentialRefinementSelection | None = None, -) -> ParameterTuningReport: - """Adapt four selected phases to the report consumed by finalization.""" - if evidence.finalCandidateId is None: - if refinement is not None or refinement_selection is not None: - raise ValueError("Post-grid refinement requires four selected phases") - final_phase = evidence.phases[-1] - cell_selection = next( - ( - evaluation.cellSelection - for evaluation in final_phase.evaluations - if evaluation.cellSelection is not None - ), - None, - ) - if cell_selection is None: - raise ValueError( - "Incomplete sequential evidence lacks an exact cell selection" - ) - selection_status = final_phase.selection.status - if selection_status == "selected" and evidence.pendingDecisionId is None: - raise ValueError( - "A selected intermediate phase must be followed before report adaptation" - ) - if evidence.pendingDecisionId is not None or selection_status == "needsInput": - report_status: Literal["needsInput", "abstained"] = "needsInput" - else: - report_status = "abstained" - needs_input = ( - ParameterTuningNeedsInput( - question=( - f"Resolve the registered {evidence.pendingDecisionId} decision." - ), - options=list(evidence.pendingOptionIds), - evidenceIds=list(evidence.pendingEvidenceIds), - ) - if report_status == "needsInput" - else None - ) - return ParameterTuningReport( - status=report_status, - fromAssay=evidence.assay, - cellSelection=cell_selection, - evaluations=list(final_phase.evaluations), - rationale=final_phase.selection.rationale, - evidenceIds=list(final_phase.selection.evidenceIds), - limitations=[ - "Sequential parameter adjudication did not select all four phases." - ], - stopReason=report_status, - needsInput=needs_input, - totalCandidates=sum(len(value.evaluations) for value in evidence.phases), - ) - final_phase = evidence.phases[-1] - selected = final_phase.selected_evaluation() - assert selected is not None - phase_selected = selected - phase_evidence_ids = list( - dict.fromkeys( - evidence_id - for value in evidence.phases - for evidence_id in value.selection.evidenceIds - ) - ) - evaluations = list(_sequential_candidate_evaluations(evidence)) - search_plan: ParameterSearchPlan | None = None - rationale = " ".join(value.selection.rationale for value in evidence.phases) - stop_reason = "Four causal RNA parameter phases were selected." - evidence_ids = phase_evidence_ids - if refinement is not None: - search_plan = refinement.plan - evaluations = list( - sequential_refinement_selection_candidates(evidence, refinement) - ) - evidence_ids = list( - dict.fromkeys([*phase_evidence_ids, *refinement.plan.evidenceIds]) - ) - rationale = f"{rationale} {refinement.plan.rationale}" - if refinement.evaluation is None: - if refinement_selection is not None: - raise ValueError( - "A no-refinement result cannot have a refinement selection" - ) - stop_reason = "The bounded post-grid review found no justified refinement." - else: - if refinement_selection is None: - raise ValueError( - "Executed refinement requires an explicit final selection" - ) - by_id = {evaluation.candidateId: evaluation for evaluation in evaluations} - selected = by_id.get(refinement_selection.selectedCandidateId) - if selected is None: - raise ValueError("Refinement selection references an unknown candidate") - if selected.status != "done" or not selected.eligible: - raise ValueError( - "Refinement selection must choose an eligible execution" - ) - if core_artifact_reference( - selected.cellSelection - ) != core_artifact_reference(phase_selected.cellSelection): - raise ValueError("Refinement selection changed the exact cell axis") - known_evidence = { - evidence_id - for evaluation in evaluations - for evidence_id in evaluation.evidenceIds - } - unknown_evidence = sorted( - set(refinement_selection.evidenceIds) - known_evidence - ) - if unknown_evidence: - raise ValueError( - f"Refinement selection cites unknown evidence {unknown_evidence}" - ) - prefix = f"candidate:{selected.candidateId}:" - if not any( - evidence_id.startswith(prefix) - for evidence_id in refinement_selection.evidenceIds - ): - raise ValueError( - "Refinement selection must cite its selected candidate" - ) - graph_partition_dominators = [ - candidate_id - for candidate_id in selected.metrics.dominatedByCandidateIds - if candidate_id in by_id - and _changed_refinement_parameters( - selected.parameters, - by_id[candidate_id].parameters, - ) - in {("neighborsK",), ("leidenResolution",)} - ] - if graph_partition_dominators: - require_dominated_candidate_evidence( - selected, - refinement_selection.evidenceIds, - context="The post-grid selection", - ) - evidence_ids = list( - dict.fromkeys([*evidence_ids, *refinement_selection.evidenceIds]) - ) - rationale = f"{rationale} {refinement_selection.rationale}" - stop_reason = "One bounded post-grid refinement was adjudicated." - elif refinement_selection is not None: - raise ValueError("Refinement selection requires a refinement result") - assay_report = ParameterTuningReport( - status="done", - fromAssay=evidence.assay, - cellSelection=selected.cellSelection, - evaluations=evaluations, - recommendedCandidateId=selected.candidateId, - selectedArtifacts=dict(selected.artifacts), - confidence="medium", - rationale=rationale, - evidenceIds=evidence_ids, - limitations=[], - stopReason=stop_reason, - searchPlan=search_plan, - recommendedByAssay={evidence.assay: selected.candidateId}, - totalCandidates=len(evaluations), - ) - report = assay_report.model_copy( - update={"assayReports": {evidence.assay: assay_report}} - ) - return finalize_parameter_tuning_selection( - report, - marker_assay=marker_assay or evidence.assay, - native_assay=evidence.assay, - ) - - -__all__ = [ - "CorrectionNeedSelection", - "execute_parameter_phase", - "execute_sequential_refinement", - "ParameterPhaseEvidence", - "ParameterPhasePlan", - "ParameterPhaseSelection", - "prepare_sequential_refinement_dependencies", - "SequentialAssayTuningEvidence", - "SequentialRefinementResult", - "SequentialRefinementSelection", - "SequentialRnaTuningPlanner", - "sequential_evidence_to_report", - "sequential_refinement_selection_candidates", - "validate_parameter_phase_selection", - "validate_sequential_refinement_plan", -] diff --git a/scarf/agent/persistence/__init__.py b/scarf/agent/persistence/__init__.py deleted file mode 100644 index cfcac4dd..00000000 --- a/scarf/agent/persistence/__init__.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Immutable agent workflow, report, and decision persistence.""" - -from .contracts import ( - AgentInvocation, - AgentName, - AgentPersistenceTarget, - AgentReportLink, - AgentReportRecord, - AgentReportReference, - AgentReportType, - AgentTerminalStatus, - AgentWorkflowRun, - AgentWorkflowStatus, -) -from .reports import ( - AgentReport, - create_agent_workflow, - finalize_agent_workflow, - list_agent_reports, - list_agent_workflows, - load_agent_record, - load_agent_report, - load_agent_workflow, - save_agent_report, -) - -__all__ = [ - "AgentInvocation", - "AgentName", - "AgentPersistenceTarget", - "AgentReport", - "AgentReportLink", - "AgentReportRecord", - "AgentReportReference", - "AgentReportType", - "AgentTerminalStatus", - "AgentWorkflowRun", - "AgentWorkflowStatus", - "create_agent_workflow", - "finalize_agent_workflow", - "list_agent_reports", - "list_agent_workflows", - "load_agent_record", - "load_agent_report", - "load_agent_workflow", - "save_agent_report", -] diff --git a/scarf/agent/persistence/contracts.py b/scarf/agent/persistence/contracts.py deleted file mode 100644 index 2f71e93c..00000000 --- a/scarf/agent/persistence/contracts.py +++ /dev/null @@ -1,415 +0,0 @@ -"""Serializable contracts for immutable Scarf agent records.""" - -import re -from pathlib import Path -from typing import Any, Literal - -import zarr -from pydantic import Field, field_validator, model_validator - -from ...datastore.datastore import DataStore -from ...storage.schema import validate_workspace_name -from ..config import AgentRunConfig -from ..types import ( - AgentDataModel, - ArtifactReferenceModel, - ExperimentalBiologyHandoff, - ExperimentalTuningHandoff, - TuningBiologyHandoff, -) - -type AgentName = Literal[ - "data_enrichment", - "experimental_context", - "parameter_tuning", - "biological_interpretation", -] -type AgentReportType = Literal[ - "", - "DataEnrichmentReport", - "ExperimentalContextResult", - "ParameterTuningReport", - "BiologicalInterpretationReport", -] -type AgentPersistenceTarget = str | Path | zarr.Group | DataStore -type AgentWorkflowStatus = Literal[ - "running", - "completed", - "abstained", - "failed", - "abandoned", -] -type AgentTerminalStatus = Literal["completed", "abstained", "failed", "abandoned"] - -_FORMAT = "scarf_agent_reports" -_RUN_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,127}$") -_SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") - - -class AgentReportLink(AgentDataModel): - """Immutable identity of one report used as an invocation parent.""" - - type: Literal["agentReportLink"] = "agentReportLink" - workflowRunId: str = "" - workspace: str | None = None - agentName: AgentName = "data_enrichment" - agentRunId: str = "" - contentSha256: str = "" - - @field_validator("workflowRunId", "agentRunId") - @classmethod - def validate_run_ids(cls, value: str) -> str: - if value: - _validate_run_id(value, "run ID") - return value - - @field_validator("workspace") - @classmethod - def validate_workspace(cls, value: str | None) -> str | None: - validate_workspace_name(value) - return value - - @field_validator("contentSha256") - @classmethod - def validate_content_sha256(cls, value: str) -> str: - if value and _SHA256_PATTERN.fullmatch(value) is None: - raise ValueError("contentSha256 must be a lowercase SHA-256 digest") - return value - - @model_validator(mode="after") - def validate_complete_identity(self) -> "AgentReportLink": - if self.workflowRunId or self.agentRunId or self.contentSha256: - if not self.workflowRunId or not self.agentRunId or not self.contentSha256: - raise ValueError("A parent report link requires a complete identity") - return self - - @classmethod - def from_reference(cls, reference: "AgentReportReference") -> "AgentReportLink": - return cls( - workflowRunId=reference.workflowRunId, - workspace=reference.workspace, - agentName=reference.agentName, - agentRunId=reference.agentRunId, - contentSha256=reference.contentSha256, - ) - - @classmethod - def get_blank(cls) -> "AgentReportLink": - return cls() - - @classmethod - def get_example(cls) -> "AgentReportLink": - return cls( - workflowRunId="workflow-1", - agentName="experimental_context", - agentRunId="experimental-run-1", - contentSha256="0" * 64, - ) - - -class AgentInvocation(AgentDataModel): - """Replay-relevant inputs and typed handoffs for one agent invocation.""" - - agentName: AgentName = "data_enrichment" - parentReports: list[AgentReportLink] = Field(default_factory=list) - inputs: dict[str, Any] = Field(default_factory=dict) - artifacts: dict[str, ArtifactReferenceModel] = Field(default_factory=dict) - runConfig: AgentRunConfig = Field(default_factory=AgentRunConfig) - experimentalTuningHandoff: ExperimentalTuningHandoff | None = None - experimentalBiologyHandoff: ExperimentalBiologyHandoff | None = None - tuningBiologyHandoff: TuningBiologyHandoff | None = None - - @model_validator(mode="after") - def validate_parent_reports(self) -> "AgentInvocation": - identities = [ - (parent.workflowRunId, parent.agentName, parent.agentRunId) - for parent in self.parentReports - ] - if len(identities) != len(set(identities)): - raise ValueError("parentReports must not contain duplicate reports") - return self - - @classmethod - def get_blank(cls) -> "AgentInvocation": - return cls() - - @classmethod - def get_example(cls) -> "AgentInvocation": - cell_selection = ArtifactReferenceModel( - scope="datastore", - kind="cell_selection", - artifactId="c" * 64, - ) - return cls( - agentName="parameter_tuning", - parentReports=[AgentReportLink.get_example()], - inputs={ - "fromAssay": "RNA", - "cellSelection": cell_selection.model_dump(mode="json"), - }, - artifacts={"cellSelection": cell_selection}, - runConfig=AgentRunConfig.get_example(), - experimentalTuningHandoff=ExperimentalTuningHandoff( - cellSelection=cell_selection, - batchAction="skip", - ), - ) - - -class AgentReportReference(AgentDataModel): - """Stable identity for one immutable agent report.""" - - type: Literal["agentReport"] = "agentReport" - workflowRunId: str = "" - workspace: str | None = None - agentName: AgentName = "data_enrichment" - agentRunId: str = "" - reportType: AgentReportType = "" - executionRunId: str = "" - createdAtNs: int = Field(default=0, ge=0, strict=True) - complete: bool = Field(default=False, strict=True) - parentReports: list[AgentReportLink] = Field(default_factory=list) - contentSha256: str = "" - - @field_validator("workflowRunId", "agentRunId") - @classmethod - def validate_run_ids(cls, value: str) -> str: - if value: - _validate_run_id(value, "run ID") - return value - - @field_validator("workspace") - @classmethod - def validate_workspace(cls, value: str | None) -> str | None: - validate_workspace_name(value) - return value - - @field_validator("contentSha256") - @classmethod - def validate_content_sha256(cls, value: str) -> str: - if value and _SHA256_PATTERN.fullmatch(value) is None: - raise ValueError("contentSha256 must be a lowercase SHA-256 digest") - return value - - @model_validator(mode="after") - def validate_complete_identity(self) -> "AgentReportReference": - has_identity = bool( - self.workflowRunId - or self.agentRunId - or self.reportType - or self.createdAtNs - or self.complete - or self.contentSha256 - ) - if has_identity and ( - not self.workflowRunId - or not self.agentRunId - or not self.reportType - or self.createdAtNs < 1 - or not self.complete - or not self.contentSha256 - ): - raise ValueError("An agent report reference requires a complete identity") - return self - - @classmethod - def get_blank(cls) -> "AgentReportReference": - return cls() - - @classmethod - def get_example(cls) -> "AgentReportReference": - return cls( - workflowRunId="workflow-1", - agentName="data_enrichment", - agentRunId="agent-run-1", - reportType="DataEnrichmentReport", - executionRunId="provider-run-1", - createdAtNs=1, - complete=True, - contentSha256="0" * 64, - ) - - -class AgentReportRecord(AgentDataModel): - """Complete JSON envelope for one immutable report and its invocation.""" - - recordType: Literal["agentReport"] = "agentReport" - formatVersion: Literal[2] = 2 - reference: AgentReportReference = Field(default_factory=AgentReportReference) - invocation: AgentInvocation = Field(default_factory=AgentInvocation) - report: dict[str, Any] = Field(default_factory=dict) - - @model_validator(mode="after") - def validate_identity(self) -> "AgentReportRecord": - if self.reference.agentName != self.invocation.agentName: - raise ValueError("Report reference and invocation agent names differ") - if self.reference.parentReports != self.invocation.parentReports: - raise ValueError("Report reference and invocation parents differ") - if any( - parent.workflowRunId == self.reference.workflowRunId - and parent.agentName == self.reference.agentName - and parent.agentRunId == self.reference.agentRunId - for parent in self.invocation.parentReports - ): - raise ValueError("An agent report cannot cite itself as a parent") - return self - - @classmethod - def get_blank(cls) -> "AgentReportRecord": - return cls() - - @classmethod - def get_example(cls) -> "AgentReportRecord": - from ..data_enrichment.contracts import DataEnrichmentReport - - report = DataEnrichmentReport.get_example() - return cls( - reference=AgentReportReference.get_example(), - invocation=AgentInvocation(agentName="data_enrichment"), - report=report.model_dump(mode="json"), - ) - - -class AgentWorkflowRun(AgentDataModel): - """One dataset-bound workflow and its immutable report records.""" - - type: Literal["agentWorkflowRun"] = "agentWorkflowRun" - formatVersion: Literal[2] = 2 - workflowRunId: str = "" - workspace: str | None = None - createdAtNs: int = Field(default=0, ge=0, strict=True) - finalizedAtNs: int = Field(default=0, ge=0, strict=True) - status: AgentWorkflowStatus = "running" - finalizationMessage: str = "" - analysisStore: str = "" - datasetFingerprints: dict[str, str] = Field(default_factory=dict) - reports: list[AgentReportReference] = Field(default_factory=list) - - @field_validator("workflowRunId") - @classmethod - def validate_workflow_run_id(cls, value: str) -> str: - if value: - _validate_run_id(value, "workflowRunId") - return value - - @field_validator("workspace") - @classmethod - def validate_workspace(cls, value: str | None) -> str | None: - validate_workspace_name(value) - return value - - @field_validator("datasetFingerprints") - @classmethod - def validate_dataset_fingerprints(cls, value: dict[str, str]) -> dict[str, str]: - if any(not assay or not fingerprint for assay, fingerprint in value.items()): - raise ValueError("Dataset fingerprint names and values must be non-empty") - return dict(sorted(value.items())) - - @model_validator(mode="after") - def validate_lifecycle(self) -> "AgentWorkflowRun": - if self.workflowRunId and self.createdAtNs < 1: - raise ValueError("A workflow requires a positive createdAtNs") - if self.workflowRunId and not self.datasetFingerprints: - raise ValueError("A workflow requires exact dataset fingerprints") - if self.status == "running" and self.finalizedAtNs != 0: - raise ValueError("A running workflow cannot have finalizedAtNs") - if self.status == "running" and self.finalizationMessage: - raise ValueError("A running workflow cannot have a finalizationMessage") - if self.status != "running" and self.finalizedAtNs < 1: - raise ValueError("A terminal workflow requires finalizedAtNs") - if ( - self.status != "running" - and self.createdAtNs - and self.finalizedAtNs < self.createdAtNs - ): - raise ValueError("finalizedAtNs cannot precede createdAtNs") - return self - - @classmethod - def get_blank(cls) -> "AgentWorkflowRun": - return cls() - - @classmethod - def get_example(cls) -> "AgentWorkflowRun": - return cls( - workflowRunId="workflow-1", - createdAtNs=1, - analysisStore="analysis.zarr", - datasetFingerprints={"RNA": "dataset-1"}, - reports=[AgentReportReference.get_example()], - ) - - -class AgentStoreManifest(AgentDataModel): - """Identity document for one workspace-local agent JSON store.""" - - type: Literal["agentReportStore"] = "agentReportStore" - format: Literal["scarf_agent_reports"] = "scarf_agent_reports" - formatVersion: Literal[2] = 2 - workspace: str | None = None - - @field_validator("workspace") - @classmethod - def validate_workspace(cls, value: str | None) -> str | None: - validate_workspace_name(value) - return value - - @classmethod - def get_blank(cls) -> "AgentStoreManifest": - return cls() - - @classmethod - def get_example(cls) -> "AgentStoreManifest": - return cls(workspace="analysis") - - -class AgentWorkflowFinalization(AgentDataModel): - """Immutable terminal event for a workflow.""" - - recordType: Literal["agentWorkflowFinalization"] = "agentWorkflowFinalization" - formatVersion: Literal[2] = 2 - workflowRunId: str = "" - workspace: str | None = None - status: AgentTerminalStatus = "completed" - finalizedAtNs: int = Field(default=0, ge=0, strict=True) - message: str = "" - - @field_validator("workflowRunId") - @classmethod - def validate_workflow_run_id(cls, value: str) -> str: - if value: - _validate_run_id(value, "workflowRunId") - return value - - @field_validator("workspace") - @classmethod - def validate_workspace(cls, value: str | None) -> str | None: - validate_workspace_name(value) - return value - - @model_validator(mode="after") - def validate_finalization(self) -> "AgentWorkflowFinalization": - if self.workflowRunId and self.finalizedAtNs < 1: - raise ValueError("A finalization requires a positive finalizedAtNs") - return self - - @classmethod - def get_blank(cls) -> "AgentWorkflowFinalization": - return cls() - - @classmethod - def get_example(cls) -> "AgentWorkflowFinalization": - return cls( - workflowRunId="workflow-1", - status="completed", - finalizedAtNs=2, - ) - - -def _validate_run_id(value: str, label: str) -> str: - if _RUN_ID_PATTERN.fullmatch(value) is None: - raise ValueError( - f"{label} must be one safe path component containing 1-128 ASCII " - "lowercase letters, numbers, underscores, or hyphens" - ) - return value diff --git a/scarf/agent/persistence/decisions.py b/scarf/agent/persistence/decisions.py deleted file mode 100644 index eeabaa6e..00000000 --- a/scarf/agent/persistence/decisions.py +++ /dev/null @@ -1,771 +0,0 @@ -"""Append-only persistence for decision-driven orchestration ledgers.""" - -import hashlib -import json -import re -import time -from typing import Literal, cast - -import zarr -from pydantic import ConfigDict, Field, field_validator, model_validator -from zarr.core.buffer import default_buffer_prototype -from zarr.core.sync import sync - -from .. import record_io -from ..decisions.kernel import ( - DecisionRecord, - DecisionWorkflowRun, - PendingDecision, - RevisionRequest, -) -from ..decisions.rna import ( - RNA_DECISION_TRANSITION_GRAPH, - CompiledRnaDecision, - RnaDecisionCheckpoint, -) -from ..orchestrator.models import ( - _ORCHESTRATION_FORMAT, - _ORCHESTRATION_VERSION, - OrchestrationRequestRecord, -) -from ..types import AgentDataModel -from .contracts import AgentPersistenceTarget -from .reports import _resolve_target - -_RUN_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,127}$") -_SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") -_RERUN_MESSAGE = ( - "Start a new orchestration run; decision snapshots are not migrated or resumed " - "across persistence formats. Existing artifacts are left unchanged." -) - - -class DecisionPersistenceFormatError(ValueError): - """Raised for old or unknown persistence without mutating stored data.""" - - -class DecisionSnapshotModel(AgentDataModel): - """Base for immutable decision-persistence records.""" - - model_config = ConfigDict(extra="forbid", frozen=True, validate_default=True) - - -class OrchestrationRunIdentity(DecisionSnapshotModel): - """Exact immutable orchestration request linked by a decision snapshot.""" - - workflowRunId: str - workspace: str | None = None - requestSha256: str - configSha256: str - requestContentSha256: str - - @field_validator("workflowRunId") - @classmethod - def validate_workflow_run_id(cls, value: str) -> str: - if _RUN_ID_PATTERN.fullmatch(value) is None: - raise ValueError("workflowRunId must be a lowercase run identifier") - return value - - @field_validator("requestSha256", "configSha256", "requestContentSha256") - @classmethod - def validate_sha256(cls, value: str, info: object) -> str: - if _SHA256_PATTERN.fullmatch(value) is None: - field_name = getattr(info, "field_name", "digest") - raise ValueError(f"{field_name} must be a lowercase SHA-256 digest") - return value - - -class DecisionWorkflowSnapshot(DecisionSnapshotModel): - """One content-addressed snapshot in an immutable decision ledger chain.""" - - recordType: Literal["decisionWorkflowSnapshot"] = "decisionWorkflowSnapshot" - formatVersion: Literal[2] = 2 - sequence: int = Field(ge=0, strict=True) - createdAtNs: int = Field(ge=1, strict=True) - parentContentSha256: str | None = None - orchestrationRun: OrchestrationRunIdentity - workflow: DecisionWorkflowRun - contentSha256: str - - @field_validator("parentContentSha256", "contentSha256") - @classmethod - def validate_sha256(cls, value: str | None, info: object) -> str | None: - if value is not None and _SHA256_PATTERN.fullmatch(value) is None: - field_name = getattr(info, "field_name", "digest") - raise ValueError(f"{field_name} must be a lowercase SHA-256 digest") - return value - - @model_validator(mode="after") - def validate_identity(self) -> "DecisionWorkflowSnapshot": - if self.sequence == 0 and self.parentContentSha256 is not None: - raise ValueError("The first snapshot cannot have a parent") - if self.sequence > 0 and self.parentContentSha256 is None: - raise ValueError("A later snapshot requires its exact parent checksum") - if self.workflow.workflowRunId != self.orchestrationRun.workflowRunId: - raise ValueError( - "Decision workflow identity must match the orchestration run" - ) - return self - - -def _validate_run_id(value: str) -> str: - if _RUN_ID_PATTERN.fullmatch(value) is None: - raise ValueError("workflow_run_id must be a lowercase run identifier") - return value - - -def _validate_sha256(value: str, label: str) -> str: - if _SHA256_PATTERN.fullmatch(value) is None: - raise ValueError(f"{label} must be a lowercase SHA-256 digest") - return value - - -def _model_checksum(value: AgentDataModel) -> str: - return hashlib.sha256( - record_io.canonical_json_bytes(value.model_dump(mode="json")) - ).hexdigest() - - -def _record_checksum(value: AgentDataModel) -> str: - return hashlib.sha256( - record_io.canonical_json_bytes( - value.model_dump(mode="json", exclude={"contentSha256"}) - ) - ).hexdigest() - - -def decision_record_checksum(record: DecisionRecord) -> str: - """Return the canonical SHA-256 identity of one immutable decision record.""" - return hashlib.sha256( - record_io.canonical_json_bytes(record.model_dump(mode="json")) - ).hexdigest() - - -def _snapshot_checksum(snapshot: DecisionWorkflowSnapshot) -> str: - return _record_checksum(snapshot) - - -def _write_key_once(group: zarr.Group, key: str, payload: bytes) -> None: - store = group.store - if bool(getattr(store, "read_only", False)) or not bool( - getattr(store, "supports_writes", True) - ): - raise PermissionError("Decision persistence target is read-only") - if record_io.read_key(group, key) is not None: - raise FileExistsError(f"Immutable decision snapshot {key!r} already exists") - buffer = default_buffer_prototype().buffer.from_bytes(payload) - sync(store.set_if_not_exists(key, buffer)) - stored = record_io.read_key(group, key) - if stored is None: - raise RuntimeError(f"Decision snapshot {key!r} was not stored") - if stored != payload: - raise FileExistsError( - f"Immutable decision snapshot {key!r} was written by another writer" - ) - - -def _list_keys(group: zarr.Group, prefix: str) -> list[str]: - if not group.store.supports_listing: - raise NotImplementedError("Decision persistence requires a listable Zarr store") - return record_io.list_keys(group, prefix) - - -def _orchestration_prefix(group: zarr.Group) -> str: - return record_io.join_key( - str(getattr(group, "path", "")).strip("/"), - "agents", - "orchestrations", - ) - - -def _request_key(prefix: str, workflow_run_id: str) -> str: - return record_io.join_key(prefix, workflow_run_id, "request.json") - - -def _snapshot_prefix(prefix: str, workflow_run_id: str) -> str: - return record_io.join_key( - prefix, - workflow_run_id, - "decisions", - "snapshots", - ) - - -def _snapshot_key(prefix: str, workflow_run_id: str, content_sha256: str) -> str: - return record_io.join_key( - _snapshot_prefix(prefix, workflow_run_id), - f"{content_sha256}.json", - ) - - -def _format_error(detail: str) -> DecisionPersistenceFormatError: - return DecisionPersistenceFormatError(f"{detail} {_RERUN_MESSAGE}") - - -def _resolve_orchestration_group( - target: AgentPersistenceTarget, - *, - write: bool, - workspace: str | None, -) -> tuple[zarr.Group, str | None, str]: - group, _datastore, resolved_workspace, _analysis_store = _resolve_target( - target, - write=write, - workspace=workspace, - ) - if "agents" not in group: - raise FileNotFoundError( - "No agent namespace exists for this data group. " + _RERUN_MESSAGE - ) - agents = group["agents"] - if not isinstance(agents, zarr.Group): - raise _format_error("The agents namespace is not a Zarr group.") - if "orchestrations" not in agents: - raise FileNotFoundError( - "No orchestration journal exists for this data group. " + _RERUN_MESSAGE - ) - orchestrations = agents["orchestrations"] - if not isinstance(orchestrations, zarr.Group): - raise _format_error("The orchestrations namespace is not a Zarr group.") - observed_format = orchestrations.attrs.get("format") - observed_version = orchestrations.attrs.get("format_version") - if ( - observed_format != _ORCHESTRATION_FORMAT - or observed_version != _ORCHESTRATION_VERSION - ): - raise _format_error( - "Unsupported orchestration persistence format " - f"{observed_format!r} version {observed_version!r}." - ) - return group, resolved_workspace, _orchestration_prefix(group) - - -def _decode_json(raw: bytes, key: str) -> object: - try: - return json.loads(raw) - except (UnicodeDecodeError, json.JSONDecodeError) as exc: - raise ValueError(f"Decision JSON record {key!r} is malformed") from exc - - -def _load_orchestration_identity( - group: zarr.Group, - prefix: str, - workflow_run_id: str, - workspace: str | None, -) -> OrchestrationRunIdentity: - key = _request_key(prefix, workflow_run_id) - raw = record_io.read_key(group, key) - if raw is None: - raise KeyError( - f"Unknown orchestration run {workflow_run_id!r}; {_RERUN_MESSAGE}" - ) - decoded = _decode_json(raw, key) - if not isinstance(decoded, dict): - raise ValueError(f"Orchestration request {key!r} is not a JSON object") - if decoded.get("formatVersion") != 2: - raise _format_error( - f"Unsupported orchestration request version " - f"{decoded.get('formatVersion')!r}." - ) - if decoded.get("recordType") != "automatedWorkflowRequest": - raise _format_error( - f"Unsupported orchestration request type {decoded.get('recordType')!r}." - ) - try: - request = OrchestrationRequestRecord.model_validate(decoded) - except ValueError as exc: - raise ValueError( - f"Orchestration request {key!r} does not match its schema" - ) from exc - if request.workflowRunId != workflow_run_id: - raise ValueError("Orchestration request identity does not match its path") - if request.request.workspace != workspace: - raise ValueError("Orchestration request workspace does not match its path") - if request.requestSha256 != _model_checksum(request.request): - raise ValueError("Orchestration request payload checksum is invalid") - if request.configSha256 != _model_checksum(request.config): - raise ValueError("Orchestration configuration checksum is invalid") - if request.contentSha256 != _record_checksum(request): - raise ValueError("Orchestration request envelope checksum is invalid") - return OrchestrationRunIdentity( - workflowRunId=workflow_run_id, - workspace=workspace, - requestSha256=request.requestSha256, - configSha256=request.configSha256, - requestContentSha256=request.contentSha256, - ) - - -def _load_snapshot_at( - group: zarr.Group, - prefix: str, - workflow_run_id: str, - content_sha256: str, - expected_identity: OrchestrationRunIdentity, -) -> DecisionWorkflowSnapshot: - content_sha256 = _validate_sha256(content_sha256, "content_sha256") - key = _snapshot_key(prefix, workflow_run_id, content_sha256) - raw = record_io.read_key(group, key) - if raw is None: - raise KeyError( - f"Unknown decision snapshot {content_sha256!r} for " - f"workflow {workflow_run_id!r}" - ) - decoded = _decode_json(raw, key) - if not isinstance(decoded, dict): - raise ValueError(f"Decision snapshot {key!r} is not a JSON object") - if decoded.get("formatVersion") != 2: - raise _format_error( - f"Unsupported decision snapshot version {decoded.get('formatVersion')!r}." - ) - if decoded.get("recordType") != "decisionWorkflowSnapshot": - raise _format_error( - f"Unsupported decision snapshot type {decoded.get('recordType')!r}." - ) - try: - snapshot = DecisionWorkflowSnapshot.model_validate(decoded) - except ValueError as exc: - raise ValueError( - f"Decision snapshot {key!r} does not match its schema" - ) from exc - if snapshot.contentSha256 != content_sha256: - raise ValueError("Decision snapshot checksum does not match its path") - if snapshot.contentSha256 != _snapshot_checksum(snapshot): - raise ValueError("Decision snapshot checksum does not match its content") - if snapshot.orchestrationRun != expected_identity: - raise ValueError("Decision snapshot orchestration identity is stale") - if snapshot.workflow.workflowRunId != workflow_run_id: - raise ValueError("Decision snapshot workflow identity does not match its path") - return snapshot - - -def _validate_snapshot_evolution( - previous: DecisionWorkflowSnapshot, - current: DecisionWorkflowSnapshot, -) -> None: - if previous.orchestrationRun != current.orchestrationRun: - raise ValueError("Decision snapshot orchestration identity changed") - if previous.workflow.workflowRunId != current.workflow.workflowRunId: - raise ValueError("Decision snapshot workflow identity changed") - if current.createdAtNs < previous.createdAtNs: - raise ValueError("Decision snapshot creation times must not move backwards") - for field_name in ( - "decisionRecords", - "verificationRecords", - "revisionRequests", - ): - old_values = getattr(previous.workflow, field_name) - new_values = getattr(current.workflow, field_name) - if new_values[: len(old_values)] != old_values: - raise ValueError( - f"Decision snapshot {field_name} must preserve its immutable prefix" - ) - if previous.workflow.status in {"completed", "abstained", "failed"}: - raise ValueError("Terminal decision snapshots cannot have descendants") - - -def _load_snapshot_chain( - group: zarr.Group, - prefix: str, - workflow_run_id: str, - identity: OrchestrationRunIdentity, -) -> list[DecisionWorkflowSnapshot]: - snapshot_prefix = _snapshot_prefix(prefix, workflow_run_id) - snapshots: list[DecisionWorkflowSnapshot] = [] - for key in _list_keys(group, snapshot_prefix): - if not key.endswith(".json"): - continue - filename = key.rsplit("/", 1)[-1] - content_sha256 = filename.removesuffix(".json") - if _SHA256_PATTERN.fullmatch(content_sha256) is None: - raise ValueError("Decision snapshot path is not content-addressed") - snapshots.append( - _load_snapshot_at( - group, - prefix, - workflow_run_id, - content_sha256, - identity, - ) - ) - snapshots.sort(key=lambda value: (value.sequence, value.contentSha256)) - for expected_sequence, snapshot in enumerate(snapshots): - if snapshot.sequence != expected_sequence: - raise ValueError("Decision snapshot chain has a gap or fork") - expected_parent = ( - snapshots[expected_sequence - 1].contentSha256 - if expected_sequence > 0 - else None - ) - if snapshot.parentContentSha256 != expected_parent: - raise ValueError("Decision snapshot parent does not match the exact chain") - if expected_sequence > 0: - _validate_snapshot_evolution( - snapshots[expected_sequence - 1], - snapshot, - ) - return snapshots - - -def save_decision_workflow_snapshot( - target: AgentPersistenceTarget, - workflow: DecisionWorkflowRun, - *, - workspace: str | None = None, - created_at_ns: int | None = None, -) -> DecisionWorkflowSnapshot: - """Append one content-addressed snapshot without overwriting earlier state.""" - workflow_run_id = _validate_run_id(workflow.workflowRunId) - group, resolved_workspace, prefix = _resolve_orchestration_group( - target, - write=True, - workspace=workspace, - ) - identity = _load_orchestration_identity( - group, - prefix, - workflow_run_id, - resolved_workspace, - ) - snapshots = _load_snapshot_chain( - group, - prefix, - workflow_run_id, - identity, - ) - if snapshots and snapshots[-1].workflow == workflow: - return snapshots[-1] - if snapshots and snapshots[-1].workflow.status in { - "completed", - "abstained", - "failed", - }: - raise RuntimeError("Cannot append after a terminal decision snapshot") - timestamp = time.time_ns() if created_at_ns is None else created_at_ns - if timestamp < 1: - raise ValueError("created_at_ns must be positive") - snapshot_values = { - "sequence": len(snapshots), - "createdAtNs": timestamp, - "parentContentSha256": snapshots[-1].contentSha256 if snapshots else None, - "orchestrationRun": identity, - "workflow": workflow, - "contentSha256": "0" * 64, - } - unhashed = DecisionWorkflowSnapshot.model_validate(snapshot_values) - snapshot_values["contentSha256"] = _snapshot_checksum(unhashed) - snapshot = DecisionWorkflowSnapshot.model_validate(snapshot_values) - if snapshots: - _validate_snapshot_evolution(snapshots[-1], snapshot) - key = _snapshot_key( - prefix, - workflow_run_id, - snapshot.contentSha256, - ) - _write_key_once( - group, - key, - record_io.display_json_bytes(snapshot.model_dump(mode="json")), - ) - stored = _load_snapshot_at( - group, - prefix, - workflow_run_id, - snapshot.contentSha256, - identity, - ) - chain = _load_snapshot_chain(group, prefix, workflow_run_id, identity) - if chain[-1].contentSha256 != stored.contentSha256: - raise RuntimeError("Decision snapshot did not become the exact chain head") - return stored - - -def load_decision_workflow_snapshot( - target: AgentPersistenceTarget, - workflow_run_id: str, - content_sha256: str, - *, - workspace: str | None = None, -) -> DecisionWorkflowSnapshot: - """Load one exact content-addressed snapshot and validate its live link.""" - workflow_run_id = _validate_run_id(workflow_run_id) - group, resolved_workspace, prefix = _resolve_orchestration_group( - target, - write=False, - workspace=workspace, - ) - identity = _load_orchestration_identity( - group, - prefix, - workflow_run_id, - resolved_workspace, - ) - return _load_snapshot_at( - group, - prefix, - workflow_run_id, - content_sha256, - identity, - ) - - -def list_decision_workflow_snapshots( - target: AgentPersistenceTarget, - workflow_run_id: str, - *, - workspace: str | None = None, -) -> list[DecisionWorkflowSnapshot]: - """Return the complete validated append-only snapshot chain.""" - workflow_run_id = _validate_run_id(workflow_run_id) - group, resolved_workspace, prefix = _resolve_orchestration_group( - target, - write=False, - workspace=workspace, - ) - identity = _load_orchestration_identity( - group, - prefix, - workflow_run_id, - resolved_workspace, - ) - return _load_snapshot_chain(group, prefix, workflow_run_id, identity) - - -def load_latest_decision_workflow_snapshot( - target: AgentPersistenceTarget, - workflow_run_id: str, - *, - workspace: str | None = None, -) -> DecisionWorkflowSnapshot: - """Return the head of the fully validated immutable snapshot chain.""" - snapshots = list_decision_workflow_snapshots( - target, - workflow_run_id, - workspace=workspace, - ) - if not snapshots: - raise KeyError(f"No decision snapshots for workflow {workflow_run_id!r}") - return snapshots[-1] - - -def load_decision_workflow_for_replay( - target: AgentPersistenceTarget, - workflow_run_id: str, - content_sha256: str, - *, - expected_handoff_id: str | None = None, - workspace: str | None = None, -) -> DecisionWorkflowRun: - """Load an exact completed ledger after validating its complete chain.""" - snapshots = list_decision_workflow_snapshots( - target, - workflow_run_id, - workspace=workspace, - ) - matches = [ - snapshot for snapshot in snapshots if snapshot.contentSha256 == content_sha256 - ] - if len(matches) != 1: - raise KeyError( - f"Snapshot {content_sha256!r} is not in the exact workflow chain" - ) - workflow = matches[0].workflow - if workflow.status != "completed" or workflow.finalHandoffId is None: - raise RuntimeError("Replay requires an exact completed decision workflow") - if ( - expected_handoff_id is not None - and workflow.finalHandoffId != expected_handoff_id - ): - raise ValueError("Replay final handoff identity does not match the snapshot") - return workflow - - -def pause_decision_workflow( - workflow: DecisionWorkflowRun, - pending: PendingDecision, -) -> DecisionWorkflowRun: - """Persist one unresolved checkpoint without inventing a selection.""" - if workflow.status != "running": - raise ValueError("Only a running decision workflow can pause") - values = workflow.model_dump(mode="json") - values["status"] = "needsInput" - values["pendingDecision"] = pending.model_dump(mode="json") - return DecisionWorkflowRun.model_validate(values) - - -def attach_audited_rna_decision( - workflow: DecisionWorkflowRun, - record: DecisionRecord, - compiled: CompiledRnaDecision, - *, - revision: RevisionRequest | None = None, -) -> DecisionWorkflowRun: - """Append one audited RNA decision in exact transition order.""" - if workflow.status == "needsInput" and workflow.pendingDecision is not None: - pending = workflow.pendingDecision - mismatches = [ - field_name - for field_name, pending_value, record_value in ( - ("decisionId", pending.decisionId, record.decisionId), - ( - "definitionVersion", - pending.definitionVersion, - record.definitionVersion, - ), - ("evidenceBundleId", pending.evidenceBundleId, record.evidenceBundleId), - ( - "evidenceBundleSha256", - pending.evidenceBundleSha256, - record.evidenceBundleSha256, - ), - ("offeredOptionIds", pending.offeredOptionIds, record.offeredOptionIds), - ( - "availableEvidenceIds", - pending.availableEvidenceIds, - record.availableEvidenceIds, - ), - ) - if pending_value != record_value - ] - if mismatches: - raise ValueError( - "Decision does not resolve the exact pending checkpoint; " - f"mismatched fields: {mismatches}" - ) - elif workflow.status != "running": - raise ValueError("Only a running decision workflow can accept a decision") - if ( - compiled.decisionRecordId != record.recordId - or compiled.decisionId != record.decisionId - or compiled.selectedOptionId != record.selectedOptionId - or compiled.status != record.status - or compiled.verification.decisionRecordId != record.recordId - or compiled.verification.verificationId != record.verificationId - or compiled.verification.status != "passed" - ): - raise ValueError("Compiled RNA decision does not exactly match its record") - try: - checkpoint = cast(RnaDecisionCheckpoint, record.decisionId) - RNA_DECISION_TRANSITION_GRAPH.resolve(checkpoint, record.status) - except KeyError as exc: - raise ValueError("Decision is not a registered RNA checkpoint/status") from exc - - if record.supersedes is None: - if revision is not None: - raise ValueError("A non-superseding decision cannot attach a revision") - if workflow.decisionRecords: - previous = workflow.decisionRecords[-1] - previous_checkpoint = cast(RnaDecisionCheckpoint, previous.decisionId) - expected_checkpoint, terminal = RNA_DECISION_TRANSITION_GRAPH.resolve( - previous_checkpoint, - previous.status, - ) - if terminal is not None or expected_checkpoint != checkpoint: - raise ValueError( - "Decision does not follow the exact RNA transition order" - ) - elif checkpoint != "qcGrouping": - raise ValueError("The first RNA decision must be qcGrouping") - else: - if revision is not None: - if revision.targetDecisionRecordId != record.supersedes: - raise ValueError( - "A superseding decision requires its exact revision request" - ) - else: - if record.supersedes not in workflow.invalidated_decision_record_ids(): - raise ValueError( - "A superseding decision requires a revision or invalidation" - ) - active_records = workflow.active_decision_records() - if not active_records: - raise ValueError( - "An invalidated decision rerun requires an active predecessor" - ) - previous = active_records[-1] - expected_checkpoint, terminal = RNA_DECISION_TRANSITION_GRAPH.resolve( - cast(RnaDecisionCheckpoint, previous.decisionId), - previous.status, - ) - if terminal is not None or expected_checkpoint != checkpoint: - raise ValueError( - "Invalidated decision rerun does not follow transition order" - ) - - _next_checkpoint, terminal_status = RNA_DECISION_TRANSITION_GRAPH.resolve( - checkpoint, - record.status, - ) - status = ( - "needsInput" - if terminal_status == "needsInput" - else "abstained" - if terminal_status == "abstained" - else "running" - ) - values = workflow.model_dump(mode="json") - values["status"] = status - values["pendingDecision"] = None - values["decisionRecords"] = [*workflow.decisionRecords, record] - values["verificationRecords"] = [ - *workflow.verificationRecords, - compiled.verification, - ] - if revision is not None: - values["revisionRequests"] = [*workflow.revisionRequests, revision] - return DecisionWorkflowRun.model_validate(values) - - -def complete_decision_workflow( - workflow: DecisionWorkflowRun, - final_handoff_id: str, -) -> DecisionWorkflowRun: - """Finalize a fully adjudicated RNA ledger with its exact handoff ID.""" - if workflow.status != "running" or not workflow.decisionRecords: - raise ValueError("Only a running adjudicated workflow can complete") - active_records = { - record.decisionId: record for record in workflow.active_decision_records() - } - expected: str = "qcGrouping" - visited: set[str] = set() - while expected != "finalize": - record = active_records.get(expected) - if record is None: - raise ValueError(f"RNA decision path is missing {expected!r}") - visited.add(expected) - try: - destination, terminal = RNA_DECISION_TRANSITION_GRAPH.resolve( - cast(RnaDecisionCheckpoint, expected), - record.status, - ) - except KeyError as exc: - raise ValueError( - f"Active decision {expected!r} has no registered transition" - ) from exc - if terminal is not None or destination is None: - raise ValueError("RNA decisions have not reached the finalize transition") - expected = destination - if visited != set(active_records): - raise ValueError( - "Decision workflow contains active records outside its RNA path" - ) - values = workflow.model_dump(mode="json") - values["status"] = "completed" - values["finalHandoffId"] = final_handoff_id - return DecisionWorkflowRun.model_validate(values) - - -__all__ = [ - "DecisionPersistenceFormatError", - "DecisionWorkflowSnapshot", - "OrchestrationRunIdentity", - "attach_audited_rna_decision", - "complete_decision_workflow", - "decision_record_checksum", - "list_decision_workflow_snapshots", - "load_decision_workflow_for_replay", - "load_decision_workflow_snapshot", - "load_latest_decision_workflow_snapshot", - "pause_decision_workflow", - "save_decision_workflow_snapshot", -] diff --git a/scarf/agent/persistence/reports.py b/scarf/agent/persistence/reports.py deleted file mode 100644 index d30196a7..00000000 --- a/scarf/agent/persistence/reports.py +++ /dev/null @@ -1,1188 +0,0 @@ -"""Immutable JSON storage for structured Scarf agent reports and workflows.""" - -import hashlib -import json -import time -import uuid -from collections.abc import Mapping -from pathlib import Path -from typing import cast - -import zarr -from zarr.core.buffer import default_buffer_prototype -from zarr.core.sync import sync - -from ...datastore.datastore import DataStore -from ...storage.artifacts import inspect_artifact -from ...storage.refs import ArtifactRef -from ...storage.schema import validate_workspace_name -from ...utils.logging import logger -from .. import record_io -from ..biological_interpretation.contracts import BiologicalInterpretationReport -from ..data_enrichment.contracts import DataEnrichmentReport -from ..experimental_context.contracts import ExperimentalContextResult -from ..parameter_tuning.contracts import ParameterTuningReport -from ..types import ( - AgentDataModel, - ArtifactReferenceModel, - ExperimentalBiologyHandoff, - ExperimentalTuningHandoff, -) -from .contracts import ( - _FORMAT, - AgentInvocation, - AgentName, - AgentPersistenceTarget, - AgentReportLink, - AgentReportRecord, - AgentReportReference, - AgentReportType, - AgentStoreManifest, - AgentTerminalStatus, - AgentWorkflowFinalization, - AgentWorkflowRun, - _validate_run_id, -) - -type AgentReport = ( - DataEnrichmentReport - | ExperimentalContextResult - | ParameterTuningReport - | BiologicalInterpretationReport -) - -_REPORT_TYPES: dict[AgentName, type[AgentDataModel]] = { - "data_enrichment": DataEnrichmentReport, - "experimental_context": ExperimentalContextResult, - "parameter_tuning": ParameterTuningReport, - "biological_interpretation": BiologicalInterpretationReport, -} -_AGENT_NAMES: dict[type[AgentDataModel], AgentName] = { - report_type: agent_name for agent_name, report_type in _REPORT_TYPES.items() -} - - -def _key_exists(group: zarr.Group, key: str) -> bool: - return record_io.read_key(group, key) is not None - - -def _list_keys(group: zarr.Group, prefix: str) -> list[str]: - if not group.store.supports_listing: - raise NotImplementedError("Agent persistence requires a listable Zarr store") - return record_io.list_keys(group, prefix) - - -def _write_key_once(group: zarr.Group, key: str, payload: bytes) -> None: - store = group.store - if bool(getattr(store, "read_only", False)) or not bool( - getattr(store, "supports_writes", True) - ): - raise PermissionError("Agent persistence target is read-only") - if _key_exists(group, key): - raise FileExistsError(f"Immutable agent record {key!r} already exists") - buffer = default_buffer_prototype().buffer.from_bytes(payload) - sync(store.set_if_not_exists(key, buffer)) - stored = record_io.read_key(group, key) - if stored is None: - raise RuntimeError(f"Agent record {key!r} was not stored") - if stored != payload: - raise FileExistsError( - f"Immutable agent record {key!r} was written by another writer" - ) - - -def _read_json_model( - group: zarr.Group, - key: str, - model_type: type[AgentDataModel], -) -> AgentDataModel: - payload = record_io.read_key(group, key) - if payload is None: - raise FileNotFoundError(key) - try: - decoded = json.loads(payload) - except (UnicodeDecodeError, json.JSONDecodeError) as exc: - raise ValueError(f"Agent JSON record {key!r} is malformed") from exc - try: - return model_type.model_validate(decoded) - except ValueError as exc: - raise ValueError( - f"Agent JSON record {key!r} does not match its Pydantic schema" - ) from exc - - -def _validate_scarf_data_group(group: zarr.Group) -> None: - import_source = group.attrs.get("scarf:import_source") - if import_source is not None and not bool( - group.attrs.get("scarf:import_complete", False) - ): - raise RuntimeError(f"{import_source} import is incomplete") - if group.attrs.get("format") == _FORMAT: - raise ValueError( - "Standalone agent-report sidecars require an explicit migration" - ) - if "cellData" not in group or not isinstance(group["cellData"], zarr.Group): - raise ValueError("Agent persistence requires an existing Scarf data group") - assay_names = [] - for name in sorted(dict.fromkeys(group.group_keys())): - child = group[name] - if isinstance(child, zarr.Group) and "is_assay" in child.attrs: - assay_names.append(name) - if not assay_names: - raise ValueError("Agent persistence requires at least one Scarf assay") - - -def _resolve_target( - target: AgentPersistenceTarget, - *, - write: bool, - workspace: str | None, -) -> tuple[zarr.Group, DataStore | None, str | None, str]: - validate_workspace_name(workspace) - datastore: DataStore | None = None - analysis_store = "" - if isinstance(target, DataStore): - datastore = target - if workspace is not None and workspace != target.workspace: - raise ValueError("workspace does not match the DataStore workspace") - if write and target.zarr_mode != "r+": - raise PermissionError("Agent persistence requires a writable DataStore") - group = target.zw - resolved_workspace = target.workspace - analysis_store = str(target.zarr_loc) - elif isinstance(target, zarr.Group): - target_path = str(getattr(target, "path", "")).strip("/") - if workspace is None: - group = target - resolved_workspace = target_path or None - if resolved_workspace is not None: - validate_workspace_name(resolved_workspace) - elif target_path == workspace: - group = target - resolved_workspace = workspace - elif not target_path and workspace in target: - child = target[workspace] - if not isinstance(child, zarr.Group): - raise TypeError(f"Workspace {workspace!r} is not a Zarr group") - group = child - resolved_workspace = workspace - else: - raise ValueError("workspace does not match the supplied Zarr group") - else: - location = str(target) - if isinstance(target, Path) and not target.exists(): - raise FileNotFoundError(target) - root = zarr.open_group(location, mode="r+" if write else "r") - analysis_store = location - if workspace is None: - group = root - resolved_workspace = None - else: - if workspace not in root: - raise KeyError(f"Unknown Scarf workspace {workspace!r}") - child = root[workspace] - if not isinstance(child, zarr.Group): - raise TypeError(f"Workspace {workspace!r} is not a Zarr group") - group = child - resolved_workspace = workspace - _validate_scarf_data_group(group) - return group, datastore, resolved_workspace, analysis_store - - -def _live_dataset_fingerprints( - group: zarr.Group, - datastore: DataStore | None, - *, - ensure: bool, -) -> dict[str, str]: - if datastore is not None: - assay_names = list(datastore.assay_names) - fingerprints = { - assay_name: ( - datastore._ensure_dataset_fingerprint(assay_name) - if ensure - else str( - datastore._get_assay(assay_name).attrs.get("dataset_fingerprint") - or "" - ) - ) - for assay_name in assay_names - } - else: - assay_names = [] - fingerprints = {} - for name in sorted(dict.fromkeys(group.group_keys())): - child = group[name] - if isinstance(child, zarr.Group) and "is_assay" in child.attrs: - assay_names.append(name) - fingerprints[name] = str(child.attrs.get("dataset_fingerprint") or "") - if not assay_names: - raise ValueError("Dataset binding requires at least one assay") - missing = [name for name in assay_names if not fingerprints[name]] - if missing: - raise ValueError( - "Dataset fingerprints are missing for assays " + repr(sorted(missing)) - ) - return dict(sorted(fingerprints.items())) - - -def _validate_dataset_binding( - stored: dict[str, str], - observed: dict[str, str], -) -> None: - if stored != observed: - raise ValueError( - "Agent workflow dataset fingerprints do not match the current store: " - f"stored={stored!r}, observed={observed!r}" - ) - - -def _agents_prefix(group: zarr.Group) -> str: - return record_io.join_key(str(getattr(group, "path", "")), "agents") - - -def _manifest_key(prefix: str) -> str: - return record_io.join_key(prefix, "store.json") - - -def _workflow_prefix(prefix: str, workflow_run_id: str) -> str: - return record_io.join_key(prefix, "runs", workflow_run_id) - - -def _workflow_key(prefix: str, workflow_run_id: str) -> str: - return record_io.join_key( - _workflow_prefix(prefix, workflow_run_id), - "workflow.json", - ) - - -def _finalization_key(prefix: str, workflow_run_id: str) -> str: - return record_io.join_key( - _workflow_prefix(prefix, workflow_run_id), - "finalization.json", - ) - - -def _report_key( - prefix: str, - workflow_run_id: str, - agent_name: AgentName, - agent_run_id: str, -) -> str: - return record_io.join_key( - _workflow_prefix(prefix, workflow_run_id), - agent_name, - agent_run_id, - "report.json", - ) - - -def _open_agent_store( - group: zarr.Group, - *, - workspace: str | None, - initialize: bool, -) -> str: - prefix = _agents_prefix(group) - if "agents" in group: - node = group["agents"] - if ( - isinstance(node, zarr.Group) - and node.attrs.get("format") == _FORMAT - and node.attrs.get("format_version") == 1 - ): - raise ValueError( - "Zarr-backed agent report format version 1 requires an explicit " - "migration" - ) - if not isinstance(node, zarr.Group): - raise ValueError("The agents namespace must be a Zarr group") - if node.attrs.get("format") != _FORMAT or node.attrs.get("format_version") != 2: - raise ValueError( - "The agents namespace collides with an unrecognized Zarr group" - ) - else: - existing_keys = _list_keys(group, prefix) - if existing_keys: - raise ValueError( - "A plain-JSON agents hierarchy without Zarr group metadata " - "requires an explicit migration" - ) - if not initialize: - raise FileNotFoundError(_manifest_key(prefix)) - if bool(getattr(group.store, "read_only", False)) or not bool( - getattr(group.store, "supports_writes", True) - ): - raise PermissionError("Agent persistence target is read-only") - group.create_group( - "agents", - attributes={"format": _FORMAT, "format_version": 2}, - ) - - manifest_key = _manifest_key(prefix) - payload = record_io.read_key(group, manifest_key) - if payload is None: - if not initialize: - raise FileNotFoundError(manifest_key) - metadata_keys = { - record_io.join_key(prefix, "zarr.json"), - record_io.join_key(prefix, ".zgroup"), - record_io.join_key(prefix, ".zattrs"), - } - if any(key not in metadata_keys for key in _list_keys(group, prefix)): - raise ValueError("Refusing to initialize a non-empty agents hierarchy") - manifest = AgentStoreManifest(workspace=workspace) - _write_key_once( - group, - manifest_key, - record_io.display_json_bytes(manifest.model_dump(mode="json")), - ) - manifest = cast( - AgentStoreManifest, - _read_json_model(group, manifest_key, AgentStoreManifest), - ) - if manifest.workspace != workspace: - raise ValueError("Agent store workspace does not match the active data group") - return prefix - - -def _load_workflow_record( - group: zarr.Group, - prefix: str, - workflow_run_id: str, - workspace: str | None, -) -> AgentWorkflowRun: - workflow_run_id = _validate_run_id(workflow_run_id, "workflow_run_id") - key = _workflow_key(prefix, workflow_run_id) - try: - workflow = cast( - AgentWorkflowRun, - _read_json_model(group, key, AgentWorkflowRun), - ) - except FileNotFoundError as exc: - raise KeyError(f"Unknown agent workflow {workflow_run_id!r}") from exc - if workflow.workflowRunId != workflow_run_id: - raise ValueError("Agent workflow identity does not match its path") - if workflow.workspace != workspace: - raise ValueError("Agent workflow workspace does not match its path") - if ( - workflow.status != "running" - or workflow.finalizedAtNs != 0 - or workflow.finalizationMessage - or workflow.reports - ): - raise ValueError( - "Immutable workflow.json must contain the running identity only" - ) - finalization_payload = record_io.read_key( - group, - _finalization_key(prefix, workflow_run_id), - ) - if finalization_payload is None: - return workflow - finalization = cast( - AgentWorkflowFinalization, - _read_json_model( - group, - _finalization_key(prefix, workflow_run_id), - AgentWorkflowFinalization, - ), - ) - if finalization.workflowRunId != workflow_run_id: - raise ValueError("Agent workflow finalization identity does not match its path") - if finalization.workspace != workspace: - raise ValueError( - "Agent workflow finalization workspace does not match its path" - ) - return AgentWorkflowRun.model_validate( - { - **workflow.model_dump(mode="json"), - "status": finalization.status, - "finalizedAtNs": finalization.finalizedAtNs, - "finalizationMessage": finalization.message, - } - ) - - -def _report_checksum(record: AgentReportRecord) -> str: - reference = record.reference.model_dump( - mode="json", - exclude={"contentSha256"}, - ) - content = { - "recordType": record.recordType, - "formatVersion": record.formatVersion, - "reference": reference, - "invocation": record.invocation.model_dump(mode="json"), - "report": record.report, - } - return hashlib.sha256(record_io.canonical_json_bytes(content)).hexdigest() - - -def _load_report_record_at( - group: zarr.Group, - key: str, - *, - workflow_run_id: str, - agent_name: AgentName, - agent_run_id: str, - workspace: str | None, -) -> AgentReportRecord: - record = cast( - AgentReportRecord, - _read_json_model(group, key, AgentReportRecord), - ) - reference = record.reference - expected_type = cast(AgentReportType, _REPORT_TYPES[agent_name].__name__) - if ( - reference.workflowRunId != workflow_run_id - or reference.agentName != agent_name - or reference.agentRunId != agent_run_id - ): - raise ValueError("Agent report identity does not match its path") - if reference.workspace != workspace: - raise ValueError("Agent report workspace does not match its path") - if reference.reportType != expected_type: - raise ValueError( - f"Agent report type must be {expected_type!r} for {agent_name!r}" - ) - if not reference.complete: - raise ValueError("Atomic agent report records must be complete") - if _report_checksum(record) != reference.contentSha256: - raise ValueError( - "Agent report content checksum does not match its JSON payload" - ) - report_type = _REPORT_TYPES[agent_name] - try: - report = cast(AgentReport, report_type.model_validate(record.report)) - except ValueError as exc: - raise ValueError( - "Agent report payload does not match its Pydantic schema" - ) from exc - if report.runInfo.runId != reference.executionRunId: - raise ValueError("Agent report execution ID does not match its JSON payload") - return record - - -def _report_references( - group: zarr.Group, - prefix: str, - workflow_run_id: str, - workspace: str | None, -) -> list[AgentReportReference]: - run_prefix = _workflow_prefix(prefix, workflow_run_id) - references: list[AgentReportReference] = [] - for key in _list_keys(group, run_prefix): - relative = key.removeprefix(f"{run_prefix}/") - if relative in {"workflow.json", "finalization.json"}: - continue - if relative == "report" or relative.startswith("report/"): - continue - parts = relative.split("/") - if len(parts) != 3 or parts[2] != "report.json": - raise ValueError(f"Unexpected agent workflow record {key!r}") - raw_agent_name, agent_run_id, _filename = parts - if raw_agent_name not in _REPORT_TYPES: - raise ValueError(f"Unknown agent name {raw_agent_name!r}") - agent_name = raw_agent_name - _validate_run_id(agent_run_id, "agent_run_id") - record = _load_report_record_at( - group, - key, - workflow_run_id=workflow_run_id, - agent_name=agent_name, - agent_run_id=agent_run_id, - workspace=workspace, - ) - _validate_invocation( - group, - prefix, - workflow_run_id, - workspace, - agent_name, - record.invocation, - ) - references.append(record.reference) - return sorted( - references, - key=lambda item: (item.createdAtNs, item.agentName, item.agentRunId), - ) - - -def _load_parent_records( - group: zarr.Group, - prefix: str, - workflow_run_id: str, - workspace: str | None, - invocation: AgentInvocation, -) -> dict[AgentName, list[AgentReportRecord]]: - parents: dict[AgentName, list[AgentReportRecord]] = { - name: [] for name in _REPORT_TYPES - } - for link in invocation.parentReports: - if link.workflowRunId != workflow_run_id: - raise ValueError("Parent reports must belong to the same workflow") - if link.workspace != workspace: - raise ValueError("Parent reports must belong to the same workspace") - key = _report_key( - prefix, - workflow_run_id, - link.agentName, - link.agentRunId, - ) - try: - record = _load_report_record_at( - group, - key, - workflow_run_id=workflow_run_id, - agent_name=link.agentName, - agent_run_id=link.agentRunId, - workspace=workspace, - ) - except FileNotFoundError as exc: - raise ValueError( - f"Unknown parent agent report {link.agentRunId!r}" - ) from exc - if AgentReportLink.from_reference(record.reference) != link: - raise ValueError("Parent report link does not match the stored report") - parents[link.agentName].append(record) - return parents - - -def _has_handoff_parent( - *, - label: str, - supplied: AgentDataModel | None, - parent_records: list[AgentReportRecord], -) -> bool: - if supplied is None and parent_records: - raise ValueError(f"{label} is required when its parent report is cited") - if supplied is not None and len(parent_records) != 1: - raise ValueError(f"{label} requires exactly one matching parent report") - return supplied is not None - - -def _selection_descends_from( - group: zarr.Group, - supplied: ArtifactReferenceModel | None, - expected: ArtifactReferenceModel | None, -) -> bool: - if supplied is None or expected is None: - return supplied == expected - current = ArtifactRef( - scope=supplied.scope, - assay=supplied.assay, - kind=supplied.kind, - artifact_id=supplied.artifactId, - ) - ancestor = ArtifactRef( - scope=expected.scope, - assay=expected.assay, - kind=expected.kind, - artifact_id=expected.artifactId, - ) - if current.kind != "cell_selection" or ancestor.kind != "cell_selection": - return False - visited: set[ArtifactRef] = set() - while current not in visited: - if current == ancestor: - return True - visited.add(current) - status = inspect_artifact(group, current) - if not status.exists or not status.complete: - return False - raw_parent = (status.inputs or {}).get("prior_cell_selection") - if not isinstance(raw_parent, Mapping): - return False - try: - current = ArtifactRef.from_dict(dict(raw_parent)) - except (KeyError, TypeError, ValueError): - return False - return False - - -def _validate_projected_experimental_handoff( - group: zarr.Group, - invocation: AgentInvocation, - *, - label: str, - supplied: ExperimentalTuningHandoff | ExperimentalBiologyHandoff, - expected: ExperimentalTuningHandoff | ExperimentalBiologyHandoff, -) -> None: - supplied_selection = supplied.cellSelection - if invocation.artifacts.get("cellSelection") != supplied_selection: - raise ValueError(f"{label} cellSelection must be an invocation artifact") - if not _selection_descends_from( - group, - supplied_selection, - expected.cellSelection, - ): - raise ValueError( - f"{label} cellSelection does not descend from the cited parent report" - ) - if supplied != expected.model_copy(update={"cellSelection": supplied_selection}): - raise ValueError(f"{label} does not match the cited parent report") - - -def _validate_invocation( - group: zarr.Group, - prefix: str, - workflow_run_id: str, - workspace: str | None, - agent_name: AgentName, - invocation: AgentInvocation, -) -> None: - if invocation.agentName != agent_name: - raise ValueError("Invocation agentName does not match the report type") - if not invocation.inputs: - raise ValueError("Invocation inputs must record the agent call arguments") - parents = _load_parent_records( - group, - prefix, - workflow_run_id, - workspace, - invocation, - ) - if agent_name == "parameter_tuning": - experimental_parents = parents["experimental_context"] - if _has_handoff_parent( - label="experimentalTuningHandoff", - supplied=invocation.experimentalTuningHandoff, - parent_records=experimental_parents, - ): - expected = ExperimentalContextResult.model_validate( - experimental_parents[0].report - ).to_parameter_tuning_handoff() - assert invocation.experimentalTuningHandoff is not None - _validate_projected_experimental_handoff( - group, - invocation, - label="experimentalTuningHandoff", - supplied=invocation.experimentalTuningHandoff, - expected=expected, - ) - elif invocation.experimentalTuningHandoff is not None: - raise ValueError("experimentalTuningHandoff is only valid for parameter_tuning") - - if agent_name == "biological_interpretation": - experimental_parents = parents["experimental_context"] - if len(experimental_parents) > 1: - raise ValueError( - "Biological Interpretation accepts at most one Experimental " - "Context parent report" - ) - if invocation.experimentalBiologyHandoff is not None: - if len(experimental_parents) != 1: - raise ValueError( - "experimentalBiologyHandoff requires exactly one matching " - "parent report" - ) - assert invocation.experimentalBiologyHandoff is not None - expected_experimental = ExperimentalContextResult.model_validate( - experimental_parents[0].report - ).to_biological_handoff( - invocation.experimentalBiologyHandoff.conditionColumn - ) - _validate_projected_experimental_handoff( - group, - invocation, - label="experimentalBiologyHandoff", - supplied=invocation.experimentalBiologyHandoff, - expected=expected_experimental, - ) - tuning_parents = parents["parameter_tuning"] - if _has_handoff_parent( - label="tuningBiologyHandoff", - supplied=invocation.tuningBiologyHandoff, - parent_records=tuning_parents, - ): - expected_tuning = ParameterTuningReport.model_validate( - tuning_parents[0].report - ).to_biological_handoff() - if invocation.tuningBiologyHandoff != expected_tuning: - raise ValueError( - "tuningBiologyHandoff does not match the cited parent report" - ) - elif ( - invocation.experimentalBiologyHandoff is not None - or invocation.tuningBiologyHandoff is not None - ): - raise ValueError( - "Biology handoffs are only valid for biological_interpretation" - ) - - -def _resolved_workflow( - group: zarr.Group, - datastore: DataStore | None, - prefix: str, - workflow_run_id: str, - workspace: str | None, - *, - ensure_fingerprints: bool, -) -> AgentWorkflowRun: - workflow = _load_workflow_record( - group, - prefix, - workflow_run_id, - workspace, - ) - observed = _live_dataset_fingerprints( - group, - datastore, - ensure=ensure_fingerprints, - ) - _validate_dataset_binding(workflow.datasetFingerprints, observed) - return workflow - - -def create_agent_workflow( - target: AgentPersistenceTarget, - *, - workflow_run_id: str | None = None, - analysis_store: str = "", - dataset_fingerprints: dict[str, str] | None = None, - workspace: str | None = None, -) -> AgentWorkflowRun: - """Create an immutable, dataset-bound workflow in the active data group.""" - if not isinstance(analysis_store, str): - raise TypeError("analysis_store must be a string") - group, datastore, resolved_workspace, inferred_store = _resolve_target( - target, - write=True, - workspace=workspace, - ) - observed = _live_dataset_fingerprints(group, datastore, ensure=True) - if dataset_fingerprints is not None: - if not isinstance(dataset_fingerprints, dict) or any( - not isinstance(assay, str) or not isinstance(fingerprint, str) - for assay, fingerprint in dataset_fingerprints.items() - ): - raise TypeError("dataset_fingerprints must map assay names to strings") - supplied = dict(sorted(dataset_fingerprints.items())) - _validate_dataset_binding(supplied, observed) - resolved_run_id = _validate_run_id( - uuid.uuid4().hex if workflow_run_id is None else workflow_run_id, - "workflow_run_id", - ) - prefix = _open_agent_store( - group, - workspace=resolved_workspace, - initialize=True, - ) - key = _workflow_key(prefix, resolved_run_id) - if _list_keys(group, _workflow_prefix(prefix, resolved_run_id)): - raise FileExistsError(f"Agent workflow {resolved_run_id!r} already exists") - workflow = AgentWorkflowRun( - workflowRunId=resolved_run_id, - workspace=resolved_workspace, - createdAtNs=time.time_ns(), - analysisStore=analysis_store or inferred_store, - datasetFingerprints=observed, - ) - _write_key_once( - group, - key, - record_io.display_json_bytes(workflow.model_dump(mode="json")), - ) - logger.info( - f"Created agent workflow {resolved_run_id}: workspace=" - f"{resolved_workspace or 'root'}, assays={len(observed)}" - ) - return workflow - - -def save_agent_report( - target: AgentPersistenceTarget, - workflow_run_id: str, - report: AgentReport, - *, - invocation: AgentInvocation, - agent_run_id: str | None = None, - workspace: str | None = None, -) -> AgentReportReference: - """Persist one immutable report together with its replay-relevant inputs.""" - agent_name = _AGENT_NAMES.get(type(report)) - if agent_name is None: - raise TypeError("report must be one of the four Scarf agent report models") - if not isinstance(invocation, AgentInvocation): - raise TypeError("invocation must be an AgentInvocation") - resolved_workflow_id = _validate_run_id(workflow_run_id, "workflow_run_id") - resolved_agent_run_id = _validate_run_id( - uuid.uuid4().hex if agent_run_id is None else agent_run_id, - "agent_run_id", - ) - group, datastore, resolved_workspace, _analysis_store = _resolve_target( - target, - write=True, - workspace=workspace, - ) - prefix = _open_agent_store( - group, - workspace=resolved_workspace, - initialize=False, - ) - workflow = _resolved_workflow( - group, - datastore, - prefix, - resolved_workflow_id, - resolved_workspace, - ensure_fingerprints=True, - ) - if workflow.status != "running": - raise RuntimeError( - f"Cannot save a report to a {workflow.status!r} agent workflow" - ) - _validate_invocation( - group, - prefix, - resolved_workflow_id, - resolved_workspace, - agent_name, - invocation, - ) - key = _report_key( - prefix, - resolved_workflow_id, - agent_name, - resolved_agent_run_id, - ) - if _key_exists(group, key): - raise FileExistsError( - f"Agent report {resolved_agent_run_id!r} already exists for {agent_name!r}" - ) - reference = AgentReportReference( - workflowRunId=resolved_workflow_id, - workspace=resolved_workspace, - agentName=agent_name, - agentRunId=resolved_agent_run_id, - reportType=cast(AgentReportType, type(report).__name__), - executionRunId=report.runInfo.runId, - createdAtNs=time.time_ns(), - complete=True, - parentReports=list(invocation.parentReports), - contentSha256="0" * 64, - ) - record = AgentReportRecord( - reference=reference, - invocation=invocation, - report=report.model_dump(mode="json"), - ) - checksum = _report_checksum(record) - reference = reference.model_copy(update={"contentSha256": checksum}) - record = record.model_copy(update={"reference": reference}) - _write_key_once( - group, - key, - record_io.display_json_bytes(record.model_dump(mode="json")), - ) - stored = _load_report_record_at( - group, - key, - workflow_run_id=resolved_workflow_id, - agent_name=agent_name, - agent_run_id=resolved_agent_run_id, - workspace=resolved_workspace, - ) - logger.info( - f"Saved {agent_name} report {resolved_agent_run_id} for workflow " - f"{resolved_workflow_id}: status={getattr(report, 'status', 'done')}, " - f"parents={len(invocation.parentReports)}" - ) - return stored.reference - - -def _validate_supplied_reference( - supplied: AgentReportReference, - stored: AgentReportReference, -) -> None: - if supplied.workflowRunId != stored.workflowRunId: - raise ValueError( - "Agent report reference workflow does not match stored metadata" - ) - if supplied.workspace != stored.workspace: - raise ValueError( - "Agent report reference workspace does not match stored metadata" - ) - if ( - supplied.agentName != stored.agentName - or supplied.agentRunId != stored.agentRunId - ): - raise ValueError( - "Agent report reference identity does not match stored metadata" - ) - if supplied.reportType and supplied.reportType != stored.reportType: - raise ValueError("Agent report reference type does not match stored metadata") - if supplied.executionRunId and supplied.executionRunId != stored.executionRunId: - raise ValueError("Agent report reference execution ID does not match metadata") - if supplied.createdAtNs and supplied.createdAtNs != stored.createdAtNs: - raise ValueError( - "Agent report reference timestamp does not match stored metadata" - ) - if supplied.parentReports and supplied.parentReports != stored.parentReports: - raise ValueError("Agent report reference parents do not match stored metadata") - if supplied.contentSha256 and supplied.contentSha256 != stored.contentSha256: - raise ValueError( - "Agent report reference checksum does not match stored metadata" - ) - - -def load_agent_record( - target: AgentPersistenceTarget, - reference: AgentReportReference, - *, - workspace: str | None = None, -) -> AgentReportRecord: - """Load and validate a report envelope, including lineage and inputs.""" - group, datastore, resolved_workspace, _analysis_store = _resolve_target( - target, - write=False, - workspace=workspace, - ) - prefix = _open_agent_store( - group, - workspace=resolved_workspace, - initialize=False, - ) - _resolved_workflow( - group, - datastore, - prefix, - reference.workflowRunId, - resolved_workspace, - ensure_fingerprints=False, - ) - key = _report_key( - prefix, - reference.workflowRunId, - reference.agentName, - reference.agentRunId, - ) - try: - record = _load_report_record_at( - group, - key, - workflow_run_id=reference.workflowRunId, - agent_name=reference.agentName, - agent_run_id=reference.agentRunId, - workspace=resolved_workspace, - ) - except FileNotFoundError as exc: - raise KeyError(f"Unknown agent report at {key!r}") from exc - _validate_invocation( - group, - prefix, - reference.workflowRunId, - resolved_workspace, - reference.agentName, - record.invocation, - ) - _validate_supplied_reference(reference, record.reference) - return record - - -def load_agent_report( - target: AgentPersistenceTarget, - reference: AgentReportReference, - *, - workspace: str | None = None, -) -> AgentReport: - """Load and strictly revalidate one persisted agent report.""" - record = load_agent_record(target, reference, workspace=workspace) - report_type = _REPORT_TYPES[record.reference.agentName] - return cast(AgentReport, report_type.model_validate(record.report)) - - -def list_agent_reports( - target: AgentPersistenceTarget, - workflow_run_id: str, - *, - agent_name: AgentName | None = None, - include_incomplete: bool = False, - workspace: str | None = None, -) -> list[AgentReportReference]: - """List atomic report references for one dataset-bound workflow.""" - del include_incomplete - if agent_name is not None and agent_name not in _REPORT_TYPES: - raise ValueError(f"Unknown agent name {agent_name!r}") - group, datastore, resolved_workspace, _analysis_store = _resolve_target( - target, - write=False, - workspace=workspace, - ) - prefix = _open_agent_store( - group, - workspace=resolved_workspace, - initialize=False, - ) - _resolved_workflow( - group, - datastore, - prefix, - workflow_run_id, - resolved_workspace, - ensure_fingerprints=False, - ) - references = _report_references( - group, - prefix, - workflow_run_id, - resolved_workspace, - ) - if agent_name is not None: - references = [item for item in references if item.agentName == agent_name] - return references - - -def load_agent_workflow( - target: AgentPersistenceTarget, - workflow_run_id: str, - *, - include_incomplete: bool = False, - workspace: str | None = None, -) -> AgentWorkflowRun: - """Load one workflow, its lifecycle state, and all report references.""" - del include_incomplete - group, datastore, resolved_workspace, _analysis_store = _resolve_target( - target, - write=False, - workspace=workspace, - ) - prefix = _open_agent_store( - group, - workspace=resolved_workspace, - initialize=False, - ) - workflow = _resolved_workflow( - group, - datastore, - prefix, - workflow_run_id, - resolved_workspace, - ensure_fingerprints=False, - ) - reports = _report_references( - group, - prefix, - workflow_run_id, - resolved_workspace, - ) - if workflow.status == "completed" and not reports: - raise ValueError("A completed workflow must contain at least one report") - return AgentWorkflowRun.model_validate( - { - **workflow.model_dump(mode="json"), - "reports": reports, - } - ) - - -def list_agent_workflows( - target: AgentPersistenceTarget, - *, - include_incomplete: bool = False, - workspace: str | None = None, -) -> list[AgentWorkflowRun]: - """List terminal workflows, optionally including workflows still running.""" - group, datastore, resolved_workspace, _analysis_store = _resolve_target( - target, - write=False, - workspace=workspace, - ) - prefix = _open_agent_store( - group, - workspace=resolved_workspace, - initialize=False, - ) - runs_prefix = record_io.join_key(prefix, "runs") - workflow_ids: set[str] = set() - for key in _list_keys(group, runs_prefix): - relative = key.removeprefix(f"{runs_prefix}/") - parts = relative.split("/") - if len(parts) < 2: - raise ValueError(f"Unexpected agent workflow key {key!r}") - workflow_ids.add(_validate_run_id(parts[0], "workflow_run_id")) - workflows = [ - load_agent_workflow( - target, - workflow_run_id, - workspace=workspace, - ) - for workflow_run_id in sorted(workflow_ids) - ] - if not include_incomplete: - workflows = [item for item in workflows if item.status != "running"] - return sorted( - workflows, - key=lambda item: (item.createdAtNs, item.workflowRunId), - ) - - -def finalize_agent_workflow( - target: AgentPersistenceTarget, - workflow_run_id: str, - *, - status: AgentTerminalStatus, - message: str = "", - workspace: str | None = None, -) -> AgentWorkflowRun: - """Write the one terminal event allowed for a running workflow.""" - if status not in {"completed", "abstained", "failed", "abandoned"}: - raise ValueError("status must be completed, abstained, failed, or abandoned") - if not isinstance(message, str): - raise TypeError("message must be a string") - group, datastore, resolved_workspace, _analysis_store = _resolve_target( - target, - write=True, - workspace=workspace, - ) - prefix = _open_agent_store( - group, - workspace=resolved_workspace, - initialize=False, - ) - workflow = _resolved_workflow( - group, - datastore, - prefix, - workflow_run_id, - resolved_workspace, - ensure_fingerprints=True, - ) - if workflow.status != "running": - raise FileExistsError( - f"Agent workflow {workflow_run_id!r} is already {workflow.status!r}" - ) - reports = _report_references( - group, - prefix, - workflow_run_id, - resolved_workspace, - ) - if status in {"completed", "abstained"} and not reports: - raise ValueError( - "A completed or abstained workflow must contain at least one report" - ) - finalization = AgentWorkflowFinalization( - workflowRunId=workflow_run_id, - workspace=resolved_workspace, - status=status, - finalizedAtNs=time.time_ns(), - message=message, - ) - _write_key_once( - group, - _finalization_key(prefix, workflow_run_id), - record_io.display_json_bytes(finalization.model_dump(mode="json")), - ) - finalized = load_agent_workflow( - target, - workflow_run_id, - workspace=workspace, - ) - logger.info( - f"Finalized agent workflow {workflow_run_id}: status={status}, " - f"reports={len(finalized.reports)}" - ) - return finalized diff --git a/scarf/agent/report/artifacts.py b/scarf/agent/report/artifacts.py index 4887b90b..9364fb11 100644 --- a/scarf/agent/report/artifacts.py +++ b/scarf/agent/report/artifacts.py @@ -1,30 +1,22 @@ -"""Persisted artifact and workflow-stage collection for agent reports.""" +"""Read-only adapters from the authoritative stage journal to a local report.""" -import re -from collections.abc import Mapping, Sequence +from collections.abc import Mapping from pathlib import Path -from typing import Any, cast +from typing import TYPE_CHECKING, Any -from ...datastore.datastore import DataStore +from ...storage.refs import ArtifactRef from ...storage.stores import zarr_root_path -from .. import record_io -from ..orchestrator import journal -from ..orchestrator.models import ( - _STAGE_ORDER, - AutomatedWorkflowResult, - OrchestrationRequestRecord, - WorkflowStageAttempt, - artifact_model_to_ref, -) -from ..persistence.contracts import AgentWorkflowRun -from ..persistence.decisions import load_latest_decision_workflow_snapshot -from ..persistence.reports import load_agent_report from ..types import ArtifactReferenceModel -from .contracts import _is_sequence, _mapping, _mappings, _text_values +from .contracts import mapping, mappings, texts +if TYPE_CHECKING: + from ...datastore.datastore import DataStore + + +def _local_root(target: "str | Path | DataStore") -> Path: + """Resolve a local store without accepting remote locations.""" + from ...datastore.datastore import DataStore -def _local_root(target: str | Path | DataStore) -> Path: - """Resolve a local filesystem root without accepting remote stores.""" if isinstance(target, DataStore): location = zarr_root_path(target.z) if location is None: @@ -32,516 +24,101 @@ def _local_root(target: str | Path | DataStore) -> Path: path = Path(location) elif isinstance(target, Path): path = target - elif isinstance(target, str) and target.startswith("file://"): - path = Path(target.removeprefix("file://")) elif isinstance(target, str): - if "://" in target: + if "://" in target and not target.startswith("file://"): raise ValueError("Agent HTML reports require a local filesystem store") - path = Path(target) + path = Path(target.removeprefix("file://")) else: raise TypeError("Agent HTML reports require a local filesystem store") - path = path.expanduser().resolve() - if not path.is_dir(): - raise FileNotFoundError(path) - return path - - -def _open_datastore( - target: str | Path | DataStore, - root: Path, - workflow: AgentWorkflowRun, -) -> DataStore: - if isinstance(target, DataStore): - if target.workspace != workflow.workspace: - raise ValueError("Workflow workspace does not match the DataStore") - return target - default_assay = next(iter(workflow.datasetFingerprints)) - return DataStore( - str(root), - default_assay=default_assay, - min_features_per_cell=-1, - mito_pattern="", - ribo_pattern="", - zarr_mode="r", - workspace=workflow.workspace, - ) - - -def _load_request( - store: DataStore, - prefix: str, - workflow_run_id: str, -) -> OrchestrationRequestRecord: - record = cast( - OrchestrationRequestRecord, - journal._read_model( - store.zw, - journal._request_key(prefix, workflow_run_id), - OrchestrationRequestRecord, - ), - ) - if record.workflowRunId != workflow_run_id: - raise ValueError("Stored orchestration request belongs to another workflow") - if record.requestSha256 != journal._sha256_model(record.request): - raise ValueError("Stored orchestration request checksum is invalid") - if record.configSha256 != journal._sha256_model(record.config): - raise ValueError("Stored orchestration configuration checksum is invalid") - if record.contentSha256 != journal._record_checksum(record): - raise ValueError("Stored orchestration request envelope is invalid") - return record - - -def _load_completed_result( - store: DataStore, - workflow: AgentWorkflowRun, -) -> tuple[str, AutomatedWorkflowResult, OrchestrationRequestRecord]: - if workflow.status != "completed": - raise RuntimeError( - "Agent HTML reports can only be generated for completed workflows" - ) - prefix = journal._ensure_orchestration_store(store) - result = journal._load_terminal_result(store, prefix, workflow) - if result is None: - raise FileNotFoundError( - f"Completed workflow {workflow.workflowRunId!r} has no terminal result" - ) - if result.status != "completed" or result.finalAnalysis is None: - raise ValueError("Completed workflow result lacks its final analysis handoff") - request = _load_request(store, prefix, workflow.workflowRunId) - if request.request.workspace != workflow.workspace: - raise ValueError("Stored request workspace does not match the workflow") - return prefix, result, request - - -def _collect_reports( - store: DataStore, - result: AutomatedWorkflowResult, -) -> dict[str, list[dict[str, Any]]]: - reports: dict[str, list[dict[str, Any]]] = {} - for reference in result.reportReferences: - report = load_agent_report(store, reference) - reports.setdefault(reference.agentName, []).append( - report.model_dump(mode="json") - ) - return reports - - -def _collect_active_decisions( - store: DataStore, - workflow_run_id: str, -) -> dict[str, dict[str, Any]]: - try: - snapshot = load_latest_decision_workflow_snapshot(store, workflow_run_id) - except KeyError: - return {} - decisions: dict[str, dict[str, Any]] = {} - for record in snapshot.workflow.active_decision_records(): - if record.decisionId in decisions: - raise ValueError( - f"Decision workflow has multiple active {record.decisionId!r} records" - ) - decisions[record.decisionId] = record.model_dump(mode="json") - return decisions - - -def _stage_summary(attempt: WorkflowStageAttempt) -> dict[str, Any]: - duration = ( - (attempt.completedAtNs - attempt.startedAtNs) / 1_000_000_000 - if attempt.completedAtNs - else None + root = path.expanduser().resolve() + if not root.is_dir(): + raise FileNotFoundError(root) + return root + + +def report_directory(root: Path, run_id: str, workspace: str | None) -> Path: + """Keep derived pages within the exact journal owner's local directory.""" + if not run_id or run_id in {".", ".."} or "/" in run_id or "\\" in run_id: + raise ValueError("Invalid analysis run identifier") + active = root if workspace is None else (root / workspace).resolve() + if not active.is_relative_to(root): + raise ValueError("Analysis workspace resolves outside the store") + destination = (active / "agents" / "orchestrations" / run_id / "report").resolve() + if not destination.is_relative_to(active): + raise ValueError("Analysis report resolves outside the store") + return destination + + +def artifact_ref(value: Any) -> ArtifactRef: + ref = ArtifactReferenceModel.model_validate(value) + return ArtifactRef( + scope=ref.scope, assay=ref.assay, kind=ref.kind, artifact_id=ref.artifactId ) - error_type = None - if attempt.error: - candidate = attempt.error.partition(":")[0].strip() - error_type = ( - candidate - if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_.]{0,127}", candidate) - else "WorkflowStageError" - ) - return { - "stage": attempt.stage, - "attemptId": attempt.attemptId, - "status": attempt.status, - "durationSeconds": duration, - "actions": list(attempt.actions), - "reportCount": len(attempt.reportReferences), - "artifactCount": len(attempt.artifacts), - "artifacts": { - name: artifact.model_dump(mode="json") - for name, artifact in attempt.artifacts.items() - }, - "parentAttempts": [ - f"{parent.stage}:{parent.attemptId}" for parent in attempt.parentAttempts - ], - "questionIds": ( - [question.questionId for question in attempt.needsInput.questions] - if attempt.needsInput is not None - else [] - ), - "noteCount": len(attempt.notes), - "notes": list(attempt.notes), - "errorType": error_type, - } -def _collect_history( - store: DataStore, - prefix: str, - workflow: AgentWorkflowRun, - request: OrchestrationRequestRecord, -) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: - attempts: dict[tuple[str, str], WorkflowStageAttempt] = {} - summaries: list[tuple[int, dict[str, Any]]] = [] - for stage in _STAGE_ORDER: - starts = { - item.attemptId: item - for item in journal._stage_starts( - store.zw, prefix, workflow.workflowRunId, stage - ) - } - outcomes = { - item.attemptId: item - for item in journal._stage_outcomes( - store.zw, prefix, workflow.workflowRunId, stage - ) - } - if not set(outcomes).issubset(starts): - raise ValueError("Workflow history contains an outcome without a start") - for attempt_id, started in starts.items(): - attempt = outcomes.get(attempt_id, started) - identity = (stage, attempt_id) - if identity in attempts: - raise ValueError("Workflow history contains duplicate stage attempts") - attempts[identity] = attempt - summaries.append((attempt.startedAtNs, _stage_summary(attempt))) - - for attempt in attempts.values(): - for parent in attempt.parentAttempts: - observed = attempts.get((parent.stage, parent.attemptId)) - if ( - observed is None - or observed.status != "done" - or observed.contentSha256 != parent.contentSha256 - ): - raise ValueError("Workflow parent-stage lineage does not resolve") - - terminal_candidates = [ - attempt - for attempt in attempts.values() - if attempt.stage == "analysis_finalization" and attempt.status == "done" +def scientific_summary(snapshot: Mapping[str, Any]) -> dict[str, Any]: + """Select recorded scientific values without inferring decision rationales.""" + final = mapping(snapshot.get("finalAnalysis")) + stages = mappings(snapshot.get("stages")) + decisions = [ + decision for stage in stages for decision in mappings(stage.get("decisions")) ] - if len(terminal_candidates) != 1: - raise ValueError( - "Completed workflow lacks one exact analysis finalization attempt" - ) - current = terminal_candidates[0] - terminal_chain: set[tuple[str, str]] = set() - while True: - identity = (current.stage, current.attemptId) - if identity in terminal_chain: - raise ValueError("Workflow stage lineage contains a cycle") - terminal_chain.add(identity) - if not journal._stage_outcome_resolves( - store, - prefix, - workflow.workflowRunId, - request, - current, - ): - raise ValueError("Terminal workflow stage artifacts do not resolve") - stage_index = _STAGE_ORDER.index(current.stage) - if stage_index == 0: - if current.parentAttempts: - raise ValueError("The ingest stage cannot have a parent") - break - if len(current.parentAttempts) != 1: - raise ValueError("Every terminal-chain stage must have one parent") - parent = current.parentAttempts[0] - if parent.stage != _STAGE_ORDER[stage_index - 1]: - raise ValueError("Terminal workflow lineage skips a stage") - current = attempts[(parent.stage, parent.attemptId)] - - resumes: list[dict[str, Any]] = [] - resume_prefix = record_io.join_key(prefix, workflow.workflowRunId, "resumes") - for key in record_io.list_keys(store.zw, resume_prefix): - if not key.endswith(".json"): - continue - resume_id = key.rsplit("/", 1)[-1].removesuffix(".json") - resume = journal._validated_resume_record( - store, prefix, workflow.workflowRunId, resume_id - ) - resumes.append( - { - "resumeId": resume.resumeId, - "createdAtNs": resume.createdAtNs, - "answeredStage": ( - resume.answeredAttempt.stage - if resume.answeredAttempt is not None - else None - ), - "answeredAttemptId": ( - resume.answeredAttempt.attemptId - if resume.answeredAttempt is not None - else None - ), - "questionIds": list(resume.questionIds), - } - ) - resumes.sort(key=lambda value: (value["createdAtNs"], value["resumeId"])) - ordered = sorted( - summaries, - key=lambda item: ( - item[0], - str(item[1]["stage"]), - str(item[1]["attemptId"]), - ), + limitations = texts(final.get("limitations")) + texts(snapshot.get("limitations")) + assessments = mappings(snapshot.get("analysisReviews")) + full_assessments = [item for item in assessments if item["scope"] == "full"] + accepted = full_assessments[-1] if full_assessments else {} + findings = texts(accepted.get("quantitativeFindings")) + texts( + accepted.get("qualitativeFindings") ) - return [value for _, value in ordered], resumes - - -def _hvg_diagnostic_evidence( - store: DataStore, - reference: Mapping[str, Any], -) -> dict[str, Any]: - import numpy as np - - model = ArtifactReferenceModel.model_validate(reference) - group: Any = store.load_artifact(artifact_model_to_ref(model)) - provenance = _mapping(group.attrs.get("provenance")) - parameters = _mapping(provenance.get("parameters")) - ranking = np.asarray(group["ranking"][:], dtype=np.int64) - corrected_variance = np.asarray( - group["global_corrected_variance"][:], - dtype=np.float64, - ) - recurrence = np.asarray(group["recurrence"][:], dtype=np.int64) - eligible = np.asarray(group["eligible"][:], dtype=bool) - if ( - ranking.ndim != 1 - or corrected_variance.ndim != 1 - or recurrence.shape != corrected_variance.shape - or eligible.shape != corrected_variance.shape - ): - raise ValueError("HVG diagnostic arrays are malformed") - if ranking.size and ( - int(ranking.min()) < 0 or int(ranking.max()) >= corrected_variance.size - ): - raise ValueError("HVG diagnostic ranking contains out-of-range indices") - raw_counts = parameters.get("candidate_counts") - if not _is_sequence(raw_counts): - raise ValueError("HVG diagnostic is missing candidate counts") - raw_count_values = cast(Sequence[Any], raw_counts) - candidate_counts = [ - int(value) - for value in raw_count_values - if isinstance(value, int) and not isinstance(value, bool) - ] - if len(candidate_counts) != len(raw_count_values) or any( - value < 1 or value > ranking.size for value in candidate_counts - ): - raise ValueError("HVG diagnostic candidate counts are invalid") - valid_groups = group.attrs.get("valid_groups", []) - if not _is_sequence(valid_groups): - raise ValueError("HVG diagnostic valid groups are malformed") - valid_group_count = len(cast(Sequence[Any], valid_groups)) - excluded_groups = group.attrs.get("excluded_groups", []) - if not _is_sequence(excluded_groups): - raise ValueError("HVG diagnostic excluded groups are malformed") - eligible_variance = float(corrected_variance[eligible].sum()) - recurrence_threshold = max(2, (valid_group_count + 1) // 2) - candidates: list[dict[str, Any]] = [] - for count in candidate_counts: - selected = ranking[:count] - variance_fraction = ( - float(corrected_variance[selected].sum()) / eligible_variance - if eligible_variance > 0 - else 0.0 - ) - candidates.append( - { - "featureCount": count, - "varianceFraction": variance_fraction, - "recurrentFraction": ( - float((recurrence[selected] >= recurrence_threshold).mean()) - if valid_group_count - else None + selected: dict[str, Any] = {} + alternatives: list[dict[str, Any]] = [] + qc_profiles: list[dict[str, Any]] = [] + qc_profile_id: Any = None + for stage in stages: + report = mapping(stage.get("report")) + stage_name = str(stage.get("stage", "")) + if stage_name.startswith("parameter_tuning"): + evaluations = mappings(report.get("evaluations")) + alternatives = evaluations + recommended = report.get("recommendedCandidateId") + selected = next( + ( + item + for item in evaluations + if item.get("candidateId") == recommended ), - } - ) - broad = ranking[: max(candidate_counts)] - return { - "rankingMode": group.attrs.get("ranking_mode"), - "eligibleFeatureCount": int(eligible.sum()), - "validTechnicalGroups": valid_group_count, - "excludedTechnicalGroupCount": len(cast(Sequence[Any], excluded_groups)), - "candidateMetrics": candidates, - "meanTechnicalGroupCoverage": ( - float(recurrence[broad].mean()) / valid_group_count - if valid_group_count - else None - ), - "recurrentInTwoGroupsFraction": ( - float((recurrence[broad] >= 2).mean()) if valid_group_count else None - ), - "minimumDetectedCells": parameters.get("min_cells"), - "minimumTechnicalGroupCells": parameters.get("min_group_cells"), - } - - -def _latest_hvg_diagnostic_artifacts( - stage_attempts: Sequence[Mapping[str, Any]], -) -> tuple[str, dict[str, Any], dict[str, Any]]: - for attempt in reversed(stage_attempts): - artifacts = _mapping(attempt.get("artifacts")) - match = next( - ( - (str(name), _mapping(reference)) - for name, reference in artifacts.items() - if re.fullmatch(r".+_hvg_diagnostic", str(name)) - ), - None, - ) - if match is not None: - return match[0], match[1], artifacts - return "", {}, {} - - -def _collect_hvg_evidence( - store: DataStore, - stage_attempts: Sequence[Mapping[str, Any]], - preprocessing_plan: Mapping[str, Any], -) -> dict[str, Any]: - selected_name, selected_reference, selected_artifacts = ( - _latest_hvg_diagnostic_artifacts(stage_attempts) - ) - if not selected_reference: - return {} - assay = selected_name.removesuffix("_hvg_diagnostic") - selected = _hvg_diagnostic_evidence(store, selected_reference) - ranking_references = ( - ("global", selected_artifacts.get(f"{assay}_hvg_global_diagnostic")), - ( - "batchAware", - selected_artifacts.get(f"{assay}_hvg_batchAware_diagnostic"), - ), - ) - rankings: list[dict[str, Any]] = [] - for mode, reference in ranking_references: - if isinstance(reference, Mapping): - summary = _hvg_diagnostic_evidence(store, reference) - if summary.get("rankingMode") != mode: - raise ValueError("HVG diagnostic ranking mode does not match its role") - rankings.append(summary) - if not rankings: - rankings.append(selected) - assay_plan = next( - ( - value - for value in _mappings(preprocessing_plan.get("assays")) - if value.get("assay") == assay - ), - {}, - ) - selected_count = _mapping(assay_plan.get("featureParameters")).get("topN") - default_reference_counts = sorted( - { - int(default_match.group(1)) - for name in selected_artifacts - if ( - default_match := re.fullmatch( - rf"{re.escape(assay)}_hvg_scarf_default_([0-9]+)", - str(name), - ) + selected, ) - } - ) - executed_branch_count = len(default_reference_counts) + sum( - len(_mappings(ranking.get("candidateMetrics"))) for ranking in rankings + if stage_name == "experimental_context": + qc_profile_id = mapping(report.get("cellQc")).get("profileId") + qc_profiles = mappings(report.get("qcProfiles")) + outputs = mapping(stage.get("outputs")) + for plan_key in ("preprocessingPlan", "resolvedPreprocessingPlan"): + plan = mapping(outputs.get(plan_key)) + if plan: + qc_profile_id = mapping(plan.get("cellQc")).get("profileId") + qc = next( + (item for item in qc_profiles if item.get("profileId") == qc_profile_id), {} ) return { - "assay": assay, - "selectedRankingMode": selected.get("rankingMode"), - "selectedFeatureCount": selected_count, - "rankings": rankings, - "candidateMetrics": selected.get("candidateMetrics"), - "eligibleFeatureCount": selected.get("eligibleFeatureCount"), - "validTechnicalGroups": selected.get("validTechnicalGroups"), - "excludedTechnicalGroupCount": selected.get("excludedTechnicalGroupCount"), - "minimumDetectedCells": selected.get("minimumDetectedCells"), - "minimumTechnicalGroupCells": selected.get("minimumTechnicalGroupCells"), - "scarfDefaultReferenceCounts": default_reference_counts, - "executedBranchCount": executed_branch_count, - } - - -def _collect_default_feature_inventories( - store: DataStore, - preprocessing_plan: Mapping[str, Any], -) -> list[dict[str, Any]]: - inventories: list[dict[str, Any]] = [] - for assay_plan in _mappings(preprocessing_plan.get("assays")): - assay_name = str(assay_plan.get("assay") or "") - parameters = _mapping(assay_plan.get("featureParameters")) - inventory = _mapping(parameters.get("defaultFeatureInventory")) - if not inventory: - continue - feature_column = str(inventory.get("featureColumn") or "") - blacklist = str(inventory.get("blacklist") or "") - if not assay_name or not feature_column or not blacklist: - raise ValueError("Scarf default feature inventory is incomplete") - assay = store.get_assay(assay_name) - if feature_column not in assay.feats.columns: - raise ValueError( - f"Scarf default feature column {feature_column!r} is unavailable " - f"for assay {assay_name!r}" + "request": mapping(snapshot.get("request")), + "finalAnalysis": final, + "decisions": decisions, + "assessments": assessments, + "alternatives": alternatives, + "findings": list(dict.fromkeys(findings)), + "limitations": list(dict.fromkeys(limitations)), + "selectedParameters": mapping(selected.get("parameters")), + "selectedMetrics": mapping(selected.get("metrics")), + "selectedSetting": mapping( + mapping(accepted.get("settings")).get( + str(accepted.get("selectedCandidateId")) ) - names = [str(value) for value in assay.feats.fetch_all(feature_column)] - try: - compiled = re.compile(blacklist.upper()) - except re.error as exc: - raise ValueError("Scarf default feature blacklist is invalid") from exc - matched = sorted( - (name for name in names if compiled.match(name.upper()) is not None), - key=lambda value: (value.casefold(), value), - ) - expected_total = inventory.get("totalFeatures") - expected_matches = inventory.get("matchCount") - if isinstance(expected_total, int) and expected_total != len(names): - raise ValueError( - f"Scarf default feature inventory for {assay_name!r} has stale " - "total feature evidence" - ) - if isinstance(expected_matches, int) and expected_matches != len(matched): - raise ValueError( - f"Scarf default feature inventory for {assay_name!r} has stale " - "blacklist match evidence" + ), + "selectedFeatures": mapping( + mapping(accepted.get("featureEvidence")).get( + str(accepted.get("selectedCandidateId")) ) - inventories.append( - { - **inventory, - "assay": assay_name, - "appliedToSelectedRepresentation": ( - parameters.get("useScarfDefaultBlacklist") is True - ), - "selectedExcludeFamilies": _text_values( - parameters.get("excludeFamilies") - ), - "selectedProtectFamilies": _text_values( - parameters.get("protectFamilies") - ), - "matchedFeatures": matched, - } - ) - return inventories - - -def _default_inventory_for_assay( - inventories: Sequence[Mapping[str, Any]], - assay: str, -) -> dict[str, Any]: - matches = [dict(value) for value in inventories if value.get("assay") == assay] - if len(matches) > 1: - raise ValueError( - f"Multiple Scarf default inventories found for assay {assay!r}" - ) - return matches[0] if matches else {} + ), + "qc": qc, + } diff --git a/scarf/agent/report/contracts.py b/scarf/agent/report/contracts.py index ce8e81b0..c2bfe98f 100644 --- a/scarf/agent/report/contracts.py +++ b/scarf/agent/report/contracts.py @@ -1,243 +1,41 @@ -"""Shared internal value contracts for agent report generation.""" +"""Small value adapters for the saved analysis summary.""" import re from collections.abc import Mapping, Sequence from typing import Any -def _present(value: Any) -> bool: - return value is not None and value != "" and value != [] and value != {} - - -def _label(value: Any) -> str: - text = str(value).replace("_", " ").strip() - words: list[str] = [] - for index, character in enumerate(text): - if ( - index - and character.isupper() - and not text[index - 1].isupper() - and text[index - 1] != " " - ): - words.append(" ") - words.append(character) - text = "".join(words) - return text[:1].upper() + text[1:] - - -def _scalar(value: Any) -> str: - if value is None or value == "": - return "Not provided" - if isinstance(value, bool): - return "Yes" if value else "No" - if isinstance(value, int): - return f"{value:,}" - if isinstance(value, float): - if value == 0: - return "0" - if abs(value) < 0.001 or abs(value) >= 10_000: - return f"{value:.3g}" - return f"{value:.3f}".rstrip("0").rstrip(".") - return str(value) - - -def _mapping(value: Any) -> dict[str, Any]: +def mapping(value: Any) -> dict[str, Any]: return dict(value) if isinstance(value, Mapping) else {} -def _mappings(value: Any) -> list[dict[str, Any]]: - if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)): +def mappings(value: Any) -> list[dict[str, Any]]: + if not isinstance(value, Sequence) or isinstance(value, str | bytes): return [] return [dict(item) for item in value if isinstance(item, Mapping)] -def _is_sequence(value: Any) -> bool: - return isinstance(value, Sequence) and not isinstance( - value, (str, bytes, bytearray) - ) - - -def _is_leaf(value: Any) -> bool: - return not isinstance(value, Mapping) and not _is_sequence(value) - - -def _is_simple(value: Any) -> bool: - if _is_leaf(value): - return True - return _is_sequence(value) and all(_is_leaf(item) for item in value) - - -def _is_mapping_sequence(value: Any) -> bool: - return ( - _is_sequence(value) - and bool(value) - and all(isinstance(item, Mapping) for item in value) - ) - - -def _latest(reports: Mapping[str, Any], agent_name: str) -> dict[str, Any]: - values = reports.get(agent_name) - if isinstance(values, Mapping): - return dict(values) - if isinstance(values, Sequence) and not isinstance(values, (str, bytes, bytearray)): - for value in reversed(values): - if isinstance(value, Mapping): - return dict(value) - return {} - - -def _text_values(value: Any) -> list[str]: - if not _is_sequence(value): +def texts(value: Any) -> list[str]: + if not isinstance(value, Sequence) or isinstance(value, str | bytes): return [] - return [str(item).strip() for item in value if _is_leaf(item) and str(item).strip()] - - -def _specific_references(values: Sequence[str]) -> list[str]: - unique = list(dict.fromkeys(values)) - return [ - value - for value in unique - if not any( - value.casefold() != other.casefold() - and value.casefold() in other.casefold() - for other in unique + return list( + dict.fromkeys( + item.strip() for item in value if isinstance(item, str) and item.strip() ) - ] - - -def _brief_text(value: Any, *, max_length: int = 240) -> str: - if not isinstance(value, str): - return "" - text = " ".join(value.split()) - text = re.sub( - r"\b[0-9a-f]{64}\b", - "recorded result", - text, - flags=re.IGNORECASE, - ) - text = re.sub( - r"\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b", - "recorded value", - text, - flags=re.IGNORECASE, ) - text = re.sub( - r"\b([A-Za-z][A-Za-z0-9]*)_id\b", - lambda match: _label(match.group(1)).lower(), - text, - ) - if not text: - return "" - first_sentence = re.split(r"(?<=[.!?])\s+", text, maxsplit=1)[0] - if len(first_sentence) <= max_length: - return first_sentence - shortened = first_sentence[: max_length - 3].rsplit(" ", 1)[0] - return f"{shortened or first_sentence[: max_length - 3]}..." - - -def _format_text_list(values: Sequence[str]) -> str: - items = [value for value in dict.fromkeys(values) if value] - if not items: - return "" - if len(items) == 1: - return items[0] - if len(items) == 2: - return f"{items[0]} and {items[1]}" - return f"{', '.join(items[:-1])}, and {items[-1]}" - - -def _assay_label(value: Any) -> str: - labels = { - "RNA": "RNA", - "ATAC": "chromatin accessibility", - "ADT": "protein abundance", - "HTO": "sample tags", - } - text = str(value or "").strip() - return labels.get(text.upper(), _label(text).lower()) if text else "" - - -def _selected_qc_profile( - experimental: Mapping[str, Any], - cell_qc: Mapping[str, Any], -) -> dict[str, Any]: - profiles = _mappings(experimental.get("qcProfiles")) - profile_id = cell_qc.get("profileId") - if profile_id: - for profile in profiles: - if profile.get("profileId") == profile_id: - return profile - return profiles[0] if len(profiles) == 1 else {} - - -def _feature_family_label(value: Any) -> str: - labels = { - "ribosomal": "ribosomal genes", - "ribosomalProtein": "ribosomal protein genes", - "mitochondrial": "mitochondrial genes", - "mitoribosomal": "mitoribosomal genes", - "sex": "sex-linked genes", - "sexLinked": "sex-linked genes", - "cellCycle": "cell-cycle genes", - "cellCycleCcn": "CCN-prefixed genes", - "hla": "HLA genes", - "h2": "H2 genes", - "histone": "histone genes", - } - text = str(value or "").strip() - return labels.get(text, _label(text).lower()) if text else "" -def _public_field_label(value: Any) -> str: - labels = { - "T2D": "T2D status", - "donor_id": "donor", - "library_id": "library", - "RNA_nCounts": "RNA counts", - "RNA_nFeatures": "detected genes", - "RNA_percentMito": "mitochondrial percentage", - "RNA_percentRibo": "ribosomal percentage", - "sample_id": "sample", - "sex": "sex", - "tissue": "tissue", - } - text = str(value or "").strip() - if not text: - return "" - if text in labels: - return labels[text] - return _label(text.removesuffix("_id")).lower() +def label(value: str) -> str: + return re.sub(r"(?<=[a-z])(?=[A-Z])", " ", value.replace("_", " ")).capitalize() -def _analysis_percent(value: Any) -> str: - if not isinstance(value, (int, float)) or isinstance(value, bool): - return "Not available" - return f"{float(value):.1%}" - - -def _analysis_number_range(values: Sequence[Any]) -> str: - numbers = [ - float(value) - for value in values - if isinstance(value, (int, float)) and not isinstance(value, bool) - ] - if not numbers: - return "Not available" - low = min(numbers) - high = max(numbers) - - def display(value: float) -> str: - if abs(value) >= 100: - return f"{value:,.0f}" - return f"{value:,.3f}".rstrip("0").rstrip(".") - - if low == high: - return display(low) - return f"{display(low)} to {display(high)}" - - -def _qc_resolved_bounds(profile: Mapping[str, Any]) -> list[dict[str, Any]]: - direct = _mappings(profile.get("resolvedBounds")) - if direct: - return direct - return _mappings(_mapping(profile.get("parameters")).get("resolvedBounds")) +def scalar(value: Any) -> str: + if value is None: + return "Unavailable" + if isinstance(value, bool): + return "Yes" if value else "No" + if isinstance(value, int): + return f"{value:,}" + if isinstance(value, float): + return f"{value:.3g}" + return str(value) diff --git a/scarf/agent/report/decision_tree.py b/scarf/agent/report/decision_tree.py deleted file mode 100644 index e19f8a7f..00000000 --- a/scarf/agent/report/decision_tree.py +++ /dev/null @@ -1,1183 +0,0 @@ -"""Decision-tree construction and rendering for agent reports.""" - -import html -from collections import Counter -from collections.abc import Mapping, Sequence -from typing import Any - -from .artifacts import _default_inventory_for_assay -from .contracts import ( - _analysis_percent, - _brief_text, - _feature_family_label, - _format_text_list, - _label, - _latest, - _mapping, - _mappings, - _present, - _public_field_label, - _scalar, - _text_values, -) -from .plots import _hvg_ranking_label - - -def _tree_branch( - *, - label: str, - status: str, - state: str, - metrics: Sequence[str], - reason: str, -) -> dict[str, Any]: - return { - "label": label, - "status": status, - "state": state, - "metrics": list(metrics), - "reason": reason, - } - - -def _qc_profile_label(profile: Mapping[str, Any]) -> str: - labels = { - "retainWithFlags": "Retain cells with quality flags", - "globalMad5": "Global quality threshold", - "captureMad5": "Per-library quality threshold", - "captureMad3Sensitivity": "Stricter per-library sensitivity check", - } - registered = str(profile.get("registeredProfile") or "") - if registered in labels: - return labels[registered] - profile_id = str(profile.get("profileId") or "") - for name, label in labels.items(): - if name in profile_id: - return label - action = str(profile.get("action") or "") - return { - "skip": "Retain reviewed cells", - "globalGaussian": "Global quality threshold", - "sampleMad": "Per-sample quality threshold", - "registeredMad": "Registered quality threshold", - }.get(action, "Quality-control option") - - -def _qc_tree_stage( - experimental: Mapping[str, Any], - plan: Mapping[str, Any], - total_cells: int, -) -> dict[str, Any] | None: - decision = _mapping(experimental.get("decision")) - cell_qc = _mapping(plan.get("cellQc")) - if not cell_qc: - cell_qc = _mapping(decision.get("cellQc")) - if not cell_qc: - cell_qc = _mapping(experimental.get("cellQc")) - if not cell_qc: - return None - profiles = _mappings(experimental.get("qcProfiles")) - if not profiles: - profiles = [ - { - **cell_qc, - "activeCells": total_cells or None, - "retainedCells": total_cells or None, - } - ] - selected_id = cell_qc.get("profileId") - selected_name = cell_qc.get("registeredProfile") - branches: list[dict[str, Any]] = [] - for profile in profiles: - selected = bool( - (selected_id and profile.get("profileId") == selected_id) - or ( - not selected_id - and selected_name - and profile.get("registeredProfile") == selected_name - ) - or (len(profiles) == 1) - ) - active = profile.get("activeCells") - retained = profile.get("retainedCells") - metrics: list[str] = [] - removed: int | None = None - if isinstance(active, int) and isinstance(retained, int) and active: - retained_percent = retained / active * 100 - percent_text = "100%" if retained == active else f"{retained_percent:.2f}%" - metrics.append( - f"Retained {retained:,} of {active:,} cells ({percent_text})" - ) - removed = active - retained - if selected: - reason = ( - "Selected because it preserved the reviewed dataset without " - "unsupported filtering." - if removed == 0 - else "Selected as the best-supported balance of cell retention and " - "quality control." - ) - elif removed == 0: - reason = ( - "Not selected because it retained the same cells while adding a " - "filtering rule that was not needed." - ) - elif removed is not None: - reason = ( - f"Not selected because it removed {removed:,} additional cells " - "without stronger support." - ) - else: - reason = "Evaluated but not selected for the final cell set." - branches.append( - _tree_branch( - label=_qc_profile_label(profile), - status="Selected" if selected else "Not selected", - state="selected" if selected else "alternative", - metrics=metrics, - reason=reason, - ) - ) - branches.sort(key=lambda branch: branch["state"] != "selected") - return { - "question": "Which cells should be retained?", - "description": ( - "The workflow compared the registered quality-control choices before " - "changing the cell set." - ), - "branches": branches, - } - - -def _feature_tree_stage( - plan: Mapping[str, Any], - inventories: Sequence[Mapping[str, Any]], -) -> dict[str, Any] | None: - assay_plans = _mappings(plan.get("assays")) - selected_assay = next( - (assay for assay in assay_plans if assay.get("graphEligible") is True), - assay_plans[0] if assay_plans else {}, - ) - if not selected_assay: - return None - feature_method = str(selected_assay.get("featureMethod") or "none") - feature_labels = { - "hvg": "Most variable genes", - "prevalentPeaks": "Frequently observed chromatin regions", - "panel": "Predefined feature panel", - "none": "No feature subset", - } - parameters = _mapping(selected_assay.get("featureParameters")) - metrics: list[str] = [] - top_n = parameters.get("topN") - min_cells = parameters.get("minCells") - if isinstance(top_n, int): - metrics.append(f"Selected {top_n:,} features") - if isinstance(min_cells, int): - metrics.append(f"Required presence in at least {min_cells:,} cells") - excluded = [ - _feature_family_label(item) - for item in _text_values(parameters.get("excludeFamilies")) - ] - protected = [ - _feature_family_label(item) - for item in _text_values(parameters.get("protectFamilies")) - ] - if excluded: - metrics.append(f"Excluded {_format_text_list(excluded)}") - if protected: - metrics.append(f"Kept {_format_text_list(protected)} eligible") - inventory = _default_inventory_for_assay( - inventories, - str(selected_assay.get("assay") or ""), - ) - if inventory: - match_count = inventory.get("matchCount") - total_features = inventory.get("totalFeatures") - if isinstance(match_count, int) and isinstance(total_features, int): - metrics.append( - f"Scarf default reference matched {match_count:,} of " - f"{total_features:,} genes" - ) - metrics.append( - "Complete Scarf default blacklist applied: " - + ( - "yes" - if inventory.get("appliedToSelectedRepresentation") is True - else "no" - ) - ) - return { - "question": "Which measurements should shape the cell map?", - "description": ( - "The selected feature policy controls which biological variation can " - "influence the map." - ), - "branches": [ - _tree_branch( - label=feature_labels.get( - feature_method, - "Analysis-specific feature set", - ), - status="Selected", - state="selected", - metrics=metrics, - reason=( - "Selected to emphasize informative variation while limiting " - "known unwanted signal." - ), - ) - ], - } - - -def _batch_tree_stage( - experimental: Mapping[str, Any], - parameter: Mapping[str, Any], - final: Mapping[str, Any], - decisions: Mapping[str, Any], -) -> dict[str, Any] | None: - decision = _mapping(experimental.get("decision")) - batch_plan = _mapping(decision.get("batchCorrection")) - if not batch_plan: - return None - native_analyses = _mappings(final.get("nativeAnalyses")) - if final.get("graphMethod") == "native" and final.get("primaryAssay"): - selected_native = [ - item - for item in native_analyses - if item.get("assay") == final.get("primaryAssay") - ] - else: - selected_native = native_analyses - adjustment_applied = any( - _present(item.get("batchCorrection")) for item in selected_native - ) - native_candidate, harmony_candidate = _harmony_candidate_pair(parameter, final) - harmony_executed = _harmony_completed(native_candidate) and _harmony_completed( - harmony_candidate - ) - degraded = _degraded_protected_columns(native_candidate, harmony_candidate) - safety = _mappings(experimental.get("batchSafety")) - unsafe = [item for item in safety if item.get("status") == "unsafe"] - coefficients = [ - _public_field_label(item.get("coefficient")) - for item in unsafe - if _public_field_label(item.get("coefficient")) - ] - coefficients = list(dict.fromkeys(coefficients)) - remaining_capacity = [ - _mapping(item.get("estimability")).get("estimableDf") for item in unsafe - ] - adjustment_metrics: list[str] = [] - if coefficients: - adjustment_metrics.append( - f"Protected comparisons at risk: {_format_text_list(coefficients)}" - ) - if remaining_capacity and all(value == 0 for value in remaining_capacity): - adjustment_metrics.append("Remaining comparison capacity: 0") - if harmony_candidate: - harmony_parameters = _mapping(harmony_candidate.get("parameters")) - adjustment_metrics.append( - "Matched parameters: " - f"{_scalar(harmony_parameters.get('dimensions'))} dimensions, " - f"{_scalar(harmony_parameters.get('neighborsK'))} neighbors, " - f"resolution {_scalar(harmony_parameters.get('leidenResolution'))}" - ) - if harmony_executed: - adjustment_metrics.insert(0, "Run status: completed diagnostic") - native_metrics = _mapping(native_candidate.get("metrics")) - harmony_metrics = _mapping(harmony_candidate.get("metrics")) - native_batch = _mapping(native_metrics.get("batchMixing")) - harmony_batch = _mapping(harmony_metrics.get("batchMixing")) - for column in dict.fromkeys([*native_batch, *harmony_batch]): - adjustment_metrics.append( - f"{_public_field_label(column).capitalize()} mixing: " - f"{_score_transition(native_batch.get(column), harmony_batch.get(column))}" - ) - if degraded: - adjustment_metrics.append( - "Protected evidence degraded: " + _format_text_list(degraded) - ) - correction_license = _active_decision(decisions, "correctionLicense") - diagnostic_only = str(correction_license.get("selectedOptionId") or "").endswith( - "unsafeConfounded" - ) - if diagnostic_only: - adjustment_metrics.append("Selection license: diagnostic only") - action = str(batch_plan.get("action") or "") - if adjustment_applied: - unadjusted_state = "alternative" - adjusted_state = "selected" - unadjusted_status = "Not selected" - adjusted_status = "Selected" - unadjusted_reason = ( - "The adjusted result provided stronger supported comparability." - ) - adjusted_reason = ( - "Selected because it improved technical comparability while preserving " - "the biological structure being studied." - ) - else: - unadjusted_state = "selected" - adjusted_state = ( - "rejected" - if harmony_executed - else ("blocked" if action in {"unsafe", "skip"} else "alternative") - ) - unadjusted_status = "Selected" - adjusted_status = ( - "Run diagnostically; rejected" - if harmony_executed - else ("Not run" if adjusted_state == "blocked" else "Not selected") - ) - unadjusted_reason = ( - "Selected after the matched diagnostic retained more of the protected " - "biological structure." - if harmony_executed - else "Selected because adjustment was not shown to improve the data safely." - ) - adjusted_reason = ( - "Rejected because protected evidence degraded for " - f"{_format_text_list(degraded)}" - + ( - " and the design allowed diagnostic use only." - if diagnostic_only - else "." - ) - if harmony_executed and degraded - else ( - "Run as a matched diagnostic but not selected." - if harmony_executed - else ( - "Not run because technical and biological differences could " - "not be separated safely." - if adjusted_state == "blocked" - else "Tested but did not provide a safer improvement over the " - "unadjusted data." - ) - ) - ) - return { - "question": "Should technical variation be adjusted?", - "description": ( - "Adjustment was accepted only if it improved comparability without " - "removing protected biological differences." - ), - "branches": [ - _tree_branch( - label="Use the unadjusted representation", - status=unadjusted_status, - state=unadjusted_state, - metrics=[ - "Final representation: native", - "Protected biological comparisons retained", - ], - reason=unadjusted_reason, - ), - _tree_branch( - label="Apply Harmony batch adjustment", - status=adjusted_status, - state=adjusted_state, - metrics=adjustment_metrics, - reason=adjusted_reason, - ), - ], - } - - -def _selected_parameter_context( - parameter: Mapping[str, Any], - final: Mapping[str, Any], -) -> tuple[dict[str, Any], list[dict[str, Any]], dict[str, Any]]: - assay_reports = _mapping(parameter.get("assayReports")) - preferred_assay = str( - parameter.get("graphAssay") - or final.get("primaryAssay") - or parameter.get("fromAssay") - or "" - ) - report = _mapping(assay_reports.get(preferred_assay)) - if not report and assay_reports: - report = _mapping(next(iter(assay_reports.values()))) - if not report: - report = dict(parameter) - evaluations = _mappings(report.get("evaluations")) - recommended = _mapping(parameter.get("recommendedByAssay")) - selected_id = ( - recommended.get(preferred_assay) - or report.get("recommendedCandidateId") - or parameter.get("recommendedCandidateId") - ) - selected = next( - ( - evaluation - for evaluation in evaluations - if evaluation.get("candidateId") == selected_id - ), - {}, - ) - return report, evaluations, selected - - -def _harmony_candidate_pair( - parameter: Mapping[str, Any], - final: Mapping[str, Any], -) -> tuple[dict[str, Any], dict[str, Any]]: - _report, evaluations, _selected = _selected_parameter_context(parameter, final) - for harmony in reversed(evaluations): - harmony_parameters = _mapping(harmony.get("parameters")) - if harmony_parameters.get("useHarmony") is not True: - continue - signature = { - key: value - for key, value in harmony_parameters.items() - if key not in {"candidateId", "useHarmony"} - } - native_candidates = [ - evaluation - for evaluation in evaluations - if _mapping(evaluation.get("parameters")).get("useHarmony") is False - and { - key: value - for key, value in _mapping(evaluation.get("parameters")).items() - if key not in {"candidateId", "useHarmony"} - } - == signature - ] - if not native_candidates: - continue - expected_native_id = str(harmony.get("candidateId") or "").replace( - "_correction_harmony", - "_correction_native", - ) - native = next( - ( - evaluation - for evaluation in native_candidates - if evaluation.get("candidateId") == expected_native_id - ), - native_candidates[-1], - ) - return native, harmony - return {}, {} - - -def _harmony_completed(evaluation: Mapping[str, Any]) -> bool: - return ( - evaluation.get("status") == "done" and evaluation.get("eligible") is not False - ) - - -def _score_transition(native: Any, harmony: Any) -> str: - if not isinstance(native, (int, float)) or isinstance(native, bool): - return "Not available" - if not isinstance(harmony, (int, float)) or isinstance(harmony, bool): - return "Not available" - delta = float(harmony) - float(native) - return f"{float(native):.3f} to {float(harmony):.3f} (change {delta:+.3f})" - - -def _harmony_metric_rows( - native: Mapping[str, Any], - harmony: Mapping[str, Any], -) -> list[dict[str, Any]]: - native_metrics = _mapping(native.get("metrics")) - harmony_metrics = _mapping(harmony.get("metrics")) - rows: list[dict[str, Any]] = [] - - def add( - category: str, - metric: str, - native_value: Any, - harmony_value: Any, - interpretation: str, - ) -> None: - delta = ( - float(harmony_value) - float(native_value) - if isinstance(native_value, (int, float)) - and not isinstance(native_value, bool) - and isinstance(harmony_value, (int, float)) - and not isinstance(harmony_value, bool) - else None - ) - rows.append( - { - "category": category, - "metric": metric, - "native": native_value, - "Harmony": harmony_value, - "change": delta, - "interpretation": interpretation, - } - ) - - native_batch = _mapping(native_metrics.get("batchMixing")) - harmony_batch = _mapping(harmony_metrics.get("batchMixing")) - for column in dict.fromkeys([*native_batch, *harmony_batch]): - add( - "Batch removal", - f"{_public_field_label(column)} mixing", - native_batch.get(column), - harmony_batch.get(column), - "Higher values indicate stronger mixing across the technical group.", - ) - - native_association = _mapping(native_metrics.get("technicalAssociation")) - harmony_association = _mapping(harmony_metrics.get("technicalAssociation")) - for column in dict.fromkeys([*native_association, *harmony_association]): - add( - "Technical association", - _public_field_label(column), - native_association.get(column), - harmony_association.get(column), - "Lower values indicate less association with the technical group.", - ) - - native_biology = _mapping(native_metrics.get("biologicalPreservation")) - harmony_biology = _mapping(harmony_metrics.get("biologicalPreservation")) - for column in dict.fromkeys([*native_biology, *harmony_biology]): - native_scores = _mapping(native_biology.get(column)) - harmony_scores = _mapping(harmony_biology.get(column)) - for name in dict.fromkeys([*native_scores, *harmony_scores]): - add( - "Protected biology", - f"{_public_field_label(column)} {_label(name)}", - native_scores.get(name), - harmony_scores.get(name), - "Protected evidence should not decrease materially.", - ) - - for key, label, interpretation in ( - ( - "crossUnitSupport", - "Cross-sample support", - "Higher values indicate broader support across study units.", - ), - ( - "markerCoherence", - "Marker coherence", - "Higher values indicate more groups with coherent markers.", - ), - ( - "markerSpecificityMedian", - "Median marker specificity", - "Higher values indicate more group-specific markers.", - ), - ( - "clusterConnectivity", - "Cluster connectivity", - "Higher values indicate better connected groups.", - ), - ( - "membershipStrengthMean", - "Mean membership strength", - "Higher values indicate more stable cluster membership.", - ), - ( - "doubletHighScoreConcentration", - "Doublet-score concentration", - "Lower values indicate less concentration of high doublet scores.", - ), - ): - if key in native_metrics or key in harmony_metrics: - add( - "Supporting diagnostic", - label, - native_metrics.get(key), - harmony_metrics.get(key), - interpretation, - ) - return rows - - -def _degraded_protected_columns( - native: Mapping[str, Any], - harmony: Mapping[str, Any], - *, - tolerance: float = 0.05, -) -> list[str]: - native_biology = _mapping( - _mapping(native.get("metrics")).get("biologicalPreservation") - ) - harmony_biology = _mapping( - _mapping(harmony.get("metrics")).get("biologicalPreservation") - ) - degraded: list[str] = [] - for column, raw_native in native_biology.items(): - native_scores = _mapping(raw_native) - harmony_scores = _mapping(harmony_biology.get(column)) - if any( - isinstance(value, (int, float)) - and not isinstance(value, bool) - and isinstance(harmony_scores.get(name), (int, float)) - and not isinstance(harmony_scores.get(name), bool) - and float(harmony_scores[name]) < float(value) - tolerance - for name, value in native_scores.items() - ): - degraded.append(_public_field_label(column)) - return degraded - - -def _active_decision( - decisions: Mapping[str, Any], - decision_id: str, -) -> dict[str, Any]: - return _mapping(decisions.get(decision_id)) - - -def _common_parameter( - evaluations: Sequence[Mapping[str, Any]], - key: str, -) -> Any: - values = [ - _mapping(evaluation.get("parameters")).get(key) - for evaluation in evaluations - if _present(_mapping(evaluation.get("parameters")).get(key)) - ] - return Counter(values).most_common(1)[0][0] if values else None - - -def _parameter_options( - evaluations: Sequence[Mapping[str, Any]], - key: str, - filters: Mapping[str, Any], -) -> list[dict[str, Any]]: - by_value: dict[Any, dict[str, Any]] = {} - for evaluation in evaluations: - if evaluation.get("status") != "done" or evaluation.get("eligible") is False: - continue - parameters = _mapping(evaluation.get("parameters")) - if any(parameters.get(name) != value for name, value in filters.items()): - continue - value = parameters.get(key) - if not _present(value): - continue - current = by_value.get(value) - current_metrics = _mapping(current.get("metrics")) if current else {} - metrics = _mapping(evaluation.get("metrics")) - if current is None or len(metrics) > len(current_metrics): - by_value[value] = dict(evaluation) - return [ - by_value[value] - for value in sorted( - by_value, - key=lambda item: (not isinstance(item, (int, float)), item), - ) - ] - - -def _candidate_metrics( - evaluation: Mapping[str, Any], - *, - include_stability: bool = False, -) -> list[str]: - metrics = _mapping(evaluation.get("metrics")) - values: list[str] = [] - clusters = metrics.get("nClusters") - separation = metrics.get("graphSilhouetteMedian") - smallest = metrics.get("minClusterCells") - if isinstance(clusters, int): - values.append(f"Cell groups: {clusters:,}") - if isinstance(separation, (int, float)): - values.append(f"Separation score: {float(separation):.3f}") - if isinstance(smallest, int): - values.append(f"Smallest group: {smallest:,} cells") - if include_stability: - seed = metrics.get("seedStability") - subsample = metrics.get("subsampleStability") - marker = metrics.get("markerCoherence") - support = metrics.get("crossUnitSupport") - if isinstance(seed, (int, float)): - values.append(f"Repeat-run stability: {float(seed):.3f}") - if isinstance(subsample, (int, float)): - values.append(f"Subsample stability: {float(subsample):.3f}") - if isinstance(marker, (int, float)): - values.append(f"Marker coherence: {float(marker):.3f}") - if isinstance(support, (int, float)): - values.append(f"Cross-sample support: {float(support):.3f}") - return values - - -def _parameter_tree_stage( - *, - question: str, - description: str, - options: Sequence[Mapping[str, Any]], - parameter_name: str, - selected_value: Any, - label: Any, - selected_reason: str, - alternative_reason: Any, - include_stability: bool = False, -) -> dict[str, Any] | None: - if not options: - return None - branches: list[dict[str, Any]] = [] - for evaluation in options: - value = _mapping(evaluation.get("parameters")).get(parameter_name) - selected = value == selected_value - branches.append( - _tree_branch( - label=str(label(value)), - status="Selected" if selected else "Not selected", - state="selected" if selected else "alternative", - metrics=_candidate_metrics( - evaluation, - include_stability=include_stability and selected, - ), - reason=( - selected_reason - if selected - else str(alternative_reason(value, evaluation)) - ), - ) - ) - return { - "question": question, - "description": description, - "branches": branches, - } - - -def _parameter_tree_stages( - parameter: Mapping[str, Any], - final: Mapping[str, Any], -) -> tuple[list[dict[str, Any]], dict[str, Any]]: - _report, evaluations, selected = _selected_parameter_context(parameter, final) - if not evaluations or not selected: - return [], selected - selected_parameters = _mapping(selected.get("parameters")) - selected_dimensions = selected_parameters.get("dimensions") - selected_neighbors = selected_parameters.get("neighborsK") - selected_resolution = selected_parameters.get("leidenResolution") - selected_harmony = selected_parameters.get("useHarmony") - common_neighbors = _common_parameter(evaluations, "neighborsK") - common_resolution = _common_parameter(evaluations, "leidenResolution") - - dimension_options = _parameter_options( - evaluations, - "dimensions", - { - "neighborsK": common_neighbors, - "leidenResolution": common_resolution, - "useHarmony": selected_harmony, - }, - ) - neighbor_options = _parameter_options( - evaluations, - "neighborsK", - { - "dimensions": selected_dimensions, - "leidenResolution": common_resolution, - "useHarmony": selected_harmony, - }, - ) - resolution_options = _parameter_options( - evaluations, - "leidenResolution", - { - "dimensions": selected_dimensions, - "neighborsK": selected_neighbors, - "useHarmony": selected_harmony, - }, - ) - - def dimension_alternative(value: Any, _evaluation: Mapping[str, Any]) -> str: - if isinstance(value, (int, float)) and isinstance( - selected_dimensions, (int, float) - ): - if value > selected_dimensions: - return ( - "Not selected because the smaller representation retained " - "sufficient structure with less added noise." - ) - return "Not selected because it retained too little stable structure." - return "Evaluated but not selected." - - def neighbor_alternative(value: Any, _evaluation: Mapping[str, Any]) -> str: - if isinstance(value, (int, float)) and isinstance( - selected_neighbors, (int, float) - ): - if value < selected_neighbors: - return ( - "Provided finer local detail but produced smaller, less stable " - "groups." - ) - return "Smoothed across more cells and reduced useful local detail." - return "Evaluated but not selected." - - selected_metrics = _mapping(selected.get("metrics")) - selected_separation = selected_metrics.get("graphSilhouetteMedian") - - def resolution_alternative( - _value: Any, - evaluation: Mapping[str, Any], - ) -> str: - metrics = _mapping(evaluation.get("metrics")) - groups = metrics.get("nClusters") - separation = metrics.get("graphSilhouetteMedian") - if isinstance(groups, int) and isinstance(separation, (int, float)): - return ( - f"Produced {groups:,} groups with separation " - f"{float(separation):.3f}, weaker than the selected balance." - ) - if isinstance(selected_separation, (int, float)): - return ( - f"Did not match the selected separation score of " - f"{float(selected_separation):.3f}." - ) - return "Evaluated but not selected." - - stages = [ - stage - for stage in ( - _parameter_tree_stage( - question="How many variation patterns should be retained?", - description=( - "Dimensions are compressed patterns of gene variation used to " - "build the cell map." - ), - options=dimension_options, - parameter_name="dimensions", - selected_value=selected_dimensions, - label=lambda value: f"{int(value):,} dimensions", - selected_reason=( - "Selected as the smallest representation that retained a stable " - "cell map." - ), - alternative_reason=dimension_alternative, - ), - _parameter_tree_stage( - question="How local should each cell neighborhood be?", - description=( - "Smaller neighborhoods emphasize local detail; larger ones " - "produce broader smoothing." - ), - options=neighbor_options, - parameter_name="neighborsK", - selected_value=selected_neighbors, - label=lambda value: f"{int(value):,} nearest neighbors", - selected_reason=( - "Selected to balance local detail with stable cell-group sizes." - ), - alternative_reason=neighbor_alternative, - ), - _parameter_tree_stage( - question="How finely should cells be divided into groups?", - description=( - "Resolution controls whether the final map contains broader or " - "more finely divided cell groups." - ), - options=resolution_options, - parameter_name="leidenResolution", - selected_value=selected_resolution, - label=lambda value: f"Resolution {float(value):g}", - selected_reason=( - "Selected for the strongest supported separation, stability, " - "marker coherence, and group sizes." - ), - alternative_reason=resolution_alternative, - include_stability=True, - ), - ) - if stage is not None and len(stage["branches"]) > 1 - ] - return stages, selected - - -def _analysis_tree_stages(payload: Mapping[str, Any]) -> list[dict[str, Any]]: - reports = _mapping(payload.get("reports")) - workflow_result = _mapping(payload.get("workflowResult")) - plan = _mapping(workflow_result.get("preprocessingPlan")) - final = _mapping(workflow_result.get("finalAnalysis")) - experimental = _latest(reports, "experimental_context") - parameter = _latest(reports, "parameter_tuning") - biology = _latest(reports, "biological_interpretation") - decisions = _mapping(payload.get("activeDecisions")) - inventories = _mappings(payload.get("defaultFeatureInventories")) - cluster_counts = _mapping(payload.get("clusterCounts")) - total_cells = sum(int(value) for value in cluster_counts.values()) - stages: list[dict[str, Any]] = [] - for stage in ( - _qc_tree_stage(experimental, plan, total_cells), - _feature_tree_stage(plan, inventories), - ): - if stage is not None: - stages.append(stage) - stages.extend(_hvg_tree_stages(_mapping(payload.get("hvgEvidence")))) - batch_stage = _batch_tree_stage(experimental, parameter, final, decisions) - if batch_stage is not None: - stages.append(batch_stage) - parameter_stages, selected = _parameter_tree_stages(parameter, final) - stages.extend(parameter_stages) - - interpretations = _mappings(biology.get("clusterInterpretations")) - final_metrics = [f"Cells analyzed: {total_cells:,}"] if total_cells else [] - final_metrics.extend(_candidate_metrics(selected, include_stability=True)) - if not selected and cluster_counts: - final_metrics.append(f"Cell groups: {len(cluster_counts):,}") - stages.append( - { - "question": "Which result became the final analysis?", - "description": ( - "Only the selected branch was carried into visualization and marker " - "analysis." - ), - "branches": [ - _tree_branch( - label=( - f"{len(cluster_counts):,} cell groups" - if cluster_counts - else "Final selected cell map" - ), - status="Final result", - state="selected", - metrics=final_metrics, - reason=( - f"{len(interpretations):,} groups also received biological " - "interpretations." - if interpretations - else "No biological cell-type labels were inferred." - ), - ) - ], - } - ) - return stages - - -def _tree_connector_svg( - branch_count: int, - selected_index: int, - stage_index: int, - *, - continues: bool, -) -> tuple[str, str]: - width = 1200 - centers = [(index + 0.5) * width / branch_count for index in range(branch_count)] - branch_marker_id = f"tree-branch-arrow-{stage_index}" - if branch_count == 1: - branch_paths = ( - f'' - ) - else: - branch_paths = ( - f'' - f'' - + "".join( - f'' - for center in centers - ) - ) - branch_definitions = ( - f'' - '' - ) - branch_svg = ( - '" - ) - if not continues: - return branch_svg, "" - selected_x = centers[selected_index] - selection_marker_id = f"tree-selection-arrow-{stage_index}" - selection_path = ( - f"M {selected_x:g} 0 V 28 H {width / 2:g} V 78" - if selected_x != width / 2 - else f"M {width / 2:g} 0 V 78" - ) - selection_definitions = ( - f'' - "" - ) - selection_svg = ( - '' - ) - return branch_svg, selection_svg - - -def _render_decision_tree(stages: Sequence[Mapping[str, Any]]) -> str: - if not stages: - return '

    No completed analysis decisions were available.

    ' - rendered: list[str] = [] - for stage_index, stage in enumerate(stages, start=1): - branches = _mappings(stage.get("branches")) - if not branches: - continue - selected_index = next( - ( - index - for index, branch in enumerate(branches) - if branch.get("state") == "selected" - ), - 0, - ) - branch_svg, selection_svg = _tree_connector_svg( - len(branches), - selected_index, - stage_index, - continues=stage_index < len(stages), - ) - branch_markup = "".join( - '
    '.format( - html.escape(str(branch.get("state") or "alternative"), quote=True) - ) - + '{}'.format( - html.escape(str(branch.get("status") or "Evaluated")) - ) - + f"

    {html.escape(str(branch.get('label') or 'Option'))}

    " - + ( - '
      ' - + "".join( - f"
    • {html.escape(metric)}
    • " - for metric in _text_values(branch.get("metrics")) - ) - + "
    " - if _present(branch.get("metrics")) - else "" - ) - + ( - f"

    {html.escape(_brief_text(branch.get('reason')))}

    " - if _brief_text(branch.get("reason")) - else "" - ) - + "
    " - for branch in branches - ) - rendered.append( - '
    ' - '
    ' - f"Decision {stage_index}" - f"{html.escape(str(stage.get('question') or 'Analysis decision'))}" - "
    " - + ( - f'

    {html.escape(_brief_text(stage.get("description")))}

    ' - if _brief_text(stage.get("description")) - else "" - ) - + branch_svg - + '
    '.format( - len(branches) - ) - + branch_markup - + "
    " - + selection_svg - + "
    " - ) - return ( - '
    ' - + "".join(rendered) - + "
    " - ) - - -def _hvg_tree_stages(evidence: Mapping[str, Any]) -> list[dict[str, Any]]: - rankings = _mappings(evidence.get("rankings")) - candidates = _mappings(evidence.get("candidateMetrics")) - default_counts = [ - int(value) - for value in evidence.get("scarfDefaultReferenceCounts", []) - if isinstance(value, int) - ] - selected_mode = evidence.get("selectedRankingMode") - selected_count = evidence.get("selectedFeatureCount") - stages: list[dict[str, Any]] = [] - if rankings: - ranking_branches: list[dict[str, Any]] = [] - for ranking in rankings: - selected = ranking.get("rankingMode") == selected_mode - ranking_branches.append( - _tree_branch( - label=_hvg_ranking_label(ranking.get("rankingMode")), - status="Selected" if selected else "Not selected", - state="selected" if selected else "alternative", - metrics=[ - "Mean library coverage: " - f"{_analysis_percent(ranking.get('meanTechnicalGroupCoverage'))}", - "Recurring in at least two libraries: " - f"{_analysis_percent(ranking.get('recurrentInTwoGroupsFraction'))}", - ], - reason=( - "Selected after the combined recurrence, default-overlap, " - "technical-association, and downstream-stability comparison." - if selected - else ( - "Not selected after the combined upstream and downstream " - "comparison." - ) - ), - ) - ) - if default_counts: - ranking_branches.append( - _tree_branch( - label="Exact Scarf-default blacklist reference", - status="Reference evaluated", - state="reviewed", - metrics=[ - "Executed set sizes: " - + ", ".join(f"{value:,}" for value in default_counts) - ], - reason=( - "Used as an exact comparison reference; it was not a " - "selectable ranking mode." - ), - ) - ) - stages.append( - { - "question": "How should highly variable genes be ranked?", - "description": ( - "The workflow compared a global variability ranking with a " - "ranking that emphasized recurrence across libraries." - ), - "branches": ranking_branches, - } - ) - if candidates: - count_branches: list[dict[str, Any]] = [] - for candidate in candidates: - count = candidate.get("featureCount") - if not isinstance(count, int): - continue - selected = count == selected_count - count_branches.append( - _tree_branch( - label=f"{count:,} variable genes", - status="Selected" if selected else "Not selected", - state="selected" if selected else "alternative", - metrics=[ - "Corrected variance captured: " - f"{_analysis_percent(candidate.get('varianceFraction'))}", - "Recurring across most libraries: " - f"{_analysis_percent(candidate.get('recurrentFraction'))}", - ], - reason=( - "Selected as the supported balance of captured variation, " - "reproducibility, and downstream stability." - if selected - else "Not selected after comparison with the supported set size." - ), - ) - ) - if count_branches: - stages.append( - { - "question": "How many highly variable genes should be used?", - "description": ( - "Registered focused, standard, and broad feature-set sizes " - "were all executed and compared." - ), - "branches": count_branches, - } - ) - return stages diff --git a/scarf/agent/report/generator.py b/scarf/agent/report/generator.py index 916aa0cf..88cb5542 100644 --- a/scarf/agent/report/generator.py +++ b/scarf/agent/report/generator.py @@ -1,37 +1,34 @@ -"""Top-level local agent report assembly.""" +"""Generate one local analysis summary from the authoritative stage history.""" import os import uuid -from datetime import UTC, datetime +from collections.abc import Mapping from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any -from ...datastore.datastore import DataStore -from ...utils.logging import logger -from ..persistence.reports import load_agent_workflow -from .artifacts import ( - _collect_active_decisions, - _collect_default_feature_inventories, - _collect_history, - _collect_hvg_evidence, - _collect_reports, - _load_completed_result, - _local_root, - _open_datastore, -) -from .contracts import _latest, _mapping, _selected_qc_profile -from .plots import _collect_final_artifacts, _collect_hvg_plots -from .rendering import ( - _render_analysis_document, - _render_technical_document, -) +from .artifacts import _local_root, report_directory, scientific_summary +from .plots import collect_analysis_artifacts +from .rendering import render_analysis_document +if TYPE_CHECKING: + from ...datastore.datastore import DataStore -def _write_report_page(report_dir: Path, filename: str, document: str) -> Path: - destination = report_dir / filename - temporary = report_dir / f".{filename}.{uuid.uuid4().hex}.tmp" + +def render_analysis_report( + store: "DataStore", snapshot: Mapping[str, Any], output_dir: Path +) -> Path: + """Replace derived display files without changing the saved analysis.""" + if snapshot.get("status") != "completed" or not snapshot.get("finalAnalysis"): + raise ValueError("Reports require a completed analysis with final artifacts") + payload = scientific_summary(snapshot) + output_dir.mkdir(parents=True, exist_ok=True) + payload.update( + collect_analysis_artifacts(store, payload["finalAnalysis"], output_dir) + ) + destination = output_dir / "index.html" + temporary = output_dir / f".index.{uuid.uuid4().hex}.tmp" try: - temporary.write_text(document, encoding="utf-8") + temporary.write_text(render_analysis_document(payload), encoding="utf-8") os.replace(temporary, destination) finally: temporary.unlink(missing_ok=True) @@ -39,114 +36,28 @@ def _write_report_page(report_dir: Path, filename: str, document: str) -> Path: def generate_agent_report( - target: str | Path | DataStore, + target: "str | Path | DataStore", workflow_run_id: str, *, workspace: str | None = None, ) -> Path: - """Generate a local HTML report for one completed automated workflow. + """Return one concise local report for a completed analysis. - The returned ``index.html`` opens the analysis and its decisions directly, - with a secondary link to the technical details. - Existing derived report files may be replaced; immutable agent and - orchestration records are only read. + Decisions, their evidence, and numerical artifacts come from the saved + stage history. Regeneration makes no model or scientific computation calls. """ - root = _local_root(target) - resolved_workspace = ( - target.workspace if isinstance(target, DataStore) else workspace - ) - if ( - isinstance(target, DataStore) - and workspace is not None - and workspace != target.workspace - ): - raise ValueError("workspace does not match the DataStore workspace") - workflow = load_agent_workflow( - target, - workflow_run_id, - workspace=resolved_workspace, - ) - store = _open_datastore(target, root, workflow) - prefix, result, request = _load_completed_result(store, workflow) - reports = _collect_reports(store, result) - stage_attempts, resumes = _collect_history(store, prefix, workflow, request) + from ...datastore.datastore import DataStore + from ..orchestrator import journal - active_root = ( - root if workflow.workspace is None else (root / workflow.workspace).resolve() - ) - if not active_root.is_relative_to(root): - raise ValueError("Workflow workspace resolves outside the analysis store") - report_dir = ( - active_root / "agents" / "runs" / workflow_run_id / "report" - ).resolve() - if not report_dir.is_relative_to(active_root): - raise ValueError("Agent report path resolves outside the analysis store") - plot_dir = report_dir / "plots" - report_dir.mkdir(parents=True, exist_ok=True) - preprocessing_plan = ( - result.preprocessingPlan.model_dump(mode="json") - if result.preprocessingPlan is not None - else {} - ) - experimental = _latest(reports, "experimental_context") - selected_qc_profile = _selected_qc_profile( - experimental, - _mapping(preprocessing_plan.get("cellQc")), - ) - cluster_counts, top_markers, plot_files, plot_notes = _collect_final_artifacts( - store, - result, - plot_dir, - qc_profile=selected_qc_profile, - ) - hvg_evidence = _collect_hvg_evidence( - store, - stage_attempts, - preprocessing_plan, - ) - hvg_plots, hvg_plot_notes = _collect_hvg_plots( - store, - stage_attempts, - preprocessing_plan, - plot_dir, - ) - plot_files.update(hvg_plots) - plot_notes.extend(hvg_plot_notes) - active_decisions = _collect_active_decisions(store, workflow_run_id) - default_feature_inventories = _collect_default_feature_inventories( - store, - preprocessing_plan, - ) - payload: dict[str, Any] = { - "status": result.status, - "currentStage": result.currentStage, - "workflowRunId": workflow_run_id, - "generatedAt": datetime.now(UTC).isoformat(), - "request": request.request.model_dump(mode="json"), - "effectiveConfig": request.config.model_dump(mode="json"), - "workflowResult": result.model_dump(mode="json"), - "reports": reports, - "stageAttempts": stage_attempts, - "workflowResumes": resumes, - "clusterCounts": cluster_counts, - "topMarkers": top_markers, - "plotFiles": plot_files, - "plotNotes": plot_notes, - "hvgEvidence": hvg_evidence, - "activeDecisions": active_decisions, - "defaultFeatureInventories": default_feature_inventories, - } - documents = ( - ("technical.html", _render_technical_document(payload)), - ("index.html", _render_analysis_document(payload)), - ) - destination = report_dir / "index.html" - for filename, document in documents: - written = _write_report_page(report_dir, filename, document) - if filename == "index.html": - destination = written - (report_dir / "analysis.html").unlink(missing_ok=True) - logger.info( - f"Generated HTML report for agent workflow {workflow_run_id}: {destination}" + root = _local_root(target) + if isinstance(target, DataStore): + if workspace is not None and workspace != target.workspace: + raise ValueError("workspace does not match the DataStore workspace") + store = target + workspace = store.workspace + else: + store = journal.open_analysis_store(root, workflow_run_id, workspace=workspace) + snapshot = journal.analysis_snapshot(store, workflow_run_id) + return render_analysis_report( + store, snapshot, report_directory(root, workflow_run_id, workspace) ) - return destination diff --git a/scarf/agent/report/plots.py b/scarf/agent/report/plots.py index 1c20cd6f..c2d15cfa 100644 --- a/scarf/agent/report/plots.py +++ b/scarf/agent/report/plots.py @@ -1,49 +1,21 @@ -"""Plot and bounded visual-artifact collection for agent reports.""" +"""One saved map and a compact marker table for the analysis report.""" -import html -import json import os -import re import uuid -from collections import Counter -from collections.abc import Mapping, Sequence +from collections.abc import Mapping from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any -from ...datastore.datastore import DataStore -from ...storage.refs import ArtifactRef -from ...storage.types import as_zarr_array -from ..orchestrator.models import AutomatedWorkflowResult, artifact_model_to_ref -from ..types import ArtifactReferenceModel -from .artifacts import _latest_hvg_diagnostic_artifacts -from .contracts import ( - _analysis_number_range, - _label, - _mapping, - _mappings, - _qc_resolved_bounds, -) +from .._plots import cluster_counts, plot_final_umap +from .artifacts import artifact_ref -MAX_MARKER_DOTPLOT_FEATURES = 24 +if TYPE_CHECKING: + from ...datastore.datastore import DataStore + from ...plotting import PlotResult -CLUSTER_COUNT_BLOCK_SIZE = 100_000 - - -MAX_EMBEDDING_PLOT_CELLS = 250_000 - - -MAX_DOTPLOT_CELLS = 75_000 - - -MAX_CONNECTIVITY_PLOT_CELLS = 100_000 - - -MAX_COMPOSITION_PLOT_CELLS = 1_000_000 - - -def _save_plot(plot: Any, path: Path) -> None: - """Atomically save one plot and its provenance, always closing its figure.""" +def _save_plot(plot: "PlotResult", path: Path) -> None: + """Atomically replace the derived image and its provenance sidecar.""" token = uuid.uuid4().hex temporary = path.with_name(f".{path.stem}.{token}{path.suffix}") sidecar = path.with_suffix(path.suffix + ".json") @@ -59,754 +31,75 @@ def _save_plot(plot: Any, path: Path) -> None: plot.close() -def _safe_assay_name(value: str, fallback: str) -> str: - label = "_".join(part.lower() for part in re.findall(r"[A-Za-z0-9]+", value)) - return label[:64].rstrip("_") or fallback - - -def _annotate_qc_cutoffs(plot: Any, profile: Mapping[str, Any]) -> None: - bounds = _qc_resolved_bounds(profile) - if not bounds: - return - diagnostic_only = profile.get("action") == "skip" - styles = { - "lowerRemoval": ( - "lower diagnostic bound" if diagnostic_only else "lower removal cutoff", - "#d62728", - "--", - ), - "upperRemoval": ( - "upper diagnostic bound" if diagnostic_only else "upper removal cutoff", - "#d62728", - "--", - ), - "upperFlag": ("high-value diagnostic bound", "#ff7f0e", ":"), - } - recorded: list[dict[str, Any]] = [] - for metric, axis in plot.axes.items(): - metric_bounds = [ - bound for bound in bounds if str(bound.get("metric") or "") == str(metric) - ] - original_limits = axis.get_ylim() - visible_low, visible_high = sorted(float(value) for value in original_limits) - has_legend_entry = False - for field, (label, color, linestyle) in styles.items(): - values = sorted( - { - float(bound[field]) - for bound in metric_bounds - if isinstance(bound.get(field), int | float) - and not isinstance(bound.get(field), bool) - } - ) - if not values: - continue - formatted_values = _analysis_number_range(values) - if len(values) == 1: - if visible_low <= values[0] <= visible_high: - axis.axhline( - values[0], - color=color, - linestyle=linestyle, - linewidth=1.2, - label=f"{label}: {formatted_values}", - ) - else: - axis.plot( - [], - [], - color=color, - linestyle=linestyle, - linewidth=1.2, - label=f"{label}: {formatted_values} (outside plot)", - ) - else: - clipped_low = max(values[0], visible_low) - clipped_high = min(values[-1], visible_high) - if clipped_low <= clipped_high: - range_suffix = ( - " (partly outside plot)" - if values[0] < visible_low or values[-1] > visible_high - else "" - ) - axis.axhspan( - clipped_low, - clipped_high, - color=color, - alpha=0.1, - label=f"{label}: {formatted_values}{range_suffix}", - ) - for value in values: - if visible_low <= value <= visible_high: - axis.axhline( - value, - color=color, - linestyle=linestyle, - linewidth=0.8, - ) - else: - axis.plot( - [], - [], - color=color, - linestyle=linestyle, - linewidth=1.2, - label=f"{label}: {formatted_values} (outside plot)", - ) - has_legend_entry = True - recorded.extend( - { - "metric": str(metric), - "field": field, - "group": bound.get("group"), - "value": bound.get(field), - } - for bound in metric_bounds - if isinstance(bound.get(field), int | float) - and not isinstance(bound.get(field), bool) - ) - if has_legend_entry: - axis.legend(frameon=False, fontsize=6.5, loc="upper left") - axis.set_ylim(original_limits) - plot.provenance.extras["qc_cutoffs"] = recorded - plot.provenance.extras["qc_profile"] = profile.get("registeredProfile") - - -def _collect_final_artifacts( - store: DataStore, - result: AutomatedWorkflowResult, - plot_dir: Path, - *, - qc_profile: Mapping[str, Any] | None = None, -) -> tuple[ - dict[str, int], - list[dict[str, Any]], - dict[str, str], - list[str], -]: - """Validate the final handoff and derive bounded tables and plots.""" - import numpy as np - - final = result.finalAnalysis - assert final is not None - if final.cellSelection is None or final.clusters is None or final.umap is None: - raise ValueError("Final handoff lacks its selection, clusters, or UMAP") - - artifact_models = [ - final.cellSelection, - final.graph, - final.clusters, - final.embeddingInitialization, - final.umap, - final.markerFeatures, - final.markers, - ] - for native in final.nativeAnalyses: - artifact_models.extend( - [ - native.featureSelection, - native.markerFeatures, - native.normalized, - native.reduction, - native.batchCorrection, - native.annIndex, - native.embeddingInitialization, - native.neighbors, - native.graph, - native.clusters, - native.umap, - ] - ) - for artifact in artifact_models: - if artifact is not None: - store.load_artifact(artifact_model_to_ref(artifact)) - - cluster_ref = artifact_model_to_ref(final.clusters) - umap_ref = artifact_model_to_ref(final.umap) - cluster_artifact: Any = store.load_artifact(cluster_ref) - values = cluster_artifact["values"] - counts: Counter[str] = Counter() - for start in range(0, int(values.shape[0]), CLUSTER_COUNT_BLOCK_SIZE): - block = np.asarray(values[start : start + CLUSTER_COUNT_BLOCK_SIZE]).astype(str) - block_labels, frequencies = np.unique(block, return_counts=True) - counts.update( - { - str(label): int(frequency) - for label, frequency in zip(block_labels, frequencies, strict=True) - } - ) - cluster_counts = dict(sorted(counts.items())) - cluster_labels = list(cluster_counts) - n_cells = sum(cluster_counts.values()) - plots: dict[str, str] = {} +def collect_analysis_artifacts( + store: "DataStore", final: Mapping[str, Any], report_dir: Path +) -> dict[str, Any]: + """Read final numerical artifacts without launching any analysis operation.""" + clusters = artifact_ref(final.get("clusters")) notes: list[str] = [] - plot_dir.mkdir(parents=True, exist_ok=True) - - def render_plot(name: str, filename: str, create: Any) -> None: - try: - path = plot_dir / filename - _save_plot(create(), path) - plots[name] = f"plots/{filename}" - except Exception as exc: - notes.append(f"{name}: {type(exc).__name__}: {exc}") - - if n_cells <= MAX_EMBEDDING_PLOT_CELLS: - render_plot( - "umapClusters", - "final_umap.png", - lambda: store.plots.embedding( - layout=umap_ref, - color_by=cluster_ref, - show=False, - ), - ) - else: - notes.append( - "umapClusters: skipped because the final selection has " - f"{n_cells:,} cells, above the memory-safe report limit of " - f"{MAX_EMBEDDING_PLOT_CELLS:,}" - ) - - observed_native_names: set[str] = set() - for index, native in enumerate(final.nativeAnalyses): - if native.umap is None or native.clusters is None: - continue - native_umap = artifact_model_to_ref(native.umap) - native_clusters = artifact_model_to_ref(native.clusters) - if native_umap == umap_ref and native_clusters == cluster_ref: - continue - suffix = _safe_assay_name(native.assay, f"assay_{index + 1}") - base_name = "nativeUmap" + "".join( - part.capitalize() for part in suffix.split("_") - ) - plot_name = base_name - serial = 1 - while plot_name in observed_native_names: - serial += 1 - plot_name = f"{base_name}{serial}" - observed_native_names.add(plot_name) - file_suffix = suffix if serial == 1 else f"{suffix}_{serial}" - if n_cells <= MAX_EMBEDDING_PLOT_CELLS: - render_plot( - plot_name, - f"native_umap_{file_suffix}.png", - lambda layout=native_umap, color=native_clusters: store.plots.embedding( - layout=layout, color_by=color, show=False - ), - ) - else: - notes.append( - f"{plot_name}: skipped because {n_cells:,} cells exceed the " - f"memory-safe report limit of {MAX_EMBEDDING_PLOT_CELLS:,}" - ) - - if n_cells <= MAX_COMPOSITION_PLOT_CELLS: - render_plot( - "clusterComposition", - "cluster_composition.png", - lambda: store.plots.composition( - categories=cluster_ref, - show_percent_labels=len(cluster_labels) <= 12, - show=False, - ), - ) - else: - notes.append( - "clusterComposition: skipped because the final selection has " - f"{n_cells:,} cells, above the memory-safe report limit of " - f"{MAX_COMPOSITION_PLOT_CELLS:,}" - ) - - qc_attributes = ( - list(result.preprocessingPlan.cellQc.attributes) - if result.preprocessingPlan is not None - else [] - ) - available_qc_attributes = [ - value for value in qc_attributes if value in store.cells.columns - ] - artifact_qc_metrics = ( - [ - artifact_model_to_ref(value.artifact) - for value in result.preprocessingPlan.cellQc.artifactMetrics - ] - if result.preprocessingPlan is not None - else [] - ) - available_qc_attributes = available_qc_attributes[:4] - - def qc_distribution(selection: Any) -> Any: - plot = store.plots.distribution( - keys=available_qc_attributes, - cell_selection=artifact_model_to_ref(selection), - kind="violin", - max_points=10_000, + plot_path: str | None = None + displayed: int | None = None + counts: dict[str, int] + try: + plot = plot_final_umap( + store, + umap=artifact_ref(final.get("umap")), + clusters=clusters, + cell_selection=artifact_ref(final.get("cellSelection")), + graph=artifact_ref(final.get("graph")), show=False, ) - if qc_profile: - _annotate_qc_cutoffs(plot, qc_profile) - return plot - - active_cells = qc_profile.get("activeCells") if qc_profile else None - retained_cells = qc_profile.get("retainedCells") if qc_profile else None - if ( - available_qc_attributes - and result.preprocessingPlan is not None - and result.preprocessingPlan.cellSelection is not None - and isinstance(active_cells, int) - and isinstance(retained_cells, int) - and retained_cells != active_cells - ): - render_plot( - "qcDistributionsBeforeFiltering", - "qc_distributions_before_filtering.png", - lambda: qc_distribution(result.preprocessingPlan.cellSelection), - ) - if available_qc_attributes: - render_plot( - "qcDistributions", - "qc_distributions.png", - lambda: qc_distribution(final.cellSelection), - ) - remaining_qc_plots = max(0, 4 - len(available_qc_attributes)) - for index, metric in enumerate(artifact_qc_metrics[:remaining_qc_plots]): - render_plot( - f"qcDistributionDerived{index + 1}", - f"qc_distribution_derived_{index + 1}.png", - lambda source=metric: store.plots.distribution( - keys=source, - kind="violin", - max_points=10_000, - show=False, - ), - ) - - for index, score_model in enumerate(final.doubletScores[:4]): - score_ref = artifact_model_to_ref(score_model) - render_plot( - f"doubletDistribution{index + 1}", - f"doublet_distribution_{index + 1}.png", - lambda score=score_ref: store.plots.distribution( - keys=score, - kind="hist", - bins=40, - show=False, - ), - ) - if index == 0 and n_cells <= MAX_EMBEDDING_PLOT_CELLS: - render_plot( - "doubletEmbedding", - "doublet_embedding.png", - lambda score=score_ref: store.plots.embedding( - layout=umap_ref, - color_by=score, - show=False, - ), - ) - - top_markers: list[dict[str, Any]] = [] - if final.markers is not None: - marker_ref = artifact_model_to_ref(final.markers) - marker_parameters = store.inspect_artifact(marker_ref).parameters or {} - raw_normalization = marker_parameters.get("normalization", {}) - marker_normalization = ( - dict(raw_normalization) if isinstance(raw_normalization, Mapping) else {} - ) - marker_log_transform = marker_normalization.get("log_transform", False) is True - if marker_normalization.get("renormalize_subset", False) is True: - notes.append( - "marker visualizations: the persisted marker search renormalized " - "its feature subset; current plotting APIs preserve its log " - "transform but visualize assay-wide normalized values" - ) - for label in cluster_labels: + counts = dict( + zip( + plot.tables["cluster_counts"]["cluster"], + plot.tables["cluster_counts"]["cells"], + strict=True, + ) + ) + displayed = plot.provenance.n_cells + plot_dir = report_dir / "plots" + plot_dir.mkdir(parents=True, exist_ok=True) + _save_plot(plot, plot_dir / "final_umap.png") + plot_path = "plots/final_umap.png" + except (ImportError, OSError, RuntimeError) as exc: + counts = cluster_counts(store, clusters) + notes.append(f"UMAP display unavailable: {type(exc).__name__}: {exc}") + marker_rows: list[dict[str, Any]] = [] + if final.get("markers") is not None: + marker = artifact_ref(final["markers"]) + marker_inputs = store.inspect_artifact(marker).inputs or {} + if marker_inputs.get("clusters") != clusters.to_dict(): + raise ValueError("Final marker statistics must use the selected clusters") + if ( + marker_inputs.get("cell_selection") + != artifact_ref(final.get("cellSelection")).to_dict() + ): + raise ValueError("Final markers must use the selected cells") + if marker.assay != clusters.assay: + raise ValueError("Final markers must use the selected RNA assay") + for cluster in counts: try: - table = store.get_markers( - marker_ref, - group_id=label, - min_score=-1, - min_frac_exp=-1, - ) - if not table.empty: - if "score" in table: - table = table.sort_values( - "score", ascending=False, kind="stable" - ) - top_markers.extend( - json.loads(table.head(5).to_json(orient="records")) + table = store.get_markers(marker, group_id=cluster) + if "score" in table: + table = table.sort_values("score", ascending=False, kind="stable") + for row in table.head(3).to_dict(orient="records"): + marker_rows.append( + { + "cluster": cluster, + "feature": row.get( + "feature_name", row.get("feature_id", "") + ), + "score": row.get("score"), + } ) except Exception as exc: notes.append( - f"marker export for cluster {label}: {type(exc).__name__}: {exc}" - ) - - render_plot( - "markerHeatmap", - "marker_heatmap.png", - lambda: store.plots.marker_heatmap( - marker=marker_ref, - log_transform=marker_log_transform, - show=False, - ), - ) - - try: - from ...plotting import FeatureRef, NormalizationSpec - - by_cluster: dict[str, list[tuple[tuple[str, str], Any]]] = { - label: [] for label in cluster_labels - } - for marker in top_markers: - group_id = str(marker.get("group_id", "")) - if group_id not in by_cluster: - continue - feature_name = marker.get("feature_name") - feature_id = marker.get("feature_id") - feature_index = marker.get("feature_index") - label = str(feature_name or feature_id or feature_index or "") - if isinstance(feature_index, (int, float)): - identity = ("index", str(int(feature_index))) - feature = FeatureRef( - value=int(feature_index), - assay=final.markerAssay, - by="index", - label=label, - ) - elif isinstance(feature_id, str) and feature_id: - identity = ("id", feature_id) - feature = FeatureRef( - value=feature_id, - assay=final.markerAssay, - by="id", - label=label, - ) - else: - continue - if all(observed != identity for observed, _ in by_cluster[group_id]): - by_cluster[group_id].append((identity, feature)) - - marker_groups: dict[str, list[Any]] = {} - selected: set[tuple[str, str]] = set() - max_rank = max(map(len, by_cluster.values()), default=0) - rank = 0 - while rank < max_rank and len(selected) < MAX_MARKER_DOTPLOT_FEATURES: - for cluster in cluster_labels: - features = by_cluster[cluster] - if rank >= len(features): - continue - identity, feature = features[rank] - if identity in selected: - continue - marker_groups.setdefault(f"Cluster {cluster}", []).append(feature) - selected.add(identity) - if len(selected) == MAX_MARKER_DOTPLOT_FEATURES: - break - rank += 1 - if marker_groups and n_cells <= MAX_DOTPLOT_CELLS: - render_plot( - "markerDotplot", - "marker_dotplot.png", - lambda: store.plots.dotplot( - features=marker_groups, - groups=cluster_ref, - from_assay=final.markerAssay, - normalization=NormalizationSpec( - source="assay", - transform=("log1p" if marker_log_transform else "none"), - ), - standardize="feature", - show=False, - ), - ) - elif marker_groups: - notes.append( - "markerDotplot: skipped because the final selection has " - f"{n_cells:,} cells, above the memory-safe report limit of " - f"{MAX_DOTPLOT_CELLS:,}" - ) - except Exception as exc: - notes.append(f"markerDotplot: {type(exc).__name__}: {exc}") - - if final.graph is not None: - graph_ref = artifact_model_to_ref(final.graph) - if n_cells <= MAX_CONNECTIVITY_PLOT_CELLS: - render_plot( - "clusterConnectivity", - "cluster_connectivity.png", - lambda: store.plots.cluster_connectivity( - groups=cluster_ref, - layout=umap_ref, - graph=graph_ref, - show=False, - ), - ) - else: - notes.append( - "clusterConnectivity: skipped because the final selection has " - f"{n_cells:,} cells, above the memory-safe report limit of " - f"{MAX_CONNECTIVITY_PLOT_CELLS:,}" - ) - return cluster_counts, top_markers, plots, notes - - -def _collect_hvg_plots( - store: DataStore, - stage_attempts: Sequence[Mapping[str, Any]], - preprocessing_plan: Mapping[str, Any], - plot_dir: Path, -) -> tuple[dict[str, str], list[str]]: - import numpy as np - - selected_name, selected_reference, artifacts = _latest_hvg_diagnostic_artifacts( - stage_attempts - ) - if not selected_reference: - return {}, [] - assay_name = selected_name.removesuffix("_hvg_diagnostic") - assay_plan = next( - ( - value - for value in _mappings(preprocessing_plan.get("assays")) - if value.get("assay") == assay_name - ), - {}, - ) - selected_count = _mapping(assay_plan.get("featureParameters")).get("topN") - if not isinstance(selected_count, int) or isinstance(selected_count, bool): - return {}, ["HVG diagnostics: selected feature count is unavailable"] - - references = ( - ("global", artifacts.get(f"{assay_name}_hvg_global_diagnostic")), - ("batchAware", artifacts.get(f"{assay_name}_hvg_batchAware_diagnostic")), - ) - plots: dict[str, str] = {} - notes: list[str] = [] - seen_artifact_ids: set[str] = set() - plot_dir.mkdir(parents=True, exist_ok=True) - for ranking_mode, raw_reference in references: - if not isinstance(raw_reference, Mapping): - continue - model = ArtifactReferenceModel.model_validate(dict(raw_reference)) - if model.artifactId in seen_artifact_ids: - continue - seen_artifact_ids.add(model.artifactId) - plot_name = "hvgGlobal" if ranking_mode == "global" else "hvgBatchAware" - filename = ( - "hvg_global.png" if ranking_mode == "global" else "hvg_batch_aware.png" - ) - try: - diagnostic_ref = artifact_model_to_ref(model) - diagnostic = store.load_artifact(diagnostic_ref) - observed_mode = diagnostic.attrs.get("ranking_mode") - if observed_mode != ranking_mode: - raise ValueError( - f"HVG diagnostic expected {ranking_mode!r}, got {observed_mode!r}" + f"Markers unavailable for cluster {cluster}: {type(exc).__name__}: {exc}" ) - status = store.inspect_artifact(diagnostic_ref) - raw_summary = (status.inputs or {}).get("global_feature_summary") - if not isinstance(raw_summary, Mapping): - raise ValueError("HVG diagnostic lacks its global feature summary") - summary_ref = ArtifactRef.from_dict(dict(raw_summary)) - summary = store.load_artifact(summary_ref) - corrected_variance = np.asarray( - as_zarr_array( - diagnostic["global_corrected_variance"], - name="global_corrected_variance", - )[:], - dtype=np.float64, - ) - ranking = np.asarray( - as_zarr_array(diagnostic["ranking"], name="ranking")[:], - dtype=np.int64, - ) - normed_tot = np.asarray( - as_zarr_array(summary["normed_tot"], name="normed_tot")[:], - dtype=np.float64, - ) - normed_n = np.asarray( - as_zarr_array(summary["normed_n"], name="normed_n")[:], - dtype=np.float64, - ) - shape = corrected_variance.shape - if ( - corrected_variance.ndim != 1 - or normed_tot.shape != shape - or normed_n.shape != shape - or selected_count > ranking.size - or ranking.size - and (int(ranking.min()) < 0 or int(ranking.max()) >= shape[0]) - or np.unique(ranking).size != ranking.size - ): - raise ValueError("HVG plotting arrays are malformed") - selected = np.zeros(shape, dtype=bool) - selected[ranking[:selected_count]] = True - mean_nonzero = np.divide( - normed_tot, - normed_n, - out=np.zeros_like(normed_tot), - where=normed_n != 0, - ) - from ...plotting import highly_variable_features - - plot = highly_variable_features( - mean_nonzero=mean_nonzero, - corrected_variance=corrected_variance, - n_cells=normed_n, - selected=selected, - show=False, - ) - plot.axes["highly_variable_features"].set_title( - f"{_hvg_ranking_label(ranking_mode)}\n{selected_count:,} selected genes" - ) - plot.provenance.extras.update( - { - "assay": assay_name, - "diagnostic_artifact_id": model.artifactId, - "ranking_mode": ranking_mode, - "selected_feature_count": selected_count, - } - ) - _save_plot(plot, plot_dir / filename) - plots[plot_name] = f"plots/{filename}" - except Exception as exc: - notes.append(f"{plot_name}: {type(exc).__name__}: {exc}") - return plots, notes - - -def _render_plots( - plots: Mapping[str, str], - notes: Sequence[str], - *, - order: Sequence[str] | None = None, - titles: Mapping[str, tuple[str, str]] | None = None, - show_provenance: bool = True, - show_notes: bool = True, - empty_message: str | None = None, -) -> str: - plot_titles = { - "umapClusters": ( - "Final UMAP by cluster", - "The selected final representation, colored by final cluster.", - ), - "markerHeatmap": ( - "Marker heatmap", - "Marker-feature patterns across the final clusters.", - ), - "markerDotplot": ( - "Marker dot plot", - "A bounded expression summary for exact exported marker features.", - ), - "clusterComposition": ( - "Cluster composition", - "The relative size of each cluster in the final cell selection.", - ), - "clusterConnectivity": ( - "Cluster connectivity", - "Connectivity between final clusters in the selected graph.", - ), - "qcDistributions": ( - "QC distributions after the selected policy", - "Retained-cell distributions with the selected profile's cutoff annotations.", - ), - "qcDistributionsBeforeFiltering": ( - "QC distributions before filtering", - "Input-cell distributions with the selected profile's cutoff annotations.", - ), - "hvgGlobal": ( - "Global HVG diagnostic", - "Mean-variance evidence with genes selected by the global ranking highlighted.", - ), - "hvgBatchAware": ( - "Group-aware HVG diagnostic", - "Mean-variance evidence with genes selected for recurrence across technical groups highlighted.", - ), - "doubletEmbedding": ( - "Advisory doublet scores", - "The final embedding colored by non-removing doublet evidence.", - ), - } - if titles is not None: - plot_titles.update(titles) - plot_order = ( - list(order) - if order is not None - else [ - "umapClusters", - *(name for name in plots if name.startswith("nativeUmap")), - "markerHeatmap", - "markerDotplot", - "clusterComposition", - "clusterConnectivity", - "qcDistributionsBeforeFiltering", - "qcDistributions", - *(name for name in plots if name.startswith("qcDistributionDerived")), - "hvgGlobal", - "hvgBatchAware", - "doubletEmbedding", - *(name for name in plots if name.startswith("doubletDistribution")), - *plots, - ] - ) - figures: list[str] = [] - for name in dict.fromkeys(plot_order): - source = plots.get(name) - if source is None: - continue - if name.startswith("nativeUmap"): - assay = name.removeprefix("nativeUmap") or "assay" - title = f"{assay} native UMAP" - caption = f"The finalized native {assay} representation and clusters." - elif name.startswith("doubletDistribution"): - title = "Advisory doublet-score distribution" - caption = ( - "Capture-aware doublet evidence retained as flags without removal." - ) - elif name.startswith("qcDistributionDerived"): - title = "Derived QC metric distribution" - caption = "An immutable feature-family QC metric on the selected cell axis." - else: - title, caption = plot_titles.get( - name, (_label(name), "A finalized Scarf analysis plot.") - ) - escaped_source = html.escape(source, quote=True) - plot_class = ' class="primary"' if name == "umapClusters" else "" - provenance_markup = "" - if show_provenance: - provenance = html.escape(source + ".json", quote=True) - provenance_markup = f' Plot provenance' - figures.append( - f"" - f'' - f"
    {html.escape(title)}
    " - f"{html.escape(caption)}{provenance_markup}
    " - ) - if not figures: - if empty_message is None: - plot_markup = ( - '

    No plots could be rendered. The structured ' - "analysis remains available below. Install Scarf with the " - "extra dependency group to enable plotting.

    " - ) - else: - plot_markup = ( - f'

    {html.escape(empty_message)}

    ' - ) - else: - plot_markup = f'
    {"".join(figures)}
    ' - note_markup = "" - if show_notes and notes: - note_markup = ( - "
    Plot availability notes" - '
      ' - + "".join(f"
    • {html.escape(note)}
    • " for note in notes) - + "
    " - ) - return plot_markup + note_markup - - -def _hvg_ranking_label(value: Any) -> str: return { - "global": "Global variability ranking", - "batchAware": "Group-aware variability ranking", - }.get(str(value or ""), "Variable-gene ranking") + "clusterCounts": counts, + "markers": marker_rows, + "umap": plot_path, + "displayedCells": displayed, + "displayNotes": notes, + } diff --git a/scarf/agent/report/rendering.py b/scarf/agent/report/rendering.py index 8b2c703b..cbeb92bb 100644 --- a/scarf/agent/report/rendering.py +++ b/scarf/agent/report/rendering.py @@ -1,3028 +1,280 @@ -"""HTML sections and templates for agent reports.""" +"""A single readable analysis page, using only recorded scientific evidence.""" import html -import json -from collections import Counter from collections.abc import Mapping, Sequence from typing import Any -from ... import __version__ -from .artifacts import _default_inventory_for_assay -from .contracts import ( - _analysis_number_range, - _analysis_percent, - _assay_label, - _brief_text, - _feature_family_label, - _format_text_list, - _is_leaf, - _is_mapping_sequence, - _is_sequence, - _is_simple, - _label, - _latest, - _mapping, - _mappings, - _present, - _public_field_label, - _qc_resolved_bounds, - _scalar, - _selected_qc_profile, - _specific_references, - _text_values, -) -from .decision_tree import ( - _active_decision, - _analysis_tree_stages, - _degraded_protected_columns, - _harmony_candidate_pair, - _harmony_completed, - _harmony_metric_rows, - _qc_profile_label, - _render_decision_tree, - _score_transition, - _selected_parameter_context, -) -from .plots import _hvg_ranking_label, _render_plots - -MAX_CHIP_LENGTH = 56 - - -MAX_TABLE_COLUMNS = 7 - - -MAX_INLINE_LEAVES = 12 - - -REPORT_STYLES = """ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400&display=swap'); - -:root { - --blue: #0077fc; - --black: #000000; - --gray: #b4b4b4; - --white: #ffffff; -} - -* { box-sizing: border-box; } -html { background: var(--white); color: var(--black); font-family: Inter, sans-serif; } -body { - margin: 0; - overflow-x: hidden; - background: var(--white); - color: var(--black); - font-family: Inter, sans-serif; - font-weight: 300; - letter-spacing: -0.04em; - line-height: 1.45; - overflow-wrap: break-word; - word-break: normal; -} -a { color: var(--blue); } -header, main, footer { - width: min(100%, 1240px); - max-width: 100%; - margin: 0 auto; - padding-left: clamp(1.25rem, 5vw, 4.5rem); - padding-right: clamp(1.25rem, 5vw, 4.5rem); -} -.technical-page header, .technical-page main, .technical-page footer { - width: min(100%, 1800px); -} -header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - border-bottom: 1px solid var(--black); - padding-top: 1.75rem; - padding-bottom: 1.75rem; -} -.brand { - color: var(--black); - font-size: 1rem; - font-weight: 400; - text-decoration: none; -} -main { padding-top: clamp(3rem, 8vw, 7rem); padding-bottom: 6rem; } -footer { - border-top: 1px solid var(--black); - padding-top: 2rem; - padding-bottom: 2rem; -} -h1, h2, h3, p { margin-top: 0; } -h1 { - max-width: 15ch; - margin-bottom: 1.5rem; - font-size: clamp(2.75rem, 7vw, 5rem); - font-weight: 400; - letter-spacing: 0; - line-height: 1.2; -} -h2 { - margin-bottom: 1.5rem; - font-size: clamp(1.65rem, 3vw, 2.25rem); - font-weight: 400; - letter-spacing: -0.04em; - line-height: 1.2; -} -h3 { - margin-bottom: .8rem; - font-size: 1rem; - font-weight: 300; - letter-spacing: -0.04em; - line-height: 1.2; -} -p, li, td, th, summary, code, pre, a, dd, dt { - font-family: Inter, sans-serif; - letter-spacing: -0.04em; - line-height: 1.45; -} -strong { font-weight: 400; } -.eyebrow { - margin-bottom: 1rem; - color: var(--gray); - font-size: .75rem; - font-weight: 400; - text-transform: uppercase; -} -.lead { - max-width: 48ch; - font-size: clamp(1.2rem, 2vw, 1.7rem); - font-weight: 300; -} -.report-nav { - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - gap: .35rem; -} -.report-nav a { - border-radius: .35rem; - padding: .4rem .65rem; - color: var(--black); - font-size: .8rem; - font-weight: 400; - text-decoration: none; -} -.report-nav a[aria-current="page"] { - box-shadow: inset 0 0 0 1px var(--blue); - color: var(--blue); -} -.pill-row, .chip-row, .metric-grid, .kv-list { - display: flex; - flex-wrap: wrap; - gap: .65rem; - min-width: 0; - max-width: 100%; -} -.pill-row { margin-top: 1.75rem; } -.pill, .chip { - display: inline-flex; - max-width: 100%; - font-size: .82rem; - font-weight: 400; - line-height: 1.35; - overflow-wrap: anywhere; - word-break: normal; -} -.pill { - align-items: center; - border: 1px solid var(--blue); - border-radius: 999px; - padding: .68rem 1.1rem; - background: var(--blue); - color: var(--white); - text-decoration: none; - white-space: nowrap; -} -.pill-outline { - background: var(--white); - box-shadow: inset 0 0 0 1px var(--blue); - color: var(--blue); -} -.chip { - display: inline-block; - border-radius: .35rem; - box-shadow: inset 0 0 0 1px var(--blue); - padding: .4rem .7rem; - color: var(--black); - white-space: normal; - overflow: visible; -} -.text-item { - display: block; - min-width: 0; - max-width: 100%; - overflow-wrap: anywhere; - word-break: normal; -} -.metric-grid { margin-top: 2rem; } -.metric { - display: flex; - min-width: 0; - max-width: 100%; - flex: 1 1 9rem; - flex-direction: column; - gap: .2rem; - border-radius: 1.5rem; - box-shadow: inset 0 0 0 1px var(--blue); - padding: .8rem 1.2rem; -} -.metric-label { - color: var(--gray); - font-size: .68rem; - font-weight: 400; - text-transform: uppercase; -} -.metric-value { font-size: .95rem; font-weight: 400; overflow-wrap: anywhere; } -.summary-grid, .interpretation-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr)); - gap: 1rem; - min-width: 0; - max-width: 100%; -} -.summary-card, .interpretation-card { - min-width: 0; - border: 1px solid var(--black); - padding: 1.25rem; -} -.summary-card p:last-child, .interpretation-card p:last-child { margin-bottom: 0; } -.summary-label { - margin-bottom: .5rem; - color: var(--gray); - font-size: .72rem; - font-weight: 400; - text-transform: uppercase; -} -.decision-tree { - min-width: 0; - max-width: 100%; - margin-top: 2rem; -} -.tree-stage { - min-width: 0; - max-width: 100%; - margin: 0; - border: 0; - padding: 0; -} -.tree-question { - display: flex; - width: min(100%, 19rem); - min-height: 8rem; - align-items: center; - justify-content: center; - margin: 0 auto; - clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); - flex-direction: column; - padding: 1.75rem 3rem; - background: var(--blue); - color: var(--white); - text-align: center; -} -.tree-question span { - margin-bottom: .35rem; - font-size: .68rem; - font-weight: 400; - text-transform: uppercase; -} -.tree-question strong { - font-size: .9rem; - line-height: 1.25; -} -.tree-stage-description { - max-width: 42rem; - margin: 1rem auto 0; - color: var(--gray); - text-align: center; -} -.tree-branch-connectors, .tree-selection-connector { - display: block; - width: 100%; - height: 5.25rem; - color: var(--blue); -} -.tree-branch-connectors path, .tree-selection-connector path { - fill: none; - stroke: currentColor; - stroke-width: 1.5; - vector-effect: non-scaling-stroke; -} -.tree-branch-connectors marker path, .tree-selection-connector marker path { - fill: currentColor; - stroke: none; -} -.tree-branches { - display: grid; - grid-template-columns: repeat(var(--branch-count), minmax(0, 1fr)); - gap: .75rem; - min-width: 0; - max-width: 100%; -} -.tree-branch { - min-width: 0; - border: 1px solid var(--gray); - padding: 1rem; - background: var(--white); -} -.tree-branch-selected { - border: 2px solid var(--blue); - box-shadow: inset 0 .25rem 0 var(--blue); -} -.tree-branch-blocked { - border-style: dashed; -} -.tree-branch-status { - display: inline-block; - margin-bottom: .65rem; - border-radius: .3rem; - box-shadow: inset 0 0 0 1px var(--gray); - padding: .25rem .45rem; - color: var(--gray); - font-size: .68rem; - font-weight: 400; - text-transform: uppercase; -} -.tree-branch-selected .tree-branch-status { - box-shadow: inset 0 0 0 1px var(--blue); - color: var(--blue); -} -.tree-branch h3 { margin-bottom: .65rem; font-weight: 400; } -.tree-branch p { margin-bottom: 0; font-size: .82rem; } -.tree-metrics { - margin: 0 0 .8rem; - padding-left: 1rem; - font-size: .76rem; -} -.tree-metrics li { margin: .25rem 0; } -.evidence-accordion { - display: flex; - flex-direction: column; - gap: .8rem; - margin-top: 1.5rem; -} -.evidence-panel { - margin: 0; - border: 1px solid var(--black); - padding: 0; -} -.evidence-panel > summary { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 1rem; - align-items: center; - padding: 1.15rem 1.25rem; - list-style: none; -} -.evidence-panel > summary::-webkit-details-marker { display: none; } -.evidence-panel > summary::after { - color: var(--blue); - content: "+"; - font-size: 1.4rem; - line-height: 1; -} -.evidence-panel[open] > summary::after { content: "−"; } -.evidence-panel-title { - display: block; - margin-bottom: .25rem; - color: var(--gray); - font-size: .7rem; - font-weight: 400; - text-transform: uppercase; -} -.evidence-panel-outcome { - display: block; - font-size: .95rem; - font-weight: 400; -} -.evidence-panel-body { - border-top: 1px solid var(--black); - padding: 1.25rem; -} -.evidence-panel-body > p:first-child { max-width: 55rem; } -.evidence-choice-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr)); - gap: .75rem; - margin-top: 1rem; -} -.evidence-choice { - min-width: 0; - border: 1px solid var(--gray); - padding: 1rem; -} -.evidence-choice-selected { - border: 2px solid var(--blue); - box-shadow: inset 0 .2rem 0 var(--blue); -} -.evidence-choice-rejected { border-style: dashed; } -.evidence-choice-status { - display: inline-block; - margin-bottom: .55rem; - color: var(--gray); - font-size: .68rem; - font-weight: 400; - text-transform: uppercase; -} -.evidence-choice-selected .evidence-choice-status { color: var(--blue); } -.evidence-choice h3 { margin-bottom: .55rem; font-weight: 400; } -.evidence-choice p:last-child { margin-bottom: 0; } -.evidence-choice .plain-list { - margin-bottom: .75rem; - font-size: .8rem; -} -.evidence-measurements { - margin-top: 1.25rem; - border: 0; - border-top: 1px solid var(--gray); - padding-top: .8rem; -} -.evidence-measurements > summary { - color: var(--blue); - font-size: .82rem; -} -.evidence-measurements-body { padding-top: 1rem; } -.evidence-measurement-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr)); - gap: .75rem; -} -.evidence-measurement { - min-width: 0; - border-bottom: 1px solid var(--gray); - padding: .7rem 0; -} -.evidence-measurement dt { - margin-bottom: .35rem; - color: var(--gray); -} -.evidence-measurement dd { font-size: .86rem; } -.evidence-measurement small { - display: block; - margin-top: .35rem; - color: var(--gray); - font-size: .72rem; -} -.plain-list { margin: 0; padding-left: 1.2rem; } -.plain-list li { margin: .55rem 0; } -.column-list { - columns: 4 12rem; - column-gap: 2rem; -} -.column-list li { - break-inside: avoid; - margin: .25rem 0; -} -.section { - margin-top: 4rem; - min-width: 0; - max-width: 100%; - border-top: 1px solid var(--black); - padding-top: 1.5rem; -} -.section:target { scroll-margin-top: 1rem; } -.section-heading { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 1rem; - align-items: start; -} -.subsection { margin-top: 2rem; min-width: 0; max-width: 100%; } -.card-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr)); - gap: 1rem; - min-width: 0; - max-width: 100%; -} -.card, .callout, .record { - min-width: 0; - max-width: 100%; - border: 1px solid var(--black); - padding: 1.25rem; - background: var(--white); - overflow: visible; -} -.callout { border-color: var(--blue); } -.record-stack { - display: flex; - flex-direction: column; - gap: 1rem; - min-width: 0; - max-width: 100%; -} -.product-callout { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 1rem; - align-items: center; - margin-top: 2.5rem; - border-radius: 1.5rem; - box-shadow: inset 0 0 0 1px var(--blue); - padding: 1.4rem; -} -.product-callout p { margin-bottom: 0; max-width: 55rem; } -.empty { color: var(--gray); font-style: italic; } -.table-wrap { - width: 100%; - max-width: 100%; - overflow: visible; -} -table { - width: 100%; - table-layout: auto; - border-collapse: collapse; - font-size: .86rem; -} -th, td { - min-width: 0; - width: auto; - border-bottom: 1px solid var(--black); - padding: .8rem .7rem; - text-align: left; - vertical-align: top; - overflow-wrap: break-word; - word-break: normal; - hyphens: auto; -} -th { - position: sticky; - top: 0; - background: var(--white); - color: var(--gray); - font-weight: 400; - overflow-wrap: normal; - text-transform: uppercase; -} -td { font-weight: 300; overflow-wrap: anywhere; } -td > * { max-width: 100%; } -tr.selected { box-shadow: inset 4px 0 0 var(--blue); } -.table-records { gap: 1.25rem; } -.table-record { - border-color: var(--gray); - padding: 1rem; -} -.table-record-selected { - border: 2px solid var(--blue); - box-shadow: inset .25rem 0 0 var(--blue); -} -.record-fields { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr)); - gap: .9rem 1.25rem; -} -.record-field { - min-width: 0; - border-bottom: 1px solid var(--gray); - padding-bottom: .65rem; -} -.record-field-wide { grid-column: 1 / -1; } -.record-field dt { margin-bottom: .3rem; } -.record-field dd { - overflow-wrap: anywhere; - word-break: normal; -} -dl { margin: 0; min-width: 0; max-width: 100%; } -.details > div { - display: grid; - grid-template-columns: minmax(0, 12rem) minmax(0, 1fr); - gap: 1rem; - min-width: 0; - border-bottom: 1px solid var(--gray); - padding: .55rem 0; -} -.details .details > div { - grid-template-columns: minmax(0, 1fr); - gap: .2rem; -} -dt { color: var(--gray); font-size: .78rem; font-weight: 400; text-transform: uppercase; } -dd { min-width: 0; margin: 0; overflow-wrap: anywhere; } -.kv { display: inline-flex; flex-wrap: wrap; gap: .25rem .4rem; min-width: 0; max-width: 100%; } -.kv-k { - color: var(--gray); - font-size: .72rem; - font-weight: 400; - text-transform: uppercase; -} -.kv-v { overflow-wrap: anywhere; word-break: normal; } -.nested-records { - display: block; - margin: .15rem 0; - border: 0; - padding: 0; - min-width: 0; - max-width: 100%; - overflow: visible; -} -.nested-records > summary { color: var(--blue); font-size: .82rem; } -.nested-records .table-wrap, .nested-records .record-stack { margin-top: .55rem; } -.plot-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(100%, 26rem), 1fr)); - gap: 2rem; - min-width: 0; -} -figure { margin: 0; min-width: 0; } -figure.primary { grid-column: 1 / -1; } -figure img { display: block; width: 100%; height: auto; border: 1px solid var(--black); } -figcaption { margin-top: .7rem; color: var(--black); font-size: .85rem; } -.cluster-row { - display: grid; - grid-template-columns: minmax(0, 8rem) minmax(0, 1fr) auto; - gap: .7rem; - align-items: center; - margin: .5rem 0; - min-width: 0; -} -.cluster-track { height: .7rem; border-radius: 999px; background: var(--gray); overflow: hidden; } -.cluster-fill { height: 100%; border-radius: 999px; background: var(--blue); } -.text-list { padding-left: 1.2rem; } -.text-list li { margin: .45rem 0; } -details { margin-top: 1rem; border-top: 1px solid var(--gray); padding-top: .8rem; } -summary { cursor: pointer; font-weight: 400; } -pre { - max-height: 36rem; - max-width: 100%; - overflow: auto; - background: var(--white); - box-shadow: inset 0 0 0 1px var(--blue); - padding: 1rem; - font-size: .76rem; - white-space: pre-wrap; - word-break: break-word; -} -@media (max-width: 900px) { - .tree-branches { grid-template-columns: 1fr; } - .tree-branch-connectors, .tree-selection-connector { display: none; } - .tree-question { margin-bottom: 2.5rem; } - .tree-stage:not(:last-child)::after { - display: block; - margin: .25rem 0 2rem; - color: var(--blue); - content: "↓"; - font-size: 1.5rem; - text-align: center; - } - .tree-branch { - position: relative; - margin-bottom: 1.5rem; - } - .tree-branch::before { - position: absolute; - top: -1.65rem; - left: 50%; - color: var(--blue); - content: "↓"; - } -} -@media (max-width: 680px) { - header { align-items: flex-start; flex-direction: column; } - .report-nav { justify-content: flex-start; } - .section-heading, .product-callout { grid-template-columns: 1fr; } - .details > div { grid-template-columns: 1fr; gap: .25rem; } - .cluster-row { grid-template-columns: minmax(0, 1fr) auto; } -} +from .contracts import label, mapping, mappings, scalar, texts + + +_STYLES = """ +:root{color-scheme:light;font:16px/1.6 system-ui,sans-serif;color:#223137;background:#f4f6f5} +*{box-sizing:border-box}body{margin:0}main{max-width:1060px;margin:auto;padding:36px 28px 64px} +header{border-bottom:2px solid #237e6a;padding-bottom:22px}h1,h2,h3{line-height:1.25;color:#164c40} +h1{font-size:2.2rem;margin:8px 0}h2{font-size:1.4rem;margin-top:36px}h3{font-size:1.05rem} +p{max-width:90ch}a{color:#17644f}small,.muted{color:#586763}.numbers{font-size:1.25rem;font-weight:600} +figure{margin:24px 0;background:white;padding:12px;border-radius:8px}figure img{width:100%;height:auto} +figcaption{font-size:.9rem;text-align:center}.decision{border-top:1px solid #ccd7d1;padding:14px 0} +.decision h3{margin:0}.decision p{margin:8px 0}details{margin:12px 0}summary{cursor:pointer;color:#17644f} +.table-wrap{overflow-x:auto}table{border-collapse:collapse;width:100%;font-size:.92rem;margin:14px 0} +th,td{text-align:left;vertical-align:top;padding:9px 12px;border-bottom:1px solid #d9e0dc} +th{background:#e9efeb}td p{margin:0}li{margin:6px 0} +footer{margin-top:36px;border-top:1px solid #ccd7d1;padding-top:18px;font-size:.85rem} +@media(max-width:600px){main{padding:20px 14px}h1{font-size:1.7rem}th,td{padding:7px}} +@media print{body{background:white}main{padding:0}details{break-inside:avoid}} """ -def _chip(text: str) -> str: - escaped = html.escape(text) - if len(text) > MAX_CHIP_LENGTH: - return f'{escaped}' - return f'{escaped}' - - -def _chips(value: Any, empty: str = "Not provided") -> str: - if not _present(value): - return f'{html.escape(empty)}' - if isinstance(value, Mapping): - items = [f"{_label(key)}: {_scalar(item)}" for key, item in value.items()] - elif _is_sequence(value): - items = list(value) - else: - items = [value] - return '{}'.format( - "".join(_chip(_scalar(item)) for item in items) - ) - - -def _kv_list(mapping: Mapping[str, Any]) -> str: - parts: list[str] = [] - for key, item in mapping.items(): - if not _present(item): - continue - label = html.escape(_label(key)) - if _is_leaf(item): - text = f"{_label(key)}: {_scalar(item)}" - if len(text) <= MAX_CHIP_LENGTH: - parts.append(_chip(text)) - else: - parts.append( - f'{label}' - f'{html.escape(_scalar(item))}' - ) - elif _is_simple(item): - parts.append( - f'{label}{_chips(item)}' - ) - else: - parts.append( - '
    ' - f"{label}{_value(item)}
    " - ) - if not parts: - return 'Not provided' - return f'
    {"".join(parts)}
    ' - - -def _cell(value: Any) -> str: - if not _present(value): - return 'Not provided' - if isinstance(value, Mapping): - return _kv_list(value) - if _is_mapping_sequence(value): - count = len(value) - return ( - '
    ' - f"{count:,} records" - f"{_mapping_list(value)}
    " - ) - if _is_sequence(value) and all(_is_leaf(item) for item in value): - if len(value) > MAX_INLINE_LEAVES: - return html.escape(", ".join(_scalar(item) for item in value)) - return _chips(value) - if _is_sequence(value): - return _chips(value) - return html.escape(_scalar(value)) - - -def _visible_columns(rows: Sequence[Mapping[str, Any]]) -> list[str]: - return list( - dict.fromkeys( - key for row in rows for key in row if not str(key).startswith("_") - ) - ) - - -def _render_record_rows( - rows: Sequence[Mapping[str, Any]], - columns: Sequence[str], -) -> str: - records: list[str] = [] - for row in rows: - fields: list[str] = [] - for key in columns: - value = row.get(key) - wide = not _is_simple(value) or ( - isinstance(value, str) and len(value) > MAX_CHIP_LENGTH - ) - field_class = "record-field record-field-wide" if wide else "record-field" - fields.append( - f'
    ' - f"
    {html.escape(_label(key))}
    " - f"
    {_cell(value)}
    " - ) - selected_class = " table-record-selected" if row.get("_selected") else "" - records.append( - f'
    ' - f'
    {"".join(fields)}
    ' - ) - return f'
    {"".join(records)}
    ' +def _escape(value: Any) -> str: + return html.escape(scalar(value)) -def _mapping_list(rows: Sequence[Mapping[str, Any]]) -> str: - normalized = [dict(row) for row in rows] - if not normalized: - return '

    No records available.

    ' - visible = _visible_columns(normalized) - if len(visible) <= MAX_TABLE_COLUMNS: - return _table(normalized) - return _render_record_rows(normalized, visible) - - -def _value(value: Any) -> str: - if not _present(value): - return 'Not provided' - if isinstance(value, Mapping): - rows = "".join( - "
    {}
    {}
    ".format( - html.escape(_label(key)), - ( - _mapping_list(_mappings(item)) - if _is_mapping_sequence(item) - else _value(item) - ), - ) - for key, item in value.items() - if _present(item) - ) - return f'
    {rows}
    ' - if _is_mapping_sequence(value): - return _mapping_list(value) - if _is_sequence(value): - if all(_is_leaf(item) for item in value): - return _chips(value) - return '
    {}
    '.format( - "".join(f'
    {_value(item)}
    ' for item in value) - ) - return html.escape(_scalar(value)) - - -def _table( - rows: Sequence[Mapping[str, Any]], - *, - columns: Sequence[str] | None = None, - empty: str = "No records available.", -) -> str: - normalized = [dict(row) for row in rows] - if not normalized: - return f'

    {html.escape(empty)}

    ' - visible = list(columns or ()) or _visible_columns(normalized) - if len(visible) > MAX_TABLE_COLUMNS: - return _render_record_rows(normalized, visible) - headings = "".join(f"{html.escape(_label(key))}" for key in visible) - body = "".join( - ('' if row.get("_selected") else "") - + "".join(f"{_cell(row.get(key))}" for key in visible) - + "" - for row in normalized - ) +def _list(items: Sequence[str]) -> str: return ( - '
    ' - f"{headings}{body}
    " - ) - - -def _render_clusters(cluster_counts: Mapping[str, int]) -> str: - if not cluster_counts: - return '

    No final cluster counts were available.

    ' - maximum = max(cluster_counts.values(), default=1) or 1 - return "".join( - '
    ' - f"Cluster {html.escape(str(label))}" - '' - f'' - "" - f"{count:,}
    " - for label, count in cluster_counts.items() - ) - - -def _parameter_rows(parameter: Mapping[str, Any]) -> list[dict[str, Any]]: - assay_reports = _mapping(parameter.get("assayReports")) - if not assay_reports and _present(parameter.get("evaluations")): - assay_reports = {str(parameter.get("fromAssay") or "Primary"): dict(parameter)} - recommended = _mapping(parameter.get("recommendedByAssay")) - rows: list[dict[str, Any]] = [] - for assay, raw_report in assay_reports.items(): - report = _mapping(raw_report) - selected = recommended.get(assay) or report.get("recommendedCandidateId") - for evaluation in _mappings(report.get("evaluations")): - parameters = _mapping(evaluation.get("parameters")) - rows.append( - { - "_selected": evaluation.get("candidateId") == selected, - "assay": assay, - "candidate": evaluation.get("candidateId"), - "phase": evaluation.get("phase"), - "status": evaluation.get("status"), - "eligible": evaluation.get("eligible"), - "selection confidence": report.get("confidence"), - "reduction": parameters.get("reductionMethod"), - "dimensions": parameters.get("dimensions"), - "neighbors K": parameters.get("neighborsK"), - "resolution": parameters.get("leidenResolution"), - "Harmony": parameters.get("useHarmony"), - "metrics": evaluation.get("metrics"), - } - ) - return rows - - -def _render_parameter_tuning(parameter: Mapping[str, Any]) -> str: - if not parameter: - return '

    No Parameter Tuning report was persisted.

    ' - candidate_rows = _parameter_rows(parameter) - integration_rows = _mappings(parameter.get("integrationEvaluations")) - plans: list[dict[str, Any]] = [] - comparisons: list[dict[str, Any]] = [] - root_plan = _mapping(parameter.get("searchPlan")) - if root_plan: - plans.append({"assay": parameter.get("fromAssay"), **root_plan}) - for comparison in _mappings(parameter.get("comparisons")): - comparisons.append( - {"scope": parameter.get("fromAssay") or "primary assay", **comparison} - ) - for assay, report in _mapping(parameter.get("assayReports")).items(): - assay_report = _mapping(report) - plan = _mapping(assay_report.get("searchPlan")) - if plan and plan not in plans: - plans.append({"assay": assay, **plan}) - for comparison in _mappings(assay_report.get("comparisons")): - comparisons.append({"scope": assay, **comparison}) - final_selection = _mapping(parameter.get("finalSelection")) - for comparison in _mappings(final_selection.get("comparisons")): - comparisons.append({"scope": "final graph", **comparison}) - narrative = { - "status": parameter.get("status"), - "totalCandidates": parameter.get("totalCandidates"), - "recommendedByAssay": parameter.get("recommendedByAssay"), - "recommendedIntegrationId": parameter.get("recommendedIntegrationId"), - "confidence": parameter.get("confidence"), - "rationale": parameter.get("rationale"), - "tradeoffs": parameter.get("tradeoffs"), - "stopReason": parameter.get("stopReason"), - "finalSelection": final_selection, - } - return ( - '

    Final graph selection

    ' - f"{_value(narrative)}
    " - '

    Native and Harmony candidates

    ' - f"{_table(candidate_rows, empty='No native candidates were recorded.')}
    " - '

    SNN and WNN integration candidates

    ' - f"{_table(integration_rows, empty='No integration candidates were eligible.')}
    " - '

    Model-authored comparisons

    ' - f"{_table(comparisons, empty='No candidate comparisons were required.')}
    " - '

    Bounded search plans

    ' - f"{_value(plans) if plans else '

    No refinement plan was requested.

    '}" - "
    " + "
      " + "".join(f"
    • {html.escape(item)}
    • " for item in items) + "
    " + if items + else "" ) -def _execution_rows(reports: Mapping[str, Any]) -> list[dict[str, Any]]: - rows: list[dict[str, Any]] = [] - seen: set[tuple[str, str, str]] = set() - - def visit(value: Any, stage: str, path: tuple[str, ...]) -> None: - if isinstance(value, Mapping): - usage = value.get("usage") - agent_name = value.get("agentName") - if ( - isinstance(usage, Mapping) - and isinstance(agent_name, str) - and agent_name.strip() - ): - run_id = str(value.get("runId") or "") - identity = (agent_name, run_id, str(value.get("modelName") or "")) - if identity not in seen: - seen.add(identity) - rows.append( - { - "agent stage": _label(stage), - "execution": _label(path[-1]) if path else agent_name, - "agent": agent_name, - "run ID": run_id or "deterministic", - "model": value.get("modelName") or "not applicable", - "duration seconds": value.get("durationSeconds"), - "requests": usage.get("requests", 0), - "tool calls": usage.get("toolCalls", 0), - "input tokens": usage.get("inputTokens", 0), - "output tokens": usage.get("outputTokens", 0), - "total tokens": usage.get("totalTokens", 0), - } - ) - for key, item in value.items(): - visit(item, stage, (*path, str(key))) - elif isinstance(value, Sequence) and not isinstance( - value, (str, bytes, bytearray) - ): - for index, item in enumerate(value): - visit(item, stage, (*path, str(index + 1))) - - for stage, records in reports.items(): - visit(records, str(stage), ()) - return rows - - -def _render_executions(reports: Mapping[str, Any]) -> str: - rows = _execution_rows(reports) +def _table(headers: Sequence[str], rows: Sequence[Sequence[Any]]) -> str: if not rows: - return '

    No provider execution metadata was recorded.

    ' - totals = { - "recorded executions": len(rows), - "provider executions": sum( - int( - bool(row["model"] != "not applicable") - or int(row["requests"] or 0) > 0 - or int(row["input tokens"] or 0) > 0 - or int(row["output tokens"] or 0) > 0 - ) - for row in rows - ), - "requests": sum(int(row["requests"] or 0) for row in rows), - "tool calls": sum(int(row["tool calls"] or 0) for row in rows), - "input tokens": sum(int(row["input tokens"] or 0) for row in rows), - "output tokens": sum(int(row["output tokens"] or 0) for row in rows), - "total tokens": sum(int(row["total tokens"] or 0) for row in rows), - } - return ( - '

    Recorded totals

    ' - f'{_chips(totals)}
    {_table(rows)}
    ' - ) - - -def _render_timeline( - attempts: Sequence[Mapping[str, Any]], - resumes: Sequence[Mapping[str, Any]], -) -> str: - artifacts: list[dict[str, Any]] = [] - for attempt in attempts: - for name, reference in _mapping(attempt.get("artifacts")).items(): - artifact = _mapping(reference) - artifacts.append( - { - "stage": attempt.get("stage"), - "attempt": attempt.get("attemptId"), - "name": name, - "scope": artifact.get("scope"), - "assay": artifact.get("assay"), - "kind": artifact.get("kind"), - "artifact ID": artifact.get("artifactId"), - } - ) - return ( - "

    Stage attempts

    " - + _table( - attempts, - columns=( - "stage", - "status", - "durationSeconds", - "actions", - "reportCount", - "artifactCount", - "parentAttempts", - "questionIds", - "noteCount", - "errorType", - ), - ) - + '

    Stage artifact inventory

    ' - + _table(artifacts, empty="No stage artifacts were recorded.") - + "
    " - + '

    Resume lineage

    ' - + _table( - resumes, - columns=( - "resumeId", - "answeredStage", - "answeredAttemptId", - "questionIds", - ), - empty="No resume was required.", - ) - + "
    " - ) - - -def _study_overview( - payload: Mapping[str, Any], -) -> tuple[str, list[str], list[str]]: - reports = _mapping(payload.get("reports")) - request = _mapping(payload.get("request")) - enrichment = _latest(reports, "data_enrichment") - study = _mapping(enrichment.get("studyContextSummary")) - objective = "" - for candidate in ( - study.get("studyObjective"), - request.get("studyObjective"), - study.get("studyContext"), - request.get("studyContext"), - ): - objective = _brief_text(candidate) - if objective: - break - return ( - objective or "The automated analysis completed successfully.", - _specific_references(_text_values(study.get("organismReferences"))), - _specific_references(_text_values(study.get("tissueReferences"))), - ) - - -def _biological_source( - organisms: Sequence[str], - tissues: Sequence[str], -) -> str: - organism = _format_text_list(organisms) - tissue = _format_text_list(tissues) - if organism and tissue: - return f"{organism} material from {tissue}" - if organism: - return f"{organism} biological material" - if tissue: - return f"biological material from {tissue}" - return "" - - -def _report_assays(plan: Mapping[str, Any]) -> list[str]: - assays: list[str] = [] - for assay in _mappings(plan.get("assays")): - label = _assay_label(assay.get("assayType") or assay.get("assay")) - if label and label not in assays: - assays.append(label) - return assays - - -def _render_metrics(metrics: Sequence[tuple[str, Any]]) -> str: - markup = "".join( - '' - f'{html.escape(label)}' - f'{html.escape(_scalar(value))}' - for label, value in metrics - if _present(value) - ) - return f'
    {markup}
    ' if markup else "" - - -def _render_report_navigation(active_page: str) -> str: - links = ( - ("analysis", "index.html", "Analysis summary"), - ("technical", "technical.html", "Methods and evidence"), - ) - return ''.format( - "".join( - '{}'.format( - html.escape(path, quote=True), - ' aria-current="page"' if page == active_page else "", - html.escape(label), - ) - for page, path, label in links - ) + return "" + head = "".join(f"{html.escape(value)}" for value in headers) + body = "".join( + "" + "".join(f"{_escape(value)}" for value in row) + "" + for row in rows ) + return f'
    {head}{body}
    ' -def _render_report_shell( - *, - title: str, - active_page: str, - body: str, -) -> str: - return f""" - - - - - {html.escape(title)} - - - -
    - Nygen Analytics - {_render_report_navigation(active_page)} -
    -
    -{body} -
    - - - -""" - - -def _render_selection_evidence(payload: Mapping[str, Any]) -> str: - reports = _mapping(payload.get("reports")) - workflow_result = _mapping(payload.get("workflowResult")) - final = _mapping(workflow_result.get("finalAnalysis")) - parameter = _latest(reports, "parameter_tuning") - _report, _evaluations, selected = _selected_parameter_context(parameter, final) - metrics = _mapping(selected.get("metrics")) - cards: list[tuple[str, str, str]] = [] - candidate_count = parameter.get("totalCandidates") - if isinstance(candidate_count, int): - cards.append( - ( - "Settings compared", - f"{candidate_count:,}", - "Completed parameter combinations considered before selection.", - ) - ) - card_specs = ( - ( - "graphSilhouetteMedian", - "Group separation", - "Higher values indicate clearer separation between neighboring groups.", - ), - ( - "minClusterCells", - "Smallest group", - "Number of cells in the smallest selected group.", - ), - ( - "seedStability", - "Repeat-run stability", - "Agreement when clustering is repeated with a different random seed.", - ), +def _decision(decision: Mapping[str, Any]) -> str: + spec, record = mapping(decision.get("spec")), mapping(decision.get("record")) + options = mappings(spec.get("options")) + selected = next( ( - "subsampleStability", - "Subsample stability", - "Agreement when the analysis is repeated on a subset of cells.", + option + for option in options + if option.get("optionId") == record.get("selectedOptionId") ), - ( - "markerCoherence", - "Marker coherence", - "Consistency of marker support across the selected groups.", - ), - ( - "crossUnitSupport", - "Cross-sample support", - "Support for the selected groups across the study units.", - ), - ) - for key, label, explanation in card_specs: - value = metrics.get(key) - if isinstance(value, int): - display = f"{value:,} cells" if key == "minClusterCells" else f"{value:,}" - elif isinstance(value, float): - display = f"{value:.3f}" - else: - continue - cards.append((label, display, explanation)) - if not cards: - return "" - return '
    {}
    '.format( - "".join( - '
    ' - f'

    {html.escape(label)}

    ' - f"

    {html.escape(value)}

    " - f"

    {html.escape(explanation)}

    " - "
    " - for label, value, explanation in cards - ) - ) - - -def _render_evidence_choices(choices: Sequence[Mapping[str, Any]]) -> str: - return '
    {}
    '.format( - "".join( - '
    '.format( - html.escape(str(choice.get("state") or "reviewed"), quote=True) - ) - + '{}'.format( - html.escape(str(choice.get("status") or "Reviewed")) - ) - + f"

    {html.escape(str(choice.get('label') or 'Evidence'))}

    " - + _render_plain_list(_text_values(choice.get("metrics"))) - + ( - f"

    {html.escape(_brief_text(choice.get('reason')))}

    " - if _brief_text(choice.get("reason")) - else "" - ) - + "
    " - for choice in choices - ) - ) - - -def _render_evidence_measurements( - measurements: Sequence[tuple[str, str, str]], -) -> str: - if not measurements: - return "" - return '
    {}
    '.format( - "".join( - '
    ' - f"
    {html.escape(label)}
    " - f"
    {html.escape(value)}" - + (f"{html.escape(detail)}" if detail else "") - + "
    " - for label, value, detail in measurements - ) - ) - - -def _render_evidence_panel( - *, - title: str, - outcome: str, - introduction: str, - body: str, - measurements: str = "", - expanded: bool = False, -) -> str: - measurement_markup = ( - '
    Measurements' - f'
    {measurements}
    ' - if measurements - else "" - ) - open_attribute = " open" if expanded else "" - return ( - f'
    ' - f'{html.escape(title)}' - f'{html.escape(outcome)}' - "" - '
    ' - f"

    {html.escape(introduction)}

    {body}{measurement_markup}
    " - ) - - -def _qc_profile_scope(profile: Mapping[str, Any]) -> str: - bounds = _qc_resolved_bounds(profile) - groups = {str(item.get("group")) for item in bounds if _present(item.get("group"))} - return "Per-library thresholds" if len(groups) > 1 else "Global thresholds" - - -def _qc_flag_summary(profile: Mapping[str, Any]) -> list[str]: - labels = ( - ("nCounts:high", "High RNA count flags"), - ("nCounts:lowQuality", "Low RNA count flags"), - ("nFeatures:high", "High detected-gene flags"), - ("nFeatures:lowQuality", "Low detected-gene flags"), - ("percentMito:highMito", "High mitochondrial-percentage flags"), - ("percentRibo:highRibo", "High ribosomal-percentage flags"), - ) - flags = _mapping(profile.get("flaggedCells")) - values: list[str] = [] - for suffix, label in labels: - count = next( - ( - value - for key, value in flags.items() - if str(key).endswith(suffix) and isinstance(value, int) - ), - None, - ) - if count is not None: - values.append(f"{label}: {count:,}") - return values - - -def _qc_bound_summary(profile: Mapping[str, Any]) -> str: - bounds = _qc_resolved_bounds(profile) - parts: list[str] = [] - for role, label in ( - ("count", "RNA counts"), - ("feature", "Detected genes"), - ("mitochondrial", "Mitochondrial percentage"), - ("ribosomal", "Ribosomal percentage"), - ): - matching = [item for item in bounds if item.get("role") == role] - if not matching: - continue - lower = _analysis_number_range([item.get("lowerRemoval") for item in matching]) - upper_removal = _analysis_number_range( - [item.get("upperRemoval") for item in matching] - ) - upper_flag = _analysis_number_range( - [item.get("upperFlag") for item in matching] - ) - cutoffs = [ - value - for value in ( - f"lower cutoff {lower}" if lower != "Not available" else "", - ( - f"upper cutoff {upper_removal}" - if upper_removal != "Not available" - else "" - ), - ( - f"high-value flag {upper_flag}" - if upper_flag != "Not available" - else "" - ), - ) - if value - ] - if cutoffs: - parts.append(f"{label}: {'; '.join(cutoffs)}") - return ". ".join(parts) - - -def _qc_metric_rows(profile: Mapping[str, Any]) -> list[dict[str, Any]]: - bounds = _qc_resolved_bounds(profile) - by_metric: dict[str, list[dict[str, Any]]] = {} - for bound in bounds: - metric = str(bound.get("metric") or bound.get("role") or "") - if metric: - by_metric.setdefault(metric, []).append(bound) - capture_comparisons = _mappings( - _mapping(profile.get("parameters")).get("captureComparisons") - ) - if capture_comparisons: - first_metrics = _mapping(capture_comparisons[0].get("metricComparisons")) - for metric, raw in first_metrics.items(): - if str(metric).startswith("artifact_") or metric in by_metric: - continue - comparison = _mapping(raw) - by_metric[metric] = [ - { - "metric": metric, - "group": "global diagnostic", - "role": comparison.get("role"), - "median": comparison.get("globalMedian"), - "diagnosticLower": comparison.get("globalLower"), - "diagnosticUpper": comparison.get("globalUpper"), - } + {}, + ) + question = str( + spec.get("question") or label(str(record.get("decisionId", "Analysis setting"))) + ) + chosen = str(selected.get("label") or "Selection unavailable") + rationale = str(record.get("rationale") or "No rationale was recorded.") + source = { + "agent": "Agent choice", + "rule": "Scarf rule", + "human": "User choice", + }.get(str(record.get("source", "")), "") + alternatives = _table( + ("Option", "Action", "Description"), + [ + [ + option.get("label"), + "Selected" if option is selected else "Not selected", + option.get("description"), ] - flags = _mapping(profile.get("metricFlaggedCells")) - rows: list[dict[str, Any]] = [] - for metric, metric_bounds in by_metric.items(): - medians = [item.get("median") for item in metric_bounds] - lower = [item.get("lowerRemoval") for item in metric_bounds] - upper = [item.get("upperRemoval") for item in metric_bounds] - high_flag = [item.get("upperFlag") for item in metric_bounds] - diagnostic_range = [ - item.get(field) - for item in metric_bounds - for field in ("diagnosticLower", "diagnosticUpper") - ] - metric_flags = _mapping(flags.get(metric)) - rows.append( - { - "metric": _public_field_label(metric), - "scope": ( - "Global diagnostic only" - if any(value is not None for value in diagnostic_range) - else _qc_profile_scope({"resolvedBounds": metric_bounds}) - ), - "median": _analysis_number_range(medians), - "diagnostic reference": _analysis_number_range(diagnostic_range), - "lower cutoff": _analysis_number_range(lower), - "upper cutoff": _analysis_number_range(upper), - "high flag": _analysis_number_range(high_flag), - "flagged cells": sum( - int(value) - for value in metric_flags.values() - if isinstance(value, int) - ), - } - ) - return rows - - -def _qc_profile_rows(profiles: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: - rows: list[dict[str, Any]] = [] - for profile in profiles: - active = profile.get("activeCells") - retained = profile.get("retainedCells") - removed = ( - active - retained - if isinstance(active, int) and isinstance(retained, int) - else None - ) - rows.append( - { - "profile": _qc_profile_label(profile), - "scope": _qc_profile_scope(profile), - "active cells": active, - "retained cells": retained, - "removed cells": removed, - "flags": _qc_flag_summary(profile), - "failed libraries": len( - _text_values(profile.get("failedCaptureCandidates")) - ), - } - ) - return rows - - -def _qc_bound_rows(profiles: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: - rows: list[dict[str, Any]] = [] - for profile in profiles: - for bound in _qc_resolved_bounds(profile): - rows.append( - { - "profile": _qc_profile_label(profile), - "group": bound.get("group"), - "metric": _public_field_label( - bound.get("metric") or bound.get("role") - ), - "median": bound.get("median"), - "lower removal": bound.get("lowerRemoval"), - "upper removal": bound.get("upperRemoval"), - "upper flag": bound.get("upperFlag"), - } - ) - return rows - - -def _render_filtering_evidence( - experimental: Mapping[str, Any], - plan: Mapping[str, Any], -) -> str: - profiles = _mappings(experimental.get("qcProfiles")) - if not profiles: - return "" - decision = _mapping(experimental.get("decision")) - cell_qc = _mapping(plan.get("cellQc")) - if not cell_qc: - cell_qc = _mapping(decision.get("cellQc")) - if not cell_qc: - cell_qc = _mapping(experimental.get("cellQc")) - selected = _selected_qc_profile(experimental, cell_qc) - selected_id = selected.get("profileId") - selected_name = selected.get("registeredProfile") - choices: list[dict[str, Any]] = [] - for profile in profiles: - is_selected = bool( - (selected_id and profile.get("profileId") == selected_id) - or ( - not selected_id - and selected_name - and profile.get("registeredProfile") == selected_name - ) - ) - active = profile.get("activeCells") - retained = profile.get("retainedCells") - metrics: list[str] = [] - removed: int | None = None - if isinstance(active, int) and isinstance(retained, int): - removed = active - retained - metrics.extend( - ( - f"Retained: {retained:,} of {active:,}", - f"Removed: {removed:,}", - ) - ) - n_mads = _mapping(profile.get("parameters")).get("nMads") - if isinstance(n_mads, (int, float)): - metrics.append( - f"Threshold distance: {float(n_mads):g} median absolute deviations" - ) - metrics.append(_qc_profile_scope(profile)) - metrics.extend(_qc_flag_summary(profile)) - choices.append( - { - "label": _qc_profile_label(profile), - "status": "Selected" if is_selected else "Not selected", - "state": "selected" if is_selected else "rejected", - "metrics": metrics, - "reason": ( - str( - cell_qc.get("rationale") - or "Selected the registered QC profile shown above." - ) - if is_selected - else "An alternative registered QC profile with the measured retention shown above." - ), - } - ) - active = selected.get("activeCells") - retained = selected.get("retainedCells") - selected_label = _qc_profile_label(selected) - selected_flags = sum( - int(value) - for value in _mapping(selected.get("flaggedCells")).values() - if isinstance(value, int) - ) - outcome = ( - f"{selected_label}; {retained:,} of {active:,} cells retained; " - f"{selected_flags:,} diagnostic flags" - if isinstance(active, int) and isinstance(retained, int) - else f"{selected_label} selected" - ) - measurements: list[tuple[str, str, str]] = [] - for profile in profiles: - parameters = _mapping(profile.get("parameters")) - n_mads = parameters.get("nMads") - rule = ( - f"{float(n_mads):g} median absolute deviations (MAD), " - f"{_qc_profile_scope(profile).lower()}" - if isinstance(n_mads, (int, float)) - else _qc_profile_scope(profile) - ) - measurements.append( - ( - _qc_profile_label(profile), - rule, - ". ".join( - value - for value in ( - _qc_bound_summary(profile), - "; ".join(_qc_flag_summary(profile)), - ) - if value - ), - ) - ) - for column, group_counts in _mapping(selected.get("retainedCellsByColumn")).items(): - counts = list(_mapping(group_counts).values()) - numeric = [value for value in counts if isinstance(value, int)] - if numeric: - measurements.append( - ( - f"Retention across {_public_field_label(column)}", - f"{len(numeric):,} groups", - f"{min(numeric):,} to {max(numeric):,} retained cells per group.", - ) - ) - return _render_evidence_panel( - title="Cell filtering", - outcome=outcome, - introduction=( - f"{len(profiles):,} registered filtering strategies were compared. " - "The selected strategy retained the published cell set because stricter " - "alternatives did not provide stronger support. Its cutoffs are " - "diagnostic bounds and did not remove cells." - ), - body=( - _render_evidence_choices(choices) - + '

    Selected QC metrics and cutoffs

    ' - + _table( - _qc_metric_rows(selected), - columns=( - "metric", - "scope", - "median", - "diagnostic reference", - "lower cutoff", - "upper cutoff", - "high flag", - "flagged cells", - ), - empty="No selected QC metric cutoffs were recorded.", - ) - + "
    " - ), - measurements=_render_evidence_measurements(measurements), - expanded=True, - ) - - -def _covariate_pair_measurements( - characterization: Mapping[str, Any], -) -> list[tuple[str, str, str]]: - measurements: list[tuple[str, str, str]] = [] - for item in _mappings(characterization.get("confounding")): - coefficient = _public_field_label(item.get("coefficient")) - for pair in _mappings(item.get("pairs")): - technical = _public_field_label(pair.get("technical")) - association = _mapping(pair.get("association")) - status = str(association.get("status") or "") - value = association.get("value") - uncorrected = association.get("valueUncorrected") - if status == "notComputed": - display = "Not independently measurable" - elif isinstance(value, (int, float)): - display = f"Association score: {float(value):.3f}" - else: - display = "Association not available" - rows_used = association.get("rowsUsed") - details = ( - [f"{rows_used:,} study units"] if isinstance(rows_used, int) else [] - ) - if status == "notComputed" and isinstance(uncorrected, (int, float)): - details.append( - f"uncorrected association score {float(uncorrected):.3f}" - ) - elif status: - details.append("association measured") - measurements.append( - ( - f"{coefficient} and {technical}", - display, - ("; ".join(details) + ".") if details else "", - ) - ) - return measurements - - -def _render_covariate_evidence(experimental: Mapping[str, Any]) -> str: - characterization = _mapping(experimental.get("characterization")) - columns = _mappings(characterization.get("columns")) - if not columns: - return "" - domains = Counter(str(item.get("domain") or "unclassified") for item in columns) - domain_labels = { - "biological": "Biological variables", - "technical": "Technical variables", - "design": "Study-design variables", - "ignore": "Excluded metadata", - "unclassified": "Unclassified metadata", - } - role_choices = [ - { - "label": domain_labels.get(domain, _label(domain)), - "status": "Reviewed", - "state": "reviewed", - "metrics": [f"Columns: {count:,}"], - "reason": "", - } - for domain, count in sorted(domains.items()) - ] - coefficients = _mappings(characterization.get("coefficients")) - coefficient_names = [ - _public_field_label(item.get("name")) - for item in coefficients - if _public_field_label(item.get("name")) - ] - outcome = ( - f"{len(columns):,} columns reviewed; " - f"{_format_text_list(coefficient_names)} selected as study comparisons" - if coefficient_names - else f"{len(columns):,} metadata columns reviewed" - ) - measurements: list[tuple[str, str, str]] = [] - for coefficient in coefficients: - rows = coefficient.get("designRows") - observation = _public_field_label(coefficient.get("observationUnit")) - independent = _public_field_label(coefficient.get("independentUnit")) - scope = { - "betweenUnit": "between independent units", - "withinUnit": "within independent units", - "mixed": "within and between independent units", - }.get( - str(coefficient.get("scope") or ""), - _label(coefficient.get("scope")).lower(), - ) - measurements.append( - ( - _public_field_label(coefficient.get("name")), - ( - f"{int(rows):,} {observation} records" - if isinstance(rows, int) - else "Selected biological comparison" - ), - (f"Independent unit: {independent}; comparison type: {scope}."), - ) - ) - for nesting in _mappings(characterization.get("technicalNesting")): - left = _public_field_label(nesting.get("left")) - right = _public_field_label(nesting.get("right")) - measurements.append( - ( - "Technical nesting", - f"{right} is nested within {left}", - "This structure limits which technical effects can be separated.", - ) - ) - measurements.extend(_covariate_pair_measurements(characterization)) - return _render_evidence_panel( - title="Covariate analysis", - outcome=outcome, - introduction=( - "Metadata were classified by role before correction or clustering. " - "The review separated biological comparisons from technical structure " - "and metadata that should not guide the analysis." - ), - body=_render_evidence_choices(role_choices), - measurements=_render_evidence_measurements(measurements), - ) - - -def _feature_family_counts( - enrichment: Mapping[str, Any], -) -> dict[tuple[str, str], Mapping[str, Any]]: - counts: dict[tuple[str, str], Mapping[str, Any]] = {} - for inspection in _mappings(enrichment.get("inspections")): - assay = str(inspection.get("assay") or "") - for family in _mappings(inspection.get("families")): - counts[(assay, str(family.get("family") or ""))] = family - return counts - - -def _default_inventory_family_rows( - inventory: Mapping[str, Any], -) -> list[dict[str, Any]]: - return [ - { - "family": _feature_family_label(family.get("family")), - "pattern": family.get("pattern"), - "matched genes": family.get("count"), - "examples": _text_values(family.get("examples")), - } - for family in _mappings(inventory.get("families")) - ] - - -def _render_default_inventory_summary( - inventory: Mapping[str, Any], - *, - heading: str, -) -> str: - if not inventory: - return "" - match_count = inventory.get("matchCount") - total_features = inventory.get("totalFeatures") - applied = inventory.get("appliedToSelectedRepresentation") is True - count_summary = ( - f"{int(match_count):,} of {int(total_features):,} genes matched." - if isinstance(match_count, int) and isinstance(total_features, int) - else "The exact default pattern was evaluated." - ) - effect = ( - "The complete default blacklist was applied to the selected representation." - if applied - else ( - "The complete default blacklist was evaluated as a reference but was " - "not applied wholesale to the selected representation." - ) - ) - blacklist = str(inventory.get("blacklist") or "") - pattern_markup = ( - "

    Exact combined pattern: " - f"{html.escape(blacklist)}

    " - if blacklist - else "" - ) - return ( - f'

    {html.escape(heading)}

    ' - f"

    {html.escape(count_summary)} {html.escape(effect)}

    " - + _table( - _default_inventory_family_rows(inventory), - columns=("family", "pattern", "matched genes", "examples"), - empty="No default blacklist families were recorded.", - ) - + pattern_markup - + "
    " - ) - - -def _render_normalization_evidence( - enrichment: Mapping[str, Any], - plan: Mapping[str, Any], - inventories: Sequence[Mapping[str, Any]], -) -> str: - assay_plans = _mappings(plan.get("assays")) - if not assay_plans: - return "" - families = _feature_family_counts(enrichment) - choices: list[dict[str, Any]] = [] - measurements: list[tuple[str, str, str]] = [] - outcome_parts: list[str] = [] - for assay_plan in assay_plans: - assay = str(assay_plan.get("assay") or "Assay") - normalization = _mapping(assay_plan.get("normalizationParameters")) - feature_parameters = _mapping(assay_plan.get("featureParameters")) - inventory = _default_inventory_for_assay(inventories, assay) - log_transform = normalization.get("logTransform") is True - renormalize = normalization.get("renormalizeSubset") is True - normalization_metrics = [ - "Log transform applied" if log_transform else "No log transform", - ( - "Selected cells renormalized" - if renormalize - else "Existing normalization retained" - ), - ] - choices.append( - { - "label": f"{_assay_label(assay)} normalization", - "status": "Selected", - "state": "selected", - "metrics": normalization_metrics, - "reason": "Used consistently for map construction.", - } - ) - excluded = [ - str(value) - for value in _text_values(feature_parameters.get("excludeFamilies")) - ] - protected = [ - str(value) - for value in _text_values(feature_parameters.get("protectFamilies")) - ] - if excluded: - choices.append( - { - "label": f"Exclude {_format_text_list([_feature_family_label(value) for value in excluded])}", - "status": "Excluded from map", - "state": "rejected", - "metrics": [ - "Still available for marker testing", - ], - "reason": ( - "Excluded only from map-building features to reduce " - "unwanted signal." - ), - } - ) - if protected: - choices.append( - { - "label": f"Protect {_format_text_list([_feature_family_label(value) for value in protected])}", - "status": "Preserved", - "state": "selected", - "metrics": ["Remained eligible for map construction"], - "reason": ( - "Protected so biological structure was not removed as " - "technical noise." - ), - } - ) - if inventory: - default_applied = inventory.get("appliedToSelectedRepresentation") is True - match_count = inventory.get("matchCount") - total_features = inventory.get("totalFeatures") - choices.append( - { - "label": "Exact Scarf default HVG blacklist", - "status": ( - "Applied to selected representation" - if default_applied - else "Evaluated as reference" - ), - "state": "selected" if default_applied else "reviewed", - "metrics": ( - [ - f"Matched {int(match_count):,} of " - f"{int(total_features):,} genes" - ] - if isinstance(match_count, int) - and isinstance(total_features, int) - else [] - ), - "reason": ( - "Applied as the complete selected representation blacklist." - if default_applied - else ( - "Not applied wholesale; the final policy used only the " - "families supported by the decision evidence." - ) - ), - } - ) - outcome_parts.append( - f"{_assay_label(assay)} log normalization" - if log_transform - else f"{_assay_label(assay)} normalization" - ) - if excluded: - outcome_parts.append( - f"excluded {_format_text_list([_feature_family_label(value) for value in excluded])} from map construction" - ) - if inventory and not inventory.get("appliedToSelectedRepresentation"): - outcome_parts.append( - "complete Scarf default blacklist not applied wholesale" - ) - for family_name in dict.fromkeys([*excluded, *protected]): - family = _mapping(families.get((assay, family_name))) - count = family.get("count") - skipped = family.get("skipped") - action = ( - "Excluded from map construction" - if family_name in excluded - else "Protected and retained" - ) - measurements.append( - ( - _feature_family_label(family_name).capitalize(), - ( - "Not counted" - if skipped - else ( - f"{int(count):,} identified features" - if isinstance(count, int) - else "Feature count unavailable" - ) - ), - ( - f"{action}. Inspection was skipped because " - f"{_label(skipped).lower()}." - if skipped - else f"{action}." - ), - ) - ) - min_cells = feature_parameters.get("minCells") - if isinstance(min_cells, int): - measurements.append( - ( - f"{_assay_label(assay)} detection requirement", - f"Present in at least {min_cells:,} cells", - "Applied before variable-gene ranking.", - ) - ) - inventory_markup = "".join( - _render_default_inventory_summary( - inventory, - heading=f"{_assay_label(inventory.get('assay'))} default blacklist audit", - ) - for inventory in inventories - ) - return _render_evidence_panel( - title="Normalization and feature policy", - outcome="; ".join(outcome_parts), - introduction=( - "Normalization and feature-family rules were fixed before tuning. " - "Representation exclusions changed the map-building features, not the " - "genes available for marker analysis." - ), - body=_render_evidence_choices(choices) + inventory_markup, - measurements=_render_evidence_measurements(measurements), - ) - - -def _render_batch_evidence( - experimental: Mapping[str, Any], - parameter: Mapping[str, Any], - final: Mapping[str, Any], - decisions: Mapping[str, Any], -) -> str: - decision = _mapping(experimental.get("decision")) - batch_plan = _mapping(decision.get("batchCorrection")) - safety = _mappings(experimental.get("batchSafety")) - if not batch_plan and not safety: - return "" - native_analyses = _mappings(final.get("nativeAnalyses")) - if final.get("graphMethod") == "native" and final.get("primaryAssay"): - native_analyses = [ - item - for item in native_analyses - if item.get("assay") == final.get("primaryAssay") - ] - adjusted = any(_present(item.get("batchCorrection")) for item in native_analyses) - native_candidate, harmony_candidate = _harmony_candidate_pair(parameter, final) - harmony_executed = _harmony_completed(native_candidate) and _harmony_completed( - harmony_candidate - ) - degraded = _degraded_protected_columns(native_candidate, harmony_candidate) - coefficients = list( - dict.fromkeys( - _public_field_label(item.get("coefficient")) - for item in safety - if _public_field_label(item.get("coefficient")) - ) - ) - unsafe = any(item.get("status") == "unsafe" for item in safety) - correction_outcome = _active_decision(decisions, "correctionOutcome") - correction_license = _active_decision(decisions, "correctionLicense") - diagnostic_only = str(correction_license.get("selectedOptionId") or "").endswith( - "unsafeConfounded" - ) - native_metrics = _mapping(native_candidate.get("metrics")) - harmony_metrics = _mapping(harmony_candidate.get("metrics")) - native_batch = _mapping(native_metrics.get("batchMixing")) - harmony_batch = _mapping(harmony_metrics.get("batchMixing")) - harmony_choice_metrics: list[str] = [] - if harmony_candidate: - parameters = _mapping(harmony_candidate.get("parameters")) - harmony_choice_metrics.append( - "Matched parameters: " - f"{_scalar(parameters.get('dimensions'))} dimensions, " - f"{_scalar(parameters.get('neighborsK'))} neighbors, " - f"resolution {_scalar(parameters.get('leidenResolution'))}" - ) - if harmony_executed: - harmony_choice_metrics.insert(0, "Run status: completed") - for column in dict.fromkeys([*native_batch, *harmony_batch]): - harmony_choice_metrics.append( - f"{_public_field_label(column).capitalize()} mixing: " - f"{_score_transition(native_batch.get(column), harmony_batch.get(column))}" - ) - if degraded: - harmony_choice_metrics.append( - "Protected evidence degraded: " + _format_text_list(degraded) - ) - if coefficients: - harmony_choice_metrics.append( - f"Design-confounded comparisons: {_format_text_list(coefficients)}" - ) - if diagnostic_only: - harmony_choice_metrics.append("Selection license: diagnostic only") - recorded_rationale = str(correction_outcome.get("rationale") or "").strip() - if harmony_executed and degraded: - harmony_reason = ( - "Rejected because protected evidence degraded for " - f"{_format_text_list(degraded)}" - + ("; the design license was diagnostic only." if diagnostic_only else ".") - ) - elif harmony_executed: - harmony_reason = "Executed as a matched diagnostic but not selected." - elif unsafe: - harmony_reason = ( - "Not run because library effects could not be separated safely from " - "the protected study comparisons." - ) - else: - harmony_reason = "No completed matched Harmony diagnostic was recorded." - choices = [ - { - "label": "Use the unadjusted representation", - "status": "Selected" if not adjusted else "Not selected", - "state": "selected" if not adjusted else "rejected", - "metrics": ["Protected biological comparisons remain intact"], - "reason": ( - "Selected after the matched diagnostic retained more protected " - "biological structure." - if harmony_executed and not adjusted - else "Selected because no safe, measurable correction was available." - if not adjusted - else "Not selected after the adjusted result showed a safe benefit." - ), - }, - { - "label": "Apply Harmony correction", - "status": ( - "Run and selected" - if adjusted - else ( - "Run diagnostically; rejected" - if harmony_executed - else ("Not run" if unsafe else "Not selected") - ) - ), - "state": "rejected" if not adjusted else "selected", - "metrics": harmony_choice_metrics, - "reason": harmony_reason, - }, - ] - measurements: list[tuple[str, str, str]] = [] - for item in safety: - estimability = _mapping(item.get("estimability")) - coefficient = _public_field_label(item.get("coefficient")) - estimable = estimability.get("coefficientEstimable") is True - rows = estimability.get("rowsUsed") - rank = estimability.get("rankTechnical") - residual = estimability.get("residualDf") - remaining = estimability.get("estimableDf") - measurements.append( + for option in options + ], + ) + evidence = mappings(mapping(decision.get("evidence")).get("evidence")) + evidence_markup = _list( + [str(item["summary"]) for item in evidence if item.get("summary")] + ) + checks = mappings(decision.get("checks")) + check_markup = _table( + ("Check", "Outcome", "Finding"), + [ + [ + item.get("label", item.get("name")), + item.get("status"), + item.get("reason", item.get("summary")), + ] + for item in checks + ], + ) + return f"""
    +

    {html.escape(question)}

    +

    {html.escape(chosen)}. {html.escape(rationale)}

    +{"" + source + "" if source else ""} +
    Alternatives and supporting evidence{alternatives}{evidence_markup}{check_markup}
    +
    """ + + +def render_analysis_document(payload: Mapping[str, Any]) -> str: + final = mapping(payload.get("finalAnalysis")) + request = mapping(payload.get("request")) + counts = mapping(payload.get("clusterCounts")) + total = sum(int(value) for value in counts.values()) + context = request.get("studyObjective") or request.get("studyContext") or "" + assay = final.get("primaryAssay") or request.get("primaryAssay") or "RNA" + qc = mapping(payload.get("qc")) + qc_text = "" + if isinstance(qc.get("retainedCells"), int) and isinstance( + qc.get("retainedFraction"), int | float + ): + qc_text = f"

    QC retained {_escape(qc['retainedCells'])} cells ({float(qc['retainedFraction']):.1%}).

    " + map_markup = "" + if payload.get("umap"): + display = int(payload.get("displayedCells") or total) + map_markup = f'
    Final UMAP colored by saved cluster labels
    {display:,} of {total:,} cells shown. Counts and marker statistics use the complete selection.
    ' + decisions = "".join(_decision(item) for item in mappings(payload.get("decisions"))) + assessments = mappings(payload.get("assessments")) + for assessment in assessments: + scope = ( + "Full cohort" if assessment.get("scope") == "full" else "Screening sample" + ) + alternatives = mappings(assessment.get("candidates")) + settings = mapping(assessment.get("settings")) + comparison = _table( ( - f"Harmony safety for {coefficient}", - "Estimable" if estimable else "Not estimable", - "; ".join( - value - for value in ( - f"Study units: {int(rows):,}" if isinstance(rows, int) else "", - f"Technical rank: {int(rank):,}" - if isinstance(rank, int) - else "", - f"Residual degrees of freedom: {int(residual):,}" - if isinstance(residual, int) - else "", - f"Remaining comparison capacity: {int(remaining):,}" - if isinstance(remaining, int) - else "", - ) - if value - ), - ) - ) - measurements.extend( - _covariate_pair_measurements(_mapping(experimental.get("characterization"))) - ) - outcome = ( - "Harmony completed and was selected" - if adjusted - else ( - "Diagnostic Harmony completed; rejected and native representation retained" - if harmony_executed - else ( - "Harmony not applied; protected comparisons were not independently estimable" - if unsafe - else "No batch correction was selected" - ) - ) - ) - comparison_markup = ( - '

    Matched native versus Harmony metrics

    ' - + _table( - _harmony_metric_rows(native_candidate, harmony_candidate), - columns=( - "category", - "metric", - "native", + "Resolution", + "PCA dimensions", + "Neighbors", + "HVGs", + "HVG ranking", "Harmony", - "change", - "interpretation", + "Clusters", + "Seed stability", + "Marker coherence", + "Selected", ), - empty="No matched Harmony measurements were recorded.", - ) - + "
    " - if harmony_executed - else "" - ) - rationale_markup = ( - '

    Recorded correction decision

    ' - f"

    {html.escape(recorded_rationale)}

    " - if recorded_rationale - else "" - ) - return _render_evidence_panel( - title="Harmony and batch correction", - outcome=outcome, - introduction=( - "Selection required measured technical improvement without material " - "loss of the recorded protected study structure. A diagnostic " - "run could still be completed when the design was not licensed for " - "corrected-result selection." - ), - body=(_render_evidence_choices(choices) + comparison_markup + rationale_markup), - measurements=_render_evidence_measurements(measurements), - ) - - -def _render_hvg_evidence(evidence: Mapping[str, Any]) -> str: - rankings = _mappings(evidence.get("rankings")) - candidates = _mappings(evidence.get("candidateMetrics")) - default_counts = [ - int(value) - for value in evidence.get("scarfDefaultReferenceCounts", []) - if isinstance(value, int) - ] - if not rankings and not candidates: - return "" - selected_mode = evidence.get("selectedRankingMode") - selected_count = evidence.get("selectedFeatureCount") - ranking_choices: list[dict[str, Any]] = [] - for ranking in rankings: - selected = ranking.get("rankingMode") == selected_mode - ranking_choices.append( - { - "label": _hvg_ranking_label(ranking.get("rankingMode")), - "status": "Selected" if selected else "Not selected", - "state": "selected" if selected else "rejected", - "metrics": [ - "Mean coverage across libraries: " - f"{_analysis_percent(ranking.get('meanTechnicalGroupCoverage'))}", - "Genes recurring in at least two libraries: " - f"{_analysis_percent(ranking.get('recurrentInTwoGroupsFraction'))}", - ], - "reason": ( - "Selected after combining recurrence, exact Scarf-default " - "overlap, technical association, and downstream stability." - if selected - else ( - "Not selected after the combined upstream and downstream " - "comparison." - ) - ), - } - ) - if default_counts: - ranking_choices.append( - { - "label": "Exact Scarf-default blacklist reference", - "status": "Reference evaluated", - "state": "reviewed", - "metrics": [ - "Executed set sizes: " - + ", ".join(f"{value:,}" for value in default_counts) - ], - "reason": ( - "Used as a fixed comparison reference, not as a selectable " - "ranking mode." - ), - } - ) - candidate_choices: list[dict[str, Any]] = [] - for candidate in candidates: - count = candidate.get("featureCount") - if not isinstance(count, int): - continue - selected = count == selected_count - candidate_choices.append( - { - "label": f"{count:,} variable genes", - "status": "Selected" if selected else "Not selected", - "state": "selected" if selected else "rejected", - "metrics": [ - "Corrected variance captured: " - f"{_analysis_percent(candidate.get('varianceFraction'))}", - "Genes recurring across most libraries: " - f"{_analysis_percent(candidate.get('recurrentFraction'))}", - ], - "reason": ( - "Selected as the best balance of captured variation and " - "cross-library reproducibility." - if selected - else ( - "Captured less variation than the selected set." - if count < int(selected_count or 0) - else "Added genes with substantially lower reproducibility." - ) - ), - } - ) - measurements = [ - ( - "Eligible genes", - f"{int(evidence['eligibleFeatureCount']):,}", - "Genes available after detection and feature-family rules.", - ) - if isinstance(evidence.get("eligibleFeatureCount"), int) - else None, - ( - "Libraries represented", - f"{int(evidence['validTechnicalGroups']):,}", - "Registered technical groups used to assess recurrence.", - ) - if isinstance(evidence.get("validTechnicalGroups"), int) - else None, - ( - "Minimum detection", - f"{int(evidence['minimumDetectedCells']):,} cells", - "Required before a gene could enter the ranking.", - ) - if isinstance(evidence.get("minimumDetectedCells"), int) - else None, - ( - "Excluded libraries", - f"{int(evidence['excludedTechnicalGroupCount']):,}", - "Libraries omitted from the group-aware ranking.", - ) - if isinstance(evidence.get("excludedTechnicalGroupCount"), int) - else None, - ( - "HVG branches executed", - f"{int(evidence['executedBranchCount']):,}", - "Global, group-aware, and exact Scarf-default reference branches.", - ) - if isinstance(evidence.get("executedBranchCount"), int) - else None, - ] - body = ( - '

    Ranking method

    ' - f"{_render_evidence_choices(ranking_choices)}
    " - '

    Number of variable genes

    ' - f"{_render_evidence_choices(candidate_choices)}
    " - ) - outcome = ( - f"{_hvg_ranking_label(selected_mode)}; {int(selected_count):,} genes selected" - if isinstance(selected_count, int) - else f"{_hvg_ranking_label(selected_mode)} selected" - ) - return _render_evidence_panel( - title="Highly variable genes (HVGs)", - outcome=outcome, - introduction=( - "The workflow first compared how genes were ranked, then compared three " - "registered set sizes. Selection combined recurrence, exact " - "Scarf-default overlap, technical association, and downstream " - "stability rather than using one metric alone." - ), - body=body, - measurements=_render_evidence_measurements( - [item for item in measurements if item is not None] - ), - expanded=True, - ) - - -def _render_analysis_evidence(payload: Mapping[str, Any]) -> str: - reports = _mapping(payload.get("reports")) - workflow_result = _mapping(payload.get("workflowResult")) - plan = _mapping(workflow_result.get("preprocessingPlan")) - final = _mapping(workflow_result.get("finalAnalysis")) - enrichment = _latest(reports, "data_enrichment") - experimental = _latest(reports, "experimental_context") - parameter = _latest(reports, "parameter_tuning") - decisions = _mapping(payload.get("activeDecisions")) - inventories = _mappings(payload.get("defaultFeatureInventories")) - panels = [ - _render_filtering_evidence(experimental, plan), - _render_covariate_evidence(experimental), - _render_normalization_evidence(enrichment, plan, inventories), - _render_batch_evidence(experimental, parameter, final, decisions), - _render_hvg_evidence(_mapping(payload.get("hvgEvidence"))), - ] - panels = [panel for panel in panels if panel] - if not panels: - return "" - return ( - '
    ' - "

    Evidence behind the decisions

    " - "

    Open a section to compare the selected and rejected choices. " - "Each section keeps denser thresholds and scores under Measurements.

    " - f'
    {"".join(panels)}
    ' - ) - - -def _narrative_items(value: Any, keys: Sequence[str]) -> list[str]: - if not _is_sequence(value): - return [] - items: list[str] = [] - for item in value: - if isinstance(item, Mapping): - text = next( - ( - _brief_text(item.get(key)) - for key in keys - if _brief_text(item.get(key)) - ), - "", - ) - else: - text = _brief_text(item) - if text: - items.append(text) - return items - - -def _render_plain_list(items: Sequence[str]) -> str: - if not items: - return "" - return '
      {}
    '.format( - "".join(f"
  • {html.escape(item)}
  • " for item in items) - ) - - -def _render_analysis_biology(biology: Mapping[str, Any]) -> str: - interpretations = _mappings(biology.get("clusterInterpretations")) - observations = _narrative_items( - biology.get("treatmentObservations"), - ("observation",), - ) - follow_ups = _narrative_items( - biology.get("followUps"), - ("question", "rationale"), - ) - if not interpretations and not observations and not follow_ups: - return "" - - cards = "".join( - '
    ' - f'

    Cell group {html.escape(str(item.get("clusterId") or "unresolved"))}

    ' - f"

    {html.escape(str(item.get('proposedIdentity') or 'Unresolved'))}

    " - + ( - f"

    {html.escape(_brief_text(item.get('rationale')))}

    " - if _brief_text(item.get("rationale")) - else "" - ) - + ( - 'Tentative interpretation' - if item.get("identityIsHypothesis") is True + [ + [ + mapping(item.get("parameters")).get("leidenResolution"), + mapping(item.get("parameters")).get("dimensions"), + mapping(item.get("parameters")).get("neighborsK"), + mapping(settings.get(str(item.get("candidateId")))).get("hvgCount"), + mapping(settings.get(str(item.get("candidateId")))).get("ranking"), + mapping(item.get("parameters")).get("useHarmony"), + mapping(item.get("metrics")).get("nClusters"), + mapping(item.get("metrics")).get("seedStability"), + mapping(item.get("metrics")).get("markerCoherence"), + item.get("candidateId") == assessment.get("selectedCandidateId"), + ] + for item in alternatives + ], + ) + assessment_findings = texts(assessment.get("quantitativeFindings")) + texts( + assessment.get("qualitativeFindings") + ) + details = _list(assessment_findings) + if assessment.get("evidenceMode") == "structured": + details = ( + "

    The model assessed structured loading, marker and diagnostic evidence. " + "No plots were supplied for visual inspection.

    " + details + ) + rationale = html.escape(str(assessment.get("rationale", ""))) + action = { + "accept": "Accepted settings", + "experiment": "Selected a targeted experiment", + "enlarge": "Requested more cells", + "defer": "Required more evidence", + }.get(str(assessment.get("action", "")), "Analysis assessment") + protection = html.escape(str(assessment.get("objectivePreservation", ""))) + experiment = "" + if assessment.get("experimentId"): + experiment = ( + f"

    Experiment: {_escape(assessment['experimentId'])}

    " + f"

    Observed concern: {_escape(assessment.get('concern'))}

    " + f"

    Expected improvement: {_escape(assessment.get('expectedImprovement'))}

    " + ) + correction = assessment.get("correctionNeed") + correction_text = ( + f"

    Correction necessity: {_escape(label(str(correction)))}.

    " + if correction else "" ) - + "
    " - for item in interpretations - ) - interpretation_markup = ( - f'
    {cards}
    ' if cards else "" - ) - observation_markup = ( - '

    Observed group differences

    ' - f"{_render_plain_list(observations)}
    " - if observations - else "" - ) - follow_up_markup = ( - '

    Recommended follow-up

    ' - f"{_render_plain_list(follow_ups)}
    " - if follow_ups - else "" - ) - return f""" -
    -

    Biological interpretation

    - {interpretation_markup} - {observation_markup} - {follow_up_markup} -
    -""" - - -def _render_column_list(items: Sequence[str]) -> str: - if not items: - return '

    No matched feature names were recorded.

    ' - return '
      {}
    '.format( - "".join(f"
  • {html.escape(item)}
  • " for item in items) - ) - - -def _render_qc_technical_audit( - experimental: Mapping[str, Any], - plan: Mapping[str, Any], -) -> str: - profiles = _mappings(experimental.get("qcProfiles")) - if not profiles: - return "" - decision = _mapping(experimental.get("decision")) - cell_qc = _mapping(plan.get("cellQc")) - if not cell_qc: - cell_qc = _mapping(decision.get("cellQc")) - if not cell_qc: - cell_qc = _mapping(experimental.get("cellQc")) - selected = _selected_qc_profile(experimental, cell_qc) - selected_id = selected.get("profileId") - selected_name = selected.get("registeredProfile") - profile_rows = _qc_profile_rows(profiles) - for row, profile in zip(profile_rows, profiles, strict=True): - row["_selected"] = bool( - (selected_id and profile.get("profileId") == selected_id) - or ( - not selected_id - and selected_name - and profile.get("registeredProfile") == selected_name - ) - ) - selected_metrics = _qc_metric_rows(selected) - return f""" -
    -

    Cell QC audit

    -

    Selected and alternative filtering profiles, diagnostic flags, and every persisted cutoff are shown below. A cutoff in a retain-with-flags profile is diagnostic and did not remove cells.

    -

    Profile comparison

    {_table(profile_rows, columns=("profile", "scope", "active cells", "retained cells", "removed cells", "flags", "failed libraries"))}
    -

    Selected-profile metric summary

    {_table(selected_metrics, columns=("metric", "scope", "median", "diagnostic reference", "lower cutoff", "upper cutoff", "high flag", "flagged cells"))}
    -
    All global and per-library cutoffs{_table(_qc_bound_rows(profiles), columns=("profile", "group", "metric", "median", "lower removal", "upper removal", "upper flag"), empty="No persisted QC cutoffs were recorded.")}
    -
    -""" - - -def _render_feature_technical_audit( - plan: Mapping[str, Any], - inventories: Sequence[Mapping[str, Any]], -) -> str: - if not inventories: - return "" - policy_rows: list[dict[str, Any]] = [] - for assay_plan in _mappings(plan.get("assays")): - parameters = _mapping(assay_plan.get("featureParameters")) - if not parameters: - continue - policy_rows.append( - { - "assay": assay_plan.get("assay"), - "selected features": parameters.get("topN"), - "minimum detected cells": parameters.get("minCells"), - "excluded families": _text_values(parameters.get("excludeFamilies")), - "protected families": _text_values(parameters.get("protectFamilies")), - "complete default blacklist applied": ( - parameters.get("useScarfDefaultBlacklist") is True - ), - } - ) - inventory_markup: list[str] = [] - for inventory in inventories: - assay = _assay_label(inventory.get("assay")) or "Assay" - match_count = inventory.get("matchCount") - names = _text_values(inventory.get("matchedFeatures")) - inventory_markup.append( - _render_default_inventory_summary( - inventory, - heading=f"{assay} exact Scarf-default blacklist", - ) - + "
    All " - + ( - f"{int(match_count):,}" - if isinstance(match_count, int) - else f"{len(names):,}" - ) - + " matched feature names" - + _render_column_list(names) - + "
    " - ) - return f""" -
    -

    Normalization and feature-selection audit

    -

    The selected representation policy is separate from the exact Scarf-default blacklist reference. Genes excluded from map construction remained available to marker testing.

    - {_table(policy_rows, columns=("assay", "selected features", "minimum detected cells", "excluded families", "protected families", "complete default blacklist applied"))} - {"".join(inventory_markup)} -
    -""" - - -def _render_harmony_technical_audit( - experimental: Mapping[str, Any], - parameter: Mapping[str, Any], - final: Mapping[str, Any], - decisions: Mapping[str, Any], -) -> str: - native, harmony = _harmony_candidate_pair(parameter, final) - if not native and not harmony: - return "" - outcome = _active_decision(decisions, "correctionOutcome") - license_record = _active_decision(decisions, "correctionLicense") - rationale = str(outcome.get("rationale") or "").strip() - license_option = str(license_record.get("selectedOptionId") or "not recorded") - license_label = _label(license_option.rpartition(":")[2]) - run_status = ( - "completed" if _harmony_completed(harmony) else _scalar(harmony.get("status")) - ) - rationale_markup = ( - '

    Recorded rejection rationale

    ' - f"

    {html.escape(rationale)}

    " - if rationale - else "" - ) - candidate_rows = [ - { - "candidate": "Native", - "status": native.get("status"), - "eligible": native.get("eligible"), - **_mapping(native.get("parameters")), - }, - { - "candidate": "Harmony", - "status": harmony.get("status"), - "eligible": harmony.get("eligible"), - **_mapping(harmony.get("parameters")), - }, - ] - safety_rows: list[dict[str, Any]] = [] - for item in _mappings(experimental.get("batchSafety")): - estimability = _mapping(item.get("estimability")) - safety_rows.append( - { - "comparison": _public_field_label(item.get("coefficient")), - "status": item.get("status"), - "study units": estimability.get("rowsUsed"), - "technical rank": estimability.get("rankTechnical"), - "residual degrees of freedom": estimability.get("residualDf"), - "remaining capacity": estimability.get("estimableDf"), - } - ) - return f""" -
    -

    Harmony diagnostic audit

    -

    Run status: {html.escape(run_status)}. Selection license: {html.escape(license_label)}.

    -

    Matched candidates

    {_table(candidate_rows, columns=("candidate", "status", "eligible", "dimensions", "neighborsK", "leidenResolution", "useHarmony"))}
    -

    Native versus Harmony measurements

    {_table(_harmony_metric_rows(native, harmony), columns=("category", "metric", "native", "Harmony", "change", "interpretation"))}
    -

    Design safety

    {_table(safety_rows, columns=("comparison", "status", "study units", "technical rank", "residual degrees of freedom", "remaining capacity"), empty="No design-safety rows were recorded.")}
    - {rationale_markup} -
    -""" - - -def _analysis_limitations(payload: Mapping[str, Any]) -> list[str]: - reports = _mapping(payload.get("reports")) - workflow_result = _mapping(payload.get("workflowResult")) - final = _mapping(workflow_result.get("finalAnalysis")) - parameter = _latest(reports, "parameter_tuning") - biology = _latest(reports, "biological_interpretation") - interpretations = _mappings(biology.get("clusterInterpretations")) - limitations: list[str] = [] - if not interpretations: - limitations.append( - "No biological cell-type interpretation was generated, so the cell " - "groups should not be treated as named cell types." - ) - elif any(item.get("identityIsHypothesis") is True for item in interpretations): - limitations.append( - "Cell-group identities are hypotheses based on observed marker patterns " - "and need independent validation." - ) - if _mappings(biology.get("treatmentObservations")): - limitations.append( - "Reported group differences are descriptive and do not establish cause " - "and effect." - ) - if parameter.get("totalCandidates"): - limitations.append( - "The final result was selected only from the analysis settings that " - "were explicitly evaluated." - ) - if _present(final.get("limitations")) or _present(parameter.get("limitations")): - limitations.append( - "Additional technical limitations are recorded in the technical report." - ) - if _present(payload.get("plotNotes")): - limitations.append( - "Some optional visualizations were unavailable; the technical report " - "records the reason." - ) - return limitations - - -def _render_analysis_document(payload: Mapping[str, Any]) -> str: - reports = _mapping(payload.get("reports")) - workflow_result = _mapping(payload.get("workflowResult")) - plan = _mapping(workflow_result.get("preprocessingPlan")) - biology = _latest(reports, "biological_interpretation") - cluster_counts = { - str(key): int(value) - for key, value in _mapping(payload.get("clusterCounts")).items() - } - plots = { - str(key): str(value) - for key, value in _mapping(payload.get("plotFiles")).items() - } - objective, organisms, tissues = _study_overview(payload) - assays = _report_assays(plan) - total_cells = sum(cluster_counts.values()) - metrics = _render_metrics( - ( - ("Cells analyzed", total_cells or None), - ("Cell groups", len(cluster_counts) or None), - ("Data analyzed", _format_text_list(assays) or None), - ) - ) - source = _biological_source(organisms, tissues) or "Not specified" - source = source[:1].upper() + source[1:] - tree_stages = _analysis_tree_stages(payload) - selection_evidence = _render_selection_evidence(payload) - decision_evidence = _render_analysis_evidence(payload) - analysis_plots = _render_plots( - plots, - (), - order=("umapClusters", "clusterComposition", "markerHeatmap"), - titles={ - "umapClusters": ( - "Final cell map", - "Each point is a cell, colored by its selected cell group.", - ), - "clusterComposition": ( - "Relative group sizes", - "The relative size of each selected cell group.", - ), - "markerHeatmap": ( - "Marker patterns", - "Features that help distinguish the selected cell groups.", - ), - }, - show_provenance=False, - show_notes=False, - empty_message=( - "Visual results are unavailable for this report. Technical details " - "record the reason." - ), - ) - diagnostic_plot_order = ( - "qcDistributionsBeforeFiltering", - "qcDistributions", - *(name for name in plots if name.startswith("qcDistributionDerived")), - "hvgGlobal", - "hvgBatchAware", - ) - diagnostic_plots = ( - _render_plots( - plots, - (), - order=diagnostic_plot_order, - show_provenance=False, - show_notes=False, - ) - if any(name in plots for name in diagnostic_plot_order) - else "" - ) - diagnostic_section = ( - """ -
    -

    Quality control and variable-gene diagnostics

    -

    QC panels show the selected cutoff annotations. HVG panels highlight the genes retained by each executed ranking at the selected feature count.

    - {plots} -
    -""".format(plots=diagnostic_plots) - if diagnostic_plots - else "" - ) - limitations = _analysis_limitations(payload) - biology_markup = _render_analysis_biology(biology) - body = f"""

    Analysis summary

    -

    The analysis, at a glance.

    -

    {html.escape(objective)}

    - {metrics} - -
    -

    What was analyzed

    -
    -
    -

    Biological source

    -

    {html.escape(source)}

    -
    -
    -

    Final result

    -

    {total_cells:,} cells organized into {len(cluster_counts):,} groups

    -
    -
    -
    - -
    -

    Analysis decision tree

    -

    Each decision shows the selected branch, the alternatives considered, their measured values, and why the selected path continued.

    - {_render_decision_tree(tree_stages)} -
    - - {decision_evidence} - - {diagnostic_section} - -
    -

    Why the final result was selected

    -

    These are the main measurements supporting the final cell map. Values closer to 1 indicate stronger agreement for the stability and coherence measures.

    - {selection_evidence or '

    No final selection measurements were available.

    '} -
    - -
    -

    Visual results

    - {analysis_plots} -
    - - {biology_markup} - -
    -

    Limitations

    - {_render_plain_list(limitations) if limitations else "

    No additional user-facing limitations were recorded.

    "} -
    - - -""" - return _render_report_shell( - title="Scarf analysis summary", - active_page="analysis", - body=body, - ) - - -def _render_technical_document(payload: Mapping[str, Any]) -> str: - reports = _mapping(payload.get("reports")) - workflow_result = _mapping(payload.get("workflowResult")) - request = _mapping(payload.get("request")) - final = _mapping(workflow_result.get("finalAnalysis")) - plan = _mapping(workflow_result.get("preprocessingPlan")) - enrichment = _latest(reports, "data_enrichment") - experimental = _latest(reports, "experimental_context") - parameter = _latest(reports, "parameter_tuning") - biology = _latest(reports, "biological_interpretation") - decisions = _mapping(payload.get("activeDecisions")) - inventories = _mappings(payload.get("defaultFeatureInventories")) - cluster_counts = { - str(key): int(value) - for key, value in _mapping(payload.get("clusterCounts")).items() - } - top_markers = _mappings(payload.get("topMarkers")) - plots = { - str(key): str(value) - for key, value in _mapping(payload.get("plotFiles")).items() - } - plot_notes = [str(item) for item in payload.get("plotNotes", [])] - attempts = _mappings(payload.get("stageAttempts")) - resumes = _mappings(payload.get("workflowResumes")) - workflow = _mapping(workflow_result.get("workflowRun")) - workflow_id = str(workflow.get("workflowRunId") or "unavailable") - total_cells = sum(cluster_counts.values()) - assay_plans = _mappings(plan.get("assays")) - assays = [str(item.get("assay")) for item in assay_plans if item.get("assay")] - doublet_evidence = _mapping(final.get("doubletEvidence")) - marker_evidence = _mapping(final.get("markerEvidence")) - metrics = [ - ("Final cells", total_cells or None), - ("Final clusters", len(cluster_counts) or None), - ("Assays", ", ".join(assays) or None), - ("Candidates", parameter.get("totalCandidates")), - ("Selected graph", final.get("graphMethod")), - ("Marker assay", final.get("markerAssay")), - ("Marker specificity", marker_evidence.get("specificityMedian")), - ("Doublet capture coverage", doublet_evidence.get("captureCoverage")), - ( - "Statistical test artifacts", - len(_mappings(final.get("statisticalTests"))) or None, - ), - ] - metric_markup = _render_metrics(metrics) - interpretation = { - "status": biology.get("status"), - "clusterInterpretations": biology.get("clusterInterpretations"), - "evidenceIds": biology.get("evidenceIds"), - "stopReason": biology.get("stopReason"), - } - study = enrichment.get("studyContextSummary") or { - "originalContext": request.get("studyContext") - } - enrichment_summary = { - "status": enrichment.get("status"), - "policies": enrichment.get("policies"), - "inspections": enrichment.get("inspections"), - "defaultFeatureEvidence": enrichment.get("defaultFeatureEvidence"), - "evidenceIds": enrichment.get("evidenceIds"), - "unresolvedQuestions": enrichment.get("unresolvedQuestions"), - } - experimental_summary = { - "status": experimental.get("status"), - "decision": experimental.get("decision"), - "cellQc": experimental.get("cellQc"), - "qcProfiles": experimental.get("qcProfiles"), - "batchSafety": experimental.get("batchSafety"), - "characterization": experimental.get("characterization"), - "contrastPlans": experimental.get("contrastPlans"), - } - preprocessing_summary = { - "primaryAssay": plan.get("primaryAssay"), - "markerAssay": plan.get("markerAssay"), - "pairedAssays": plan.get("pairedAssays"), - "cellQc": plan.get("cellQc"), - "assays": plan.get("assays"), - "planChecksum": plan.get("planChecksum"), - } - limitations = { - "Data Enrichment": enrichment.get("limitations"), - "Experimental Context": experimental.get("notes"), - "Parameter Tuning": parameter.get("limitations"), - "Biological Interpretation": biology.get("limitations"), - "Final analysis": final.get("limitations"), - "Workflow": workflow_result.get("notes"), - "Plots": plot_notes, - } - limitations = {key: value for key, value in limitations.items() if _present(value)} - marker_columns = [ - key - for key in ( - "group_id", - "feature_name", - "feature_id", - "score", - "frac_exp", - "fold_change", - "p_value", - ) - if any(key in row for row in top_markers) - ] - provenance: list[dict[str, Any]] = [ - {"field": "Workflow run ID", "value": workflow_id}, - {"field": "Scarf version", "value": __version__}, - {"field": "Workspace", "value": workflow.get("workspace")}, - {"field": "Analysis store", "value": workflow.get("analysisStore")}, - {"field": "Dataset fingerprints", "value": workflow.get("datasetFingerprints")}, - {"field": "Generated at", "value": payload.get("generatedAt")}, - {"field": "Source path", "value": request.get("sourcePath")}, - ] - raw_json = json.dumps( - payload, indent=2, sort_keys=True, ensure_ascii=False, default=str - ) - biology_nav = ( - 'Biology' if biology else "" - ) - biology_markup = ( - f""" -
    -

    Biological interpretation

    - {_value(interpretation)} -

    Treatment observations

    {_value(biology.get("treatmentObservations"))}
    -

    Follow-up recommendations

    {_value(biology.get("followUps"))}
    -
    -""" - if biology - else "" - ) - qc_audit = _render_qc_technical_audit(experimental, plan) - feature_audit = _render_feature_technical_audit(plan, inventories) - harmony_audit = _render_harmony_technical_audit( - experimental, - parameter, - final, - decisions, - ) - qc_nav = 'QC' if qc_audit else "" - feature_nav = ( - 'Features' - if feature_audit - else "" - ) - harmony_nav = ( - 'Harmony' - if harmony_audit - else "" - ) - title = f"Scarf agent report {workflow_id}" - body = f"""

    Technical report

    -

    Evidence from an automated analysis.

    -

    The workflow completed and its selected artifacts and decisions are summarized here.

    -
    - Completed - {html.escape(_label(workflow_result.get("currentStage") or "completed"))} -
    - {metric_markup} - - - - -
    -

    Visual results

    Persisted artifacts
    - {_render_plots(plots, plot_notes)} -
    - -
    -

    Final partition evidence

    -

    Final cluster sizes

    {_render_clusters(cluster_counts)}
    -

    Top marker evidence

    {_table(top_markers, columns=marker_columns, empty="No marker table was available.")}
    -

    Marker-family summary

    {_value(marker_evidence)}
    -

    Advisory doublet summary

    {_value(doublet_evidence)}
    -
    - - {biology_markup} - - {qc_audit} - {feature_audit} - {harmony_audit} - -

    Study context

    {_value(study)}
    -

    Data enrichment

    {_value(enrichment_summary)}
    -

    Experimental design

    {_value(experimental_summary)}
    -

    Preprocessing plan

    {_value(preprocessing_summary)}
    - -

    Parameter tuning and graph selection

    {_render_parameter_tuning(parameter)}
    -

    Bounded analysis review and hypothesis tests

    {_value({"analysisEvidence": final.get("analysisEvidence"), "statisticalTests": final.get("statisticalTests")})}
    -

    Workflow execution

    {_render_timeline(attempts, resumes)}
    -

    Agent execution

    {_render_executions(reports)}
    -

    Limitations and workflow notes

    {_value(limitations) if limitations else '

    No limitations were recorded.

    '}
    - -
    -

    Technical provenance

    - {_table(provenance)} -
    Final immutable artifact references{_value(final)}
    -
    Structured report data
    {html.escape(raw_json)}
    -
    -""" - return _render_report_shell( - title=title, - active_page="technical", - body=body, - ) + decisions += f'

    {scope}: {action}

    {rationale}

    {experiment}
    Compared settings and evidence{comparison}{details}{correction_text}

    {protection}

    ' + if not decisions: + decisions = "

    No consequential decisions were recorded.

    " + findings = _list(texts(payload.get("findings"))) + marker_rows = mappings(payload.get("markers")) + cluster_table = _table( + ("Cluster", "Cells", "Top marker genes"), + [ + [ + cluster, + count, + ", ".join( + str(row["feature"]) + for row in marker_rows + if row.get("cluster") == cluster + ) + or "No markers passed the saved-table filters", + ] + for cluster, count in counts.items() + ], + ) + parameters = mapping(payload.get("selectedParameters")) + metrics = mapping(payload.get("selectedMetrics")) + selected_setting = mapping(payload.get("selectedSetting")) + selected_features = mapping(payload.get("selectedFeatures")) + methods = _table( + ("Setting", "Selected value"), + [ + [name, parameters[key]] + for key, name in ( + ("dimensions", "PCA dimensions"), + ("neighborsK", "Neighbors"), + ("leidenResolution", "Clustering resolution"), + ("useHarmony", "Harmony correction"), + ) + if key in parameters + ], + ) + methods += _table( + ("Gene selection", "Selected value"), + [ + [name, selected_setting[key]] + for key, name in ( + ("hvgCount", "HVG count"), + ("ranking", "HVG ranking"), + ("rankingColumn", "Ranking technical column"), + ) + if selected_setting.get(key) is not None + ], + ) + methods += _table( + ("Feature family", "Eligible genes", "Selected HVGs"), + [ + [ + label(name), + mapping(values).get("eligibleGenes"), + mapping(values).get("selectedGenes"), + ] + for name, values in mapping(selected_features.get("families")).items() + ], + ) + measurements = _table( + ("Measure", "Recorded value"), + [ + [name, metrics[key]] + for key, name in ( + ("seedStability", "Clustering stability across seeds"), + ("subsampleStability", "Clustering stability across subsamples"), + ("markerCoherence", "Marker coherence"), + ("crossUnitSupport", "Support across study units"), + ("minClusterCells", "Smallest cluster"), + ) + if key in metrics and metrics[key] is not None + ], + ) + limitations = _list(texts(payload.get("limitations"))) + display_notes = _list(texts(payload.get("displayNotes"))) + return f""" + +Scarf analysis summary
    +
    Scarf analysis

    Analysis summary

    {_escape(context)}

    +

    {total:,} cells · {len(counts):,} clusters · {_escape(assay)}

    {qc_text}
    +{map_markup}

    Analysis decisions

    {decisions}
    +{"

    What the evidence shows

    " + findings + "
    " if findings else ""} +

    Clusters and markers

    {cluster_table}

    Marker genes describe the saved clusters; they do not establish cell identities.

    +{"

    Limitations

    " + limitations + "
    " if limitations else ""} +
    Selected methods and measurements{methods}{measurements}

    All measurements and explanations are read from the completed analysis. The map uses saved coordinates and a bounded display sample. No analysis or model calls run when this report is generated.

    +{"
    Unavailable displays" + display_notes + "
    " if display_notes else ""} +
    Generated locally by Scarf.
    +
    """ diff --git a/scarf/agent/runtime.py b/scarf/agent/runtime.py deleted file mode 100644 index 0f5d8616..00000000 --- a/scarf/agent/runtime.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Runtime checks and local env loading for LLM endpoints.""" - -import json -import os -import urllib.error -import urllib.request -from pathlib import Path -from typing import Any - -_ENV_PATH = Path(__file__).resolve().parent / ".env" - - -def load_env(path: Path | None = None) -> Path | None: - """Load scarf/agent/.env into os.environ without overriding existing vars. - - Returns the path loaded, or None if no file was found. - """ - env_path = path or _ENV_PATH - if not env_path.is_file(): - return None - for line in env_path.read_text(encoding="utf-8").splitlines(): - line = line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - key, value = line.split("=", 1) - key = key.strip() - value = value.strip().strip('"').strip("'") - if key and key not in os.environ: - os.environ[key] = value - return env_path - - -def _unreachable_message(baseUrl: str, model: str) -> str: - return ( - f"OpenAI-compatible endpoint unreachable at {baseUrl!r} " - f"(requested model {model!r})." - ) - - -def _missing_model_message(baseUrl: str, model: str, available: set[str]) -> str: - listed = ", ".join(sorted(available)) if available else "(none)" - return f"Model {model!r} not found at {baseUrl!r}. Available models: {listed}." - - -def _missing_config_message() -> str: - example = _ENV_PATH.with_name(".env.example") - return ( - "baseUrl and model are required. Pass them as arguments or set " - "OLLAMA_BASE_URL and OLLAMA_MODEL in the environment or in " - f"{_ENV_PATH} (see {example})." - ) - - -def _request_json( - url: str, - *, - timeout: float, - api_key: str | None, -) -> Any: - headers = {"Accept": "application/json"} - if api_key: - headers["Authorization"] = f"Bearer {api_key}" - request = urllib.request.Request(url, headers=headers, method="GET") - with urllib.request.urlopen(request, timeout=timeout) as response: - return json.loads(response.read().decode("utf-8")) - - -def _listed_model_ids(payload: Any) -> set[str]: - ids: set[str] = set() - if not isinstance(payload, dict): - return ids - data = payload.get("data") - if isinstance(data, list): - for item in data: - if isinstance(item, dict) and isinstance(item.get("id"), str): - ids.add(item["id"]) - models = payload.get("models") - if isinstance(models, list): - for item in models: - if not isinstance(item, dict): - continue - name = item.get("name") or item.get("model") - if isinstance(name, str): - ids.add(name) - return ids - - -def check_runtime( - *, - baseUrl: str | None = None, - model: str | None = None, - timeout: float = 5.0, -) -> None: - """Fail fast if the OpenAI-compatible endpoint or model is unavailable. - - Loads `scarf/agent/.env` first (without overriding existing env vars). - Uses `OLLAMA_BASE_URL`, `OLLAMA_MODEL`, and `OLLAMA_API_KEY` when args are omitted. - """ - load_env() - resolved_base = baseUrl or os.environ.get("OLLAMA_BASE_URL") - resolved_model = model or os.environ.get("OLLAMA_MODEL") - if not resolved_base or not resolved_model: - raise ValueError(_missing_config_message()) - - api_key = os.environ.get("OLLAMA_API_KEY") or None - models_url = f"{resolved_base.rstrip('/')}/models" - try: - payload = _request_json(models_url, timeout=timeout, api_key=api_key) - except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: - raise RuntimeError(_unreachable_message(resolved_base, resolved_model)) from exc - - available = _listed_model_ids(payload) - if resolved_model not in available: - raise RuntimeError( - _missing_model_message(resolved_base, resolved_model, available) - ) diff --git a/scarf/agent/types.py b/scarf/agent/types.py index 28e9ed3c..3e9d946a 100644 --- a/scarf/agent/types.py +++ b/scarf/agent/types.py @@ -30,11 +30,6 @@ def get_blank(cls) -> "AgentDataModel": """Return an empty but valid value for fallback paths.""" return cls() - @classmethod - def get_example(cls) -> "AgentDataModel": - """Return a small representative value for tests and fixtures.""" - return cls.get_blank() - class ArtifactReferenceModel(AgentDataModel): type: Literal["artifact"] = "artifact" @@ -53,14 +48,6 @@ def from_artifact_ref(cls, ref: Any) -> "ArtifactReferenceModel": artifactId=str(getattr(ref, "artifact_id", "")), ) - @classmethod - def get_example(cls) -> "ArtifactReferenceModel": - return cls( - assay="RNA", - kind="reduction", - artifactId="0" * 64, - ) - class BatchSafetyEvidence(AgentDataModel): """Estimability for one coefficient and exact proposed batch-column set.""" @@ -78,23 +65,6 @@ class BatchSafetyEvidence(AgentDataModel): def get_blank(cls) -> "BatchSafetyEvidence": return cls() - @classmethod - def get_example(cls) -> "BatchSafetyEvidence": - return cls( - coefficient="treatment", - coefficientKind="categorical", - observationUnit="sample", - batchColumns=["batch"], - unitConstantBatchColumns=["batch"], - status="safe", - estimability={ - "status": "ok", - "coefficientEstimable": True, - "rankDeficient": False, - }, - evidenceId="batchEstimability:treatment:batch", - ) - class ExperimentalTuningHandoff(AgentDataModel): """Validated Experimental Context inputs for Parameter Tuning.""" @@ -135,37 +105,12 @@ class TuningBiologyHandoff(AgentDataModel): def get_blank(cls) -> "TuningBiologyHandoff": return cls() - @classmethod - def get_example(cls) -> "TuningBiologyHandoff": - return cls( - cellSelection=ArtifactReferenceModel( - scope="datastore", - assay=None, - kind="cell_selection", - artifactId="c" * 64, - ), - fromAssay="RNA", - graphAssay="RNA", - markerAssay="RNA", - recommendedCandidateId="baseline", - clusterArtifact=ArtifactReferenceModel( - assay="RNA", - kind="cluster_labels", - artifactId="1" * 64, - ), - evidenceIds=["candidate:baseline:clusters"], - ) - class ToolCallInfo(AgentDataModel): toolName: str = "" callId: str = "" arguments: dict[str, Any] = Field(default_factory=dict) - @classmethod - def get_example(cls) -> "ToolCallInfo": - return cls(toolName="inspect_store", callId="tool-call-1") - class AgentUsageInfo(AgentDataModel): inputTokens: int = 0 @@ -174,16 +119,6 @@ class AgentUsageInfo(AgentDataModel): requests: int = 0 toolCalls: int = 0 - @classmethod - def get_example(cls) -> "AgentUsageInfo": - return cls( - inputTokens=100, - outputTokens=50, - totalTokens=150, - requests=2, - toolCalls=1, - ) - class AgentRunInfo(AgentDataModel): agentName: str = "" @@ -193,26 +128,11 @@ class AgentRunInfo(AgentDataModel): usage: AgentUsageInfo = Field(default_factory=AgentUsageInfo) toolCalls: list[ToolCallInfo] = Field(default_factory=list) - @classmethod - def get_example(cls) -> "AgentRunInfo": - return cls( - agentName="data_enrichment", - modelName="example-model", - runId="example-run", - durationSeconds=0.1, - usage=AgentUsageInfo.get_example(), - toolCalls=[ToolCallInfo.get_example()], - ) - class AgentExecutionResult(AgentDataModel): output: Any = None runInfo: AgentRunInfo = Field(default_factory=AgentRunInfo) - @classmethod - def get_example(cls) -> "AgentExecutionResult": - return cls(output={}, runInfo=AgentRunInfo.get_example()) - class EvidenceItem(AgentDataModel): id: str @@ -223,14 +143,6 @@ class EvidenceItem(AgentDataModel): def get_blank(cls) -> "EvidenceItem": return cls(id="", label="", summary="") - @classmethod - def get_example(cls) -> "EvidenceItem": - return cls( - id="evidence:example", - label="example", - summary="A bounded observed fact.", - ) - class Decision(AgentDataModel): selectedId: str = Field( @@ -246,14 +158,6 @@ class Decision(AgentDataModel): def get_blank(cls) -> "Decision": return cls(selectedId="", rationale="") - @classmethod - def get_example(cls) -> "Decision": - return cls( - selectedId="evidence:example", - rationale="The evidence directly answers the question.", - evidenceIds=["evidence:example"], - ) - class NeedsInput(AgentDataModel): question: str @@ -264,13 +168,6 @@ class NeedsInput(AgentDataModel): def get_blank(cls) -> "NeedsInput": return cls(question="") - @classmethod - def get_example(cls) -> "NeedsInput": - return cls( - question="Which condition column should be used?", - options=["condition", "treatment"], - ) - class StageResult(AgentDataModel): status: StageStatus @@ -282,7 +179,3 @@ class StageResult(AgentDataModel): @classmethod def get_blank(cls) -> "StageResult": return cls(status="needsInput") - - @classmethod - def get_example(cls) -> "StageResult": - return cls(status="done", decision=Decision.get_example()) diff --git a/tests/agent_examples.py b/tests/agent_examples.py new file mode 100644 index 00000000..65f027a3 --- /dev/null +++ b/tests/agent_examples.py @@ -0,0 +1,1261 @@ +"""Representative agent values belong to test fixtures, not production APIs.""" + + +def example(model): + """Construct a fixture through its closest registered model factory.""" + for base in model.__mro__: + factory = _FACTORIES.get(f"{base.__module__}.{base.__name__}") + if factory is not None: + return factory(model) + return model.get_blank() + + +def _example_0_BiologicalContext(cls): + return cls( + organism="Homo sapiens", + studyContext="Human lung samples were profiled after drug or vehicle treatment.", + tissue="lung", + cellTypeReferences=["alveolar macrophage", "T cell"], + experimentalDetails=["drug and vehicle groups"], + treatmentQuestion="Which populations respond selectively to treatment?", + ) + + +def _example_1_ConditionClusterSummary(cls): + return cls( + condition="treated", + clusterId="3", + nSamples=4, + meanFraction=0.18, + minFraction=0.12, + maxFraction=0.25, + cellCount=180, + evidenceId="composition:RNA_cluster:condition:treated:cluster:3", + ) + + +def _example_2_ClusterCompositionEvidence(cls): + from scarf.agent.biological_interpretation.contracts import ( + ArtifactReferenceModel, + ConditionClusterSummary, + ) + + summary = example(ConditionClusterSummary) + reference_summary = ConditionClusterSummary( + condition="control", + clusterId=summary.clusterId, + nSamples=4, + meanFraction=0.11, + minFraction=0.08, + maxFraction=0.15, + cellCount=110, + evidenceId="composition:RNA_cluster:condition:control:cluster:3", + ) + return cls( + clusterArtifact=ArtifactReferenceModel( + assay="RNA", kind="cluster_labels", artifactId="b" * 64 + ), + cellSelection=ArtifactReferenceModel( + scope="datastore", assay=None, kind="cell_selection", artifactId="c" * 64 + ), + totalCells=1000, + clusterCounts={"0": 520, "1": 300, "3": 180}, + sampleColumn="sample", + conditionColumn="treatment", + conditionSummaries=[reference_summary, summary], + evidenceIds=[ + "composition:RNA_cluster:counts", + reference_summary.evidenceId, + summary.evidenceId, + ], + ) + + +def _example_3_MarkerFeature(cls): + return cls( + featureId="ENSG00000173372", + featureName="C1QA", + featureIndex=123, + score=0.83, + foldChange=3.4, + fractionExpressed=0.76, + fractionExpressedRest=0.18, + auc=0.91, + adjustedPvalue=0.001, + ) + + +def _example_4_ClusterMarkerEvidence(cls): + from scarf.agent.biological_interpretation.contracts import ( + ArtifactReferenceModel, + MarkerFeature, + ) + + return cls( + clusterId="3", + markers=[example(MarkerFeature)], + markerArtifact=ArtifactReferenceModel( + assay="RNA", kind="marker_table", artifactId="a" * 64 + ), + evidenceId="markers:RNA_cluster:cluster:3", + ) + + +def _example_5_ClusterMarkerBatchEvidence(cls): + from scarf.agent.biological_interpretation.contracts import ClusterMarkerEvidence + + cluster = example(ClusterMarkerEvidence) + return cls(clusters=[cluster], evidenceIds=[cluster.evidenceId]) + + +def _example_6_ClusterInterpretation(cls): + return cls( + clusterId="3", + proposedIdentity="alveolar macrophage-like", + identityIsHypothesis=True, + confidence="medium", + rationale="Observed marker pattern is consistent with the proposed identity.", + evidenceIds=["markers:RNA_cluster:cluster:3"], + ) + + +def _example_7_TreatmentObservation(cls): + return cls( + clusterId="3", + referenceCondition="control", + comparisonCondition="treated", + direction="higher", + observation="Cluster 3 has a higher mean fraction in treated samples.", + evidenceIds=[ + "composition:RNA_cluster:condition:control:cluster:3", + "composition:RNA_cluster:condition:treated:cluster:3", + ], + ) + + +def _example_8_FollowUpRecommendation(cls): + return cls( + question="Is the abundance difference reproducible across donors?", + operation="sample-level differential abundance", + rationale="Current evidence is descriptive and requires independent replicates.", + requiredInputs=["sample", "condition", "donor"], + evidenceIds=[ + "composition:RNA_cluster:condition:control:cluster:3", + "composition:RNA_cluster:condition:treated:cluster:3", + ], + ) + + +def _example_9_BiologicalInterpretationNeedsInput(cls): + return cls( + question="Provide an exact marker artifact or authorize marker search.", + requiredInputs=["markerArtifact"], + ) + + +def _example_10_BiologicalInterpretationReport(cls): + from scarf.agent.biological_interpretation.contracts import ( + ClusterCompositionEvidence, + ClusterInterpretation, + ClusterMarkerEvidence, + FollowUpRecommendation, + TreatmentObservation, + ) + + interpretation = example(ClusterInterpretation) + observation = example(TreatmentObservation) + follow_up = example(FollowUpRecommendation) + return cls( + status="done", + clusterInterpretations=[interpretation], + treatmentObservations=[observation], + followUps=[follow_up], + clusterArtifact=example(ClusterCompositionEvidence).clusterArtifact, + markerArtifact=example(ClusterMarkerEvidence).markerArtifact, + graphAssay="RNA", + markerAssay="RNA", + evidenceIds=sorted( + { + *interpretation.evidenceIds, + *observation.evidenceIds, + *follow_up.evidenceIds, + } + ), + limitations=[ + "Cell identities remain hypotheses until independently validated." + ], + stopReason="The requested clusters were reviewed.", + ) + + +def _example_11_BiologicalInterpretationDependencies(cls): + return cls( + cluster=object(), + fromAssay="RNA", + graphAssay="RNA", + markerAssay="RNA", + markerAssayType="RNA", + sampleColumn="sample", + conditionColumn="treatment", + ) + + +def _example_12_AgentRunConfig(cls): + return cls(requestLimit=9, toolCallLimit=5, outputTokenLimit=2048) + + +def _example_13_FeatureCharacterization(cls): + return cls( + status="done", + notes=["Feature identity and families were characterized."], + assays=[{"assay": "RNA", "species": "homo_sapiens"}], + ) + + +def _example_14_DataEnrichmentContext(cls): + return cls( + studyContext="Single-cell profiling of treated lung tissue", + studyObjective="Discover stable populations while preserving treatment effects.", + organismHint="human", + tissueReferences=["lung"], + cellTypeReferences=["alveolar macrophage", "T cell"], + experimentalDetails=["CRISPR perturbation", "10x 3 prime RNA-seq"], + ) + + +def _example_15_StudyContextSummary(cls): + return cls( + studyContext="Single-cell profiling of treated human lung tests whether treatment changes alveolar macrophage states.", + studyObjective="Discover populations while preserving the treatment comparison.", + organismReferences=["human"], + tissueReferences=["lung"], + cellTypeReferences=["alveolar macrophage"], + experimentalReferences=["treated"], + hypothesisReferences=["treatment changes alveolar macrophage states"], + analysisIntentReferences=["Single-cell profiling"], + evidenceIds=["context:study"], + ) + + +def _example_16_AdtControlEvidence(cls): + return cls( + featureId="Mouse-IgG1-Control", + featureName="Mouse IgG1 isotype control", + matchedToken="isotype", + evidenceId="assay:ADT:adtControl:Mouse-IgG1-Control", + ) + + +def _example_17_HtoTagEvidence(cls): + return cls( + featureId="HTO-1", + featureName="Sample tag 1", + evidenceId="assay:HTO:htoTag:HTO-1", + ) + + +def _example_18_AtacCoordinateEvidence(cls): + return cls( + status="valid", + totalFeatures=2, + validFeatures=2, + validExamples=["chr1:100-200", "chr2:300-450"], + evidenceId="assay:ATAC:atacCoordinates", + ) + + +def _example_19_AssayModalityEvidence(cls): + from scarf.agent.data_enrichment.contracts import AdtControlEvidence + + control = example(AdtControlEvidence) + return cls( + assayType="ADT", + modality="ADT", + typeSource="persisted", + graphEligible=True, + markerEligible=True, + adtControls=[control], + totalObservedFeatures=20, + reportedFeatures=1, + evidenceIds=["assay:ADT:modality", control.evidenceId], + ) + + +def _example_20_FeatureFamilyEvidence(cls): + return cls( + family="mitochondrial", + species="homo_sapiens", + method="chromosome", + count=2, + examples=["MT-CO1", "MT-CYB"], + defaultExclude=True, + evidenceId="assay:RNA:family:mitochondrial", + ) + + +def _example_21_DefaultHvgFamilyEvidence(cls): + return cls( + family="mitochondrial", + pattern="^MT-", + count=2, + examples=["MT-CO1", "MT-CYB"], + evidenceId="assay:RNA:scarfDefaultHvg:family:mitochondrial", + ) + + +def _example_22_RnaFeatureInventoryEvidence(cls): + from scarf.features.variability import DEFAULT_HVG_BLACKLIST + from scarf.agent.data_enrichment.contracts import DefaultHvgFamilyEvidence + + family = example(DefaultHvgFamilyEvidence) + evidence_id = "assay:RNA:scarfDefaultHvg:combined" + return cls( + totalFeatures=20000, + blacklist=DEFAULT_HVG_BLACKLIST, + matchCount=2, + examples=["MT-CO1", "MT-CYB"], + families=[family], + evidenceId=evidence_id, + evidenceIds=[evidence_id, family.evidenceId], + ) + + +def _example_23_ExogenousFeatureEvidence(cls): + return cls( + featureId="ERCC-00002", + featureName="ERCC-00002", + score=4, + classification="potentialExogenous", + evidenceId="assay:RNA:exogenous:ERCC-00002", + ) + + +def _example_24_AssayFeatureInspection(cls): + from scarf.agent.data_enrichment.contracts import ( + AssayModalityEvidence, + FeatureFamilyEvidence, + RnaFeatureInventoryEvidence, + ) + + family = example(FeatureFamilyEvidence) + default_inventory = example(RnaFeatureInventoryEvidence) + modality = AssayModalityEvidence( + assayType="RNA", + modality="RNA", + typeSource="persisted", + graphEligible=True, + markerEligible=True, + totalObservedFeatures=20000, + evidenceIds=["assay:RNA:modality"], + ) + return cls( + assay="RNA", + assayKind="RNAassay", + identity={"nFeatures": 20000, "nDuplicateIds": 0}, + species="homo_sapiens", + speciesMethod="ensemblPrefix", + speciesReason="Most feature IDs carry the ENSG prefix", + families=[family], + defaultFeatureInventory=default_inventory, + modalityEvidence=modality, + evidenceIds=[ + "assay:RNA:identity", + "assay:RNA:species", + family.evidenceId, + *default_inventory.evidenceIds, + *modality.evidenceIds, + ], + ) + + +def _example_25_AssayFeatureInspectionBatch(cls): + from scarf.agent.data_enrichment.contracts import AssayFeatureInspection + + inspection = example(AssayFeatureInspection) + return cls(inspections=[inspection], evidenceIds=list(inspection.evidenceIds)) + + +def _example_26_FeatureReference(cls): + return cls(featureId="ENSG00000198727", featureName="MT-CYB") + + +def _example_27_FeatureMatch(cls): + from scarf.agent.data_enrichment.contracts import FeatureReference + + return cls( + query="MT-CYB", + status="present", + matches=[example(FeatureReference)], + evidenceIds=["assay:RNA:feature:ENSG00000198727"], + ) + + +def _example_28_FeatureLookupResult(cls): + from scarf.agent.data_enrichment.contracts import FeatureMatch + + match = example(FeatureMatch) + return cls(assay="RNA", results=[match], evidenceIds=list(match.evidenceIds)) + + +def _example_29_FeatureLookupBatch(cls): + from scarf.agent.data_enrichment.contracts import FeatureLookupResult + + lookup = example(FeatureLookupResult) + return cls(lookups=[lookup], evidenceIds=list(lookup.evidenceIds)) + + +def _example_30_FeatureSelectionPolicy(cls): + return cls( + assay="RNA", + species="homo_sapiens", + organismName="human", + speciesConfidence="high", + speciesRationale="Gene IDs and study context agree", + excludeFamilies=["mitochondrial", "ribosomal"], + protectFamilies=["cellCycle", "sex"], + artificialFeatures=["ERCC-00002"], + tissueReferences=["lung"], + cellTypeReferences=["alveolar macrophage"], + experimentalReferences=["ERCC spike-in"], + assayType="RNA", + assayModality="RNA", + graphEligible=True, + markerEligible=True, + rationale="Use technical families for feature-selection exclusions", + evidenceIds=["assay:RNA:species", "assay:RNA:family:mitochondrial"], + ) + + +def _example_31_DataEnrichmentToolCall(cls): + return cls( + name="inspect_assay_features", + assay="RNA", + evidenceIds=["assay:RNA:identity", "assay:RNA:species"], + ) + + +def _example_32_DataEnrichmentReport(cls): + from scarf.agent.data_enrichment.contracts import ( + AgentRunInfo, + AssayFeatureInspection, + DataEnrichmentToolCall, + FeatureSelectionPolicy, + StudyContextSummary, + ) + + policy = example(FeatureSelectionPolicy) + inspection = example(AssayFeatureInspection) + return cls( + status="done", + policies=[policy], + inspections=[inspection], + studyContextSummary=example(StudyContextSummary), + evidenceIds=list(policy.evidenceIds), + toolCalls=[example(DataEnrichmentToolCall)], + runInfo=example(AgentRunInfo), + ) + + +def _example_33_DataEnrichmentDependencies(cls): + from scarf.agent.data_enrichment.contracts import DataEnrichmentContext, Path + + return cls( + context=example(DataEnrichmentContext), + assays=["RNA"], + cacheDir=Path("/tmp/scarf-gene-reference"), + allowDownload=False, + evidenceIds={"context:organism", "context:tissue:0"}, + ) + + +def _example_34_CovariateCharacterization(cls): + from scarf.agent.experimental_context.contracts import ArtifactReferenceModel + + return cls( + status="done", + cellSelection=ArtifactReferenceModel( + scope="datastore", kind="cell_selection", artifactId="c" * 64 + ), + notes=["Cell covariates and confounding were characterized."], + columns=[{"name": "batch", "domain": "technical"}], + ) + + +def _example_35_InferenceUnit(cls): + return cls(observationUnit="sample", independentUnit="donor") + + +def _example_36_BatchCorrectionPlan(cls): + return cls( + action="evaluateHarmony", + batchColumns=["batch"], + preserveColumns=["cell_type", "treatment"], + metricsRequired=["iLISI", "cLISI", "graphConnectivity"], + rationale="Batch is technical and crossed with treatment, so compare an exact Harmony candidate while protecting biological labels.", + evidenceIds=[ + "column:batch", + "estimability:treatment", + "batchEstimability:treatment:batch", + ], + ) + + +def _example_37_NamedArtifactSource(cls): + from scarf.agent.experimental_context.contracts import ArtifactReferenceModel + + return cls( + name="RNA_percentMito", + artifact=ArtifactReferenceModel( + assay="RNA", kind="quality_metric", artifactId="1" * 64 + ), + ) + + +def _example_38_CellQcProfileEvidence(cls): + from scarf.agent.experimental_context.contracts import NamedArtifactSource + + return cls( + profileId="cellQc:RNA:globalMad5", + action="registeredMad", + registeredProfile="globalMad5", + driverAssay="RNA", + driverAssayType="RNA", + attributes=["RNA_nCounts", "RNA_nFeatures"], + artifactMetrics=[example(NamedArtifactSource)], + parameters={"nMads": 5.0}, + activeCells=100, + retainedCells=96, + retainedFraction=0.96, + evidenceId="qcProfile:cellQc:RNA:globalMad5", + ) + + +def _example_39_CellQcPlan(cls): + from scarf.agent.experimental_context.contracts import CellQcProfileEvidence + + evidence = example(CellQcProfileEvidence) + return cls( + action=evidence.action, + registeredProfile=evidence.registeredProfile, + profileId=evidence.profileId, + driverAssay=evidence.driverAssay, + driverAssayType=evidence.driverAssayType, + sampleColumn=evidence.sampleColumn, + sampleArtifact=evidence.sampleArtifact, + attributes=evidence.attributes, + artifactMetrics=evidence.artifactMetrics, + rationale="Use the bounded global profile for the RNA assay.", + evidenceIds=[evidence.evidenceId], + ) + + +def _example_40_ExperimentalContextDecision(cls): + from scarf.agent.experimental_context.contracts import ( + BatchCorrectionPlan, + InferenceUnit, + ) + + return cls( + columnDomains={ + "batch": "technical", + "sample": "design", + "donor": "design", + "treatment": "biological", + }, + coefficientsOfInterest=["treatment"], + unitsOfInference={"treatment": example(InferenceUnit)}, + batchCorrection=example(BatchCorrectionPlan), + rationale="Treatment is the primary between-sample contrast.", + evidenceIds=[ + "column:batch", + "column:donor", + "column:sample", + "column:treatment", + ], + ) + + +def _example_41_RepresentationEvaluation(cls): + from scarf.agent.experimental_context.contracts import ArtifactReferenceModel + + return cls( + available=True, + assay="RNA", + cellSelection=ArtifactReferenceModel( + scope="datastore", kind="cell_selection", artifactId="c" * 64 + ), + neighbors=ArtifactReferenceModel( + assay="RNA", kind="neighbors", artifactId="a" * 64 + ), + connectivityMap=ArtifactReferenceModel( + assay="RNA", kind="connectivity_map", artifactId="b" * 64 + ), + metrics={"iLISI:batch": 0.71, "cLISI:cell_type": 0.94}, + evidenceIds=[ + "metric:iLISI:batch:assay:RNA:neighbors:example-neighbors", + "metric:cLISI:cell_type:assay:RNA:neighbors:example-neighbors", + ], + ) + + +def _example_42_CovariateEvidence(cls): + from scarf.agent.experimental_context.contracts import ( + ArtifactReferenceModel, + CellQcProfileEvidence, + CovariateCharacterization, + NamedArtifactSource, + ) + + return cls( + characterization=CovariateCharacterization( + status="done", notes=["Example deterministic covariate characterization"] + ), + qcProfiles=[example(CellQcProfileEvidence)], + htoIdentityColumns=["sample_id"], + htoIdentityArtifacts=[ + NamedArtifactSource( + name="HTO_htoIdentity", + artifact=ArtifactReferenceModel( + assay="HTO", kind="hto_identity", artifactId="2" * 64 + ), + ) + ], + evidenceIds=[ + "column:batch", + example(CellQcProfileEvidence).evidenceId, + "htoIdentity:sample_id", + f"htoIdentityArtifact:HTO_htoIdentity:{'2' * 64}", + ], + ) + + +def _example_43_ExperimentalContextResult(cls): + from scarf.agent.experimental_context.contracts import ( + AgentRunInfo, + ArtifactReferenceModel, + BatchSafetyEvidence, + CellQcProfileEvidence, + CovariateCharacterization, + ExperimentalContextDecision, + NamedArtifactSource, + RepresentationEvaluation, + ) + + representation = example(RepresentationEvaluation) + return cls( + status="done", + decision=example(ExperimentalContextDecision), + characterization=CovariateCharacterization( + status="done", notes=["Example deterministic design characterization"] + ), + cellSelection=representation.cellSelection, + qcProfiles=[example(CellQcProfileEvidence)], + qualityMetricArtifacts=[example(NamedArtifactSource)], + htoIdentityColumns=["sample_id"], + htoIdentityArtifacts=[ + NamedArtifactSource( + name="HTO_htoIdentity", + artifact=ArtifactReferenceModel( + assay="HTO", kind="hto_identity", artifactId="2" * 64 + ), + ) + ], + batchSafety=[example(BatchSafetyEvidence)], + currentRepresentation=representation, + runInfo=example(AgentRunInfo), + ) + + +def _example_44_ExperimentalContextDependencies(cls): + return cls( + studyContext="Case-control study with samples nested in donors.", + studyObjective="Discover populations while preserving the case-control contrast.", + directions={"columnDomains": {"batch": "technical"}}, + ) + + +def _example_45_StudyContract(cls): + return cls( + studyContext="Treated and control blood samples from multiple donors.", + studyObjective="Discover stable populations while preserving treatment-associated structure.", + processingGoal="conditionPreservingDiscovery", + scientificQuestions=[ + "Discover stable populations while preserving treatment-associated structure." + ], + physicalCaptureColumn="sample", + independentUnitColumns=["donor"], + conditionColumns=["treatment"], + technicalBatchColumns=["batch"], + protectedColumns=["treatment", "donor"], + correctionLicense="safe", + allowedClaims=["Describe reproducible population structure."], + unsupportedClaims=[ + "This workflow does not test differential-expression hypotheses." + ], + evidenceIds=["column:batch", "column:donor", "column:treatment"], + ) + + +def _example_46_IngestResult(cls): + return cls( + status="done", format="h5ad", zarrPath="dataset.zarr", assayNames=["RNA"] + ) + + +def _example_47_WorkflowQuestion(cls): + return cls( + questionId="approvePlanChecksum", + question="Approve this preprocessing plan?", + planChecksum="0" * 64, + ) + + +def _example_48_WorkflowNeedsInput(cls): + from scarf.agent.orchestrator.models import WorkflowQuestion + + return cls(questions=[example(WorkflowQuestion)]) + + +def _example_49_WorkflowStageLink(cls): + return cls(stage="ingest", attemptId="attempt-1", contentSha256="0" * 64) + + +def _example_50_WorkflowStageAttempt(cls): + return cls( + workflowRunId="workflow-1", + stage="ingest", + attemptId="attempt-1", + status="done", + startedAtNs=1, + completedAtNs=2, + requestSha256="0" * 64, + configSha256="1" * 64, + contentSha256="2" * 64, + ) + + +def _example_51_AssayPreprocessingPlan(cls): + return cls( + assay="RNA", + assayType="RNA", + role="graph", + graphEligible=True, + markerEligible=True, + featureMethod="hvg", + reductionMethod="pca", + featureParameters={"topN": 1000, "minCells": 20}, + ) + + +def _example_52_AutomatedPreprocessingPlan(cls): + from scarf.agent.orchestrator.models import ( + ArtifactReferenceModel, + AssayPreprocessingPlan, + ) + + return cls( + primaryAssay="RNA", + markerAssay="RNA", + cellSelection=ArtifactReferenceModel( + scope="datastore", kind="cell_selection", artifactId="c" * 64 + ), + assays=[example(AssayPreprocessingPlan)], + planChecksum="0" * 64, + ) + + +def _example_53_PreprocessedAssayHandoff(cls): + from scarf.agent.orchestrator.models import ArtifactReferenceModel + + return cls( + assay="RNA", + assayType="RNA", + cellSelection=ArtifactReferenceModel( + scope="datastore", kind="cell_selection", artifactId="c" * 64 + ), + reductionMethod="pca", + graphFeatures=example(ArtifactReferenceModel), + markerFeatures=example(ArtifactReferenceModel), + normalized=ArtifactReferenceModel( + assay="RNA", kind="normalized", artifactId="1" * 64 + ), + nCells=100, + nFeatures=1000, + ) + + +def _example_55_FinalAnalysisHandoff(cls): + from scarf.agent.orchestrator.models import ArtifactReferenceModel + + return cls( + workflowRunId="workflow-1", + primaryAssay="RNA", + markerAssay="RNA", + cellSelection=ArtifactReferenceModel( + scope="datastore", kind="cell_selection", artifactId="c" * 64 + ), + graph=ArtifactReferenceModel( + assay="RNA", kind="connectivity_map", artifactId="2" * 64 + ), + embeddingInitialization=ArtifactReferenceModel( + assay="RNA", kind="embedding_initialization", artifactId="5" * 64 + ), + clusters=ArtifactReferenceModel( + assay="RNA", kind="cluster_labels", artifactId="3" * 64 + ), + ) + + +def _example_56_AutomatedWorkflowConfig(cls): + return cls() + + +def _example_57_AutomatedWorkflowRequest(cls): + return cls( + sourcePath="dataset.h5ad", + zarrPath="dataset.zarr", + studyContext="Single-cell profiling of treated human blood.", + studyObjective="Discover stable populations while preserving treatment structure.", + ) + + +def _example_58_AutomatedWorkflowResumeRequest(cls): + return cls( + zarrPath="dataset.zarr", + workflowRunId="workflow-1", + answers={"approvePlanChecksum": "0" * 64}, + ) + + +def _example_59_OrchestrationResumeRecord(cls): + return cls(workflowRunId="workflow-1", answers={"approvePlanChecksum": "0" * 64}) + + +def _example_60_ArtifactRecord(cls): + return cls(scope="assay", kind="connectivity_map", artifactId="a" * 64, assay="RNA") + + +def _example_61_ParameterCandidate(cls): + return cls( + candidateId="baseline", + reductionMethod="pca", + dimensions=21, + leidenResolution=1.0, + neighborsK=11, + useHarmony=False, + ) + + +def _example_62_ParameterMetrics(cls): + return cls( + nClusters=8, + minClusterCells=42, + minClusterFraction=0.021, + graphSilhouetteMedian=0.41, + pcaSilhouette=0.36, + macroF1=0.82, + weightedF1=0.86, + batchMixing={"batch": 0.73}, + biologicalPreservation={ + "cell_type": {"clisi": 0.88, "graphConnectivity": 0.91} + }, + ) + + +def _example_63_ParameterCandidateEvaluation(cls): + from scarf.agent.parameter_tuning.contracts import ( + ArtifactRecord, + ArtifactReferenceModel, + ParameterCandidate, + ParameterMetrics, + ) + + candidate = example(ParameterCandidate) + return cls( + candidateId=candidate.candidateId, + status="done", + eligible=True, + parameters=candidate, + artifacts={ + "connectivityMap": example(ArtifactRecord), + "clusters": ArtifactRecord( + assay="RNA", kind="cluster_labels", artifactId="b" * 64 + ), + }, + cellSelection=ArtifactReferenceModel( + scope="datastore", assay=None, kind="cell_selection", artifactId="c" * 64 + ), + clusterColumn="RNA_agent_tuning_baseline", + clusterLabel="agent_tuning_baseline", + effectiveDimensions=21, + metrics=example(ParameterMetrics), + evidenceIds=["candidate:baseline:clusters"], + ) + + +def _example_64_IntegrationMetrics(cls): + return cls( + nClusters=8, + minClusterCells=37, + minClusterFraction=0.0185, + adjustedRandByAssay={"RNA": 0.71, "ADT": 0.63}, + normalizedMutualInformationByAssay={"RNA": 0.76, "ADT": 0.69}, + modalityWeightsValid=True, + ) + + +def _example_65_IntegrationCandidateEvaluation(cls): + from scarf.agent.parameter_tuning.contracts import ( + ArtifactRecord, + ArtifactReferenceModel, + IntegrationMetrics, + ) + + return cls( + integrationId="wnn_resolution_1", + method="wnn", + assays=["RNA", "ADT"], + status="done", + eligible=True, + cellSelection=ArtifactReferenceModel( + scope="datastore", assay=None, kind="cell_selection", artifactId="c" * 64 + ), + graphArtifact=ArtifactRecord( + scope="datastore", kind="integrated_graph", artifactId="2" * 64 + ), + clusterArtifact=ArtifactRecord( + scope="datastore", kind="cluster_labels", artifactId="3" * 64 + ), + clusterColumn="agent_wnn_cluster", + metrics=example(IntegrationMetrics), + evidenceIds=["integration:wnn_resolution_1:clusters"], + ) + + +def _example_66_FinalGraphComparison(cls): + return cls( + optionId="native:ADT:baseline", + summary="The RNA-native option better preserves the requested labels.", + evidenceIds=[ + "native:RNA:candidate:baseline:clusters", + "native:ADT:candidate:baseline:clusters", + ], + ) + + +def _example_67_FinalGraphNeedsInput(cls): + return cls( + question="Which biological signal must the final graph preserve?", + options=["cell_type", "condition"], + ) + + +def _example_68_FinalGraphSelection(cls): + from scarf.agent.parameter_tuning.contracts import AgentRunInfo + + return cls( + status="done", + selectedOptionId="native:RNA:baseline", + graphMethod="native", + nativeAssay="RNA", + nativeCandidateId="baseline", + markerAssay="RNA", + confidence="medium", + rationale="The selected native graph has the strongest supported balance.", + evidenceIds=["native:RNA:candidate:baseline:clusters"], + runInfo=example(AgentRunInfo), + ) + + +def _example_69_CandidateComparison(cls): + return cls( + candidateId="pca_15", + summary="The selected baseline retains larger minimum clusters.", + evidenceIds=["candidate:baseline:clusters", "candidate:pca_15:clusters"], + ) + + +def _example_70_ParameterSearchPlan(cls): + from scarf.agent.parameter_tuning.contracts import AgentRunInfo, ParameterCandidate + + return cls( + status="refine", + candidates=[ + ParameterCandidate( + candidateId="refined_pca_18", + dimensions=18, + leidenResolution=1.0, + neighborsK=11, + useHarmony=False, + ) + ], + basedOnCandidateIds=["baseline", "pca_15"], + harmonyBatchColumns=[], + objectives=["Resolve the dimension tradeoff."], + rationale="The initial screen brackets a narrower dimension range.", + evidenceIds=["candidate:baseline:clusters", "candidate:pca_15:clusters"], + stoppingCriteria=["Run the proposed candidate once."], + runInfo=example(AgentRunInfo), + ) + + +def _example_71_ParameterTuningBatchSearchPlan(cls): + from scarf.agent.parameter_tuning.contracts import ParameterSearchPlan + + return cls(assayPlans={"RNA": example(ParameterSearchPlan)}) + + +def _example_72_ParameterTuningNeedsInput(cls): + return cls( + question="Which trusted biological label should be preserved?", + options=["cell_type", "none"], + evidenceIds=["candidate:baseline:batchMixing:batch"], + ) + + +def _example_73_ParameterTuningReport(cls): + from scarf.agent.parameter_tuning.contracts import ( + AgentRunInfo, + FinalGraphSelection, + ParameterCandidateEvaluation, + ) + + evaluation = example(ParameterCandidateEvaluation) + return cls( + status="done", + fromAssay="RNA", + cellSelection=evaluation.cellSelection, + evaluations=[evaluation], + recommendedCandidateId=evaluation.candidateId, + selectedArtifacts=dict(evaluation.artifacts), + confidence="medium", + rationale="The baseline balances separation and cluster size.", + evidenceIds=["candidate:baseline:clusters"], + tradeoffs=["Higher resolutions produced smaller clusters."], + limitations=["No trusted biological preservation label was supplied."], + stopReason="All authorized candidates were evaluated.", + recommendedByAssay={"RNA": evaluation.candidateId}, + totalCandidates=1, + graphAssay="RNA", + markerAssay="RNA", + finalSelection=example(FinalGraphSelection), + runInfo=example(AgentRunInfo), + ) + + +def _example_74_ParameterTuningDependencies(cls): + from scarf.agent.parameter_tuning.contracts import ParameterCandidate + + candidate = example(ParameterCandidate) + return cls( + fromAssay="RNA", + normalizedShape=(1000, 2000), + candidates={candidate.candidateId: candidate}, + batchColumns=("batch",), + preservationColumns=("cell_type",), + ) + + +def _example_75_ParameterTuningAssayInput(cls): + from scarf.agent.parameter_tuning.contracts import ( + ArtifactRecord, + ExperimentalTuningHandoff, + _default_parameter_candidates, + ) + + return cls( + normalized=ArtifactRecord(assay="RNA", kind="normalized", artifactId="4" * 64), + candidates=_default_parameter_candidates(), + experimentalHandoff=ExperimentalTuningHandoff(batchAction="skip"), + ) + + +def _example_76_AgentDataModel(cls): + """Return a small representative value for tests and fixtures.""" + return cls.get_blank() + + +def _example_77_ArtifactReferenceModel(cls): + return cls(assay="RNA", kind="reduction", artifactId="0" * 64) + + +def _example_78_BatchSafetyEvidence(cls): + return cls( + coefficient="treatment", + coefficientKind="categorical", + observationUnit="sample", + batchColumns=["batch"], + unitConstantBatchColumns=["batch"], + status="safe", + estimability={ + "status": "ok", + "coefficientEstimable": True, + "rankDeficient": False, + }, + evidenceId="batchEstimability:treatment:batch", + ) + + +def _example_79_TuningBiologyHandoff(cls): + from scarf.agent.types import ArtifactReferenceModel + + return cls( + cellSelection=ArtifactReferenceModel( + scope="datastore", assay=None, kind="cell_selection", artifactId="c" * 64 + ), + fromAssay="RNA", + graphAssay="RNA", + markerAssay="RNA", + recommendedCandidateId="baseline", + clusterArtifact=ArtifactReferenceModel( + assay="RNA", kind="cluster_labels", artifactId="1" * 64 + ), + evidenceIds=["candidate:baseline:clusters"], + ) + + +def _example_80_ToolCallInfo(cls): + return cls(toolName="inspect_store", callId="tool-call-1") + + +def _example_81_AgentUsageInfo(cls): + return cls( + inputTokens=100, outputTokens=50, totalTokens=150, requests=2, toolCalls=1 + ) + + +def _example_82_AgentRunInfo(cls): + from scarf.agent.types import AgentUsageInfo, ToolCallInfo + + return cls( + agentName="data_enrichment", + modelName="example-model", + runId="example-run", + durationSeconds=0.1, + usage=example(AgentUsageInfo), + toolCalls=[example(ToolCallInfo)], + ) + + +def _example_83_AgentExecutionResult(cls): + from scarf.agent.types import AgentRunInfo + + return cls(output={}, runInfo=example(AgentRunInfo)) + + +def _example_84_EvidenceItem(cls): + return cls( + id="evidence:example", label="example", summary="A bounded observed fact." + ) + + +def _example_85_Decision(cls): + return cls( + selectedId="evidence:example", + rationale="The evidence directly answers the question.", + evidenceIds=["evidence:example"], + ) + + +def _example_86_NeedsInput(cls): + return cls( + question="Which condition column should be used?", + options=["condition", "treatment"], + ) + + +def _example_87_StageResult(cls): + from scarf.agent.types import Decision + + return cls(status="done", decision=example(Decision)) + + +_FACTORIES = { + "scarf.agent.biological_interpretation.contracts.BiologicalContext": _example_0_BiologicalContext, + "scarf.agent.biological_interpretation.contracts.ConditionClusterSummary": _example_1_ConditionClusterSummary, + "scarf.agent.biological_interpretation.contracts.ClusterCompositionEvidence": _example_2_ClusterCompositionEvidence, + "scarf.agent.biological_interpretation.contracts.MarkerFeature": _example_3_MarkerFeature, + "scarf.agent.biological_interpretation.contracts.ClusterMarkerEvidence": _example_4_ClusterMarkerEvidence, + "scarf.agent.biological_interpretation.contracts.ClusterMarkerBatchEvidence": _example_5_ClusterMarkerBatchEvidence, + "scarf.agent.biological_interpretation.contracts.ClusterInterpretation": _example_6_ClusterInterpretation, + "scarf.agent.biological_interpretation.contracts.TreatmentObservation": _example_7_TreatmentObservation, + "scarf.agent.biological_interpretation.contracts.FollowUpRecommendation": _example_8_FollowUpRecommendation, + "scarf.agent.biological_interpretation.contracts.BiologicalInterpretationNeedsInput": _example_9_BiologicalInterpretationNeedsInput, + "scarf.agent.biological_interpretation.contracts.BiologicalInterpretationReport": _example_10_BiologicalInterpretationReport, + "scarf.agent.biological_interpretation.contracts.BiologicalInterpretationDependencies": _example_11_BiologicalInterpretationDependencies, + "scarf.agent.config.AgentRunConfig": _example_12_AgentRunConfig, + "scarf.agent.data_enrichment.characterization.FeatureCharacterization": _example_13_FeatureCharacterization, + "scarf.agent.data_enrichment.contracts.DataEnrichmentContext": _example_14_DataEnrichmentContext, + "scarf.agent.data_enrichment.contracts.StudyContextSummary": _example_15_StudyContextSummary, + "scarf.agent.data_enrichment.contracts.AdtControlEvidence": _example_16_AdtControlEvidence, + "scarf.agent.data_enrichment.contracts.HtoTagEvidence": _example_17_HtoTagEvidence, + "scarf.agent.data_enrichment.contracts.AtacCoordinateEvidence": _example_18_AtacCoordinateEvidence, + "scarf.agent.data_enrichment.contracts.AssayModalityEvidence": _example_19_AssayModalityEvidence, + "scarf.agent.data_enrichment.contracts.FeatureFamilyEvidence": _example_20_FeatureFamilyEvidence, + "scarf.agent.data_enrichment.contracts.DefaultHvgFamilyEvidence": _example_21_DefaultHvgFamilyEvidence, + "scarf.agent.data_enrichment.contracts.RnaFeatureInventoryEvidence": _example_22_RnaFeatureInventoryEvidence, + "scarf.agent.data_enrichment.contracts.ExogenousFeatureEvidence": _example_23_ExogenousFeatureEvidence, + "scarf.agent.data_enrichment.contracts.AssayFeatureInspection": _example_24_AssayFeatureInspection, + "scarf.agent.data_enrichment.contracts.AssayFeatureInspectionBatch": _example_25_AssayFeatureInspectionBatch, + "scarf.agent.data_enrichment.contracts.FeatureReference": _example_26_FeatureReference, + "scarf.agent.data_enrichment.contracts.FeatureMatch": _example_27_FeatureMatch, + "scarf.agent.data_enrichment.contracts.FeatureLookupResult": _example_28_FeatureLookupResult, + "scarf.agent.data_enrichment.contracts.FeatureLookupBatch": _example_29_FeatureLookupBatch, + "scarf.agent.data_enrichment.contracts.FeatureSelectionPolicy": _example_30_FeatureSelectionPolicy, + "scarf.agent.data_enrichment.contracts.DataEnrichmentToolCall": _example_31_DataEnrichmentToolCall, + "scarf.agent.data_enrichment.contracts.DataEnrichmentReport": _example_32_DataEnrichmentReport, + "scarf.agent.data_enrichment.contracts.DataEnrichmentDependencies": _example_33_DataEnrichmentDependencies, + "scarf.agent.experimental_context.contracts.CovariateCharacterization": _example_34_CovariateCharacterization, + "scarf.agent.experimental_context.contracts.InferenceUnit": _example_35_InferenceUnit, + "scarf.agent.experimental_context.contracts.BatchCorrectionPlan": _example_36_BatchCorrectionPlan, + "scarf.agent.experimental_context.contracts.NamedArtifactSource": _example_37_NamedArtifactSource, + "scarf.agent.experimental_context.contracts.CellQcProfileEvidence": _example_38_CellQcProfileEvidence, + "scarf.agent.experimental_context.contracts.CellQcPlan": _example_39_CellQcPlan, + "scarf.agent.experimental_context.contracts.ExperimentalContextDecision": _example_40_ExperimentalContextDecision, + "scarf.agent.experimental_context.contracts.RepresentationEvaluation": _example_41_RepresentationEvaluation, + "scarf.agent.experimental_context.contracts.CovariateEvidence": _example_42_CovariateEvidence, + "scarf.agent.experimental_context.contracts.ExperimentalContextResult": _example_43_ExperimentalContextResult, + "scarf.agent.experimental_context.contracts.ExperimentalContextDependencies": _example_44_ExperimentalContextDependencies, + "scarf.agent.experimental_context.study.StudyContract": _example_45_StudyContract, + "scarf.agent.ingest.result.IngestResult": _example_46_IngestResult, + "scarf.agent.orchestrator.models.WorkflowQuestion": _example_47_WorkflowQuestion, + "scarf.agent.orchestrator.models.WorkflowNeedsInput": _example_48_WorkflowNeedsInput, + "scarf.agent.orchestrator.models.WorkflowStageLink": _example_49_WorkflowStageLink, + "scarf.agent.orchestrator.models.WorkflowStageAttempt": _example_50_WorkflowStageAttempt, + "scarf.agent.orchestrator.models.AssayPreprocessingPlan": _example_51_AssayPreprocessingPlan, + "scarf.agent.orchestrator.models.AutomatedPreprocessingPlan": _example_52_AutomatedPreprocessingPlan, + "scarf.agent.orchestrator.models.PreprocessedAssayHandoff": _example_53_PreprocessedAssayHandoff, + "scarf.agent.orchestrator.models.FinalAnalysisHandoff": _example_55_FinalAnalysisHandoff, + "scarf.agent.orchestrator.models.AutomatedWorkflowConfig": _example_56_AutomatedWorkflowConfig, + "scarf.agent.orchestrator.models.AutomatedWorkflowRequest": _example_57_AutomatedWorkflowRequest, + "scarf.agent.orchestrator.models.AutomatedWorkflowResumeRequest": _example_58_AutomatedWorkflowResumeRequest, + "scarf.agent.orchestrator.models.OrchestrationResumeRecord": _example_59_OrchestrationResumeRecord, + "scarf.agent.parameter_tuning.contracts.ArtifactRecord": _example_60_ArtifactRecord, + "scarf.agent.parameter_tuning.contracts.ParameterCandidate": _example_61_ParameterCandidate, + "scarf.agent.parameter_tuning.contracts.ParameterMetrics": _example_62_ParameterMetrics, + "scarf.agent.parameter_tuning.contracts.ParameterCandidateEvaluation": _example_63_ParameterCandidateEvaluation, + "scarf.agent.parameter_tuning.contracts.IntegrationMetrics": _example_64_IntegrationMetrics, + "scarf.agent.parameter_tuning.contracts.IntegrationCandidateEvaluation": _example_65_IntegrationCandidateEvaluation, + "scarf.agent.parameter_tuning.contracts.FinalGraphComparison": _example_66_FinalGraphComparison, + "scarf.agent.parameter_tuning.contracts.FinalGraphNeedsInput": _example_67_FinalGraphNeedsInput, + "scarf.agent.parameter_tuning.contracts.FinalGraphSelection": _example_68_FinalGraphSelection, + "scarf.agent.parameter_tuning.contracts.CandidateComparison": _example_69_CandidateComparison, + "scarf.agent.parameter_tuning.contracts.ParameterSearchPlan": _example_70_ParameterSearchPlan, + "scarf.agent.parameter_tuning.contracts.ParameterTuningBatchSearchPlan": _example_71_ParameterTuningBatchSearchPlan, + "scarf.agent.parameter_tuning.contracts.ParameterTuningNeedsInput": _example_72_ParameterTuningNeedsInput, + "scarf.agent.parameter_tuning.contracts.ParameterTuningReport": _example_73_ParameterTuningReport, + "scarf.agent.parameter_tuning.contracts.ParameterTuningDependencies": _example_74_ParameterTuningDependencies, + "scarf.agent.parameter_tuning.contracts.ParameterTuningAssayInput": _example_75_ParameterTuningAssayInput, + "scarf.agent.types.AgentDataModel": _example_76_AgentDataModel, + "scarf.agent.types.ArtifactReferenceModel": _example_77_ArtifactReferenceModel, + "scarf.agent.types.BatchSafetyEvidence": _example_78_BatchSafetyEvidence, + "scarf.agent.types.TuningBiologyHandoff": _example_79_TuningBiologyHandoff, + "scarf.agent.types.ToolCallInfo": _example_80_ToolCallInfo, + "scarf.agent.types.AgentUsageInfo": _example_81_AgentUsageInfo, + "scarf.agent.types.AgentRunInfo": _example_82_AgentRunInfo, + "scarf.agent.types.AgentExecutionResult": _example_83_AgentExecutionResult, + "scarf.agent.types.EvidenceItem": _example_84_EvidenceItem, + "scarf.agent.types.Decision": _example_85_Decision, + "scarf.agent.types.NeedsInput": _example_86_NeedsInput, + "scarf.agent.types.StageResult": _example_87_StageResult, +} diff --git a/tests/agent_journal_store.py b/tests/agent_journal_store.py new file mode 100644 index 00000000..86ea6cfa --- /dev/null +++ b/tests/agent_journal_store.py @@ -0,0 +1,51 @@ +"""In-memory journal fixture without numerical work or a provider.""" + +from types import SimpleNamespace + +import zarr +from zarr.storage import MemoryStore + +from scarf.agent.orchestrator import AutomatedWorkflowConfig, AutomatedWorkflowRequest +from scarf.agent.orchestrator import journal +from scarf.agent.orchestrator.models import OrchestrationRequestRecord + + +def memory_journal(workspace: str | None = None): + root = zarr.open_group(store=MemoryStore(), mode="w") + active = root if workspace is None else root.create_group(workspace) + store = SimpleNamespace( + zw=active, + z=root, + zarr_loc="analysis.zarr", + workspace=workspace, + cells=SimpleNamespace(columns=[]), + load_artifact=lambda ref: ref, + ) + prefix = journal._ensure_orchestration_store(store) + request = AutomatedWorkflowRequest( + sourcePath="analysis.zarr", + zarrPath="analysis.zarr", + workspace=workspace, + primaryAssay="RNA", + markerAssay="RNA", + analysisAssays=["RNA"], + studyContext="Two replicated conditions", + studyObjective="Resolve stable populations", + ) + config = AutomatedWorkflowConfig() + record = OrchestrationRequestRecord( + workflowRunId="workflow-1", + request=request, + config=config, + requestSha256=journal._sha256_model(request), + configSha256=journal._sha256_model(config), + modelIdentity="test-model", + inputIdentity={"data": "selected-rna"}, + ) + record = record.model_copy( + update={"contentSha256": journal._record_checksum(record)} + ) + journal._write_model_once( + active, journal._request_key(prefix, record.workflowRunId), record + ) + return store, prefix, record diff --git a/tests/test_agent_analysis_plots.py b/tests/test_agent_analysis_plots.py new file mode 100644 index 00000000..e2ed3dfc --- /dev/null +++ b/tests/test_agent_analysis_plots.py @@ -0,0 +1,171 @@ +"""Bounded, immutable cluster displays for agent results and reports.""" + +import json +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import numpy as np +import pytest + +from scarf.agent import _plots +from scarf.storage.refs import ArtifactRef + + +class VirtualArray: + """A large immutable array that refuses unbounded or whole-column reads.""" + + def __init__(self, rows: int, *, coordinates: bool = False) -> None: + self.shape = (rows, 2) if coordinates else (rows,) + self.ndim = len(self.shape) + self.dtype = np.dtype(float if coordinates else int) + self.coordinates = coordinates + self.read_sizes: list[int] = [] + + def __getitem__(self, selection: slice) -> np.ndarray: + assert isinstance(selection, slice) + assert selection.start is not None and selection.stop is not None + assert selection.stop - selection.start <= _plots.DISPLAY_BLOCK_ROWS + rows = np.arange(selection.start, min(selection.stop, self.shape[0])) + self.read_sizes.append(len(rows)) + if self.coordinates: + return np.column_stack((rows, rows % 11)).astype(float) + return np.where(rows == self.shape[0] - 1, 7, rows % 3) + + +def display_store( + monkeypatch: pytest.MonkeyPatch, n: int = 621_200 +) -> tuple[Any, dict[str, ArtifactRef], dict[str, VirtualArray]]: + refs = { + "cell_selection": ArtifactRef("datastore", "cell_selection", "1" * 64), + "graph": ArtifactRef("assay", "connectivity_map", "2" * 64, "RNA2"), + "clusters": ArtifactRef("assay", "cluster_labels", "3" * 64, "RNA2"), + "umap": ArtifactRef("assay", "embedding", "4" * 64, "RNA2"), + } + arrays = { + "clusters": VirtualArray(n), + "umap": VirtualArray(n, coordinates=True), + } + store = SimpleNamespace(zw=object(), workspace="selected-workspace") + store.load_artifact = lambda ref: { + "values": arrays[next(name for name in arrays if refs[name] == ref)] + } + store.inspect_artifact = lambda ref: SimpleNamespace( + complete=True, + operation="run_umap" if ref == refs["umap"] else "run_leiden_clustering", + inputs={ + "cell_selection": refs["cell_selection"].to_dict(), + "graph": refs["graph"].to_dict(), + }, + ) + monkeypatch.setattr(_plots, "as_zarr_array", lambda value, **_: value) + monkeypatch.setattr( + _plots, "graph_cell_selection", lambda root, ref: refs["cell_selection"] + ) + + def validate(root: Any, ref: ArtifactRef, **kwargs: Any) -> Any: + assert root is store.zw and ref == refs["cell_selection"] + assert kwargs == { + "kind": "cell_selection", + "scope": "datastore", + "assay": None, + "table_path": "cellData", + } + return SimpleNamespace(selected_count=n) + + monkeypatch.setattr(_plots, "validate_stored_selection_integrity", validate) + return store, refs, arrays + + +def test_large_final_map_is_bounded_cluster_aware_and_preserves_counts( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + store, refs, arrays = display_store(monkeypatch) + plot = _plots.plot_final_umap(store, **refs, show=False) + try: + assert plot.provenance.n_cells == 50_000 + assert plot.provenance.extras["input_n_cells"] == 621_200 + assert plot.provenance.extras["workspace"] == "selected-workspace" + assert plot.provenance.extras["layout"] == refs["umap"].to_dict() + counts = plot.tables["cluster_counts"].set_index("cluster")["cells"].to_dict() + assert sum(counts.values()) == 621_200 and counts["7"] == 1 + axes = plot.axes["clusters"] + assert sum(len(artist.get_offsets()) for artist in axes.collections) == 50_000 + from matplotlib.colors import to_rgb + + palette = plot.scales[0].palette + for artist in axes.collections: + positions = np.asarray(artist.get_offsets())[:, 0].astype(int) + expected = np.where(positions == 621_199, 7, positions % 3) + np.testing.assert_allclose( + artist.get_facecolors()[:, :3], + np.asarray([to_rgb(palette[str(label)]) for label in expected]), + ) + assert 621_199 in positions + assert all(max(array.read_sizes) <= 100_000 for array in arrays.values()) + sidecar = plot.save_provenance(tmp_path / "map.json") + assert ( + json.loads(sidecar.read_text())["provenance"]["extras"]["cluster_counts"] + == counts + ) + finally: + plot.close() + + +def test_sampling_is_reproducible_across_read_boundaries_and_keeps_rare_cells() -> None: + values = np.asarray(["common"] * 999 + ["rare"]) + counts = {"common": 999, "rare": 1} + first = _plots._sample_cluster_rows( + values, counts, maximum=50, seed=5, block_rows=73 + ) + second = _plots._sample_cluster_rows( + values, counts, maximum=50, seed=5, block_rows=221 + ) + np.testing.assert_array_equal(first[0], second[0]) + np.testing.assert_array_equal(first[1], second[1]) + assert first[0][-1] == 999 and list(first[1]).count("rare") == 1 + third = _plots._sample_cluster_rows(values, counts, maximum=50, seed=7) + assert not np.array_equal(first[0], third[0]) + + +@pytest.mark.parametrize("mismatch", ["selection", "graph", "producer", "shape"]) +def test_final_map_rejects_incompatible_saved_artifacts( + monkeypatch: pytest.MonkeyPatch, mismatch: str +) -> None: + store, refs, arrays = display_store(monkeypatch, n=12) + inspect = store.inspect_artifact + + def changed(ref: ArtifactRef) -> Any: + status = inspect(ref) + if ref == refs["umap"]: + if mismatch == "selection": + status.inputs["cell_selection"] = ArtifactRef( + "datastore", "cell_selection", "a" * 64 + ).to_dict() + elif mismatch == "graph": + status.inputs["graph"] = ArtifactRef( + "assay", "connectivity_map", "a" * 64, "RNA2" + ).to_dict() + elif mismatch == "producer": + status.operation = "import_dimreduc" + return status + + store.inspect_artifact = changed + if mismatch == "shape": + arrays["umap"].shape = (13, 2) + with pytest.raises(ValueError): + _plots.plot_final_umap(store, **refs, show=False) + assert not any(array.read_sizes for array in arrays.values()) + + +def test_small_map_draws_every_frozen_cell(monkeypatch: pytest.MonkeyPatch) -> None: + store, refs, _ = display_store(monkeypatch, n=12) + plot = _plots.plot_final_umap(store, **refs, show=False) + try: + assert plot.provenance.n_cells == 12 + offsets = np.concatenate( + [artist.get_offsets()[:, 0] for artist in plot.axes["clusters"].collections] + ) + np.testing.assert_array_equal(np.sort(offsets), np.arange(12)) + finally: + plot.close() diff --git a/tests/test_agent_beginner.py b/tests/test_agent_beginner.py index 8fbeee30..3448cdf8 100644 --- a/tests/test_agent_beginner.py +++ b/tests/test_agent_beginner.py @@ -1,5 +1,7 @@ """Beginner RNA entry point and exact completed-result access.""" +from tests.agent_examples import example + from pathlib import Path from types import SimpleNamespace from typing import Any @@ -8,12 +10,12 @@ pytest.importorskip("pydantic_ai") -from scarf.agent import analyze_rna -from scarf.agent.orchestrator import api +from scarf.agent import AnalysisError, AutomatedWorkflowResult, analyze_rna +from scarf.agent.orchestrator import api, journal from scarf.agent.orchestrator.models import ( AutomatedWorkflowConfig, AutomatedWorkflowRequest, - AutomatedWorkflowResult, + FinalAnalysisHandoff, OrchestrationRequestRecord, artifact_model_to_ref, ) @@ -21,34 +23,31 @@ def _completed_result(root: Path) -> AutomatedWorkflowResult: - result = AutomatedWorkflowResult.get_example() - assert result.finalAnalysis is not None and result.workflowRun is not None - final = result.finalAnalysis.model_copy( - update={ - "handoffId": "", - "umap": ArtifactReferenceModel( - assay="RNA", kind="embedding", artifactId="6" * 64 - ), - "markers": ArtifactReferenceModel( - assay="RNA", kind="marker_table", artifactId="7" * 64 - ), - } - ).with_handoff_id() - values = result.model_dump(mode="json") - values.update( + return AutomatedWorkflowResult( + status="completed", + currentStage="analysis_finalization", zarrPath=str(root), - finalAnalysis=final.model_dump(mode="json"), - finalHandoffId=final.handoffId, + workspace="analysis", + workflowRunId="workflow-1", ) - values["workflowRun"]["workspace"] = "analysis" - return AutomatedWorkflowResult.model_validate(values) -def test_analyze_rna_passes_one_request_and_effective_budget( +def _final_analysis() -> FinalAnalysisHandoff: + final = example(FinalAnalysisHandoff) + final.umap = ArtifactReferenceModel( + assay="RNA", kind="embedding", artifactId="6" * 64 + ) + final.markers = ArtifactReferenceModel( + assay="RNA", kind="marker_table", artifactId="7" * 64 + ) + return final + + +def test_analyze_rna_passes_one_request_and_bounded_defaults( monkeypatch: pytest.MonkeyPatch, ) -> None: called: dict[str, Any] = {} - outcome = AutomatedWorkflowResult(status="abstained", notes=["Missing context"]) + outcome = _completed_result(Path("study.zarr")) class Orchestrator: def __init__(self, model: Any, *, config: AutomatedWorkflowConfig) -> None: @@ -67,12 +66,18 @@ def run(self, request: AutomatedWorkflowRequest) -> AutomatedWorkflowResult: study_objective="Identify stable populations.", assay="counts", zarr_path=Path("study.zarr"), - max_candidates=40, ) assert result is outcome assert called["model"] is model - assert called["config"].inputPolicy == "unattended" - assert called["config"].maxCandidateEvaluations == 40 + config = called["config"] + assert config.inputPolicy == "unattended" + assert config.screeningCells == 50_000 + assert config.maxScreeningCells == 100_000 + assert config.maxScreeningEvaluations == 12 + assert config.maxTotalScreeningEvaluations == 24 + assert config.maxFullGraphs == 4 + assert config.maxFullPartitions == 8 + assert config.maxFullRepairs == 1 request = called["request"] assert request.sourcePath == "study.h5ad" assert request.zarrPath == "study.zarr" @@ -81,9 +86,59 @@ def run(self, request: AutomatedWorkflowRequest) -> AutomatedWorkflowResult: assert request.ingestDirections == {} +@pytest.mark.parametrize("status", ["failed", "abstained", "needsInput"]) +def test_beginner_failure_raises_with_resumable_result( + monkeypatch: pytest.MonkeyPatch, status: str +) -> None: + outcome = AutomatedWorkflowResult.model_validate( + { + "status": status, + "currentStage": "experimental_context", + "zarrPath": "study.zarr", + "workflowRunId": "workflow-1", + "notes": ["Capture identity is unresolved"], + } + ) + monkeypatch.setattr( + api, + "AgentOrchestrator", + lambda *_args, **_kwargs: SimpleNamespace(run=lambda _request: outcome), + ) + with pytest.raises(AnalysisError, match="Capture identity is unresolved") as error: + analyze_rna( + "study.zarr", + model=object(), + study_context="Human blood.", + study_objective="Identify stable populations.", + ) + assert error.value.result is outcome + assert "Resume workflow 'workflow-1' in 'study.zarr'" in str(error.value) + + +def test_beginner_rejects_removed_candidate_control() -> None: + with pytest.raises(TypeError, match="max_candidates"): + analyze_rna( + "study.zarr", + model=object(), + study_context="Human blood.", + study_objective="Identify stable populations.", + **{"max_candidates": 1}, + ) + + @pytest.mark.parametrize( "obsolete", [ + "maxRefinedCandidatesPerAssay", + "maxHarmonyCandidatesPerAssay", + "runConfoundedHarmonyDiagnostic", + "maxCandidateEvaluations", + "maxIdentityFeatures", + "hvgCandidateCounts", + "pcaCandidateDimensions", + "graphNeighborCandidates", + "leidenResolutionCandidates", + "maxRevisions", "maxCandidateBranches", "primaryInitialCandidates", "secondaryInitialCandidates", @@ -98,54 +153,31 @@ def test_legacy_config_and_saved_requests_fail_explicitly(obsolete: str) -> None old_config = {obsolete: 1} with pytest.raises(ValueError, match="Create a new single-RNA workflow"): AutomatedWorkflowConfig.model_validate(old_config) - saved = OrchestrationRequestRecord.get_example().model_dump(mode="json") + saved = OrchestrationRequestRecord( + inputIdentity={}, + modelIdentity="test", + request=example(AutomatedWorkflowRequest), + ).model_dump(mode="json") saved["config"].update(old_config) with pytest.raises(ValueError, match="cannot be resumed or regenerated"): OrchestrationRequestRecord.model_validate(saved) @pytest.mark.parametrize( - "field,values", + "limits", [ - ("hvgCandidateCounts", (1,)), - ("hvgCandidateCounts", (2, 1000)), - ("pcaCandidateDimensions", (1,)), - ("graphNeighborCandidates", (1,)), - ("leidenResolutionCandidates", (float("nan"),)), - ("leidenResolutionCandidates", (float("inf"),)), - ("leidenResolutionCandidates", (float("-inf"),)), + {"screeningCells": 19}, + {"screeningCells": 100, "maxScreeningCells": 99}, + {"maxScreeningEvaluations": 3}, + {"maxScreeningEvaluations": 12, "maxTotalScreeningEvaluations": 11}, + {"maxFullGraphs": 0}, + {"maxFullPartitions": 0}, + {"maxFullRepairs": 2}, ], ) -def test_config_rejects_impossible_candidates_before_execution( - field: str, values: tuple[int | float, ...] -) -> None: - with pytest.raises(ValueError, match=field): - AutomatedWorkflowConfig.model_validate({field: values}) - - -def test_config_minimum_candidates_meet_the_sequential_planner_contract() -> None: - from scarf.agent.parameter_tuning.hvg import effective_hvg_candidate_counts - from scarf.agent.parameter_tuning.sequential import SequentialRnaTuningPlanner - - config = AutomatedWorkflowConfig( - hvgCandidateCounts=(3,), - pcaCandidateDimensions=(2,), - graphNeighborCandidates=(2,), - leidenResolutionCandidates=(0.25,), - ) - selected_features = effective_hvg_candidate_counts(3, config.hvgCandidateCounts) - planner = SequentialRnaTuningPlanner( - workflow_run_id="minimum-candidates", - assay="RNA", - n_cells=3, - n_features=selected_features[0], - harmony_authorized=False, - dimension_candidates=config.pcaCandidateDimensions, - neighbor_candidates=config.graphNeighborCandidates, - resolution_candidates=config.leidenResolutionCandidates, - ) - assert planner.dimensions == (2,) - assert planner.neighbors == (2,) +def test_impossible_work_limits_fail_before_execution(limits: dict[str, int]) -> None: + with pytest.raises(ValueError): + AutomatedWorkflowConfig.model_validate(limits) @pytest.mark.parametrize( @@ -159,46 +191,58 @@ def test_config_minimum_candidates_meet_the_sequential_planner_contract() -> Non ], ) def test_request_rejects_unsupported_routing(routing: dict[str, Any]) -> None: - values = AutomatedWorkflowRequest.get_example().model_dump(mode="json") + values = example(AutomatedWorkflowRequest).model_dump(mode="json") with pytest.raises(ValueError): AutomatedWorkflowRequest.model_validate({**values, **routing}) -def test_result_helpers_reopen_read_only_with_exact_refs_and_workspace( +def test_result_helpers_resolve_exact_journal_refs_and_workspace( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - import scarf.datastore.datastore as datastore_module + from scarf.agent import _plots result = _completed_result(tmp_path) + final = _final_analysis() original = result.model_dump(mode="json") - opened: list[tuple[str, dict[str, Any]]] = [] + opened: list[tuple[str, str, str | None]] = [] + snapshots: list[tuple[Any, str]] = [] plotted: list[dict[str, Any]] = [] markers: list[dict[str, Any]] = [] plot_result, marker_table = object(), object() + store = SimpleNamespace( + get_markers=lambda **options: markers.append(options) or marker_table + ) - def open_store(path: str, **kwargs: Any) -> Any: - opened.append((path, kwargs)) - return SimpleNamespace( - plots=SimpleNamespace( - embedding=lambda **options: plotted.append(options) or plot_result - ), - get_markers=lambda **options: markers.append(options) or marker_table, - ) + def open_store(path: str, run_id: str, *, workspace: str | None) -> Any: + opened.append((path, run_id, workspace)) + return store + + def snapshot(target: Any, run_id: str) -> dict[str, Any]: + snapshots.append((target, run_id)) + return {"status": "completed", "finalAnalysis": final.model_dump(mode="json")} - monkeypatch.setattr(datastore_module, "DataStore", open_store) - assert result.plot_embedding(frame="none") is plot_result + def plot(target: Any, **options: Any) -> Any: + assert target is store + plotted.append(options) + return plot_result + + monkeypatch.setattr(journal, "open_analysis_store", open_store) + monkeypatch.setattr(journal, "analysis_snapshot", snapshot) + monkeypatch.setattr(_plots, "plot_final_umap", plot) + assert result.plot_embedding(figsize=(8, 5)) is plot_result assert result.get_markers(group_id="2", min_score=0.5) is marker_table - assert all(path == str(tmp_path) for path, _ in opened) - assert all(options["zarr_mode"] == "r" for _, options in opened) - assert all(options["workspace"] == "analysis" for _, options in opened) - final = result.finalAnalysis - assert final is not None and final.umap is not None - assert final.clusters is not None and final.markers is not None + assert opened == [(str(tmp_path), "workflow-1", "analysis")] * 2 + assert snapshots == [(store, "workflow-1")] * 2 + assert final.umap is not None and final.clusters is not None + assert final.cellSelection is not None and final.graph is not None + assert final.markers is not None assert plotted == [ { - "layout": artifact_model_to_ref(final.umap), - "color_by": artifact_model_to_ref(final.clusters), - "frame": "none", + "umap": artifact_model_to_ref(final.umap), + "clusters": artifact_model_to_ref(final.clusters), + "cell_selection": artifact_model_to_ref(final.cellSelection), + "graph": artifact_model_to_ref(final.graph), + "figsize": (8, 5), } ] assert markers == [ @@ -210,42 +254,57 @@ def open_store(path: str, **kwargs: Any) -> Any: } ] assert result.model_dump(mode="json") == original - with pytest.raises(ValueError, match="completed analysis layout"): + assert "finalAnalysis" not in original + assert "workflowRun" not in original + with pytest.raises(ValueError, match="exact completed cluster map"): result.plot_embedding(layout=artifact_model_to_ref(final.umap)) + with pytest.raises(ValueError, match="exact completed cluster map"): + result.plot_embedding(color_by="condition") @pytest.mark.parametrize("method", ["plot_embedding", "get_markers", "report"]) def test_result_helpers_explain_noncompleted_outcome(method: str) -> None: result = AutomatedWorkflowResult(notes=["Input file is missing"]) - with pytest.raises(RuntimeError, match="failed at ingest.*Input file is missing"): + with pytest.raises( + AnalysisError, match="failed during ingest.*Input file is missing" + ): getattr(result, method)() -def test_result_report_reuses_existing_path_and_generates_only_if_missing( +def test_result_report_regenerates_from_exact_saved_analysis( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: import scarf.agent.report.generator as generator result = _completed_result(tmp_path) - assert result.workflowRun is not None - expected = ( - tmp_path - / "analysis/agents/runs" - / result.workflowRun.workflowRunId - / "report/index.html" - ) - generated: list[tuple[str, str, str | None]] = [] - - def generate(target: str, run_id: str, *, workspace: str | None) -> Path: - generated.append((target, run_id, workspace)) - expected.parent.mkdir(parents=True) + final = _final_analysis() + store = object() + expected = tmp_path / "analysis/agents/orchestrations/workflow-1/report/index.html" + generated: list[tuple[Any, str]] = [] + + def open_store(path: str, run_id: str, *, workspace: str | None) -> Any: + assert (path, run_id, workspace) == (str(tmp_path), "workflow-1", "analysis") + return store + + def generate(target: Any, run_id: str) -> Path: + generated.append((target, run_id)) + expected.parent.mkdir(parents=True, exist_ok=True) expected.write_text("Analysis", encoding="utf-8") return expected + monkeypatch.setattr(journal, "open_analysis_store", open_store) + monkeypatch.setattr( + journal, + "analysis_snapshot", + lambda *_args: { + "status": "completed", + "finalAnalysis": final.model_dump(mode="json"), + }, + ) monkeypatch.setattr(generator, "generate_agent_report", generate) assert result.report() == expected assert result.report() == expected - assert generated == [(str(tmp_path), result.workflowRun.workflowRunId, "analysis")] + assert generated == [(store, "workflow-1")] * 2 @pytest.mark.parametrize("workspace", [None, "analysis"]) @@ -256,21 +315,10 @@ def test_legacy_saved_config_blocks_resume_and_report_without_changing_artifacts import numpy as np - from scarf.agent import generate_agent_report from scarf.agent import record_io - from scarf.agent.data_enrichment.contracts import DataEnrichmentReport - from scarf.agent.orchestrator import AgentOrchestrator, journal - from scarf.agent.orchestrator.models import ( - AutomatedWorkflowResumeRequest, - FinalAnalysisHandoff, - NativeAnalysisHandoff, - ) - from scarf.agent.persistence.reports import ( - create_agent_workflow, - finalize_agent_workflow, - save_agent_report, - ) - from scarf.agent.persistence.contracts import AgentInvocation + from scarf.agent.orchestrator import AgentOrchestrator + from scarf.agent.orchestrator.models import AutomatedWorkflowResumeRequest + from scarf.agent.report import generate_agent_report from scarf.datastore.datastore import DataStore from tests.agent_orchestrator_store import create_store @@ -288,7 +336,6 @@ def test_legacy_saved_config_blocks_resume_and_report_without_changing_artifacts features = store.select_all_features(from_assay="RNA") normalized = store.run_normalization(cells, features) original_values = np.asarray(store.load_artifact(normalized)["data"][:]) - workflow = create_agent_workflow(store, workflow_run_id="legacy-config") prefix = journal._ensure_orchestration_store(store) request = AutomatedWorkflowRequest( sourcePath=str(path), @@ -299,10 +346,11 @@ def test_legacy_saved_config_blocks_resume_and_report_without_changing_artifacts primaryAssay="RNA", ) old_config = AutomatedWorkflowConfig().model_dump(mode="json") - old_config.pop("maxCandidateEvaluations") old_config["maxCandidateBranches"] = 24 payload = OrchestrationRequestRecord( - workflowRunId=workflow.workflowRunId, + inputIdentity={}, + modelIdentity="test", + workflowRunId="legacy-config", request=request, requestSha256=journal._sha256_model(request), ).model_dump(mode="json") @@ -315,57 +363,24 @@ def test_legacy_saved_config_blocks_resume_and_report_without_changing_artifacts {key: value for key, value in payload.items() if key != "contentSha256"} ) ).hexdigest() - request_key = journal._request_key(prefix, workflow.workflowRunId) + request_key = journal._request_key(prefix, "legacy-config") original_request = record_io.display_json_bytes(payload) journal._write_key_once(store.zw, request_key, original_request) - message = "start a new workflow.*Older saved request/config shapes" - with pytest.raises(ValueError, match=message): - AgentOrchestrator(object()).resume( - AutomatedWorkflowResumeRequest( - zarrPath=str(path), - workflowRunId=workflow.workflowRunId, - workspace=workspace, - ) - ) - save_agent_report( - store, - workflow.workflowRunId, - DataEnrichmentReport.get_example(), - invocation=AgentInvocation( - agentName="data_enrichment", inputs={"fromAssay": "RNA"} - ), - ) - workflow = finalize_agent_workflow( - store, workflow.workflowRunId, status="completed" - ) - final = FinalAnalysisHandoff( - workflowRunId=workflow.workflowRunId, - primaryAssay="RNA", - markerAssay="RNA", - cellSelection=ArtifactReferenceModel.from_artifact_ref(cells), - nativeAnalyses=[ - NativeAnalysisHandoff( - assay="RNA", - normalized=ArtifactReferenceModel.from_artifact_ref(normalized), - ) - ], - ).with_handoff_id() - terminal = AutomatedWorkflowResult( - status="completed", - currentStage="analysis_finalization", - zarrPath=str(path), - workflowRun=workflow, - reportReferences=list(workflow.reports), - finalAnalysis=final, - finalHandoffId=final.handoffId, - decisionRunId=workflow.workflowRunId, + message = "Unsupported saved agent workflow.*cannot be resumed or regenerated" + resume_request = AutomatedWorkflowResumeRequest( + zarrPath=str(path), workflowRunId="legacy-config", workspace=workspace ) - terminal.contentSha256 = journal._record_checksum(terminal) - journal._persist_terminal_result(store, prefix, workflow, terminal) + orchestrator = AgentOrchestrator(object()) + with pytest.raises(ValueError, match=message): + orchestrator.load_request_for_resume(resume_request) + failed = orchestrator.resume(resume_request) + assert failed.status == "failed" + assert failed.workflowRunId == "legacy-config" + assert any("Unsupported saved agent workflow" in note for note in failed.notes) with pytest.raises(ValueError, match=message): - generate_agent_report(path, workflow.workflowRunId, workspace=workspace) + generate_agent_report(path, "legacy-config", workspace=workspace) reopened = DataStore( str(path), diff --git a/tests/test_agent_biological_interpretation.py b/tests/test_agent_biological_interpretation.py index c0b93bdd..eeaf53c3 100644 --- a/tests/test_agent_biological_interpretation.py +++ b/tests/test_agent_biological_interpretation.py @@ -1,5 +1,7 @@ """Tests for the bounded Biological Interpretation Agent.""" +from tests.agent_examples import example + import asyncio from types import SimpleNamespace @@ -266,7 +268,7 @@ def test_models_have_blank_and_example_constructors() -> None: ) for model in models: assert isinstance(model.get_blank(), model) - assert isinstance(model.get_example(), model) + assert isinstance(example(model), model) assert all("_" not in field_name for field_name in model.model_fields) diff --git a/tests/test_agent_characterize_covariates.py b/tests/test_agent_characterize_covariates.py index 70e9a4b4..0afed291 100644 --- a/tests/test_agent_characterize_covariates.py +++ b/tests/test_agent_characterize_covariates.py @@ -11,7 +11,8 @@ from pydantic_ai.models.function import AgentInfo, FunctionModel from scipy.sparse import csr_matrix -from scarf.agent import CovariateCharacterization, characterize_covariates +from scarf.agent.experimental_context.contracts import CovariateCharacterization +from scarf.agent.experimental_context.characterization import characterize_covariates from scarf.agent.experimental_context.characterization import ( _Run, _assign_domain, diff --git a/tests/test_agent_characterize_features.py b/tests/test_agent_characterize_features.py index daba9261..116c58e8 100644 --- a/tests/test_agent_characterize_features.py +++ b/tests/test_agent_characterize_features.py @@ -7,7 +7,10 @@ import numpy as np from scipy.sparse import csr_matrix -from scarf.agent import FeatureCharacterization, characterize_features +from scarf.agent.data_enrichment.characterization import ( + FeatureCharacterization, + characterize_features, +) from scarf.agent.data_enrichment.characterization import ( _assist_species, _load_or_fetch_reference, diff --git a/tests/test_agent_data_enrichment.py b/tests/test_agent_data_enrichment.py index 84593422..4ec5aa44 100644 --- a/tests/test_agent_data_enrichment.py +++ b/tests/test_agent_data_enrichment.py @@ -1,5 +1,7 @@ """Tests for the read-only data enrichment agent.""" +from tests.agent_examples import example + import asyncio from types import SimpleNamespace @@ -133,7 +135,7 @@ def test_data_enrichment_models_have_factories_and_camelcase_fields() -> None: for model_type in model_types: assert isinstance(model_type.get_blank(), model_type) - assert isinstance(model_type.get_example(), model_type) + assert isinstance(example(model_type), model_type) assert all("_" not in field_name for field_name in model_type.model_fields) @@ -490,7 +492,7 @@ async def reply( assert state["request"] == 3 -def test_data_enrichment_pauses_after_completed_inspection_without_selection( +def test_data_enrichment_fails_after_completed_inspection_without_selection( monkeypatch: pytest.MonkeyPatch, ) -> None: from scarf.agent.data_enrichment import tools as module @@ -521,10 +523,10 @@ def unavailable_structured_output(**kwargs: object) -> None: context=DataEnrichmentContext(organismHint="human"), ) - assert result.status == "needsInput" - assert result.runInfo.agentName == "data_enrichment_needs_input" + assert result.status == "failed" + assert result.runInfo.agentName == "data_enrichment_failed" assert result.policies == [] - assert result.unresolvedQuestions + assert result.unresolvedQuestions == [] assert result.inspections[0].species == "unknown" assert "No scientific feature policy was selected" in result.limitations[0] assert tool_retries == { @@ -533,10 +535,11 @@ def unavailable_structured_output(**kwargs: object) -> None: } -def test_unattended_data_enrichment_uses_inspected_policy_after_model_failure( +def test_data_enrichment_preserves_validated_policy_uncertainty( monkeypatch: pytest.MonkeyPatch, ) -> None: from scarf.agent.data_enrichment import tools as module + from scarf.agent.types import AgentRunInfo store = ReadOnlyStore() monkeypatch.setattr( @@ -545,26 +548,36 @@ def test_unattended_data_enrichment_uses_inspected_policy_after_model_failure( lambda *_args, **_kwargs: characterization(), ) - def unavailable_structured_output(**kwargs: object) -> None: + def unresolved_structured_output(**kwargs: object) -> SimpleNamespace: deps = kwargs["deps"] assert isinstance(deps, DataEnrichmentDependencies) asyncio.run(module.inspect_assay_features_batch(SimpleNamespace(deps=deps))) - raise UnexpectedModelBehavior("structured output unavailable") + return SimpleNamespace( + output=DataEnrichmentReport( + status="needsInput", + unresolvedQuestions=[ + "The objective does not resolve which response genes must be protected." + ], + ), + runInfo=AgentRunInfo(agentName="data_enrichment", modelName="test-model"), + ) monkeypatch.setattr( data_enrichment_agent_module, "run_agent_sync", - unavailable_structured_output, + unresolved_structured_output, ) - result = DataEnrichmentAgent(object(), unattended=True).run( + result = DataEnrichmentAgent(object()).run( store, context=DataEnrichmentContext(organismHint="human"), ) - assert result.status == "done" - assert [policy.assay for policy in result.policies] == ["RNA"] - assert result.unresolvedQuestions == [] - assert result.runInfo.agentName == "data_enrichment_deterministic" + assert result.status == "needsInput" + assert result.policies == [] + assert result.unresolvedQuestions == [ + "The objective does not resolve which response genes must be protected." + ] + assert result.runInfo.agentName == "data_enrichment" def test_feature_lookup_cache_rejects_different_arguments() -> None: @@ -1110,23 +1123,18 @@ def test_data_enrichment_report_rejects_incomplete_assay_inventory() -> None: ) -def test_deterministic_enrichment_requires_complete_inspection_evidence() -> None: - error = RuntimeError("model failed") - incomplete = DataEnrichmentDependencies( - store=ReadOnlyStore(), - assays=["RNA"], - ) - with pytest.raises(RuntimeError, match="model failed"): - data_enrichment_validation.deterministic_data_enrichment_report( - incomplete, - error=error, - model_name="test", - ) - - empty_inspection = AssayFeatureInspection(assay="RNA", species="unknown") - with pytest.raises(ValueError, match="no deterministic feature evidence"): - data_enrichment_validation.deterministic_data_enrichment_report( - incomplete.model_copy(update={"inspections": {"RNA": empty_inspection}}), - error=error, +def test_failed_enrichment_retains_partial_evidence_without_inventing_policy() -> None: + incomplete = DataEnrichmentDependencies(store=ReadOnlyStore(), assays=["RNA"]) + for inspections in ( + {}, + {"RNA": AssayFeatureInspection(assay="RNA", species="unknown")}, + ): + failed = data_enrichment_validation.failed_data_enrichment_report( + incomplete.model_copy(update={"inspections": inspections}), + error=RuntimeError("model failed"), model_name="test", ) + assert failed.status == "failed" + assert failed.policies == [] + assert failed.inspections == list(inspections.values()) + assert "model failed" in failed.limitations diff --git a/tests/test_agent_decide.py b/tests/test_agent_decide.py index b9ed5d60..2cdd454b 100644 --- a/tests/test_agent_decide.py +++ b/tests/test_agent_decide.py @@ -8,7 +8,8 @@ from pydantic_ai.models.function import AgentInfo from pydantic_ai.models.test import TestModel -from scarf.agent import DecisionValidationError, EvidenceItem, decide +from scarf.agent.decisions.selection import DecisionValidationError, decide +from scarf.agent.types import EvidenceItem from scarf.agent.decisions.selection import _SYSTEM_PROMPT, validate_decision from scarf.agent.types import Decision diff --git a/tests/test_agent_decision_kernel.py b/tests/test_agent_decision_kernel.py index 97cdb9b1..a3cd735c 100644 --- a/tests/test_agent_decision_kernel.py +++ b/tests/test_agent_decision_kernel.py @@ -1,7 +1,5 @@ """Contract tests for the decision kernel and deterministic auditor.""" -from collections.abc import Callable - import pytest from pydantic import ValidationError @@ -9,16 +7,10 @@ DecisionEvidence, DecisionOption, DecisionRecord, - DecisionSelection, DecisionSpec, - DecisionWorkflowRun, DeterministicDecisionAuditor, EvidenceBundle, - PendingDecision, ProtectedVariableEffect, - RevisionRequest, - VerificationCheck, - VerificationRecord, ) from scarf.agent.types import ArtifactReferenceModel @@ -122,40 +114,6 @@ def _decision_record( confidence="medium", overrideOfOptionId=override_of, overrideEvidenceIds=override_evidence_ids or [], - verificationId=f"verification:{record_id}", - supersedes=supersedes, - ) - - -def _verification_record( - record: DecisionRecord, - *, - verification_id: str | None = None, - status: str = "passed", -) -> VerificationRecord: - return VerificationRecord( - verificationId=verification_id or f"verification:{record.recordId}", - decisionRecordId=record.recordId, - status=status, - checks=[ - VerificationCheck( - checkId=f"check:{record.recordId}", - status=status, - summary=f"The decision {status} its deterministic check.", - ) - ], - ) - - -def _pending_decision() -> PendingDecision: - return PendingDecision( - questionId="question:cluster", - decisionId="clusterPartition", - definitionVersion=1, - evidenceBundleId="bundle:clusterPartition", - evidenceBundleSha256="0" * 64, - offeredOptionIds=["partition:coarse", "partition:fine"], - reason="More evidence is required.", ) @@ -260,7 +218,7 @@ def test_decision_record_rejects_non_exact_references( ), ( {"supersedes": "decision:cluster:1"}, - "cannot supersede itself", + "Extra inputs are not permitted", ), ( { @@ -287,225 +245,6 @@ def test_decision_record_rejects_invalid_override_and_lineage_references( DecisionRecord.model_validate(values) -@pytest.mark.parametrize( - ("factory", "message"), - [ - ( - lambda: DecisionEvidence( - evidenceId="invalid evidence", - evidenceClass="technical", - summary="Observed evidence.", - ), - "stable identifier", - ), - ( - lambda: DecisionEvidence( - evidenceId="evidence:trim", - evidenceClass="technical", - summary=" surrounding whitespace ", - ), - "surrounding whitespace", - ), - ( - lambda: DecisionEvidence( - evidenceId="evidence:artifact", - evidenceClass="technical", - summary="Observed evidence.", - artifactReferences=[ - ArtifactReferenceModel( - scope="assay", - assay="RNA", - kind="", - artifactId="a" * 64, - ) - ], - ), - "require kind and artifactId", - ), - ( - lambda: DecisionOption( - optionId="option:trim", - status="apply", - label=" Label ", - description="Description.", - ), - "surrounding whitespace", - ), - ( - lambda: DecisionOption( - optionId="option:classes", - status="apply", - label="Label", - description="Description.", - requiredEvidenceClasses=["technical", "technical"], - ), - "must not contain duplicates", - ), - ( - lambda: DecisionSpec.model_validate( - {**_decision_spec().model_dump(), "question": " Question "} - ), - "surrounding whitespace", - ), - ( - lambda: DecisionSpec.model_validate( - {**_decision_spec().model_dump(), "allowedSources": []} - ), - "must not be empty", - ), - ( - lambda: DecisionSpec.model_validate( - { - **_decision_spec().model_dump(), - "allowedSources": ["agent", "agent"], - } - ), - "must not contain duplicates", - ), - ( - lambda: DecisionSpec.model_validate( - { - **_decision_spec().model_dump(), - "baselineOptionId": "partition:unknown", - } - ), - "baselineOptionId must reference", - ), - ( - lambda: DecisionSpec.model_validate( - { - **_decision_spec().model_dump(), - "metricPreferredOptionId": "partition:unknown", - } - ), - "metricPreferredOptionId must reference", - ), - ( - lambda: DecisionSpec.model_validate( - { - **_decision_spec().model_dump(), - "metricPreferredOptionId": None, - } - ), - "requires metricPreferredOptionId", - ), - ( - lambda: ProtectedVariableEffect( - variable="condition", - status="preserved", - summary=" Whitespace ", - ), - "surrounding whitespace", - ), - ( - lambda: DecisionSelection( - selectedOptionId="partition:coarse", - rationale=" Whitespace ", - ), - "surrounding whitespace", - ), - ( - lambda: DecisionSelection( - selectedOptionId="partition:coarse", - evidenceIds=[], - rationale="Reason.", - overrideOfOptionId="partition:fine", - overrideEvidenceIds=["evidence:markers"], - ), - "must be included in evidenceIds", - ), - ( - lambda: DecisionSelection( - selectedOptionId="partition:coarse", - evidenceIds=["evidence:markers"], - rationale="Reason.", - overrideEvidenceIds=["evidence:markers"], - ), - "require overrideOfOptionId", - ), - ( - lambda: DecisionSelection( - selectedOptionId="partition:coarse", - rationale="Reason.", - overrideOfOptionId="partition:coarse", - ), - "must differ from selectedOptionId", - ), - ( - lambda: PendingDecision.model_validate( - {**_pending_decision().model_dump(), "reason": " Reason "} - ), - "surrounding whitespace", - ), - ( - lambda: PendingDecision.model_validate( - { - **_pending_decision().model_dump(), - "evidenceBundleSha256": "invalid", - } - ), - "lowercase SHA-256", - ), - ( - lambda: DecisionRecord.model_validate( - {**_decision_record().model_dump(), "rationale": " Reason "} - ), - "surrounding whitespace", - ), - ( - lambda: DecisionRecord.model_validate( - { - **_decision_record().model_dump(), - "evidenceBundleSha256": "invalid", - } - ), - "lowercase SHA-256", - ), - ( - lambda: DecisionRecord.model_validate( - {**_decision_record().model_dump(), "modelName": " model "} - ), - "without surrounding whitespace", - ), - ( - lambda: VerificationCheck( - checkId="check:trim", - status="passed", - summary=" Summary ", - ), - "surrounding whitespace", - ), - ( - lambda: RevisionRequest( - revisionId="revision:checksum", - targetDecisionRecordId="decision:cluster:1", - verificationId="verification:decision:cluster:1", - replacementOptionId="partition:fine", - reason="Reason.", - evidenceBundleSha256="invalid", - ), - "lowercase SHA-256", - ), - ( - lambda: RevisionRequest( - revisionId="revision:reason", - targetDecisionRecordId="decision:cluster:1", - verificationId="verification:decision:cluster:1", - replacementOptionId="partition:fine", - reason=" Reason ", - ), - "surrounding whitespace", - ), - ], -) -def test_kernel_rejects_invalid_scalar_and_collection_contracts( - factory: Callable[[], object], - message: str, -) -> None: - with pytest.raises(ValidationError, match=message): - factory() - - def test_auditor_accepts_an_exact_metric_preferred_decision() -> None: record = _decision_record() @@ -513,9 +252,8 @@ def test_auditor_accepts_an_exact_metric_preferred_decision() -> None: _decision_spec(), _evidence_bundle(), record, created_at_ns=10 ) - assert verification.status == "passed" - assert verification.verificationId == record.verificationId - assert {check.status for check in verification.checks} == {"passed"} + assert all(check.status == "passed" for check in verification) + assert {check.status for check in verification} == {"passed"} def test_auditor_rejects_a_tampered_evidence_bundle_checksum() -> None: @@ -529,10 +267,10 @@ def test_auditor_rejects_a_tampered_evidence_bundle_checksum() -> None: record, ) - assert verification.status == "failed" - assert [ - check.checkId for check in verification.checks if check.status == "failed" - ] == ["decisionIdentity"] + assert any(check.status == "failed" for check in verification) + assert [check.checkId for check in verification if check.status == "failed"] == [ + "decisionIdentity" + ] def test_auditor_requires_evidence_bound_to_the_selected_option() -> None: @@ -546,10 +284,10 @@ def test_auditor_requires_evidence_bound_to_the_selected_option() -> None: _decision_record(), ) - assert verification.status == "failed" - assert [ - check.checkId for check in verification.checks if check.status == "failed" - ] == ["requiredEvidence"] + assert any(check.status == "failed" for check in verification) + assert [check.checkId for check in verification if check.status == "failed"] == [ + "requiredEvidence" + ] def test_auditor_rejects_a_tampered_option_or_evidence_inventory() -> None: @@ -564,10 +302,8 @@ def test_auditor_rejects_a_tampered_option_or_evidence_inventory() -> None: _decision_spec(), _evidence_bundle(), record ) - assert verification.status == "failed" - failed = { - check.checkId for check in verification.checks if check.status == "failed" - } + assert any(check.status == "failed" for check in verification) + failed = {check.checkId for check in verification if check.status == "failed"} assert failed == {"exactOptionInventory", "exactEvidenceInventory"} @@ -583,8 +319,8 @@ def test_auditor_requires_two_independent_non_geometric_override_classes() -> No _decision_spec(), _evidence_bundle(), record ) - assert verification.status == "failed" - failed = [check for check in verification.checks if check.status == "failed"] + assert any(check.status == "failed" for check in verification) + failed = [check for check in verification if check.status == "failed"] assert [check.checkId for check in failed] == ["independentOverrideEvidence"] @@ -600,7 +336,7 @@ def test_auditor_accepts_two_independent_non_geometric_override_classes() -> Non _decision_spec(), _evidence_bundle(), record ) - assert verification.status == "passed" + assert all(check.status == "passed" for check in verification) def test_auditor_rejects_override_evidence_from_another_option() -> None: @@ -627,10 +363,10 @@ def test_auditor_rejects_override_evidence_from_another_option() -> None: record, ) - assert verification.status == "failed" - assert [ - check.checkId for check in verification.checks if check.status == "failed" - ] == ["independentOverrideEvidence"] + assert any(check.status == "failed" for check in verification) + assert [check.checkId for check in verification if check.status == "failed"] == [ + "independentOverrideEvidence" + ] def test_human_choices_obey_the_same_source_and_status_contracts() -> None: @@ -641,579 +377,6 @@ def test_human_choices_obey_the_same_source_and_status_contracts() -> None: verification = DeterministicDecisionAuditor.audit(spec, _evidence_bundle(), record) - assert verification.status == "failed" - failed = { - check.checkId for check in verification.checks if check.status == "failed" - } + assert any(check.status == "failed" for check in verification) + failed = {check.checkId for check in verification if check.status == "failed"} assert failed == {"selectedOption", "decisionSource"} - - -@pytest.mark.parametrize( - ("status", "check_statuses", "message"), - [ - ("passed", ["passed", "failed"], "every check to pass"), - ("failed", ["passed"], "requires a failed check"), - ( - "inconclusive", - ["inconclusive", "failed"], - "inconclusive check and no failures", - ), - ( - "inconclusive", - ["passed"], - "inconclusive check and no failures", - ), - ], -) -def test_verification_record_enforces_aggregate_status( - status: str, - check_statuses: list[str], - message: str, -) -> None: - checks = [ - VerificationCheck( - checkId=f"check:{index}", - status=check_status, - summary="The check has an explicit result.", - ) - for index, check_status in enumerate(check_statuses) - ] - with pytest.raises(ValidationError, match=message): - VerificationRecord( - verificationId="verification:aggregate", - decisionRecordId="decision:aggregate", - status=status, - checks=checks, - ) - - -def test_verification_record_rejects_duplicate_check_ids() -> None: - check = VerificationCheck( - checkId="check:duplicate", - status="passed", - summary="The check passed.", - ) - with pytest.raises(ValidationError, match="check IDs"): - VerificationRecord( - verificationId="verification:duplicate", - decisionRecordId="decision:duplicate", - status="passed", - checks=[check, check], - ) - - -@pytest.mark.parametrize( - ("changes", "message"), - [ - ( - {"evidenceBundleId": "bundle:revision"}, - "ID and checksum must be provided together", - ), - ( - {"availableEvidenceIds": ["evidence:new"]}, - "requires an exact evidence bundle", - ), - ( - { - "evidenceBundleId": "bundle:revision", - "evidenceBundleSha256": "1" * 64, - "availableEvidenceIds": ["evidence:new"], - "evidenceIds": ["evidence:missing"], - }, - "reference its exact available inventory", - ), - ], -) -def test_revision_request_rejects_incomplete_evidence_references( - changes: dict[str, object], - message: str, -) -> None: - values = { - "revisionId": "revision:cluster", - "targetDecisionRecordId": "decision:cluster:1", - "verificationId": "verification:decision:cluster:1", - "replacementOptionId": "partition:fine", - "reason": "Use the registered alternative.", - **changes, - } - with pytest.raises(ValidationError, match=message): - RevisionRequest.model_validate(values) - - -def test_workflow_ledger_accepts_one_verified_revision_chain() -> None: - original = _decision_record(record_id="decision:cluster:1") - original_verification = VerificationRecord( - verificationId="verification:decision:cluster:1", - decisionRecordId=original.recordId, - status="failed", - checks=[ - VerificationCheck( - checkId="clusterAudit", - status="failed", - summary="The coarse partition merges marker-supported populations.", - evidenceIds=["evidence:markers", "evidence:stability"], - ) - ], - ) - revision = RevisionRequest( - revisionId="revision:cluster:1", - targetDecisionRecordId=original.recordId, - verificationId=original_verification.verificationId, - replacementOptionId="partition:fine", - reason="Independent evidence supports the finer registered partition.", - evidenceBundleId="bundle:cluster-revision", - evidenceBundleSha256="1" * 64, - availableEvidenceIds=["evidence:markers", "evidence:stability"], - evidenceIds=["evidence:markers", "evidence:stability"], - ) - replacement = _decision_record( - record_id="decision:cluster:2", - selected_option_id="partition:fine", - evidence_ids=["evidence:markers", "evidence:stability"], - override_of="partition:coarse", - override_evidence_ids=["evidence:markers", "evidence:stability"], - supersedes=original.recordId, - ) - replacement_verification = DeterministicDecisionAuditor.audit( - _decision_spec(), _evidence_bundle(), replacement - ) - - run = DecisionWorkflowRun( - workflowRunId="workflow:1", - decisionRecords=[original, replacement], - verificationRecords=[original_verification, replacement_verification], - revisionRequests=[revision], - ) - - assert run.formatVersion == 2 - assert run.maxRevisions == 2 - - -def test_workflow_ledger_rejects_more_than_two_revisions() -> None: - values = { - "workflowRunId": "workflow:1", - "revisionRequests": [ - { - "revisionId": f"revision:{index}", - "targetDecisionRecordId": "decision:target", - "verificationId": "verification:target", - "replacementOptionId": "option:replacement", - "reason": "Retry a registered alternative.", - } - for index in range(3) - ], - } - - with pytest.raises(ValidationError, match="configured revision limit"): - DecisionWorkflowRun.model_validate(values) - - -def test_workflow_ledger_honors_a_disabled_revision_budget() -> None: - with pytest.raises(ValidationError, match="configured revision limit"): - DecisionWorkflowRun( - workflowRunId="workflow:no-revisions", - maxRevisions=0, - revisionRequests=[ - RevisionRequest( - revisionId="revision:disabled", - targetDecisionRecordId="decision:target", - verificationId="verification:target", - replacementOptionId="option:replacement", - reason="This revision should be rejected before execution.", - ) - ], - ) - - -def test_workflow_ledger_rejects_upstream_revision_invalidation() -> None: - original = _decision_record(record_id="decision:cluster:1") - verification = VerificationRecord( - verificationId="verification:decision:cluster:1", - decisionRecordId=original.recordId, - status="failed", - checks=[ - VerificationCheck( - checkId="clusterAudit", - status="failed", - summary="The partition failed its deterministic audit.", - ) - ], - ) - revision = RevisionRequest( - revisionId="revision:cluster:1", - targetDecisionRecordId=original.recordId, - verificationId=verification.verificationId, - replacementOptionId="partition:fine", - reason="Use the registered alternative.", - invalidatesDecisionRecordIds=[original.recordId], - ) - - with pytest.raises(ValidationError, match="only downstream decisions"): - DecisionWorkflowRun( - workflowRunId="workflow:1", - decisionRecords=[original], - verificationRecords=[verification], - revisionRequests=[revision], - ) - - -@pytest.mark.parametrize( - ("case", "message"), - [ - ("duplicateRecord", "unique recordId"), - ("missingSupersedes", "must supersede the current active"), - ("unexpectedSupersedes", "must reference an earlier matching decision"), - ("duplicateVerification", "unique verificationId"), - ("unknownVerificationRecord", "exact decision record"), - ("secondVerification", "only one verification"), - ("mismatchedVerification", "references must agree exactly"), - ], -) -def test_workflow_ledger_rejects_invalid_record_and_verification_topology( - case: str, - message: str, -) -> None: - first = _decision_record(record_id="decision:cluster:1") - second = _decision_record(record_id="decision:cluster:2") - records = [first] - verifications: list[VerificationRecord] = [] - - if case == "duplicateRecord": - records.append(first) - elif case == "missingSupersedes": - records.append(second) - elif case == "unexpectedSupersedes": - records = [ - _decision_record( - record_id="decision:cluster:2", - supersedes="decision:cluster:missing", - ) - ] - elif case == "duplicateVerification": - other = second.model_copy( - update={ - "decisionId": "featurePolicy", - "verificationId": first.verificationId, - } - ) - records.append(other) - verifications = [ - _verification_record(first), - _verification_record( - other, - verification_id=first.verificationId, - ), - ] - elif case == "unknownVerificationRecord": - verification = _verification_record(first).model_copy( - update={"decisionRecordId": "decision:missing"} - ) - verifications = [verification] - elif case == "secondVerification": - verifications = [ - _verification_record(first), - _verification_record( - first, - verification_id="verification:decision:cluster:other", - ), - ] - elif case == "mismatchedVerification": - verifications = [ - _verification_record( - first, - verification_id="verification:decision:cluster:other", - ) - ] - - with pytest.raises(ValidationError, match=message): - DecisionWorkflowRun( - workflowRunId="workflow:invalid-topology", - decisionRecords=records, - verificationRecords=verifications, - ) - - -@pytest.mark.parametrize( - ("case", "message"), - [ - ("duplicateRevision", "unique revisionId"), - ("duplicateTarget", "may be revised only once"), - ("unknownTarget", "exact decision record"), - ("wrongVerification", "target decision's verification"), - ("passedWithoutEvidence", "requires exact downstream evidence"), - ("unchangedOption", "must change the selected option"), - ("bundleDrift", "match the target bundle exactly"), - ("unknownInvalidation", "exact decision record"), - ("supersedingWithoutRevision", "requires a revision request"), - ("replacementMismatch", "select the requested replacement"), - ], -) -def test_workflow_ledger_rejects_invalid_revision_references( - case: str, - message: str, -) -> None: - original = _decision_record(record_id="decision:cluster:1") - failed_verification = _verification_record(original, status="failed") - revision = RevisionRequest( - revisionId="revision:cluster:1", - targetDecisionRecordId=original.recordId, - verificationId=failed_verification.verificationId, - replacementOptionId="partition:fine", - reason="Use the registered alternative.", - ) - replacement = _decision_record( - record_id="decision:cluster:2", - selected_option_id="partition:fine", - supersedes=original.recordId, - ) - records = [original] - verifications = [failed_verification] - revisions = [revision] - - if case == "duplicateRevision": - revisions.append(revision) - elif case == "duplicateTarget": - revisions.append( - revision.model_copy(update={"revisionId": "revision:cluster:2"}) - ) - elif case == "unknownTarget": - revisions = [ - revision.model_copy(update={"targetDecisionRecordId": "decision:missing"}) - ] - elif case == "wrongVerification": - revisions = [ - revision.model_copy(update={"verificationId": "verification:missing"}) - ] - elif case == "passedWithoutEvidence": - verifications = [_verification_record(original)] - elif case == "unchangedOption": - revisions = [ - revision.model_copy( - update={"replacementOptionId": original.selectedOptionId} - ) - ] - elif case == "bundleDrift": - revisions = [ - revision.model_copy( - update={ - "evidenceBundleId": original.evidenceBundleId, - "evidenceBundleSha256": "1" * 64, - } - ) - ] - elif case == "unknownInvalidation": - revisions = [ - revision.model_copy( - update={"invalidatesDecisionRecordIds": ["decision:missing"]} - ) - ] - elif case == "supersedingWithoutRevision": - records.append(replacement) - revisions = [] - elif case == "replacementMismatch": - records.append( - replacement.model_copy( - update={ - "selectedOptionId": "partition:abstain", - "status": "abstain", - } - ) - ) - - with pytest.raises(ValidationError, match=message): - DecisionWorkflowRun( - workflowRunId="workflow:invalid-revision", - decisionRecords=records, - verificationRecords=verifications, - revisionRequests=revisions, - ) - - -def test_revision_replaces_target_and_recomputed_downstream_records() -> None: - def record( - record_id: str, - decision_id: str, - selected_option_id: str, - offered_option_ids: list[str], - *, - supersedes: str | None = None, - ) -> DecisionRecord: - return DecisionRecord( - recordId=record_id, - decisionId=decision_id, - definitionVersion=1, - evidenceBundleId=f"bundle:{record_id}", - evidenceBundleSha256="0" * 64, - offeredOptionIds=offered_option_ids, - availableEvidenceIds=[], - selectedOptionId=selected_option_id, - status="apply", - source="agent", - rationale="The exact registered option is supported.", - verificationId=f"verification:{record_id}", - supersedes=supersedes, - ) - - cell = record( - "decision:cell:1", - "cellQuality", - "cell:global", - ["cell:global"], - ) - feature = record( - "decision:feature:1", - "featurePolicy", - "feature:keep", - ["feature:keep", "feature:exclude"], - ) - old_hvg = record( - "decision:hvg:1", - "hvgCount", - "hvg:standard", - ["hvg:standard"], - ) - revised_feature = record( - "decision:feature:2", - "featurePolicy", - "feature:exclude", - ["feature:keep", "feature:exclude"], - supersedes=feature.recordId, - ) - recomputed_hvg = record( - "decision:hvg:2", - "hvgCount", - "hvg:standard", - ["hvg:standard"], - supersedes=old_hvg.recordId, - ) - records = [cell, feature, old_hvg, revised_feature, recomputed_hvg] - verifications = [ - VerificationRecord( - verificationId=f"verification:{value.recordId}", - decisionRecordId=value.recordId, - status="passed", - checks=[ - VerificationCheck( - checkId="exactContract", - status="passed", - summary="The exact decision contract passed.", - ) - ], - ) - for value in records - ] - revision = RevisionRequest( - revisionId="revision:feature:1", - targetDecisionRecordId=feature.recordId, - verificationId=f"verification:{feature.recordId}", - replacementOptionId="feature:exclude", - reason="Downstream representation evidence supports the registered exclusion.", - evidenceBundleId="bundle:feature-dominance", - evidenceBundleSha256="1" * 64, - availableEvidenceIds=["evidence:feature-dominance"], - evidenceIds=["evidence:feature-dominance"], - invalidatesDecisionRecordIds=[old_hvg.recordId], - ) - - workflow = DecisionWorkflowRun( - workflowRunId="workflow:revision", - decisionRecords=records, - verificationRecords=verifications, - revisionRequests=[revision], - ) - - assert [value.recordId for value in workflow.active_decision_records()] == [ - cell.recordId, - revised_feature.recordId, - recomputed_hvg.recordId, - ] - - -def test_completed_workflow_requires_verified_active_decisions() -> None: - record = _decision_record() - verification = DeterministicDecisionAuditor.audit( - _decision_spec(), _evidence_bundle(), record - ) - - run = DecisionWorkflowRun( - workflowRunId="workflow:complete", - status="completed", - decisionRecords=[record], - verificationRecords=[verification], - finalHandoffId="handoff:1", - ) - - assert run.status == "completed" - assert run.finalHandoffId == "handoff:1" - - -@pytest.mark.parametrize( - ("case", "message"), - [ - ("completedWithoutHandoff", "require finalHandoffId"), - ("completedWithPending", "cannot contain a pending decision"), - ("completedUnverified", "every active decision to pass"), - ("runningWithHandoff", "Only completed workflows"), - ("needsInputWithoutPause", "require a pending or active defer"), - ("runningWithPending", "Only needsInput workflows"), - ("abstainedWithoutDecision", "require an active abstain"), - ], -) -def test_workflow_terminal_status_requires_matching_ledger_state( - case: str, - message: str, -) -> None: - record = _decision_record() - values: dict[str, object] = { - "workflowRunId": "workflow:terminal", - "status": "running", - "decisionRecords": [record], - "verificationRecords": [_verification_record(record)], - } - if case == "completedWithoutHandoff": - values["status"] = "completed" - elif case == "completedWithPending": - values.update( - status="completed", - finalHandoffId="handoff:1", - pendingDecision=_pending_decision(), - ) - elif case == "completedUnverified": - values.update( - status="completed", - finalHandoffId="handoff:1", - verificationRecords=[], - ) - elif case == "runningWithHandoff": - values["finalHandoffId"] = "handoff:1" - elif case == "needsInputWithoutPause": - values["status"] = "needsInput" - elif case == "runningWithPending": - values["pendingDecision"] = _pending_decision() - elif case == "abstainedWithoutDecision": - values["status"] = "abstained" - - with pytest.raises(ValidationError, match=message): - DecisionWorkflowRun.model_validate(values) - - -@pytest.mark.parametrize( - ("status", "decision_status"), - [("needsInput", "defer"), ("abstained", "abstain")], -) -def test_non_success_terminal_status_requires_matching_active_decision( - status: str, decision_status: str -) -> None: - values = _decision_record().model_dump() - values["status"] = decision_status - if decision_status == "defer": - values["selectedOptionId"] = "partition:abstain" - record = DecisionRecord.model_validate(values) - - run = DecisionWorkflowRun( - workflowRunId=f"workflow:{status}", - status=status, - decisionRecords=[record], - ) - - assert run.status == status diff --git a/tests/test_agent_decision_persistence.py b/tests/test_agent_decision_persistence.py deleted file mode 100644 index 7a22c528..00000000 --- a/tests/test_agent_decision_persistence.py +++ /dev/null @@ -1,965 +0,0 @@ -"""Persistence tests for immutable decision-workflow snapshots.""" - -import hashlib -import json -from types import SimpleNamespace -from typing import Any - -import pytest -import zarr -from pydantic_ai.exceptions import AgentRunError -from zarr.core.buffer import default_buffer_prototype -from zarr.core.sync import sync - -import scarf.agent.persistence.decisions as persistence_module -import scarf.agent.orchestrator.decisions as decisions_module -from scarf.agent import record_io -from scarf.agent.decisions.kernel import ( - DecisionEvidence, - DecisionRecord, - DecisionSelection, - DecisionWorkflowRun, - EvidenceBundle, - VerificationCheck, - VerificationRecord, -) -from scarf.agent.persistence.decisions import ( - DecisionPersistenceFormatError, - DecisionWorkflowSnapshot, - attach_audited_rna_decision, - decision_record_checksum, - list_decision_workflow_snapshots, - load_decision_workflow_for_replay, - load_decision_workflow_snapshot, - load_latest_decision_workflow_snapshot, - save_decision_workflow_snapshot, -) -from scarf.agent.orchestrator.models import ( - _ORCHESTRATION_FORMAT, - AutomatedWorkflowConfig, - AutomatedWorkflowRequest, - OrchestrationRequestRecord, -) -from scarf.agent.orchestrator.decisions import DecisionStagesMixin -from scarf.agent.decisions.rna import ( - build_cell_quality_decision, - build_feature_policy_decision, - build_pca_prefix_decision, - build_qc_grouping_decision, - compile_rna_decision, -) -from tests.agent_orchestrator_store import create_store - - -def _set_raw(group: zarr.Group, key: str, payload: bytes) -> None: - buffer = default_buffer_prototype().buffer.from_bytes(payload) - sync(group.store.set(key, buffer)) - - -def _orchestration_request_record( - path: Any, - workflow_run_id: str, -) -> OrchestrationRequestRecord: - request = AutomatedWorkflowRequest( - sourcePath=str(path), - zarrPath=str(path), - studyContext="A test single-cell study.", - studyObjective="Discover stable RNA populations.", - ) - config = AutomatedWorkflowConfig() - record = OrchestrationRequestRecord( - workflowRunId=workflow_run_id, - createdAtNs=1, - request=request, - config=config, - requestSha256=persistence_module._model_checksum(request), - configSha256=persistence_module._model_checksum(config), - ) - return record.model_copy( - update={"contentSha256": persistence_module._record_checksum(record)} - ) - - -def _seed_orchestration( - path: Any, - workflow_run_id: str, - *, - format_version: int = 2, -) -> zarr.Group: - create_store(path) - group = zarr.open_group(str(path), mode="r+") - agents = group.create_group( - "agents", - attributes={"format": "scarf_agent_reports", "format_version": 2}, - ) - agents.create_group( - "orchestrations", - attributes={ - "format": _ORCHESTRATION_FORMAT, - "format_version": format_version, - }, - ) - record = _orchestration_request_record(path, workflow_run_id) - _set_raw( - group, - f"agents/orchestrations/{workflow_run_id}/request.json", - record_io.display_json_bytes(record.model_dump(mode="json")), - ) - return group - - -def _decision_record( - workflow_run_id: str, - *, - status: str, - verification: bool, -) -> tuple[DecisionRecord, VerificationRecord | None]: - option_id = f"option:{status}" - verification_id = f"verification:{workflow_run_id}:1" if verification else None - record = DecisionRecord( - recordId=f"decision:{workflow_run_id}:1", - decisionId="cellQuality", - definitionVersion=1, - evidenceBundleId="bundle:test", - evidenceBundleSha256="0" * 64, - offeredOptionIds=[option_id], - availableEvidenceIds=[], - selectedOptionId=option_id, - status=status, - source="rule", - rationale="Use the exact registered test outcome.", - verificationId=verification_id, - ) - if not verification: - return record, None - return record, VerificationRecord( - verificationId=verification_id, - decisionRecordId=record.recordId, - status="passed", - checks=[ - VerificationCheck( - checkId="exactContract", - status="passed", - summary="The registered test contract is exact.", - ) - ], - ) - - -def _workflow_for_status(workflow_run_id: str, status: str) -> DecisionWorkflowRun: - if status == "running": - return DecisionWorkflowRun(workflowRunId=workflow_run_id) - decision_status = "defer" if status == "needsInput" else "abstain" - if status == "completed": - decision_status = "apply" - record, verification = _decision_record( - workflow_run_id, - status=decision_status, - verification=status == "completed", - ) - return DecisionWorkflowRun( - workflowRunId=workflow_run_id, - status=status, - decisionRecords=[record], - verificationRecords=[verification] if verification is not None else [], - finalHandoffId=f"handoff-{workflow_run_id}" if status == "completed" else None, - ) - - -def _compiled_cell_quality( - workflow_run_id: str, -) -> tuple[DecisionRecord, Any]: - definition = build_cell_quality_decision( - evidence_bundle_id="bundle:cellQuality", - available_profiles=["retainWithFlags", "globalMad5"], - ) - bundle = EvidenceBundle( - bundleId="bundle:cellQuality", - decisionId="cellQuality", - evidence=[ - DecisionEvidence( - evidenceId="evidence:quality", - evidenceClass="qualityControl", - summary="The published cell set passes lenient quality review.", - ) - ], - ).with_content_sha256() - assert bundle.contentSha256 is not None - record = DecisionRecord( - recordId=f"decision:{workflow_run_id}:cellQuality", - decisionId="cellQuality", - definitionVersion=1, - evidenceBundleId=bundle.bundleId, - evidenceBundleSha256=bundle.contentSha256, - offeredOptionIds=[option.optionId for option in definition.spec.options], - availableEvidenceIds=["evidence:quality"], - selectedOptionId="cellQuality:retainWithFlags", - status="skip", - source="agent", - evidenceIds=["evidence:quality"], - rationale="The published cells do not need another destructive filter.", - verificationId=f"verification:decision:{workflow_run_id}:cellQuality", - ) - return record, compile_rna_decision(definition, bundle, record) - - -def _compiled_qc_grouping( - workflow_run_id: str, -) -> tuple[DecisionRecord, Any]: - definition = build_qc_grouping_decision( - evidence_bundle_id="bundle:qcGrouping", - physical_capture_eligible=False, - pooled_reference_eligible=False, - ) - bundle = EvidenceBundle( - bundleId="bundle:qcGrouping", - decisionId="qcGrouping", - evidence=[ - DecisionEvidence( - evidenceId="evidence:quality", - evidenceClass="qualityControl", - summary="Global quality-reference evidence is available.", - ), - DecisionEvidence( - evidenceId="evidence:design", - evidenceClass="design", - summary="No physical capture is registered.", - ), - ], - ).with_content_sha256() - assert bundle.contentSha256 is not None - record = DecisionRecord( - recordId=f"decision:{workflow_run_id}:qcGrouping", - decisionId="qcGrouping", - definitionVersion=1, - evidenceBundleId=bundle.bundleId, - evidenceBundleSha256=bundle.contentSha256, - offeredOptionIds=[option.optionId for option in definition.spec.options], - availableEvidenceIds=[item.evidenceId for item in bundle.evidence], - selectedOptionId="qcGrouping:global", - status="apply", - source="agent", - evidenceIds=[item.evidenceId for item in bundle.evidence], - rationale="Use a global quality reference without proven captures.", - verificationId=f"verification:decision:{workflow_run_id}:qcGrouping", - ) - return record, compile_rna_decision(definition, bundle, record) - - -@pytest.mark.parametrize("status", ["running", "needsInput", "abstained", "completed"]) -def test_snapshot_round_trip_supports_workflow_statuses( - tmp_path: Any, status: str -) -> None: - workflow_run_id = f"workflow-{status.lower()}" - path = tmp_path / f"{status}.zarr" - _seed_orchestration(path, workflow_run_id) - workflow = _workflow_for_status(workflow_run_id, status) - - saved = save_decision_workflow_snapshot(path, workflow, created_at_ns=10) - loaded = load_decision_workflow_snapshot( - path, - workflow_run_id, - saved.contentSha256, - ) - - assert loaded == saved - assert loaded.workflow.status == status - assert loaded.orchestrationRun.workflowRunId == workflow_run_id - assert loaded.orchestrationRun.requestContentSha256 - - -def test_snapshots_are_content_addressed_append_only_and_idempotent( - tmp_path: Any, -) -> None: - workflow_run_id = "workflow-chain" - path = tmp_path / "chain.zarr" - _seed_orchestration(path, workflow_run_id) - initial = DecisionWorkflowRun(workflowRunId=workflow_run_id) - first = save_decision_workflow_snapshot(path, initial, created_at_ns=10) - record, compiled = _compiled_qc_grouping(workflow_run_id) - advanced = attach_audited_rna_decision(initial, record, compiled) - second = save_decision_workflow_snapshot(path, advanced, created_at_ns=20) - - snapshots = list_decision_workflow_snapshots(path, workflow_run_id) - assert [snapshot.sequence for snapshot in snapshots] == [0, 1] - assert second.parentContentSha256 == first.contentSha256 - assert load_latest_decision_workflow_snapshot(path, workflow_run_id) == second - assert ( - load_decision_workflow_snapshot(path, workflow_run_id, first.contentSha256) - == first - ) - - retried = save_decision_workflow_snapshot(path, advanced, created_at_ns=30) - assert retried == second - assert len(list_decision_workflow_snapshots(path, workflow_run_id)) == 2 - - -def test_decision_record_checksum_is_canonical_and_content_sensitive() -> None: - record, _verification = _decision_record( - "workflow-checksum", status="apply", verification=True - ) - expected = hashlib.sha256( - record_io.canonical_json_bytes(record.model_dump(mode="json")) - ).hexdigest() - - assert decision_record_checksum(record) == expected - changed = record.model_copy(update={"rationale": "A different rationale."}) - assert decision_record_checksum(changed) != expected - - -def test_exact_load_rejects_tampered_snapshot_content(tmp_path: Any) -> None: - workflow_run_id = "workflow-tampered" - path = tmp_path / "tampered.zarr" - group = _seed_orchestration(path, workflow_run_id) - snapshot = save_decision_workflow_snapshot( - path, - DecisionWorkflowRun(workflowRunId=workflow_run_id), - created_at_ns=10, - ) - key = persistence_module._snapshot_key( - "agents/orchestrations", - workflow_run_id, - snapshot.contentSha256, - ) - raw = record_io.read_key(group, key) - assert raw is not None - payload = json.loads(raw) - payload["createdAtNs"] = 11 - _set_raw(group, key, record_io.display_json_bytes(payload)) - - with pytest.raises(ValueError, match="does not match its content"): - load_decision_workflow_snapshot(path, workflow_run_id, snapshot.contentSha256) - - -def test_latest_load_rejects_a_gap_or_fork_in_the_chain(tmp_path: Any) -> None: - workflow_run_id = "workflow-gap" - path = tmp_path / "gap.zarr" - group = _seed_orchestration(path, workflow_run_id) - first = save_decision_workflow_snapshot( - path, - DecisionWorkflowRun(workflowRunId=workflow_run_id), - created_at_ns=10, - ) - values = { - "sequence": 2, - "createdAtNs": 20, - "parentContentSha256": first.contentSha256, - "orchestrationRun": first.orchestrationRun, - "workflow": first.workflow, - "contentSha256": "0" * 64, - } - unhashed = DecisionWorkflowSnapshot.model_validate(values) - values["contentSha256"] = persistence_module._snapshot_checksum(unhashed) - orphan = DecisionWorkflowSnapshot.model_validate(values) - key = persistence_module._snapshot_key( - "agents/orchestrations", workflow_run_id, orphan.contentSha256 - ) - _set_raw( - group, - key, - record_io.display_json_bytes(orphan.model_dump(mode="json")), - ) - - with pytest.raises(ValueError, match="gap or fork"): - load_latest_decision_workflow_snapshot(path, workflow_run_id) - - -def test_unknown_or_old_formats_fail_with_actionable_rerun_message( - tmp_path: Any, -) -> None: - workflow_run_id = "workflow-old" - old_path = tmp_path / "old.zarr" - _seed_orchestration(old_path, workflow_run_id, format_version=1) - - with pytest.raises( - DecisionPersistenceFormatError, match="Start a new orchestration run" - ): - save_decision_workflow_snapshot( - old_path, - DecisionWorkflowRun(workflowRunId=workflow_run_id), - created_at_ns=10, - ) - - current_path = tmp_path / "unknown-snapshot.zarr" - group = _seed_orchestration(current_path, workflow_run_id) - snapshot = save_decision_workflow_snapshot( - current_path, - DecisionWorkflowRun(workflowRunId=workflow_run_id), - created_at_ns=10, - ) - key = persistence_module._snapshot_key( - "agents/orchestrations", workflow_run_id, snapshot.contentSha256 - ) - raw = record_io.read_key(group, key) - assert raw is not None - payload = json.loads(raw) - payload["formatVersion"] = 1 - _set_raw(group, key, record_io.display_json_bytes(payload)) - - with pytest.raises( - DecisionPersistenceFormatError, match="Start a new orchestration run" - ): - load_decision_workflow_snapshot( - current_path, workflow_run_id, snapshot.contentSha256 - ) - - -def test_snapshot_requires_exact_orchestration_run_identity(tmp_path: Any) -> None: - path = tmp_path / "identity.zarr" - _seed_orchestration(path, "workflow-identity") - - with pytest.raises(KeyError, match="Unknown orchestration run"): - save_decision_workflow_snapshot( - path, - DecisionWorkflowRun(workflowRunId="workflow-other"), - created_at_ns=10, - ) - - -def test_replay_requires_exact_completed_snapshot_and_handoff(tmp_path: Any) -> None: - workflow_run_id = "workflow-replay" - path = tmp_path / "replay.zarr" - _seed_orchestration(path, workflow_run_id) - completed = _workflow_for_status(workflow_run_id, "completed") - snapshot = save_decision_workflow_snapshot(path, completed, created_at_ns=10) - - replay = load_decision_workflow_for_replay( - path, - workflow_run_id, - snapshot.contentSha256, - expected_handoff_id=completed.finalHandoffId, - ) - assert replay == completed - with pytest.raises(ValueError, match="final handoff identity"): - load_decision_workflow_for_replay( - path, - workflow_run_id, - snapshot.contentSha256, - expected_handoff_id="handoff-other", - ) - - running_id = "workflow-running-replay" - running_path = tmp_path / "running-replay.zarr" - _seed_orchestration(running_path, running_id) - running = save_decision_workflow_snapshot( - running_path, - DecisionWorkflowRun(workflowRunId=running_id), - created_at_ns=10, - ) - with pytest.raises(RuntimeError, match="completed decision workflow"): - load_decision_workflow_for_replay( - running_path, running_id, running.contentSha256 - ) - - -def test_builder_attaches_only_exact_audited_transition_order() -> None: - workflow_run_id = "workflow-builder" - workflow = DecisionWorkflowRun(workflowRunId=workflow_run_id) - grouping_record, grouping_compiled = _compiled_qc_grouping(workflow_run_id) - after_grouping = attach_audited_rna_decision( - workflow, - grouping_record, - grouping_compiled, - ) - cell_record, cell_compiled = _compiled_cell_quality(workflow_run_id) - - after_cell = attach_audited_rna_decision( - after_grouping, - cell_record, - cell_compiled, - ) - assert after_cell.decisionRecords == [grouping_record, cell_record] - assert after_cell.verificationRecords == [ - grouping_compiled.verification, - cell_compiled.verification, - ] - - pca = build_pca_prefix_decision(evidence_bundle_id="bundle:pca", matrix_rank=50) - pca_bundle = EvidenceBundle( - bundleId="bundle:pca", - decisionId="pcaPrefix", - evidence=[ - DecisionEvidence( - evidenceId="evidence:geometry", - evidenceClass="geometric", - summary="The standard prefix has stable neighbors.", - ), - DecisionEvidence( - evidenceId="evidence:technical", - evidenceClass="technical", - summary="The standard prefix is not dominated by technical loadings.", - ), - ], - ).with_content_sha256() - assert pca_bundle.contentSha256 is not None - pca_record = DecisionRecord( - recordId="decision:workflow-builder:pca", - decisionId="pcaPrefix", - definitionVersion=1, - evidenceBundleId=pca_bundle.bundleId, - evidenceBundleSha256=pca_bundle.contentSha256, - offeredOptionIds=[option.optionId for option in pca.spec.options], - availableEvidenceIds=[item.evidenceId for item in pca_bundle.evidence], - selectedOptionId="pcaPrefix:standard", - status="apply", - source="agent", - evidenceIds=[item.evidenceId for item in pca_bundle.evidence], - rationale="The standard prefix is the smallest stable registered option.", - verificationId="verification:decision:workflow-builder:pca", - ) - pca_compiled = compile_rna_decision(pca, pca_bundle, pca_record) - with pytest.raises(ValueError, match="transition order"): - attach_audited_rna_decision(after_cell, pca_record, pca_compiled) - - features = build_feature_policy_decision( - evidence_bundle_id="bundle:features", - proposed_exclusion_families=[], - dominant_families=[], - protected_families=[], - ) - feature_bundle = EvidenceBundle( - bundleId="bundle:features", - decisionId="featurePolicy", - evidence=[ - DecisionEvidence( - evidenceId="evidence:technical", - evidenceClass="technical", - summary="No conditional family dominates the representation.", - ) - ], - ).with_content_sha256() - assert feature_bundle.contentSha256 is not None - feature_record = DecisionRecord( - recordId="decision:workflow-builder:features", - decisionId="featurePolicy", - definitionVersion=1, - evidenceBundleId=feature_bundle.bundleId, - evidenceBundleSha256=feature_bundle.contentSha256, - offeredOptionIds=[option.optionId for option in features.spec.options], - availableEvidenceIds=["evidence:technical"], - selectedOptionId="featurePolicy:keepAll", - status="skip", - source="agent", - evidenceIds=["evidence:technical"], - rationale="No eligible nuisance bundle is supported.", - verificationId="verification:decision:workflow-builder:features", - ) - feature_compiled = compile_rna_decision(features, feature_bundle, feature_record) - after_features = attach_audited_rna_decision( - after_cell, feature_record, feature_compiled - ) - assert [record.decisionId for record in after_features.decisionRecords] == [ - "qcGrouping", - "cellQuality", - "featurePolicy", - ] - - -def test_snapshot_storage_does_not_overwrite_existing_content(tmp_path: Any) -> None: - workflow_run_id = "workflow-no-overwrite" - path = tmp_path / "no-overwrite.zarr" - group = _seed_orchestration(path, workflow_run_id) - snapshot = save_decision_workflow_snapshot( - path, - DecisionWorkflowRun(workflowRunId=workflow_run_id), - created_at_ns=10, - ) - key = persistence_module._snapshot_key( - "agents/orchestrations", workflow_run_id, snapshot.contentSha256 - ) - - with pytest.raises(FileExistsError, match="already exists"): - persistence_module._write_key_once(group, key, b"different") - - assert record_io.read_key(group, key) == record_io.display_json_bytes( - snapshot.model_dump(mode="json") - ) - - -def test_selection_validator_rejects_ineligible_override_fields() -> None: - definition = build_feature_policy_decision( - evidence_bundle_id="bundle:features", - proposed_exclusion_families=["ribosomal"], - dominant_families=["ribosomal"], - protected_families=[], - ) - evidence = EvidenceBundle( - bundleId="bundle:features", - decisionId="featurePolicy", - evidence=[ - DecisionEvidence( - evidenceId="evidence:technical", - evidenceClass="technical", - summary="Ribosomal features dominate the representation.", - ) - ], - ) - selection = DecisionSelection( - selectedOptionId="featurePolicy:excludeEligibleBundle", - evidenceIds=["evidence:technical"], - rationale="Exclude the eligible family.", - overrideOfOptionId="featurePolicy:keepAll", - overrideEvidenceIds=["evidence:technical"], - ) - - with pytest.raises( - ValueError, - match="Override fields require an eligible metric-preferred override", - ): - decisions_module._validate_selection(definition, evidence, selection) - - -def test_resolver_replays_an_exact_audited_decision_without_provider( - tmp_path: Any, - monkeypatch: pytest.MonkeyPatch, -) -> None: - workflow_run_id = "workflow-decision-replay" - path = tmp_path / "decision-replay.zarr" - _seed_orchestration(path, workflow_run_id) - request_record = _orchestration_request_record(path, workflow_run_id) - evidence = EvidenceBundle( - bundleId="bundle:qc-grouping", - decisionId="qcGrouping", - evidence=[ - DecisionEvidence( - evidenceId="evidence:quality", - evidenceClass="qualityControl", - summary="Global quality-reference evidence is available.", - ), - DecisionEvidence( - evidenceId="evidence:design", - evidenceClass="design", - summary="No physical capture is registered.", - ), - ], - ) - definition = build_qc_grouping_decision( - evidence_bundle_id=evidence.bundleId, - physical_capture_eligible=False, - pooled_reference_eligible=False, - ) - calls = 0 - - def select_once(**_kwargs: Any) -> SimpleNamespace: - nonlocal calls - calls += 1 - return SimpleNamespace( - output=DecisionSelection( - selectedOptionId="qcGrouping:global", - evidenceIds=["evidence:quality", "evidence:design"], - rationale="The registered global reference is supported.", - ), - runInfo=SimpleNamespace(modelName="test-model"), - ) - - monkeypatch.setattr(decisions_module, "run_agent_sync", select_once) - resolver = DecisionStagesMixin() - resolver.model = object() - - first = resolver._resolve_rna_decision( - path, - request_record, - definition, - evidence, - {}, - ) - second = resolver._resolve_rna_decision( - path, - request_record, - definition, - evidence, - {}, - ) - - assert calls == 1 - assert first.record is not None - assert second.record == first.record - assert second.compiled == first.compiled - - -def test_agent_reconsideration_revises_and_recomputes_invalidated_descendant( - tmp_path: Any, - monkeypatch: pytest.MonkeyPatch, -) -> None: - workflow_run_id = "workflow-decision-revision" - path = tmp_path / "decision-revision.zarr" - _seed_orchestration(path, workflow_run_id) - request_record = _orchestration_request_record(path, workflow_run_id) - resolver = DecisionStagesMixin() - resolver.model = object() - - grouping_evidence = EvidenceBundle( - bundleId="bundle:qc-grouping:initial", - decisionId="qcGrouping", - evidence=[ - DecisionEvidence( - evidenceId="evidence:quality:initial", - evidenceClass="qualityControl", - summary="Initial quality evidence supports a global reference.", - ), - DecisionEvidence( - evidenceId="evidence:design:initial", - evidenceClass="design", - summary="No physical capture was initially licensed.", - ), - ], - ) - grouping_definition = build_qc_grouping_decision( - evidence_bundle_id=grouping_evidence.bundleId, - physical_capture_eligible=False, - pooled_reference_eligible=False, - ) - initial_grouping = resolver._resolve_rna_decision( - path, - request_record, - grouping_definition, - grouping_evidence, - {}, - rule_selection=DecisionSelection( - selectedOptionId="qcGrouping:global", - evidenceIds=[ - "evidence:quality:initial", - "evidence:design:initial", - ], - rationale="Use the only licensed global reference.", - ), - ) - assert initial_grouping.record is not None - - cell_evidence = EvidenceBundle( - bundleId="bundle:cell-quality", - decisionId="cellQuality", - evidence=[ - DecisionEvidence( - evidenceId="evidence:cell-quality", - evidenceClass="qualityControl", - summary="The published cells can be retained with diagnostic flags.", - ) - ], - ) - cell_definition = build_cell_quality_decision( - evidence_bundle_id=cell_evidence.bundleId, - available_profiles=["retainWithFlags", "globalMad5"], - ) - initial_cell = resolver._resolve_rna_decision( - path, - request_record, - cell_definition, - cell_evidence, - {}, - rule_selection=DecisionSelection( - selectedOptionId="cellQuality:retainWithFlags", - evidenceIds=["evidence:cell-quality"], - rationale="Retain the initial cells with diagnostic flags.", - ), - ) - assert initial_cell.record is not None - - revision_evidence = EvidenceBundle( - bundleId="bundle:qc-grouping:revision", - decisionId="qcGrouping", - evidence=[ - DecisionEvidence( - evidenceId="evidence:quality:revision", - evidenceClass="qualityControl", - summary="Capture-level projections are now available.", - ), - DecisionEvidence( - evidenceId="evidence:design:revision", - evidenceClass="design", - summary="Physical captures are now explicitly registered.", - ), - ], - ).with_content_sha256() - assert revision_evidence.contentSha256 is not None - revision_definition = build_qc_grouping_decision( - evidence_bundle_id=revision_evidence.bundleId, - physical_capture_eligible=True, - pooled_reference_eligible=False, - ) - monkeypatch.setattr( - decisions_module, - "run_agent_sync", - lambda **_kwargs: SimpleNamespace( - output=DecisionSelection( - selectedOptionId="qcGrouping:physicalCapture", - evidenceIds=[ - "evidence:quality:revision", - "evidence:design:revision", - ], - rationale="Use the newly licensed physical-capture references.", - ), - runInfo=SimpleNamespace(modelName="test-model"), - ), - ) - reconsidered = resolver._reconsider_rna_decision( - path, - request_record, - revision_definition, - revision_evidence, - {}, - ) - assert reconsidered.revised is True - assert reconsidered.resolution is not None - revised_grouping = reconsidered.resolution - revision = revised_grouping.workflow.revisionRequests[0] - assert revised_grouping.record is not None - assert revised_grouping.record.supersedes == initial_grouping.record.recordId - assert revised_grouping.workflow.revisionRequests == [revision] - assert [ - record.recordId - for record in revised_grouping.workflow.active_decision_records() - ] == [revised_grouping.record.recordId] - - revised_cell_definition = build_cell_quality_decision( - evidence_bundle_id=cell_evidence.bundleId, - available_profiles=["retainWithFlags", "captureMad5"], - ) - recomputed_cell = resolver._resolve_rna_decision( - path, - request_record, - revised_cell_definition, - cell_evidence, - {}, - rule_selection=DecisionSelection( - selectedOptionId="cellQuality:captureMad5", - evidenceIds=["evidence:cell-quality"], - rationale="Recompute cell quality within the registered captures.", - ), - ) - assert recomputed_cell.record is not None - assert recomputed_cell.record.supersedes == initial_cell.record.recordId - assert [ - record.recordId for record in recomputed_cell.workflow.active_decision_records() - ] == [revised_grouping.record.recordId, recomputed_cell.record.recordId] - - -def test_resolver_persists_pending_state_after_model_failure( - tmp_path: Any, - monkeypatch: pytest.MonkeyPatch, -) -> None: - workflow_run_id = "workflow-decision-failure" - path = tmp_path / "decision-failure.zarr" - _seed_orchestration(path, workflow_run_id) - request_record = _orchestration_request_record(path, workflow_run_id) - evidence = EvidenceBundle( - bundleId="bundle:qc-grouping", - decisionId="qcGrouping", - evidence=[ - DecisionEvidence( - evidenceId="evidence:quality", - evidenceClass="qualityControl", - summary="Registered cell-quality projections are available.", - ), - DecisionEvidence( - evidenceId="evidence:design", - evidenceClass="design", - summary="No physical capture is registered.", - ), - ], - ) - definition = build_qc_grouping_decision( - evidence_bundle_id=evidence.bundleId, - physical_capture_eligible=False, - pooled_reference_eligible=False, - ) - - def fail_model(**_kwargs: Any) -> None: - raise AgentRunError("bounded model failure") - - monkeypatch.setattr(decisions_module, "run_agent_sync", fail_model) - resolver = DecisionStagesMixin() - resolver.model = object() - - resolution = resolver._resolve_rna_decision( - path, - request_record, - definition, - evidence, - {}, - ) - - assert resolution.compiled is None - assert resolution.record is None - assert resolution.workflow.status == "needsInput" - assert resolution.pending is not None - persisted = load_latest_decision_workflow_snapshot(path, workflow_run_id) - assert persisted.workflow.pendingDecision == resolution.pending - - resumed = resolver._resolve_rna_decision( - path, - request_record, - definition, - evidence, - { - "decision:qcGrouping": { - "decisionId": "qcGrouping", - "optionId": "qcGrouping:global", - "rationale": "Use the completed registered grouping evidence.", - } - }, - ) - - assert resumed.workflow.status == "running" - assert resumed.workflow.pendingDecision is None - assert resumed.record is not None - assert resumed.record.source == "human" - - -def test_unattended_resolver_uses_registered_baseline_after_model_failure( - tmp_path: Any, - monkeypatch: pytest.MonkeyPatch, -) -> None: - workflow_run_id = "workflow-unattended-decision-failure" - path = tmp_path / "unattended-decision-failure.zarr" - _seed_orchestration(path, workflow_run_id) - request_record = _orchestration_request_record( - path, - workflow_run_id, - ).model_copy( - update={ - "config": AutomatedWorkflowConfig(inputPolicy="unattended"), - } - ) - evidence = EvidenceBundle( - bundleId="bundle:qc-grouping", - decisionId="qcGrouping", - evidence=[ - DecisionEvidence( - evidenceId="evidence:quality", - evidenceClass="qualityControl", - summary="Registered cell-quality projections are available.", - ), - DecisionEvidence( - evidenceId="evidence:design", - evidenceClass="design", - summary="No physical capture is registered.", - ), - ], - ) - definition = build_qc_grouping_decision( - evidence_bundle_id=evidence.bundleId, - physical_capture_eligible=False, - pooled_reference_eligible=False, - ) - - def fail_model(**_kwargs: Any) -> None: - raise AgentRunError("bounded model failure") - - monkeypatch.setattr(decisions_module, "run_agent_sync", fail_model) - resolver = DecisionStagesMixin() - resolver.model = object() - - resolution = resolver._resolve_rna_decision( - path, - request_record, - definition, - evidence, - {}, - ) - - assert resolution.workflow.status == "running" - assert resolution.pending is None - assert resolution.record is not None - assert resolution.record.selectedOptionId == "qcGrouping:global" - assert resolution.record.source == "rule" diff --git a/tests/test_agent_design_comparisons.py b/tests/test_agent_design_comparisons.py new file mode 100644 index 00000000..fb2e9322 --- /dev/null +++ b/tests/test_agent_design_comparisons.py @@ -0,0 +1,612 @@ +"""Objective-led metadata comparisons remain bounded and respect study units.""" + +import asyncio +from types import SimpleNamespace + +import numpy as np +import pandas as pd +import pytest +from pydantic import ValidationError +from pydantic_ai import ModelRetry + +from scarf.agent.experimental_context import tools +from scarf.agent.experimental_context import qc_evidence +from scarf.agent.cell_quality.profiles import project_registered_qc_profile +from scarf.agent.experimental_context.comparisons import ( + accept_capture_proposal, + canonical_design_choices, + combination_labels, + compare_covariates, + evaluate_proposals, +) +from scarf.agent.experimental_context.contracts import ( + CaptureProposal, + CovariateCharacterization, + CovariateProposal, + ExperimentalContextDecision, + ExperimentalContextDependencies, +) +from scarf.agent.experimental_context.study import build_study_contract +from scarf.agent.parameter_tuning import diagnostics, execution +from scarf.storage.refs import ArtifactRef + + +class _Cells: + def __init__(self, frame: pd.DataFrame) -> None: + self.frame = frame + self.columns = list(frame.columns) + + def fetch(self, column: str) -> np.ndarray: + return self.frame[column].to_numpy() + + +def _design() -> tuple[_Cells, CovariateCharacterization]: + frame = pd.DataFrame( + { + "sample": [f"s{i}" for i in range(8)], + "treatment": ["a", "a", "b", "b"] * 2, + "time": ["early", "late", "early", "late"] * 2, + "response": ["same", "different", "different", "same"] * 2, + "age": np.arange(8, dtype=float), + } + ) + cells = _Cells(frame.loc[frame.index.repeat(3)].reset_index(drop=True)) + characterization = CovariateCharacterization( + status="done", + columns=[ + { + "name": name, + "kind": "continuous" if name == "age" else "categorical", + "domain": "design" if name == "sample" else "biological", + } + for name in cells.columns + ], + ) + return cells, characterization + + +def _proposal(**changes: object) -> CovariateProposal: + return CovariateProposal.model_validate( + { + "response": "response", + "explanatoryColumns": ["treatment", "time"], + "observationUnit": "sample", + "rationale": "Preserve treatment-by-time structure relevant to the objective.", + **changes, + } + ) + + +def _deps(cells: _Cells) -> ExperimentalContextDependencies: + return ExperimentalContextDependencies( + cells=cells, + store=SimpleNamespace(cells=cells), + cellSelection=ArtifactRef( + scope="datastore", kind="cell_selection", artifact_id="c" * 64 + ), + ) + + +def test_joint_explanation_detects_interaction_without_additive_alias() -> None: + cells, characterization = _design() + result = compare_covariates( + cells, characterization, _proposal(), selection_identity={"id": "one"} + ) + assert result.status == "computed" + assert result.evidence["independentUnits"] == 8 + assert result.evidence["jointEstimability"]["coefficientEstimable"] is True + assert result.evidence["jointGroupEstimability"]["coefficientEstimable"] is False + assert all( + item["value"] == 0 for item in result.evidence["singleAssociations"].values() + ) + assert ( + result.evidence["jointAssociation"]["directionalMapping"]["nesting"] != "none" + ) + + +def test_conditional_comparison_keeps_each_stratum_and_support() -> None: + cells, characterization = _design() + result = compare_covariates( + cells, + characterization, + _proposal(explanatoryColumns=["treatment"], conditionedOn="time"), + selection_identity={}, + ) + assert result.status == "computed" + assert len(result.evidence["strata"]) == 2 + assert all(row["independentUnits"] == 4 for row in result.evidence["strata"]) + + +@pytest.mark.parametrize( + "change,reason", + [ + ( + {"explanatoryColumns": ["treatment"], "conditionedOn": "age"}, + "continuousConditioningIsUnsupported", + ), + ( + {"observationUnit": "response"}, + "observationAndIndependentUnitsMustBeDesignOrTechnical", + ), + ({"response": "missing"}, "unknownObservedColumn"), + ], +) +def test_unsupported_explanations_are_explicit( + change: dict[str, object], reason: str +) -> None: + cells, characterization = _design() + result = compare_covariates( + cells, characterization, _proposal(**change), selection_identity={} + ) + assert result.status == "unsupported" + assert reason in result.reasons + + +def test_missingness_does_not_become_a_categorical_group() -> None: + cells, characterization = _design() + cells.frame.loc[:2, "response"] = None + result = compare_covariates( + cells, characterization, _proposal(), selection_identity={} + ) + assert result.evidence["missingCells"] == 3 + assert result.evidence["missingCellsByColumn"]["response"] == 3 + assert result.evidence["independentUnits"] == 7 + assert result.status == "unsupported" + + +@pytest.mark.parametrize("declared", [False, True]) +def test_biological_donor_can_be_an_explicit_independent_unit(declared: bool) -> None: + cells, characterization = _design() + cells.frame["donor"] = cells.frame["sample"].map( + {f"s{i}": f"d{i % 4}" for i in range(8)} + ) + cells.columns.append("donor") + characterization.columns.append( + {"name": "donor", "kind": "categorical", "domain": "biological"} + ) + if declared: + characterization.coefficients = [ + { + "name": "response", + "observationUnit": "sample", + "independentUnit": "donor", + } + ] + result = compare_covariates( + cells, + characterization, + _proposal(explanatoryColumns=["treatment"], independentUnit="donor"), + selection_identity={}, + ) + if declared: + assert result.status == "computed" + assert result.evidence["independentUnits"] == 4 + assert result.evidence["observationUnits"] == 8 + assert result.evidence["columnDomains"]["donor"] == "biological" + else: + assert result.status == "unsupported" + assert "observationAndIndependentUnitsMustBeDesignOrTechnical" in result.reasons + + +@pytest.mark.parametrize("invalid", ["continuous", "withinDonor"]) +def test_declared_biological_unit_keeps_kind_and_repeated_measure_guards( + invalid: str, +) -> None: + cells, characterization = _design() + cells.frame["donor"] = cells.frame["sample"].map( + {f"s{i}": f"d{i % 4}" for i in range(8)} + ) + cells.columns.append("donor") + characterization.columns.append( + { + "name": "donor", + "kind": "continuous" if invalid == "continuous" else "categorical", + "domain": "biological", + } + ) + characterization.coefficients = [ + {"name": "response", "observationUnit": "sample", "independentUnit": "donor"} + ] + if invalid == "withinDonor": + cells.frame.loc[cells.frame["sample"] == "s4", "response"] = "different" + result = compare_covariates( + cells, + characterization, + _proposal(explanatoryColumns=["treatment"], independentUnit="donor"), + selection_identity={}, + ) + assert result.status == "unsupported" + assert ( + "observationAndIndependentUnitsMustBeCategorical" + if invalid == "continuous" + else "withinIndependentUnitComparisonsAreUnsupported" + ) in result.reasons + + +def test_study_contract_preserves_unsupported_comparison_limitations() -> None: + cells, characterization = _design() + comparison = compare_covariates( + cells, + characterization, + _proposal(observationUnit="response"), + selection_identity={}, + ) + characterization.comparisons = [comparison] + original = characterization.model_dump(mode="json") + contract = build_study_contract( + study_context="Independent donors with technical captures.", + study_objective="Describe populations without unsupported associations.", + experimental_result=SimpleNamespace( + status="done", + decision=ExperimentalContextDecision.get_blank(), + batchSafety=[], + characterization=characterization, + notes=[], + ), + ) + limitation = next( + value for value in contract.limitations if comparison.evidenceId in value + ) + assert comparison.reasons[0] in limitation + assert "no supported association or absence finding" in limitation + assert characterization.model_dump(mode="json") == original + + +def test_two_round_limit_counts_retries_and_reuses_identical_proposals() -> None: + cells, characterization = _design() + deps = _deps(cells) + proposal = _proposal(protectCombination=True) + evaluate_proposals(deps, characterization, [proposal] * 8) + assert len(deps.comparisons) == 1 + assert deps.protectedCombinations == [["time", "treatment"]] + with pytest.raises(ValueError, match="eight initial and four"): + evaluate_proposals(deps, characterization, [proposal] * 5) + evaluate_proposals(deps, characterization, [proposal]) + assert len(deps.comparisons) == 1 + with pytest.raises(ValueError, match="two evidence rounds"): + evaluate_proposals(deps, characterization, []) + + +def test_unsupported_explanation_does_not_discard_protected_biology() -> None: + cells, characterization = _design() + cells.frame.loc[:2, "response"] = None + deps = _deps(cells) + evaluate_proposals(deps, characterization, [_proposal(protectCombination=True)]) + assert deps.comparisons[0].status == "unsupported" + assert deps.protectedCombinations == [["time", "treatment"]] + + +def test_proposals_cannot_exceed_three_columns() -> None: + with pytest.raises(ValidationError, match="three distinct"): + _proposal(conditionedOn="age") + + +def test_combinations_preserve_typed_values_and_reject_missing() -> None: + cells = _Cells( + pd.DataFrame({"a": ["x,y", "x", 1, "1"], "b": ["z", "y,z", "a", "a"]}) + ) + assert len(np.unique(combination_labels(cells, ["a", "b"]))) == 4 + cells.frame.loc[0, "b"] = None + with pytest.raises(ValueError, match="missing"): + combination_labels(cells, ["a", "b"]) + + +def test_capture_and_reference_selection_requires_verbatim_provenance() -> None: + cells, characterization = _design() + deps = _deps(cells) + deps.studyContext = ( + "sample identifies the physical capture. s0 and s1 are reference captures." + ) + proposal = CaptureProposal( + column="sample", + provenanceQuote="sample identifies the physical capture.", + referenceCaptures=["s0", "s1"], + referenceProvenanceQuote="s0 and s1 are reference captures.", + ) + accept_capture_proposal(deps, characterization, proposal) + assert deps.directions == {} + choices = canonical_design_choices(deps, ExperimentalContextDecision()) + assert choices["physicalCaptureColumn"] == "sample" + assert choices["pooledReferenceCaptures"] == ["s0", "s1"] + with pytest.raises(ValueError, match="exact study quote"): + accept_capture_proposal(_deps(cells), characterization, proposal) + with pytest.raises(ValueError, match="Reference captures require"): + accept_capture_proposal( + deps, + characterization, + proposal.model_copy( + update={"referenceProvenanceQuote": "Made up controls"} + ), + ) + + +def test_final_decision_cannot_invent_a_protected_combination() -> None: + cells, _ = _design() + deps = _deps(cells) + decision = ExperimentalContextDecision( + protectedCombinations=[["time", "treatment"]] + ) + with pytest.raises(ValueError, match="evaluated"): + canonical_design_choices(deps, decision) + + +def test_continuous_protection_is_explicit_without_changing_design_license() -> None: + cells, characterization = _design() + deps = _deps(cells) + deps.characterization = characterization + decision = ExperimentalContextDecision(coefficientsOfInterest=["age"]) + choices = canonical_design_choices(deps, decision) + assert choices["unsupportedProtection"] == ["age"] + assert "batchCorrection" not in choices + + +def test_qc_retention_checks_joint_groups_beside_marginal_groups() -> None: + cells, characterization = _design() + characterization.coefficients = [{"name": "treatment"}, {"name": "time"}] + deps = _deps(cells) + deps.protectedCombinations = [["treatment", "time"]] + keep = ~((cells.fetch("treatment") == "a") & (cells.fetch("time") == "early")) + mito = np.where(keep, np.linspace(1, 3, len(keep)), 80.0) + projection = project_registered_qc_profile( + "globalMad5", + values_by_metric={"RNA_percentMito": mito}, + active=np.ones(len(keep), dtype=bool), + ) + result = qc_evidence._registered_profile_evidence( + projection, + deps=deps, + characterization=characterization, + driver=("RNA", "RNA"), + active=np.ones(len(keep), dtype=bool), + values_by_attr={"RNA_percentMito": mito}, + metadata_attributes=["RNA_percentMito"], + artifact_metrics=[], + metric_sources=[], + source_concordance=[], + sample_column=None, + sample_artifact=None, + capture_column=None, + capture_artifact=None, + capture_labels=None, + pooled_reference_captures=None, + active_cells=len(keep), + comparison_source=None, + ) + assert all( + count > 0 + for counts in result.retainedCellsByColumn.values() + for count in counts.values() + ) + assert any( + count == 0 + for counts in result.retainedCellsByCombination.values() + for count in counts.values() + ) + assert any( + reason.startswith("combination:") for reason in result.unsafeRetentionGroups + ) + + +@pytest.mark.parametrize("independent_units", [5, 8]) +def test_interaction_confounding_blocks_only_the_matching_batch_set( + monkeypatch: pytest.MonkeyPatch, + independent_units: int, +) -> None: + cells, characterization = _design() + cells.frame = cells.frame.iloc[: independent_units * 3].copy() + for record in characterization.columns: + if record["name"] in {"treatment", "time"}: + record["domain"] = "technical" + characterization.coefficients = [ + { + "name": "response", + "kind": "categorical", + "observationUnit": "sample", + "scope": "betweenUnit", + } + ] + characterization.confounding = [ + { + "coefficient": "response", + "observationUnit": "sample", + "pairs": [{"technical": "treatment"}, {"technical": "time"}], + } + ] + deps = _deps(cells) + evaluate_proposals(deps, characterization, [_proposal()]) + if independent_units == 5: + assert deps.comparisons[0].status == "unsupported" + assert deps.comparisons[0].reasons == ["unsupportedJointAssociation"] + assert ( + deps.comparisons[0].evidence["jointGroupEstimability"][ + "coefficientEstimable" + ] + is False + ) + monkeypatch.setattr( + tools, + "reduce_observation_units", + lambda _cells, unit, columns, **_kwargs: cells.frame.groupby(unit).first()[ + columns + ], + ) + joint = tools._batch_safety_evidence( + deps, + characterization, + coefficients=["response"], + batch_columns=["treatment", "time"], + ) + single = tools._batch_safety_evidence( + deps, characterization, coefficients=["response"], batch_columns=["treatment"] + ) + assert joint[0].status == "unsafe" + assert single[0].status == "safe" + + +def test_tool_rejects_oversized_batch_before_scanning( + monkeypatch: pytest.MonkeyPatch, +) -> None: + cells, _ = _design() + deps = _deps(cells) + monkeypatch.setattr( + tools, + "characterize_covariates", + lambda *_args, **_kwargs: pytest.fail("must reject before metadata scan"), + ) + with pytest.raises(ModelRetry, match="eight initial"): + asyncio.run( + tools.analyze_experimental_design( + SimpleNamespace(deps=deps), + column_domains={}, + coefficients_of_interest=[], + units_of_inference={}, + batch_columns=[], + proposals=[_proposal()] * 9, + ) + ) + + +def test_pca_associations_use_covariate_kind_independently_of_role( + monkeypatch: pytest.MonkeyPatch, +) -> None: + values = np.arange(12, dtype=float) + coordinates = np.column_stack([values, np.tile([1.0, -1.0], 6)]) + monkeypatch.setattr( + diagnostics, + "_aligned_metadata_values", + lambda _store, _selection, column: ( + values % 2 if column == "qc_code" else values + ), + ) + support: dict[str, object] = {} + scores = diagnostics._covariate_associations( + None, + None, + coordinates, + ["age", "technical_age", "qc_code"], + ["protected", "technical", "qc"], + {"age": "continuous", "technical_age": "continuous", "qc_code": "categorical"}, + support, + ) + assert scores[0, 0] == pytest.approx(1.0) + assert scores[0, 1] < 0.2 + np.testing.assert_array_equal(scores[0], scores[1]) + assert scores[2, 1] == pytest.approx(1.0) + assert support["age"]["kind"] == "continuous" + + +def test_pca_numeric_association_uses_complete_rows_without_loading_coordinates( + monkeypatch: pytest.MonkeyPatch, +) -> None: + coordinates = np.arange(10, dtype=float).reshape(5, 2) + + class BoundedCoordinates: + shape = coordinates.shape + + def __array__(self, *_args: object, **_kwargs: object) -> np.ndarray: + raise AssertionError("Coordinates must be read in bounded slices") + + def __getitem__(self, rows: slice) -> np.ndarray: + assert rows.stop - rows.start <= 65_536 + return coordinates[rows] + + values = np.asarray([0.0, 1.0, np.nan, 3.0, np.inf]) + monkeypatch.setattr(diagnostics, "_aligned_metadata_values", lambda *_args: values) + support: dict[str, object] = {} + scores = diagnostics._covariate_associations( + None, + None, + BoundedCoordinates(), + ["age"], + ["protected"], + {"age": "continuous"}, + support, + ) + np.testing.assert_allclose(scores, 1.0) + assert support["age"]["completeRows"] == 3 + assert support["age"]["missingRows"] == 2 + + +def test_metric_fingerprint_changes_when_only_missing_mask_changes() -> None: + values = np.asarray([1.0, 2.0, 3.0]) + missing = np.asarray([False, False, False]) + metadata = SimpleNamespace( + N=3, + _get_array=lambda _column: values, + default_block_rows=lambda _column: 2, + _get_missing_mask_array=lambda _column: missing, + ) + first = execution._metadata_column_fingerprint(metadata, "age") + missing[1] = True + assert execution._metadata_column_fingerprint(metadata, "age") != first + missing[1] = False + assert execution._metadata_column_fingerprint(metadata, "age") == first + + +def test_single_cluster_keeps_diagnostics_without_running_marker_contrasts( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from scarf.agent.parameter_tuning.contracts import ( + ArtifactRecord, + ParameterCandidateEvaluation, + ) + from scarf.agent.types import ArtifactReferenceModel + + graph = ArtifactRef( + scope="assay", assay="RNA", kind="connectivity_map", artifact_id="a" * 64 + ) + clusters = ArtifactRef( + scope="assay", assay="RNA", kind="cluster_labels", artifact_id="b" * 64 + ) + selection = ArtifactRef( + scope="datastore", kind="cell_selection", artifact_id="c" * 64 + ) + labels = np.zeros(20, dtype=int) + evaluation = ParameterCandidateEvaluation( + candidateId="single", + status="done", + eligible=True, + cellSelection=ArtifactReferenceModel.from_artifact_ref(selection), + artifacts={ + "connectivityMap": ArtifactRecord.from_ref(graph), + "clusters": ArtifactRecord.from_ref(clusters), + }, + ) + monkeypatch.setattr( + diagnostics, + "_selected_feature_names", + lambda *_args: (np.arange(3), np.asarray(["A", "B", "C"])), + ) + monkeypatch.setattr(diagnostics, "_cluster_labels", lambda *_args: labels) + monkeypatch.setattr( + diagnostics, "_subsample_partition_stability", lambda *_args: 1.0 + ) + + def no_markers(*_args, **_kwargs): + raise AssertionError("A single cluster has no marker contrast") + + store = SimpleNamespace( + run_leiden_clustering=lambda *_args, **_kwargs: clusters, + load_graph=lambda *_args: object(), + run_marker_search=no_markers, + cells=SimpleNamespace(columns=[]), + ) + (result,) = diagnostics.augment_cluster_evaluations( + store, + [evaluation], + marker_assay="RNA", + marker_features=selection, + independent_unit_columns=[], + technical_columns=[], + ) + assert not result.eligible + assert result.metrics.markerCoherence is None + assert result.metrics.markerSpecificityMedian is None + assert result.metrics.markerFamilyEnrichment == {} + assert "markerTable" not in result.artifacts + assert result.metrics.seedStability == 1.0 + assert result.metrics.subsampleStability == 1.0 + assert ( + "Marker contrasts require at least two populated clusters" + in result.eligibilityReasons + ) diff --git a/tests/test_agent_exec.py b/tests/test_agent_exec.py index e229566d..a9cde642 100644 --- a/tests/test_agent_exec.py +++ b/tests/test_agent_exec.py @@ -1,8 +1,9 @@ """Tests for shared Scarf agent execution and configuration.""" +from tests.agent_examples import example + import asyncio import json -import sys import threading import httpx @@ -23,7 +24,9 @@ from pydantic_ai.providers.openai import OpenAIProvider from pydantic_ai.exceptions import ModelHTTPError, UserError -from scarf.agent import CovariateCharacterization, FeatureCharacterization, IngestResult +from scarf.agent.experimental_context.contracts import CovariateCharacterization +from scarf.agent.data_enrichment.characterization import FeatureCharacterization +from scarf.agent.ingest import IngestResult from scarf.agent.config.agent_exec import ( _image_input_is_unsupported, _model_name, @@ -91,136 +94,27 @@ def test_shared_models_have_blank_and_example_constructors() -> None: ) for model in models: assert isinstance(model.get_blank(), model) - assert isinstance(model.get_example(), model) + assert isinstance(example(model), model) assert all("_" not in field_name for field_name in model.model_fields) -def test_four_agent_objects_are_public() -> None: - import scarf.agent as agent_package +def test_focused_scientific_agents_remain_available_in_their_subpackages() -> None: + from scarf.agent.biological_interpretation import BiologicalInterpretationAgent + from scarf.agent.data_enrichment import DataEnrichmentAgent + from scarf.agent.experimental_context import ExperimentalContextAgent + from scarf.agent.parameter_tuning import ParameterTuningAgent - assert agent_package.DataEnrichmentAgent.__name__ == "DataEnrichmentAgent" - assert agent_package.ExperimentalContextAgent.__name__ == "ExperimentalContextAgent" - assert agent_package.ParameterTuningAgent.__name__ == "ParameterTuningAgent" - assert ( - agent_package.BiologicalInterpretationAgent.__name__ - == "BiologicalInterpretationAgent" + assert all( + callable(agent) + for agent in ( + DataEnrichmentAgent, + ExperimentalContextAgent, + ParameterTuningAgent, + BiologicalInterpretationAgent, + ) ) -def test_agent_facade_exports_remain_stable() -> None: - import scarf.agent as agent_package - - expected = { - "AgentInvocation", - "AgentName", - "AgentOrchestrator", - "AgentPersistenceTarget", - "AgentReport", - "AgentReportLink", - "AgentReportRecord", - "AgentReportReference", - "AgentReportType", - "AgentRunConfig", - "AgentTerminalStatus", - "AgentWorkflowRun", - "AgentWorkflowStatus", - "AssayPreprocessingPlan", - "AutomatedPreprocessingPlan", - "AutomatedWorkflowConfig", - "AutomatedWorkflowRequest", - "AutomatedWorkflowResult", - "AutomatedWorkflowResumeRequest", - "BatchSafetyEvidence", - "BiologicalContext", - "BiologicalInterpretationAgent", - "BiologicalInterpretationReport", - "CellQcPlan", - "CovariateCharacterization", - "DataEnrichmentAgent", - "DataEnrichmentContext", - "DataEnrichmentReport", - "Decision", - "DecisionEvidence", - "DecisionOption", - "DecisionRecord", - "DecisionSelection", - "DecisionSpec", - "DecisionValidationError", - "DecisionWorkflowRun", - "DeterministicDecisionAuditor", - "DatasetManifest", - "DatasetManifestDecision", - "EvidenceBundle", - "EvidenceItem", - "ExperimentalBiologyHandoff", - "ExperimentalContextAgent", - "ExperimentalContextResult", - "ExperimentalTuningHandoff", - "FeatureCharacterization", - "FinalAnalysisHandoff", - "FinalGraphSelection", - "IngestResult", - "IntegrationCandidateEvaluation", - "IntegrationMetrics", - "NamedArtifactSource", - "NativeAnalysisHandoff", - "NeedsInput", - "ParameterCandidate", - "ParameterSearchPlan", - "ParameterTuningAgent", - "ParameterTuningAssayInput", - "ParameterTuningReport", - "PendingDecision", - "PreprocessedAssayHandoff", - "ProtectedVariableEffect", - "RevisionRequest", - "StageResult", - "StageStatus", - "StudyContextSummary", - "StudyContract", - "TuningBiologyHandoff", - "VerificationCheck", - "VerificationRecord", - "WorkflowNeedsInput", - "WorkflowQuestion", - "WorkflowStageAttempt", - "WorkflowStageLink", - "analyze_rna", - "characterize_covariates", - "characterize_features", - "check_runtime", - "create_agent_workflow", - "decide", - "detect_format", - "finalize_agent_workflow", - "generate_agent_report", - "get_default_parameter_candidates", - "ingest", - "inspect_h5ad_manifest", - "list_agent_reports", - "list_agent_workflows", - "load_agent_record", - "load_agent_report", - "load_agent_workflow", - "load_env", - "run_agent", - "run_agent_sync", - "save_agent_report", - "tune_parameters", - } - - assert set(agent_package.__all__) == expected - assert agent_package._deps is not None - assert { - "scarf.agent.biological_interpretation", - "scarf.agent.data_enrichment", - "scarf.agent.experimental_context", - "scarf.agent.parameter_tuning", - "scarf.agent.persistence", - "scarf.agent.report", - } <= sys.modules.keys() - - def test_model_settings_disable_thinking_across_provider_shapes() -> None: settings = get_model_settings( AgentRunConfig( @@ -420,7 +314,7 @@ def reply( parts=[ ToolCallPart( tool_name=info.output_tools[0].name, - args=ExampleOutput.get_example().model_dump(), + args=example(ExampleOutput).model_dump(), ) ] ) @@ -434,7 +328,7 @@ def reply( name="example-agent", ) - assert result.output == ExampleOutput.get_example() + assert result.output == example(ExampleOutput) assert result.runInfo.agentName == "example-agent" assert result.runInfo.usage.toolCalls == 1 assert [call.toolName for call in result.runInfo.toolCalls] == ["inspect_value"] @@ -512,7 +406,7 @@ def reply( parts=[ ToolCallPart( tool_name=info.output_tools[0].name, - args=ExampleOutput.get_example().model_dump(), + args=example(ExampleOutput).model_dump(), ) ] ) @@ -529,7 +423,7 @@ async def call_from_running_loop() -> object: result = asyncio.run(call_from_running_loop()) - assert result.output == ExampleOutput.get_example() + assert result.output == example(ExampleOutput) assert result.runInfo.agentName == "notebook-host" assert [call.toolName for call in result.runInfo.toolCalls] == ["inspect_value"] @@ -564,7 +458,7 @@ async def respond(request: httpx.Request) -> httpx.Response: "function": { "name": output_tool_name, "arguments": json.dumps( - ExampleOutput.get_example().model_dump() + example(ExampleOutput).model_dump() ), }, } @@ -613,8 +507,8 @@ async def call_twice_from_running_loop() -> list[AgentExecutionResult]: results = asyncio.run(call_twice_from_running_loop()) assert [result.output for result in results] == [ - ExampleOutput.get_example(), - ExampleOutput.get_example(), + example(ExampleOutput), + example(ExampleOutput), ] assert len(clients) == 2 assert all(client.is_closed for client in clients) @@ -652,7 +546,7 @@ def reply( parts=[ ToolCallPart( tool_name=info.output_tools[0].name, - args=ExampleOutput.get_example().model_dump(), + args=example(ExampleOutput).model_dump(), ) ] ) @@ -666,7 +560,7 @@ def reply( ) ) - assert result.output == ExampleOutput.get_example() + assert result.output == example(ExampleOutput) assert callback_thread != event_loop_thread diff --git a/tests/test_agent_experimental_context.py b/tests/test_agent_experimental_context.py index 62e9fa32..520d3cbc 100644 --- a/tests/test_agent_experimental_context.py +++ b/tests/test_agent_experimental_context.py @@ -1,5 +1,7 @@ """Tests for the tool-driven Experimental Context Agent.""" +from tests.agent_examples import example + import asyncio from types import SimpleNamespace from typing import Any, Literal @@ -425,7 +427,7 @@ def test_agent_models_have_blank_and_example_constructors() -> None: ) for model in models: assert isinstance(model.get_blank(), model) - assert isinstance(model.get_example(), model) + assert isinstance(example(model), model) assert all("_" not in field_name for field_name in model.model_fields) assert set(RepresentationEvaluation.model_fields) == { "available", @@ -502,6 +504,7 @@ def result(action: TestAction) -> SimpleNamespace: return SimpleNamespace( status="done", decision=_design_decision(action=action), + characterization=CovariateCharacterization(status="done"), batchSafety=[], notes=[], ) @@ -641,7 +644,7 @@ async def reply( assert sorted(store.zw.group_keys()) == ["artifacts", "cellData"] -def test_agent_pauses_after_design_tool_retry_exhaustion( +def test_agent_fails_after_design_tool_retry_exhaustion( monkeypatch: pytest.MonkeyPatch, ) -> None: store = _Store() @@ -680,20 +683,20 @@ def unavailable_design(**kwargs: Any) -> None: ) assert analyze_retries == [3] - assert result.status == "needsInput" + assert result.status == "failed" assert result.decision.batchCorrection.action == "needsInput" assert result.decision.batchCorrection.batchColumns == [] assert result.cellSelection is not None assert result.cellSelection.artifactId == store.cell_selection.artifact_id assert result.cellQc.profileId == "" assert result.qcProfiles - assert result.runInfo.agentName == "experimental_context_needs_input" + assert result.runInfo.agentName == "experimental_context_failed" with pytest.raises(ValueError, match="must be done"): result.to_parameter_tuning_handoff() - assert any("could not produce" in note for note in result.notes) + assert any("did not produce" in note for note in result.notes) -def test_agent_recovers_malformed_batch_tool_call_without_input( +def test_agent_rejects_malformed_batch_tool_call_without_default_selection( monkeypatch: pytest.MonkeyPatch, ) -> None: store = _Store() @@ -719,7 +722,7 @@ def unavailable_design(**kwargs: Any) -> None: "run_agent_sync", unavailable_design, ) - result = ExperimentalContextAgent(object(), unattended=True).run( + result = ExperimentalContextAgent(object()).run( store, study_context="Population discovery across sequencing batches.", study_objective="Discover stable cell populations.", @@ -736,12 +739,55 @@ def unavailable_design(**kwargs: Any) -> None: }, ) - assert result.status == "done" - assert result.decision.needsInput == [] - assert result.decision.batchCorrection.action == "evaluateHarmony" - assert result.decision.batchCorrection.batchColumns == ["batch"] - assert result.to_parameter_tuning_handoff().batchAction == "evaluateHarmony" - assert result.runInfo.agentName == "experimental_context_deterministic" + assert result.status == "failed" + assert result.decision.batchCorrection.batchColumns == [] + with pytest.raises(ValueError, match="must be done"): + result.to_parameter_tuning_handoff() + assert result.runInfo.agentName == "experimental_context_failed" + assert any("batchassay" in note for note in result.notes) + + +def test_agent_preserves_validated_design_uncertainty( + monkeypatch: pytest.MonkeyPatch, +) -> None: + store = _Store() + + def unresolved_design(**kwargs: Any) -> SimpleNamespace: + deps = kwargs["deps"] + context = RunContext(deps=deps, model=TestModel(), usage=RunUsage()) + asyncio.run(inspect_cell_covariates(context)) + planned = _design_decision("needsInput") + asyncio.run( + analyze_experimental_design( + context, + column_domains=planned.columnDomains, + coefficients_of_interest=planned.coefficientsOfInterest, + units_of_inference=planned.unitsOfInference, + batch_columns=planned.batchCorrection.batchColumns, + ) + ) + planned.needsInput = ["Physical capture provenance remains unresolved."] + decision = kwargs["output_validator"](planned) + return SimpleNamespace( + output=decision, + runInfo=experimental_context_contracts.AgentRunInfo( + agentName="experimental_context", modelName="test" + ), + ) + + monkeypatch.setattr(experimental_context_agent, "run_agent_sync", unresolved_design) + result = ExperimentalContextAgent(object()).run( + store, + study_context="Case-control study with samples nested in donors.", + cell_selection=store.cell_selection, + ) + assert result.status == "needsInput" + assert result.decision.needsInput == [ + "Physical capture provenance remains unresolved." + ] + assert result.runInfo.agentName == "experimental_context" + with pytest.raises(ValueError, match="must be done"): + result.to_parameter_tuning_handoff() def test_handoff_builders_reject_incomplete_or_ambiguous_results() -> None: @@ -749,7 +795,7 @@ def test_handoff_builders_reject_incomplete_or_ambiguous_results() -> None: with pytest.raises(ValueError, match="must be done"): incomplete.to_parameter_tuning_handoff() - ambiguous = ExperimentalContextResult.get_example() + ambiguous = example(ExperimentalContextResult) ambiguous.decision.coefficientsOfInterest.append("second_coefficient") with pytest.raises(ValueError, match="Select one coefficient explicitly"): ambiguous.to_biological_handoff() @@ -1553,6 +1599,8 @@ def test_batch_safety_checks_multiple_columns_jointly() -> None: batch_columns=["plate"], ) ) + context = _context(store, directions={"columnKinds": {"disease": "continuous"}}) + asyncio.run(inspect_cell_covariates(context)) joint = asyncio.run( analyze_experimental_design( context, @@ -1894,7 +1942,7 @@ def test_returned_decision_canonicalizes_caller_directions() -> None: def test_named_artifact_and_qc_source_validation_edges() -> None: - metric = NamedArtifactSource.get_example() + metric = example(NamedArtifactSource) identity = NamedArtifactSource( name="HTO_identity", artifact=ArtifactReferenceModel( @@ -2267,7 +2315,7 @@ def test_cell_qc_profile_requires_exact_capture_failure_inventory() -> None: def test_experimental_handoff_validation_edges() -> None: - result = ExperimentalContextResult.get_example() + result = example(ExperimentalContextResult) without_selection = result.model_copy(update={"cellSelection": None}) with pytest.raises(ValueError, match="lacks a cell selection"): without_selection.to_parameter_tuning_handoff() @@ -2421,7 +2469,7 @@ def test_experimental_context_private_input_guards( experimental_context_qc._active_cell_count(deps) -def test_qc_profile_degradation_and_selection_guards( +def test_core_qc_reports_unavailable_default_bounds( monkeypatch: pytest.MonkeyPatch, ) -> None: store = _Store() @@ -2440,7 +2488,7 @@ def test_qc_profile_degradation_and_selection_guards( notes, ) assert profile is None - assert notes == ["Ignored constant QC metric 'constant'"] + assert "constant metric 'constant'" in notes[0] monkeypatch.setattr( experimental_context_qc, @@ -2461,87 +2509,6 @@ def test_qc_profile_degradation_and_selection_guards( assert profile is None assert "non-finite Gaussian bounds" in notes[0] - skip = CellQcProfileEvidence( - profileId="skip", - action="skip", - activeCells=store.cells.N, - retainedCells=store.cells.N, - retainedFraction=1.0, - evidenceId="qcProfile:skip", - ) - global_profile = CellQcProfileEvidence( - profileId="global", - action="globalGaussian", - driverAssay="RNA", - driverAssayType="RNA", - attributes=["RNA_nCounts"], - activeCells=store.cells.N, - retainedCells=store.cells.N - 1, - retainedFraction=(store.cells.N - 1) / store.cells.N, - evidenceId="qcProfile:global", - ) - deps.qcProfiles = {"skip": skip, "global": global_profile} - characterization = CovariateCharacterization(status="done") - - deps.directions = {"cellQc": {"profileId": 3}} - with pytest.raises(ModelRetry, match="profileId direction must be a string"): - experimental_context_validation._canonical_cell_qc_plan( - CellQcPlan(), deps, characterization - ) - deps.directions = { - "cellQc": {"sampleColumn": "sample", "sampleArtifactName": "identity"} - } - with pytest.raises(ModelRetry, match="cannot select both"): - experimental_context_validation._canonical_cell_qc_plan( - CellQcPlan(), deps, characterization - ) - deps.directions = {"cellQc": {"sampleArtifactName": 3}} - with pytest.raises(ModelRetry, match="sampleArtifactName must be a string"): - experimental_context_validation._canonical_cell_qc_plan( - CellQcPlan(), deps, characterization - ) - deps.directions = {"cellQc": {"action": "unknown"}} - with pytest.raises(ModelRetry, match="Unsupported cellQc.action"): - experimental_context_validation._canonical_cell_qc_plan( - CellQcPlan(), deps, characterization - ) - deps.directions = {"cellQc": {"action": "sampleMad"}} - with pytest.raises(ModelRetry, match="exactly one offered profile"): - experimental_context_validation._canonical_cell_qc_plan( - CellQcPlan(), deps, characterization - ) - deps.directions = {"cellQc": {"profileId": "unknown"}} - with pytest.raises(ModelRetry, match="was not offered"): - experimental_context_validation._canonical_cell_qc_plan( - CellQcPlan(), deps, characterization - ) - - deps.directions = {} - selected = experimental_context_validation._canonical_cell_qc_plan( - CellQcPlan(), deps, characterization - ) - assert selected.profileId == "global" - - mismatched = CellQcPlan( - action="globalGaussian", - profileId="global", - driverAssay="RNA", - driverAssayType="RNA", - attributes=["different_metric"], - evidenceIds=["qcProfile:global"], - ) - with pytest.raises(ModelRetry, match="copy the selected offered profile"): - experimental_context_validation._canonical_cell_qc_plan( - mismatched, deps, characterization - ) - missing_evidence = mismatched.model_copy( - update={"attributes": ["RNA_nCounts"], "evidenceIds": []} - ) - with pytest.raises(ModelRetry, match="cite its exact profile"): - experimental_context_validation._canonical_cell_qc_plan( - missing_evidence, deps, characterization - ) - def test_design_analysis_rejects_invalid_batch_proposals( monkeypatch: pytest.MonkeyPatch, @@ -2840,7 +2807,7 @@ def changed_plan(**updates: Any) -> ExperimentalContextDecision: "kind": "continuous", }, } - with pytest.raises(ModelRetry, match="must be categorical"): + with pytest.raises(ModelRetry, match="Batch correction is unsafe"): validate( changed_plan( action="evaluateHarmony", diff --git a/tests/test_agent_hvg_diagnostics.py b/tests/test_agent_hvg_diagnostics.py index a00d57cf..4d1b5293 100644 --- a/tests/test_agent_hvg_diagnostics.py +++ b/tests/test_agent_hvg_diagnostics.py @@ -5,9 +5,7 @@ HvgGroupVariability, aggregate_hvg_rankings, effective_hvg_candidate_counts, - run_hvg_diagnostic_artifacts, ) -from scarf.storage.artifacts import inspect_artifact def test_hvg_candidate_counts_are_capped_and_unique() -> None: @@ -50,93 +48,3 @@ def test_batch_aware_hvg_ranking_keeps_nested_registered_candidates() -> None: assert np.all(~narrow | broad) assert int(narrow.sum()) == 2 assert int(broad.sum()) == 3 - - -def test_hvg_diagnostic_artifacts_reuse_exact_pooled_candidates( - datastore_ephemeral: object, -) -> None: - store = datastore_ephemeral - cell_selection = store.snapshot_cell_selection("I") - all_features = store.select_all_features(from_assay="RNA") - - first = run_hvg_diagnostic_artifacts( - store.zw, - store.RNA, - cell_selection=cell_selection, - eligible_features=all_features, - all_features=all_features, - technical_group_column=None, - min_group_cells=2, - min_cells=0, - n_bins=20, - lowess_frac=0.2, - invalidate_cache=False, - candidate_targets=(3, 5), - ) - second = run_hvg_diagnostic_artifacts( - store.zw, - store.RNA, - cell_selection=cell_selection, - eligible_features=all_features, - all_features=all_features, - technical_group_column=None, - min_group_cells=2, - min_cells=0, - n_bins=20, - lowess_frac=0.2, - invalidate_cache=False, - candidate_targets=(3, 5), - ) - - assert first == second - assert len(first) == 1 - global_ranking = first[0] - assert global_ranking.ranking_mode == "global" - assert [candidate.top_n for candidate in global_ranking.candidates] == [3, 5] - assert inspect_artifact(store.zw, global_ranking.diagnostic).operation == ( - "diagnose_hvg_candidates" - ) - masks = [ - np.asarray(store.load_artifact(candidate.features)["values"][:], dtype=bool) - for candidate in global_ranking.candidates - ] - assert int(masks[0].sum()) == 3 - assert int(masks[1].sum()) == 5 - assert np.all(~masks[0] | masks[1]) - - -def test_hvg_diagnostics_persist_global_and_batch_aware_rankings( - datastore_ephemeral: object, -) -> None: - store = datastore_ephemeral - midpoint = store.cells.N // 2 - store.cells.insert( - "technical_batch", - np.asarray(["a"] * midpoint + ["b"] * (store.cells.N - midpoint)), - overwrite=True, - ) - rankings = run_hvg_diagnostic_artifacts( - store.zw, - store.RNA, - cell_selection=store.snapshot_cell_selection("I"), - eligible_features=store.select_all_features(from_assay="RNA"), - all_features=store.select_all_features(from_assay="RNA"), - technical_group_column="technical_batch", - min_group_cells=2, - min_cells=0, - n_bins=20, - lowess_frac=0.2, - invalidate_cache=False, - candidate_targets=(3, 5), - ) - - assert [ranking.ranking_mode for ranking in rankings] == [ - "global", - "batchAware", - ] - assert rankings[0].diagnostic != rankings[1].diagnostic - assert len(rankings[1].valid_groups) == 2 - for ranking in rankings: - group = store.load_artifact(ranking.diagnostic) - assert group.attrs["ranking_mode"] == ranking.ranking_mode - assert [candidate.top_n for candidate in ranking.candidates] == [3, 5] diff --git a/tests/test_agent_ingest.py b/tests/test_agent_ingest.py index f8994180..e0589c08 100644 --- a/tests/test_agent_ingest.py +++ b/tests/test_agent_ingest.py @@ -8,8 +8,7 @@ import zarr from scipy.sparse import csr_matrix -from scarf.agent import detect_format, ingest -from scarf.agent.persistence import AgentWorkflowRun +from scarf.agent.ingest import detect_format, ingest from scarf.agent.types import Decision from scarf.readers import inspect_h5ad @@ -86,7 +85,6 @@ def _patch_ingest_summary( import importlib ingest_common = importlib.import_module("scarf.agent.ingest.common") - persistence = importlib.import_module("scarf.agent.persistence.reports") def summarize( _zarr_path: str, @@ -101,16 +99,6 @@ def summarize( ) monkeypatch.setattr(ingest_common, "open_summary", summarize) - monkeypatch.setattr( - persistence, - "create_agent_workflow", - lambda _path: AgentWorkflowRun( - workflowRunId="workflow-1", - createdAtNs=1, - analysisStore=str(_path), - datasetFingerprints={assay_name: "fingerprint-1"}, - ), - ) def test_detect_format_by_suffix(tmp_path: Path) -> None: @@ -174,24 +162,15 @@ def test_ingest_h5ad_prefers_raw_integer_matrix(tmp_path: Path) -> None: assert result.zarrPath is not None assert "RNA" in result.assayNames assert result.summary is not None - assert result.workflowRun is not None assert result.acceptedActions - assert result.acceptedActions[-2]["op"] == "DataStore" - assert result.acceptedActions[-1]["op"] == "createAgentWorkflow" + assert result.acceptedActions[-1]["op"] == "DataStore" root = zarr.open_group(result.zarrPath, mode="r") - assert result.workflowRun.datasetFingerprints == { - assay_name: str(root[assay_name].attrs["dataset_fingerprint"]) + assert all( + root[assay_name].attrs["dataset_fingerprint"] for assay_name in result.assayNames - } - assert isinstance(root["agents"], zarr.Group) - assert root["agents"].attrs["format"] == "scarf_agent_reports" - assert ( - Path(result.zarrPath) - / "agents" - / "runs" - / result.workflowRun.workflowRunId - / "workflow.json" - ).is_file() + ) + assert "agents" not in root + assert "workflowRun" not in result.model_dump() def test_ingest_h5ad_stops_on_prenormalized_only(tmp_path: Path) -> None: @@ -358,10 +337,8 @@ def test_ingest_10x_h5(tmp_path: Path) -> None: assert result.status == "done", result.notes assert result.format == "10x_h5" assert "RNA" in result.assayNames - assert result.workflowRun is not None assert result.acceptedActions - assert result.acceptedActions[-2]["op"] == "DataStore" - assert result.acceptedActions[-1]["op"] == "createAgentWorkflow" + assert result.acceptedActions[-1]["op"] == "DataStore" def _file_snapshot(location: Path) -> dict[str, tuple[int, int]]: @@ -438,29 +415,21 @@ def test_ingest_overwrite_true_replaces_destination(tmp_path: Path) -> None: assert not sentinel.exists() -def test_ingest_derives_destination_and_creates_workflow(tmp_path: Path) -> None: +def test_ingest_derives_destination_without_creating_workflow(tmp_path: Path) -> None: path = tmp_path / "counts.h5ad" _write_h5ad(path, np.array([[1, 0], [0, 2]], dtype=np.uint16)) result = ingest(path=path) assert result.status == "done" assert result.format == "h5ad" assert result.zarrPath == str(tmp_path / "counts.zarr") - assert result.workflowRun is not None assert any("Using derived Zarr destination" in note for note in result.notes) root = zarr.open_group(result.zarrPath, mode="r") - assert result.workflowRun.datasetFingerprints == { - assay_name: str(root[assay_name].attrs["dataset_fingerprint"]) + assert all( + root[assay_name].attrs["dataset_fingerprint"] for assay_name in result.assayNames - } - assert isinstance(root["agents"], zarr.Group) - assert root["agents"].attrs["format"] == "scarf_agent_reports" - assert ( - Path(result.zarrPath) - / "agents" - / "runs" - / result.workflowRun.workflowRunId - / "workflow.json" - ).is_file() + ) + assert "agents" not in root + assert "workflowRun" not in result.model_dump() def test_ingest_overlapping_source_destination_fails(tmp_path: Path) -> None: @@ -581,8 +550,7 @@ def make_writer(reader: FakeReader, *, zarr_loc: str) -> FakeWriter: assert created_writers[0].dump_calls == 1 assert result.acceptedActions[0]["op"] == "MtxToZarr" assert result.acceptedActions[0]["mtxIndex"] == 1 - assert result.acceptedActions[-2]["op"] == "DataStore" - assert result.acceptedActions[-1]["op"] == "createAgentWorkflow" + assert result.acceptedActions[-1]["op"] == "DataStore" assert destination.is_dir() @@ -764,8 +732,7 @@ def make_writer(reader: FakeReader, *, zarr_loc: str) -> FakeWriter: assert created_writers[0].reader is created_readers[0] assert created_writers[0].dump_calls == 1 assert result.acceptedActions[0]["op"] == "SeuratToZarr" - assert result.acceptedActions[-2]["op"] == "DataStore" - assert result.acceptedActions[-1]["op"] == "createAgentWorkflow" + assert result.acceptedActions[-1]["op"] == "DataStore" assert destination.is_dir() @@ -913,9 +880,8 @@ def make_writer( assert created_writers[0].assay_name == "ADT" assert created_writers[0].dump_calls == 1 assert result.acceptedActions[0]["op"] == "LoomToZarr" - assert result.acceptedActions[-2]["op"] == "DataStore" - assert result.acceptedActions[-2]["defaultAssay"] == "ADT" - assert result.acceptedActions[-1]["op"] == "createAgentWorkflow" + assert result.acceptedActions[-1]["op"] == "DataStore" + assert result.acceptedActions[-1]["defaultAssay"] == "ADT" assert destination.is_dir() @@ -1207,10 +1173,8 @@ def test_ingest_h5ad_still_initializes_qc(tmp_path: Path) -> None: dest = tmp_path / "out.zarr" result = ingest(path=path, zarrPath=dest) assert result.status == "done" - assert result.workflowRun is not None - assert result.acceptedActions[-2]["op"] == "DataStore" - assert result.acceptedActions[-2]["zarrMode"] == "r+" - assert result.acceptedActions[-1]["op"] == "createAgentWorkflow" + assert result.acceptedActions[-1]["op"] == "DataStore" + assert result.acceptedActions[-1]["zarrMode"] == "r+" from scarf.datastore.datastore import DataStore store = DataStore(str(dest), default_assay="RNA", nthreads=1) diff --git a/tests/test_agent_ingest_manifest.py b/tests/test_agent_ingest_manifest.py index e9831dd5..f5dd370b 100644 --- a/tests/test_agent_ingest_manifest.py +++ b/tests/test_agent_ingest_manifest.py @@ -7,7 +7,7 @@ import numpy as np from scipy.sparse import csr_matrix -from scarf.agent import ingest +from scarf.agent.ingest import ingest from scarf.agent.ingest.manifest import inspect_h5ad_manifest diff --git a/tests/test_agent_orchestrator.py b/tests/test_agent_orchestrator.py index bd7a9b33..b358634c 100644 --- a/tests/test_agent_orchestrator.py +++ b/tests/test_agent_orchestrator.py @@ -1,5 +1,7 @@ """Public facade, model, and end-to-end orchestrator contracts.""" +from tests.agent_examples import example + import json from pathlib import Path from typing import Any @@ -16,40 +18,37 @@ import scarf.agent as agent_module import scarf.agent.orchestrator as orchestrator_module -from scarf.agent.biological_interpretation import ( - BiologicalInterpretationReport, - ClusterCompositionEvidence, - ClusterInterpretation, - ClusterMarkerBatchEvidence, -) from scarf.agent.data_enrichment import ( DataEnrichmentReport, FeatureSelectionPolicy, StudyContextSummary, ) from scarf.agent.decisions.kernel import DecisionSelection -from scarf.agent.persistence.decisions import ( - load_latest_decision_workflow_snapshot, -) from scarf.agent.experimental_context import ( BatchCorrectionPlan, CellQcPlan, CovariateEvidence, ExperimentalContextDecision, ) -from scarf.agent.experimental_context.contracts import ContrastPlan from scarf.agent.parameter_tuning import ParameterTuningReport -from scarf.agent.persistence import load_agent_record +from scarf.agent.parameter_tuning.contracts import ParameterCandidateEvaluation +from scarf.agent.orchestrator.journal import ( + analysis_snapshot, + load_checkpoint, + _ensure_orchestration_store, +) +from scarf.agent.orchestrator.rna_tuning import TuningAction, _DOMAINS from scarf.agent.orchestrator import ( AgentOrchestrator, - AssayPreprocessingPlan, - AutomatedPreprocessingPlan, AutomatedWorkflowConfig, AutomatedWorkflowRequest, - AutomatedWorkflowResult, AutomatedWorkflowResumeRequest, +) +from scarf.agent.orchestrator.models import ( + AssayPreprocessingPlan, + AutomatedPreprocessingPlan, + AutomatedWorkflowResult, FinalAnalysisHandoff, - NativeAnalysisHandoff, PreprocessedAssayHandoff, WorkflowNeedsInput, WorkflowQuestion, @@ -59,14 +58,15 @@ ) from scarf.datastore.datastore import DataStore from scarf.storage.refs import ArtifactRef +from scarf.storage.selections import read_stored_selection_indices from tests.test_agent_ingest import _write_h5ad _PLAN_CHECKSUM = "a" * 64 -def _rna_workflow_model() -> tuple[FunctionModel, dict[str, int]]: - state = { +def _rna_workflow_model() -> tuple[FunctionModel, dict[str, Any]]: + state: dict[str, Any] = { "enrichment": 0, "context": 0, "parameter": 0, @@ -209,107 +209,62 @@ async def reply( ] ) - if ( - tools.intersection( - {"inspect_cluster_composition", "inspect_cluster_markers_batch"} + prompt = prompt_text(messages) + payload, _ = json.JSONDecoder().raw_decode(prompt[prompt.index("{") :]) + if any( + {"selectedCandidateId", "correctionNeed", "assessedDomains"}.issubset( + tool.parameters_json_schema.get("properties", {}) ) - or state["biology"] + for tool in info.output_tools ): - if state["biology"] == 0: - state["biology"] = 1 - return ModelResponse( - parts=[ - ToolCallPart( - tool_name="inspect_cluster_composition", - args={}, - ) - ] - ) - if state["biology"] == 1: - composition = tool_result( - messages, - "inspect_cluster_composition", - ClusterCompositionEvidence, - ) - state["biology"] = 2 - return ModelResponse( - parts=[ - ToolCallPart( - tool_name="inspect_cluster_markers_batch", - args={"cluster_ids": list(composition.clusterCounts)}, - ) - ] - ) - batch = tool_result( - messages, - "inspect_cluster_markers_batch", - ClusterMarkerBatchEvidence, - ) - interpretations = [] - for cluster in batch.clusters: - if cluster.evidenceId and cluster.markers: - marker = cluster.markers[0] - marker_name = marker.featureName or marker.featureId - interpretations.append( - ClusterInterpretation( - clusterId=cluster.clusterId, - proposedIdentity=f"{marker_name}-high RNA state", - identityIsHypothesis=True, - confidence="low", - rationale=( - f"The observed marker panel is led by {marker_name}." - ), - evidenceIds=[cluster.evidenceId], - ) - ) - state["biology"] = 3 - report = BiologicalInterpretationReport( - status="done", - clusterInterpretations=interpretations, - evidenceIds=[item.evidenceIds[0] for item in interpretations], - limitations=["Synthetic data supports marker-linked hypotheses only."], - stopReason=( - "Every cluster with returned marker evidence was reviewed." + state["pca_prompts"] += 1 + selected = next( + ( + row["candidateId"] + for row in payload["candidates"] + if row["parameters"]["leidenResolution"] == 0.5 ), + payload["currentCandidateId"], ) - return ModelResponse( - parts=[ - ToolCallPart( - tool_name=info.output_tools[0].name, - args=report.model_dump(), - ) - ] + action = TuningAction( + action="defer" if state["pca_pauses"] == 0 else "accept", + selectedCandidateId=selected, + correctionNeed="notApplicable", + assessedDomains=sorted(_DOMAINS), + evidenceIds=[f"candidate:{selected}", *payload["imageHashes"]], + quantitativeFindings=[ + "The measured stability and marker evidence supports the observed population partition." + ], + qualitativeFindings=[ + "The diagnostic board shows the distinct CD3D and MS4A1 marker programs." + ], + objectivePreservation="Retain marker-supported populations and every QC-retained cell.", + rationale="Review the supplied evidence before continuing." + if state["pca_pauses"] == 0 + else "Full-cell quantitative and visual evidence supports the selected partition.", ) - - prompt = prompt_text(messages) - if any( - tool.parameters_json_schema.get("title") == "AnalysisVisualAdjudication" - for tool in info.output_tools - ): - payload, _ = json.JSONDecoder().raw_decode(prompt[prompt.index("{") :]) + state["pca_pauses"] += 1 + state["answer"] = action.model_copy( + update={ + "action": "accept", + "rationale": "Accept the observed screening evidence and validate these settings on the full cohort.", + } + ).model_dump(mode="json") return ModelResponse( parts=[ ToolCallPart( - tool_name=info.output_tools[0].name, - args={ - "status": "acceptable", - "selectedCandidateId": payload["selectedCandidateId"], - "rationale": ( - "The bounded diagnostic board agrees with the " - "registered numeric evidence." - ), - }, + tool_name=info.output_tools[0].name, args=action.model_dump() ) ] ) payload, _ = json.JSONDecoder().raw_decode(prompt[prompt.index("{") :]) - decision = payload + decision = payload["spec"] decision_id = decision["decisionId"] if decision_id == "pcaPrefix": state["pca_prompts"] += 1 evidence_by_class: dict[str, str] = {} evidence_class_by_id: dict[str, str] = {} - for item in payload["evidence"]: + for item in payload["evidence"]["evidence"]: evidence_by_class.setdefault( item["evidenceClass"], item["evidenceId"], @@ -370,7 +325,6 @@ def test_public_orchestrator_models_have_factories_and_camelcase_fields() -> Non AutomatedWorkflowResult, AutomatedWorkflowResumeRequest, FinalAnalysisHandoff, - NativeAnalysisHandoff, PreprocessedAssayHandoff, StudyContextSummary, CellQcPlan, @@ -382,7 +336,7 @@ def test_public_orchestrator_models_have_factories_and_camelcase_fields() -> Non for model in models: assert isinstance(model.get_blank(), model) - assert isinstance(model.get_example(), model) + assert isinstance(example(model), model) assert all("_" not in field_name for field_name in model.model_fields) for model in ( AutomatedPreprocessingPlan, @@ -391,30 +345,18 @@ def test_public_orchestrator_models_have_factories_and_camelcase_fields() -> Non ): assert "cellSelection" in model.model_fields assert "cellKey" not in model.model_fields - for model in (NativeAnalysisHandoff, FinalAnalysisHandoff): + for model in (FinalAnalysisHandoff,): assert "clusterColumn" not in model.model_fields assert "umapColumns" not in model.model_fields def test_orchestrator_package_preserves_the_public_facade() -> None: - assert agent_module.AgentOrchestrator is orchestrator_module.AgentOrchestrator + assert not hasattr(agent_module, "AgentOrchestrator") assert orchestrator_module.__all__ == [ "AgentOrchestrator", - "analyze_rna", - "AssayPreprocessingPlan", - "AutomatedPreprocessingPlan", "AutomatedWorkflowConfig", "AutomatedWorkflowRequest", - "AutomatedWorkflowResult", "AutomatedWorkflowResumeRequest", - "FinalAnalysisHandoff", - "NativeAnalysisHandoff", - "PreprocessedAssayHandoff", - "WorkflowNeedsInput", - "WorkflowQuestion", - "WorkflowStageAttempt", - "WorkflowStageLink", - "artifact_model_to_ref", ] @@ -423,10 +365,10 @@ def test_rna_h5ad_completes_public_automated_workflow( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - from scarf.agent.orchestrator import tuning as tuning_module + from scarf.agent.orchestrator import rna_tuning as tuning_module rng = np.random.default_rng(4444) - values = rng.poisson(1.0, size=(80, 50)).astype(np.uint16) + values = rng.poisson(0.2, size=(80, 50)).astype(np.uint16) values[:40, :12] += rng.poisson(9.0, size=(40, 12)).astype(np.uint16) values[40:, 12:24] += rng.poisson(9.0, size=(40, 12)).astype(np.uint16) feature_names = [ @@ -465,8 +407,6 @@ def test_rna_h5ad_completes_public_automated_workflow( feature_names=feature_names, ) model, state = _rna_workflow_model() - phase_calls: list[str] = [] - execute_parameter_phase = tuning_module.execute_parameter_phase pca_diagnostic_calls: list[ArtifactRef] = [] augment_pca = tuning_module.augment_pca_evaluations @@ -474,28 +414,10 @@ def track_pca_diagnostics(*args: Any, **kwargs: Any) -> Any: pca_diagnostic_calls.append(kwargs["feature_selection"]) return augment_pca(*args, **kwargs) - def track_parameter_phase(*args: Any, **kwargs: Any) -> Any: - phase_calls.append(kwargs["plan"].phase) - return execute_parameter_phase(*args, **kwargs) - - monkeypatch.setattr( - tuning_module, - "execute_parameter_phase", - track_parameter_phase, - ) monkeypatch.setattr(tuning_module, "augment_pca_evaluations", track_pca_diagnostics) orchestrator = AgentOrchestrator( model, - config=AutomatedWorkflowConfig( - hvgCandidateCounts=(1000,), - pcaCandidateDimensions=(10,), - graphNeighborCandidates=(11,), - leidenResolutionCandidates=(0.75,), - maxRefinedCandidatesPerAssay=0, - maxHarmonyCandidatesPerAssay=0, - maxCandidateEvaluations=14, - minClusterCells=2, - ), + config=AutomatedWorkflowConfig(screeningCells=60, maxScreeningCells=70), ) request = AutomatedWorkflowRequest( @@ -509,49 +431,20 @@ def track_parameter_phase(*args: Any, **kwargs: Any) -> Any: markerAssay="RNA", analysisAssays=["RNA"], ) - finalize_stage = orchestrator.analysis_finalization_stage - - def finalize_with_unresolved_contrast(*args: Any, **kwargs: Any) -> Any: - kwargs["experimental"] = kwargs["experimental"].model_copy( - update={ - "contrastPlans": [ - ContrastPlan.get_blank().model_copy( - update={"coefficient": "condition", "status": "needsInput"} - ) - ] - } - ) - return finalize_stage(*args, **kwargs) - - monkeypatch.setattr( - orchestrator, "analysis_finalization_stage", finalize_with_unresolved_contrast - ) paused = orchestrator.run(request) assert paused.status == "needsInput" assert paused.currentStage == "parameter_tuning" - assert paused.workflowRun is not None + assert paused.workflowRunId is not None assert paused.needsInput is not None assert len(paused.needsInput.questions) == 1 question = paused.needsInput.questions[0] - assert question.questionId == "decision:pcaPrefix" - assert question.decisionId == "pcaPrefix" - assert question.options - selected_option = next( - option_id for option_id in question.options if option_id != "pcaPrefix:defer" - ) - pca_calls_before_resume = phase_calls.count("pcaPrefix") + assert question.questionId == "parameter_tuning" pca_diagnostics_before_resume = list(pca_diagnostic_calls) resume_request = AutomatedWorkflowResumeRequest( zarrPath=str(target), - workflowRunId=paused.workflowRun.workflowRunId, - answers={ - question.questionId: { - "decisionId": question.decisionId, - "optionId": selected_option, - "rationale": "Use the completed registered PCA evidence.", - } - }, + workflowRunId=paused.workflowRunId, + answers={"parameter_tuning": state["answer"]}, ) editable = DataStore( str(target), @@ -564,68 +457,39 @@ def finalize_with_unresolved_contrast(*args: Any, **kwargs: Any) -> Any: changed_counts = original_counts.copy() changed_counts[0] += 1 editable.cells.insert("RNA_nCounts", changed_counts, overwrite=True) - with pytest.raises(ValueError, match="Tuning metadata changed"): - orchestrator.resume(resume_request) - assert phase_calls.count("pcaPrefix") == pca_calls_before_resume + rejected = orchestrator.resume(resume_request) + assert rejected.status == "failed" + assert any("metadata" in note for note in rejected.notes) + assert pca_diagnostic_calls == pca_diagnostics_before_resume editable.cells.insert("RNA_nCounts", original_counts, overwrite=True) + execute_candidate = tuning_module.RnaTuningRun.execute + + def interrupt_before_full(self: Any, scope: str, *args: Any, **kwargs: Any) -> Any: + if scope == "full": + monkeypatch.setattr( + tuning_module.RnaTuningRun, "execute", execute_candidate + ) + raise KeyboardInterrupt( + "interrupt after the screening answer was committed" + ) + return execute_candidate(self, scope, *args, **kwargs) + + monkeypatch.setattr(tuning_module.RnaTuningRun, "execute", interrupt_before_full) + with pytest.raises(KeyboardInterrupt, match="screening answer"): + orchestrator.resume(resume_request) + assert pca_diagnostic_calls == pca_diagnostics_before_resume result = orchestrator.resume(resume_request) assert result.status == "completed", result.notes - assert phase_calls.count("pcaPrefix") == pca_calls_before_resume - assert pca_diagnostic_calls == pca_diagnostics_before_resume - assert state["pca_prompts"] == 1 + assert len(pca_diagnostic_calls) == len(pca_diagnostics_before_resume) + 1 + assert state["pca_prompts"] == 2 assert result.currentStage == "analysis_finalization" - assert result.workflowRun is not None - assert result.workflowRun.status == "completed" - report_path = ( - target - / "agents" - / "runs" - / result.workflowRun.workflowRunId - / "report" - / "index.html" - ) + assert result.workflowRunId is not None + report_path = result.report() assert report_path.is_file() - assert "Nygen Analytics" in report_path.read_text(encoding="utf-8") + assert "Scarf analysis summary" in report_path.read_text(encoding="utf-8") assert state["requests"] >= 8 assert state["biology"] == 0 - assert [reference.agentName for reference in result.reportReferences] == [ - "data_enrichment", - "experimental_context", - "parameter_tuning", - "parameter_tuning", - ] - assert result.finalAnalysis is not None - assert result.preprocessingPlan is not None - assert result.preprocessingPlan.cellQualityPayload is not None - assert ( - result.preprocessingPlan.cellQualityPayload.profile - == result.preprocessingPlan.cellQc.registeredProfile - ) - final = result.finalAnalysis - assert result.finalHandoffId == final.handoffId - assert result.decisionRunId == result.workflowRun.workflowRunId - assert result.verificationSummary - assert "pipelineRunId" not in result.model_dump() - assert "pipelineRunId" not in final.model_dump() - assert final.graphMethod == "native" - assert final.primaryAssay == final.markerAssay == "RNA" - assert final.graph is not None - assert final.clusters is not None - assert final.cellSelection is not None - assert final.embeddingInitialization is not None - assert final.umap is not None - assert final.markers is not None - assert final.statisticalTests == [] - assert final.analysisEvidence["contrastPlans"][0]["status"] == "needsInput" - assert final.analysisEvidence["contrastPlans"][0]["coefficient"] == "condition" - assert "hypothesisTests" not in final.analysisEvidence - assert len(final.doubletScores) == 1 - assert final.cellSelection.kind == "cell_selection" - assert final.clusters.kind == "cluster_labels" - assert final.embeddingInitialization.kind == "embedding_initialization" - assert final.umap.kind == "embedding" - assert final.cellSelection != result.preprocessingPlan.cellSelection persisted = DataStore( str(target), default_assay="RNA", @@ -634,14 +498,23 @@ def finalize_with_unresolved_contrast(*args: Any, **kwargs: Any) -> Any: ribo_pattern="", zarr_mode="r", ) - tuning_evaluations = [ - evaluation - for reference in result.reportReferences - if reference.agentName == "parameter_tuning" - for evaluation in ParameterTuningReport.model_validate( - load_agent_record(persisted, reference).report - ).evaluations - ] + snapshot = analysis_snapshot(persisted, result.workflowRunId) + stages = {stage["stage"]: stage for stage in snapshot["stages"]} + preprocessing_plan = AutomatedPreprocessingPlan.model_validate( + stages["preprocessing_plan"]["outputs"]["preprocessingPlan"] + ) + assert preprocessing_plan.cellQualityPayload is not None + final = FinalAnalysisHandoff.model_validate(snapshot["finalAnalysis"]) + assert final.primaryAssay == final.markerAssay == "RNA" + assert final.graph is not None and final.clusters is not None + assert final.cellSelection is not None and final.umap is not None + assert final.embeddingInitialization is not None and final.markers is not None + assert len(final.doubletScores) == 1 + assert final.cellSelection != preprocessing_plan.cellSelection + parameter_report = ParameterTuningReport.model_validate( + stages["parameter_tuning"]["report"] + ) + tuning_evaluations = parameter_report.evaluations pca_evidence = next( evaluation for evaluation in tuning_evaluations @@ -685,31 +558,77 @@ def finalize_with_unresolved_contrast(*args: Any, **kwargs: Any) -> Any: scored_partition = ArtifactRef.from_dict(doublet_inputs["clusters"]) assert scored_partition.kind == "cluster_labels" assert persisted.inspect_artifact(scored_partition).complete - decision_snapshot = load_latest_decision_workflow_snapshot( + selected_evaluation = tuning_evaluations[0] + assert ( + persisted.inspect_artifact( + artifact_model_to_ref(selected_evaluation.artifacts["normalized"]) + ).inputs["cell_selection"] + == artifact_model_to_ref(final.cellSelection).to_dict() + ) + assert len(tuning_evaluations) == 1 + snapshot = analysis_snapshot(persisted, result.workflowRunId) + evidence = next( + stage for stage in snapshot["stages"] if stage["stage"] == "parameter_tuning" + )["outputs"]["tuningEvidence"] + assert evidence["budget"]["scopes"]["sample0"]["reserved"]["partitions"] == 4 + assert evidence["budget"]["scopes"]["full"]["reserved"]["graphs"] == 1 + assert evidence["budget"]["scopes"]["full"]["reserved"]["partitions"] == 1 + sample_record = load_checkpoint( persisted, - result.workflowRun.workflowRunId, + _ensure_orchestration_store(persisted), + result.workflowRunId, + "parameter_tuning/sample0/evaluation0/complete", + inputs=None, ) - assert decision_snapshot.workflow.status == "completed" - assert [ - record.decisionId - for record in decision_snapshot.workflow.active_decision_records() - ] == [ - "qcGrouping", - "cellQuality", - "featurePolicy", - "hvgRanking", - "hvgCount", - "pcaPrefix", - "correctionLicense", - "correctionOutcome", - "graphK", - "clusterPartition", - ] - assert decision_snapshot.workflow.finalHandoffId == final.handoffId - pca_record = next( - record - for record in decision_snapshot.workflow.active_decision_records() - if record.decisionId == "pcaPrefix" + assert sample_record is not None + sample_evaluation = ParameterCandidateEvaluation.model_validate( + sample_record["evaluation"] ) - assert pca_record.source == "human" + assert ( + sample_evaluation.parameters.leidenResolution + == selected_evaluation.parameters.leidenResolution + == 0.5 + ) + assert sample_evaluation.cellSelection != final.cellSelection + + def selected_rows(reference: Any) -> np.ndarray: + return read_stored_selection_indices( + persisted.zw, + artifact_model_to_ref(reference), + kind="cell_selection", + scope="datastore", + assay=None, + table_path="cellData", + ) + + sample_rows = selected_rows(sample_evaluation.cellSelection) + full_rows = selected_rows(final.cellSelection) + sample_labels = np.asarray( + persisted.load_artifact( + artifact_model_to_ref(sample_evaluation.artifacts["clusters"]) + )["values"][:] + ) + full_labels = np.asarray( + persisted.load_artifact(artifact_model_to_ref(final.clusters))["values"][:] + ) + from sklearn.metrics import adjusted_rand_score + + assert adjusted_rand_score( + sample_labels, full_labels[np.searchsorted(full_rows, sample_rows)] + ) == pytest.approx(1.0) + markers = result.get_markers(min_score=0.0, min_frac_exp=0.0) + assert {"CD3D", "MS4A1"}.issubset(set(markers.feature_name)) + plotted = result.plot_embedding(show=False) + assert plotted.figure is not None + plotted.close() + requests_before = state["requests"] + diagnostics_before = list(pca_diagnostic_calls) + completed = orchestrator.run(request) + assert completed.status == "completed" + assert completed.workflowRunId == result.workflowRunId + assert analysis_snapshot(persisted, result.workflowRunId)[ + "finalAnalysis" + ] == final.model_dump(mode="json") + assert state["requests"] == requests_before + assert pca_diagnostic_calls == diagnostics_before assert "pipeline" not in persisted.zw diff --git a/tests/test_agent_orchestrator_journal_edges.py b/tests/test_agent_orchestrator_journal_edges.py index 7e5fe21a..4fb5e979 100644 --- a/tests/test_agent_orchestrator_journal_edges.py +++ b/tests/test_agent_orchestrator_journal_edges.py @@ -1,760 +1,386 @@ -"""Focused edge coverage for orchestration journal contracts.""" +"""Immutable evidence, integrity, and exact stage lineage contracts.""" +import json from types import SimpleNamespace -from typing import Any import pytest -import zarr -from pydantic import ValidationError -from zarr.storage import MemoryStore +from zarr.core.buffer import default_buffer_prototype +from zarr.core.sync import sync -import scarf.agent.orchestrator.journal as journal_module -from scarf.agent.orchestrator import ( - AutomatedWorkflowRequest, - AutomatedWorkflowResult, - AutomatedWorkflowResumeRequest, - FinalAnalysisHandoff, +from scarf.agent.orchestrator import journal +from scarf.agent.orchestrator.models import ( + OrchestrationResumeRecord, + StageEvidenceReference, WorkflowNeedsInput, WorkflowQuestion, WorkflowStageAttempt, - WorkflowStageLink, ) -from scarf.agent.orchestrator.models import ( - OrchestrationRequestRecord, - OrchestrationResumeRecord, -) -from scarf.agent.persistence import ( - AgentInvocation, - AgentReportReference, - AgentWorkflowRun, -) - - -def _started( - *, - stage: str = "ingest", - parents: list[WorkflowStageLink] | None = None, - inputs: dict[str, Any] | None = None, -) -> WorkflowStageAttempt: - attempt = WorkflowStageAttempt( - workflowRunId="workflow-1", - stage=stage, - attemptId="attempt-1", - status="started", - startedAtNs=1, - requestSha256="a" * 64, - configSha256="b" * 64, - parentAttempts=parents or [], - inputs=inputs or {}, - ) - return attempt.model_copy( - update={"contentSha256": journal_module._stage_checksum(attempt)} - ) - - -def _request_record() -> OrchestrationRequestRecord: - return OrchestrationRequestRecord( - workflowRunId="workflow-1", - requestSha256="a" * 64, - configSha256="b" * 64, - ) - - -def _with_checksum(record: Any) -> Any: - return record.model_copy( - update={"contentSha256": journal_module._record_checksum(record)} - ) - - -def _terminal_workflow() -> AgentWorkflowRun: - return AgentWorkflowRun( - workflowRunId="workflow-1", - createdAtNs=1, - finalizedAtNs=2, - status="completed", - finalizationMessage="completed", - analysisStore="analysis.zarr", - datasetFingerprints={"RNA": "fingerprint"}, - ) - - -def _terminal_result(workflow: AgentWorkflowRun) -> AutomatedWorkflowResult: - final_analysis = FinalAnalysisHandoff( - workflowRunId=workflow.workflowRunId, - primaryAssay="RNA", - markerAssay="RNA", - ).with_handoff_id() - return _with_checksum( - AutomatedWorkflowResult( - status="completed", - currentStage="analysis_finalization", - zarrPath="analysis.zarr", - workflowRun=workflow, - reportReferences=list(workflow.reports), - finalAnalysis=final_analysis, - finalHandoffId=final_analysis.handoffId, - decisionRunId=workflow.workflowRunId, - ) - ) - - -def test_orchestration_model_validation_edges() -> None: - for field, value, message in ( - ("attemptId", "UPPER CASE", "lowercase run identifier"), - ("contentSha256", "not-a-checksum", "lowercase SHA-256"), - ): - with pytest.raises(ValidationError, match=message): - WorkflowStageLink(**{field: value}) - - invalid_attempts = ( - ({"status": "started", "completedAtNs": 1}, "cannot have completedAtNs"), - ( - {"status": "done", "startedAtNs": 2, "completedAtNs": 1}, - "must not precede", - ), - ({"status": "needsInput"}, "require questions"), - ({"status": "failed"}, "require an error"), - ) - for updates, message in invalid_attempts: - with pytest.raises(ValidationError, match=message): - WorkflowStageAttempt(**updates) - - invalid_requests = ( - ( - { - "sourcePath": " ", - "studyContext": "study", - "studyObjective": "objective", - }, - "sourcePath", - ), - ( - { - "sourcePath": "data", - "studyContext": " ", - "studyObjective": "objective", - }, - "studyContext", - ), - ( - {"sourcePath": "data", "studyContext": "study"}, - "studyObjective", - ), - ( - { - "sourcePath": "data", - "studyContext": "study", - "studyObjective": "objective", - "analysisAssays": ["RNA", "RNA"], - }, - "analysisAssays", - ), - ( - { - "sourcePath": "data", - "studyContext": "study", - "studyObjective": "objective", - "pairedAssays": ["RNA", "RNA"], - }, - "pairedAssays is unsupported", - ), - ( - { - "sourcePath": "data", - "studyContext": "study", - "studyObjective": "objective", - "pairedAssays": ["RNA"], - }, - "pairedAssays is unsupported", - ), - ) - for values, message in invalid_requests: - with pytest.raises(ValidationError, match=message): - AutomatedWorkflowRequest(**values) - - with pytest.raises(ValidationError, match="zarrPath"): - AutomatedWorkflowResumeRequest(zarrPath=" ", workflowRunId="workflow-1") - with pytest.raises(ValidationError, match="workflowRunId"): - AutomatedWorkflowResumeRequest(zarrPath="data.zarr", workflowRunId="BAD") - - assert OrchestrationRequestRecord.get_blank().workflowRunId == "" - assert OrchestrationRequestRecord.get_example().workflowRunId == "workflow-1" - assert OrchestrationResumeRecord.get_blank().resumeId == "" - assert OrchestrationResumeRecord.get_example().resumeId == "resume-1" - - -def test_journal_storage_guards(monkeypatch: pytest.MonkeyPatch) -> None: - root = zarr.open_group(store=MemoryStore(), mode="w") - journal_module._write_key_once(root, "record.json", b"first") - with pytest.raises(FileExistsError, match="exists"): - journal_module._write_key_once(root, "record.json", b"second") - - with monkeypatch.context() as patch: - observed = iter((None, b"different")) - patch.setattr( - journal_module.record_io, - "read_key", - lambda *_args: next(observed), - ) - with pytest.raises(FileExistsError, match="raced"): - journal_module._write_key_once(root, "raced.json", b"payload") - - unsupported = SimpleNamespace(store=SimpleNamespace(supports_listing=False)) - with pytest.raises(NotImplementedError, match="requires listing"): - journal_module._list_keys(unsupported, "prefix") +from scarf.agent.types import AgentDataModel +from tests.agent_journal_store import memory_journal - with monkeypatch.context() as patch: - patch.setattr(journal_module.record_io, "read_key", lambda *_args: b"{") - with pytest.raises(ValueError, match="Malformed orchestration record"): - journal_module._read_model(root, "bad.json", WorkflowQuestion) +class Evidence(AgentDataModel): + rationale: str + nCells: int -def test_final_handoff_journal_is_content_addressed_and_idempotent() -> None: - root = zarr.open_group(store=MemoryStore(), mode="w") - root.create_group("agents") - store = SimpleNamespace(zw=root) - prefix = journal_module._ensure_orchestration_store(store) - handoff = FinalAnalysisHandoff( - workflowRunId="workflow-1", - primaryAssay="RNA", - markerAssay="RNA", - ).with_handoff_id() - first = journal_module.save_final_analysis_handoff(store, prefix, handoff) - second = journal_module.save_final_analysis_handoff(store, prefix, handoff) - - assert first == second == handoff +def test_checkpoint_is_immutable_idempotent_and_input_bound() -> None: + store, prefix, record = memory_journal("analysis") + inputs = {"selection": "cells-1", "metadata": {"age": "continuous"}} + value = {"decision": "retain", "evidence": [1, 2]} + key = "parameter_tuning/sample0/eval0" assert ( - journal_module.load_final_analysis_handoff( - store, - prefix, - handoff.workflowRunId, - handoff.handoffId, - ) - == handoff - ) - - -def test_orchestration_namespace_validation(monkeypatch: pytest.MonkeyPatch) -> None: - root = zarr.open_group(store=MemoryStore(), mode="w") - with pytest.raises(RuntimeError, match="Create the agent workflow"): - journal_module._ensure_orchestration_store(SimpleNamespace(zw=root)) - - root = zarr.open_group(store=MemoryStore(), mode="w") - root.create_array("agents", shape=(1,), dtype="i1") - with pytest.raises(ValueError, match="agents namespace"): - journal_module._ensure_orchestration_store(SimpleNamespace(zw=root)) - - root = zarr.open_group(store=MemoryStore(), mode="w") - root.create_group("agents") - with monkeypatch.context() as patch: - patch.setattr(journal_module, "_list_keys", lambda *_args: ["occupied"]) - with pytest.raises(ValueError, match="non-Zarr object"): - journal_module._ensure_orchestration_store(SimpleNamespace(zw=root)) - - root = zarr.open_group(store=MemoryStore(), mode="w") - agents = root.create_group("agents") - agents.create_array("orchestrations", shape=(1,), dtype="i1") - with pytest.raises(ValueError, match="orchestrations namespace"): - journal_module._ensure_orchestration_store(SimpleNamespace(zw=root)) - - root = zarr.open_group(store=MemoryStore(), mode="w") - agents = root.create_group("agents") - agents.create_group( - "orchestrations", - attributes={"format": "foreign", "format_version": 99}, + journal.save_checkpoint(store, prefix, record.workflowRunId, key, inputs, value) + == value ) - with pytest.raises(ValueError, match="Unrecognized orchestration"): - journal_module._ensure_orchestration_store(SimpleNamespace(zw=root)) - - -def test_stage_record_identity_and_checksum_guards( - monkeypatch: pytest.MonkeyPatch, -) -> None: - outcome_key = "root/workflow-1/stages/ingest/attempt-1/outcome.json" - start_key = "root/workflow-1/stages/ingest/attempt-1/started.json" - done = journal_module._complete_attempt(_started(), status="done") - - with monkeypatch.context() as patch: - patch.setattr(journal_module, "_list_keys", lambda *_args: [outcome_key]) - patch.setattr( - journal_module, - "_read_model", - lambda *_args: done.model_copy(update={"attemptId": "other"}), - ) - with pytest.raises(ValueError, match="outcome identity"): - journal_module._stage_outcomes(object(), "root", "workflow-1", "ingest") - patch.setattr( - journal_module, - "_read_model", - lambda *_args: done.model_copy(update={"contentSha256": "0" * 64}), - ) - with pytest.raises(ValueError, match="outcome checksum"): - journal_module._stage_outcomes(object(), "root", "workflow-1", "ingest") - - started = _started() - with monkeypatch.context() as patch: - patch.setattr(journal_module, "_list_keys", lambda *_args: [start_key]) - patch.setattr( - journal_module, - "_read_model", - lambda *_args: started.model_copy(update={"status": "done"}), - ) - with pytest.raises(ValueError, match="start identity"): - journal_module._stage_starts(object(), "root", "workflow-1", "ingest") - patch.setattr( - journal_module, - "_read_model", - lambda *_args: started.model_copy(update={"contentSha256": "0" * 64}), - ) - with pytest.raises(ValueError, match="start checksum"): - journal_module._stage_starts(object(), "root", "workflow-1", "ingest") - - -def test_resume_answer_helper_edges() -> None: - assert not journal_module._has_resume_answer(None) - assert not journal_module._has_resume_answer(" ") - assert journal_module._has_resume_answer("answer") - assert not journal_module._has_resume_answer({}) - assert journal_module._has_resume_answer({"answer": 1}) - assert not journal_module._has_resume_answer([]) - assert journal_module._has_resume_answer([1]) - assert journal_module._has_resume_answer(0) - - assert journal_module._unsafe_context_resolution("skipHarmony") == "skip" + before = journal._list_keys(store.zw, prefix) assert ( - journal_module._unsafe_context_resolution( - {"batchCorrection": {"action": "skip"}} - ) - == "skip" - ) - assert journal_module._unsafe_context_resolution({"selection": "other"}) is None - - assert journal_module._resume_answer_errors(_started(), {}) == [ - "The latest paused stage does not contain persisted questions" - ] - choice = journal_module._complete_attempt( - _started(), - status="needsInput", - needs_input=WorkflowNeedsInput( - questions=[ - WorkflowQuestion( - questionId="finalGraphOptionId", - options=["graph-a", "graph-b"], - ) - ] - ), - ) - assert ( - "must be one of" - in journal_module._resume_answer_errors( - choice, {"finalGraphOptionId": "graph-c"} - )[0] - ) - generic = journal_module._complete_attempt( - _started(), - status="needsInput", - needs_input=WorkflowNeedsInput( - questions=[WorkflowQuestion(questionId="freeText")] - ), + journal.save_checkpoint(store, prefix, record.workflowRunId, key, inputs, value) + == value ) + assert journal._list_keys(store.zw, prefix) == before assert ( - "must be non-empty" - in journal_module._resume_answer_errors(generic, {"freeText": " "})[0] + journal.load_checkpoint(store, prefix, record.workflowRunId, key, inputs) + == value ) - - -def test_persisted_resume_record_validation_edges( - monkeypatch: pytest.MonkeyPatch, -) -> None: - store = SimpleNamespace(zw=object()) - - def validate(record: OrchestrationResumeRecord) -> OrchestrationResumeRecord: - monkeypatch.setattr(journal_module, "_read_model", lambda *_args: record) - return journal_module._validated_resume_record( - store, "root", "workflow-1", "resume-1" + checkpoint = journal.read_checkpoint(store, prefix, record.workflowRunId, key) + assert checkpoint["inputs"] == inputs + assert checkpoint["outputs"] == value + with pytest.raises(ValueError, match="different scientific inputs"): + journal.load_checkpoint( + store, prefix, record.workflowRunId, key, {**inputs, "selection": "cells-2"} ) - - mismatched = _with_checksum( - OrchestrationResumeRecord(workflowRunId="other", resumeId="resume-1") - ) - with pytest.raises(ValueError, match="identity"): - validate(mismatched) - - with pytest.raises(ValueError, match="checksum"): - validate( - OrchestrationResumeRecord( - workflowRunId="workflow-1", - resumeId="resume-1", - contentSha256="0" * 64, - ) + with pytest.raises(ValueError, match="different outcome"): + journal.save_checkpoint( + store, prefix, record.workflowRunId, key, inputs, {"decision": "exclude"} ) + assert all(path.startswith("analysis/agents/orchestrations/") for path in before) - unanswered = _with_checksum( - OrchestrationResumeRecord( - workflowRunId="workflow-1", - resumeId="resume-1", - answers={"unexpected": "answer"}, - ) - ) - with pytest.raises(ValueError, match="without an answered attempt"): - validate(unanswered) - empty = _with_checksum( - OrchestrationResumeRecord(workflowRunId="workflow-1", resumeId="resume-1") +def test_checkpoint_corruption_is_not_silently_repaired() -> None: + store, prefix, record = memory_journal() + key = "preprocessing/decision" + journal.save_checkpoint( + store, prefix, record.workflowRunId, key, {"input": 1}, {"chosen": 2} ) - assert validate(empty) == empty - - paused = journal_module._complete_attempt( - _started(), - status="needsInput", - needs_input=WorkflowNeedsInput( - questions=[WorkflowQuestion(questionId="freeText")] - ), + path = journal._checkpoint_key(prefix, record.workflowRunId, key) + raw = journal.record_io.read_key(store.zw, path) + data = json.loads(raw) + data["outputs"]["chosen"] = 3 + altered = journal.record_io.display_json_bytes(data) + sync( + store.zw.store.set(path, default_buffer_prototype().buffer.from_bytes(altered)) ) - link = journal_module._parent_link(paused) - answered = _with_checksum( - OrchestrationResumeRecord( - workflowRunId="workflow-1", - resumeId="resume-1", - answeredAttempt=link, - questionIds=["freeText"], - answers={"freeText": "answer"}, - ) + with pytest.raises(ValueError, match="checksum"): + journal.load_checkpoint(store, prefix, record.workflowRunId, key, None) + assert journal.record_io.read_key(store.zw, path) == altered + + +@pytest.mark.parametrize("key", ["../escape", "x//y", "/root", "x/../y", "x\\y"]) +def test_checkpoint_path_cannot_escape_owner(key: str) -> None: + with pytest.raises(ValueError, match="path components"): + journal._checkpoint_key("agents/orchestrations", "workflow-1", key) + + +def test_committed_evidence_survives_interruption_before_stage_outcome() -> None: + store, prefix, request = memory_journal() + first = journal._start_attempt( + store.zw, + prefix, + request.workflowRunId, + "experimental_context", + request, + [], + inputs={"columns": ["condition"]}, + ) + report = Evidence(rationale="Condition crosses donors", nCells=100) + _, reference = journal._save_stage_report( + store, first, report, expected_type=Evidence + ) + restarted = journal._start_attempt( + store.zw, + prefix, + request.workflowRunId, + "experimental_context", + request, + [], + inputs=first.inputs, + ) + recovered, recovered_ref = journal._recover_persisted_stage_report( + store, restarted, expected_type=Evidence + ) + assert recovered == report + assert recovered_ref == reference + changed = journal._start_attempt( + store.zw, + prefix, + request.workflowRunId, + "experimental_context", + request, + [], + inputs={"columns": ["condition", "age"]}, ) - - monkeypatch.setattr(journal_module, "_stage_outcomes", lambda *_args: []) - with pytest.raises(ValueError, match="does not cite one paused"): - validate(answered) - - monkeypatch.setattr(journal_module, "_stage_outcomes", lambda *_args: [paused]) - stale = _with_checksum(answered.model_copy(update={"questionIds": ["stale"]})) - with pytest.raises(ValueError, match="question IDs are stale"): - validate(stale) - - invalid = _with_checksum(answered.model_copy(update={"answers": {"freeText": ""}})) - with pytest.raises(ValueError, match="invalid answers"): - validate(invalid) - - -def test_stage_outcome_resolution_edges(monkeypatch: pytest.MonkeyPatch) -> None: - request = _request_record() - store = SimpleNamespace( - zw=object(), - cells=SimpleNamespace(columns=[]), - load_artifact=lambda *_args: object(), + assert ( + journal._recover_persisted_stage_report(store, changed, expected_type=Evidence) + is None ) - done = journal_module._complete_attempt(_started(), status="done") - - with pytest.raises(ValueError, match="checksum is stale"): - journal_module._stage_outcome_resolves( - store, - "root", - "workflow-1", - request, - done.model_copy(update={"requestSha256": "c" * 64}), - ) - with pytest.raises(ValueError, match="cannot retain started"): - journal_module._stage_outcome_resolves( - store, "root", "workflow-1", request, _started() - ) - - monkeypatch.setattr( - journal_module, - "_read_model", - lambda *_args: _started().model_copy(update={"inputs": {"changed": True}}), + assert journal.read_stage_evidence(store, reference) == report.model_dump( + mode="json" ) - with pytest.raises(ValueError, match="do not match"): - journal_module._stage_outcome_resolves( - store, "root", "workflow-1", request, done + with pytest.raises(ValueError, match="owned"): + journal.read_stage_evidence( + store, + StageEvidenceReference.model_validate( + {**reference.model_dump(), "stage": "preprocessing"} + ), ) - - parent_outcome = journal_module._complete_attempt(_started(), status="done") - parent = journal_module._parent_link(parent_outcome) - child_started = _started(stage="data_enrichment", parents=[parent]) - child = journal_module._complete_attempt(child_started, status="done") - monkeypatch.setattr(journal_module, "_read_model", lambda *_args: child_started) - monkeypatch.setattr(journal_module, "_stage_outcomes", lambda *_args: []) - assert not journal_module._stage_outcome_resolves( - store, "root", "workflow-1", request, child - ) - - reference = AgentReportReference.get_example().model_copy( - update={"workflowRunId": "other-workflow"} - ) - report_outcome = journal_module._complete_attempt( - _started(), status="done", report_references=[reference] - ) - monkeypatch.setattr(journal_module, "_read_model", lambda *_args: _started()) - assert not journal_module._stage_outcome_resolves( - store, "root", "workflow-1", request, report_outcome - ) - - metadata_outcome = journal_module._complete_attempt( - _started(), status="done", outputs={"metadataColumns": ["missing"]} - ) - assert not journal_module._stage_outcome_resolves( - store, "root", "workflow-1", request, metadata_outcome - ) - - monkeypatch.setattr( - journal_module, - "_stage_outcomes", - lambda *_args: [done.model_copy(update={"configSha256": "c" * 64})], + assert not any( + "/runs/" in key or "snapshot" in key or "verification" in key + for key in journal._list_keys(store.zw, "agents") ) - with pytest.raises(ValueError, match="checksum is stale"): - journal_module._validated_done_outcome( - store, "root", "workflow-1", "ingest", request, [] - ) - - -def test_stage_invocation_and_report_loading_edges( - monkeypatch: pytest.MonkeyPatch, -) -> None: - started = _started(stage="parameter_tuning") - with pytest.raises(ValueError, match="conflicting orchestration identity"): - journal_module._stage_invocation( - started, - AgentInvocation(inputs={"orchestrationExecutionId": "other"}), - ) - with pytest.raises(ValueError, match="exactly one report reference"): - journal_module.load_stage_report(object(), started, WorkflowQuestion) - execution_id = journal_module._stage_execution_id(started) - matching = AgentReportReference.get_example().model_copy( - update={"agentRunId": execution_id} +def test_stage_reuse_requires_exact_parent_and_resolving_evidence() -> None: + store, prefix, request = memory_journal() + start = journal._start_attempt( + store.zw, prefix, request.workflowRunId, "ingest", request, [] ) - other = AgentReportReference.get_example().model_copy( - update={"agentRunId": "other-report"} + parent = journal._complete_attempt(start, status="done") + journal._save_outcome(store.zw, prefix, parent) + link = journal._parent_link(parent) + child = journal._start_attempt( + store.zw, prefix, request.workflowRunId, "data_enrichment", request, [link] ) - tuning = started.model_copy(update={"reportReferences": [matching, other]}) - monkeypatch.setattr( - journal_module, - "load_agent_report", - lambda *_args: WorkflowQuestion(questionId="answer"), - ) - assert isinstance( - journal_module.load_stage_report(object(), tuning, WorkflowQuestion), - WorkflowQuestion, - ) - - wrong = started.model_copy(update={"reportReferences": [matching]}) - monkeypatch.setattr( - journal_module, - "load_agent_report", - lambda *_args: WorkflowNeedsInput(), - ) - with pytest.raises(TypeError, match="report is not WorkflowQuestion"): - journal_module.load_stage_report(object(), wrong, WorkflowQuestion) - - -def test_persisted_stage_report_recovery_edges( - monkeypatch: pytest.MonkeyPatch, -) -> None: - started = _started(stage="data_enrichment") - execution_id = journal_module._stage_execution_id(started) - reference = AgentReportReference.get_example().model_copy( - update={"agentRunId": execution_id} - ) - store = SimpleNamespace(load_artifact=lambda *_args: object()) - - monkeypatch.setattr( - journal_module, - "list_agent_reports", - lambda *_args, **_kwargs: [reference, reference], + _, ref = journal._save_stage_report( + store, + child, + Evidence(rationale="RNA identified", nCells=100), + expected_type=Evidence, ) - with pytest.raises(ValueError, match="multiple persisted reports"): - journal_module._recover_persisted_stage_report( - store, - started, - agent_name="data_enrichment", - expected_type=WorkflowQuestion, + outcome = journal._complete_attempt(child, status="done", report_references=[ref]) + journal._save_outcome(store.zw, prefix, outcome) + assert ( + journal._validated_done_outcome( + store, prefix, request.workflowRunId, "data_enrichment", request, [link] ) - - monkeypatch.setattr( - journal_module, - "list_agent_reports", - lambda *_args, **_kwargs: [reference], - ) - monkeypatch.setattr( - journal_module, - "load_agent_record", - lambda *_args: SimpleNamespace( - invocation=AgentInvocation( - agentName="data_enrichment", - inputs={"orchestrationExecutionId": "stale"}, - ) - ), + == outcome ) - with pytest.raises(ValueError, match="stale execution identity"): - journal_module._recover_persisted_stage_report( - store, - started, - agent_name="data_enrichment", - expected_type=WorkflowQuestion, + assert ( + journal._validated_done_outcome( + store, prefix, request.workflowRunId, "data_enrichment", request, [] ) - - monkeypatch.setattr( - journal_module, - "load_agent_record", - lambda *_args: SimpleNamespace( - invocation=AgentInvocation( - agentName="data_enrichment", - inputs={"orchestrationExecutionId": execution_id}, - ) - ), + is None ) - monkeypatch.setattr( - journal_module, - "load_agent_report", - lambda *_args: WorkflowNeedsInput(), - ) - with pytest.raises(TypeError, match="is not WorkflowQuestion"): - journal_module._recover_persisted_stage_report( - store, - started, - agent_name="data_enrichment", - expected_type=WorkflowQuestion, + sync( + store.zw.store.delete( + journal._checkpoint_key(prefix, request.workflowRunId, ref.key) ) - - report = WorkflowQuestion(questionId="question") - invocation = AgentInvocation(agentName="data_enrichment") - monkeypatch.setattr( - journal_module, - "save_agent_report", - lambda *_args, **_kwargs: (_ for _ in ()).throw(FileExistsError), - ) - monkeypatch.setattr( - journal_module, - "_recover_persisted_stage_report", - lambda *_args, **_kwargs: None, ) - with pytest.raises(FileExistsError): - journal_module._save_stage_report( - store, - started, - report, - invocation=invocation, - expected_type=WorkflowQuestion, + assert ( + journal._validated_done_outcome( + store, prefix, request.workflowRunId, "data_enrichment", request, [link] ) - - monkeypatch.setattr( - journal_module, - "_recover_persisted_stage_report", - lambda *_args, **_kwargs: (report, reference), + is None ) - assert journal_module._save_stage_report( - store, - started, - report, - invocation=invocation, - expected_type=WorkflowQuestion, - ) == (report, reference) - - -def test_terminal_result_validation_and_persistence_edges( - monkeypatch: pytest.MonkeyPatch, -) -> None: - workflow = _terminal_workflow() - result = _terminal_result(workflow) - store = SimpleNamespace(zw=object()) + with pytest.raises(ValueError, match="unresolved"): + journal.analysis_snapshot(store, request.workflowRunId) - def load(payload: bytes) -> AutomatedWorkflowResult | None: - monkeypatch.setattr( - journal_module.record_io, "read_key", lambda *_args: payload - ) - return journal_module._load_terminal_result(store, "root", workflow) - with pytest.raises(ValueError, match="Malformed automated workflow result"): - load(b"{") - with pytest.raises(ValueError, match="checksum is invalid"): - load( - journal_module.record_io.display_json_bytes( - result.model_dump(mode="json") | {"contentSha256": "0" * 64} - ) - ) - - missing_workflow = _with_checksum( - AutomatedWorkflowResult.model_construct( - status="completed", currentStage="analysis_finalization" - ) +def test_resume_answers_are_committed_in_stage_inputs_without_another_ledger() -> None: + store, prefix, request = memory_journal() + start = journal._start_attempt( + store.zw, prefix, request.workflowRunId, "preprocessing_plan", request, [] ) - with pytest.raises(ValueError, match="Malformed automated workflow result"): - load( - journal_module.record_io.display_json_bytes( - missing_workflow.model_dump(mode="json") - ) - ) - - stale = _with_checksum( - result.model_copy( - update={ - "workflowRun": workflow.model_copy( - update={"analysisStore": "other.zarr"} + pause = journal._complete_attempt( + start, + status="needsInput", + needs_input=WorkflowNeedsInput( + questions=[ + WorkflowQuestion( + questionId="decision:cellQuality", + decisionId="cellQuality", + options=["keep"], + question="Which supported QC policy?", ) - } - ) + ] + ), ) - with pytest.raises(ValueError, match="stale workflow metadata"): - load(journal_module.record_io.display_json_bytes(stale.model_dump(mode="json"))) - - wrong_status = _with_checksum(result.model_copy(update={"status": "failed"})) - with pytest.raises(ValueError, match="stale terminal status"): - load( - journal_module.record_io.display_json_bytes( - wrong_status.model_dump(mode="json") - ) + answers = { + "decision:cellQuality": { + "decisionId": "cellQuality", + "optionId": "keep", + "rationale": "Preserves replicated populations", + } + } + assert journal._resume_answer_errors(pause, answers) == [] + assert journal._resume_answer_errors(pause, {"unrelated": 1}) + bad = { + "decision:cellQuality": { + **answers["decision:cellQuality"], + "optionId": "invented", + } + } + assert journal._resume_answer_errors(pause, bad) + resume = OrchestrationResumeRecord( + workflowRunId=request.workflowRunId, + answeredAttempt=journal._parent_link(pause), + answers=answers, + questionIds=list(answers), + ) + answered = journal._start_attempt( + store.zw, + prefix, + request.workflowRunId, + "preprocessing_plan", + request, + [], + resume_record=resume, + ) + replay = journal._start_attempt( + store.zw, + prefix, + request.workflowRunId, + "preprocessing_plan", + request, + [], + resume_record=resume, + ) + assert answered.inputs["resumeAnswers"] == answers + assert journal._stage_execution_id(answered) == journal._stage_execution_id(replay) + assert not any("/resumes/" in key for key in journal._list_keys(store.zw, prefix)) + + +def test_stage_checksums_and_original_start_are_required() -> None: + store, prefix, request = memory_journal() + start = journal._start_attempt( + store.zw, prefix, request.workflowRunId, "ingest", request, [] + ) + outcome = journal._complete_attempt(start, status="done") + journal._save_outcome(store.zw, prefix, outcome) + altered = outcome.model_copy(update={"inputs": {"different": True}}) + with pytest.raises(ValueError, match="started and outcome"): + journal._stage_outcome_resolves( + store, prefix, request.workflowRunId, request, altered ) + with pytest.raises(FileExistsError, match="exists"): + journal._save_outcome(store.zw, prefix, outcome) + no_listing = SimpleNamespace(store=SimpleNamespace(supports_listing=False)) + with pytest.raises(NotImplementedError, match="listing"): + journal._list_keys(no_listing, prefix) + + +@pytest.mark.parametrize( + "values", + [ + {"status": "started", "completedAtNs": 1}, + {"status": "done", "startedAtNs": 2, "completedAtNs": 1}, + {"status": "needsInput"}, + {"status": "failed"}, + ], +) +def test_invalid_stage_lifecycle_is_rejected(values) -> None: + with pytest.raises(ValueError): + WorkflowStageAttempt(**values) - wrong_reports = _with_checksum( - result.model_copy( - update={"reportReferences": [AgentReportReference.get_example()]} - ) - ) - with pytest.raises(ValueError, match="stale report references"): - load( - journal_module.record_io.display_json_bytes( - wrong_reports.model_dump(mode="json") - ) - ) - with monkeypatch.context() as patch: - patch.setattr(journal_module, "_load_terminal_result", lambda *_args: result) - assert ( - journal_module._persist_terminal_result(store, "root", workflow, result) - == result +@pytest.mark.parametrize( + "mismatch", [None, "missingMarker", "differentCohort", "unaccepted"] +) +def test_final_checkpoint_requires_exact_artifacts_cohort_and_acceptance( + monkeypatch, mismatch +) -> None: + from scarf.agent.orchestrator.models import FinalAnalysisHandoff, _STAGE_ORDER + from scarf.agent.types import ArtifactReferenceModel + + class TuningProof(AgentDataModel): + recommendedCandidateId: str + finalClusterArtifact: ArtifactReferenceModel + + store, prefix, request = memory_journal() + artifacts = { + name: ArtifactReferenceModel( + scope="datastore" if name == "cellSelection" else "assay", + assay=None if name == "cellSelection" else "RNA", + kind=kind, + artifactId=f"{index:064x}", + ) + for index, (name, kind) in enumerate( + { + "cellSelection": "cell_selection", + "graph": "connectivity_map", + "clusters": "cluster_labels", + "umap": "embedding", + "embeddingInitialization": "embedding_initialization", + "markerFeatures": "feature_selection", + "markers": "marker_table", + }.items(), + 1, + ) + } + final = FinalAnalysisHandoff( + workflowRunId=request.workflowRunId, + primaryAssay="RNA", + markerAssay="RNA", + **artifacts, + ) + inputs = { + "preprocessedAssays": [ + {"cellSelection": artifacts["cellSelection"].model_dump(mode="json")} + ] + } + if mismatch == "missingMarker": + final = final.model_copy(update={"markers": None}) + if mismatch == "differentCohort": + inputs["preprocessedAssays"][0]["cellSelection"]["artifactId"] = "e" * 64 + parents = [] + for stage in _STAGE_ORDER: + start = journal._start_attempt( + store.zw, + prefix, + request.workflowRunId, + stage, + request, + parents, + inputs=inputs if stage == "analysis_finalization" else {}, ) - - with monkeypatch.context() as patch: - patch.setattr(journal_module, "_load_terminal_result", lambda *_args: None) - with pytest.raises(ValueError, match="exact content checksum"): - journal_module._persist_terminal_result( + references = [] + if stage == "parameter_tuning": + _, ref = journal._save_stage_report( store, - "root", - workflow, - result.model_copy(update={"contentSha256": ""}), + start, + TuningProof( + recommendedCandidateId="selected", + finalClusterArtifact=artifacts["clusters"], + ), + expected_type=TuningProof, ) - - with monkeypatch.context() as patch: - persisted = iter((None, result)) - patch.setattr( - journal_module, - "_load_terminal_result", - lambda *_args: next(persisted), - ) - patch.setattr( - journal_module, - "_write_model_once", - lambda *_args: (_ for _ in ()).throw(FileExistsError), - ) - assert ( - journal_module._persist_terminal_result(store, "root", workflow, result) - == result - ) - - with monkeypatch.context() as patch: - patch.setattr(journal_module, "_load_terminal_result", lambda *_args: None) - patch.setattr(journal_module, "_write_model_once", lambda *_args: None) - with pytest.raises(RuntimeError, match="was not persisted"): - journal_module._persist_terminal_result(store, "root", workflow, result) + references.append(ref) + outcome = journal._complete_attempt( + start, + status="done", + report_references=references, + artifacts=artifacts if stage == "analysis_finalization" else {}, + outputs={"finalAnalysis": final.model_dump(mode="json")} + if stage == "analysis_finalization" + else {}, + ) + journal._save_outcome(store.zw, prefix, outcome) + parents = [journal._parent_link(outcome)] + monkeypatch.setattr( + journal, + "_analysis_review_views", + lambda *args: [ + { + "scope": "full", + "action": "defer" if mismatch == "unaccepted" else "accept", + "selectedCandidateId": "selected", + } + ], + ) + if mismatch: + with pytest.raises(ValueError, match="Final analysis"): + journal.analysis_snapshot(store, request.workflowRunId) + else: + snapshot = journal.analysis_snapshot(store, request.workflowRunId) + assert snapshot["status"] == "completed" + assert snapshot["finalAnalysis"] == final.model_dump(mode="json") diff --git a/tests/test_agent_orchestrator_lifecycle.py b/tests/test_agent_orchestrator_lifecycle.py index fa6e7546..00f602bf 100644 --- a/tests/test_agent_orchestrator_lifecycle.py +++ b/tests/test_agent_orchestrator_lifecycle.py @@ -1,1133 +1,389 @@ -"""Lifecycle, persistence, resume, and recovery orchestrator contracts.""" +"""Journal-owned resume, input identity, and visible failure behavior.""" -import hashlib -import json -import shutil -import uuid -from collections.abc import Mapping from pathlib import Path -from typing import Any +from types import SimpleNamespace +import numpy as np import pytest import zarr -from pydantic_ai import ModelHTTPError -import scarf.agent.orchestrator.context as context_module -import scarf.agent.orchestrator.journal as journal_module -import scarf.agent.orchestrator.main as main_module -from scarf.agent.config import AgentRunConfig -from scarf.agent.data_enrichment import DataEnrichmentReport -from scarf.agent.ingest import IngestResult from scarf.agent.orchestrator import ( AgentOrchestrator, AutomatedWorkflowConfig, AutomatedWorkflowRequest, - AutomatedWorkflowResult, AutomatedWorkflowResumeRequest, - FinalAnalysisHandoff, - WorkflowNeedsInput, - WorkflowQuestion, - WorkflowStageAttempt, - WorkflowStageLink, + journal, ) from scarf.agent.orchestrator.models import ( - OrchestrationRequestRecord, + AutomatedWorkflowResult, OrchestrationResumeRecord, + WorkflowIdentity, + WorkflowNeedsInput, + WorkflowQuestion, ) -from scarf.agent.persistence import ( - AgentInvocation, - create_agent_workflow, - finalize_agent_workflow, - list_agent_reports, - load_agent_report, - load_agent_workflow, - save_agent_report, -) -from scarf.agent.types import AgentRunInfo, ArtifactReferenceModel -from scarf.datastore.datastore import DataStore +from tests.agent_journal_store import memory_journal from tests.agent_orchestrator_store import create_store -_PLAN_CHECKSUM = "a" * 64 - - -def _record_checksum(payload: dict[str, Any]) -> str: - content = dict(payload) - content.pop("contentSha256", None) - encoded = json.dumps( - content, - sort_keys=True, - separators=(",", ":"), - ensure_ascii=False, - allow_nan=False, - ).encode("utf-8") - return hashlib.sha256(encoded).hexdigest() - - -def _assert_record_checksum(path: Path) -> dict[str, Any]: - payload = json.loads(path.read_text()) - assert payload["contentSha256"] == _record_checksum(payload) - return payload - - -def _mock_ingest(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(main_module, "detect_format", lambda _path: "zarr") - - def fake_ingest( - path: str, - *, - zarrPath: str | None, - model: Any, - directions: Mapping[str, Any], - ) -> IngestResult: - del model, directions - resolved = str(Path(zarrPath or path).resolve()) - return IngestResult( - status="done", - format="zarr", - zarrPath=resolved, - assayNames=["RNA"], - summary={"assays": ["RNA"]}, - actions=["summarize_zarr"], - ) - - monkeypatch.setattr(main_module, "ingest", fake_ingest) - - -class _CheckpointOrchestrator(AgentOrchestrator): - """Minimal deterministic stage machine exercising persistence and resume.""" - - def __init__( - self, - *, - config: AutomatedWorkflowConfig | None = None, - ) -> None: - super().__init__(object(), config=config) - self.enrichmentExecutions = 0 - - def _continue( - self, - store: Any, - workflow: Any, - request_record: Any, - *, - answers: Mapping[str, Any], - resume_record: OrchestrationResumeRecord | None = None, - ) -> AutomatedWorkflowResult: - prefix = journal_module._ensure_orchestration_store(store) - ingest = journal_module._validated_done_outcome( - store, - prefix, - workflow.workflowRunId, - "ingest", - request_record, - [], - ) - assert ingest is not None - parents = [journal_module._parent_link(ingest)] - - enrichment = journal_module._validated_done_outcome( - store, - prefix, - workflow.workflowRunId, - "data_enrichment", - request_record, - parents, - ) - if enrichment is None: - self.enrichmentExecutions += 1 - report = DataEnrichmentReport.get_example() - report = report.model_copy( - update={ - "runInfo": report.runInfo.model_copy( - update={"runId": uuid.uuid4().hex} - ) - } - ) - reference = save_agent_report( - store, - workflow.workflowRunId, - report, - invocation=AgentInvocation( - agentName="data_enrichment", - inputs={"studyContext": request_record.request.studyContext}, - ), - ) - started = journal_module._start_attempt( - store.zw, - prefix, - workflow.workflowRunId, - "data_enrichment", - request_record, - parents, - inputs={"studyContext": request_record.request.studyContext}, - resume_record=resume_record, - ) - enrichment = journal_module._complete_attempt( - started, - status="done", - report_references=[reference], - ) - journal_module._save_outcome(store.zw, prefix, enrichment) - parents = [journal_module._parent_link(enrichment)] - - approved = answers.get("approvePlanChecksum") == _PLAN_CHECKSUM - completed_plan = journal_module._validated_done_outcome( - store, - prefix, - workflow.workflowRunId, - "preprocessing_plan", - request_record, - parents, - ) - if completed_plan is None: - started = journal_module._start_attempt( - store.zw, - prefix, - workflow.workflowRunId, - "preprocessing_plan", - request_record, - parents, - inputs={"planChecksum": _PLAN_CHECKSUM}, - resume_record=resume_record, - ) - if not approved: - paused = journal_module._complete_attempt( - started, - status="needsInput", - needs_input=WorkflowNeedsInput( - questions=[ - WorkflowQuestion( - questionId="approvePlanChecksum", - question="Approve the preprocessing plan?", - planChecksum=_PLAN_CHECKSUM, - ) - ] - ), - ) - journal_module._save_outcome(store.zw, prefix, paused) - return journal_module.paused_or_failed_result( - store, - workflow, - request_record, - paused, - ) - completed_plan = journal_module._complete_attempt( - started, - status="done", - outputs={"planChecksum": _PLAN_CHECKSUM}, - actions=["approve_preprocessing_plan"], - ) - journal_module._save_outcome(store.zw, prefix, completed_plan) - - terminal = finalize_agent_workflow( - store, - workflow.workflowRunId, - status="completed", - message="Checkpoint workflow completed", - ) - final_analysis = FinalAnalysisHandoff( - workflowRunId=terminal.workflowRunId, - primaryAssay="RNA", - markerAssay="RNA", - ).with_handoff_id() - result = AutomatedWorkflowResult( - status="completed", - currentStage="preprocessing_plan", - zarrPath=str(Path(store.zarr_loc).resolve()), - workflowRun=terminal, - reportReferences=journal_module.all_report_references( - store, - prefix, - workflow.workflowRunId, - ), - finalAnalysis=final_analysis, - finalHandoffId=final_analysis.handoffId, - decisionRunId=terminal.workflowRunId, - ) - result = result.model_copy( - update={"contentSha256": journal_module._record_checksum(result)} - ) - journal_module._write_model_once( - store.zw, - journal_module._result_key(prefix, workflow.workflowRunId), - result, - ) - return result - - -def _start_paused_workflow( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - *, - workspace: str | None = None, -) -> tuple[_CheckpointOrchestrator, AutomatedWorkflowResult, Path]: - path = create_store(tmp_path / "data.zarr", workspace=workspace) - _mock_ingest(monkeypatch) - orchestrator = _CheckpointOrchestrator() - result = orchestrator.run( - AutomatedWorkflowRequest( - sourcePath=str(path), - zarrPath=str(path), - studyContext="A deterministic test study.", - studyObjective="Discover stable RNA populations.", - workspace=workspace, - ) +def _saved_workflow(path: Path, *, workspace: str | None = None): + create_store(path, workspace=workspace) + orchestrator = AgentOrchestrator("test-model") + request = AutomatedWorkflowRequest( + sourcePath=str(path), + zarrPath=str(path), + workspace=workspace, + primaryAssay="RNA", + markerAssay="RNA", + analysisAssays=["RNA"], + studyContext="Independent donors in two conditions", + studyObjective="Resolve stable populations", ) - assert result.status == "needsInput" - assert result.workflowRun is not None - return orchestrator, result, path - - -def test_orchestration_records_are_plain_json_with_valid_checksums( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - _orchestrator, result, path = _start_paused_workflow(tmp_path, monkeypatch) - workflow_id = result.workflowRun.workflowRunId - agents = path / "agents" - orchestration = agents / "orchestrations" - workflow_path = orchestration / workflow_id - - assert (agents / "zarr.json").is_file() - assert (agents / "store.json").is_file() - assert (orchestration / "zarr.json").is_file() - assert not (workflow_path / "zarr.json").exists() - assert not list(orchestration.rglob("c")) - - request = _assert_record_checksum(workflow_path / "request.json") - assert request["workflowRunId"] == workflow_id - records = sorted((workflow_path / "stages").rglob("*.json")) - assert records - for record_path in records: - _assert_record_checksum(record_path) - assert record_path.name in {"started.json", "outcome.json"} - - -def test_unattended_workflow_never_returns_needs_input( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - path = create_store(tmp_path / "data.zarr") - _mock_ingest(monkeypatch) - orchestrator = _CheckpointOrchestrator( - config=AutomatedWorkflowConfig(inputPolicy="unattended") + store = orchestrator.open_store(str(path), request) + store.cells.insert("condition", np.array(["a", "a", "b", "b"])) + record = orchestrator.initialize_request( + store, WorkflowIdentity("workflow-1", workspace), request ) - - result = orchestrator.run( - AutomatedWorkflowRequest( - sourcePath=str(path), - zarrPath=str(path), - studyContext="A deterministic unattended test study.", - studyObjective="Discover stable RNA populations.", - ) + resume = AutomatedWorkflowResumeRequest( + zarrPath=str(path), workspace=workspace, workflowRunId="workflow-1" ) + return orchestrator, store, record, resume - assert result.status == "failed" - assert result.needsInput is None - assert result.unresolvedClaims == ["Approve the preprocessing plan?"] - -def test_approval_resume_reuses_completed_stages_and_persists_answer_lineage( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, +@pytest.mark.parametrize("workspace", [None, "analysis"]) +def test_resume_preserves_selected_rna_workspace_and_original_metadata( + tmp_path, workspace ) -> None: - orchestrator, paused, path = _start_paused_workflow(tmp_path, monkeypatch) - workflow_id = paused.workflowRun.workflowRunId - workflow_path = path / "agents" / "orchestrations" / workflow_id - paused_outcome_path = next( - (workflow_path / "stages" / "preprocessing_plan").glob("*/outcome.json") - ) - paused_outcome = json.loads(paused_outcome_path.read_text()) - - completed = orchestrator.resume( - AutomatedWorkflowResumeRequest( - zarrPath=str(path), - workflowRunId=workflow_id, - answers={"approvePlanChecksum": _PLAN_CHECKSUM}, - ) - ) - - assert completed.status == "completed" - assert completed.workflowRun is not None - assert completed.workflowRun.status == "completed" - assert orchestrator.enrichmentExecutions == 1 - assert len(list((workflow_path / "stages" / "ingest").glob("*/outcome.json"))) == 1 - assert ( - len(list((workflow_path / "stages" / "data_enrichment").glob("*/outcome.json"))) - == 1 - ) + orchestrator, store, record, resume = _saved_workflow( + tmp_path / "rna.zarr", workspace=workspace + ) + reopened_record, reopened = orchestrator.load_request_for_resume(resume) + assert reopened_record == record + assert reopened.workspace == workspace + assert reopened.summary().default_assay == "RNA" + store.cells.insert("derived_agent_column", np.array([1, 1, 2, 2])) + assert orchestrator.load_request_for_resume(resume)[0] == record + store.cells.insert("condition", np.array(["a", "b", "b", "b"]), overwrite=True) + with pytest.raises(ValueError, match="metadata changed"): + orchestrator.load_request_for_resume(resume) + + +def test_resume_rejects_changed_counts_model_and_execution_settings(tmp_path) -> None: + orchestrator, store, record, resume = _saved_workflow(tmp_path / "rna.zarr") + with pytest.raises(ValueError, match="model differs"): + AgentOrchestrator("another-model").load_request_for_resume(resume) + with pytest.raises(ValueError, match="execution settings differ"): + AgentOrchestrator( + "test-model", config=AutomatedWorkflowConfig(randomSeed=3) + ).load_request_for_resume(resume) + root = zarr.open_group(str(store.zarr_loc), mode="r+") + counts = root["RNA/counts"] + counts[0, 0] = int(counts[0, 0]) + 1 + with pytest.raises(ValueError, match="Selected RNA data"): + orchestrator.load_request_for_resume(resume) assert ( - len( - list( - (workflow_path / "stages" / "preprocessing_plan").glob("*/outcome.json") - ) + journal.read_request( + store.zw, journal._orchestration_prefix(store), record.workflowRunId ) - == 2 + == record ) - resume_path = next((workflow_path / "resumes").glob("*.json")) - resume = _assert_record_checksum(resume_path) - assert resume["answeredAttempt"]["attemptId"] == paused_outcome["attemptId"] - assert resume["questionIds"] == ["approvePlanChecksum"] - assert resume["answers"] == {"approvePlanChecksum": _PLAN_CHECKSUM} - resumed_start = next( - payload - for payload in ( - json.loads(path.read_text()) - for path in (workflow_path / "stages" / "preprocessing_plan").glob( - "*/started.json" - ) - ) - if "resumeLineage" in payload["inputs"] - ) - assert resumed_start["inputs"]["resumeLineage"] == { - "resumeId": resume["resumeId"], - "answeredAttempt": resume["answeredAttempt"], - "questionIds": ["approvePlanChecksum"], - } - - persisted = _assert_record_checksum(workflow_path / "result.json") - assert persisted["status"] == "completed" - assert persisted["workflowRun"]["status"] == "completed" - assert load_agent_workflow(path, workflow_id).status == "completed" - -def test_resume_does_not_mutate_constructor_configuration( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - orchestrator, paused, path = _start_paused_workflow(tmp_path, monkeypatch) - assert paused.workflowRun is not None - constructor_config = AutomatedWorkflowConfig(maxCandidateEvaluations=25) - orchestrator.config = constructor_config - - completed = orchestrator.resume( - AutomatedWorkflowResumeRequest( - zarrPath=str(path), - workflowRunId=paused.workflowRun.workflowRunId, - answers={"approvePlanChecksum": _PLAN_CHECKSUM}, - ) +def test_resume_never_reads_an_unrelated_latest_workflow(tmp_path, monkeypatch) -> None: + orchestrator, store, record, resume = _saved_workflow(tmp_path / "rna.zarr") + other_request = record.request.model_copy( + update={"studyObjective": "A different question"} ) + orchestrator.initialize_request(store, WorkflowIdentity("other-run"), other_request) + seen = [] - assert completed.status == "completed" - assert orchestrator.config == constructor_config - - -@pytest.mark.parametrize( - ("answers", "expected_note"), - [ - ({}, "Missing resume answer"), - ({"approvePlanChecksum": "approve"}, "persisted plan checksum"), - ( - { - "approvePlanChecksum": _PLAN_CHECKSUM, - "unexpectedAnswer": "ignored", - }, - "Unknown resume answer key", - ), - ], -) -def test_invalid_resume_answers_retain_the_persisted_pause( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - answers: dict[str, Any], - expected_note: str, -) -> None: - orchestrator, paused, path = _start_paused_workflow(tmp_path, monkeypatch) - assert paused.workflowRun is not None - workflow_id = paused.workflowRun.workflowRunId - - result = orchestrator.resume( - AutomatedWorkflowResumeRequest( - zarrPath=str(path), - workflowRunId=workflow_id, - answers=answers, + def stop(request): + seen.append(request.workflowRunId) + return AutomatedWorkflowResult( + status="abstained", workflowRunId=request.workflowRunId ) - ) - assert result.status == "needsInput" - assert result.currentStage == "preprocessing_plan" - assert result.needsInput == paused.needsInput - assert result.workflowRun is not None - assert result.workflowRun.status == "running" - assert any(expected_note in note for note in result.notes) - workflow_path = path / "agents" / "orchestrations" / workflow_id + monkeypatch.setattr(orchestrator, "resume", stop) assert ( - len( - list( - (workflow_path / "stages" / "preprocessing_plan").glob("*/outcome.json") - ) - ) - == 1 + orchestrator._reuse_or_resume(record.request).workflowRunId + == record.workflowRunId ) - assert len(list((workflow_path / "resumes").glob("*.json"))) == 1 + assert seen == [record.workflowRunId] + conflicting = AgentOrchestrator("different-model") + with pytest.raises(ValueError, match="different model or execution settings"): + conflicting._reuse_or_resume(record.request) -def test_interrupted_attempt_supersedes_a_historical_pause_for_resume( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, +def test_older_request_reports_actionable_hard_break_without_rewriting( + tmp_path, ) -> None: - orchestrator, paused, path = _start_paused_workflow(tmp_path, monkeypatch) - assert paused.workflowRun is not None - workflow_id = paused.workflowRun.workflowRunId - record, store = orchestrator.load_request_for_resume( - AutomatedWorkflowResumeRequest( - zarrPath=str(path), - workflowRunId=workflow_id, - ) - ) - prefix = journal_module._ensure_orchestration_store(store) - journal_module._start_attempt( - store.zw, - prefix, - workflow_id, - "preprocessing", - record, - [], - inputs={"simulatedCrash": True}, - ) - - with pytest.raises(ValueError, match="no active persisted questions"): - orchestrator.resume( - AutomatedWorkflowResumeRequest( - zarrPath=str(path), - workflowRunId=workflow_id, - answers={"approvePlanChecksum": _PLAN_CHECKSUM}, - ) - ) - - resumed = orchestrator.resume( - AutomatedWorkflowResumeRequest( - zarrPath=str(path), - workflowRunId=workflow_id, - ) - ) - - assert resumed.status == "needsInput" - assert not any("Missing resume answer" in note for note in resumed.notes) - workflow_path = path / "agents" / "orchestrations" / workflow_id - assert ( - len( - list( - (workflow_path / "stages" / "preprocessing_plan").glob("*/outcome.json") + orchestrator, store, record, resume = _saved_workflow(tmp_path / "rna.zarr") + path = Path(store.zarr_loc) / "agents/orchestrations/old/request.json" + path.parent.mkdir() + content = '{"formatVersion":2,"workflowRunId":"old"}' + path.write_text(content) + with pytest.raises( + ValueError, match="older requests cannot be resumed or regenerated" + ): + journal.open_analysis_store(store.zarr_loc, "old") + assert path.read_text() == content + assert store.get_assay("RNA").rawData.shape == (4, 4) + + +def _pause_with_interrupted_answer( + *, failed: bool = False, tuning: bool = False, interrupted: bool = True +): + store, prefix, record = memory_journal() + start = journal._start_attempt( + store.zw, prefix, record.workflowRunId, "ingest", record, [] + ) + ingest = journal._complete_attempt(start, status="done") + journal._save_outcome(store.zw, prefix, ingest) + parents = [journal._parent_link(ingest)] + if tuning: + for stage in ( + "data_enrichment", + "rna_quality_metrics", + "experimental_context", + "preprocessing_plan", + "preprocessing", + ): + started = journal._start_attempt( + store.zw, prefix, record.workflowRunId, stage, record, parents ) - ) - == 2 - ) - - -def test_interrupted_answered_attempt_inherits_exact_persisted_answers( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - orchestrator, paused, path = _start_paused_workflow(tmp_path, monkeypatch) - assert paused.workflowRun is not None - workflow_id = paused.workflowRun.workflowRunId - record, store = orchestrator.load_request_for_resume( - AutomatedWorkflowResumeRequest( - zarrPath=str(path), - workflowRunId=workflow_id, - ) - ) - prefix = journal_module._ensure_orchestration_store(store) - paused_outcome = journal_module._stage_outcomes( - store.zw, - prefix, - workflow_id, - "preprocessing_plan", - )[0] - first_resume = OrchestrationResumeRecord( - workflowRunId=workflow_id, - resumeId="resume-before-crash", - createdAtNs=paused_outcome.completedAtNs + 1, - answeredAttempt=journal_module._parent_link(paused_outcome), - questionIds=["approvePlanChecksum"], - answers={"approvePlanChecksum": _PLAN_CHECKSUM}, - ) - first_resume = first_resume.model_copy( - update={"contentSha256": journal_module._record_checksum(first_resume)} - ) - journal_module._write_model_once( - store.zw, - journal_module._resume_key( - prefix, - workflow_id, - first_resume.resumeId, + completed = journal._complete_attempt(started, status="done") + journal._save_outcome(store.zw, prefix, completed) + parents = [journal._parent_link(completed)] + stage = "parameter_tuning" if tuning else "data_enrichment" + question_id = "parameter_tuning" if tuning else "enrichmentDirections" + start = journal._start_attempt( + store.zw, prefix, record.workflowRunId, stage, record, parents + ) + paused = journal._complete_attempt( + start, + status="needsInput", + needs_input=WorkflowNeedsInput( + questions=[ + WorkflowQuestion( + questionId=question_id, + question="Inspect unresolved scientific evidence" + if tuning + else "Confirm RNA organism", + ) + ] ), - first_resume, ) - journal_module._start_attempt( + journal._save_outcome(store.zw, prefix, paused) + answers = { + question_id: {"action": "defer", "rationale": "More evidence is required"} + if tuning + else {"organism": "human"} + } + resume_record = OrchestrationResumeRecord( + workflowRunId=record.workflowRunId, + answeredAttempt=journal._parent_link(paused), + answers=answers, + questionIds=list(answers), + ) + if not interrupted: + return store, record, answers, resume_record + interrupted_start = journal._start_attempt( store.zw, prefix, - workflow_id, - "preprocessing_plan", + record.workflowRunId, + stage, record, - paused_outcome.parentAttempts, - inputs={"planChecksum": _PLAN_CHECKSUM}, - resume_record=first_resume, + parents, + resume_record=resume_record, ) - - with pytest.raises(ValueError, match="Cannot change answers"): - orchestrator.resume( - AutomatedWorkflowResumeRequest( - zarrPath=str(path), - workflowRunId=workflow_id, - answers={"approvePlanChecksum": "b" * 64}, - ) + if failed: + outcome = journal._complete_attempt( + interrupted_start, status="failed", error="Provider unavailable" ) + journal._save_outcome(store.zw, prefix, outcome) + return store, record, answers, resume_record - completed = orchestrator.resume( - AutomatedWorkflowResumeRequest( - zarrPath=str(path), - workflowRunId=workflow_id, - ) - ) - assert completed.status == "completed" - resume_records = sorted( - (path / "agents" / "orchestrations" / workflow_id / "resumes").glob("*.json") - ) - assert len(resume_records) == 2 - inherited = next( - json.loads(value.read_text()) - for value in resume_records - if value.stem != first_resume.resumeId +@pytest.mark.parametrize("failed", [False, True]) +@pytest.mark.parametrize("tuning", [False, True]) +def test_interrupted_answered_attempt_resumes_without_asking_or_spending_again( + monkeypatch, failed, tuning +) -> None: + store, record, answers, original = _pause_with_interrupted_answer( + failed=failed, tuning=tuning ) - assert inherited["answers"] == first_resume.answers - assert inherited["answeredAttempt"] == first_resume.answeredAttempt.model_dump( - mode="json" + orchestrator = AgentOrchestrator("test-model") + monkeypatch.setattr( + orchestrator, "load_request_for_resume", lambda request: (record, store) ) - assert inherited["questionIds"] == first_resume.questionIds - - -def test_resume_rejects_changed_dataset_fingerprint( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - orchestrator, paused, path = _start_paused_workflow(tmp_path, monkeypatch) - workflow_id = paused.workflowRun.workflowRunId - root = zarr.open_group(str(path), mode="r+") - root["RNA"].attrs["dataset_fingerprint"] = "changed-dataset" - - with pytest.raises(ValueError, match="dataset fingerprints"): - orchestrator.resume( - AutomatedWorkflowResumeRequest( - zarrPath=str(path), - workflowRunId=workflow_id, - answers={"approvePlanChecksum": _PLAN_CHECKSUM}, - ) - ) + captured = {} - -def test_resume_rejects_request_envelope_and_destination_mismatch( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - orchestrator, paused, path = _start_paused_workflow(tmp_path, monkeypatch) - workflow_id = paused.workflowRun.workflowRunId - request_path = path / "agents" / "orchestrations" / workflow_id / "request.json" - payload = json.loads(request_path.read_text()) - payload["request"]["studyContext"] = "Tampered study context" - request_path.write_text(json.dumps(payload)) - - with pytest.raises(ValueError, match="request checksum"): - orchestrator.resume( - AutomatedWorkflowResumeRequest( - zarrPath=str(path), - workflowRunId=workflow_id, - ) + def continue_work(*args, **kwargs): + captured.update(kwargs) + return AutomatedWorkflowResult( + status="abstained", notes=["Stopped at the resumed boundary"] ) - copied = tmp_path / "copied.zarr" - shutil.copytree(path, copied) - with pytest.raises(ValueError, match="zarrPath"): - orchestrator.resume( - AutomatedWorkflowResumeRequest( - zarrPath=str(copied), - workflowRunId=workflow_id, - ) + monkeypatch.setattr(orchestrator, "_continue", continue_work) + result = orchestrator.resume( + AutomatedWorkflowResumeRequest( + zarrPath="analysis.zarr", workflowRunId=record.workflowRunId ) - - -def test_workspace_records_are_isolated_and_wrong_workspace_cannot_resume( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - orchestrator, paused, path = _start_paused_workflow( - tmp_path, - monkeypatch, - workspace="workspace_a", ) - workflow_id = paused.workflowRun.workflowRunId - root = zarr.open_group(str(path), mode="r+") - root.create_group("workspace_b") - - assert ( - path - / "workspace_a" - / "agents" - / "orchestrations" - / workflow_id - / "request.json" - ).is_file() - assert not (path / "agents").exists() - assert not (path / "workspace_b" / "agents").exists() - - with pytest.raises(FileNotFoundError): - orchestrator.resume( - AutomatedWorkflowResumeRequest( - zarrPath=str(path), - workflowRunId=workflow_id, - workspace="workspace_b", - ) - ) + assert result.status == "abstained" + assert captured["answers"] == answers + assert captured["resume_record"].answeredAttempt == original.answeredAttempt -def test_cancel_finalizes_abandoned_and_prevents_future_resume( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, +@pytest.mark.parametrize("tuning", [False, True]) +def test_empty_answer_reenters_only_the_checkpointed_tuning_stage( + monkeypatch, tuning ) -> None: - orchestrator, paused, path = _start_paused_workflow(tmp_path, monkeypatch) - workflow_id = paused.workflowRun.workflowRunId - request = AutomatedWorkflowResumeRequest( - zarrPath=str(path), - workflowRunId=workflow_id, - ) - - cancelled = orchestrator.cancel(request, message="Stop this test workflow") - - assert cancelled.status == "abandoned" - assert cancelled.workflowRun is not None - assert cancelled.workflowRun.status == "abandoned" - assert load_agent_workflow(path, workflow_id).status == "abandoned" - result_path = path / "agents" / "orchestrations" / workflow_id / "result.json" - persisted = _assert_record_checksum(result_path) - assert persisted["status"] == "abandoned" - assert persisted["notes"] == ["Stop this test workflow"] - with pytest.raises(RuntimeError, match="Cannot resume"): - orchestrator.resume(request) - - result_path.unlink() - repaired = orchestrator.resume(request) - assert repaired.status == "abandoned" - assert repaired.workflowRun is not None - assert repaired.workflowRun.status == "abandoned" - _assert_record_checksum(result_path) - with pytest.raises(RuntimeError, match="Cannot resume"): - orchestrator.resume(request) - - -def test_failed_stage_preserves_completed_operation_journal(tmp_path: Path) -> None: - path = create_store(tmp_path / "failure-journal.zarr") - store = DataStore( - str(path), - default_assay="RNA", - min_features_per_cell=-1, - mito_pattern="", - ribo_pattern="", - zarr_mode="r+", + store, record, _, _ = _pause_with_interrupted_answer( + tuning=tuning, interrupted=False ) - workflow = create_agent_workflow(store, workflow_run_id="failure-journal") - request_record = OrchestrationRequestRecord( - workflowRunId=workflow.workflowRunId, - request=AutomatedWorkflowRequest( - sourcePath=str(path), - zarrPath=str(path), - studyContext="A failed-stage journal test.", - studyObjective="Discover stable RNA populations.", - ), - config=AutomatedWorkflowConfig(), + orchestrator = AgentOrchestrator("test-model") + monkeypatch.setattr( + orchestrator, "load_request_for_resume", lambda request: (record, store) ) - prefix = journal_module._ensure_orchestration_store(store) - started = journal_module._start_attempt( - store.zw, - prefix, - workflow.workflowRunId, - "preprocessing", - request_record, - [], + before = journal.analysis_snapshot(store, record.workflowRunId) + assert before["status"] == "needsInput" + assert before["stages"][-1]["stage"] == ( + "parameter_tuning" if tuning else "data_enrichment" ) - operation = { - "operation": "filter_cells", - "attrs": ["RNA_nCounts"], - "resetPrevious": True, - } + captured = {} - outcome = journal_module.finish_exception( - store, - prefix, - workflow, - started, - RuntimeError("failure after filtering"), - actions=["cell_qc_global:validated"], - outputs={"operations": [operation]}, - notes=["The completed selection mutation is retained for audit."], - ) + def continue_work(*args, **kwargs): + captured.update(kwargs) + return AutomatedWorkflowResult( + status="abstained", currentStage="parameter_tuning" + ) - assert outcome.status == "failed" - assert outcome.actions == ["cell_qc_global:validated"] - assert outcome.outputs["operations"] == [operation] - assert outcome.notes == ["The completed selection mutation is retained for audit."] - persisted = journal_module._stage_outcomes( - store.zw, - prefix, - workflow.workflowRunId, - "preprocessing", + monkeypatch.setattr(orchestrator, "_continue", continue_work) + result = orchestrator.resume( + AutomatedWorkflowResumeRequest( + zarrPath="analysis.zarr", workflowRunId=record.workflowRunId + ) ) - assert persisted == [outcome] - assert load_agent_workflow(store, workflow.workflowRunId).status == "failed" + if tuning: + assert result.status == "abstained" + assert captured == {"answers": {}, "resume_record": None} + else: + assert result.status == "needsInput" + assert captured == {} + assert journal.analysis_snapshot(store, record.workflowRunId) == before -@pytest.mark.parametrize("status_code", [429, 500, 503, 599]) -def test_retryable_model_http_error_leaves_stage_interrupted( - tmp_path: Path, - status_code: int, -) -> None: - path = create_store(tmp_path / f"retryable-model-{status_code}.zarr") - store = DataStore( - str(path), - default_assay="RNA", - min_features_per_cell=-1, - mito_pattern="", - ribo_pattern="", - zarr_mode="r+", +def test_explicit_tuning_answer_keeps_its_exact_paused_attempt(monkeypatch) -> None: + store, record, answers, original = _pause_with_interrupted_answer( + tuning=True, interrupted=False ) - workflow = create_agent_workflow( - store, - workflow_run_id=f"retryable-model-{status_code}", - ) - request_record = OrchestrationRequestRecord( - workflowRunId=workflow.workflowRunId, - request=AutomatedWorkflowRequest( - sourcePath=str(path), - zarrPath=str(path), - studyContext="A retryable model failure test.", - studyObjective="Discover stable RNA populations.", - ), - config=AutomatedWorkflowConfig(), - ) - prefix = journal_module._ensure_orchestration_store(store) - started = journal_module._start_attempt( - store.zw, - prefix, - workflow.workflowRunId, - "experimental_context", - request_record, - [], + orchestrator = AgentOrchestrator("test-model") + monkeypatch.setattr( + orchestrator, "load_request_for_resume", lambda request: (record, store) ) - error = ModelHTTPError(status_code, "test-model", {"error": "transient"}) + captured = {} - with pytest.raises(ModelHTTPError) as raised: - journal_module.finish_exception( - store, - prefix, - workflow, - started, - error, - ) + def continue_work(*args, **kwargs): + captured.update(kwargs) + return AutomatedWorkflowResult(status="abstained") - assert raised.value is error - assert ( - journal_module._stage_outcomes( - store.zw, - prefix, - workflow.workflowRunId, - "experimental_context", + monkeypatch.setattr(orchestrator, "_continue", continue_work) + result = orchestrator.resume( + AutomatedWorkflowResumeRequest( + zarrPath="analysis.zarr", + workflowRunId=record.workflowRunId, + answers=answers, ) - == [] ) - assert load_agent_workflow(store, workflow.workflowRunId).status == "running" + assert result.status == "abstained" + assert captured["answers"] == answers + assert captured["resume_record"].answeredAttempt == original.answeredAttempt -@pytest.mark.parametrize("status_code", [400, 401, 403, 404]) -def test_nonretryable_model_http_error_remains_terminal( - tmp_path: Path, - status_code: int, +def test_completed_resume_regenerates_report_without_reentering_analysis( + monkeypatch, ) -> None: - path = create_store(tmp_path / f"terminal-model-{status_code}.zarr") - store = DataStore( - str(path), - default_assay="RNA", - min_features_per_cell=-1, - mito_pattern="", - ribo_pattern="", - zarr_mode="r+", + store, prefix, record = memory_journal() + orchestrator = AgentOrchestrator("test-model") + monkeypatch.setattr( + orchestrator, "load_request_for_resume", lambda request: (record, store) ) - workflow = create_agent_workflow( - store, - workflow_run_id=f"terminal-model-{status_code}", + monkeypatch.setattr( + journal, + "analysis_snapshot", + lambda *_: { + "status": "completed", + "finalAnalysis": {"limitations": ["No donor replication"]}, + }, ) - request_record = OrchestrationRequestRecord( - workflowRunId=workflow.workflowRunId, - request=AutomatedWorkflowRequest( - sourcePath=str(path), - zarrPath=str(path), - studyContext="A terminal model failure test.", - studyObjective="Discover stable RNA populations.", - ), - config=AutomatedWorkflowConfig(), + calls = [] + monkeypatch.setattr( + AutomatedWorkflowResult, "report", lambda self: calls.append(self.workflowRunId) ) - prefix = journal_module._ensure_orchestration_store(store) - started = journal_module._start_attempt( - store.zw, - prefix, - workflow.workflowRunId, - "experimental_context", - request_record, - [], + monkeypatch.setattr( + orchestrator, + "_continue", + lambda *_a, **_k: pytest.fail("Numerical workflow repeated"), ) - - outcome = journal_module.finish_exception( - store, - prefix, - workflow, - started, - ModelHTTPError(status_code, "test-model", {"error": "terminal"}), + result = orchestrator.resume( + AutomatedWorkflowResumeRequest( + zarrPath="analysis.zarr", workflowRunId=record.workflowRunId + ) ) - - assert outcome.status == "failed" - assert outcome.error is not None - assert outcome.error.startswith("ModelHTTPError:") - assert load_agent_workflow(store, workflow.workflowRunId).status == "failed" + assert result.status == "completed" + assert result.limitations == ["No donor replication"] + assert calls == [record.workflowRunId] -def test_failed_stage_links_report_committed_before_exception(tmp_path: Path) -> None: - path = create_store(tmp_path / "failure-report-link.zarr") - store = DataStore( - str(path), - default_assay="RNA", - min_features_per_cell=-1, - mito_pattern="", - ribo_pattern="", - zarr_mode="r+", - ) - workflow = create_agent_workflow(store, workflow_run_id="failure-report-link") - request_record = OrchestrationRequestRecord( - workflowRunId=workflow.workflowRunId, - request=AutomatedWorkflowRequest( - sourcePath=str(path), - zarrPath=str(path), - studyContext="A report-link crash test.", - studyObjective="Discover stable RNA populations.", - ), - config=AutomatedWorkflowConfig(), - ) - prefix = journal_module._ensure_orchestration_store(store) - started = journal_module._start_attempt( - store.zw, - prefix, - workflow.workflowRunId, - "data_enrichment", - request_record, - [], - inputs={"studyContext": request_record.request.studyContext}, - ) - report = DataEnrichmentReport.get_example().model_copy( - update={ - "runInfo": AgentRunInfo( - agentName="data_enrichment", - runId=uuid.uuid4().hex, - ) - } - ) - _saved, reference = journal_module._save_stage_report( - store, - started, - report, - invocation=AgentInvocation( - agentName="data_enrichment", - inputs={"studyContext": request_record.request.studyContext}, - ), - expected_type=DataEnrichmentReport, - ) - - outcome = journal_module.finish_exception( - store, - prefix, - workflow, - started, - RuntimeError("failure after report commit"), +def test_nonstage_failure_keeps_last_stage_and_resume_address(monkeypatch) -> None: + store, prefix, record = memory_journal("analysis") + journal._start_attempt( + store.zw, prefix, record.workflowRunId, "parameter_tuning", record, [] ) + orchestrator = AgentOrchestrator("test-model") - assert outcome.reportReferences == [reference] - assert load_agent_report(store, reference) == report + def fail(*args, **kwargs): + raise RuntimeError("The selected full-cohort candidate lacks markers") - -def test_orphan_report_recovery_uses_semantic_stage_identity( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - _orchestrator, paused, path = _start_paused_workflow(tmp_path, monkeypatch) - assert paused.workflowRun is not None - workflow_id = paused.workflowRun.workflowRunId - store = DataStore( - str(path), - min_features_per_cell=-1, - mito_pattern="", - ribo_pattern="", - zarr_mode="r+", - ) - started = WorkflowStageAttempt( - workflowRunId=workflow_id, - stage="data_enrichment", - attemptId="crash-attempt-one", - status="started", - startedAtNs=1, - requestSha256="1" * 64, - configSha256="2" * 64, - inputs={ - "effectiveContext": "same", - "resumeLineage": { - "resumeId": "first-resume", - "answeredAttempt": WorkflowStageLink.get_example().model_dump( - mode="json" - ), - "questionIds": ["approvePlanChecksum"], - }, - }, - ) - report = DataEnrichmentReport.get_example().model_copy( - update={ - "runInfo": DataEnrichmentReport.get_example().runInfo.model_copy( - update={"runId": uuid.uuid4().hex} - ) - } - ) - persisted_report, reference = journal_module._save_stage_report( - store, - started, - report, - invocation=AgentInvocation( - agentName="data_enrichment", - inputs={"effectiveContext": "same"}, - ), - expected_type=DataEnrichmentReport, - ) - assert persisted_report == report - - retried = started.model_copy( - update={ - "attemptId": "crash-attempt-two", - "startedAtNs": 2, - "inputs": { - "effectiveContext": "same", - "resumeLineage": { - "resumeId": "second-resume", - "answeredAttempt": None, - "questionIds": [], - }, - }, - } - ) - assert journal_module._stage_execution_id(started) == ( - journal_module._stage_execution_id(retried) - ) - changed_context = retried.model_copy( - update={"inputs": {"effectiveContext": "different"}} - ) - assert journal_module._stage_execution_id(started) != ( - journal_module._stage_execution_id(changed_context) + monkeypatch.setattr(orchestrator, "_execute_stages", fail) + result = orchestrator._continue( + store, WorkflowIdentity(record.workflowRunId, "analysis"), record, answers={} ) - recovered = journal_module._recover_persisted_stage_report( - store, - retried, - agent_name="data_enrichment", - expected_type=DataEnrichmentReport, - ) - - assert recovered == (report, reference) - matching = [ - value - for value in list_agent_reports( - store, - workflow_id, - agent_name="data_enrichment", - ) - if value.agentRunId == reference.agentRunId - ] - assert matching == [reference] + assert result.currentStage == "parameter_tuning" + assert result.workflowRunId == record.workflowRunId + assert result.workspace == "analysis" + assert "lacks markers" in result.notes[0] -def test_data_enrichment_recovers_report_after_outcome_write_crash( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - orchestrator, paused, path = _start_paused_workflow(tmp_path, monkeypatch) - assert paused.workflowRun is not None - workflow_id = paused.workflowRun.workflowRunId - store = DataStore( - str(path), - min_features_per_cell=-1, - mito_pattern="", - ribo_pattern="", - zarr_mode="r+", +def test_gene_annotations_are_part_of_completed_request_identity(tmp_path) -> None: + orchestrator, store, record, resume = _saved_workflow(tmp_path / "rna.zarr") + store.get_assay("RNA").feats.insert( + "names", np.array(["GENE9", "RPS3", "GENE1", "GENE2"]), overwrite=True ) - prefix = journal_module._ensure_orchestration_store(store) - request_record = journal_module._read_model( - store.zw, - journal_module._request_key(prefix, workflow_id), - OrchestrationRequestRecord, - ) - assert isinstance(request_record, OrchestrationRequestRecord) - workflow = load_agent_workflow(store, workflow_id) - calls = 0 + with pytest.raises(ValueError, match="data or relevant metadata changed"): + orchestrator.load_request_for_resume(resume) - class CountingAgent: - config = AgentRunConfig() - def run(self, *_args: Any, **_kwargs: Any) -> DataEnrichmentReport: - nonlocal calls - calls += 1 - return DataEnrichmentReport.get_example().model_copy( - update={ - "runInfo": DataEnrichmentReport.get_example().runInfo.model_copy( - update={"runId": uuid.uuid4().hex} - ) - } - ) +def test_provider_configuration_distinguishes_identically_named_models() -> None: + from scarf.agent.orchestrator.main import _model_identity - monkeypatch.setattr( - context_module, - "DataEnrichmentAgent", - lambda *_args, **_kwargs: CountingAgent(), + first = SimpleNamespace( + model_name="rna-model", + system="openai", + settings={"temperature": 0}, + provider=SimpleNamespace(name="server", base_url="https://first.example/v1"), ) - save_outcome = journal_module._save_outcome - crashed = False - - def crash_after_report(*args: Any, **kwargs: Any) -> None: - nonlocal crashed - outcome = args[2] - if outcome.stage == "data_enrichment" and not crashed: - crashed = True - raise KeyboardInterrupt("simulated process crash") - save_outcome(*args, **kwargs) - - monkeypatch.setattr(journal_module, "_save_outcome", crash_after_report) - cell_selection = ArtifactReferenceModel.from_artifact_ref( - store.snapshot_cell_selection("I") + second = SimpleNamespace( + **{ + **vars(first), + "provider": SimpleNamespace( + name="server", base_url="https://second.example/v1" + ), + } ) - with pytest.raises(KeyboardInterrupt, match="simulated process crash"): - orchestrator.data_enrichment_stage( - store, - workflow, - request_record, - [], - cell_selection, - {}, - ) - monkeypatch.setattr(journal_module, "_save_outcome", save_outcome) - - outcome, recovered = orchestrator.data_enrichment_stage( - store, - workflow, - request_record, - [], - cell_selection, - {}, + assert _model_identity(first) != _model_identity(second) + text_only = SimpleNamespace(**vars(first), supports_image_input=False) + assert _model_identity(first) != _model_identity(text_only) + assert _model_identity(text_only) == _model_identity( + SimpleNamespace(**vars(first), profile={"supports_image_input": False}) ) - - assert outcome.status == "done" - assert recovered.status == "done" - assert calls == 1 - assert "recover_persisted_data_enrichment_report" in outcome.actions diff --git a/tests/test_agent_orchestrator_stages.py b/tests/test_agent_orchestrator_stages.py index 99dc8423..1d3ed8d7 100644 --- a/tests/test_agent_orchestrator_stages.py +++ b/tests/test_agent_orchestrator_stages.py @@ -1,6 +1,7 @@ """Context, preprocessing, tuning, integration, and finalization contracts.""" -import json +from tests.agent_examples import example + import uuid from collections.abc import Mapping from pathlib import Path @@ -12,13 +13,9 @@ import scarf.agent.orchestrator.context as context_module import scarf.agent.orchestrator.journal as journal_module -import scarf.agent.orchestrator.tuning as tuning_module +from scarf.agent.parameter_tuning.selection import harmony_acceptance_gate from scarf.agent.orchestrator.preprocessing import PreprocessingStagesMixin from scarf.agent.config import AgentRunConfig -from scarf.agent.config.agent_exec import ( - ImageEvidence, - ImageInputUnsupportedError, -) from scarf.agent.data_enrichment import ( AssayFeatureInspection, DataEnrichmentReport, @@ -34,26 +31,17 @@ ) from scarf.agent.orchestrator import ( AgentOrchestrator, - AssayPreprocessingPlan, - AutomatedPreprocessingPlan, AutomatedWorkflowConfig, AutomatedWorkflowRequest, - WorkflowStageAttempt, ) -from scarf.agent.orchestrator.models import OrchestrationRequestRecord -from scarf.agent.persistence import ( - AgentInvocation, - create_agent_workflow, - load_agent_record, - save_agent_report, +from scarf.agent.orchestrator.models import ( + AutomatedPreprocessingPlan, + WorkflowStageAttempt, ) +from scarf.agent.orchestrator.models import OrchestrationRequestRecord, WorkflowIdentity from scarf.agent.parameter_tuning import ( ArtifactRecord, - IntegrationCandidateEvaluation, - IntegrationMetrics, ParameterCandidateEvaluation, - ParameterTuningReport, - finalize_parameter_tuning_selection, ) from scarf.agent.cell_quality.profiles import RegisteredCellQcProfile from scarf.agent.types import ( @@ -70,62 +58,17 @@ from tests.agent_orchestrator_store import create_store -def test_analysis_review_retries_with_numeric_evidence_when_images_are_unsupported( - monkeypatch: pytest.MonkeyPatch, -) -> None: - selected = ParameterCandidateEvaluation.get_example() - alternative_id = "alternative" - alternative = selected.model_copy( - update={ - "candidateId": alternative_id, - "parameters": selected.parameters.model_copy( - update={ - "candidateId": alternative_id, - "leidenResolution": 0.5, - } - ), - } - ) - prompts: list[object] = [] - - def run_review(**kwargs: Any) -> SimpleNamespace: - user_prompt = kwargs["user_prompt"] - prompts.append(user_prompt) - if not isinstance(user_prompt, str): - raise ImageInputUnsupportedError( - "The configured model does not accept image input" - ) - payload = json.loads(user_prompt) - assert payload["evidenceMode"] == "numeric" - assert payload["selectedCandidate"]["candidateId"] == selected.candidateId - assert payload["comparisonCandidates"][0]["candidateId"] == alternative_id - return SimpleNamespace( - output=tuning_module.AnalysisVisualAdjudication( - status="acceptable", - selectedCandidateId=selected.candidateId, - rationale="The supplied numeric evidence supports the selection.", - ) - ) +_PLAN_CHECKSUM = "a" * 64 - monkeypatch.setattr(tuning_module, "run_agent_sync", run_review) - review, mode = tuning_module._run_analysis_adjudication( - model=object(), - config=AutomatedWorkflowConfig(), - study_objective="Discover stable populations.", - selected=selected, - candidates=[selected, alternative], - visual_content=[ - ImageEvidence(identifier="diagnostic", data=b"png"), - ], +def _save_input_evidence(store, workflow, request, enrichment): + prefix = journal_module._ensure_orchestration_store(store) + started = journal_module._start_attempt( + store.zw, prefix, workflow.workflowRunId, "data_enrichment", request, [] ) - - assert review.status == "acceptable" - assert mode == "numeric" - assert len(prompts) == 2 - - -_PLAN_CHECKSUM = "a" * 64 + return journal_module._save_stage_report( + store, started, enrichment, expected_type=DataEnrichmentReport + )[1] def _cell_selection_model() -> ArtifactReferenceModel: @@ -250,11 +193,13 @@ def _planning_inputs( analysisAssays=analysis_assays or [], ) request_record = OrchestrationRequestRecord( + inputIdentity={}, + modelIdentity="test-model", workflowRunId="planning-test", request=request, config=config or AutomatedWorkflowConfig(), ) - experimental = ExperimentalContextResult.get_example() + experimental = example(ExperimentalContextResult) ingest_outcome = WorkflowStageAttempt( workflowRunId="planning-test", stage="ingest", @@ -270,7 +215,7 @@ def _planning_inputs( enrichment, experimental, ingest_outcome, - CellQcPlan.get_example(), + example(CellQcPlan), ) @@ -282,111 +227,17 @@ def _build_plan( return AgentOrchestrator(object()).build_preprocessing_plan(*inputs) -def _native_assay_report(assay: str, token: int) -> ParameterTuningReport: - evaluation = ParameterCandidateEvaluation.get_example().model_copy( - update={ - "artifacts": { - "neighbors": ArtifactRecord( - assay=assay, - kind="neighbors", - artifactId=f"{token + 2:064x}", - ), - "connectivityMap": ArtifactRecord( - assay=assay, - kind="connectivity_map", - artifactId=f"{token:064x}", - ), - "clusters": ArtifactRecord( - assay=assay, - kind="cluster_labels", - artifactId=f"{token + 1:064x}", - ), - }, - "cellSelection": _cell_selection_model(), - "clusterColumn": f"{assay}_agent_clusters", - "evidenceIds": ["candidate:baseline:clusters"], - } - ) - return ParameterTuningReport( - status="done", - fromAssay=assay, - cellSelection=_cell_selection_model(), - evaluations=[evaluation], - recommendedCandidateId=evaluation.candidateId, - selectedArtifacts=dict(evaluation.artifacts), - evidenceIds=list(evaluation.evidenceIds), - stopReason="The bounded screen completed.", - ) - - -def _native_batch_report(*assays: str) -> ParameterTuningReport: - reports = { - assay: _native_assay_report(assay, index * 10 + 1) - for index, assay in enumerate(assays) - } - primary = reports[assays[0]] - return ParameterTuningReport( - status="done", - fromAssay=assays[0], - cellSelection=_cell_selection_model(), - evaluations=list(primary.evaluations), - recommendedCandidateId=primary.recommendedCandidateId, - selectedArtifacts=dict(primary.selectedArtifacts), - assayReports=reports, - recommendedByAssay={ - assay: report.recommendedCandidateId or "" - for assay, report in reports.items() - }, - totalCandidates=sum(len(report.evaluations) for report in reports.values()), - graphAssay=assays[0], - markerAssay=assays[0], - runInfo=AgentRunInfo( - agentName="parameter_tuning", - runId=uuid.uuid4().hex, - ), - ) - - -def _eligible_integration() -> IntegrationCandidateEvaluation: - return IntegrationCandidateEvaluation( - integrationId="wnn_resolution_1", - method="wnn", - assays=["RNA", "ADT"], - status="done", - eligible=True, - cellSelection=_cell_selection_model(), - resolution=1.0, - graphArtifact=ArtifactRecord( - scope="datastore", - kind="integrated_graph", - artifactId="8" * 64, - ), - clusterArtifact=ArtifactRecord( - scope="datastore", - kind="cluster_labels", - artifactId="9" * 64, - ), - clusterColumn="agent_wnn_cluster", - metrics=IntegrationMetrics( - nClusters=2, - minClusterCells=20, - modalityWeightsValid=True, - ), - evidenceIds=["integration:wnn_resolution_1:clusters"], - ) - - def test_unsafe_experimental_context_pauses_and_explicit_skip_reuses_evidence( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: path = create_store(tmp_path / "unsafe-context.zarr") store = DataStore(str(path), default_assay="RNA", min_features_per_cell=0) - workflow = create_agent_workflow(store, workflow_run_id="unsafe-context") + workflow = WorkflowIdentity("unsafe-context") cell_selection = ArtifactReferenceModel.from_artifact_ref( store.snapshot_cell_selection("I") ) - enrichment = DataEnrichmentReport.get_example().model_copy( + enrichment = example(DataEnrichmentReport).model_copy( update={ "runInfo": AgentRunInfo( agentName="data_enrichment", @@ -394,16 +245,9 @@ def test_unsafe_experimental_context_pauses_and_explicit_skip_reuses_evidence( ) } ) - enrichment_reference = save_agent_report( - store, - workflow.workflowRunId, - enrichment, - invocation=AgentInvocation( - agentName="data_enrichment", - inputs={"studyContext": "A deliberately confounded study."}, - ), - ) request_record = OrchestrationRequestRecord( + inputIdentity={}, + modelIdentity="test-model", workflowRunId=workflow.workflowRunId, request=AutomatedWorkflowRequest( sourcePath=str(path), @@ -412,24 +256,27 @@ def test_unsafe_experimental_context_pauses_and_explicit_skip_reuses_evidence( studyObjective="Preserve treatment while discovering populations.", ), ) - example = ExperimentalContextResult.get_example() + enrichment_reference = _save_input_evidence( + store, workflow, request_record, enrichment + ) + sample_context = example(ExperimentalContextResult) evidence_id = "batchEstimability:treatment:batch" - unsafe_plan = example.decision.batchCorrection.model_copy( + unsafe_plan = sample_context.decision.batchCorrection.model_copy( update={ "action": "unsafe", "evidenceIds": [evidence_id], } ) - unsafe_report = example.model_copy( + unsafe_report = sample_context.model_copy( update={ "cellSelection": cell_selection, "qualityMetricArtifacts": [], "htoIdentityArtifacts": [], - "decision": example.decision.model_copy( + "decision": sample_context.decision.model_copy( update={"batchCorrection": unsafe_plan} ), "batchSafety": [ - BatchSafetyEvidence.get_example().model_copy( + example(BatchSafetyEvidence).model_copy( update={ "status": "unsafe", "estimability": { @@ -525,11 +372,11 @@ def test_explicit_no_inference_skip_resolves_context_without_provider_rerun( ) -> None: path = create_store(tmp_path / "no-inference-context.zarr") store = DataStore(str(path), default_assay="RNA", min_features_per_cell=0) - workflow = create_agent_workflow(store, workflow_run_id="no-inference-context") + workflow = WorkflowIdentity("no-inference-context") cell_selection = ArtifactReferenceModel.from_artifact_ref( store.snapshot_cell_selection("I") ) - enrichment = DataEnrichmentReport.get_example().model_copy( + enrichment = example(DataEnrichmentReport).model_copy( update={ "runInfo": AgentRunInfo( agentName="data_enrichment", @@ -537,16 +384,9 @@ def test_explicit_no_inference_skip_resolves_context_without_provider_rerun( ) } ) - enrichment_reference = save_agent_report( - store, - workflow.workflowRunId, - enrichment, - invocation=AgentInvocation( - agentName="data_enrichment", - inputs={"studyContext": "A study with unresolved replication."}, - ), - ) request_record = OrchestrationRequestRecord( + inputIdentity={}, + modelIdentity="test-model", workflowRunId=workflow.workflowRunId, request=AutomatedWorkflowRequest( sourcePath=str(path), @@ -555,8 +395,11 @@ def test_explicit_no_inference_skip_resolves_context_without_provider_rerun( studyObjective="Discover stable RNA populations.", ), ) - example = ExperimentalContextResult.get_example() - needs_input_plan = example.decision.batchCorrection.model_copy( + enrichment_reference = _save_input_evidence( + store, workflow, request_record, enrichment + ) + sample_context = example(ExperimentalContextResult) + needs_input_plan = sample_context.decision.batchCorrection.model_copy( update={ "action": "needsInput", "batchColumns": [], @@ -564,7 +407,7 @@ def test_explicit_no_inference_skip_resolves_context_without_provider_rerun( "metricsRequired": [], } ) - needs_input_report = example.model_copy( + needs_input_report = sample_context.model_copy( update={ "status": "needsInput", "cellSelection": cell_selection, @@ -573,7 +416,7 @@ def test_explicit_no_inference_skip_resolves_context_without_provider_rerun( "qualityMetricArtifacts": [], "htoIdentityColumns": [], "htoIdentityArtifacts": [], - "decision": example.decision.model_copy( + "decision": sample_context.decision.model_copy( update={ "batchCorrection": needs_input_plan, "cellQc": CellQcPlan(), @@ -647,22 +490,10 @@ def run(self, *_args: Any, **_kwargs: Any) -> ExperimentalContextResult: assert resolved_report.runInfo.runId == "" assert resolved_report.runInfo.usage.requests == 0 assert NeedsInputAgent.calls == 1 - paused_record = load_agent_record( - store, - paused_outcome.reportReferences[0], - ) - resolved_record = load_agent_record( - store, - resolved_outcome.reportReferences[0], - ) - assert resolved_record.invocation.artifacts == resolved_outcome.artifacts - assert resolved_record.invocation.runConfig == paused_record.invocation.runConfig - assert resolved_record.invocation.inputs["deterministicResolution"] == ( - "resolve_experimental_context:no_inference_skip_harmony" - ) - assert resolved_record.invocation.parentReports[-1].agentRunId == ( - paused_outcome.reportReferences[0].agentRunId - ) + assert journal_module.read_stage_evidence( + store, resolved_outcome.reportReferences[0] + ) == resolved_report.model_dump(mode="json") + assert paused_outcome.reportReferences[0] != resolved_outcome.reportReferences[0] def test_qc_profile_safety_rejects_self_normalizing_failed_captures() -> None: @@ -784,8 +615,10 @@ def test_percent_features_follow_deterministic_inspection_not_policy_lists( store.snapshot_cell_selection("I") ) assert "RNA_percentMito" not in store.cells.columns - workflow = create_agent_workflow(store, workflow_run_id="inspected-families") + workflow = WorkflowIdentity("inspected-families") request_record = OrchestrationRequestRecord( + inputIdentity={}, + modelIdentity="test-model", workflowRunId=workflow.workflowRunId, request=AutomatedWorkflowRequest( sourcePath=str(path), @@ -817,7 +650,7 @@ def test_percent_features_follow_deterministic_inspection_not_policy_lists( ) orchestrator = AgentOrchestrator(object()) - outcome = orchestrator._hto_stage( + outcome = orchestrator._rna_quality_metrics_stage( store, workflow, request_record, @@ -846,6 +679,34 @@ def test_percent_features_follow_deterministic_inspection_not_policy_lists( assert operation["features"]["kind"] == "feature_selection" assert operation["artifact"] == metric_source.artifact.model_dump(mode="json") + from scarf.agent.cell_quality.profiles import project_auto_filter_profile + from scarf.metadata.selection import resolve_cell_aligned_artifact + + mito_values = np.asarray( + resolve_cell_aligned_artifact( + store.zw, + ArtifactRef( + scope="assay", + assay="RNA", + kind="quality_metric", + artifact_id=metric_source.artifact.artifactId, + ), + cell_selection=ArtifactRef( + scope="datastore", + kind="cell_selection", + artifact_id=cell_selection.artifactId, + ), + expected_kind="quality_metric", + ).values + ) + projection = project_auto_filter_profile( + "globalGaussian", + values_by_metric={ + "RNA_nCounts": store.cells.fetch("RNA_nCounts"), + metric_source.name: mito_values, + }, + active=np.ones(len(mito_values), dtype=bool), + ) profile = CellQcProfileEvidence( profileId="cellQc:RNA:RNA:globalGaussian:0.01:0.99", action="globalGaussian", @@ -853,10 +714,12 @@ def test_percent_features_follow_deterministic_inspection_not_policy_lists( driverAssayType="RNA", attributes=["RNA_nCounts"], artifactMetrics=[metric_source], - parameters={"minP": 0.01, "maxP": 0.99}, - activeCells=4, - retainedCells=2, - retainedFraction=0.5, + parameters=projection.parameters, + resolvedBounds=projection.parameters["resolvedBounds"], + flaggedCells=projection.flagCounts, + activeCells=len(mito_values), + retainedCells=projection.retainedCells, + retainedFraction=projection.retainedCells / len(mito_values), evidenceId="qcProfile:artifact-backed-global", ) plan = CellQcPlan( @@ -922,8 +785,10 @@ def test_hto_processing_is_not_executed_by_rna_workflow( cell_selection = ArtifactReferenceModel.from_artifact_ref( store.snapshot_cell_selection("I") ) - workflow = create_agent_workflow(store, workflow_run_id="hto-once") + workflow = WorkflowIdentity("hto-once") request_record = OrchestrationRequestRecord( + inputIdentity={}, + modelIdentity="test-model", workflowRunId=workflow.workflowRunId, request=AutomatedWorkflowRequest( sourcePath=str(path), @@ -985,13 +850,13 @@ def run_hto( orchestrator = AgentOrchestrator(object()) with pytest.raises(ValueError, match="only the selected RNA assay"): - orchestrator._hto_stage( + orchestrator._rna_quality_metrics_stage( store, workflow, request_record, [], enrichment, cell_selection ) enrichment = DataEnrichmentReport( status="done", policies=[_modality_policy("RNA", "RNA")] ) - first = orchestrator._hto_stage( + first = orchestrator._rna_quality_metrics_stage( store, workflow, request_record, @@ -999,7 +864,7 @@ def run_hto( enrichment, cell_selection, ) - second = orchestrator._hto_stage( + second = orchestrator._rna_quality_metrics_stage( store, workflow, request_record, @@ -1015,7 +880,11 @@ def run_hto( assert all(ref.kind != "hto_identity" for ref in first.artifacts.values()) -def test_selected_sample_mad_qc_passes_exact_artifact_sources() -> None: +def test_selected_sample_mad_qc_passes_exact_artifact_sources( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from scarf.agent.orchestrator import preprocessing as preprocessing_module + cell_selection = ArtifactReferenceModel( scope="datastore", kind="cell_selection", @@ -1047,7 +916,8 @@ def test_selected_sample_mad_qc_passes_exact_artifact_sources() -> None: sampleArtifact=sample, attributes=["RNA_nCounts"], artifactMetrics=[metric], - parameters={"nMads": 4.0, "minCellsPerSample": 12}, + parameters={"nMads": 3.0, "minCellsPerSample": 20}, + resolvedBounds={"capture-a": {"RNA_nCounts": {"low": 2.0, "high": 200.0}}}, activeCells=100, retainedCells=95, retainedFraction=0.95, @@ -1079,15 +949,17 @@ def test_selected_sample_mad_qc_passes_exact_artifact_sources() -> None: ) captured: dict[str, Any] = {} - class Store: - def auto_filter_cells(self, **kwargs: Any) -> ArtifactRef: - captured.update(kwargs) - return output_selection + def execute(_store: Any, action: str, **kwargs: Any): + assert action == "sampleMad" + captured.update(kwargs) + return output_selection, None + + monkeypatch.setattr(preprocessing_module, "execute_auto_cell_qc", execute) actions: list[str] = [] operations: list[dict[str, Any]] = [] result = AgentOrchestrator(object()).apply_cell_qc( - Store(), + object(), experimental, ArtifactRef( scope="datastore", @@ -1109,32 +981,14 @@ def auto_filter_cells(self, **kwargs: Any) -> ArtifactRef: assert captured["sample_artifact"].artifact.artifact_id == ( sample.artifact.artifactId ) - assert captured["n_mads"] == 4.0 - assert captured["min_cells_per_sample"] == 12 + assert captured["profile_parameters"] == profile.parameters + assert captured["expected_resolved_bounds"] == profile.resolvedBounds + assert captured["expected_active_cells"] == 100 + assert captured["expected_retained_cells"] == 95 assert operations[0]["sampleArtifact"] == sample.model_dump(mode="json") assert operations[0]["artifactMetrics"] == [metric.model_dump(mode="json")] -def test_parameter_tuning_rejects_more_than_one_refinement_candidate() -> None: - with pytest.raises(ValueError, match="less than or equal to 1"): - AutomatedWorkflowConfig(maxRefinedCandidatesPerAssay=2) - - -def test_integration_evaluations_contribute_to_final_candidate_count() -> None: - report = _native_batch_report("RNA", "ADT") - integration = _eligible_integration() - - finalized = finalize_parameter_tuning_selection( - report, - marker_assay="RNA", - integration_evaluations=[integration], - native_assay="RNA", - ) - - assert report.totalCandidates == 2 - assert finalized.totalCandidates == 3 - - def test_capture_cell_selections_are_exact_and_idempotent(tmp_path: Path) -> None: path = create_store(tmp_path / "capture-selections.zarr") store = DataStore( @@ -1171,75 +1025,6 @@ def test_capture_cell_selections_are_exact_and_idempotent(tmp_path: Path) -> Non assert selected_a.tolist() == [True, True, False, False] -def test_exact_feature_exclusion_covers_all_supported_families() -> None: - class Features: - N = 4 - - @staticmethod - def fetch_all(column: str) -> np.ndarray: - values = { - "ids": np.asarray(["MT-CO1", "RPS3", "HIST1H1", "gene4"]), - "names": np.asarray(["mito", "ribo", "histone", "GENE4"]), - } - return values[column] - - class Store: - def __init__(self) -> None: - self.mask: np.ndarray | None = None - - @staticmethod - def get_assay(_assay: str) -> Any: - return SimpleNamespace(feats=Features()) - - @staticmethod - def load_artifact(_source: ArtifactRef) -> dict[str, np.ndarray]: - return {"values": np.ones(4, dtype=bool)} - - def set_feature_selection( - self, *, from_assay: str, mask: np.ndarray, **_kwargs: Any - ) -> ArtifactRef: - self.mask = mask.copy() - return ArtifactRef( - scope="assay", - assay=from_assay, - kind="feature_selection", - artifact_id="6" * 64, - ) - - plan = AssayPreprocessingPlan( - assay="RNA", - assayType="RNA", - role="graph", - graphEligible=True, - markerEligible=True, - featureMethod="hvg", - reductionMethod="pca", - featureParameters={ - "excludeFamilies": ["mitochondrial", "ribosomal", "histone"] - }, - ) - orchestrator = AgentOrchestrator(object()) - blacklist = orchestrator.rna_blacklist(plan) - assert all(token in blacklist for token in ("MT-", "RPS", "HIST")) - source = ArtifactRef( - scope="assay", - assay="RNA", - kind="feature_selection", - artifact_id="7" * 64, - ) - store = Store() - result = orchestrator.exclude_exact_features(store, plan, source) - assert result.kind == "feature_selection" - assert store.mask is not None - np.testing.assert_array_equal(store.mask, [False, False, False, True]) - with pytest.raises(ValueError, match="removed every feature"): - orchestrator.exclude_exact_features( - store, - plan.model_copy(update={"exactExcludedFeatures": ["gene4"]}), - source, - ) - - def test_cell_qc_artifact_and_execution_validation_edges() -> None: metric = NamedArtifactSource( name="metric", @@ -1405,7 +1190,7 @@ def report( invalid_global_profile = global_profile.model_copy( update={"sampleColumn": "sample"} ) - with pytest.raises(ValueError, match="cannot include a sample"): + with pytest.raises(ValueError, match="cannot use a core sample source"): orchestrator.apply_cell_qc( object(), report(invalid_global_plan, invalid_global_profile, quality=[metric]), @@ -1498,7 +1283,7 @@ def run_leiden_clustering( artifact_id="5" * 64, ) - base = ParameterCandidateEvaluation.get_example() + base = example(ParameterCandidateEvaluation) native = base.model_copy( update={ "candidateId": "native", @@ -1562,7 +1347,7 @@ def test_harmony_acceptance_requires_improvement_without_biological_loss( marker_coherence: float, expected: bool, ) -> None: - base = ParameterCandidateEvaluation.get_example() + base = example(ParameterCandidateEvaluation) native_parameters = base.parameters.model_copy( update={"candidateId": "native", "useHarmony": False} ) @@ -1610,7 +1395,7 @@ def test_harmony_acceptance_requires_improvement_without_biological_loss( } ) - accepted, reasons = tuning_module.harmony_acceptance_gate( + accepted, reasons = harmony_acceptance_gate( native, harmony, batch_columns=["batch"], diff --git a/tests/test_agent_parameter_tuning.py b/tests/test_agent_parameter_tuning.py index 0fcbc893..622bd957 100644 --- a/tests/test_agent_parameter_tuning.py +++ b/tests/test_agent_parameter_tuning.py @@ -1,5 +1,7 @@ """Tests for bounded parameter tuning agent execution.""" +from tests.agent_examples import example + import asyncio from types import SimpleNamespace from typing import Any @@ -336,7 +338,7 @@ def _dependencies( max_candidates: int = 5, min_cluster_cells: int = 20, ) -> ParameterTuningDependencies: - candidate_values = candidates or [ParameterCandidate.get_example()] + candidate_values = candidates or [example(ParameterCandidate)] return ParameterTuningDependencies( store=store, normalized=store.normalized, @@ -387,7 +389,7 @@ def test_parameter_models_have_blank_and_example( model_type: type[AgentDataModel], ) -> None: assert isinstance(model_type.get_blank(), model_type) - assert isinstance(model_type.get_example(), model_type) + assert isinstance(example(model_type), model_type) assert all("_" not in field for field in model_type.model_fields) @@ -435,7 +437,7 @@ def test_harmony_pairing_covers_every_initial_candidate() -> None: def test_prompts_include_only_explicit_candidate_context() -> None: - evaluation = ParameterCandidateEvaluation.get_example() + evaluation = example(ParameterCandidateEvaluation) plan = ParameterSearchPlan(status="complete") cell_selection = ArtifactReferenceModel.from_artifact_ref(_cell_selection()) planning_system_prompt = parameter_search_system_prompt() @@ -716,7 +718,7 @@ def test_duplicate_candidate_returns_recorded_execution_without_rerun() -> None: def test_candidate_budget_prevents_another_execution() -> None: store = _FakeStore() candidates = [ - ParameterCandidate.get_example(), + example(ParameterCandidate), ParameterCandidate(candidateId="pca_15", dimensions=15), ] deps = _dependencies(store, candidates=candidates, max_candidates=1) @@ -733,7 +735,7 @@ def test_candidate_budget_prevents_another_execution() -> None: def test_refinement_plan_is_bounded_by_initial_evidence_and_envelope() -> None: store = _FakeStore() candidates = [ - ParameterCandidate.get_example(), + example(ParameterCandidate), ParameterCandidate(candidateId="pca_15", dimensions=15), ] deps = _dependencies(store, candidates=candidates, max_candidates=3) @@ -770,7 +772,7 @@ def test_refinement_plan_is_bounded_by_initial_evidence_and_envelope() -> None: def test_refinement_plan_canonicalizes_status_from_candidate_presence() -> None: store = _FakeStore() candidates = [ - ParameterCandidate.get_example(), + example(ParameterCandidate), ParameterCandidate(candidateId="pca_15", dimensions=15), ] deps = _dependencies(store, candidates=candidates, max_candidates=3) @@ -816,7 +818,7 @@ def test_refinement_plan_canonicalizes_status_from_candidate_presence() -> None: def test_refinement_plan_requires_authorized_matched_harmony_evidence() -> None: store = _FakeStore() candidates = [ - ParameterCandidate.get_example(), + example(ParameterCandidate), ParameterCandidate(candidateId="pca_15", dimensions=15), ] deps = _dependencies(store, candidates=candidates, max_candidates=3) @@ -917,7 +919,7 @@ def test_report_validation_uses_only_executed_results_and_artifacts() -> None: status="done", evaluations=[ParameterCandidateEvaluation.get_blank()], recommendedCandidateId="baseline", - selectedArtifacts={"invented": ArtifactRecord.get_example()}, + selectedArtifacts={"invented": example(ArtifactRecord)}, confidence="medium", rationale="Balanced candidate.", evidenceIds=[evaluation.evidenceIds[0]], @@ -992,7 +994,7 @@ def test_report_validation_rejects_unknown_evidence_and_ineligible_choice() -> N def test_done_report_requires_a_successful_comparator_when_available() -> None: store = _FakeStore() candidates = [ - ParameterCandidate.get_example(), + example(ParameterCandidate), ParameterCandidate(candidateId="pca_15", dimensions=15), ] deps = _dependencies(store, candidates=candidates, max_candidates=2) @@ -1033,7 +1035,7 @@ def test_done_report_requires_a_successful_comparator_when_available() -> None: def test_comparison_requires_selected_and_comparator_evidence() -> None: store = _FakeStore() candidates = [ - ParameterCandidate.get_example(), + example(ParameterCandidate), ParameterCandidate(candidateId="pca_15", dimensions=15), ] deps = _dependencies(store, candidates=candidates, max_candidates=2) @@ -1069,7 +1071,7 @@ def test_comparison_requires_selected_and_comparator_evidence() -> None: def test_comparison_rejects_unknown_or_duplicate_candidate_ids() -> None: store = _FakeStore() candidates = [ - ParameterCandidate.get_example(), + example(ParameterCandidate), ParameterCandidate(candidateId="pca_15", dimensions=15), ] deps = _dependencies(store, candidates=candidates, max_candidates=2) @@ -1211,7 +1213,7 @@ def test_biology_handoff_requires_selected_cluster_artifact() -> None: def test_integrated_final_selection_separates_graph_and_marker_assays() -> None: - native = ParameterTuningReport.get_example() + native = example(ParameterTuningReport) aggregate = ParameterTuningReport( status="done", fromAssay="RNA", @@ -1279,7 +1281,7 @@ def test_integrated_final_selection_requires_marker_assay() -> None: def test_final_graph_selector_uses_one_grounded_provider_request() -> None: - native_evaluation = ParameterCandidateEvaluation.get_example() + native_evaluation = example(ParameterCandidateEvaluation) native_evaluation.artifacts["clusters"] = ArtifactRecord( assay="RNA", kind="cluster_labels", @@ -1461,7 +1463,7 @@ async def reply( result = ParameterTuningAgent(FunctionModel(reply)).run( _FakeStore(), normalized=_artifact("normalized", 1), - candidates=[ParameterCandidate.get_example()], + candidates=[example(ParameterCandidate)], experimental_handoff=ExperimentalTuningHandoff( cellSelection=ArtifactReferenceModel.from_artifact_ref(_cell_selection()), batchAction="skip", @@ -1726,12 +1728,12 @@ async def reply( assays=[ ParameterTuningAssayInput( normalized=_artifact("normalized", 1, "RNA"), - candidates=[ParameterCandidate.get_example()], + candidates=[example(ParameterCandidate)], maxCandidates=1, ), ParameterTuningAssayInput( normalized=_artifact("normalized", 10, "ADT"), - candidates=[ParameterCandidate.get_example()], + candidates=[example(ParameterCandidate)], maxCandidates=1, ), ], @@ -1794,7 +1796,7 @@ async def reply( assays=[ ParameterTuningAssayInput( normalized=_artifact("normalized", 1), - candidates=[ParameterCandidate.get_example()], + candidates=[example(ParameterCandidate)], maxCandidates=2, maxRefinedCandidates=1, ) @@ -1834,7 +1836,7 @@ def unavailable_structured_output(**kwargs: Any) -> None: ParameterTuningAssayInput( normalized=_artifact("normalized", 1), candidates=[ - ParameterCandidate.get_example(), + example(ParameterCandidate), ParameterCandidate(candidateId="pca_15", dimensions=15), ], maxCandidates=3, @@ -1870,7 +1872,7 @@ def unavailable_structured_output(**_kwargs: Any) -> None: model=object(), normalized=_artifact("normalized", 1), candidates=[ - ParameterCandidate.get_example(), + example(ParameterCandidate), ParameterCandidate(candidateId="pca_15", dimensions=15), ], max_candidates=3, @@ -1887,7 +1889,7 @@ def unavailable_structured_output(**_kwargs: Any) -> None: def test_pending_parameter_report_does_not_select_without_successful_baseline() -> None: candidates = [ - ParameterCandidate.get_example(), + example(ParameterCandidate), ParameterCandidate(candidateId="pca_15", dimensions=15), ParameterCandidate(candidateId="pca_30", dimensions=30), ] @@ -1919,7 +1921,7 @@ def test_pending_parameter_report_does_not_select_without_successful_baseline() def test_parameter_prompt_payload_is_bounded_and_excludes_artifacts() -> None: - evaluation = ParameterCandidateEvaluation.get_example().model_copy( + evaluation = example(ParameterCandidateEvaluation).model_copy( update={ "warnings": ["w" * 700 for _index in range(12)], "error": "e" * 700, @@ -1936,7 +1938,7 @@ def test_parameter_prompt_payload_is_bounded_and_excludes_artifacts() -> None: def test_single_eligible_final_graph_skips_provider_selection() -> None: - evaluation = ParameterCandidateEvaluation.get_example() + evaluation = example(ParameterCandidateEvaluation) evaluation.artifacts["clusters"] = ArtifactRecord( assay="RNA", kind="cluster_labels", @@ -1980,7 +1982,7 @@ def test_final_graph_retry_exhaustion_pauses_when_multiple_options_exist( reports: dict[str, ParameterTuningReport] = {} for assay, token in (("RNA", "7"), ("ADT", "8")): - evaluation = ParameterCandidateEvaluation.get_example().model_copy( + evaluation = example(ParameterCandidateEvaluation).model_copy( update={ "clusterColumn": f"{assay}_agent_tuning_baseline", "artifacts": { @@ -2056,7 +2058,7 @@ def test_batched_tuning_enforces_global_candidate_limit_before_execution() -> No assays = [ ParameterTuningAssayInput( normalized=_artifact("normalized", 1, assay), - candidates=[ParameterCandidate.get_example()], + candidates=[example(ParameterCandidate)], maxCandidates=1, ) for assay in ("RNA", "ADT") @@ -2074,7 +2076,7 @@ def test_batched_tuning_enforces_global_candidate_limit_before_execution() -> No def test_parameter_tuning_handoff_validation_edges() -> None: - report = ParameterTuningReport.get_example() + report = example(ParameterTuningReport) with pytest.raises(ValueError, match="must be done"): report.model_copy(update={"status": "failed"}).to_biological_handoff() with pytest.raises(ValueError, match="must recommend"): @@ -2142,7 +2144,7 @@ def test_parameter_tuning_handoff_validation_edges() -> None: def test_final_graph_option_filtering_and_validation_edges() -> None: - report = ParameterTuningReport.get_example() + report = example(ParameterTuningReport) with pytest.raises(ValueError, match="lacks an exact cell selection"): parameter_tuning_selection.final_graph_options( report.model_copy(update={"cellSelection": None}), @@ -2183,7 +2185,7 @@ def test_final_graph_option_filtering_and_validation_edges() -> None: [], ) - integration = IntegrationCandidateEvaluation.get_example() + integration = example(IntegrationCandidateEvaluation) for ignored in ( integration.model_copy(update={"status": "failed"}), integration.model_copy(update={"graphArtifact": None}), @@ -2298,7 +2300,7 @@ def metric_graph_connectivity(self, *_args: Any, **_kwargs: Any) -> float: assert invalid.status == "failed" assert message in (invalid.error or "") - harmony = ParameterCandidate.get_example().model_copy( + harmony = example(ParameterCandidate).model_copy( update={"candidateId": "baseline_harmony", "useHarmony": True} ) harmony_deps = _dependencies(_FakeStore(), candidates=[harmony]) @@ -2310,7 +2312,7 @@ def metric_graph_connectivity(self, *_args: Any, **_kwargs: Any) -> float: def test_parameter_search_plan_validation_edges() -> None: candidates = [ - ParameterCandidate.get_example(), + example(ParameterCandidate), ParameterCandidate(candidateId="pca_15", dimensions=15), ] deps = _dependencies(_FakeStore(), candidates=candidates, max_candidates=4) @@ -2537,8 +2539,8 @@ def test_parameter_tuning_report_validation_status_edges() -> None: def test_final_graph_selection_validation_edges() -> None: - report = ParameterTuningReport.get_example() - integration = IntegrationCandidateEvaluation.get_example() + report = example(ParameterTuningReport) + integration = example(IntegrationCandidateEvaluation) native_evidence = "native:RNA:candidate:baseline:clusters" integration_evidence = integration.evidenceIds[0] comparison = FinalGraphComparison( @@ -2743,7 +2745,7 @@ def test_experimental_tuning_handoff_resolution_edges() -> None: def test_prepare_parameter_tuning_dependencies_validation_edges() -> None: store = _FakeStore() normalized = store.normalized - candidate = ParameterCandidate.get_example() + candidate = example(ParameterCandidate) for kwargs, message in ( ({"max_candidates": 0}, "max_candidates"), diff --git a/tests/test_agent_population_support.py b/tests/test_agent_population_support.py new file mode 100644 index 00000000..ad51b8e3 --- /dev/null +++ b/tests/test_agent_population_support.py @@ -0,0 +1,249 @@ +"""Population support uses frozen cells, bounded reads and observed study units.""" + +from collections import Counter +from types import SimpleNamespace +from typing import Any + +import numpy as np +import pytest + +from scarf.agent.parameter_tuning import diagnostics +from scarf.agent.parameter_tuning.contracts import ParameterCandidateEvaluation +from scarf.storage.refs import ArtifactRef +from tests.agent_examples import example + + +class _BoundedArray: + def __init__(self, values: np.ndarray) -> None: + self.values = values + self.shape, self.dtype = values.shape, values.dtype + self.reads: list[int] = [] + + def __getitem__(self, key: Any) -> np.ndarray: + value = self.values[key] + assert value.size <= 65_536 + self.reads.append(value.size) + return value + + +class _Metadata: + def __init__(self, values: dict[str, np.ndarray]) -> None: + self.arrays = {column: _BoundedArray(value) for column, value in values.items()} + self.columns = [*values, "author_cell_type"] + self.missing: dict[str, _BoundedArray] = {} + self.accesses: list[str] = [] + + def _get_array(self, column: str) -> _BoundedArray: + assert column != "author_cell_type" + self.accesses.append(column) + return self.arrays[column] + + def _get_missing_mask_array(self, column: str) -> _BoundedArray | None: + return self.missing.get(column) + + +def _setup( + monkeypatch: pytest.MonkeyPatch, + labels: np.ndarray, + metadata: dict[str, np.ndarray], + rows: np.ndarray | None = None, +) -> tuple[Any, ParameterCandidateEvaluation, _BoundedArray]: + evaluation = example(ParameterCandidateEvaluation) + selection = ArtifactRef( + scope="datastore", kind="cell_selection", artifact_id="c" * 64 + ) + evaluation.cellSelection = evaluation.cellSelection.model_copy( + update={ + "scope": "datastore", + "assay": None, + "kind": "cell_selection", + "artifactId": selection.artifact_id, + } + ) + status = SimpleNamespace( + exists=True, complete=True, inputs={"cell_selection": selection.to_dict()} + ) + calls: Counter[str] = Counter() + array = _BoundedArray(labels) + + def inspect(ref: ArtifactRef) -> Any: + calls["inspect_artifact"] += 1 + return status + + def load(ref: ArtifactRef) -> Any: + calls["load_artifact"] += 1 + return {"values": array} + + store = SimpleNamespace( + zw=object(), + cells=_Metadata(metadata), + inspect_artifact=inspect, + load_artifact=load, + calls=calls, + status=status, + ) + monkeypatch.setattr(diagnostics, "as_zarr_array", lambda value, **kwargs: value) + + def selected(*args: Any, **kwargs: Any) -> np.ndarray: + assert args[1] == selection + return np.arange(len(labels)) if rows is None else rows + + monkeypatch.setattr(diagnostics, "read_stored_selection_indices", selected) + return store, evaluation, array + + +def test_support_exposes_restricted_populations_and_missing_unit_coverage( + monkeypatch: pytest.MonkeyPatch, +) -> None: + rows = np.asarray([1, 3, 4, 6, 8, 9, 10, 11]) + donors = np.full(12, "unused", dtype=object) + captures = donors.copy() + donors[rows] = ["d1", "d1", "d1", "d2", "d2", "d2", "d2", "d3"] + captures[rows] = ["c1", "c1", "c1", "c2", "c3", "c3", "c3", "c4"] + store, evaluation, _ = _setup( + monkeypatch, + np.asarray([1, 1, 1, 1, 2, 2, 2, 3]), + {"donor": donors, "capture": captures}, + rows, + ) + mask = np.zeros(12, dtype=bool) + mask[10] = True + store.cells.missing["donor"] = _BoundedArray(mask) + evidence = diagnostics.population_support_evidence( + store, evaluation, ["donor", "capture"] + ) + donor = evidence["columns"]["donor"] + assert evidence["selectedCells"] == 8 + assert evidence["observedPopulations"] == 3 + assert donor["observedGroups"] == 3 + assert donor["coveredCells"] == 7 + assert donor["missingCells"] == 1 + assert donor["coverageFraction"] == 7 / 8 + assert donor["populations"][0]["cluster"] == "3" + populations = {row["cluster"]: row for row in donor["populations"]} + assert populations["1"]["supportingGroups"] == 2 + assert populations["1"]["largestGroupFraction"] == 3 / 4 + assert populations["1"]["topGroups"][1] == { + "value": "d2", + "valueType": "str", + "cells": 1, + "fractionOfPopulation": 1 / 4, + "fractionOfGroup": 1 / 3, + } + assert populations["2"]["supportingGroups"] == 1 + assert populations["2"]["coverageFraction"] == 2 / 3 + assert populations["2"]["largestGroupFraction"] == 2 / 3 + assert populations["3"]["groupsWithAtLeast5Cells"] == 0 + assert evidence["columns"]["capture"]["observedGroups"] == 4 + assert store.calls == {"inspect_artifact": 1, "load_artifact": 1} + assert set(store.cells.accesses) == {"donor", "capture"} + + +def test_support_reads_large_selected_axes_in_bounded_blocks( + monkeypatch: pytest.MonkeyPatch, +) -> None: + n = 70_000 + labels = np.zeros(n, dtype=np.int32) + labels[-5:] = 1 + groups = np.full(n, "main", dtype=object) + groups[-5:] = "rare" + store, evaluation, array = _setup(monkeypatch, labels, {"donor": groups}) + evidence = diagnostics.population_support_evidence(store, evaluation, ["donor"]) + assert array.reads == [65_536, n - 65_536] + assert store.cells.arrays["donor"].reads == [65_536, n - 65_536] + rare = evidence["columns"]["donor"]["populations"][0] + assert rare["cluster"] == "1" + assert rare["cells"] == 5 + assert rare["supportingGroups"] == rare["groupsWithAtLeast5Cells"] == 1 + assert rare["largestGroupFraction"] == 1.0 + + +def test_group_display_truncation_keeps_exact_counts_and_fractions( + monkeypatch: pytest.MonkeyPatch, +) -> None: + groups = np.asarray([f"d{i}" for i in range(70)]) + store, evaluation, _ = _setup( + monkeypatch, np.ones(70, dtype=int), {"donor": groups} + ) + evidence = diagnostics.population_support_evidence(store, evaluation, ["donor"]) + column = evidence["columns"]["donor"] + population = column["populations"][0] + assert column["observedGroups"] == population["supportingGroups"] == 70 + assert population["omittedGroups"] == population["omittedCells"] == 65 + assert len(population["topGroups"]) == 5 + assert population["largestGroupFraction"] == 1 / 70 + assert ( + sum(row["cells"] for row in population["topGroups"]) + + population["omittedCells"] + == population["cells"] + ) + + +def test_population_and_column_display_omissions_are_explicit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + store, evaluation, _ = _setup( + monkeypatch, np.arange(70), {"donor": np.asarray(["one"] * 70)} + ) + evidence = diagnostics.population_support_evidence( + store, evaluation, ["donor", "absent", "unused"] + ) + column = evidence["columns"]["donor"] + assert evidence["observedPopulations"] == 70 + assert column["omittedPopulations"] == column["omittedPopulationCells"] == 6 + assert len(column["populations"]) == 64 + assert evidence["omittedColumns"] == ["unused"] + assert evidence["columns"]["absent"]["status"] == "unavailable" + assert store.cells.accesses == ["donor"] + + +def test_missing_nonfinite_and_typed_unit_values_are_not_silently_merged( + monkeypatch: pytest.MonkeyPatch, +) -> None: + store, evaluation, _ = _setup( + monkeypatch, + np.ones(7, dtype=int), + { + "donor": np.asarray( + [1, "1", True, None, float("nan"), float("inf"), ""], dtype=object + ) + }, + ) + column = diagnostics.population_support_evidence(store, evaluation, ["donor"])[ + "columns" + ]["donor"] + assert column["observedGroups"] == 3 + assert column["coveredCells"] == 3 + assert column["missingCells"] == 4 + assert {row["valueType"] for row in column["populations"][0]["topGroups"]} == { + "str", + "int", + "bool", + } + + +@pytest.mark.parametrize( + "damage", ["failed", "incomplete", "wrongCells", "shape", "nonfinite", "fractional"] +) +def test_invalid_population_evidence_fails_before_metadata_reads( + monkeypatch: pytest.MonkeyPatch, damage: str +) -> None: + labels = np.asarray([1.0, 2.0]) + store, evaluation, array = _setup( + monkeypatch, labels, {"donor": np.asarray(["a", "b"])} + ) + if damage == "failed": + evaluation.status = "failed" + elif damage == "incomplete": + store.status.complete = False + elif damage == "wrongCells": + store.status.inputs["cell_selection"]["artifact_id"] = "d" * 64 + elif damage == "shape": + array.shape = (3,) + elif damage == "nonfinite": + labels[0] = np.nan + else: + labels[0] = 1.5 + with pytest.raises(ValueError): + diagnostics.population_support_evidence(store, evaluation, ["donor"]) + assert store.cells.accesses == [] diff --git a/tests/test_agent_provider_edges.py b/tests/test_agent_provider_edges.py new file mode 100644 index 00000000..f8ae01e4 --- /dev/null +++ b/tests/test_agent_provider_edges.py @@ -0,0 +1,275 @@ +"""Standalone scientific agent and provider failure edge cases.""" + +from tests.agent_examples import example + +import asyncio +from types import SimpleNamespace + +from scarf.storage.refs import ArtifactRef + +import pytest +from pydantic_ai import ModelRetry, UnexpectedModelBehavior + +import scarf.agent.biological_interpretation.tools as biological_tools +import scarf.agent.biological_interpretation.validation as biological_validation +import scarf.agent.config.agent_exec as agent_exec_module +import scarf.agent.data_enrichment.agent as enrichment_agent +import scarf.agent.data_enrichment.tools as enrichment_tools +import scarf.agent.data_enrichment.validation as enrichment_validation +import scarf.agent.experimental_context.tools as experimental_tools +import scarf.agent.experimental_context.validation as experimental_validation +from scarf.agent.biological_interpretation import ( + BiologicalInterpretationNeedsInput, + BiologicalInterpretationReport, + ClusterCompositionEvidence, + ClusterMarkerEvidence, +) +from scarf.agent.biological_interpretation.contracts import ( + BiologicalInterpretationDependencies, +) +from scarf.agent.data_enrichment import ( + AssayFeatureInspection, + DataEnrichmentAgent, + DataEnrichmentDependencies, + DataEnrichmentToolCall, +) +from scarf.agent.experimental_context import ( + CellQcProfileEvidence, + ExperimentalContextDependencies, +) +from scarf.agent.experimental_context.contracts import CovariateCharacterization + + +def test_data_enrichment_cache_rollback_and_pending_branches( + monkeypatch: pytest.MonkeyPatch, +) -> None: + inspection = example(AssayFeatureInspection) + completed = DataEnrichmentDependencies( + store=object(), + assays=["RNA"], + inspections={"RNA": inspection}, + toolCalls=[ + DataEnrichmentToolCall( + name="inspect_assay_features_batch", + assay="all", + ) + ], + ) + completed_context = SimpleNamespace(deps=completed) + + assert ( + asyncio.run( + enrichment_tools.inspect_assay_features( + completed_context, + assay_name="RNA", + ) + ) + == inspection + ) + cached_batch = asyncio.run( + enrichment_tools.inspect_assay_features_batch(completed_context) + ) + assert cached_batch.inspections == [inspection] + assert cached_batch.evidenceIds == inspection.evidenceIds + + incomplete = DataEnrichmentDependencies( + assays=["RNA"], + toolCalls=[DataEnrichmentToolCall(name="sentinel", assay="RNA")], + ) + with pytest.raises(ModelRetry, match="datastore"): + asyncio.run( + enrichment_tools.inspect_assay_features_batch( + SimpleNamespace(deps=incomplete) + ) + ) + assert [call.name for call in incomplete.toolCalls] == ["sentinel"] + + provider_error = UnexpectedModelBehavior("provider output failed") + failed = enrichment_validation.failed_data_enrichment_report( + DataEnrichmentDependencies( + assays=["RNA"], + inspections={"RNA": inspection}, + evidenceIds=set(inspection.evidenceIds), + ), + error=provider_error, + model_name="test-model", + ) + assert failed.status == "failed" + assert failed.policies == [] + assert failed.inspections == [inspection] + + def fail_before_inspection(**_kwargs: object) -> object: + raise UnexpectedModelBehavior("no inspection completed") + + monkeypatch.setattr(enrichment_agent, "run_agent_sync", fail_before_inspection) + store = SimpleNamespace(assay_names=["RNA"]) + failed = DataEnrichmentAgent(object()).run(store) + assert failed.status == "failed" + assert failed.inspections == [] + assert failed.policies == [] + + +def test_biological_interpretation_cache_and_fallback_branches() -> None: + composition = example(ClusterCompositionEvidence) + composition_deps = BiologicalInterpretationDependencies( + compositionEvidence=composition + ) + assert ( + asyncio.run( + biological_tools.inspect_cluster_composition( + SimpleNamespace(deps=composition_deps) + ) + ) + == composition + ) + + marker = example(ClusterMarkerEvidence) + marker_deps = BiologicalInterpretationDependencies( + clusterValues={marker.clusterId: 0}, + markerEvidence={marker.clusterId: marker}, + ) + assert ( + asyncio.run( + biological_tools.inspect_cluster_markers( + SimpleNamespace(deps=marker_deps), + cluster_id=marker.clusterId, + ) + ) + == marker + ) + + invalid_report = BiologicalInterpretationReport( + status="done", + needsInput=BiologicalInterpretationNeedsInput(question="More context?"), + ) + with pytest.raises(ModelRetry, match="Only a needsInput"): + biological_validation.validate_biological_interpretation_report( + invalid_report, + BiologicalInterpretationDependencies(clusterValues={"0": 0}), + ) + + provider_error = UnexpectedModelBehavior("structured output failed") + with pytest.raises(UnexpectedModelBehavior, match="structured output failed"): + biological_validation.fallback_biological_interpretation_report( + BiologicalInterpretationDependencies(), + error=provider_error, + model_name="test-model", + ) + needs_markers = biological_validation.fallback_biological_interpretation_report( + BiologicalInterpretationDependencies( + clusterValues={"0": 0}, + evidenceIds={"composition:clusters"}, + ), + error=provider_error, + model_name="test-model", + ) + assert needs_markers.status == "needsInput" + assert needs_markers.needsInput is not None + assert needs_markers.evidenceIds == ["composition:clusters"] + + +def test_experimental_context_rejects_invalid_batches_and_preserves_failed_evidence() -> ( + None +): + invalid_batches = ( + ( + [{"name": "batch", "domain": "technical", "kind": "categorical"}], + "missing", + "Unknown batch column", + ), + ( + [{"name": "condition", "domain": "biological", "kind": "categorical"}], + "condition", + "must be classified as technical", + ), + ( + [{"name": "depth", "domain": "technical", "kind": "continuous"}], + "depth", + "must be categorical", + ), + ) + for columns, batch_column, message in invalid_batches: + deps = ExperimentalContextDependencies( + characterization=CovariateCharacterization( + status="done", + columns=columns, + ) + ) + with pytest.raises(ModelRetry, match=message): + asyncio.run( + experimental_tools.analyze_experimental_design( + SimpleNamespace(deps=deps), + column_domains={}, + coefficients_of_interest=[], + units_of_inference={}, + batch_columns=[batch_column], + ) + ) + + characterization = CovariateCharacterization(status="done") + observed = example(CellQcProfileEvidence) + failed_deps = ExperimentalContextDependencies( + cellSelection=ArtifactRef( + scope="datastore", kind="cell_selection", artifact_id="c" * 64 + ), + characterization=characterization, + qcProfiles={observed.profileId: observed}, + htoIdentityColumns=["hto_identity"], + ) + failed = experimental_validation.failed_experimental_context_result( + failed_deps, + error=UnexpectedModelBehavior("design output failed"), + model_name="test-model", + ) + assert failed.status == "failed" + assert failed.characterization is characterization + assert failed.cellQc.profileId == "" + assert failed.qcProfiles == [observed] + assert failed.decision.batchCorrection.action == "needsInput" + assert failed.runInfo.agentName == "experimental_context_failed" + + +def test_agent_execution_logs_nested_failures_for_sync_and_async_runners( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class FailingAgent: + async def __aenter__(self) -> "FailingAgent": + return self + + async def __aexit__(self, *_args: object) -> bool: + return False + + async def run(self, *_args: object, **_kwargs: object) -> object: + try: + raise ValueError("inner failure") + except ValueError as cause: + raise RuntimeError("outer failure") from cause + + monkeypatch.setattr( + agent_exec_module, + "_build_agent", + lambda **_kwargs: FailingAgent(), + ) + messages: list[str] = [] + monkeypatch.setattr(agent_exec_module.logger, "error", messages.append) + with pytest.raises(RuntimeError, match="outer failure"): + agent_exec_module.run_agent_sync( + model=object(), + output_type=dict, + system_prompt="system", + user_prompt="user", + name="sync-failure", + ) + with pytest.raises(RuntimeError, match="outer failure"): + asyncio.run( + agent_exec_module.run_agent( + model=object(), + output_type=dict, + system_prompt="system", + user_prompt="user", + name="async-failure", + ) + ) + assert all("caused by ValueError: inner failure" in message for message in messages) + assert "sync-failure" in messages[0] + assert "async-failure" in messages[1] diff --git a/tests/test_agent_qc_decision_evidence.py b/tests/test_agent_qc_decision_evidence.py new file mode 100644 index 00000000..c3f015df --- /dev/null +++ b/tests/test_agent_qc_decision_evidence.py @@ -0,0 +1,116 @@ +"""QC choices distinguish reference grouping, measured retention and biology.""" + +from types import SimpleNamespace + +import pytest + +from scarf.agent.experimental_context.contracts import ( + CaptureFailureEvidence, + CellQcProfileEvidence, +) +from scarf.agent.experimental_context.study import StudyContract +from scarf.agent.orchestrator import AgentOrchestrator +from scarf.agent.orchestrator.preprocessing import PreprocessingStagesMixin +from scarf.agent.decisions.rna import QcGroupingExecutorPayload + + +def profile(policy: str, retained: int) -> CellQcProfileEvidence: + return CellQcProfileEvidence.model_validate( + { + "action": "skip" if policy == "retainWithFlags" else "registeredMad", + "registeredProfile": policy, + "attributes": [] if policy == "retainWithFlags" else ["RNA_percentMito"], + "captureColumn": "library", + "sampleColumn": "library" if policy == "captureMad5" else None, + "activeCells": 100, + "retainedCells": retained, + "evidenceId": f"qc:{policy}", + } + ) + + +def test_qc_evidence_reports_true_median_fractions_and_metric_flags() -> None: + selected = profile("globalMad5", 40) + selected.sampleRetainedCells = {"a": 10, "b": 30} + selected.retainedCellsByColumn = {"condition": {"a": 10, "b": 30}} + selected.metricFlaggedCells = {"RNA_percentMito": {"highMito": 60}} + selected.captureFailureEvidence = [ + CaptureFailureEvidence( + capture="a", activeCells=50, retainedCells=10, retainedFraction=0.2 + ), + CaptureFailureEvidence( + capture="b", activeCells=50, retainedCells=30, retainedFraction=0.6 + ), + ] + reference = profile("retainWithFlags", 100) + reference.retainedCellsByColumn = {"condition": {"a": 50, "b": 50}} + summary = PreprocessingStagesMixin._profile_evidence(selected, reference).summary + assert "min/median/max=10/20/30" in summary + assert "min/median/max=20.0%/40.0%/60.0%" in summary + assert "a=10/50 (20.0%)" in summary + assert "b=30/50 (60.0%)" in summary + assert "'highMito': 60" in summary + assert "flags may overlap" in summary + assert ( + "fractions unavailable" + in PreprocessingStagesMixin._profile_evidence(selected).summary + ) + + +def test_qc_grouping_compares_the_same_cutoff_method( + monkeypatch: pytest.MonkeyPatch, +) -> None: + orchestrator = AgentOrchestrator("test-model") + profiles = [ + CellQcProfileEvidence( + action="globalGaussian", + attributes=["RNA_percentMito"], + activeCells=100, + retainedCells=60, + evidenceId="qc:globalGaussian", + ), + CellQcProfileEvidence( + action="sampleMad", + sampleColumn="library", + attributes=["RNA_percentMito"], + activeCells=100, + retainedCells=55, + evidenceId="qc:sampleMad3", + ), + profile("globalMad5", 90), + profile("captureMad5", 95), + profile("retainWithFlags", 100), + ] + seen = {} + + def resolve(_store, _request, definition, bundle, _answers, **kwargs): + seen["definition"] = definition + seen["bundle"] = bundle + return SimpleNamespace( + compiled=SimpleNamespace( + executorPayload=QcGroupingExecutorPayload(groupingMode="global") + ), + checkpointSha256="a" * 64, + ) + + monkeypatch.setattr(orchestrator, "_resolve_rna_decision", resolve) + orchestrator._resolve_qc_grouping_decision( + SimpleNamespace(), + SimpleNamespace(), + SimpleNamespace(qcProfiles=profiles), + StudyContract.get_blank().model_copy( + update={"physicalCaptureColumn": "library"} + ), + {}, + ) + options = seen["definition"].spec.option_by_id() + assert "qc:globalMad5" in options["qcGrouping:global"].requiredEvidenceIds + assert "qc:captureMad5" in options["qcGrouping:physicalCapture"].requiredEvidenceIds + assert ( + "qc:sampleMad3" not in options["qcGrouping:physicalCapture"].requiredEvidenceIds + ) + design = next( + row.summary for row in seen["bundle"].evidence if row.evidenceClass == "design" + ) + assert "need not be independent biological units or healthy references" in design + assert "different cutoff methods cannot isolate" in design diff --git a/tests/test_agent_report.py b/tests/test_agent_report.py index 66632147..f9ce0b80 100644 --- a/tests/test_agent_report.py +++ b/tests/test_agent_report.py @@ -1,1738 +1,536 @@ -"""Supported local HTML report contracts for completed agent workflows.""" +"""One faithful, read-only analysis report from the authoritative journal.""" -import asyncio -import sys -from collections.abc import Mapping +import copy +import json from pathlib import Path from types import SimpleNamespace -from typing import Any, Literal +from typing import Any -import numpy as np import pandas as pd import pytest -import zarr -from pydantic_ai import ModelRetry, UnexpectedModelBehavior -import scarf.agent as agent_api -import scarf.agent.biological_interpretation.tools as biological_tools -import scarf.agent.biological_interpretation.validation as biological_validation -import scarf.agent.config.agent_exec as agent_exec_module -import scarf.agent.data_enrichment.agent as enrichment_agent -import scarf.agent.data_enrichment.tools as enrichment_tools -import scarf.agent.data_enrichment.validation as enrichment_validation -import scarf.agent.experimental_context.tools as experimental_tools -import scarf.agent.experimental_context.validation as experimental_validation -import scarf.agent.report.artifacts as report_artifacts -import scarf.agent.report.contracts as report_contracts -import scarf.agent.report.decision_tree as report_decision_tree -import scarf.agent.report.generator as report_generator -import scarf.agent.report.plots as report_plots -import scarf.agent.report.rendering as report_rendering -import scarf.agent.orchestrator.journal as journal_module -import scarf.agent.orchestrator.main as orchestrator_main -from scarf.agent import ( - AgentWorkflowRun, - AutomatedWorkflowConfig, - AutomatedWorkflowRequest, - AutomatedWorkflowResult, - FinalAnalysisHandoff, - create_agent_workflow, - generate_agent_report, - list_agent_workflows, - load_agent_workflow, -) -from scarf.agent.biological_interpretation import ( - BiologicalInterpretationNeedsInput, - BiologicalInterpretationReport, - ClusterCompositionEvidence, - ClusterMarkerEvidence, -) -from scarf.agent.biological_interpretation.contracts import ( - BiologicalInterpretationDependencies, -) -from scarf.agent.experimental_context.contracts import CovariateCharacterization -from scarf.agent.data_enrichment import ( - AssayFeatureInspection, - DataEnrichmentAgent, - DataEnrichmentDependencies, - DataEnrichmentToolCall, -) -from scarf.agent.experimental_context import ( - CellQcProfileEvidence, - ExperimentalContextDependencies, -) -from scarf.agent.orchestrator.models import ( - AssayPreprocessingPlan, - AutomatedPreprocessingPlan, - NativeAnalysisHandoff, - OrchestrationRequestRecord, - WorkflowStageAttempt, +from scarf.agent.orchestrator import journal +from scarf.agent.report import generator, plots +from scarf.agent.report.artifacts import ( + _local_root, + report_directory, + scientific_summary, ) +from scarf.agent.report.rendering import render_analysis_document from scarf.agent.types import ArtifactReferenceModel +from tests.test_agent_analysis_plots import display_store -def _workflow( - *, - workspace: str | None = None, - status: Literal["completed", "running"] = "completed", -) -> AgentWorkflowRun: - return AgentWorkflowRun( - workflowRunId="report-workflow", - workspace=workspace, - createdAtNs=1, - finalizedAtNs=2 if status != "running" else 0, - status=status, - finalizationMessage="analysis completed" if status != "running" else "", - analysisStore="data.zarr", - datasetFingerprints={"RNA": "dataset-rna"}, - ) - - -def _reports(study_context: str) -> dict[str, list[dict[str, Any]]]: - run_info = { - "agentName": "data_enrichment", - "runId": "provider-run", - "modelName": "test-model", - "durationSeconds": 1.5, - "usage": { - "requests": 2, - "toolCalls": 1, - "inputTokens": 20, - "outputTokens": 5, - "totalTokens": 25, - }, - } - candidate = { - "candidateId": "refined", - "phase": "refined", - "status": "done", - "eligible": True, - "parameters": { - "reductionMethod": "pca", - "dimensions": 21, - "neighborsK": 11, - "leidenResolution": 0.75, - "useHarmony": False, - }, - "metrics": { - "nClusters": 7, - "minClusterCells": 42, - "graphSilhouetteMedian": 0.343, - }, - } - baseline_candidate = { - "candidateId": "baseline", - "phase": "initial", - "status": "done", - "eligible": True, - "parameters": { - "reductionMethod": "pca", - "dimensions": 21, - "neighborsK": 11, - "leidenResolution": 1.0, - "useHarmony": False, - }, - "metrics": { - "nClusters": 9, - "minClusterCells": 18, - "graphSilhouetteMedian": 0.221, +def snapshot() -> dict[str, Any]: + state = { + "runId": "exact-analysis", + "status": "completed", + "request": { + "studyContext": "Human RNA cells", + "studyObjective": "Find stable populations batch artifacts.", }, - } - return { - "data_enrichment": [ - { - "status": "done", - "studyContextSummary": { - "studyContext": study_context, - "organismReferences": ["human"], - "tissueReferences": ["blood"], - }, - "policies": [ - { - "assay": "RNA", - "excludeFamilies": ["ribosomal"], - "protectFamilies": ["sex", "cellCycle"], - } - ], - "inspections": [ - { - "assay": "RNA", - "families": [ - { - "family": "ribosomal", - "count": 193, - "method": "symbolPrefix", - "skipped": None, - }, - { - "family": "sex", - "count": 0, - "method": "chromosome", - "skipped": "referenceUnavailable", - }, + "finalAnalysis": { + "primaryAssay": "RNA2", + "limitations": ["Condition and batch are confounded."], + "analysisEvidence": { + "analysisReview": { + "tuningEvidence": { + "history": [ { - "family": "cellCycle", - "count": 94, - "method": "staticList", - "skipped": None, - }, - ], + "scope": "full", + "review": { + "action": "accept", + "selectedCandidateId": "candidate-two", + "quantitativeFindings": [ + "The chosen partition has seed stability 0.92." + ], + "qualitativeFindings": [ + "MS4A1 and CD79A support the same partition." + ], + "rationale": "The selected partition preserves a small marker-supported population.", + "objectivePreservation": "Retain the rare marker program.", + }, + } + ] } - ], - "runInfo": run_info, - } - ], - "experimental_context": [ + } + }, + }, + "stages": [ { + "stage": "parameter_tuning", "status": "done", - "decision": {"batchCorrection": {"action": "unsafe"}}, - "cellQc": { - "action": "skip", - "driverAssay": "RNA", - "profileId": "qc-selected", - "registeredProfile": "retainWithFlags", + "report": { + "recommendedCandidateId": "candidate-two", + "evaluations": [ + { + "candidateId": "candidate-two", + "parameters": { + "dimensions": 20, + "neighborsK": 15, + "leidenResolution": 0.75, + "useHarmony": False, + }, + "metrics": {"seedStability": 0.92, "markerCoherence": 0.84}, + } + ], }, - "qcProfiles": [ + "decisions": [ { - "profileId": "qc-selected", - "registeredProfile": "retainWithFlags", - "activeCells": 100, - "retainedCells": 100, - "retainedFraction": 1.0, - "flaggedCells": { - "RNA_nCounts:high": 0, - "RNA_nCounts:lowQuality": 0, - "RNA_nFeatures:high": 0, - "RNA_nFeatures:lowQuality": 0, - }, - "parameters": { - "nMads": 5.0, - "resolvedBounds": [ + "spec": { + "question": "Which clustering resolution preserves supported populations?", + "options": [ { - "group": "global", - "role": "count", - "lowerRemoval": 50.0, - "upperFlag": 200000.0, + "optionId": "low", + "label": "Resolution 0.5", + "description": "Compare the coarser partition.", }, { - "group": "global", - "role": "feature", - "lowerRemoval": 125.0, - "upperFlag": 28000.0, + "optionId": "chosen", + "label": "Resolution 0.75", + "description": "Compare the marker-supported partition.", }, ], }, - "retainedCellsByColumn": { - "T2D": {"no": 70, "yes": 30}, - "donor_id": {"donor-a": 45, "donor-b": 55}, - "sample_id": {"sample-a": 45, "sample-b": 55}, - "tissue": {"blood": 100}, + "record": { + "selectedOptionId": "chosen", + "decisionId": "clustering", + "rationale": "Selected 0.75 because seed stability was 0.92 and B-cell markers remained coherent.", + "modelName": "not-in-report", + "recordId": "hidden-record-id", }, - }, - { - "profileId": "qc-alternative", - "registeredProfile": "captureMad5", - "activeCells": 100, - "retainedCells": 96, - "retainedFraction": 0.96, - "flaggedCells": { - "RNA_nCounts:high": 2, - "RNA_nCounts:lowQuality": 1, - "RNA_nFeatures:high": 1, - "RNA_nFeatures:lowQuality": 3, - }, - "parameters": { - "nMads": 5.0, - "resolvedBounds": [ - { - "group": "library-a", - "role": "count", - "lowerRemoval": 40.0, - "upperFlag": 180000.0, - }, - { - "group": "library-b", - "role": "count", - "lowerRemoval": 60.0, - "upperFlag": 220000.0, - }, + "evidence": { + "evidence": [ { - "group": "library-a", - "role": "feature", - "lowerRemoval": 100.0, - "upperFlag": 24000.0, + "summary": "Resolution 0.5: stability 0.96; marker coherence 0.67." }, { - "group": "library-b", - "role": "feature", - "lowerRemoval": 150.0, - "upperFlag": 32000.0, + "summary": "Resolution 0.75: stability 0.92; marker coherence 0.84." }, - ], - }, - }, - ], - "characterization": { - "columns": [ - {"name": "T2D", "domain": "biological"}, - {"name": "tissue", "domain": "biological"}, - {"name": "library_id", "domain": "technical"}, - {"name": "sample_id", "domain": "design"}, - {"name": "predicted.id", "domain": "ignore"}, - ], - "coefficients": [ - { - "name": "T2D", - "kind": "categorical", - "designRows": 22, - "observationUnit": "sample_id", - "independentUnit": "donor_id", - "scope": "betweenUnit", - }, - { - "name": "tissue", - "kind": "categorical", - "designRows": 22, - "observationUnit": "sample_id", - "independentUnit": "donor_id", - "scope": "betweenUnit", - }, - ], - "technicalNesting": [ - { - "left": "origin", - "right": "library_id", - "nesting": "rightInLeft", - } - ], - "confounding": [ - { - "coefficient": "T2D", - "pairs": [ - { - "technical": "library_id", - "selected": True, - "association": { - "status": "notComputed", - "rowsUsed": 22, - "valueUncorrected": 1.0, - }, - } - ], - } - ], - }, - "batchSafety": [ - { - "coefficient": "T2D", - "status": "unsafe", - "estimability": { - "coefficientEstimable": False, - "rowsUsed": 22, - "rankTechnical": 22, - "residualDf": 0, - "estimableDf": 0, - }, - }, - { - "coefficient": "tissue", - "status": "unsafe", - "estimability": { - "coefficientEstimable": False, - "rowsUsed": 22, - "rankTechnical": 22, - "residualDf": 0, - "estimableDf": 0, + ] }, - }, - ], - } - ], - "parameter_tuning": [ - { - "status": "done", - "fromAssay": "RNA", - "totalCandidates": 2, - "recommendedByAssay": {"RNA": "refined"}, - "rationale": "The refined candidate balanced cluster viability.", - "stopReason": "The bounded refinement completed.", - "assayReports": { - "RNA": { - "recommendedCandidateId": "refined", - "confidence": "medium", - "evaluations": [baseline_candidate, candidate], - "comparisons": [ + "checks": [ { - "candidateId": "baseline", - "summary": ( - "The refined candidate retained larger " - "minimum clusters." - ), - "evidenceIds": ["candidate:refined:clusters"], + "name": "Protected condition", + "status": "passed", + "reason": "Condition representation was retained.", } ], - "searchPlan": { - "status": "refine", - "objectives": ["Test an intermediate resolution."], - }, - } - }, - } - ], - "biological_interpretation": [ - { - "status": "done", - "clusterInterpretations": [ - { - "clusterId": "0", - "proposedIdentity": "T cell", - "identityIsHypothesis": True, } ], - "treatmentObservations": [], - "followUps": ["Validate the proposed identities."], - } + }, + { + "stage": "experimental_context", + "report": { + "cellQc": {"profileId": "selected"}, + "qcProfiles": [ + { + "profileId": "selected", + "retainedCells": 920, + "retainedFraction": 0.92, + } + ], + }, + }, ], } - -def _patch_completed_workflow( - monkeypatch: pytest.MonkeyPatch, - root: Path, - *, - workspace: str | None = None, - study_context: str = "A human blood study.", - plots: bool = True, -) -> Path: - group = zarr.open_group(str(root), mode="w", zarr_format=3) - if workspace is not None: - group.create_group(workspace) - workflow = _workflow(workspace=workspace) - final = ( - FinalAnalysisHandoff.get_example() - .model_copy( - update={ - "workflowRunId": workflow.workflowRunId, - "handoffId": "", - } - ) - .with_handoff_id() - ) - result = AutomatedWorkflowResult( - status="completed", - currentStage="analysis_finalization", - zarrPath=str(root), - workflowRun=workflow, - preprocessingPlan=AutomatedPreprocessingPlan( - primaryAssay="RNA", - markerAssay="RNA", - assays=[ - AssayPreprocessingPlan( - assay="RNA", - assayType="RNA", - role="graph", - graphEligible=True, - markerEligible=True, - featureMethod="hvg", - reductionMethod="pca", - featureParameters={ - "topN": 2000, - "minCells": 20, - "excludeFamilies": ["ribosomal"], - "protectFamilies": ["sex", "cellCycle"], - }, - normalizationParameters={ - "logTransform": True, - "renormalizeSubset": True, - }, - ) - ], - ), - finalAnalysis=final, - finalHandoffId=final.handoffId, - decisionRunId=workflow.workflowRunId, - ) - request = AutomatedWorkflowRequest( - sourcePath="input.h5ad", - zarrPath=str(root), - studyContext=study_context, - studyObjective="Discover stable RNA populations.", - workspace=workspace, - ) - request_record = SimpleNamespace( - request=request, - config=AutomatedWorkflowConfig(), - ) - - monkeypatch.setattr( - report_generator, - "load_agent_workflow", - lambda *_a, **_k: workflow, - ) - monkeypatch.setattr(report_generator, "_open_datastore", lambda *_a, **_k: object()) - monkeypatch.setattr( - report_generator, - "_load_completed_result", - lambda *_a, **_k: ("agents/orchestrations", result, request_record), - ) - monkeypatch.setattr( - report_generator, - "_collect_reports", - lambda *_a, **_k: _reports(study_context), - ) - monkeypatch.setattr( - report_generator, - "_collect_history", - lambda *_a, **_k: ( - [ - { - "stage": "parameter_tuning", - "status": "done", - "durationSeconds": 3.5, - "actions": ["evaluate_refined_candidate"], - "reportCount": 1, - "artifactCount": 4, - "artifacts": { - "selectedGraph": { - "scope": "assay", - "assay": "RNA", - "kind": "connectivity_map", - "artifactId": "a" * 64, - } - }, - "parentAttempts": ["preprocessing:attempt-1"], - "questionIds": [], - "noteCount": 0, - "errorType": None, + review = state["finalAnalysis"]["analysisEvidence"]["analysisReview"][ + "tuningEvidence" + ]["history"][0]["review"] + state["analysisReviews"] = [ + { + "scope": "full", + **review, + "candidates": state["stages"][0]["report"]["evaluations"], + "settings": {"candidate-two": {"hvgCount": 2000, "ranking": "global"}}, + "featureEvidence": { + "candidate-two": { + "families": {"hla": {"eligibleGenes": 12, "selectedGenes": 6}} } - ], - [], - ), - ) - monkeypatch.setattr(report_generator, "_collect_active_decisions", lambda *_a: {}) - monkeypatch.setattr( - report_generator, - "_collect_default_feature_inventories", - lambda *_a: [ - { - "assay": "RNA", - "source": "scarfDefaultHvgBlacklist", - "policyEffect": "evidenceOnly", - "featureColumn": "names", - "totalFeatures": 20_000, - "blacklist": "^MT-|^RPS|^RPL", - "matchCount": 2, - "examples": ["MT-CO1", "MT-CYB"], - "families": [ - { - "family": "mitochondrial", - "pattern": "^MT-", - "count": 2, - "examples": ["MT-CO1", "MT-CYB"], - } - ], - "appliedToSelectedRepresentation": False, - "selectedExcludeFamilies": ["ribosomal"], - "selectedProtectFamilies": ["sex", "cellCycle"], - "matchedFeatures": ["MT-CO1", "MT-CYB"], - } - ], - ) - - def collect_artifacts( - _store: object, - _result: AutomatedWorkflowResult, - plot_dir: Path, - *, - qc_profile: Mapping[str, Any] | None = None, - ) -> tuple[dict[str, int], list[dict[str, Any]], dict[str, str], list[str]]: - assert qc_profile is not None - if not plots: - return ( - {"0": 3, "1": 2}, - [], - {}, - ["umapClusters: ImportError: plotting dependencies are unavailable"], - ) - plot_dir.mkdir(parents=True, exist_ok=True) - (plot_dir / "final_umap.png").write_bytes(b"png") - (plot_dir / "final_umap.png.json").write_text( - '{"artifact":"umap"}\n', encoding="utf-8" - ) - return ( - {"0": 3, "1": 2}, - [{"group_id": "0", "feature_name": "CD3D", "score": 8.5}], - {"umapClusters": "plots/final_umap.png"}, - [], - ) - - monkeypatch.setattr(report_generator, "_collect_final_artifacts", collect_artifacts) + }, + } + ] + return state - def collect_hvg_plots( - _store: object, - _attempts: object, - _plan: object, - plot_dir: Path, - ) -> tuple[dict[str, str], list[str]]: - if not plots: - return {}, [] - (plot_dir / "hvg_global.png").write_bytes(b"hvg") - (plot_dir / "hvg_global.png.json").write_text( - '{"artifact":"hvg"}\n', - encoding="utf-8", - ) - return {"hvgGlobal": "plots/hvg_global.png"}, [] - monkeypatch.setattr(report_generator, "_collect_hvg_plots", collect_hvg_plots) - monkeypatch.setattr( - report_generator, - "_collect_hvg_evidence", - lambda *_a, **_k: { - "assay": "RNA", - "selectedRankingMode": "batchAware", - "selectedFeatureCount": 2000, - "rankings": [ - { - "rankingMode": "global", - "eligibleFeatureCount": 29263, - "validTechnicalGroups": 22, - "excludedTechnicalGroupCount": 0, - "meanTechnicalGroupCoverage": 0.366, - "recurrentInTwoGroupsFraction": 0.630, - }, - { - "rankingMode": "batchAware", - "eligibleFeatureCount": 29263, - "validTechnicalGroups": 22, - "excludedTechnicalGroupCount": 0, - "meanTechnicalGroupCoverage": 0.627, - "recurrentInTwoGroupsFraction": 1.0, - }, - ], - "candidateMetrics": [ - { - "featureCount": 1000, - "varianceFraction": 0.146, - "recurrentFraction": 1.0, - }, - { - "featureCount": 2000, - "varianceFraction": 0.190, - "recurrentFraction": 1.0, - }, - { - "featureCount": 4000, - "varianceFraction": 0.270, - "recurrentFraction": 0.655, - }, - ], - "eligibleFeatureCount": 29263, - "validTechnicalGroups": 22, - "excludedTechnicalGroupCount": 0, - "minimumDetectedCells": 20, - "minimumTechnicalGroupCells": 20, - "scarfDefaultReferenceCounts": [1000, 2000, 4000], - "executedBranchCount": 9, - }, - ) - return root +def display_payload() -> dict[str, Any]: + return { + "clusterCounts": {"0": 620_000, "1": 1_200}, + "markers": [{"cluster": "1", "feature": "MS4A1", "score": 0.84}], + "umap": "plots/final_umap.png", + "displayedCells": 50_000, + "displayNotes": [], + } -def test_public_report_generates_branded_readable_html_and_relative_plots( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, +@pytest.mark.parametrize("mode", ["visual", "structured"]) +def test_one_page_shows_recorded_choices_evidence_and_qualitative_findings( + mode, ) -> None: - root = _patch_completed_workflow( - monkeypatch, - tmp_path / "data.zarr", - study_context='Human blood & treatment.', - ) - immutable_record = root / "agents/runs/report-workflow/workflow.json" - immutable_record.parent.mkdir(parents=True) - immutable_record.write_bytes(b'{"immutable":true}\n') - - report_path = generate_agent_report(root, "report-workflow") - analysis_path = report_path - technical_path = report_path.with_name("technical.html") - analysis_markup = analysis_path.read_text(encoding="utf-8") - technical_markup = technical_path.read_text(encoding="utf-8") - - assert agent_api.generate_agent_report is generate_agent_report - assert report_path == root / "agents/runs/report-workflow/report/index.html" - assert analysis_path.is_file() - assert technical_path.is_file() - assert immutable_record.read_bytes() == b'{"immutable":true}\n' - for markup in (analysis_markup, technical_markup): - assert 'href="index.html"' in markup - assert 'href="analysis.html"' not in markup - assert 'href="technical.html"' in markup - assert 'href="https://www.nygen.io/"' in markup - assert ">Nygen Analytics" in markup - assert "Choose the level of detail" not in analysis_markup - assert not report_path.with_name("analysis.html").exists() - - assert "Analysis decision tree" in analysis_markup - assert '
    ') == 2 - assert analysis_markup.count('
    ') == 5 - assert "2,000 variable genes" in analysis_markup - assert "Corrected variance captured: 19.0%" in analysis_markup - assert "Genes recurring across most libraries: 65.5%" in analysis_markup - assert "Harmony not applied" in analysis_markup - assert "Selected QC metrics and cutoffs" in analysis_markup - assert "Exact Scarf default HVG blacklist" in analysis_markup - assert "Matched 2 of 20,000 genes" in analysis_markup - assert "How should highly variable genes be ranked?" in analysis_markup - assert "How many highly variable genes should be used?" in analysis_markup - assert "HVG branches executed" in analysis_markup - assert "qc-selected" not in analysis_markup - assert "library_id" not in analysis_markup - assert "batchAware" not in analysis_markup - assert "Why the final result was selected" in analysis_markup - assert "T cell" in analysis_markup - assert "Cell QC audit" in technical_markup - assert "All global and per-library cutoffs" in technical_markup - assert "Normalization and feature-selection audit" in technical_markup - assert "All 2 matched feature names" in technical_markup - assert "MT-CO1" in technical_markup - assert "provider-run" not in analysis_markup - assert "a" * 64 not in analysis_markup - assert "refined" not in analysis_markup - assert "Plot provenance" not in analysis_markup - assert 'src="plots/final_umap.png"' in analysis_markup - assert 'src="plots/hvg_global.png"' in analysis_markup - - assert "Human blood <script>alert" in technical_markup - assert '' not in technical_markup - assert "Parameter tuning and graph selection" in technical_markup - assert "refined" in technical_markup - assert "0.343" in technical_markup - assert "The refined candidate retained larger minimum clusters." in technical_markup - assert "Stage artifact inventory" in technical_markup - assert "connectivity_map" in technical_markup - assert "Recorded totals" in technical_markup - assert "evaluate_refined_candidate" in technical_markup - assert '' in technical_markup - assert "table-layout: auto" in technical_markup - assert "table-layout: fixed" not in technical_markup - assert 'class="record table-record table-record-selected"' in technical_markup - assert 'src="plots/final_umap.png"' in technical_markup - assert 'href="plots/final_umap.png.json"' in technical_markup - assert (report_path.parent / "plots/final_umap.png").read_bytes() == b"png" - - -def test_harmony_diagnostic_reports_execution_metrics_and_rejection_reason() -> None: - native = { - "candidateId": "rna_correction_native", - "status": "done", - "eligible": True, - "parameters": { - "candidateId": "rna_correction_native", - "reductionMethod": "pca", - "dimensions": 20, - "neighborsK": 21, - "leidenResolution": 1.0, - "useHarmony": False, - }, - "metrics": { - "batchMixing": {"library_id": 0.05}, - "technicalAssociation": {"library_id": 0.24}, - "biologicalPreservation": { - "tissue": {"clisi": 1.0, "graphConnectivity": 0.99} - }, - "crossUnitSupport": 1.0, - "markerCoherence": 0.82, - "markerSpecificityMedian": 0.42, - "clusterConnectivity": 1.0, - "membershipStrengthMean": 0.96, - "doubletHighScoreConcentration": 9.4, - }, + state = snapshot() + state["analysisReviews"][0]["evidenceMode"] = mode + original = copy.deepcopy(state) + payload = scientific_summary(state) | display_payload() + document = render_analysis_document(payload) + assert state == original + assert "621,200 cells" in document and "2 clusters" in document + assert "50,000 of 621,200" in document + assert "QC retained 920 cells (92.0%)" in document + assert "Selected 0.75 because seed stability was 0.92" in document + assert "Resolution 0.5: stability 0.96; marker coherence 0.67." in document + assert "MS4A1 and CD79A support the same partition." in document + assert "small marker-supported population" in document + assert "Condition and batch are confounded." in document + assert "Condition representation was retained." in document + assert "<without>" in document and "" not in document + assert "hidden-record-id" not in document and "not-in-report" not in document + assert "technical.html" not in document and "decision-tree" not in document + assert "added noise" not in document and "weaker" not in document + assert ("No plots were supplied for visual inspection" in document) == ( + mode == "structured" + ) + assert document.index("final_umap.png") < document.index("Analysis decisions") + + +def test_all_untrusted_scientific_text_is_escaped() -> None: + state = snapshot() + injection = '' + state["stages"][0]["decisions"][0]["record"]["rationale"] = injection + state["finalAnalysis"]["limitations"] = [injection] + payload = scientific_summary(state) | display_payload() + payload["markers"][0]["feature"] = injection + document = render_analysis_document(payload) + assert injection not in document + assert document.count("<img src=x onerror="alert(1)">") == 3 + + +def test_missing_evidence_is_reported_without_inventing_selection_reasons() -> None: + state = snapshot() + state["stages"] = [] + state["finalAnalysis"]["analysisEvidence"] = {} + state["analysisReviews"] = [] + document = render_analysis_document(scientific_summary(state) | display_payload()) + assert "No consequential decisions were recorded" in document + assert "What the evidence shows" not in document + assert "seed stability was 0.92" not in document + + +def test_report_distinguishes_gene_correction_and_experiment_evidence() -> None: + state = snapshot() + accepted = state["analysisReviews"][0] + accepted["correctionNeed"] = "needed" + accepted["candidates"][0]["parameters"]["useHarmony"] = True + accepted["settings"]["candidate-two"].update( + ranking="batchAware", rankingColumn="library" + ) + experiment = copy.deepcopy(accepted) + experiment.update( + action="experiment", + experimentId="includeFamily:hla", + concern="HLA markers distinguish the objective-relevant activation state.", + expectedImprovement="Restoring HLA genes may retain that state.", + ) + state["analysisReviews"].insert(0, experiment) + document = render_analysis_document(scientific_summary(state) | display_payload()) + assert "includeFamily:hla" in document + assert experiment["concern"] in document + assert experiment["expectedImprovement"] in document + assert "HVG count" in document and "2,000" in document + assert "batchAware" in document and "library" in document + assert "Feature family" in document and "Selected HVGs" in document + assert "Correction necessity: Needed" in document + + +@pytest.mark.parametrize("mode", ["visual", "structured"]) +@pytest.mark.parametrize("damage", [None, "digest", "scope", "action", "genes", "mode"]) +def test_review_view_requires_exact_checkpoint_bindings( + monkeypatch: pytest.MonkeyPatch, damage: str | None, mode: str +) -> None: + import hashlib + + from scarf.agent import record_io + from scarf.agent.orchestrator.models import AutomatedWorkflowConfig + + state = snapshot() + view = state["analysisReviews"][0] + candidate = copy.deepcopy(view["candidates"][0]) + features = ArtifactReferenceModel( + assay="RNA2", kind="feature_selection", artifactId="4" * 64 + ).model_dump(mode="json") + candidate["artifacts"] = {"graphFeatures": features} + action = { + key: value + for key, value in view.items() + if key not in {"scope", "candidates", "settings", "featureEvidence"} } - harmony = { - "candidateId": "rna_correction_harmony", - "status": "done", - "eligible": True, - "parameters": { - "candidateId": "rna_correction_harmony", - "reductionMethod": "pca", - "dimensions": 20, - "neighborsK": 21, - "leidenResolution": 1.0, - "useHarmony": True, - }, - "metrics": { - "batchMixing": {"library_id": 0.12}, - "technicalAssociation": {"library_id": 0.09}, - "biologicalPreservation": { - "tissue": {"clisi": 0.62, "graphConnectivity": 0.98} + payload = { + "inputs": { + "scope": "full", + "imageHashes": {"observed": "image-digest"} if mode == "visual" else {}, + "evidenceMode": mode, + "visualInspection": "available" if mode == "visual" else "unavailable", + "candidates": [candidate], + "settings": { + "candidate-two": { + **view["settings"]["candidate-two"], + "parameters": candidate["parameters"], + "features": features, + } }, - "crossUnitSupport": 1.0, - "markerCoherence": 0.90, - "markerSpecificityMedian": 0.51, - "clusterConnectivity": 1.0, - "membershipStrengthMean": 0.96, - "doubletHighScoreConcentration": 8.8, - }, - } - parameter = { - "fromAssay": "RNA", - "recommendedByAssay": {"RNA": "rna_correction_native"}, - "assayReports": { - "RNA": { - "recommendedCandidateId": "rna_correction_native", - "evaluations": [native, harmony], - } + "featureEvidence": view["featureEvidence"], }, + "outputs": {"action": action}, } - experimental = { - "decision": {"batchCorrection": {"action": "unsafe"}}, - "batchSafety": [ - { - "coefficient": "tissue", - "status": "unsafe", - "estimability": { - "coefficientEstimable": False, - "rowsUsed": 22, - "rankTechnical": 22, - "residualDf": 0, - "estimableDf": 0, - }, - } - ], - } - final = { - "graphMethod": "native", - "primaryAssay": "RNA", - "nativeAnalyses": [{"assay": "RNA", "batchCorrection": None}], - } - rejection = ( - "Retain native because Harmony materially degraded protected tissue " - "evidence and was diagnostic-only." - ) - decisions = { - "correctionLicense": {"selectedOptionId": "correctionLicense:unsafeConfounded"}, - "correctionOutcome": {"rationale": rejection}, + digest = hashlib.sha256(record_io.canonical_json_bytes(payload)).hexdigest() + entry = { + "scope": "full", + "review": action, + "checkpointKey": "parameter_tuning/full/review0", + "checkpointSha256": digest, + "imageHashes": payload["inputs"]["imageHashes"], + "evidenceMode": mode, + "visualInspection": payload["inputs"]["visualInspection"], } - - evidence_markup = report_rendering._render_batch_evidence( - experimental, - parameter, - final, - decisions, - ) - stage = report_decision_tree._batch_tree_stage( - experimental, - parameter, - final, - decisions, - ) - assert stage is not None - tree_markup = report_decision_tree._render_decision_tree([stage]) - technical_markup = report_rendering._render_harmony_technical_audit( - experimental, - parameter, - final, - decisions, - ) - - assert ( - "Diagnostic Harmony completed; rejected and native representation retained" - in evidence_markup - ) - assert "Run status: completed" in evidence_markup - assert "Library mixing: 0.050 to 0.120 (change +0.070)" in evidence_markup - assert "Matched native versus Harmony metrics" in evidence_markup - assert "Recorded correction decision" in evidence_markup - assert rejection in evidence_markup - assert "Run diagnostically; rejected" in tree_markup - assert "Protected evidence degraded: tissue" in tree_markup - assert "Harmony diagnostic audit" in technical_markup - assert "Native versus Harmony measurements" in technical_markup - - -def test_report_uses_workspace_path_and_can_be_regenerated( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - root = _patch_completed_workflow( - monkeypatch, - tmp_path / "data.zarr", - workspace="analysis", - study_context="First context", - ) - - first = generate_agent_report(root, "report-workflow", workspace="analysis") - assert first == (root / "analysis/agents/runs/report-workflow/report/index.html") - first_technical = first.with_name("technical.html").read_text(encoding="utf-8") - assert "First context" in first_technical - first.write_text("stale landing", encoding="utf-8") - first.with_name("analysis.html").write_text("stale analysis", encoding="utf-8") - first.with_name("technical.html").write_text("stale technical", encoding="utf-8") - - monkeypatch.setattr( - report_generator, - "_collect_reports", - lambda *_a, **_k: _reports("Regenerated context"), - ) - second = generate_agent_report(root, "report-workflow", workspace="analysis") - - assert second == first - second_analysis = second.read_text(encoding="utf-8") - assert not second.with_name("analysis.html").exists() - second_technical = second.with_name("technical.html").read_text(encoding="utf-8") - assert "Choose the level of detail" not in second_analysis - assert "Analysis decision tree" in second_analysis - assert "Regenerated context" in second_technical - assert second_technical != first_technical - - -def test_filtering_report_explains_removed_cells_using_the_recorded_rationale() -> None: - experimental = { - "qcProfiles": [ - { - "profileId": "selected-qc", - "registeredProfile": "globalMad5", - "activeCells": 100, - "retainedCells": 90, - } - ] - } - plan = { - "cellQc": { - "profileId": "selected-qc", - "rationale": "Remove low-quality cells while retaining the study groups.", + if damage == "digest": + entry["checkpointSha256"] = "bad-digest" + elif damage == "scope": + entry["scope"] = "sample0" + elif damage == "action": + entry["review"] = {**action, "rationale": "Unrecorded reasoning"} + elif damage == "mode": + entry["evidenceMode"] = "structured" if mode == "visual" else "visual" + elif damage == "genes": + payload["inputs"]["settings"]["candidate-two"]["features"] = { + **features, + "artifactId": "5" * 64, } - } - markup = report_rendering._render_filtering_evidence(experimental, plan) - assert "90 of 100" in markup - assert "Removed: 10" in markup - assert plan["cellQc"]["rationale"] in markup - assert "Preserved every reviewed cell" not in markup - - -def test_report_remains_available_when_optional_plots_fail( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - root = _patch_completed_workflow( - monkeypatch, - tmp_path / "data.zarr", - plots=False, - ) - - report_path = generate_agent_report(root, "report-workflow") - analysis_markup = report_path.read_text(encoding="utf-8") - technical_markup = report_path.with_name("technical.html").read_text( - encoding="utf-8" - ) - - assert report_path.is_file() - assert "Visual results are unavailable for this report" in analysis_markup - assert "No plots could be rendered" in technical_markup - assert "plotting dependencies are unavailable" in technical_markup - assert "Final cluster sizes" in technical_markup + digest = hashlib.sha256(record_io.canonical_json_bytes(payload)).hexdigest() + entry["checkpointSha256"] = digest + record = record_io.canonical_json_bytes({**payload, "contentSha256": digest}) + monkeypatch.setattr(record_io, "read_key", lambda *_args: record) + stages = [ + { + "stage": "parameter_tuning", + "outputs": {"tuningEvidence": {"history": [entry]}}, + } + ] + if damage is not None: + with pytest.raises(ValueError, match="Analysis review"): + journal._analysis_review_views( + SimpleNamespace(zw=object()), + "agents/orchestrations", + "workflow", + stages, + AutomatedWorkflowConfig(), + ) + else: + result = journal._analysis_review_views( + SimpleNamespace(zw=object()), + "agents/orchestrations", + "workflow", + stages, + AutomatedWorkflowConfig(), + ) + assert result[0]["rationale"] == action["rationale"] + assert result[0]["evidenceMode"] == mode + assert result[0]["settings"]["candidate-two"]["hvgCount"] == 2000 + assert "artifacts" not in result[0]["candidates"][0] -def test_report_rejects_remote_and_non_completed_workflows( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, +def test_report_regeneration_only_replaces_derived_files( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - with pytest.raises(ValueError, match="local filesystem"): - generate_agent_report("s3://bucket/data.zarr", "report-workflow") - root = tmp_path / "data.zarr" - zarr.open_group(str(root), mode="w", zarr_format=3) - running = _workflow(status="running") - monkeypatch.setattr( - report_generator, - "load_agent_workflow", - lambda *_a, **_k: running, - ) - monkeypatch.setattr(report_generator, "_open_datastore", lambda *_a, **_k: object()) - - with pytest.raises(RuntimeError, match="completed workflows"): - generate_agent_report(root, running.workflowRunId) - - -def test_orchestrator_generates_only_completed_local_reports_non_fatally( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> None: - generated: list[tuple[object, str]] = [] - local_store = SimpleNamespace(z=object()) - completed = _workflow() - - monkeypatch.setattr( - orchestrator_main, - "zarr_root_path", - lambda _store: tmp_path / "data.zarr", - ) - monkeypatch.setattr( - report_generator, - "generate_agent_report", - lambda target, workflow_run_id: ( - generated.append((target, workflow_run_id)) - or tmp_path / "data.zarr/agents/runs/report-workflow/report/index.html" - ), - ) - - orchestrator_main._generate_completed_report(local_store, completed) - - assert generated == [(local_store, completed.workflowRunId)] - assert "Agent workflow report:" in capsys.readouterr().out - - orchestrator_main._generate_completed_report( - local_store, - _workflow(status="running"), - ) - monkeypatch.setattr(orchestrator_main, "zarr_root_path", lambda _store: None) - orchestrator_main._generate_completed_report(local_store, completed) - assert len(generated) == 1 - + root.mkdir() + frozen = root / "numerical-artifact" + frozen.write_bytes(b"immutable") + output = report_directory(root, "exact-analysis", "workspace") monkeypatch.setattr( - orchestrator_main, - "zarr_root_path", - lambda _store: tmp_path / "data.zarr", + generator, "collect_analysis_artifacts", lambda *_: display_payload() ) - monkeypatch.setattr( - report_generator, - "generate_agent_report", - lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("plot failed")), - ) - orchestrator_main._generate_completed_report(local_store, completed) - - -def test_derived_report_files_do_not_change_workflow_record_discovery( - tmp_path: Path, -) -> None: - root = zarr.open_group(str(tmp_path / "data.zarr"), mode="w", zarr_format=3) - root.create_group("cellData") - assay = root.create_group("RNA") - assay.attrs["is_assay"] = True - assay.attrs["dataset_fingerprint"] = "dataset-rna" - workflow = create_agent_workflow(root, workflow_run_id="report-workflow") - report_dir = ( - tmp_path / "data.zarr" / "agents" / "runs" / workflow.workflowRunId / "report" + result = generator.render_analysis_report(SimpleNamespace(), snapshot(), output) + assert result == output / "index.html" + assert list(output.glob("*.html")) == [result] + before = result.read_text() + assert ( + generator.render_analysis_report(SimpleNamespace(), snapshot(), output) + == result ) - plot_dir = report_dir / "plots" - plot_dir.mkdir(parents=True) - (report_dir / "index.html").write_text("", encoding="utf-8") - (plot_dir / "final_umap.png").write_bytes(b"png") - (plot_dir / "final_umap.png.json").write_text("{}\n", encoding="utf-8") - - assert load_agent_workflow(root, workflow.workflowRunId) == workflow - assert list_agent_workflows(root, include_incomplete=True) == [workflow] + assert result.read_text() == before + assert frozen.read_bytes() == b"immutable" + assert not list(output.glob(".*.tmp")) -def test_report_store_request_and_result_validation_edges( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, +def test_public_report_opens_exact_journal_and_workspace( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - local_root = tmp_path / "data.zarr" - local_root.mkdir() + calls: list[Any] = [] + store = SimpleNamespace(workspace="workspace") - class LocalDataStore: - def __init__(self, *args: object, **kwargs: object) -> None: - self.args = args - self.kwargs = kwargs - self.workspace = kwargs.get("workspace") - self.z = object() + def open_store(target: Path, run_id: str, *, workspace: str | None) -> Any: + calls.append((target, run_id, workspace)) + return store - monkeypatch.setattr(report_artifacts, "DataStore", LocalDataStore) - monkeypatch.setattr(report_generator, "DataStore", LocalDataStore) - monkeypatch.setattr(report_artifacts, "zarr_root_path", lambda _store: None) - with pytest.raises(ValueError, match="local filesystem"): - report_artifacts._local_root(LocalDataStore()) - - monkeypatch.setattr( - report_artifacts, - "zarr_root_path", - lambda _store: local_root, - ) - assert report_artifacts._local_root(f"file://{local_root}") == local_root.resolve() - assert report_artifacts._local_root(str(local_root)) == local_root.resolve() - with pytest.raises(TypeError, match="local filesystem"): - report_artifacts._local_root(object()) - with pytest.raises(FileNotFoundError): - report_artifacts._local_root(tmp_path / "missing.zarr") - - workflow = _workflow() - with pytest.raises(ValueError, match="workspace"): - report_artifacts._open_datastore( - LocalDataStore(workspace="other"), - local_root, - workflow, - ) - opened = report_artifacts._open_datastore(local_root, local_root, workflow) - assert opened.args == (str(local_root),) - assert opened.kwargs["default_assay"] == "RNA" - assert opened.kwargs["zarr_mode"] == "r" + def load_snapshot(target: Any, run_id: str) -> dict[str, Any]: + assert target is store and run_id == "exact-analysis" + return snapshot() - request = AutomatedWorkflowRequest.get_example() - config = AutomatedWorkflowConfig.get_example() - valid_record = OrchestrationRequestRecord( - workflowRunId="workflow-1", - request=request, - config=config, - requestSha256=journal_module._sha256_model(request), - configSha256=journal_module._sha256_model(config), - ) - valid_record.contentSha256 = journal_module._record_checksum(valid_record) - current_record = valid_record + monkeypatch.setattr(journal, "open_analysis_store", open_store, raising=False) + monkeypatch.setattr(journal, "analysis_snapshot", load_snapshot, raising=False) monkeypatch.setattr( - journal_module, - "_read_model", - lambda *_args, **_kwargs: current_record, - ) - store = SimpleNamespace(z=object(), zw=object()) - assert report_artifacts._load_request(store, "agents", "workflow-1") == valid_record - - invalid_records = ( - ( - valid_record.model_copy(update={"workflowRunId": "another-workflow"}), - "another workflow", - ), - ( - valid_record.model_copy(update={"requestSha256": "f" * 64}), - "request checksum", - ), - ( - valid_record.model_copy(update={"configSha256": "f" * 64}), - "configuration checksum", - ), - ( - valid_record.model_copy(update={"contentSha256": "f" * 64}), - "request envelope", - ), + generator, "collect_analysis_artifacts", lambda *_: display_payload() ) - for current_record, message in invalid_records: - with pytest.raises(ValueError, match=message): - report_artifacts._load_request(store, "agents", "workflow-1") - - monkeypatch.setattr( - journal_module, - "_ensure_orchestration_store", - lambda _store: "agents/orchestrations", + path = generator.generate_agent_report( + tmp_path, "exact-analysis", workspace="workspace" ) - terminal_result: object | None = None - monkeypatch.setattr( - journal_module, - "_load_terminal_result", - lambda *_args, **_kwargs: terminal_result, + assert calls == [(tmp_path, "exact-analysis", "workspace")] + assert ( + path + == tmp_path / "workspace/agents/orchestrations/exact-analysis/report/index.html" ) - with pytest.raises(FileNotFoundError, match="no terminal result"): - report_artifacts._load_completed_result(store, workflow) - terminal_result = SimpleNamespace(status="failed", finalAnalysis=object()) - with pytest.raises(ValueError, match="final analysis"): - report_artifacts._load_completed_result(store, workflow) - terminal_result = SimpleNamespace(status="completed", finalAnalysis=object()) +def test_report_does_not_replace_existing_page_after_render_failure( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + old = tmp_path / "index.html" + old.write_text("previous report") monkeypatch.setattr( - report_artifacts, - "_load_request", - lambda *_args, **_kwargs: SimpleNamespace( - request=SimpleNamespace(workspace="other") - ), + generator, "collect_analysis_artifacts", lambda *_: display_payload() ) - with pytest.raises(ValueError, match="request workspace"): - report_artifacts._load_completed_result(store, workflow) - invalid_attempt = WorkflowStageAttempt( - status="failed", - startedAtNs=1, - completedAtNs=2, - error="not a valid error type!?: details", - ) - assert report_artifacts._stage_summary(invalid_attempt)["errorType"] == ( - "WorkflowStageError" - ) + def fail(_payload: Any) -> str: + raise RuntimeError("render failed") - data_store = LocalDataStore(workspace="analysis") - with pytest.raises(ValueError, match="workspace does not match"): - generate_agent_report( - data_store, - "report-workflow", - workspace="other", - ) + monkeypatch.setattr(generator, "render_analysis_document", fail) + with pytest.raises(RuntimeError, match="render failed"): + generator.render_analysis_report(SimpleNamespace(), snapshot(), tmp_path) + assert old.read_text() == "previous report" + assert not list(tmp_path.glob(".*.tmp")) -def test_hvg_report_evidence_uses_persisted_diagnostic_values( +def test_report_rejects_remote_paths_escaping_workspaces_and_incomplete_runs( tmp_path: Path, ) -> None: - root = zarr.open_group(str(tmp_path / "hvg.zarr"), mode="w", zarr_format=3) - groups: dict[str, Any] = {} + with pytest.raises(ValueError, match="local filesystem"): + _local_root("s3://example/data.zarr") + with pytest.raises(FileNotFoundError): + _local_root(tmp_path / "missing") + assert _local_root(f"file://{tmp_path}") == tmp_path + with pytest.raises(ValueError, match="outside"): + report_directory(tmp_path, "run", "../outside") + with pytest.raises(ValueError, match="identifier"): + report_directory(tmp_path, "../escape", None) + state = snapshot() + state["status"] = "needsInput" + with pytest.raises(ValueError, match="completed"): + generator.render_analysis_report(SimpleNamespace(), state, tmp_path / "report") + assert not (tmp_path / "report").exists() + + +def test_large_report_reads_saved_map_and_marker_table_without_analysis( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + store, refs, _ = display_store(monkeypatch) + marker_calls: list[str] = [] - def diagnostic( - artifact_id: str, - mode: str, - recurrence: list[int], - ) -> ArtifactReferenceModel: - group = root.create_group(artifact_id) - group.attrs["ranking_mode"] = mode - group.attrs["valid_groups"] = ["library-a", "library-b", "library-c"] - group.attrs["excluded_groups"] = [] - group.attrs["provenance"] = { - "parameters": { - "candidate_counts": [2, 4, 6], - "min_cells": 20, - "min_group_cells": 20, + def markers(_ref: Any, *, group_id: str) -> pd.DataFrame: + marker_calls.append(group_id) + return pd.DataFrame( + { + "feature_name": ["CD79A", "MS4A1", "CD74", "HLA-DRA"], + "score": [0.7, 0.9, 0.8, 0.6], } - } - group.create_array("ranking", data=np.arange(6, dtype=np.int64)) - group.create_array( - "global_corrected_variance", - data=np.array([6, 5, 4, 3, 2, 1], dtype=np.float64), - ) - group.create_array( - "recurrence", - data=np.asarray(recurrence, dtype=np.int32), ) - group.create_array( - "eligible", - data=np.ones(6, dtype=bool), - ) - groups[artifact_id] = group - return ArtifactReferenceModel( - assay="RNA", - kind="feature_summary", - artifactId=artifact_id, - ) - - global_ref = diagnostic("a" * 64, "global", [3, 2, 1, 1, 0, 0]) - batch_ref = diagnostic("b" * 64, "batchAware", [3, 3, 3, 2, 1, 1]) - class HvgStore: - def load_artifact(self, ref: Any) -> Any: - return groups[ref.artifact_id] + store.get_markers = markers + inspect = store.inspect_artifact - evidence = report_artifacts._collect_hvg_evidence( - HvgStore(), - [ - { - "artifacts": { - "RNA_hvg_global_diagnostic": global_ref.model_dump(mode="json"), - "RNA_hvg_batchAware_diagnostic": batch_ref.model_dump(mode="json"), - "RNA_hvg_diagnostic": batch_ref.model_dump(mode="json"), - } - } - ], - { - "assays": [ - { - "assay": "RNA", - "featureParameters": {"topN": 4}, - } - ] - }, - ) + def marker_status(ref: Any) -> Any: + status = inspect(ref) + if ref.kind == "marker_table": + status.inputs["clusters"] = refs["clusters"].to_dict() + return status - assert evidence["selectedRankingMode"] == "batchAware" - assert evidence["selectedFeatureCount"] == 4 - assert evidence["eligibleFeatureCount"] == 6 - assert evidence["validTechnicalGroups"] == 3 - assert evidence["candidateMetrics"] == [ - { - "featureCount": 2, - "varianceFraction": 11 / 21, - "recurrentFraction": 1.0, - }, - { - "featureCount": 4, - "varianceFraction": 18 / 21, - "recurrentFraction": 1.0, - }, - { - "featureCount": 6, - "varianceFraction": 1.0, - "recurrentFraction": 4 / 6, + store.inspect_artifact = marker_status + final = { + "umap": ArtifactReferenceModel.from_artifact_ref(refs["umap"]).model_dump(), + "clusters": ArtifactReferenceModel.from_artifact_ref( + refs["clusters"] + ).model_dump(), + "cellSelection": ArtifactReferenceModel.from_artifact_ref( + refs["cell_selection"] + ).model_dump(), + "graph": ArtifactReferenceModel.from_artifact_ref(refs["graph"]).model_dump(), + "markers": { + "scope": "assay", + "assay": "RNA2", + "kind": "marker_table", + "artifactId": "5" * 64, }, - ] - assert evidence["rankings"][0]["meanTechnicalGroupCoverage"] == 7 / 18 - assert evidence["rankings"][1]["meanTechnicalGroupCoverage"] == 13 / 18 - - -def test_report_renderer_edge_branches() -> None: - assert report_plots._safe_assay_name("RNA / strange assay", "fallback") == ( - "rna_strange_assay" - ) - assert report_plots._safe_assay_name("***", "fallback") == "fallback" - assert report_contracts._scalar(None) == "Not provided" - assert "Nothing" in report_rendering._chips(None, empty="Nothing") - assert "value" in report_rendering._chips("value") - public_text = report_contracts._brief_text( - f"Preserve donor_id from {'a' * 64} and 12345678-1234-1234-1234-123456789abc." - ) - assert "donor_id" not in public_text - assert "a" * 64 not in public_text - assert "12345678-1234-1234-1234-123456789abc" not in public_text - assert report_contracts._latest({"agent": {"status": "done"}}, "agent") == { - "status": "done" } - assert report_contracts._latest({}, "agent") == {} - - native_plot = report_plots._render_plots( - {"nativeUmapRna": "plots/native.png"}, - [], - ) - assert "Rna native UMAP" in native_plot - assert "finalized native Rna" in native_plot - assert "No final cluster counts" in report_rendering._render_clusters({}) - - legacy_parameter = { - "fromAssay": "RNA", - "evaluations": [{"candidateId": "native"}], - } - assert report_rendering._parameter_rows(legacy_parameter)[0]["assay"] == "RNA" - assert "No Parameter Tuning report" in report_rendering._render_parameter_tuning({}) - rendered_parameter = report_rendering._render_parameter_tuning( - { - "fromAssay": "RNA", - "searchPlan": {"status": "refine"}, - "comparisons": [{"candidateId": "native"}], - "finalSelection": {"comparisons": [{"candidateId": "integrated"}]}, - } - ) - assert "RNA" in rendered_parameter - assert "final graph" in rendered_parameter - assert "No provider execution metadata" in report_rendering._render_executions({}) - - -def test_wide_technical_records_use_readable_card_layout() -> None: - wide_row = {f"field_{index}": f"value {index}" for index in range(8)} - wide_row["_selected"] = True - wide_markup = report_rendering._table([wide_row]) - - assert "Name" in compact_markup - - -def test_report_collects_bounded_artifact_branches( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - def artifact( - kind: str, - digit: str, - *, - scope: Literal["assay", "datastore"] = "assay", - ) -> ArtifactReferenceModel: - return ArtifactReferenceModel( - scope=scope, - assay=None if scope == "datastore" else "RNA", - kind=kind, - artifactId=digit * 64, - ) - - final_clusters = artifact("cluster_labels", "3") - final_umap = artifact("embedding", "4") - final = FinalAnalysisHandoff( - workflowRunId="report-workflow", - primaryAssay="RNA", - markerAssay="RNA", - cellSelection=artifact("cell_selection", "c", scope="datastore"), - graph=artifact("connectivity_map", "2"), - clusters=final_clusters, - umap=final_umap, - markers=artifact("marker_table", "5"), - nativeAnalyses=[ - NativeAnalysisHandoff.get_blank(), - NativeAnalysisHandoff( - assay="RNA / strange assay", - reductionMethod="pca", - clusters=artifact("cluster_labels", "6"), - umap=artifact("embedding", "7"), - ), - NativeAnalysisHandoff( - assay="RNA / strange assay", - reductionMethod="pca", - clusters=artifact("cluster_labels", "8"), - umap=artifact("embedding", "9"), - ), - ], - ).with_handoff_id() - workflow = _workflow() - result = AutomatedWorkflowResult( - status="completed", - currentStage="analysis_finalization", - zarrPath=str(tmp_path / "data.zarr"), - workflowRun=workflow, - finalAnalysis=final, - finalHandoffId=final.handoffId, - decisionRunId=workflow.workflowRunId, - ) - - class PlotMethods: - @staticmethod - def marker_heatmap(**_kwargs: object) -> object: - raise RuntimeError("heatmap unavailable") - - class ArtifactStore: - plots = PlotMethods() - - @staticmethod - def load_artifact(reference: object) -> dict[str, np.ndarray]: - if getattr(reference, "artifact_id", None) == "3" * 64: - return {"values": np.asarray(["0", "0", "1"])} - return {} - - @staticmethod - def inspect_artifact(_reference: object) -> SimpleNamespace: - return SimpleNamespace( - parameters={ - "normalization": { - "log_transform": True, - "renormalize_subset": True, - } - } - ) - - @staticmethod - def get_markers( - _marker: object, - *, - group_id: str, - min_score: float, - min_frac_exp: float, - ) -> pd.DataFrame: - assert min_score == -1 - assert min_frac_exp == -1 - if group_id == "1": - raise RuntimeError("marker table unavailable") - return pd.DataFrame( - { - "group_id": ["unknown", "0", "0"], - "feature_name": ["ignored", "CD3D", "unresolved"], - "feature_id": ["ignored-id", "ENSG00000167286", None], - "feature_index": [None, None, None], - "score": [3.0, 2.0, 1.0], - } - ) - - monkeypatch.setattr(report_plots, "MAX_EMBEDDING_PLOT_CELLS", 0) - monkeypatch.setattr(report_plots, "MAX_COMPOSITION_PLOT_CELLS", 0) - monkeypatch.setattr(report_plots, "MAX_DOTPLOT_CELLS", 0) - monkeypatch.setattr(report_plots, "MAX_CONNECTIVITY_PLOT_CELLS", 0) - - store = ArtifactStore() - counts, markers, plots, notes = report_plots._collect_final_artifacts( - store, - result, - tmp_path / "plots", - ) - assert counts == {"0": 2, "1": 1} - assert len(markers) == 3 - assert plots == {} - assert any("nativeUmapRnaStrangeAssay2" in note for note in notes) - assert any("marker export for cluster 1" in note for note in notes) - assert any("markerDotplot: skipped" in note for note in notes) - assert any("clusterConnectivity: skipped" in note for note in notes) - - monkeypatch.setattr(report_plots, "MAX_MARKER_DOTPLOT_FEATURES", 1) - _counts, _markers, _plots, one_marker_notes = report_plots._collect_final_artifacts( - store, result, tmp_path / "plots-one" - ) - assert any("markerDotplot: skipped" in note for note in one_marker_notes) - - monkeypatch.setattr(report_plots, "MAX_MARKER_DOTPLOT_FEATURES", object()) - _counts, _markers, _plots, invalid_limit_notes = ( - report_plots._collect_final_artifacts(store, result, tmp_path / "plots-invalid") - ) - assert any("markerDotplot: TypeError" in note for note in invalid_limit_notes) - - incomplete = result.model_copy( - update={"finalAnalysis": FinalAnalysisHandoff.get_blank()} - ) - with pytest.raises(ValueError, match="lacks its selection"): - report_plots._collect_final_artifacts( - store, - incomplete, - tmp_path / "plots-incomplete", - ) - - -def test_data_enrichment_cache_rollback_and_pending_branches( - monkeypatch: pytest.MonkeyPatch, -) -> None: - inspection = AssayFeatureInspection.get_example() - completed = DataEnrichmentDependencies( - store=object(), - assays=["RNA"], - inspections={"RNA": inspection}, - toolCalls=[ - DataEnrichmentToolCall( - name="inspect_assay_features_batch", - assay="all", - ) - ], - ) - completed_context = SimpleNamespace(deps=completed) - - assert ( - asyncio.run( - enrichment_tools.inspect_assay_features( - completed_context, - assay_name="RNA", - ) - ) - == inspection - ) - cached_batch = asyncio.run( - enrichment_tools.inspect_assay_features_batch(completed_context) - ) - assert cached_batch.inspections == [inspection] - assert cached_batch.evidenceIds == inspection.evidenceIds - - incomplete = DataEnrichmentDependencies( - assays=["RNA"], - toolCalls=[DataEnrichmentToolCall(name="sentinel", assay="RNA")], - ) - with pytest.raises(ModelRetry, match="datastore"): - asyncio.run( - enrichment_tools.inspect_assay_features_batch( - SimpleNamespace(deps=incomplete) - ) - ) - assert [call.name for call in incomplete.toolCalls] == ["sentinel"] - - provider_error = UnexpectedModelBehavior("provider output failed") - with pytest.raises(UnexpectedModelBehavior, match="provider output failed"): - enrichment_validation.pending_data_enrichment_report( - DataEnrichmentDependencies(assays=["RNA"]), - error=provider_error, - model_name="test-model", - ) - pending = enrichment_validation.pending_data_enrichment_report( - DataEnrichmentDependencies( - assays=["RNA"], - inspections={"RNA": inspection}, - evidenceIds=set(inspection.evidenceIds), - ), - error=provider_error, - model_name="test-model", - ) - assert pending.status == "needsInput" - assert pending.policies == [] - assert pending.inspections == [inspection] - - def fail_before_inspection(**_kwargs: object) -> object: - raise UnexpectedModelBehavior("no inspection completed") - - monkeypatch.setattr(enrichment_agent, "run_agent_sync", fail_before_inspection) - store = SimpleNamespace(assay_names=["RNA"]) - with pytest.raises(UnexpectedModelBehavior, match="no inspection completed"): - DataEnrichmentAgent(object()).run(store) - - -def test_biological_interpretation_cache_and_fallback_branches() -> None: - composition = ClusterCompositionEvidence.get_example() - composition_deps = BiologicalInterpretationDependencies( - compositionEvidence=composition - ) - assert ( - asyncio.run( - biological_tools.inspect_cluster_composition( - SimpleNamespace(deps=composition_deps) - ) - ) - == composition - ) - - marker = ClusterMarkerEvidence.get_example() - marker_deps = BiologicalInterpretationDependencies( - clusterValues={marker.clusterId: 0}, - markerEvidence={marker.clusterId: marker}, - ) - assert ( - asyncio.run( - biological_tools.inspect_cluster_markers( - SimpleNamespace(deps=marker_deps), - cluster_id=marker.clusterId, - ) - ) - == marker - ) - - invalid_report = BiologicalInterpretationReport( - status="done", - needsInput=BiologicalInterpretationNeedsInput(question="More context?"), - ) - with pytest.raises(ModelRetry, match="Only a needsInput"): - biological_validation.validate_biological_interpretation_report( - invalid_report, - BiologicalInterpretationDependencies(clusterValues={"0": 0}), - ) - - provider_error = UnexpectedModelBehavior("structured output failed") - with pytest.raises(UnexpectedModelBehavior, match="structured output failed"): - biological_validation.fallback_biological_interpretation_report( - BiologicalInterpretationDependencies(), - error=provider_error, - model_name="test-model", - ) - needs_markers = biological_validation.fallback_biological_interpretation_report( - BiologicalInterpretationDependencies( - clusterValues={"0": 0}, - evidenceIds={"composition:clusters"}, - ), - error=provider_error, - model_name="test-model", - ) - assert needs_markers.status == "needsInput" - assert needs_markers.needsInput is not None - assert needs_markers.evidenceIds == ["composition:clusters"] + data = plots.collect_analysis_artifacts(store, final, tmp_path) + assert data["displayNotes"] == [] + assert data["displayedCells"] == 50_000 + assert sum(data["clusterCounts"].values()) == 621_200 + assert len(data["markers"]) == 12 and len(marker_calls) == 4 + assert data["markers"][0]["feature"] == "MS4A1" + assert (tmp_path / "plots/final_umap.png").is_file() + provenance = json.loads((tmp_path / "plots/final_umap.png.json").read_text()) + assert provenance["provenance"]["extras"]["input_n_cells"] == 621_200 + assert list((tmp_path / "plots").glob("*.png")) == [ + tmp_path / "plots/final_umap.png" + ] -def test_experimental_context_rejects_invalid_batches_and_builds_pending_result( - monkeypatch: pytest.MonkeyPatch, +def test_optional_map_failure_preserves_counts_and_report( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - invalid_batches = ( - ( - [{"name": "batch", "domain": "technical", "kind": "categorical"}], - "missing", - "Unknown batch column", - ), - ( - [{"name": "condition", "domain": "biological", "kind": "categorical"}], - "condition", - "must be classified as technical", - ), - ( - [{"name": "depth", "domain": "technical", "kind": "continuous"}], - "depth", - "must be categorical", - ), - ) - for columns, batch_column, message in invalid_batches: - deps = ExperimentalContextDependencies( - characterization=CovariateCharacterization( - status="done", - columns=columns, - ) - ) - with pytest.raises(ModelRetry, match=message): - asyncio.run( - experimental_tools.analyze_experimental_design( - SimpleNamespace(deps=deps), - column_domains={}, - coefficients_of_interest=[], - units_of_inference={}, - batch_columns=[batch_column], - ) - ) - - characterization = CovariateCharacterization( - status="done", - columns=[{"name": "condition", "domain": "biological", "kind": "categorical"}], - ) - monkeypatch.setattr( - experimental_validation, - "characterize_covariates", - lambda *_args, **_kwargs: characterization, - ) - - def offer_profile( - deps: ExperimentalContextDependencies, - _characterization: CovariateCharacterization, - ) -> list[CellQcProfileEvidence]: - profile = CellQcProfileEvidence.get_example() - deps.qcProfiles[profile.profileId] = profile - return [profile] - - monkeypatch.setattr( - experimental_validation, - "_offered_qc_profiles", - offer_profile, - ) - pending_deps = ExperimentalContextDependencies( - cellSelection=ArtifactReferenceModel( - scope="datastore", - kind="cell_selection", - artifactId="c" * 64, - ), - htoIdentityColumns=["hto_identity"], - ) - pending = experimental_validation.pending_experimental_context_result( - pending_deps, - error=UnexpectedModelBehavior("design output failed"), - model_name="test-model", - ) - assert pending.status == "needsInput" - assert pending_deps.characterization is characterization - assert pending.cellQc.profileId == "" - assert pending.qcProfiles[0].profileId == ( - CellQcProfileEvidence.get_example().profileId - ) - - -def test_agent_execution_logs_nested_failures_for_sync_and_async_runners( - monkeypatch: pytest.MonkeyPatch, + store, refs, _ = display_store(monkeypatch, n=12) + + def unavailable(*_args: Any, **_kwargs: Any) -> Any: + raise ImportError("matplotlib unavailable") + + monkeypatch.setattr(plots, "plot_final_umap", unavailable) + state = snapshot() + state["finalAnalysis"]["clusters"] = ArtifactReferenceModel.from_artifact_ref( + refs["clusters"] + ).model_dump() + for name, ref_name in ( + ("umap", "umap"), + ("cellSelection", "cell_selection"), + ("graph", "graph"), + ): + state["finalAnalysis"][name] = ArtifactReferenceModel.from_artifact_ref( + refs[ref_name] + ).model_dump() + path = generator.render_analysis_report(store, state, tmp_path) + document = path.read_text() + assert "12 cells" in document + assert "matplotlib unavailable" in document + assert "Selected 0.75 because seed stability was 0.92" in document + assert 'src="plots/final_umap.png"' not in document + + +def test_invalid_map_lineage_is_not_hidden_as_optional_display_failure( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - class FailingAgent: - async def __aenter__(self) -> "FailingAgent": - return self - - async def __aexit__(self, *_args: object) -> bool: - return False - - async def run(self, *_args: object, **_kwargs: object) -> object: - try: - raise ValueError("inner failure") - except ValueError as cause: - raise RuntimeError("outer failure") from cause - - monkeypatch.setattr( - agent_exec_module, - "_build_agent", - lambda **_kwargs: FailingAgent(), - ) - messages: list[str] = [] - monkeypatch.setattr(agent_exec_module.logger, "error", messages.append) - with pytest.raises(RuntimeError, match="outer failure"): - agent_exec_module.run_agent_sync( - model=object(), - output_type=dict, - system_prompt="system", - user_prompt="user", - name="sync-failure", - ) - with pytest.raises(RuntimeError, match="outer failure"): - asyncio.run( - agent_exec_module.run_agent( - model=object(), - output_type=dict, - system_prompt="system", - user_prompt="user", - name="async-failure", - ) + store, refs, _ = display_store(monkeypatch, n=12) + final = { + name: ArtifactReferenceModel.from_artifact_ref(refs[ref_name]).model_dump() + for name, ref_name in ( + ("umap", "umap"), + ("clusters", "clusters"), + ("graph", "graph"), + ("cellSelection", "cell_selection"), ) - assert all("caused by ValueError: inner failure" in message for message in messages) - assert "sync-failure" in messages[0] - assert "async-failure" in messages[1] + } + def mismatch(*_args: Any, **_kwargs: Any) -> Any: + raise ValueError("Final artifacts must share the exact frozen cell selection") -def test_journal_retryable_error_handles_missing_optional_dependency( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setitem(sys.modules, "pydantic_ai", None) - assert journal_module.is_retryable_model_error(RuntimeError("unavailable")) is False + monkeypatch.setattr(plots, "plot_final_umap", mismatch) + with pytest.raises(ValueError, match="exact frozen cell selection"): + plots.collect_analysis_artifacts(store, final, tmp_path) + assert not list(tmp_path.iterdir()) diff --git a/tests/test_agent_report_persistence.py b/tests/test_agent_report_persistence.py deleted file mode 100644 index 3640c294..00000000 --- a/tests/test_agent_report_persistence.py +++ /dev/null @@ -1,944 +0,0 @@ -"""Tests for immutable JSON agent reports embedded in a Scarf store.""" - -import json -import re -import warnings -from pathlib import Path - -import numpy as np -import pytest -import zarr -from pydantic import ValidationError -from zarr.errors import ZarrUserWarning - -import scarf.agent as agent_api -import scarf.agent.record_io as record_io -from scarf.agent.biological_interpretation import BiologicalInterpretationReport -from scarf.agent.config import AgentRunConfig -from scarf.agent.data_enrichment import DataEnrichmentReport -from scarf.agent.experimental_context import ExperimentalContextResult -from scarf.agent.parameter_tuning import ArtifactRecord, ParameterTuningReport -from scarf.agent.persistence import ( - AgentInvocation, - AgentReportLink, - AgentReportRecord, - AgentReportReference, - AgentWorkflowRun, - create_agent_workflow, - finalize_agent_workflow, - list_agent_workflows, - load_agent_record, - load_agent_report, - load_agent_workflow, - save_agent_report, -) -from scarf.agent.types import ( - AgentDataModel, - ArtifactReferenceModel, - ExperimentalTuningHandoff, - TuningBiologyHandoff, -) -from scarf.datastore.datastore import DataStore -from scarf.storage.schema import create_cell_data, create_zarr_count_assay - - -REPORT_CASES = ( - (DataEnrichmentReport, "data_enrichment"), - (ExperimentalContextResult, "experimental_context"), - (ParameterTuningReport, "parameter_tuning"), - (BiologicalInterpretationReport, "biological_interpretation"), -) - - -class AnyReport(AgentDataModel): - value: str = "" - - @classmethod - def get_example(cls) -> "AnyReport": - return cls(value="not an agent report") - - -def _populate_scarf_group( - group: zarr.Group, - *, - fingerprints: dict[str, str | None], -) -> None: - values = np.asarray( - [ - [4, 0, 1, 0], - [0, 3, 0, 2], - [2, 1, 0, 0], - [0, 0, 5, 1], - ], - dtype=np.uint32, - ) - cell_ids = np.asarray([f"cell-{index}" for index in range(values.shape[0])]) - feature_ids = np.asarray([f"feature-{index}" for index in range(values.shape[1])]) - feature_names = np.asarray(["MT-CO1", "RPS3", "GENE1", "GENE2"]) - create_cell_data( - group, - None, - ids=cell_ids, - names=cell_ids, - profile="fast_local", - ) - for assay_name, fingerprint in fingerprints.items(): - counts = create_zarr_count_assay( - group, - assay_name, - None, - values.shape[0], - feat_ids=feature_ids, - feat_names=feature_names, - dtype="uint32", - profile="fast_local", - ) - counts[:] = values - if fingerprint is not None: - group[assay_name].attrs["dataset_fingerprint"] = fingerprint - group.attrs["assayTypes"] = {assay_name: "Assay" for assay_name in fingerprints} - - -def _create_scarf_store( - tmp_path: Path, - *, - fingerprints: dict[str, str | None] | None = None, -) -> Path: - path = tmp_path / "data.zarr" - root = zarr.open_group(str(path), mode="w", zarr_format=3) - _populate_scarf_group( - root, - fingerprints=fingerprints or {"RNA": "dataset-rna"}, - ) - return path - - -def _create_workspace_store(tmp_path: Path) -> Path: - path = tmp_path / "data.zarr" - root = zarr.open_group(str(path), mode="w", zarr_format=3) - for workspace in ("workspace_a", "workspace_b"): - group = root.create_group(workspace) - _populate_scarf_group( - group, - fingerprints={"RNA": f"dataset-{workspace}"}, - ) - return path - - -def _report( - report_type: type[AgentDataModel], - execution_run_id: str, -) -> AgentDataModel: - report = report_type.get_example() - return report.model_copy( - update={ - "runInfo": report.runInfo.model_copy( - update={ - "agentName": "intentionally_misleading", - "modelName": "modèle-α", - "runId": execution_run_id, - } - ) - } - ) - - -def _experimental_report(execution_run_id: str) -> ExperimentalContextResult: - report = _report(ExperimentalContextResult, execution_run_id) - assert isinstance(report, ExperimentalContextResult) - characterization = report.characterization.model_copy( - update={ - "coefficients": [ - { - "name": "treatment", - "observationUnit": "sample", - "independentUnit": "donor", - "scope": "between", - } - ] - } - ) - return report.model_copy(update={"characterization": characterization}) - - -def _tuning_report(execution_run_id: str) -> ParameterTuningReport: - report = _report(ParameterTuningReport, execution_run_id) - assert isinstance(report, ParameterTuningReport) - evaluation = report.evaluations[0] - artifacts = { - **evaluation.artifacts, - "clusters": ArtifactRecord( - scope="assay", - kind="clusters", - artifactId="c" * 64, - assay="RNA", - ), - } - evaluation = evaluation.model_copy(update={"artifacts": artifacts}) - return report.model_copy( - update={ - "evaluations": [evaluation], - "selectedArtifacts": artifacts, - } - ) - - -def _invocation( - agent_name: str, - *, - parents: list[AgentReportLink] | None = None, - **updates: object, -) -> AgentInvocation: - values: dict[str, object] = { - "agentName": agent_name, - "parentReports": parents or [], - "inputs": {"fromAssay": "RNA"}, - "artifacts": {"input": ArtifactReferenceModel.get_example()}, - "runConfig": AgentRunConfig.get_example(), - } - values.update(updates) - artifacts = dict(values["artifacts"]) - for handoff_name in ( - "experimentalTuningHandoff", - "experimentalBiologyHandoff", - ): - handoff = values.get(handoff_name) - selection = getattr(handoff, "cellSelection", None) - if isinstance(selection, ArtifactReferenceModel): - artifacts["cellSelection"] = selection - values["artifacts"] = artifacts - return AgentInvocation.model_validate(values) - - -def _report_path( - path: Path, - reference: AgentReportReference, - *, - workspace: str | None = None, -) -> Path: - base = path if workspace is None else path / workspace - return ( - base - / "agents" - / "runs" - / reference.workflowRunId - / reference.agentName - / reference.agentRunId - / "report.json" - ) - - -def test_public_persistence_models_have_factories_and_exports() -> None: - model_types = ( - AgentReportLink, - AgentInvocation, - AgentReportReference, - AgentReportRecord, - AgentWorkflowRun, - ) - for model_type in model_types: - assert isinstance(model_type.get_blank(), model_type) - assert isinstance(model_type.get_example(), model_type) - assert all("_" not in field_name for field_name in model_type.model_fields) - - assert agent_api.AgentInvocation is AgentInvocation - assert agent_api.AgentReportLink is AgentReportLink - assert agent_api.AgentReportRecord is AgentReportRecord - assert agent_api.create_agent_workflow is create_agent_workflow - assert agent_api.finalize_agent_workflow is finalize_agent_workflow - assert agent_api.load_agent_record is load_agent_record - assert agent_api.save_agent_report is save_agent_report - - -def test_record_io_preserves_json_bytes_and_store_key_order(tmp_path: Path) -> None: - value = {"z": ["é", 1], "a": True} - assert record_io.canonical_json_bytes(value) == b'{"a":true,"z":["\xc3\xa9",1]}' - assert record_io.display_json_bytes(value) == ( - '{\n "a": true,\n "z": [\n "é",\n 1\n ]\n}\n'.encode() - ) - assert record_io.join_key("/agents/", "", "/runs/") == "agents/runs" - - path = _create_scarf_store(tmp_path) - create_agent_workflow(path, workflow_run_id="workflow-1") - root = zarr.open_group(str(path), mode="r") - workflow_key = "agents/runs/workflow-1/workflow.json" - - assert record_io.list_keys(root, "agents/runs") == [workflow_key] - assert record_io.read_key(root, workflow_key) == (path / workflow_key).read_bytes() - assert record_io.read_key(root, "agents/missing.json") is None - - -def test_persistence_models_reject_invalid_identity_and_duplicate_parents() -> None: - with pytest.raises(ValidationError, match="safe path component"): - AgentReportLink(workflowRunId="../workflow") - with pytest.raises(ValidationError, match="SHA-256"): - AgentReportReference(contentSha256="not-a-digest") - with pytest.raises(ValidationError, match="Workspace name"): - AgentWorkflowRun(workspace="nested/workspace") - with pytest.raises(ValidationError, match="cannot precede"): - AgentWorkflowRun( - workflowRunId="workflow-1", - createdAtNs=2, - finalizedAtNs=1, - status="failed", - datasetFingerprints={"RNA": "dataset-rna"}, - ) - - parent = AgentReportLink.get_example() - with pytest.raises(ValidationError, match="duplicate"): - AgentInvocation( - agentName="parameter_tuning", - parentReports=[parent, parent], - ) - - -def test_reports_are_plain_json_under_metadata_only_zarr_group( - tmp_path: Path, -) -> None: - path = _create_scarf_store(tmp_path) - workflow = create_agent_workflow( - path, - workflow_run_id="workflow-1", - dataset_fingerprints={"RNA": "dataset-rna"}, - ) - expected_files = { - "zarr.json", - "store.json", - "runs/workflow-1/workflow.json", - } - - for report_type, agent_name in REPORT_CASES: - report = _report(report_type, f"provider-{agent_name}") - reference = save_agent_report( - path, - workflow.workflowRunId, - report, # type: ignore[arg-type] - invocation=_invocation(agent_name), # type: ignore[arg-type] - agent_run_id=f"{agent_name}-run", - ) - record = load_agent_record(path, reference) - - assert reference.agentName == agent_name - assert record.invocation.inputs == {"fromAssay": "RNA"} - assert record.invocation.runConfig == AgentRunConfig.get_example() - assert load_agent_report(path, reference).model_dump(mode="json") == ( - report.model_dump(mode="json") - ) - expected_files.add(f"runs/workflow-1/{agent_name}/{agent_name}-run/report.json") - - agents_path = path / "agents" - actual_files = { - item.relative_to(agents_path).as_posix() - for item in agents_path.rglob("*") - if item.is_file() - } - assert actual_files == expected_files - assert not any( - item.name in {".zarray"} or "c" in item.relative_to(agents_path).parts - for item in agents_path.rglob("*") - ) - root = zarr.open_group(str(path), mode="r") - with warnings.catch_warnings(): - warnings.simplefilter("error", ZarrUserWarning) - assert "agents" in root.group_keys() - agents = root["agents"] - assert isinstance(agents, zarr.Group) - assert agents.attrs.asdict() == { - "format": "scarf_agent_reports", - "format_version": 2, - } - metadata = json.loads((agents_path / "zarr.json").read_text(encoding="utf-8")) - assert metadata["node_type"] == "group" - assert root.attrs.get("format") != "scarf_agent_reports" - - -def test_datastore_target_generates_a_missing_dataset_fingerprint( - tmp_path: Path, -) -> None: - path = _create_scarf_store(tmp_path, fingerprints={"RNA": None}) - datastore = DataStore( - str(path), - assay_types={"RNA": "Assay"}, - default_assay="RNA", - min_features_per_cell=0, - nthreads=1, - mem_budget="64M", - ) - - workflow = create_agent_workflow(datastore, workflow_run_id="workflow-1") - - fingerprint = datastore._get_assay("RNA").attrs["dataset_fingerprint"] - assert workflow.datasetFingerprints == {"RNA": fingerprint} - assert load_agent_workflow(datastore, workflow.workflowRunId) == workflow - - -@pytest.mark.parametrize( - "supplied", - [ - {"RNA": "dataset-rna"}, - {"RNA": "dataset-rna", "ATAC": "dataset-atac", "ADT": "unknown"}, - {"RNA": "wrong", "ATAC": "dataset-atac"}, - {"RNA": "dataset-rna", "ATAC": ""}, - ], -) -def test_workflow_creation_requires_exact_all_assay_fingerprints( - tmp_path: Path, - supplied: dict[str, str], -) -> None: - path = _create_scarf_store( - tmp_path, - fingerprints={"RNA": "dataset-rna", "ATAC": "dataset-atac"}, - ) - - with pytest.raises(ValueError, match="do not match"): - create_agent_workflow( - path, - workflow_run_id="workflow-1", - dataset_fingerprints=supplied, - ) - - assert not (path / "agents").exists() - - -def test_load_fails_closed_when_dataset_binding_changes(tmp_path: Path) -> None: - path = _create_scarf_store( - tmp_path, - fingerprints={"RNA": "dataset-rna", "ATAC": "dataset-atac"}, - ) - workflow = create_agent_workflow( - path, - workflow_run_id="workflow-1", - dataset_fingerprints={"ATAC": "dataset-atac", "RNA": "dataset-rna"}, - ) - assert workflow.datasetFingerprints == { - "ATAC": "dataset-atac", - "RNA": "dataset-rna", - } - - root = zarr.open_group(str(path), mode="r+") - root["ATAC"].attrs["dataset_fingerprint"] = "changed" - - with pytest.raises(ValueError, match="do not match"): - load_agent_workflow(path, workflow.workflowRunId) - with pytest.raises(ValueError, match="do not match"): - list_agent_workflows(path, include_incomplete=True) - - -def test_path_target_rejects_missing_assay_fingerprints(tmp_path: Path) -> None: - path = _create_scarf_store( - tmp_path, - fingerprints={"RNA": "dataset-rna", "ATAC": None}, - ) - - with pytest.raises(ValueError, match="missing.*ATAC"): - create_agent_workflow(path, workflow_run_id="workflow-1") - - -def test_lineage_distinguishes_parallel_reports_and_persists_typed_handoffs( - tmp_path: Path, -) -> None: - path = _create_scarf_store(tmp_path) - create_agent_workflow(path, workflow_run_id="workflow-1") - - experimental_1 = _experimental_report("experimental-provider-1") - experimental_2 = _experimental_report("experimental-provider-2") - experimental_ref_1 = save_agent_report( - path, - "workflow-1", - experimental_1, - invocation=_invocation("experimental_context"), - agent_run_id="e1", - ) - experimental_ref_2 = save_agent_report( - path, - "workflow-1", - experimental_2, - invocation=_invocation("experimental_context"), - agent_run_id="e2", - ) - experimental_link_1 = AgentReportLink.from_reference(experimental_ref_1) - experimental_link_2 = AgentReportLink.from_reference(experimental_ref_2) - - tuning_1 = _tuning_report("tuning-provider-1") - tuning_2 = _tuning_report("tuning-provider-2") - tuning_ref_1 = save_agent_report( - path, - "workflow-1", - tuning_1, - invocation=_invocation( - "parameter_tuning", - parents=[experimental_link_1], - experimentalTuningHandoff=(experimental_1.to_parameter_tuning_handoff()), - ), - agent_run_id="t1", - ) - tuning_ref_2 = save_agent_report( - path, - "workflow-1", - tuning_2, - invocation=_invocation( - "parameter_tuning", - parents=[experimental_link_2], - experimentalTuningHandoff=(experimental_2.to_parameter_tuning_handoff()), - ), - agent_run_id="t2", - ) - tuning_link_1 = AgentReportLink.from_reference(tuning_ref_1) - - biology_ref = save_agent_report( - path, - "workflow-1", - BiologicalInterpretationReport.get_example(), - invocation=_invocation( - "biological_interpretation", - parents=[experimental_link_1, tuning_link_1], - experimentalBiologyHandoff=experimental_1.to_biological_handoff(), - tuningBiologyHandoff=tuning_1.to_biological_handoff(), - ), - agent_run_id="b1", - ) - - record_t1 = load_agent_record(path, tuning_ref_1) - record_t2 = load_agent_record(path, tuning_ref_2) - record_b1 = load_agent_record(path, biology_ref) - assert record_t1.invocation.parentReports == [experimental_link_1] - assert record_t2.invocation.parentReports == [experimental_link_2] - assert record_b1.invocation.parentReports == [ - experimental_link_1, - tuning_link_1, - ] - assert record_b1.reference.parentReports == record_b1.invocation.parentReports - assert ( - record_b1.invocation.experimentalBiologyHandoff - == experimental_1.to_biological_handoff() - ) - assert record_b1.invocation.tuningBiologyHandoff == tuning_1.to_biological_handoff() - - -def test_biology_may_cite_context_without_a_treatment_handoff( - tmp_path: Path, -) -> None: - path = _create_scarf_store(tmp_path) - create_agent_workflow(path, workflow_run_id="workflow-1") - experimental = _experimental_report("experimental-provider") - experimental_ref = save_agent_report( - path, - "workflow-1", - experimental, - invocation=_invocation("experimental_context"), - agent_run_id="e1", - ) - experimental_link = AgentReportLink.from_reference(experimental_ref) - tuning = _tuning_report("tuning-provider") - tuning_ref = save_agent_report( - path, - "workflow-1", - tuning, - invocation=_invocation( - "parameter_tuning", - parents=[experimental_link], - experimentalTuningHandoff=experimental.to_parameter_tuning_handoff(), - ), - agent_run_id="t1", - ) - tuning_link = AgentReportLink.from_reference(tuning_ref) - - biology_ref = save_agent_report( - path, - "workflow-1", - BiologicalInterpretationReport.get_example(), - invocation=_invocation( - "biological_interpretation", - parents=[experimental_link, tuning_link], - tuningBiologyHandoff=tuning.to_biological_handoff(), - ), - agent_run_id="b1", - ) - - record = load_agent_record(path, biology_ref) - assert record.invocation.parentReports == [experimental_link, tuning_link] - assert record.invocation.experimentalBiologyHandoff is None - - -def test_lineage_rejects_unknown_cross_workflow_and_changed_parent_links( - tmp_path: Path, -) -> None: - path = _create_scarf_store(tmp_path) - create_agent_workflow(path, workflow_run_id="workflow-1") - create_agent_workflow(path, workflow_run_id="workflow-2") - experimental = _experimental_report("experimental-provider") - reference = save_agent_report( - path, - "workflow-1", - experimental, - invocation=_invocation("experimental_context"), - agent_run_id="e1", - ) - link = AgentReportLink.from_reference(reference) - handoff = experimental.to_parameter_tuning_handoff() - - invalid_links = ( - link.model_copy(update={"agentRunId": "unknown"}), - link.model_copy(update={"workflowRunId": "workflow-2"}), - link.model_copy(update={"contentSha256": "f" * 64}), - ) - for index, invalid_link in enumerate(invalid_links): - with pytest.raises(ValueError, match="parent|Parent"): - save_agent_report( - path, - "workflow-1", - _tuning_report(f"tuning-provider-{index}"), - invocation=_invocation( - "parameter_tuning", - parents=[invalid_link], - experimentalTuningHandoff=handoff, - ), - agent_run_id=f"invalid-{index}", - ) - - -def test_typed_handoffs_must_match_their_cited_parent_reports( - tmp_path: Path, -) -> None: - path = _create_scarf_store(tmp_path) - create_agent_workflow(path, workflow_run_id="workflow-1") - experimental = _experimental_report("experimental-provider") - experimental_ref = save_agent_report( - path, - "workflow-1", - experimental, - invocation=_invocation("experimental_context"), - agent_run_id="e1", - ) - experimental_link = AgentReportLink.from_reference(experimental_ref) - - with pytest.raises(ValueError, match="experimentalTuningHandoff.*required"): - save_agent_report( - path, - "workflow-1", - _tuning_report("missing-handoff"), - invocation=_invocation( - "parameter_tuning", - parents=[experimental_link], - ), - agent_run_id="missing-handoff", - ) - with pytest.raises(ValueError, match="does not descend"): - save_agent_report( - path, - "workflow-1", - _tuning_report("wrong-handoff"), - invocation=_invocation( - "parameter_tuning", - parents=[experimental_link], - experimentalTuningHandoff=ExperimentalTuningHandoff(batchAction="skip"), - ), - agent_run_id="wrong-handoff", - ) - - tuning = _tuning_report("tuning-provider") - tuning_ref = save_agent_report( - path, - "workflow-1", - tuning, - invocation=_invocation("parameter_tuning"), - agent_run_id="t1", - ) - with pytest.raises(ValueError, match="tuningBiologyHandoff.*does not match"): - save_agent_report( - path, - "workflow-1", - BiologicalInterpretationReport.get_example(), - invocation=_invocation( - "biological_interpretation", - parents=[AgentReportLink.from_reference(tuning_ref)], - tuningBiologyHandoff=TuningBiologyHandoff(), - ), - agent_run_id="wrong-biology-handoff", - ) - - -def test_invocation_is_required_and_must_match_report_type(tmp_path: Path) -> None: - path = _create_scarf_store(tmp_path) - create_agent_workflow(path, workflow_run_id="workflow-1") - - with pytest.raises(TypeError, match="invocation"): - save_agent_report( - path, - "workflow-1", - DataEnrichmentReport.get_example(), - invocation=None, # type: ignore[arg-type] - ) - with pytest.raises(ValueError, match="agentName"): - save_agent_report( - path, - "workflow-1", - DataEnrichmentReport.get_example(), - invocation=_invocation("experimental_context"), - ) - with pytest.raises(ValueError, match="inputs"): - save_agent_report( - path, - "workflow-1", - DataEnrichmentReport.get_example(), - invocation=AgentInvocation(agentName="data_enrichment"), - ) - - -def test_workspace_records_are_physically_and_logically_isolated( - tmp_path: Path, -) -> None: - path = _create_workspace_store(tmp_path) - workflow_a = create_agent_workflow( - path, - workflow_run_id="workflow-a", - workspace="workspace_a", - ) - workflow_b = create_agent_workflow( - path, - workflow_run_id="workflow-b", - workspace="workspace_b", - ) - reference_a = save_agent_report( - path, - workflow_a.workflowRunId, - DataEnrichmentReport.get_example(), - invocation=_invocation("data_enrichment"), - agent_run_id="run-a", - workspace="workspace_a", - ) - reference_b = save_agent_report( - path, - workflow_b.workflowRunId, - DataEnrichmentReport.get_example(), - invocation=_invocation("data_enrichment"), - agent_run_id="run-b", - workspace="workspace_b", - ) - - assert reference_a.workspace == "workspace_a" - assert reference_b.workspace == "workspace_b" - assert _report_path(path, reference_a, workspace="workspace_a").is_file() - assert _report_path(path, reference_b, workspace="workspace_b").is_file() - assert not (path / "agents").exists() - assert [ - item.workflowRunId - for item in list_agent_workflows( - path, - workspace="workspace_a", - include_incomplete=True, - ) - ] == ["workflow-a"] - assert [ - item.workflowRunId - for item in list_agent_workflows( - path, - workspace="workspace_b", - include_incomplete=True, - ) - ] == ["workflow-b"] - with pytest.raises(KeyError, match="Unknown agent workflow"): - load_agent_report(path, reference_a, workspace="workspace_b") - - -def test_datastore_rejects_an_explicit_workspace_mismatch(tmp_path: Path) -> None: - path = _create_scarf_store(tmp_path) - datastore = DataStore( - str(path), - assay_types={"RNA": "Assay"}, - default_assay="RNA", - min_features_per_cell=0, - nthreads=1, - mem_budget="64M", - ) - - with pytest.raises(ValueError, match="workspace does not match"): - create_agent_workflow( - datastore, - workflow_run_id="workflow-1", - workspace="workspace_a", - ) - - -def test_workflow_lifecycle_controls_listing_and_future_writes( - tmp_path: Path, -) -> None: - path = _create_scarf_store(tmp_path) - running = create_agent_workflow(path, workflow_run_id="running-workflow") - completed = create_agent_workflow(path, workflow_run_id="completed-workflow") - failed = create_agent_workflow(path, workflow_run_id="failed-workflow") - abandoned = create_agent_workflow(path, workflow_run_id="abandoned-workflow") - - assert list_agent_workflows(path) == [] - assert { - item.workflowRunId - for item in list_agent_workflows(path, include_incomplete=True) - } == { - running.workflowRunId, - completed.workflowRunId, - failed.workflowRunId, - abandoned.workflowRunId, - } - with pytest.raises(ValueError, match="at least one report"): - finalize_agent_workflow(path, completed.workflowRunId, status="completed") - - reference = save_agent_report( - path, - completed.workflowRunId, - DataEnrichmentReport.get_example(), - invocation=_invocation("data_enrichment"), - agent_run_id="run-1", - ) - workflow_file = path / "agents/runs/completed-workflow/workflow.json" - original_workflow = workflow_file.read_bytes() - completed_result = finalize_agent_workflow( - path, - completed.workflowRunId, - status="completed", - message="analysis finished", - ) - failed_result = finalize_agent_workflow( - path, - failed.workflowRunId, - status="failed", - message="provider failed", - ) - abandoned_result = finalize_agent_workflow( - path, - abandoned.workflowRunId, - status="abandoned", - ) - - assert workflow_file.read_bytes() == original_workflow - assert completed_result.status == "completed" - assert completed_result.finalizedAtNs > completed_result.createdAtNs - assert completed_result.finalizationMessage == "analysis finished" - assert failed_result.status == "failed" - assert failed_result.finalizationMessage == "provider failed" - assert abandoned_result.status == "abandoned" - assert load_agent_workflow(path, running.workflowRunId).status == "running" - assert {item.status for item in list_agent_workflows(path)} == { - "completed", - "failed", - "abandoned", - } - finalization = json.loads( - (path / "agents/runs/completed-workflow/finalization.json").read_text() - ) - assert finalization["status"] == "completed" - assert finalization["message"] == "analysis finished" - - with pytest.raises(FileExistsError, match="already 'completed'"): - finalize_agent_workflow(path, completed.workflowRunId, status="failed") - with pytest.raises(RuntimeError, match="completed"): - save_agent_report( - path, - completed.workflowRunId, - DataEnrichmentReport.get_example(), - invocation=_invocation("data_enrichment"), - agent_run_id="run-2", - ) - assert load_agent_report(path, reference) == DataEnrichmentReport.get_example() - - finalization["finalizedAtNs"] = completed_result.createdAtNs - 1 - (path / "agents/runs/completed-workflow/finalization.json").write_text( - json.dumps(finalization), - encoding="utf-8", - ) - with pytest.raises(ValueError, match="cannot precede"): - load_agent_workflow(path, completed.workflowRunId) - - -def test_report_paths_are_immutable_and_corruption_fails_closed( - tmp_path: Path, -) -> None: - path = _create_scarf_store(tmp_path) - create_agent_workflow(path, workflow_run_id="workflow-1") - report = DataEnrichmentReport.get_example() - reference = save_agent_report( - path, - "workflow-1", - report, - invocation=_invocation("data_enrichment"), - agent_run_id="run-1", - ) - report_path = _report_path(path, reference) - original = report_path.read_bytes() - - with pytest.raises(FileExistsError, match="already exists"): - save_agent_report( - path, - "workflow-1", - report.model_copy(update={"limitations": ["different payload"]}), - invocation=_invocation("data_enrichment"), - agent_run_id="run-1", - ) - assert report_path.read_bytes() == original - - payload = json.loads(original) - payload["report"]["limitations"] = ["tampered"] - report_path.write_text(json.dumps(payload), encoding="utf-8") - with pytest.raises(ValueError, match="checksum"): - load_agent_report(path, reference) - - -def test_malformed_json_and_legacy_zarr_agents_group_are_rejected( - tmp_path: Path, -) -> None: - path = _create_scarf_store(tmp_path) - create_agent_workflow(path, workflow_run_id="workflow-1") - workflow_path = path / "agents/runs/workflow-1/workflow.json" - workflow_path.write_text("{", encoding="utf-8") - with pytest.raises(ValueError, match="malformed"): - load_agent_workflow(path, "workflow-1") - - legacy_path = tmp_path / "legacy.zarr" - legacy_root = zarr.open_group(str(legacy_path), mode="w", zarr_format=3) - _populate_scarf_group(legacy_root, fingerprints={"RNA": "dataset-rna"}) - legacy_root.create_group( - "agents", - attributes={"format": "scarf_agent_reports", "format_version": 1}, - ) - with pytest.raises(ValueError, match="version 1.*migration"): - create_agent_workflow(legacy_path, workflow_run_id="workflow-1") - - -@pytest.mark.parametrize( - "invalid_id", - ["", ".", "..", "../x", "a/b", r"a\b", "UPPER", " leading", "x\x00"], -) -def test_workflow_run_ids_are_validated_before_writing( - tmp_path: Path, - invalid_id: str, -) -> None: - path = _create_scarf_store(tmp_path) - with pytest.raises(ValueError, match="safe path component"): - create_agent_workflow(path, workflow_run_id=invalid_id) - assert not (path / "agents").exists() - - -def test_save_rejects_unknown_report_model(tmp_path: Path) -> None: - path = _create_scarf_store(tmp_path) - create_agent_workflow(path, workflow_run_id="workflow-1") - - with pytest.raises(TypeError, match="four Scarf agent report models"): - save_agent_report( - path, - "workflow-1", - AnyReport.get_example(), # type: ignore[arg-type] - invocation=_invocation("data_enrichment"), - ) - - -def test_generated_workflow_and_agent_run_ids_are_safe(tmp_path: Path) -> None: - path = _create_scarf_store(tmp_path) - workflow = create_agent_workflow(path) - reference = save_agent_report( - path, - workflow.workflowRunId, - DataEnrichmentReport.get_example(), - invocation=_invocation("data_enrichment"), - ) - - assert re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,127}", workflow.workflowRunId) - assert re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,127}", reference.agentRunId) diff --git a/tests/test_agent_rna_adaptive.py b/tests/test_agent_rna_adaptive.py new file mode 100644 index 00000000..755c6a89 --- /dev/null +++ b/tests/test_agent_rna_adaptive.py @@ -0,0 +1,631 @@ +"""Bounded RNA admission, uniform sampling and evidence-driven acceptance.""" + +from tests.agent_examples import example + +import copy +import hashlib +from types import SimpleNamespace +from typing import Any + +import numpy as np +import pytest + +from scarf.agent.orchestrator import journal, rna_tuning, tuning +from scarf.agent import record_io +from scarf.agent.orchestrator.budget import CandidateBudget, CandidateBudgetExceeded +from scarf.agent.orchestrator.models import ( + AutomatedPreprocessingPlan, + AutomatedWorkflowConfig, + PreprocessedAssayHandoff, +) +from scarf.agent.config.agent_exec import ImageEvidence +from scarf.agent.experimental_context.study import StudyContract +from scarf.agent.parameter_tuning.contracts import ParameterCandidateEvaluation +from scarf.agent.parameter_tuning.hvg import core_hvg_evidence, rank_core_hvgs +from scarf.agent.types import ArtifactReferenceModel +from scarf.storage.selections import read_stored_selection_indices + + +@pytest.fixture +def checkpoints(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + saved: dict[str, Any] = {} + + def load( + _store: Any, _prefix: str, _workflow: str, key: str, inputs: Any = None + ) -> Any: + if key not in saved: + return None + if inputs is not None and inputs != saved[key]["inputs"]: + raise ValueError("changed checkpoint inputs") + return copy.deepcopy(saved[key]["outputs"]) + + def save( + _store: Any, _prefix: str, _workflow: str, key: str, inputs: Any, outputs: Any + ) -> Any: + record = {"inputs": copy.deepcopy(inputs), "outputs": copy.deepcopy(outputs)} + if key in saved and saved[key] != record: + raise ValueError("checkpoint conflict") + saved[key] = record + return outputs + + def read(_store: Any, _prefix: str, _workflow: str, key: str) -> Any: + if key not in saved: + return None + record = copy.deepcopy(saved[key]) + return { + **record, + "contentSha256": hashlib.sha256( + record_io.canonical_json_bytes(record) + ).hexdigest(), + } + + monkeypatch.setattr(journal, "load_checkpoint", load) + monkeypatch.setattr(journal, "save_checkpoint", save) + monkeypatch.setattr(journal, "read_checkpoint", read) + monkeypatch.setattr(journal, "_ensure_orchestration_store", lambda store: "test") + return saved + + +def numerical_inputs( + resolution: float, *, harmony: bool = False, dimensions: int = 21 +) -> dict[str, Any]: + return { + "cells": "full", + "features": "default", + "parameters": { + "candidateId": "example", + "dimensions": dimensions, + "neighborsK": 11, + "leidenResolution": resolution, + "useHarmony": harmony, + }, + } + + +def test_full_defaults_share_one_graph_and_resume_admissions( + checkpoints: dict[str, Any], +) -> None: + config = AutomatedWorkflowConfig() + budget = CandidateBudget(None, "test", "workflow", config, {"input": "frozen"}) + defaults = [numerical_inputs(value) for value in (0.5, 0.75, 1.0, 1.25)] + budget.admit_many("full", defaults) + assert budget.summary()["scopes"]["full"]["reserved"]["graphs"] == 1 + assert budget.summary()["scopes"]["full"]["reserved"]["partitions"] == 4 + assert budget.summary()["scopes"]["full"]["completed"] == { + "graphs": 0, + "partitions": 0, + } + first = budget.admit("full", defaults[0]) + budget.complete(first, {"evaluation": "complete"}) + budget.complete(first, {"evaluation": "complete"}) + assert budget.summary()["scopes"]["full"]["completed"] == { + "graphs": 1, + "partitions": 1, + } + resumed = CandidateBudget(None, "test", "workflow", config, {"input": "frozen"}) + resumed.admit_many("full", defaults) + assert resumed.completed(resumed.admit("full", defaults[0])) == { + "evaluation": "complete" + } + assert resumed.summary() == budget.summary() + resumed.complete( + resumed.admit("full", defaults[1]), {"evaluation": "another partition"} + ) + assert resumed.summary()["scopes"]["full"] == { + "reserved": {"graphs": 1, "partitions": 4}, + "completed": {"graphs": 1, "partitions": 2}, + } + with pytest.raises(ValueError, match="changed checkpoint inputs"): + CandidateBudget(None, "test", "workflow", config, {"input": "changed"}) + + +def test_matched_pair_admission_fails_before_either_branch( + checkpoints: dict[str, Any], +) -> None: + config = AutomatedWorkflowConfig(maxFullGraphs=1) + budget = CandidateBudget(None, "test", "workflow", config, {}) + with pytest.raises(CandidateBudgetExceeded, match="full-cohort comparison"): + budget.admit_many( + "full", [numerical_inputs(1.0), numerical_inputs(1.0, harmony=True)] + ) + assert checkpoints == {} + assert budget.summary()["scopes"]["full"]["reserved"]["partitions"] == 0 + + +def test_summary_counts_unique_diagnostic_evidence_and_labels_reuse( + checkpoints: dict[str, Any], monkeypatch: pytest.MonkeyPatch +) -> None: + run = object.__new__(rna_tuning.RnaTuningRun) + run.budget = CandidateBudget( + None, "test", "workflow", AutomatedWorkflowConfig(), {} + ) + run.budget.admit_many("full", [numerical_inputs(0.5), numerical_inputs(0.75)]) + evaluation = example(ParameterCandidateEvaluation) + evaluation.metrics.subsampleStability = 0.9 + prototype = next(iter(evaluation.artifacts.values())) + evaluation.artifacts = { + name: prototype.model_copy(update={"artifactId": f"{index:064x}"}) + for index, name in enumerate( + ( + "representationDiagnostic", + "stabilityClusters", + "markerTable", + "doubletScore:0", + ) + ) + } + evaluation.artifacts["doubletScore:1"] = evaluation.artifacts["doubletScore:0"] + evaluation.artifacts["clusters"] = prototype.model_copy( + update={"artifactId": "f" * 64} + ) + run.budget.complete( + run.budget.admit("full", numerical_inputs(0.5)), + {"evaluation": evaluation.model_dump(mode="json")}, + ) + failed = evaluation.model_copy( + update={ + "candidateId": "failed", + "status": "failed", + "artifacts": {"markerTable": evaluation.artifacts["clusters"]}, + } + ) + run.evaluations = { + "sample0": [], + "sample1": [], + "full": [evaluation, evaluation.model_copy(), failed], + } + run.history = [] + run.full_repairs = 0 + messages = [] + monkeypatch.setattr(rna_tuning.logger, "info", messages.append) + summary = run.summary() + assert summary["diagnosticEvidence"]["uniqueArtifacts"] == { + "pcaDiagnostics": 1, + "stabilityClusters": 1, + "markerTable": 1, + "doubletScore": 1, + } + assert summary["diagnosticEvidence"]["subsampleStabilityEvaluations"] == { + "sample0": 0, + "sample1": 0, + "full": 1, + } + assert "1/1 graphs and 1/2 partitions completed/reserved" in messages[0] + assert messages[1].startswith("Tuning limits:") + assert "Saved evidence may be reused" in messages[2] + assert "not computation counts" in messages[2] + assert run.summary() == summary + + +def test_full_fallback_correction_and_one_repair_fit_declared_limits( + checkpoints: dict[str, Any], +) -> None: + budget = CandidateBudget(None, "test", "workflow", AutomatedWorkflowConfig(), {}) + budget.admit_many( + "full", [numerical_inputs(value) for value in (0.5, 0.75, 1.0, 1.25)] + ) + budget.admit("full", numerical_inputs(1.0, harmony=True)) + budget.admit_many( + "full", + [ + numerical_inputs(1.0, dimensions=30), + numerical_inputs(1.0, dimensions=30, harmony=True), + ], + ) + assert budget.summary()["scopes"]["full"]["reserved"]["graphs"] == 4 + assert budget.summary()["scopes"]["full"]["reserved"]["partitions"] == 7 + with pytest.raises(CandidateBudgetExceeded, match="graph limit"): + budget.admit("full", numerical_inputs(1.0, dimensions=50)) + + +def test_screen_admissions_include_enlargement_and_no_double_charge( + checkpoints: dict[str, Any], +) -> None: + config = AutomatedWorkflowConfig( + maxScreeningEvaluations=4, maxTotalScreeningEvaluations=6 + ) + budget = CandidateBudget(None, "test", "workflow", config, {}) + rows = [numerical_inputs(value) for value in (0.5, 0.75, 1.0, 1.25)] + budget.admit_many("sample0", rows) + budget.admit("sample0", rows[0]) + with pytest.raises(CandidateBudgetExceeded, match="screening comparison"): + budget.admit_many("sample1", rows) + assert budget.summary()["scopes"]["sample0"]["reserved"]["partitions"] == 4 + assert budget.summary()["scopes"]["sample1"]["reserved"]["partitions"] == 0 + + +def test_uniform_screen_is_nested_exact_and_keeps_live_selection( + datastore_ephemeral: Any, +) -> None: + store = datastore_ephemeral + parent = store.snapshot_cell_selection("I") + before = store.cells.fetch_all("I").copy() + small = rna_tuning.uniform_screening_selection(store, parent, size=8, seed=4444) + large = rna_tuning.uniform_screening_selection(store, parent, size=16, seed=4444) + + def rows(selection: Any) -> np.ndarray: + return read_stored_selection_indices( + store.zw, + selection, + kind="cell_selection", + scope="datastore", + assay=None, + table_path="cellData", + ) + + assert len(rows(small)) == 8 + assert len(rows(large)) == 16 + assert set(rows(small)) < set(rows(large)) <= set(rows(parent)) + assert ( + rna_tuning.uniform_screening_selection(store, parent, size=8, seed=4444) + == small + ) + np.testing.assert_array_equal(before, store.cells.fetch_all("I")) + + +def test_canonical_hvg_global_ranking_matches_exact_core_default( + datastore_ephemeral: Any, +) -> None: + store = datastore_ephemeral + cells = store.snapshot_cell_selection("I") + refs = core_hvg_evidence(store, assay="RNA", cells=cells) + exact = store.select_hvgs(cells, from_assay="RNA", show_plot=False) + assert refs["scarfDefault"] == exact + ranked = rank_core_hvgs( + store, + eligible=refs["eligibleDefault"], + statistics=refs["eligibleAll"], + top_n=1000, + ) + np.testing.assert_array_equal( + store.load_artifact(ranked)["values"][:], + store.load_artifact(exact)["values"][:], + ) + + +@pytest.mark.parametrize("unsupported", [False, True]) +def test_native_acceptance_requires_resolved_supported_correction_need( + checkpoints: dict[str, Any], + monkeypatch: pytest.MonkeyPatch, + unsupported: bool, +) -> None: + handoff = example(PreprocessedAssayHandoff) + handoff.graphFeatureCandidates = {"eligibleDefault": handoff.graphFeatures} + study = StudyContract.get_blank().model_copy( + update={ + "correctionLicense": "safe", + "technicalBatchColumns": ["batch"], + "unsupportedProtection": ["age"] if unsupported else [], + } + ) + run = rna_tuning.RnaTuningRun( + SimpleNamespace(model=object()), + SimpleNamespace(), + SimpleNamespace(workflowRunId="workflow"), + SimpleNamespace(config=AutomatedWorkflowConfig()), + example(AutomatedPreprocessingPlan), + handoff, + study, + {}, + {}, + ) + evaluation = example(ParameterCandidateEvaluation) + evaluation.parameters.useHarmony = False + for field in ( + "seedStability", + "subsampleStability", + "markerCoherence", + "membershipStrengthMean", + "clusterConnectivity", + ): + setattr(evaluation.metrics, field, 0.9) + monkeypatch.setattr(run, "feature_evidence", lambda selected: {}) + run.evaluations["sample0"] = [evaluation] + run.settings[evaluation.candidateId] = run.baseline() + monkeypatch.setattr( + tuning, + "_analysis_visual_content", + lambda *a, **kw: [ + ImageEvidence( + identifier="observed-plot", data=b"image", media_type="image/png" + ) + ], + ) + action = rna_tuning.TuningAction( + action="accept", + selectedCandidateId=evaluation.candidateId, + correctionNeed="needed", + assessedDomains=sorted(rna_tuning._DOMAINS), + evidenceIds=[f"candidate:{evaluation.candidateId}", "observed-plot"], + quantitativeFindings=["Observed separation requires a matched comparison."], + qualitativeFindings=["Batch colors separate within a comparable population."], + objectivePreservation="Preserve condition-associated populations.", + rationale="Inspect correction.", + ) + monkeypatch.setattr( + rna_tuning, "run_agent_sync", lambda **kwargs: SimpleNamespace(output=action) + ) + with pytest.raises( + ValueError, + match="biological protection is unsupported" + if unsupported + else "required correction unresolved", + ): + run.review("sample0", 0, evaluation, {}) + assert not any("review0" in key for key in checkpoints) + + +@pytest.mark.slow +def test_full_execution_repair_and_resume_reuse_augmented_evidence( + datastore_ephemeral: Any, + checkpoints: dict[str, Any], + monkeypatch: pytest.MonkeyPatch, +) -> None: + import json + + store = datastore_ephemeral + cells = store.snapshot_cell_selection("I") + features = core_hvg_evidence(store, assay="RNA", cells=cells) + feature_models = { + key: ArtifactReferenceModel.from_artifact_ref(value) + for key, value in features.items() + } + mask = np.asarray( + store.load_artifact(features["scarfDefault"])["values"][:], dtype=bool + ) + n_cells = len( + read_stored_selection_indices( + store.zw, + cells, + kind="cell_selection", + scope="datastore", + assay=None, + table_path="cellData", + ) + ) + handoff = PreprocessedAssayHandoff( + assay="RNA", + assayType="RNA", + cellSelection=ArtifactReferenceModel.from_artifact_ref(cells), + graphFeatures=feature_models["scarfDefault"], + graphFeatureCandidates=feature_models, + markerFeatures=ArtifactReferenceModel.from_artifact_ref( + store.select_all_features(from_assay="RNA") + ), + nCells=n_cells, + nFeatures=int(mask.sum()), + reductionMethod="pca", + ) + request = SimpleNamespace(config=AutomatedWorkflowConfig()) + plan = example(AutomatedPreprocessingPlan) + plan.cellQc.attributes = [] + model_calls: list[str] = [] + + def assess(**kwargs: Any) -> Any: + evidence = json.loads(kwargs["user_prompt"][0]) + selected = evidence["currentCandidateId"] + repair = not model_calls + model_calls.append(selected) + action = rna_tuning.TuningAction( + action="experiment" if repair else "accept", + selectedCandidateId=selected, + experimentId="leidenResolution:1.5" if repair else None, + correctionNeed="notApplicable", + assessedDomains=sorted(rna_tuning._DOMAINS), + evidenceIds=[f"candidate:{selected}", *evidence["imageHashes"]], + quantitativeFindings=[ + "Compare the registered finer partition against measured stability and markers." + ], + qualitativeFindings=[ + "Inspect the observed PCA and marker diagnostic panels." + ], + concern="Test whether the current partition merges marker-supported groups." + if repair + else "", + expectedImprovement="A finer partition may preserve distinct marker programs." + if repair + else "", + objectivePreservation="Retain coherent populations and all selected cells.", + rationale="Run one targeted partition comparison." + if repair + else "The full-cohort diagnostics support this partition.", + ) + return SimpleNamespace(output=kwargs["output_validator"](action)) + + monkeypatch.setattr(rna_tuning, "run_agent_sync", assess) + + def runner() -> rna_tuning.RnaTuningRun: + return rna_tuning.RnaTuningRun( + SimpleNamespace(model=object()), + store, + SimpleNamespace(workflowRunId="full-test"), + request, + plan, + handoff, + StudyContract.get_blank(), + {}, + {"dataset": "frozen"}, + ) + + first, history = runner().run() + assert first.status == "done" + assert first.cellSelection == handoff.cellSelection + assert first.selectedArtifacts["normalized"] + assert len(model_calls) == 2 + assert history["budget"]["scopes"]["full"]["reserved"]["graphs"] == 1 + assert history["budget"]["scopes"]["full"]["reserved"]["partitions"] == 5 + assert history["fullRepairs"] == 1 + + def no_recomputation(*args: Any, **kwargs: Any) -> Any: + pytest.fail("A fully augmented candidate must not be recomputed on resume") + + monkeypatch.setattr(rna_tuning, "augment_pca_evaluations", no_recomputation) + monkeypatch.setattr(rna_tuning, "augment_cluster_evaluations", no_recomputation) + resumed, resumed_history = runner().run() + assert resumed == first + assert resumed_history == history + assert len(model_calls) == 2 + + +def test_failed_execution_retries_and_doublets_bind_exact_feature_mask( + checkpoints: dict[str, Any], + monkeypatch: pytest.MonkeyPatch, +) -> None: + handoff = example(PreprocessedAssayHandoff) + handoff.graphFeatureCandidates = {"eligibleDefault": handoff.graphFeatures} + normalized = rna_tuning.artifact_model_to_ref(handoff.normalized) + store = SimpleNamespace(run_normalization=lambda *args, **kwargs: normalized) + run = rna_tuning.RnaTuningRun( + SimpleNamespace(model=object()), + store, + SimpleNamespace(workflowRunId="retry"), + SimpleNamespace(config=AutomatedWorkflowConfig()), + example(AutomatedPreprocessingPlan), + handoff, + StudyContract.get_blank(), + {}, + {}, + ) + baseline = run.baseline() + old = example(ParameterCandidateEvaluation) + old.parameters = baseline.parameters.model_copy(update={"candidateId": "old"}) + old.candidateId = "old" + old.cellSelection = handoff.cellSelection + old.artifacts["graphFeatures"] = handoff.graphFeatures.model_copy( + update={"artifactId": "f" * 64} + ) + run.evaluations["full"] = [old] + run.settings["old"] = baseline.model_copy( + update={"features": old.artifacts["graphFeatures"]} + ) + calls = [] + + def prepare(*args: Any, **kwargs: Any) -> Any: + assert kwargs["min_cluster_cells"] == 1 + return SimpleNamespace(candidate=kwargs["candidates"][0]), ["candidate"] + + def execute(deps: Any, candidate: str) -> Any: + calls.append(candidate) + return old.model_copy( + update={ + "candidateId": deps.candidate.candidateId, + "parameters": deps.candidate, + "artifacts": dict(old.artifacts), + "status": "failed" if len(calls) == 1 else "done", + "error": "transient failure" if len(calls) == 1 else None, + } + ) + + scored = [] + + def doublets(store: Any, selected: Any, candidates: Any, **kwargs: Any) -> Any: + assert len(candidates) == 1 + assert candidates[0].candidateId == selected.candidateId + assert ( + candidates[0].artifacts["graphFeatures"].model_dump() + == handoff.graphFeatures.model_dump() + ) + scored.append(selected.candidateId) + return None + + monkeypatch.setattr(rna_tuning, "prepare_parameter_tuning_dependencies", prepare) + monkeypatch.setattr(rna_tuning, "execute_parameter_candidate", execute) + monkeypatch.setattr( + rna_tuning, "augment_pca_evaluations", lambda store, rows, **kw: rows + ) + monkeypatch.setattr( + rna_tuning, "augment_cluster_evaluations", lambda store, rows, **kw: rows + ) + monkeypatch.setattr(rna_tuning, "score_advisory_doublets", doublets) + with pytest.raises(RuntimeError, match="transient failure"): + run.execute("full", run.cells, baseline) + assert run.budget.summary()["scopes"]["full"]["reserved"]["partitions"] == 1 + assert run.budget.summary()["scopes"]["full"]["completed"]["partitions"] == 0 + assert not any(key.endswith("/complete") for key in checkpoints) + completed = run.execute("full", run.cells, baseline) + assert completed.status == "done" + assert run.budget.summary()["scopes"]["full"]["reserved"]["partitions"] == 1 + assert run.budget.summary()["scopes"]["full"]["completed"]["partitions"] == 1 + assert len(scored) == 1 + + +@pytest.mark.parametrize( + "protection", + [{"protectFamilies": ["ribosomalProtein"]}, {"protectFeatures": ["RPS1"]}], +) +def test_feature_experiments_preserve_aliases_and_exact_protected_genes( + checkpoints: dict[str, Any], + protection: dict[str, Any], +) -> None: + handoff = example(PreprocessedAssayHandoff) + handoff.graphFeatureCandidates = {"eligibleDefault": handoff.graphFeatures} + names = np.asarray(["RPS1", "RPL1", "ACTB", "CD3D"]) + feats = SimpleNamespace(fetch_all=lambda column: names) + store = SimpleNamespace( + load_artifact=lambda ref: {"values": np.ones(4, dtype=bool)}, + get_assay=lambda assay: SimpleNamespace(feats=feats), + ) + plan = example(AutomatedPreprocessingPlan) + plan.assays[0].featureParameters.update(protection) + run = rna_tuning.RnaTuningRun( + SimpleNamespace(model=object()), + store, + SimpleNamespace(workflowRunId="protection"), + SimpleNamespace(config=AutomatedWorkflowConfig()), + plan, + handoff, + StudyContract.get_blank(), + {}, + {}, + ) + selected = example(ParameterCandidateEvaluation) + run.settings[selected.candidateId] = run.baseline() + run.family_patterns = {"ribosomal": "^RP[SL]"} + with pytest.raises(ValueError, match="objective-protected"): + run.apply_experiment( + selected, {"parameter": "excludeFamily", "value": "ribosomal"} + ) + + +def test_inadequate_screens_fall_back_to_full_baseline_once( + checkpoints: dict[str, Any], + monkeypatch: pytest.MonkeyPatch, +) -> None: + handoff = example(PreprocessedAssayHandoff) + handoff.nCells = 200_000 + handoff.graphFeatureCandidates = {"eligibleDefault": handoff.graphFeatures} + run = rna_tuning.RnaTuningRun( + SimpleNamespace(model=object()), + SimpleNamespace(), + SimpleNamespace(workflowRunId="fallback"), + SimpleNamespace(config=AutomatedWorkflowConfig()), + example(AutomatedPreprocessingPlan), + handoff, + StudyContract.get_blank(), + {}, + {}, + ) + sampled = [] + assessed = [] + + def sample(store: Any, cells: Any, *, size: int, seed: int) -> Any: + sampled.append(size) + return cells.__class__( + scope=cells.scope, + assay=cells.assay, + kind=cells.kind, + artifact_id=f"{size:064x}", + ) + + def assess(scope: str, cells: Any, initial: Any) -> Any: + assessed.append((scope, cells, initial)) + return ("enlarge", None) if scope != "full" else ("defer", None) + + monkeypatch.setattr(rna_tuning, "uniform_screening_selection", sample) + monkeypatch.setattr(run, "assess_scope", assess) + report, summary = run.run() + assert report.status == "needsInput" + assert sampled == [50_000, 100_000] + assert [scope for scope, _, _ in assessed] == ["sample0", "sample1", "full"] + assert assessed[-1] == ("full", run.cells, None) + assert summary["budget"]["scopes"]["full"]["reserved"]["partitions"] == 0 diff --git a/tests/test_agent_rna_assessment_integrity.py b/tests/test_agent_rna_assessment_integrity.py new file mode 100644 index 00000000..e69fd4f4 --- /dev/null +++ b/tests/test_agent_rna_assessment_integrity.py @@ -0,0 +1,362 @@ +"""Scientific choice guards preserve exact observed comparisons and prior reviews.""" + +import json +from types import SimpleNamespace +from typing import Any + +import pytest + +from scarf.agent.orchestrator import rna_tuning +from scarf.agent.experimental_context.contracts import ( + CovariateComparison, + CovariateProposal, +) +from scarf.agent.experimental_context.study import unsupported_comparison_limitations +from scarf.agent.parameter_tuning.contracts import ParameterCandidateEvaluation +from tests.test_agent_rna_adaptive import checkpoints as memory_checkpoints # noqa: F401 +from tests.test_agent_rna_evidence_mode import assess, make_run + + +pytestmark = pytest.mark.usefixtures("memory_checkpoints") + + +def _run( + monkeypatch: pytest.MonkeyPatch, *, confounded: bool = False +) -> tuple[rna_tuning.RnaTuningRun, ParameterCandidateEvaluation]: + run, selected = make_run(monkeypatch, SimpleNamespace(supports_image_input=False)) + run.study = run.study.model_copy( + update={ + "independentUnitColumns": ["donor"], + "correctionLicense": "unsafeConfounded" if confounded else "notApplicable", + "limitations": ["Library and protected tissue are confounded."] + if confounded + else [], + } + ) + selected.metrics.crossUnitSupport = 0.9 + return run, selected + + +def _experiment_response(kwargs: dict[str, Any], correction_need: str) -> Any: + action = assess(**{**kwargs, "output_validator": lambda value: value}).output + evidence = json.loads(kwargs["user_prompt"]) + action = action.model_copy( + update={ + "action": "experiment", + "experimentId": next(iter(evidence["experiments"])), + "correctionNeed": correction_need, + "concern": "Assess sensitivity of population support to one setting.", + "expectedImprovement": "The comparison may improve population support.", + "rationale": "Request one observed-evidence-driven parameter comparison.", + } + ) + return SimpleNamespace(output=kwargs["output_validator"](action)) + + +@pytest.mark.parametrize("correction_need", ["needed", "notNeeded"]) +def test_confounded_design_rejects_identifiable_correction_claims_before_commit( + monkeypatch: pytest.MonkeyPatch, + request: pytest.FixtureRequest, + correction_need: str, +) -> None: + saved = request.getfixturevalue("memory_checkpoints") + run, selected = _run(monkeypatch, confounded=True) + monkeypatch.setattr( + rna_tuning, + "run_agent_sync", + lambda **kwargs: _experiment_response(kwargs, correction_need), + ) + with pytest.raises(ValueError, match="design confounds"): + run.review("full", 0, selected, {}) + assert "parameter_tuning/full/review0" not in saved + + +def test_confounded_design_can_accept_supported_native_descriptive_analysis( + monkeypatch: pytest.MonkeyPatch, +) -> None: + run, selected = _run(monkeypatch, confounded=True) + monkeypatch.setattr(rna_tuning, "run_agent_sync", assess) + action = run.review("full", 0, selected, {}) + assert action.action == "accept" + assert action.correctionNeed == "notApplicable" + assert not selected.parameters.useHarmony + + +@pytest.mark.parametrize("correction_need", ["needed", "notNeeded"]) +def test_historical_confounded_experiment_replays_with_a_scientific_warning( + monkeypatch: pytest.MonkeyPatch, + request: pytest.FixtureRequest, + correction_need: str, +) -> None: + saved = request.getfixturevalue("memory_checkpoints") + run, selected = _run(monkeypatch, confounded=True) + monkeypatch.setattr( + rna_tuning, + "run_agent_sync", + lambda **kwargs: _experiment_response(kwargs, "notApplicable"), + ) + run.review("full", 0, selected, {}) + key = "parameter_tuning/full/review0" + # Represent a committed action produced before this scientific guard existed. + saved[key]["outputs"]["action"]["correctionNeed"] = correction_need + expected = rna_tuning.TuningAction.model_validate(saved[key]["outputs"]["action"]) + before = json.dumps(saved[key], sort_keys=True) + + def unexpected(*args: Any, **kwargs: Any) -> Any: + pytest.fail( + "A committed experiment must replay without new model or support work" + ) + + monkeypatch.setattr(rna_tuning, "run_agent_sync", unexpected) + monkeypatch.setattr(rna_tuning, "population_support_evidence", unexpected) + assert run.review("full", 0, selected, {}) == expected + assert json.dumps(saved[key], sort_keys=True) == before + assert any( + "claimed identifiable correction necessity" in row.get("reason", "") + for row in run.history + ) + + +def _alternative( + run: rna_tuning.RnaTuningRun, + selected: ParameterCandidateEvaluation, + *, + difference: str = "oneParameter", +) -> tuple[ParameterCandidateEvaluation, str]: + offered = run.experiments(selected) + experiment_id, experiment = next( + (name, value) + for name, value in offered.items() + if value["parameter"] == "dimensions" + ) + alternative = selected.model_copy(deep=True) + alternative.candidateId = "alternative" + alternative.parameters.candidateId = alternative.candidateId + alternative.parameters.dimensions = experiment["value"] + alternative.evidenceIds = ["candidate:alternative:seedStability"] + if difference == "twoParameters": + alternative.parameters.leidenResolution += 0.25 + elif difference == "selection": + assert alternative.cellSelection is not None + alternative.cellSelection = alternative.cellSelection.model_copy( + update={"artifactId": "f" * 64} + ) + elif difference == "reductionMethod": + alternative.parameters.reductionMethod = "lsi" + setting = run.settings[selected.candidateId].model_copy( + update={"parameters": alternative.parameters} + ) + if difference == "features": + setting.features = setting.features.model_copy(update={"artifactId": "e" * 64}) + run.settings[alternative.candidateId] = setting + run.evaluations["full"].append(alternative) + return alternative, experiment_id + + +@pytest.mark.parametrize( + "difference", + ["oneParameter", "twoParameters", "selection", "features", "reductionMethod"], +) +def test_matched_comparisons_require_one_change_on_the_exact_representation( + monkeypatch: pytest.MonkeyPatch, difference: str +) -> None: + run, selected = _run(monkeypatch) + alternative, experiment_id = _alternative(run, selected, difference=difference) + observed: dict[str, Any] = {} + + def record(**kwargs: Any) -> Any: + observed.update(json.loads(kwargs["user_prompt"])) + return assess(**kwargs) + + monkeypatch.setattr(rna_tuning, "run_agent_sync", record) + run.review("full", 0, selected, {}) + context = observed["assessmentContext"] + if difference == "oneParameter": + assert ( + context["alreadyEvaluatedExperiments"][experiment_id] + == alternative.candidateId + ) + assert context["matchedComparisons"] == [ + { + "currentCandidateId": selected.candidateId, + "alternativeCandidateId": alternative.candidateId, + "changedParameter": { + "dimensions": { + "current": selected.parameters.dimensions, + "alternative": alternative.parameters.dimensions, + } + }, + "basis": context["matchedComparisons"][0]["basis"], + } + ] + assert experiment_id not in observed["experiments"] + else: + assert not context["matchedComparisons"] + assert not context["alreadyEvaluatedExperiments"] + assert experiment_id in observed["experiments"] + + +def test_completed_numeric_comparison_remains_selectable_without_reexecution( + monkeypatch: pytest.MonkeyPatch, +) -> None: + run, selected = _run(monkeypatch) + alternative, experiment_id = _alternative(run, selected) + + def choose_alternative(**kwargs: Any) -> Any: + evidence = json.loads(kwargs["user_prompt"]) + assert experiment_id not in evidence["experiments"] + assert alternative.candidateId in { + row["candidateId"] for row in evidence["candidates"] + } + action = assess(**{**kwargs, "output_validator": lambda value: value}).output + action = action.model_copy( + update={ + "selectedCandidateId": alternative.candidateId, + "evidenceIds": [f"candidate:{alternative.candidateId}"], + } + ) + return SimpleNamespace(output=kwargs["output_validator"](action)) + + monkeypatch.setattr(rna_tuning, "run_agent_sync", choose_alternative) + assert ( + run.review("full", 0, selected, {}).selectedCandidateId + == alternative.candidateId + ) + + +def test_committed_catalogue_replays_without_new_filtering_or_population_work( + monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest +) -> None: + saved = request.getfixturevalue("memory_checkpoints") + run, selected = _run(monkeypatch) + _alternative_candidate, experiment_id = _alternative(run, selected) + old_experiment = run.experiments(selected)[experiment_id] + support_calls = [] + + def support(_store: Any, candidate: Any, columns: Any) -> dict[str, Any]: + support_calls.append(candidate.candidateId) + return {"candidateId": candidate.candidateId, "columns": list(columns)} + + monkeypatch.setattr(rna_tuning, "population_support_evidence", support) + monkeypatch.setattr(rna_tuning, "run_agent_sync", assess) + expected = run.review("full", 0, selected, {}) + key = "parameter_tuning/full/review0" + assert len(run.evaluations["full"]) == 2 + assert support_calls == [selected.candidateId] + assert set(saved[key]["inputs"]["assessmentContext"]["populationSupport"]) == { + selected.candidateId + } + # Prior reviews could offer an already-completed comparison and lacked this context. + saved[key]["inputs"]["experiments"][experiment_id] = old_experiment + del saved[key]["inputs"]["assessmentContext"] + saved[key]["inputs"]["availableEvidenceIds"].remove("assessmentContext") + before = json.dumps(saved[key], sort_keys=True) + + def unexpected(*args: Any, **kwargs: Any) -> Any: + pytest.fail("Committed catalogues and support evidence must not be rebuilt") + + monkeypatch.setattr(run, "experiments", unexpected) + monkeypatch.setattr(rna_tuning, "run_agent_sync", unexpected) + monkeypatch.setattr(rna_tuning, "population_support_evidence", unexpected) + assert run.review("full", 0, selected, {}) == expected + assert json.dumps(saved[key], sort_keys=True) == before + + +@pytest.mark.parametrize("already_in_study", [False, True]) +def test_saved_unsupported_design_evidence_reaches_assessment_and_report_once( + monkeypatch: pytest.MonkeyPatch, already_in_study: bool +) -> None: + run, selected = _run(monkeypatch) + comparison = CovariateComparison( + proposal=CovariateProposal( + response="mitochondrialFraction", + explanatoryColumns=["tissue"], + observationUnit="sample", + independentUnit="donor", + rationale="Assess tissue-associated quality differences across donors.", + ), + status="unsupported", + evidence={"independentUnits": 3, "missingCells": 17}, + reasons=["withinIndependentUnitComparisonsAreUnsupported"], + evidenceId="designComparison:observedUnsupported", + ) + run.design_comparisons = (comparison,) + limitation = unsupported_comparison_limitations([comparison])[0] + run.study = run.study.model_copy( + update={ + "evidenceIds": [], + "limitations": [limitation] if already_in_study else [], + } + ) + original_study = run.study.model_dump_json() + + def defer_with_observed_design_evidence(**kwargs: Any) -> Any: + evidence = json.loads(kwargs["user_prompt"]) + assert evidence["assessmentContext"]["designComparisons"] == [ + comparison.model_dump(mode="json") + ] + assert comparison.evidenceId in evidence["availableEvidenceIds"] + action = assess(**{**kwargs, "output_validator": lambda value: value}).output + action = action.model_copy( + update={ + "action": "defer", + "evidenceIds": [ + f"candidate:{selected.candidateId}", + comparison.evidenceId, + ], + "rationale": "Essential design evidence remains unsupported.", + } + ) + return SimpleNamespace(output=kwargs["output_validator"](action)) + + monkeypatch.setattr( + rna_tuning, "run_agent_sync", defer_with_observed_design_evidence + ) + assert run.review("full", 0, selected, {}).action == "defer" + report = run.report(None, "Essential design evidence remains unsupported.") + assert report.limitations.count(limitation) == 1 + assert comparison.evidenceId in limitation + assert comparison.reasons[0] in limitation + assert "no supported association or absence finding" in limitation + assert run.study.model_dump_json() == original_study + + +@pytest.mark.parametrize("license", ["safe", "unsafeConfounded"]) +def test_uncertain_correction_can_request_harmony_only_with_safe_design( + monkeypatch: pytest.MonkeyPatch, + request: pytest.FixtureRequest, + license: str, +) -> None: + run, selected = _run(monkeypatch, confounded=license == "unsafeConfounded") + run.study = run.study.model_copy( + update={"correctionLicense": license, "technicalBatchColumns": ["batch"]} + ) + run.batch_columns = ["batch"] + + def request_harmony(**kwargs: Any) -> Any: + evidence = json.loads(kwargs["user_prompt"]) + assert ("useHarmony:true" in evidence["experiments"]) is (license == "safe") + action = assess(**{**kwargs, "output_validator": lambda value: value}).output + action = action.model_copy( + update={ + "action": "experiment", + "experimentId": "useHarmony:true", + "correctionNeed": "uncertain", + "concern": "Determine whether observed batch structure is removable.", + "expectedImprovement": "Compare mixing and protected biology against matched native settings.", + "rationale": "Request a matched native and Harmony comparison.", + } + ) + return SimpleNamespace(output=kwargs["output_validator"](action)) + + monkeypatch.setattr(rna_tuning, "run_agent_sync", request_harmony) + if license == "safe": + action = run.review("full", 0, selected, {}) + assert action.action == "experiment" + assert action.experimentId == "useHarmony:true" + assert action.correctionNeed == "uncertain" + else: + with pytest.raises(ValueError, match="Unknown experiment ID"): + run.review("full", 0, selected, {}) + saved = request.getfixturevalue("memory_checkpoints") + assert "parameter_tuning/full/review0" not in saved diff --git a/tests/test_agent_rna_decisions.py b/tests/test_agent_rna_decisions.py index 0f27cf8e..11309396 100644 --- a/tests/test_agent_rna_decisions.py +++ b/tests/test_agent_rna_decisions.py @@ -1,6 +1,4 @@ -"""Tests for deterministic RNA decision definitions and compilation.""" - -from collections.abc import Callable +"""Exact evidence and payload validation for the live RNA QC decisions.""" import pytest from pydantic import ValidationError @@ -12,30 +10,9 @@ ) from scarf.agent.decisions.rna import ( CellQualityExecutorPayload, - ClusterExecutorPayload, - CorrectionOutcomeExecutorPayload, - FeaturePolicyExecutorPayload, - GraphExecutorPayload, - HvgExecutorPayload, - HvgRankingExecutorPayload, - PcaPrefixExecutorPayload, - RNA_DECISION_TRANSITION_GRAPH, RnaDecisionCompilationError, RnaDecisionGateError, - RnaDecisionRegistry, - RnaExecutorOption, - RnaDecisionTransition, - RnaDecisionTransitionGraph, build_cell_quality_decision, - build_cluster_partition_decision, - build_correction_license_decision, - build_correction_need_decision, - build_correction_outcome_decision, - build_feature_policy_decision, - build_graph_k_decision, - build_hvg_count_decision, - build_hvg_ranking_decision, - build_pca_prefix_decision, build_qc_grouping_decision, compile_rna_decision, require_option_evidence, @@ -93,7 +70,6 @@ def _record( confidence="medium", overrideOfOptionId=override_of, overrideEvidenceIds=override_evidence_ids or [], - verificationId=f"verification:record:{spec.decisionId}:1", ) @@ -146,279 +122,6 @@ def test_cell_quality_registry_offers_only_eligible_pooled_reference() -> None: assert "cellQuality:pooledReferenceMad5" in definition.spec.option_by_id() -@pytest.mark.parametrize( - ("factory", "message"), - [ - ( - lambda: CellQualityExecutorPayload( - profile="retainWithFlags", - lowerCountMad=5.0, - groupByCapture=False, - pooledReference=False, - sensitivityOnly=False, - ), - "cannot define removal thresholds", - ), - ( - lambda: CellQualityExecutorPayload( - profile="retainWithFlags", - groupByCapture=True, - pooledReference=False, - sensitivityOnly=False, - ), - "cannot enable filtering modes", - ), - ( - lambda: CellQualityExecutorPayload( - profile="globalMad5", - lowerCountMad=5.0, - lowerFeatureMad=None, - upperMitoMad=5.0, - groupByCapture=False, - pooledReference=False, - sensitivityOnly=False, - ), - "require all three MAD thresholds", - ), - ( - lambda: CellQualityExecutorPayload( - profile="captureMad5", - lowerCountMad=5.0, - lowerFeatureMad=5.0, - upperMitoMad=5.0, - groupByCapture=False, - pooledReference=False, - sensitivityOnly=False, - ), - "Capture profiles and groupByCapture", - ), - ( - lambda: CellQualityExecutorPayload( - profile="globalMad5", - lowerCountMad=5.0, - lowerFeatureMad=5.0, - upperMitoMad=5.0, - groupByCapture=False, - pooledReference=True, - sensitivityOnly=False, - ), - "pooledReferenceMad5 and pooledReference", - ), - ( - lambda: CellQualityExecutorPayload( - profile="globalMad5", - lowerCountMad=5.0, - lowerFeatureMad=5.0, - upperMitoMad=5.0, - groupByCapture=False, - pooledReference=False, - sensitivityOnly=True, - ), - "captureMad3Sensitivity and sensitivityOnly", - ), - ( - lambda: FeaturePolicyExecutorPayload( - policy="excludeEligibleBundle", - excludedFamilies=["ribosomal", "ribosomal"], - ), - "must not contain duplicates", - ), - ( - lambda: FeaturePolicyExecutorPayload( - policy="keepAll", - excludedFamilies=["ribosomal"], - ), - "keepAll cannot exclude", - ), - ( - lambda: FeaturePolicyExecutorPayload( - policy="excludeScarfDefaults", - useScarfDefaultBlacklist=False, - ), - "requires only the Scarf default blacklist", - ), - ( - lambda: FeaturePolicyExecutorPayload( - policy="excludeEligibleBundle", - ), - "requires gene families", - ), - ( - lambda: FeaturePolicyExecutorPayload( - policy="excludeEligibleBundle", - excludedFamilies=["ribosomal"], - useScarfDefaultBlacklist=True, - ), - "cannot silently add", - ), - ( - lambda: CorrectionOutcomeExecutorPayload( - outcome="acceptHarmony", - useHarmony=False, - ), - "must agree", - ), - ( - lambda: RnaExecutorOption( - checkpoint="cellQuality", - optionId=" invalid ", - payload=CellQualityExecutorPayload( - profile="retainWithFlags", - groupByCapture=False, - pooledReference=False, - sensitivityOnly=False, - ), - ), - "without surrounding whitespace", - ), - ( - lambda: RnaDecisionTransition( - fromCheckpoint="cellQuality", - onStatus="apply", - ), - "exactly one checkpoint or terminal", - ), - ], -) -def test_rna_payload_contracts_reject_inconsistent_modes( - factory: Callable[[], object], - message: str, -) -> None: - with pytest.raises(ValidationError, match=message): - factory() - - -def test_rna_definition_and_transition_contracts_reject_registry_drift() -> None: - definition = build_pca_prefix_decision( - evidence_bundle_id="bundle:pca", - matrix_rank=20, - ) - values = definition.model_dump() - values["spec"]["checkpoint"] = "cellQuality" - with pytest.raises(ValidationError, match="checkpoint must match"): - type(definition).model_validate(values) - - values = definition.model_dump() - values["executorOptions"][1]["optionId"] = values["executorOptions"][0]["optionId"] - with pytest.raises(ValidationError, match="duplicate option IDs"): - type(definition).model_validate(values) - - values = definition.model_dump() - values["executorOptions"][0]["checkpoint"] = "cellQuality" - with pytest.raises(ValidationError, match="registry checkpoint"): - type(definition).model_validate(values) - - with pytest.raises(KeyError, match="Unknown option ID"): - definition.executor_option("pcaPrefix:unknown") - - with pytest.raises(ValidationError, match="v1 RNA checkpoint order"): - RnaDecisionTransitionGraph( - orderedNodes=tuple(reversed(RNA_DECISION_TRANSITION_GRAPH.orderedNodes)), - ) - transition = RnaDecisionTransition( - fromCheckpoint="cellQuality", - onStatus="apply", - toCheckpoint="featurePolicy", - ) - with pytest.raises(ValidationError, match="unique checkpoint/status triggers"): - RnaDecisionTransitionGraph(transitions=[transition, transition]) - - -def test_rna_builders_reject_empty_duplicate_and_out_of_range_inventories() -> None: - invalid_calls: list[tuple[Callable[[], object], str]] = [ - ( - lambda: build_cell_quality_decision( - evidence_bundle_id="bundle:cellQuality", - available_profiles=["globalMad5", "globalMad5"], - ), - "must not contain duplicates", - ), - ( - lambda: build_cell_quality_decision( - evidence_bundle_id="bundle:cellQuality", - available_profiles=[], - ), - "At least one cell-quality profile", - ), - ( - lambda: build_hvg_count_decision( - evidence_bundle_id="bundle:hvg", - eligible_feature_count=1, - ranking_mode="global", - ), - "at least two eligible genes", - ), - ( - lambda: build_hvg_count_decision( - evidence_bundle_id="bundle:hvg", - eligible_feature_count=100, - ranking_mode="global", - candidate_counts=[True], - ), - "positive integers", - ), - ( - lambda: build_feature_policy_decision( - evidence_bundle_id="bundle:features", - proposed_exclusion_families=["ribosomal", "ribosomal"], - dominant_families=["ribosomal"], - protected_families=[], - ), - "must not contain duplicates", - ), - ( - lambda: build_pca_prefix_decision( - evidence_bundle_id="bundle:pca", - matrix_rank=1, - ), - "matrix rank of at least two", - ), - ( - lambda: build_pca_prefix_decision( - evidence_bundle_id="bundle:pca", - matrix_rank=20, - candidate_dimensions=[], - ), - "At least one PCA candidate", - ), - ( - lambda: build_graph_k_decision( - evidence_bundle_id="bundle:graph", - n_cells=2, - ), - "at least three cells", - ), - ( - lambda: build_graph_k_decision( - evidence_bundle_id="bundle:graph", - n_cells=20, - candidate_neighbors=[], - ), - "At least one graph candidate", - ), - ( - lambda: build_cluster_partition_decision( - evidence_bundle_id="bundle:cluster", - metric_preferred_option_id="clusterResolution:balanced", - resolution_candidates=[0.5, 0.5], - ), - "unique values", - ), - ( - lambda: build_cluster_partition_decision( - evidence_bundle_id="bundle:cluster", - metric_preferred_option_id="clusterResolution:unknown", - resolution_candidates=[0.5], - ), - "must be a registered resolution option", - ), - ] - - for call, message in invalid_calls: - with pytest.raises(RnaDecisionGateError, match=message): - call() - - def test_qc_grouping_offers_licensed_capture_and_pooled_modes() -> None: definition = build_qc_grouping_decision( evidence_bundle_id="bundle:cellQuality", @@ -442,478 +145,103 @@ def test_qc_grouping_offers_licensed_capture_and_pooled_modes() -> None: ] == ["global", "physicalCapture", "pooledReference"] -def test_hvg_ranking_and_correction_need_build_all_licensed_options() -> None: - ranking = build_hvg_ranking_decision( - evidence_bundle_id="bundle:hvg-ranking", - batch_aware_eligible=True, - ) - assert [option.optionId for option in ranking.spec.options] == [ - "hvgRanking:global", - "hvgRanking:batchAware", - "hvgRanking:defer", - ] - batch_payload = ranking.executor_option("hvgRanking:batchAware").payload - assert isinstance(batch_payload, HvgRankingExecutorPayload) - assert batch_payload.rankingMode == "batchAware" - - correction = build_correction_need_decision( - evidence_bundle_id="bundle:correction-need", - license="safe", - ) - assert [(option.optionId, option.status) for option in correction.spec.options] == [ - ("correctionNeed:needed", "apply"), - ("correctionNeed:notNeeded", "skip"), - ("correctionNeed:indeterminate", "defer"), - ] - assert correction.executor_option("correctionNeed:needed").payload.need == "needed" - assert correction.spec.baselineOptionId == "correctionNeed:notNeeded" - - -def test_hvg_counts_are_capped_and_numeric_values_stay_in_payloads() -> None: - definition = build_hvg_count_decision( - evidence_bundle_id="bundle:hvg", - eligible_feature_count=1500, - ranking_mode="global", - ) - - assert [option.optionId for option in definition.spec.options] == [ - "hvgCount:focused", - "hvgCount:allEligible", - "hvgCount:defer", - ] - assert definition.spec.baselineOptionId == "hvgCount:allEligible" - payload = definition.executor_option("hvgCount:allEligible").payload - assert isinstance(payload, HvgExecutorPayload) - assert payload.topN == 1500 - assert "topN" not in definition.spec.model_dump_json() - - -def test_batch_aware_hvgs_require_two_valid_technical_groups() -> None: - with pytest.raises(RnaDecisionGateError, match="at least two"): - build_hvg_count_decision( - evidence_bundle_id="bundle:hvg", - eligible_feature_count=4000, - ranking_mode="batchAware", - valid_technical_groups=1, - ) - - definition = build_hvg_count_decision( - evidence_bundle_id="bundle:hvg", - eligible_feature_count=4000, - ranking_mode="batchAware", - valid_technical_groups=2, - ) - payload = definition.executor_option("hvgCount:standard").payload - assert isinstance(payload, HvgExecutorPayload) - assert payload.rankingMode == "batchAware" - - -def test_feature_policy_requires_dominance_and_blocks_protected_families() -> None: - with pytest.raises(RnaDecisionGateError, match="dominance"): - build_feature_policy_decision( - evidence_bundle_id="bundle:features", - proposed_exclusion_families=["ribosomal"], - dominant_families=[], - protected_families=[], - ) - - with pytest.raises(RnaDecisionGateError, match="protected"): - build_feature_policy_decision( - evidence_bundle_id="bundle:features", - proposed_exclusion_families=["immuneReceptor"], - dominant_families=["immuneReceptor"], - protected_families=["immuneReceptor"], - ) - - definition = build_feature_policy_decision( - evidence_bundle_id="bundle:features", - proposed_exclusion_families=["ribosomal"], - dominant_families=["ribosomal"], - protected_families=["immuneReceptor"], - ) - payload = definition.executor_option("featurePolicy:excludeEligibleBundle").payload - assert payload.operation == "featurePolicy" - assert payload.excludedFamilies == ["ribosomal"] - assert definition.spec.baselineOptionId == "featurePolicy:keepAll" - - -def test_pca_prefixes_are_capped_by_rank_and_compile_to_executor_payload() -> None: - definition = build_pca_prefix_decision( - evidence_bundle_id="bundle:pca", matrix_rank=24 - ) - assert [option.optionId for option in definition.spec.options] == [ - "pcaPrefix:short", - "pcaPrefix:standard", - "pcaPrefix:maximumAvailable", - "pcaPrefix:defer", - ] - assert definition.spec.baselineOptionId == "pcaPrefix:standard" - bundle = _bundle("pcaPrefix", "bundle:pca", ["geometric", "technical"]) - record = _record(definition, bundle, "pcaPrefix:standard") - - compiled = compile_rna_decision(definition, bundle, record) - - assert isinstance(compiled.executorPayload, PcaPrefixExecutorPayload) - assert compiled.executorPayload.dimensions == 20 - record_json = record.model_dump_json() - assert "dimensions" not in record_json - assert "20" not in record_json - - -def test_correction_license_is_rule_owned_and_need_requires_safe_license() -> None: - unsafe = build_correction_license_decision( - evidence_bundle_id="bundle:license", license="unsafeConfounded" - ) - - assert unsafe.spec.allowedSources == ["rule"] - assert unsafe.spec.options[0].status == "skip" - assert unsafe.executorOptions[0].payload.license == "unsafeConfounded" - with pytest.raises(RnaDecisionGateError, match="safe correction license"): - build_correction_need_decision( - evidence_bundle_id="bundle:need", license="unsafeConfounded" - ) - - -def test_harmony_is_offered_only_when_safe_and_needed() -> None: - unsafe = build_correction_outcome_decision( - evidence_bundle_id="bundle:outcome", - license="unsafeConfounded", - ) - assert [option.optionId for option in unsafe.spec.options] == [ - "correctionOutcome:retainNative", - "correctionOutcome:indeterminate", - ] - assert unsafe.spec.baselineOptionId == "correctionOutcome:retainNative" - - safe = build_correction_outcome_decision( - evidence_bundle_id="bundle:outcome", - license="safe", - need="needed", - ) - assert [option.optionId for option in safe.spec.options] == [ - "correctionOutcome:retainNative", - "correctionOutcome:acceptHarmony", - "correctionOutcome:indeterminate", - ] - harmony_payload = safe.executor_option("correctionOutcome:acceptHarmony").payload - assert isinstance(harmony_payload, CorrectionOutcomeExecutorPayload) - assert harmony_payload.useHarmony is True - - @pytest.mark.parametrize( - ("license", "need", "message"), + "profile,changes,message", [ - ("indeterminate", None, "Indeterminate correction license"), - ("safe", None, "requires an evaluated correction need"), - ("safe", "indeterminate", "Indeterminate correction need"), - ("unsafeConfounded", "needed", "must not bypass"), + ("retainWithFlags", {"lowerCountMad": 3.0}, "cannot define removal"), + ("retainWithFlags", {"groupByCapture": True}, "cannot enable filtering"), + ("coreGlobalGaussian", {"lowerCountMad": 3.0}, "exact core bounds"), + ("coreSampleMad3", {"groupByCapture": False}, "requires capture grouping"), + ("globalMad5", {"lowerCountMad": None}, "require all three"), + ("captureMad5", {"groupByCapture": False}, "groupByCapture"), + ("globalMad5", {"pooledReference": True}, "pooledReference"), + ("globalMad5", {"sensitivityOnly": True}, "sensitivityOnly"), ], ) -def test_correction_outcome_rejects_unsafe_or_indeterminate_bypass( - license: str, need: str | None, message: str -) -> None: - with pytest.raises(RnaDecisionGateError, match=message): - build_correction_outcome_decision( - evidence_bundle_id="bundle:outcome", - license=license, - need=need, - ) - - -def test_graph_candidates_are_capped_and_deduplicated() -> None: - definition = build_graph_k_decision(evidence_bundle_id="bundle:graph", n_cells=15) - - assert [option.optionId for option in definition.spec.options] == [ - "graphScale:local", - "graphScale:maximumAvailable", - "graphScale:defer", - ] - assert definition.spec.baselineOptionId == "graphScale:maximumAvailable" - payload = definition.executor_option("graphScale:maximumAvailable").payload - assert isinstance(payload, GraphExecutorPayload) - assert payload.neighborsK == 14 - - -def test_custom_numeric_candidate_grids_are_capped_and_deduplicated() -> None: - hvg = build_hvg_count_decision( - evidence_bundle_id="bundle:hvg", - eligible_feature_count=3000, - ranking_mode="global", - candidate_counts=[750, 2000, 9000, 750], - ) - assert [option.optionId for option in hvg.spec.options] == [ - "hvgCount:n750", - "hvgCount:standard", - "hvgCount:n3000", - "hvgCount:defer", - ] - assert hvg.executor_option("hvgCount:n3000").payload.topN == 3000 - - pca = build_pca_prefix_decision( - evidence_bundle_id="bundle:pca", - matrix_rank=15, - candidate_dimensions=[7, 20, 7], - ) - assert [option.optionId for option in pca.spec.options] == [ - "pcaPrefix:n7", - "pcaPrefix:n15", - "pcaPrefix:defer", - ] - assert pca.executor_option("pcaPrefix:n15").payload.dimensions == 15 - - graph = build_graph_k_decision( - evidence_bundle_id="bundle:graph", - n_cells=13, - candidate_neighbors=[3, 50, 3], - ) - assert [option.optionId for option in graph.spec.options] == [ - "graphScale:k3", - "graphScale:k12", - "graphScale:defer", - ] - assert graph.executor_option("graphScale:k12").payload.neighborsK == 12 - - cluster = build_cluster_partition_decision( - evidence_bundle_id="bundle:cluster", - metric_preferred_option_id="clusterResolution:balanced", - resolution_candidates=[0.4, 0.75], - ) - assert cluster.executor_option( - "clusterResolution:r0p4" - ).payload.leidenResolution == pytest.approx(0.4) - assert cluster.spec.baselineOptionId == "clusterResolution:balanced" - - -def test_custom_candidate_grids_reject_empty_or_invalid_values() -> None: - with pytest.raises(RnaDecisionGateError, match="HVG candidate"): - build_hvg_count_decision( - evidence_bundle_id="bundle:hvg", - eligible_feature_count=3000, - ranking_mode="global", - candidate_counts=[], - ) - with pytest.raises(RnaDecisionGateError, match="PCA candidate"): - build_pca_prefix_decision( - evidence_bundle_id="bundle:pca", - matrix_rank=20, - candidate_dimensions=[True], - ) - with pytest.raises(RnaDecisionGateError, match="Graph candidates"): - build_graph_k_decision( - evidence_bundle_id="bundle:graph", - n_cells=20, - candidate_neighbors=[1], - ) - with pytest.raises(RnaDecisionGateError, match="cluster resolution"): - build_cluster_partition_decision( - evidence_bundle_id="bundle:cluster", - metric_preferred_option_id="clusterResolution:balanced", - resolution_candidates=[], - ) - - -def test_clustering_uses_fixed_resolutions_and_requires_override_evidence() -> None: - definition = build_cluster_partition_decision( - evidence_bundle_id="bundle:cluster", - metric_preferred_option_id="clusterResolution:balanced", - ) - assert definition.spec.requireIndependentOverrideEvidence is True - assert definition.spec.options[-1].optionId == "clusterPartition:abstain" - payload = definition.executor_option("clusterResolution:detailed").payload - assert isinstance(payload, ClusterExecutorPayload) - assert payload.leidenResolution == 1.0 - - bundle = _bundle( - "clusterPartition", - "bundle:cluster", - ["geometric", "markerCoherence", "resamplingStability"], - ) - geometric, marker, stability = [item.evidenceId for item in bundle.evidence] - insufficient = _record( - definition, - bundle, - "clusterResolution:detailed", - evidence_ids=[geometric, marker], - override_of="clusterResolution:balanced", - override_evidence_ids=[marker], - ) - with pytest.raises( - RnaDecisionCompilationError, match="independentOverrideEvidence" - ): - compile_rna_decision(definition, bundle, insufficient) - - supported = _record( - definition, - bundle, - "clusterResolution:detailed", - evidence_ids=[geometric, marker, stability], - override_of="clusterResolution:balanced", - override_evidence_ids=[marker, stability], - ) - compiled = compile_rna_decision(definition, bundle, supported) - assert compiled.verification.status == "passed" - - -def test_clustering_baseline_uses_nearest_registered_resolution() -> None: - definition = build_cluster_partition_decision( - evidence_bundle_id="bundle:cluster", - metric_preferred_option_id="clusterResolution:coarse", - resolution_candidates=(0.5,), - ) - - assert definition.spec.baselineOptionId == "clusterResolution:coarse" - - -def test_clustering_can_abstain_without_inventing_a_resolution() -> None: - definition = build_cluster_partition_decision( - evidence_bundle_id="bundle:cluster", - metric_preferred_option_id="clusterResolution:balanced", +def test_qc_payload_rejects_inconsistent_execution_modes(profile, changes, message): + definition = build_cell_quality_decision( + evidence_bundle_id="bundle:qc", available_profiles=[profile] ) - bundle = _bundle("clusterPartition", "bundle:cluster", ["geometric"]) - record = _record(definition, bundle, "clusterPartition:abstain") - - compiled = compile_rna_decision(definition, bundle, record) - - assert compiled.status == "abstain" - assert compiled.executorPayload.operation == "noExecution" - assert compiled.executorPayload.reasonCode == "scientificAbstention" - + original = definition.executor_option(f"cellQuality:{profile}").payload + with pytest.raises(ValidationError, match=message): + CellQualityExecutorPayload.model_validate({**original.model_dump(), **changes}) -def test_transition_graph_is_forward_only_and_routes_terminal_states() -> None: - assert RNA_DECISION_TRANSITION_GRAPH.resolve("qcGrouping", "apply") == ( - "cellQuality", - None, - ) - assert RNA_DECISION_TRANSITION_GRAPH.resolve("cellQuality", "skip") == ( - "featurePolicy", - None, - ) - assert RNA_DECISION_TRANSITION_GRAPH.resolve("correctionLicense", "defer") == ( - None, - "needsInput", - ) - assert RNA_DECISION_TRANSITION_GRAPH.resolve("clusterPartition", "abstain") == ( - None, - "abstained", - ) - with pytest.raises(ValidationError, match="strictly forward"): - RnaDecisionTransitionGraph( - transitions=[ - RnaDecisionTransition( - fromCheckpoint="pcaPrefix", - onStatus="apply", - toCheckpoint="hvgCount", - ) - ] +@pytest.mark.parametrize("profiles", [[], ["globalMad5", "globalMad5"]]) +def test_qc_choices_reject_empty_or_duplicate_inventory(profiles): + with pytest.raises(RnaDecisionGateError): + build_cell_quality_decision( + evidence_bundle_id="bundle:qc", available_profiles=profiles ) -def test_registry_requires_ordered_definitions_and_transition_coverage() -> None: - cell_quality = build_cell_quality_decision( - evidence_bundle_id="bundle:cellQuality", - available_profiles=["retainWithFlags", "globalMad5"], - ) - features = build_feature_policy_decision( - evidence_bundle_id="bundle:features", - proposed_exclusion_families=[], - dominant_families=[], - protected_families=[], +@pytest.mark.parametrize( + "change", + ["specCheckpoint", "duplicateOption", "payloadCheckpoint", "missingPayload"], +) +def test_qc_definition_rejects_visible_and_executable_drift(change): + definition = build_cell_quality_decision( + evidence_bundle_id="bundle:qc", + available_profiles=["coreGlobalGaussian", "globalMad5"], ) - - registry = RnaDecisionRegistry(definitions=[cell_quality, features]) - assert registry.definition("featurePolicy") == features - - with pytest.raises(ValidationError, match="checkpoint order"): - RnaDecisionRegistry(definitions=[features, cell_quality]) + values = definition.model_dump() + if change == "specCheckpoint": + values["spec"]["checkpoint"] = "qcGrouping" + elif change == "duplicateOption": + values["executorOptions"][1]["optionId"] = values["executorOptions"][0][ + "optionId" + ] + elif change == "payloadCheckpoint": + values["executorOptions"][0]["checkpoint"] = "qcGrouping" + else: + values["executorOptions"].pop() + with pytest.raises(ValidationError): + type(definition).model_validate(values) -def test_registry_and_transition_lookup_reject_incomplete_inventories() -> None: - cell_quality = build_cell_quality_decision( - evidence_bundle_id="bundle:cellQuality", - available_profiles=["retainWithFlags", "globalMad5"], +def test_qc_compilation_requires_exact_policy_evidence_and_hides_thresholds(): + definition = build_cell_quality_decision( + evidence_bundle_id="bundle:qc", available_profiles=["globalMad5"] ) - duplicate_id = cell_quality.model_copy( - update={ - "checkpoint": "featurePolicy", - "spec": cell_quality.spec.model_copy( - update={"checkpoint": "featurePolicy"}, - ), - "executorOptions": [ - option.model_copy(update={"checkpoint": "featurePolicy"}) - for option in cell_quality.executorOptions - ], - } + bundle = _bundle("cellQuality", "bundle:qc", ["qualityControl", "design"]) + quality, design = [item.evidenceId for item in bundle.evidence] + definition = require_option_evidence( + definition, {"cellQuality:globalMad5": [quality]} ) - with pytest.raises(ValidationError, match="decision IDs must be unique"): - RnaDecisionRegistry(definitions=[cell_quality, duplicate_id]) - - duplicate_checkpoint = cell_quality.model_copy( - update={ - "spec": cell_quality.spec.model_copy( - update={"decisionId": "otherCellQuality"}, - ) - } + missing = _record( + definition, bundle, "cellQuality:globalMad5", evidence_ids=[design] ) - with pytest.raises(ValidationError, match="checkpoints must be unique"): - RnaDecisionRegistry(definitions=[cell_quality, duplicate_checkpoint]) - - incomplete_graph = RnaDecisionTransitionGraph( - transitions=[ - RnaDecisionTransition( - fromCheckpoint="cellQuality", - onStatus="apply", - toCheckpoint="featurePolicy", - ) - ] + with pytest.raises(RnaDecisionCompilationError, match="deterministic verification"): + compile_rna_decision(definition, bundle, missing) + record = _record( + definition, bundle, "cellQuality:globalMad5", evidence_ids=[quality] ) - with pytest.raises(ValidationError, match="No transition for cellQuality/skip"): - RnaDecisionRegistry( - definitions=[cell_quality], - transitionGraph=incomplete_graph, + compiled = compile_rna_decision(definition, bundle, record) + assert compiled.executorPayload.lowerCountMad == 5.0 + assert "lowerCountMad" not in record.model_dump_json() + with pytest.raises(RnaDecisionCompilationError): + compile_rna_decision( + definition, + bundle, + record.model_copy(update={"evidenceBundleSha256": "0" * 64}), ) - with pytest.raises(KeyError, match="No RNA transition"): - incomplete_graph.resolve("cellQuality", "abstain") - with pytest.raises(KeyError, match="No RNA decision definition"): - RnaDecisionRegistry().definition("cellQuality") -def test_compile_requires_exact_verification_reference() -> None: - definition = build_pca_prefix_decision( - evidence_bundle_id="bundle:pca", - matrix_rank=20, +def test_qc_defer_has_no_executable_filter_and_evidence_binding_rejects_unknown_options(): + definition = build_cell_quality_decision( + evidence_bundle_id="bundle:qc", available_profiles=["coreGlobalGaussian"] ) - bundle = _bundle("pcaPrefix", "bundle:pca", ["geometric", "technical"]) - record = _record(definition, bundle, "pcaPrefix:standard") - record = record.model_copy(update={"verificationId": "verification:other"}) - - with pytest.raises( - RnaDecisionCompilationError, - match="deterministic verification ID", - ): - compile_rna_decision(definition, bundle, record) - - -def test_option_evidence_binding_rejects_unknown_and_scalar_requirements() -> None: - definition = build_pca_prefix_decision( - evidence_bundle_id="bundle:pca", - matrix_rank=20, + bundle = _bundle("cellQuality", "bundle:qc", ["qualityControl"]) + compiled = compile_rna_decision( + definition, bundle, _record(definition, bundle, "cellQuality:defer") ) + assert compiled.status == "defer" + assert compiled.executorPayload.operation == "noExecution" with pytest.raises(ValueError, match="unknown options"): - require_option_evidence(definition, {"pcaPrefix:invented": ["evidence:x"]}) - with pytest.raises(TypeError, match="sequences of IDs"): require_option_evidence( - definition, - {"pcaPrefix:standard": "evidence:x"}, + definition, {"invented": [bundle.evidence[0].evidenceId]} + ) + with pytest.raises(TypeError, match="sequences"): + require_option_evidence( + definition, {"cellQuality:coreGlobalGaussian": "invented"} ) - - -def test_definition_rejects_executor_inventory_drift() -> None: - definition = build_pca_prefix_decision( - evidence_bundle_id="bundle:pca", matrix_rank=50 - ) - values = definition.model_dump() - values["executorOptions"] = values["executorOptions"][:-1] - - with pytest.raises(ValidationError, match="exactly match"): - type(definition).model_validate(values) diff --git a/tests/test_agent_rna_evidence_mode.py b/tests/test_agent_rna_evidence_mode.py new file mode 100644 index 00000000..db64c4cc --- /dev/null +++ b/tests/test_agent_rna_evidence_mode.py @@ -0,0 +1,761 @@ +"""RNA assessments preserve scientific checks for visual and text-only models.""" + +import json +from types import SimpleNamespace +from typing import Any + +import pytest +from pydantic import ValidationError +from pydantic_ai.exceptions import ModelHTTPError, UnexpectedModelBehavior +from pydantic_ai.messages import ( + ModelMessage, + ModelResponse, + RetryPromptPart, + ToolCallPart, + UserPromptPart, +) +from pydantic_ai.models.function import AgentInfo, FunctionModel + +from scarf.agent.config.agent_exec import ImageEvidence, ImageInputUnsupportedError +from scarf.agent.experimental_context.study import StudyContract +from scarf.agent.orchestrator import rna_tuning, tuning +from scarf.agent.orchestrator.models import ( + AutomatedPreprocessingPlan, + AutomatedWorkflowConfig, + PreprocessedAssayHandoff, +) +from scarf.agent.parameter_tuning.contracts import ParameterCandidateEvaluation +from tests.agent_examples import example +from tests.test_agent_rna_adaptive import checkpoints as memory_checkpoints # noqa: F401 + + +pytestmark = pytest.mark.usefixtures("memory_checkpoints") + + +def make_run( + monkeypatch: pytest.MonkeyPatch, model: Any +) -> tuple[rna_tuning.RnaTuningRun, ParameterCandidateEvaluation]: + handoff = example(PreprocessedAssayHandoff) + handoff.graphFeatureCandidates = {"eligibleDefault": handoff.graphFeatures} + run = rna_tuning.RnaTuningRun( + SimpleNamespace(model=model), + SimpleNamespace(), + SimpleNamespace(workflowRunId="workflow"), + SimpleNamespace(config=AutomatedWorkflowConfig()), + example(AutomatedPreprocessingPlan), + handoff, + StudyContract.get_blank(), + {}, + {"scientificInputs": "frozen"}, + ) + selected = example(ParameterCandidateEvaluation) + selected.parameters.useHarmony = False + for field in ( + "seedStability", + "subsampleStability", + "markerCoherence", + "membershipStrengthMean", + "clusterConnectivity", + ): + setattr(selected.metrics, field, 0.9) + run.evaluations["full"] = [selected] + run.settings[selected.candidateId] = run.baseline().model_copy( + update={"parameters": selected.parameters} + ) + monkeypatch.setattr( + run, + "feature_evidence", + lambda _: {"topSelectedGenes": ["NKG7", "GNLY"]}, + ) + monkeypatch.setattr( + rna_tuning, + "population_support_evidence", + lambda _store, item, columns: { + "candidateId": item.candidateId, + "columns": list(columns), + }, + ) + monkeypatch.setattr( + tuning, + "_analysis_visual_content", + lambda *args, **kwargs: [ + ImageEvidence(identifier="observed-plot", data=b"image") + ], + ) + return run, selected + + +def assess(**kwargs: Any) -> Any: + prompt = kwargs["user_prompt"] + evidence = json.loads(prompt if isinstance(prompt, str) else prompt[0]) + selected = evidence["currentCandidateId"] + action = rna_tuning.TuningAction( + action="accept", + selectedCandidateId=selected, + correctionNeed="notApplicable", + assessedDomains=sorted(rna_tuning._DOMAINS), + evidenceIds=[ + f"candidate:{selected}", + "featureEvidence", + *evidence["imageHashes"], + ], + quantitativeFindings=["The supplied stability and marker metrics are 0.9."], + qualitativeFindings=["NKG7 and GNLY support a coherent cytotoxic program."], + objectivePreservation="Preserve the observed cytotoxic population.", + rationale="The supplied diagnostics support the selected partition.", + ) + return SimpleNamespace(output=kwargs["output_validator"](action)) + + +@pytest.mark.parametrize( + "model", + [ + SimpleNamespace(supports_image_input=False), + SimpleNamespace(profile={"supports_image_input": False}), + ], +) +def test_declared_text_only_model_gets_structured_biological_evidence( + monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest, model: Any +) -> None: + saved = request.getfixturevalue("memory_checkpoints") + run, selected = make_run(monkeypatch, model) + monkeypatch.setattr( + tuning, + "_analysis_visual_content", + lambda *a, **kw: pytest.fail("A text-only assessment must not render images"), + ) + + def text_assessment(**kwargs: Any) -> Any: + assert isinstance(kwargs["user_prompt"], str) + evidence = json.loads(kwargs["user_prompt"]) + assert evidence["evidenceMode"] == "structured" + assert evidence["visualInspection"] == "unavailable" + assert evidence["imageHashes"] == {} + assert evidence["featureEvidence"][selected.candidateId][ + "topSelectedGenes" + ] == ["NKG7", "GNLY"] + assert "no images were supplied" in kwargs["system_prompt"] + assert "Do not claim to have seen" in kwargs["system_prompt"] + assert "structured_evidence" in next(iter(saved)) + return assess(**kwargs) + + monkeypatch.setattr(rna_tuning, "run_agent_sync", text_assessment) + assert run.review("full", 0, selected, {}).action == "accept" + history = run.history[-1] + assert history["evidenceMode"] == "structured" + assert history["visualInspection"] == "unavailable" + assert history["imageHashes"] == {} + assert "observed-plot" not in history["review"]["evidenceIds"] + assert ( + rna_tuning._STRUCTURED_VISUAL_LIMITATION + in run.report(None, "Inspect another setting").limitations + ) + + +@pytest.mark.parametrize( + "model", + [ + object(), + SimpleNamespace(profile={"supports_image_output": False}), + SimpleNamespace(supports_image_input=True), + ], +) +def test_unknown_or_positive_input_capability_retains_visual_assessment( + monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest, model: Any +) -> None: + saved = request.getfixturevalue("memory_checkpoints") + run, selected = make_run(monkeypatch, model) + + def visual_assessment(**kwargs: Any) -> Any: + assert not isinstance(kwargs["user_prompt"], str) + evidence = json.loads(kwargs["user_prompt"][0]) + assert evidence["evidenceMode"] == "visual" + assert evidence["visualInspection"] == "available" + assert evidence["imageHashes"] + return assess(**kwargs) + + monkeypatch.setattr(rna_tuning, "run_agent_sync", visual_assessment) + assert run.review("full", 0, selected, {}).action == "accept" + assert "parameter_tuning/structured_evidence" not in saved + + +def test_explicit_image_rejection_is_saved_before_retry_and_reused( + monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest +) -> None: + saved = request.getfixturevalue("memory_checkpoints") + run, selected = make_run(monkeypatch, object()) + modes = [] + renders = [] + + def render(*args: Any, **kwargs: Any) -> list[ImageEvidence]: + renders.append(True) + return [ImageEvidence(identifier="observed-plot", data=b"image")] + + def rejecting_assessment(**kwargs: Any) -> Any: + prompt = kwargs["user_prompt"] + modes.append("structured" if isinstance(prompt, str) else "visual") + if modes[-1] == "visual": + raise ImageInputUnsupportedError("Image input is not supported") + assert saved["parameter_tuning/structured_evidence"]["outputs"] == { + "evidenceMode": "structured", + "reason": "providerRejectedImageInput", + } + return assess(**kwargs) + + monkeypatch.setattr(tuning, "_analysis_visual_content", render) + monkeypatch.setattr(rna_tuning, "run_agent_sync", rejecting_assessment) + assert run.review("full", 0, selected, {}).action == "accept" + assert run.review("full", 1, selected, {}).action == "accept" + assert modes == ["visual", "structured", "structured"] + assert len(renders) == 1 + assert len(run.history) == 2 + assert all(row["evidenceMode"] == "structured" for row in run.history) + + +def test_interrupted_structured_retry_does_not_probe_images_on_resume( + monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest +) -> None: + saved = request.getfixturevalue("memory_checkpoints") + run, selected = make_run(monkeypatch, object()) + + def interrupt(**kwargs: Any) -> Any: + if not isinstance(kwargs["user_prompt"], str): + raise ImageInputUnsupportedError("Image input is not supported") + raise RuntimeError("Interrupted during structured assessment") + + monkeypatch.setattr(rna_tuning, "run_agent_sync", interrupt) + with pytest.raises(RuntimeError, match="Interrupted during structured"): + run.review("full", 0, selected, {}) + assert set(saved) == {"parameter_tuning/structured_evidence"} + resumed, selected = make_run(monkeypatch, object()) + monkeypatch.setattr( + tuning, + "_analysis_visual_content", + lambda *a, **kw: pytest.fail("Resume must preserve the rejected capability"), + ) + monkeypatch.setattr(rna_tuning, "run_agent_sync", assess) + assert resumed.review("full", 0, selected, {}).action == "accept" + assert resumed.history[-1]["evidenceMode"] == "structured" + + +@pytest.mark.parametrize( + "error", + [ + RuntimeError("Provider timed out"), + ModelHTTPError(401, "test-model", {"message": "Unauthorized"}), + ], +) +def test_unrelated_model_failure_never_changes_evidence_mode( + monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest, error: Exception +) -> None: + saved = request.getfixturevalue("memory_checkpoints") + run, selected = make_run(monkeypatch, object()) + + def fail(**kwargs: Any) -> Any: + raise error + + monkeypatch.setattr(rna_tuning, "run_agent_sync", fail) + with pytest.raises(type(error)) as caught: + run.review("full", 0, selected, {}) + assert caught.value is error + assert saved == {} + + +def test_committed_visual_review_replays_without_images_or_neighbor_diagnostics( + monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest +) -> None: + saved = request.getfixturevalue("memory_checkpoints") + run, selected = make_run(monkeypatch, object()) + prototype = next(iter(selected.artifacts.values())) + selected.artifacts["neighbors"] = prototype.model_copy( + update={"kind": "neighbors", "artifactId": "e" * 64} + ) + alternate = selected.model_copy(deep=True) + alternate.candidateId = "alternative" + alternate.parameters.candidateId = alternate.candidateId + alternate.artifacts["neighbors"] = selected.artifacts["neighbors"].model_copy( + update={"artifactId": "f" * 64} + ) + run.evaluations["full"].append(alternate) + run.settings[alternate.candidateId] = run.settings[selected.candidateId].model_copy( + update={"parameters": alternate.parameters} + ) + monkeypatch.setattr(rna_tuning, "_neighbor_overlap", lambda *a: 0.8) + monkeypatch.setattr(rna_tuning, "run_agent_sync", assess) + run.review("full", 0, selected, {}) + original = run.history[-1] + assert ( + saved["parameter_tuning/full/review0"]["inputs"]["neighborComparisons"][0][ + "meanNeighborJaccard" + ] + == 0.8 + ) + saved["parameter_tuning/structured_evidence"] = { + "inputs": {**run.provenance, "configuredImageInput": None}, + "outputs": { + "evidenceMode": "structured", + "reason": "providerRejectedImageInput", + }, + } + resumed, resumed_selected = make_run(monkeypatch, object()) + resumed.evaluations = run.evaluations + resumed.settings = run.settings + + def unexpected(*args: Any, **kwargs: Any) -> Any: + pytest.fail("Committed review must precede image, neighbor and model work") + + monkeypatch.setattr(tuning, "_analysis_visual_content", unexpected) + monkeypatch.setattr(rna_tuning, "_neighbor_overlap", unexpected) + monkeypatch.setattr(rna_tuning, "run_agent_sync", unexpected) + resumed.review("full", 0, resumed_selected, {}) + assert resumed.history[-1] == original + resumed_selected.metrics.seedStability = 0.5 + resumed.evaluations["full"][0] = resumed_selected + with pytest.raises(ValueError, match="different candidate evidence"): + resumed.review("full", 0, resumed_selected, {}) + + +@pytest.mark.parametrize("invalid", ["inventedImage", "missingStability"]) +def test_structured_assessment_keeps_evidence_and_scientific_checks( + monkeypatch: pytest.MonkeyPatch, + request: pytest.FixtureRequest, + invalid: str, +) -> None: + saved = request.getfixturevalue("memory_checkpoints") + run, selected = make_run(monkeypatch, SimpleNamespace(supports_image_input=False)) + if invalid == "missingStability": + selected.metrics.subsampleStability = None + + def invalid_assessment(**kwargs: Any) -> Any: + action = assess(**{**kwargs, "output_validator": lambda action: action}).output + if invalid == "inventedImage": + action.evidenceIds.append("observed-plot") + return SimpleNamespace(output=kwargs["output_validator"](action)) + + monkeypatch.setattr(rna_tuning, "run_agent_sync", invalid_assessment) + with pytest.raises( + ValueError, + match="unknown evidence" + if invalid == "inventedImage" + else "evidence is missing", + ): + run.review("full", 0, selected, {}) + assert "parameter_tuning/full/review0" not in saved + + +@pytest.mark.parametrize("text_only", [False, True]) +def test_assessment_accepts_the_detailed_evidence_it_supplies( + monkeypatch: pytest.MonkeyPatch, text_only: bool +) -> None: + run, selected = make_run( + monkeypatch, SimpleNamespace(supports_image_input=not text_only) + ) + metric = f"candidate:{selected.candidateId}:seedStability" + selected.evidenceIds = [metric, metric] + run.study.evidenceIds = ["study:observed", "shared:observed"] + run.plan.cellQc.evidenceIds = ["qc:observed", "shared:observed"] + citations = [metric, "study:observed", "qc:observed", "shared:observed"] + + def cite_supplied_evidence(**kwargs: Any) -> Any: + prompt = kwargs["user_prompt"] + evidence = json.loads(prompt if isinstance(prompt, str) else prompt[0]) + catalogue = evidence["availableEvidenceIds"] + assert len(catalogue) == len(set(catalogue)) + assert set(citations).issubset(catalogue) + assert set(evidence["candidates"][0]["evidenceIds"]).issubset(catalogue) + action = assess(**{**kwargs, "output_validator": lambda action: action}).output + action.evidenceIds = [*citations, *evidence["imageHashes"]] + return SimpleNamespace(output=kwargs["output_validator"](action)) + + monkeypatch.setattr(rna_tuning, "run_agent_sync", cite_supplied_evidence) + action = run.review("full", 0, selected, {}) + assert action.action == "accept" + assert metric in action.evidenceIds + assert f"candidate:{selected.candidateId}" not in action.evidenceIds + + +@pytest.mark.parametrize( + ("invalid", "message"), + [ + ("inventedMetric", "unknown evidence"), + ("otherCandidateOnly", "selected numerical evidence"), + ("missingImage", "visual evidence"), + ], +) +def test_detailed_citations_keep_grounding_and_visual_requirements( + monkeypatch: pytest.MonkeyPatch, + request: pytest.FixtureRequest, + invalid: str, + message: str, +) -> None: + saved = request.getfixturevalue("memory_checkpoints") + run, selected = make_run(monkeypatch, object()) + metric = f"candidate:{selected.candidateId}:seedStability" + selected.evidenceIds = [metric] + alternative = selected.model_copy(deep=True) + alternative.candidateId = "alternative" + alternative.parameters.candidateId = alternative.candidateId + alternative.evidenceIds = ["candidate:alternative:seedStability"] + run.evaluations["full"].append(alternative) + run.settings[alternative.candidateId] = run.settings[ + selected.candidateId + ].model_copy(update={"parameters": alternative.parameters}) + + def invalid_assessment(**kwargs: Any) -> Any: + action = assess(**{**kwargs, "output_validator": lambda action: action}).output + action.evidenceIds = [metric, "observed-plot"] + if invalid == "inventedMetric": + action.evidenceIds.append( + f"candidate:{selected.candidateId}:inventedMetric" + ) + elif invalid == "otherCandidateOnly": + action.evidenceIds = [*alternative.evidenceIds, "observed-plot"] + else: + action.evidenceIds = [metric] + return SimpleNamespace(output=kwargs["output_validator"](action)) + + monkeypatch.setattr(rna_tuning, "run_agent_sync", invalid_assessment) + with pytest.raises(ValueError, match=message): + run.review("full", 0, selected, {}) + assert "parameter_tuning/full/review0" not in saved + + +@pytest.mark.parametrize("decision", ["accept", "defer"]) +@pytest.mark.parametrize("legacy_catalogue", [False, True]) +def test_committed_review_preserves_its_exact_evidence_catalogue( + monkeypatch: pytest.MonkeyPatch, + request: pytest.FixtureRequest, + legacy_catalogue: bool, + decision: str, +) -> None: + saved = request.getfixturevalue("memory_checkpoints") + run, selected = make_run(monkeypatch, object()) + metric = f"candidate:{selected.candidateId}:seedStability" + selected.evidenceIds = [metric] + run.study.evidenceIds = ["study:observed"] + run.plan.cellQc.evidenceIds = ["qc:observed"] + + def decide(**kwargs: Any) -> Any: + action = assess(**{**kwargs, "output_validator": lambda action: action}).output + action.action = decision + return SimpleNamespace(output=kwargs["output_validator"](action)) + + monkeypatch.setattr(rna_tuning, "run_agent_sync", decide) + expected = run.review("full", 0, selected, {}) + key = "parameter_tuning/full/review0" + if legacy_catalogue: + # Model an existing committed review from before detailed IDs were listed. + saved[key]["inputs"]["availableEvidenceIds"] = [ + f"candidate:{selected.candidateId}", + "observed-plot", + "studyContract", + "qcPolicy", + "samplingCoverage", + "featureEvidence", + "neighborComparisons", + ] + before = json.dumps(saved[key], sort_keys=True) + resumed, _ = make_run(monkeypatch, object()) + resumed.evaluations = run.evaluations + resumed.settings = run.settings + resumed.study = run.study + resumed.plan = run.plan + + def unexpected(*args: Any, **kwargs: Any) -> Any: + pytest.fail("Committed evidence must replay without model or plot work") + + monkeypatch.setattr(rna_tuning, "run_agent_sync", unexpected) + monkeypatch.setattr(tuning, "_analysis_visual_content", unexpected) + assert resumed.review("full", 0, selected, {}) == expected + assert json.dumps(saved[key], sort_keys=True) == before + + +def test_real_agent_retry_repairs_a_citation_from_actionable_feedback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + requests = 0 + feedback: list[str] = [] + + async def reply(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: + nonlocal requests + requests += 1 + user_part = next( + part + for message in messages + for part in message.parts + if isinstance(part, UserPromptPart) + ) + assert isinstance(user_part.content, str) + evidence = json.loads(user_part.content) + selected_id = evidence["currentCandidateId"] + metric = evidence["candidates"][0]["evidenceIds"][0] + invalid_id = f"candidate:{selected_id}:inventedMetric" + if requests == 2: + feedback.extend( + str(part.content) + for message in messages + for part in message.parts + if isinstance(part, RetryPromptPart) + ) + assert any(invalid_id in text for text in feedback) + assert any("availableEvidenceIds" in text for text in feedback) + assert any(f"candidate:{selected_id}" in text for text in feedback) + action = rna_tuning.TuningAction( + action="accept", + selectedCandidateId=selected_id, + correctionNeed="notApplicable", + assessedDomains=sorted(rna_tuning._DOMAINS), + evidenceIds=[invalid_id if requests == 1 else metric], + quantitativeFindings=["The supplied seed stability is 0.9."], + qualitativeFindings=["NKG7 and GNLY support a cytotoxic program."], + objectivePreservation="Preserve the observed cytotoxic population.", + rationale="The observed diagnostics support this partition.", + ) + assert info.output_tools + return ModelResponse( + parts=[ + ToolCallPart(info.output_tools[0].name, action.model_dump(mode="json")) + ] + ) + + model = FunctionModel(reply, profile={"supports_image_input": False}) + run, selected = make_run(monkeypatch, model) + selected.evidenceIds = [f"candidate:{selected.candidateId}:seedStability"] + action = run.review("full", 0, selected, {}) + assert action.action == "accept" + assert action.evidenceIds == selected.evidenceIds + assert requests == 2 + + +def test_saved_scientific_defer_replays_completed_candidates_without_new_work( + monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest +) -> None: + saved = request.getfixturevalue("memory_checkpoints") + run, prototype = make_run(monkeypatch, object()) + run.handoff.nCells = 100 + run.evaluations["full"] = [] + run.settings = {} + + def unexpected(*args: Any, **kwargs: Any) -> Any: + pytest.fail("A committed defer cannot trigger new model or scientific work") + + run.store = SimpleNamespace( + inspect_artifact=lambda _: SimpleNamespace(exists=True, complete=True), + run_normalization=unexpected, + ) + monkeypatch.setattr( + rna_tuning, + "screening_coverage", + lambda *args, **kwargs: ({"screeningCells": 100}, []), + ) + for resolution in (0.5, 0.75, 1.0, 1.25): + setting = run.baseline(resolution) + inputs = run.execution_inputs(run.cells, setting) + candidate_id = f"rna_{rna_tuning.candidate_identity(inputs)[:24]}" + candidate = prototype.model_copy(deep=True) + candidate.candidateId = candidate_id + candidate.parameters = setting.parameters.model_copy( + update={"candidateId": candidate_id} + ) + candidate.evidenceIds = [f"candidate:{candidate_id}:seedStability"] + admission = run.budget.admit("full", inputs) + run.budget.complete( + admission, {"evaluation": candidate.model_dump(mode="json")} + ) + + def defer(**kwargs: Any) -> Any: + action = assess(**{**kwargs, "output_validator": lambda action: action}).output + action.action = "defer" + action.rationale = ( + "Independent evidence is needed before accepting this partition." + ) + return SimpleNamespace(output=kwargs["output_validator"](action)) + + monkeypatch.setattr(rna_tuning, "run_agent_sync", defer) + first_report, first_summary = run.run() + assert first_report.status == "needsInput" + before = json.dumps(saved, sort_keys=True) + + resumed, _ = make_run(monkeypatch, object()) + resumed.handoff = run.handoff + resumed.store = run.store + resumed.evaluations["full"] = [] + resumed.settings = {} + monkeypatch.setattr(rna_tuning, "run_agent_sync", unexpected) + monkeypatch.setattr(tuning, "_analysis_visual_content", unexpected) + resumed_report, resumed_summary = resumed.run() + assert resumed_report == first_report + assert resumed_report.status == "needsInput" + assert resumed_summary["budget"] == first_summary["budget"] + assert json.dumps(saved, sort_keys=True) == before + + +def test_model_failure_is_not_reported_as_a_scientific_question( + monkeypatch: pytest.MonkeyPatch, +) -> None: + run, _ = make_run(monkeypatch, SimpleNamespace(supports_image_input=False)) + failure = UnexpectedModelBehavior("Exceeded maximum output retries") + + def fail(*args: Any, **kwargs: Any) -> Any: + raise failure + + monkeypatch.setattr(run, "assess_scope", fail) + with pytest.raises(UnexpectedModelBehavior) as caught: + run.run() + assert caught.value is failure + + +def test_exhausted_scientific_work_stays_unresolved( + monkeypatch: pytest.MonkeyPatch, +) -> None: + run, _ = make_run(monkeypatch, SimpleNamespace(supports_image_input=False)) + + def exhaust(*args: Any, **kwargs: Any) -> Any: + raise rna_tuning.CandidateBudgetExceeded("Screening candidate limit reached") + + monkeypatch.setattr(run, "assess_scope", exhaust) + report, _ = run.run() + assert report.status == "needsInput" + assert report.needsInput is not None + assert "Screening candidate limit reached" in report.needsInput.question + + +@pytest.mark.parametrize("scope", ["sample0", "full"]) +@pytest.mark.parametrize("experiments", [[], ["neighborsK:21", "dimensions:30"]]) +def test_assessment_schema_limits_choices_without_changing_saved_fields( + scope: str, experiments: list[str] +) -> None: + output_type = rna_tuning._assessment_output_type( + ["observed_a", "observed_b", "observed_a"], experiments, scope=scope + ) + properties = output_type.model_json_schema()["properties"] + assert properties["selectedCandidateId"]["enum"] == ["observed_a", "observed_b"] + assert ("enlarge" in properties["action"]["enum"]) is (scope != "full") + assert ("experiment" in properties["action"]["enum"]) is bool(experiments) + if experiments: + assert properties["experimentId"]["anyOf"][0]["enum"] == experiments + else: + assert properties["experimentId"]["type"] == "null" + assert ( + output_type.model_fields.keys() == rna_tuning.TuningAction.model_fields.keys() + ) + saved_action = rna_tuning.TuningAction( + action="defer", + selectedCandidateId="observed_a", + correctionNeed="notApplicable", + assessedDomains=[], + evidenceIds=["candidate:observed_a"], + quantitativeFindings=["Observed stability needs further assessment."], + qualitativeFindings=["Marker support remains unresolved."], + objectivePreservation="Preserve marker-supported populations.", + rationale="Defer pending essential evidence.", + ) + assert ( + output_type.model_validate(saved_action.model_dump()).model_dump() + == saved_action.model_dump() + ) + if scope == "full": + with pytest.raises(ValidationError, match="accept.*defer"): + output_type.model_validate( + {**saved_action.model_dump(), "action": "enlarge"} + ) + + +@pytest.mark.parametrize("invalid_field", ["selectedCandidateId", "experimentId"]) +def test_real_agent_retries_choices_against_the_current_output_schema( + monkeypatch: pytest.MonkeyPatch, + request: pytest.FixtureRequest, + invalid_field: str, +) -> None: + requests = 0 + feedback: list[str] = [] + chosen_experiment = "" + + async def reply(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: + nonlocal requests, chosen_experiment + requests += 1 + user_part = next( + part + for message in messages + for part in message.parts + if isinstance(part, UserPromptPart) + ) + assert isinstance(user_part.content, str) + evidence = json.loads(user_part.content) + selected_id = evidence["currentCandidateId"] + chosen_experiment = next(iter(evidence["experiments"])) + assert info.output_tools + properties = info.output_tools[0].parameters_json_schema["properties"] + assert properties["selectedCandidateId"]["const"] == selected_id + assert set(properties["experimentId"]["anyOf"][0]["enum"]) == set( + evidence["experiments"] + ) + action = rna_tuning.TuningAction( + action="experiment", + selectedCandidateId=selected_id, + experimentId=chosen_experiment, + correctionNeed="notApplicable", + assessedDomains=[], + evidenceIds=[f"candidate:{selected_id}"], + quantitativeFindings=["The supplied stability metric is 0.9."], + qualitativeFindings=["Reported markers support a cytotoxic program."], + concern="Assess sensitivity to the offered parameter change.", + expectedImprovement="Test whether the comparison improves population support.", + objectivePreservation="Preserve the observed cytotoxic population.", + rationale=f"Request the offered next comparison {chosen_experiment}.", + ).model_dump(mode="json") + if requests == 1: + action[invalid_field] = "invented_choice" + else: + feedback.extend( + str(part.content) + for message in messages + for part in message.parts + if isinstance(part, RetryPromptPart) + ) + assert any(invalid_field in text for text in feedback) + assert any("invented_choice" in text for text in feedback) + assert any( + ( + selected_id + if invalid_field == "selectedCandidateId" + else chosen_experiment + ) + in text + for text in feedback + ) + return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, action)]) + + model = FunctionModel(reply, profile={"supports_image_input": False}) + run, selected = make_run(monkeypatch, model) + action = run.review("full", 0, selected, {}) + assert requests == 2 + assert action.action == "experiment" + assert action.experimentId == chosen_experiment + saved = request.getfixturevalue("memory_checkpoints") + assert saved["parameter_tuning/full/review0"]["outputs"]["action"] == ( + rna_tuning.TuningAction.model_validate(action.model_dump()).model_dump( + mode="json" + ) + ) + + +def test_invalid_experiment_feedback_names_the_available_choices( + monkeypatch: pytest.MonkeyPatch, +) -> None: + run, selected = make_run(monkeypatch, object()) + + def invalid_assessment(**kwargs: Any) -> Any: + action = assess(**{**kwargs, "output_validator": lambda action: action}).output + action.action = "experiment" + action.experimentId = "invented_choice" + action.concern = "Assess an observed concern." + action.expectedImprovement = "Improve the supported representation." + return SimpleNamespace(output=kwargs["output_validator"](action)) + + monkeypatch.setattr(rna_tuning, "run_agent_sync", invalid_assessment) + with pytest.raises(ValueError, match="Unknown experiment ID") as caught: + run.review("full", 0, selected, {}) + assert "invented_choice" in str(caught.value) + assert selected.candidateId in str(caught.value) + assert next(iter(run.experiments(selected))) in str(caught.value) diff --git a/tests/test_agent_rna_rare_population.py b/tests/test_agent_rna_rare_population.py new file mode 100644 index 00000000..947be108 --- /dev/null +++ b/tests/test_agent_rna_rare_population.py @@ -0,0 +1,217 @@ +"""Bounded numerical coverage of rare study groups during RNA screening.""" + +import json +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import numpy as np +import pytest + +from scarf.agent.experimental_context.study import StudyContract +from scarf.agent.ingest import ingest +from scarf.agent.orchestrator import rna_tuning +from scarf.agent.orchestrator.models import ( + AutomatedPreprocessingPlan, + AutomatedWorkflowConfig, + PreprocessedAssayHandoff, + artifact_model_to_ref, +) +from scarf.agent.parameter_tuning.hvg import core_hvg_evidence +from scarf.agent.types import ArtifactReferenceModel +from scarf.datastore.datastore import DataStore +from tests.agent_examples import example +from tests.test_agent_ingest import _write_h5ad +from tests.test_agent_rna_adaptive import checkpoints # noqa: F401 + + +@pytest.mark.slow +@pytest.mark.usefixtures("checkpoints") +def test_rare_study_group_enlarges_then_retains_full_reference_markers( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A 6% group needs full evidence, without being discarded as invalid.""" + rng = np.random.default_rng(4444) + values = rng.poisson(0.2, (400, 90)).astype(np.uint16) + values[:188, :12] += rng.poisson(9.0, (188, 12)).astype(np.uint16) + values[188:376, 12:24] += rng.poisson(9.0, (188, 12)).astype(np.uint16) + values[376:, 24:36] += rng.poisson(20.0, (24, 12)).astype(np.uint16) + rare_markers = [ + "NKG7", + "GNLY", + "PRF1", + "KLRD1", + "FCER1G", + "TYROBP", + "CTSW", + "CST7", + "GZMB", + "GZMH", + "CCL5", + "FGFBP2", + ] + names = [ + *[f"COMMON_A_{i}" for i in range(12)], + *[f"COMMON_B_{i}" for i in range(12)], + *rare_markers, + *[f"BACKGROUND_{i}" for i in range(54)], + ] + source, target = tmp_path / "rare_rna.h5ad", tmp_path / "rare_rna.zarr" + _write_h5ad( + source, + values, + feature_types=[b"Gene Expression"] * 90, + feature_names=[name.encode() for name in names], + ) + ingested = ingest(path=source, zarrPath=target, directions={"matrixKey": "X"}) + assert ingested.status == "done", ingested.notes + store = DataStore( + str(target), + default_assay="RNA", + min_features_per_cell=-1, + mito_pattern="", + ribo_pattern="", + ) + conditions = np.asarray(["common"] * 376 + ["rare_condition"] * 24) + store.cells.insert("condition", conditions, overwrite=True) + full_cells = store.snapshot_cell_selection("I") + live_before = store.cells.fetch_all("I").copy() + assert int(live_before.sum()) == 400 + features = core_hvg_evidence(store, assay="RNA", cells=full_cells) + + # The separate reference uses the public core recipe on every cell. + normalized = store.run_normalization(full_cells, features=features["scarfDefault"]) + pca = store.run_pca(normalized) + ann = store.build_ann_index( + pca, ann_metric="l2", ann_parallel=False, rand_state=4466 + ) + neighbors = store.query_neighbors(ann, coordinates=pca, k=11) + graph = store.build_connectivity_map( + neighbors, local_connectivity=1.0, bandwidth=1.5 + ) + reference = store.run_leiden_clustering( + graph, + resolution=0.5, + backend="igraph", + symmetric_graph=False, + graph_upper_only=False, + random_seed=4444, + ) + reference_labels = np.asarray(store.load_artifact(reference)["values"][:]) + rare_label, rare_count = np.unique(reference_labels[376:], return_counts=True) + assert len(rare_label) == 1 and rare_count[0] == 24 + assert int((reference_labels == rare_label[0]).sum()) == 24 + + refs = { + key: ArtifactReferenceModel.from_artifact_ref(ref) + for key, ref in features.items() + } + handoff = PreprocessedAssayHandoff( + assay="RNA", + assayType="RNA", + cellSelection=ArtifactReferenceModel.from_artifact_ref(full_cells), + graphFeatures=refs["scarfDefault"], + graphFeatureCandidates=refs, + markerFeatures=ArtifactReferenceModel.from_artifact_ref( + store.select_all_features(from_assay="RNA") + ), + nCells=400, + nFeatures=int( + np.asarray(store.load_artifact(features["scarfDefault"])["values"][:]).sum() + ), + reductionMethod="pca", + ) + plan = example(AutomatedPreprocessingPlan) + plan.cellQc.attributes = [] + study = StudyContract.get_blank().model_copy( + update={ + "conditionColumns": ["condition"], + "protectedColumns": ["condition"], + "columnKinds": {"condition": "categorical"}, + } + ) + assessments = [] + + def assess(**kwargs: Any) -> Any: + evidence = json.loads(kwargs["user_prompt"][0]) + assessments.append(evidence["scope"]) + assert evidence["scope"] == "full" + chosen = next( + row + for row in evidence["candidates"] + if row["parameters"]["leidenResolution"] == 0.5 + ) + assert chosen["eligible"] and chosen["metrics"]["markerCoherence"] is not None + action = rna_tuning.TuningAction( + action="accept", + selectedCandidateId=chosen["candidateId"], + correctionNeed="notApplicable", + assessedDomains=sorted(rna_tuning._DOMAINS), + evidenceIds=[ + f"candidate:{chosen['candidateId']}", + *evidence["imageHashes"], + ], + quantitativeFindings=[ + "The full reference retains the 24-cell condition group and the candidate has marker and stability evidence." + ], + qualitativeFindings=[ + "The observed marker/PCA board supports the distinct NKG7/GNLY cytotoxic program." + ], + objectivePreservation="Retain the rare condition group; do not merge or discard its cells because screening underrepresents it.", + rationale="Accept the supported full-cohort partition after both samples lacked rare-group evidence.", + ) + return SimpleNamespace(output=kwargs["output_validator"](action)) + + monkeypatch.setattr(rna_tuning, "run_agent_sync", assess) + run = rna_tuning.RnaTuningRun( + SimpleNamespace(model=object()), + store, + SimpleNamespace(workflowRunId="rare-test"), + SimpleNamespace( + config=AutomatedWorkflowConfig(screeningCells=100, maxScreeningCells=200) + ), + plan, + handoff, + study, + {}, + {"fixture": "rare-marker-program"}, + ) + report, summary = run.run() + assert report.status == "done", report.rationale + assert assessments == ["full"] + coverage = [row for row in summary["history"] if "coverage" in row] + assert [row["scope"] for row in coverage] == ["sample0", "sample1", "full"] + rare_rows = [ + next( + group + for group in row["coverage"]["groups"]["condition"] + if group["value"] == "rare_condition" + ) + for row in coverage + ] + assert 0 < rare_rows[0]["screeningCells"] < rare_rows[1]["screeningCells"] < 20 + assert rare_rows[2]["screeningCells"] == 24 + assert coverage[0]["coverageConcerns"] and coverage[1]["coverageConcerns"] + assert run.evaluations["sample0"] == run.evaluations["sample1"] == [] + assert summary["budget"]["scopes"]["sample0"]["reserved"]["partitions"] == 0 + assert summary["budget"]["scopes"]["sample1"]["reserved"]["partitions"] == 0 + assert summary["budget"]["scopes"]["full"]["completed"] == { + "graphs": 1, + "partitions": 4, + } + assert report.cellSelection == handoff.cellSelection + assert artifact_model_to_ref(report.finalClusterArtifact) == reference + np.testing.assert_array_equal(store.cells.fetch_all("I"), live_before) + selected = next( + row + for row in report.evaluations + if row.candidateId == report.recommendedCandidateId + ) + markers = store.get_markers( + marker=artifact_model_to_ref(selected.artifacts["markerTable"]), + min_score=0.0, + min_frac_exp=0.0, + ) + rare_table = markers[markers.group_id.astype(str) == str(rare_label[0])] + assert {"NKG7", "GNLY", "PRF1"}.issubset(set(rare_table.feature_name)) diff --git a/tests/test_agent_rna_workflow_scope.py b/tests/test_agent_rna_workflow_scope.py index 00dcf0bf..fa07ae27 100644 --- a/tests/test_agent_rna_workflow_scope.py +++ b/tests/test_agent_rna_workflow_scope.py @@ -1,5 +1,7 @@ """RNA selection, early rejection, and immutable resume boundaries.""" +from tests.agent_examples import example + from pathlib import Path from types import SimpleNamespace from typing import Any @@ -22,10 +24,13 @@ ) from scarf.agent.orchestrator import context as context_module from scarf.agent.orchestrator import journal, main as main_module -from scarf.agent.orchestrator.models import PreprocessedAssayHandoff, WorkflowStageName +from scarf.agent.orchestrator.models import ( + OrchestrationRequestRecord, + PreprocessedAssayHandoff, + WorkflowIdentity, + WorkflowStageName, +) from scarf.agent.orchestrator.rna import selected_rna_assay -from scarf.agent.persistence.contracts import AgentInvocation -from scarf.agent.persistence.reports import create_agent_workflow from scarf.datastore.datastore import DataStore from scarf.storage.budget import ResourceBudget from scarf.storage.schema import create_zarr_count_assay @@ -113,18 +118,18 @@ def stop_after_selection( result = AgentOrchestrator(object()).run(_request(str(path), primaryAssay="RNA2")) assert result.status == "failed" assert inspected == [["RNA2"]] - assert result.workflowRun is not None + assert result.workflowRunId is not None root = zarr.open_group(str(path), mode="r") prefix = "agents/orchestrations" record = journal._read_model( root, - journal._request_key(prefix, result.workflowRun.workflowRunId), - main_module.OrchestrationRequestRecord, + journal._request_key(prefix, result.workflowRunId), + OrchestrationRequestRecord, ) assert record.request.analysisAssays == ["RNA2"] assert record.request.primaryAssay == record.request.markerAssay == "RNA2" assert not journal._stage_outcomes( - root, prefix, result.workflowRun.workflowRunId, "hto_demultiplexing" + root, prefix, result.workflowRunId, "rna_quality_metrics" ) @@ -202,13 +207,21 @@ def test_resume_rejects_unsupported_saved_route_before_writable_open( path = create_store(tmp_path / "resume.zarr") store = DataStore(str(path), default_assay="RNA", min_features_per_cell=-1) orchestrator = AgentOrchestrator(object()) - workflow = create_agent_workflow(store) + workflow = WorkflowIdentity("resume-rna") record = orchestrator.initialize_request( - store, workflow, _request(str(path), zarrPath=str(path)) + store, + workflow, + _request( + str(path), + zarrPath=str(path), + primaryAssay="RNA", + markerAssay="RNA", + analysisAssays=["RNA"], + ), ) prefix = journal._ensure_orchestration_store(store) stage: WorkflowStageName = ( - "hto_demultiplexing" + "rna_quality_metrics" if route == "hto" else "data_enrichment" if route == "enrichment_modality" @@ -222,13 +235,12 @@ def test_resume_rejects_unsupported_saved_route_before_writable_open( if route == "hto": outputs["htoIdentityArtifacts"] = [{"name": "HTO_identity"}] elif route == "enrichment_modality": - report = DataEnrichmentReport.get_example() + report = example(DataEnrichmentReport) report.policies[0].assayModality = "ADT" _, reference = journal._save_stage_report( store, started, report, - invocation=AgentInvocation(agentName="data_enrichment"), expected_type=DataEnrichmentReport, ) references.append(reference) diff --git a/tests/test_agent_runtime.py b/tests/test_agent_runtime.py deleted file mode 100644 index 3dc4e534..00000000 --- a/tests/test_agent_runtime.py +++ /dev/null @@ -1,147 +0,0 @@ -"""Runtime reachability tests for scarf.agent.check_runtime.""" - -import json -import os -from typing import Any - -import pytest - -from scarf.agent import check_runtime, load_env -from scarf.agent import runtime as runtime_module - - -class _FakeResponse: - def __init__(self, payload: dict[str, Any]) -> None: - self._payload = payload - - def read(self) -> bytes: - return json.dumps(self._payload).encode("utf-8") - - def __enter__(self) -> "_FakeResponse": - return self - - def __exit__(self, *args: object) -> None: - return None - - -def test_load_env_reads_file_without_overriding( - tmp_path, monkeypatch: pytest.MonkeyPatch -) -> None: - env_file = tmp_path / ".env" - env_file.write_text( - "OLLAMA_BASE_URL=https://from-file.example/v1\n" - "OLLAMA_MODEL=from-file\n" - "OLLAMA_API_KEY=from-file-key\n", - encoding="utf-8", - ) - monkeypatch.delenv("OLLAMA_BASE_URL", raising=False) - monkeypatch.delenv("OLLAMA_MODEL", raising=False) - monkeypatch.setenv("OLLAMA_API_KEY", "already-set") - - loaded = load_env(env_file) - assert loaded == env_file - assert os.environ["OLLAMA_BASE_URL"] == "https://from-file.example/v1" - assert os.environ["OLLAMA_MODEL"] == "from-file" - assert os.environ["OLLAMA_API_KEY"] == "already-set" - - -def test_check_runtime_ok_when_model_listed(monkeypatch: pytest.MonkeyPatch) -> None: - def fake_urlopen(request: object, timeout: float = 0.0): - return _FakeResponse({"data": [{"id": "test-model"}]}) - - monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) - check_runtime(baseUrl="http://example.test/v1", model="test-model") - - -def test_check_runtime_sends_bearer_token(monkeypatch: pytest.MonkeyPatch) -> None: - seen: dict[str, str] = {} - - def fake_urlopen(request: Any, timeout: float = 0.0): - seen["authorization"] = request.get_header("Authorization") - return _FakeResponse({"data": [{"id": "test-model"}]}) - - monkeypatch.setenv("OLLAMA_API_KEY", "secret-key") - monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) - check_runtime(baseUrl="http://example.test/v1", model="test-model") - assert seen["authorization"] == "Bearer secret-key" - - -def test_check_runtime_uses_env_defaults(monkeypatch: pytest.MonkeyPatch) -> None: - def fake_urlopen(request: object, timeout: float = 0.0): - return _FakeResponse({"data": [{"id": "env-model"}]}) - - monkeypatch.setattr(runtime_module, "load_env", lambda path=None: None) - monkeypatch.setenv("OLLAMA_BASE_URL", "http://example.test/v1") - monkeypatch.setenv("OLLAMA_MODEL", "env-model") - monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) - check_runtime() - - -def test_check_runtime_fails_when_endpoint_down( - monkeypatch: pytest.MonkeyPatch, -) -> None: - import urllib.error - - def fake_urlopen(request: object, timeout: float = 0.0): - raise urllib.error.URLError("connection refused") - - monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) - with pytest.raises(RuntimeError, match="endpoint unreachable"): - check_runtime(baseUrl="http://example.test/v1", model="test-model") - - -def test_check_runtime_fails_when_model_missing( - monkeypatch: pytest.MonkeyPatch, -) -> None: - def fake_urlopen(request: object, timeout: float = 0.0): - return _FakeResponse({"data": [{"id": "other-model"}]}) - - monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) - with pytest.raises(RuntimeError, match="Model 'test-model' not found"): - check_runtime(baseUrl="http://example.test/v1", model="test-model") - - -@pytest.mark.integration -def test_check_runtime_live_ollama_smoke() -> None: - import urllib.error - import urllib.request - - load_env() - base_url = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434/v1") - model = os.environ.get("OLLAMA_MODEL", "qwen3.5:4b") - try: - urllib.request.urlopen(f"{base_url.rstrip('/')}/models", timeout=2.0) - except (urllib.error.URLError, TimeoutError): - pytest.skip("OpenAI-compatible endpoint is not reachable") - - try: - check_runtime(baseUrl=base_url, model=model) - except RuntimeError as exc: - pytest.skip(str(exc)) - - from pydantic_ai.models.ollama import OllamaModel - from pydantic_ai.providers.ollama import OllamaProvider - - from scarf.agent import EvidenceItem, decide - - decision = decide( - model=OllamaModel( - model, - provider=OllamaProvider(base_url=base_url), - ), - question="Which matrix looks like raw counts?", - evidence=[ - EvidenceItem( - id="matrix:X", - label="X", - summary="float, mostly non-integer", - ), - EvidenceItem( - id="matrix:raw/X", - label="raw/X", - summary="integer-like", - ), - ], - ) - assert decision.selectedId in {"matrix:X", "matrix:raw/X"} - assert decision.selectedId in decision.evidenceIds diff --git a/tests/test_agent_sequential_tuning.py b/tests/test_agent_sequential_tuning.py deleted file mode 100644 index 3283e850..00000000 --- a/tests/test_agent_sequential_tuning.py +++ /dev/null @@ -1,348 +0,0 @@ -from typing import Any - -import pytest -from pydantic import ValidationError - -from scarf.agent.parameter_tuning import ( - ArtifactRecord, - ParameterCandidate, - ParameterCandidateEvaluation, -) -from scarf.agent.parameter_tuning.sequential import ( - CorrectionNeedSelection, - ParameterPhaseEvidence, - ParameterPhasePlan, - ParameterPhaseSelection, - SequentialAssayTuningEvidence, - SequentialRnaTuningPlanner, - execute_parameter_phase, - sequential_evidence_to_report, - validate_parameter_phase_selection, -) -from scarf.agent.types import ArtifactReferenceModel - - -def _evaluation(candidate: ParameterCandidate) -> ParameterCandidateEvaluation: - evidence_id = f"candidate:{candidate.candidateId}:clusters" - return ParameterCandidateEvaluation( - candidateId=candidate.candidateId, - status="done", - eligible=True, - parameters=candidate, - artifacts={ - "connectivityMap": ArtifactRecord( - assay="RNA", - kind="connectivity_map", - artifactId="a" * 64, - ), - "clusters": ArtifactRecord( - assay="RNA", - kind="cluster_labels", - artifactId="b" * 64, - ), - }, - cellSelection=ArtifactReferenceModel( - scope="datastore", - assay=None, - kind="cell_selection", - artifactId="c" * 64, - ), - clusterColumn=f"RNA_{candidate.candidateId}", - clusterLabel=candidate.candidateId, - effectiveDimensions=candidate.dimensions, - evidenceIds=[evidence_id], - ) - - -def _selected_phase( - plan: ParameterPhasePlan, - *, - selected_index: int = -1, -) -> ParameterPhaseEvidence: - evaluations = [_evaluation(candidate) for candidate in plan.candidates] - selected = evaluations[selected_index] - return validate_parameter_phase_selection( - plan, - evaluations, - ParameterPhaseSelection( - phase=plan.phase, - status="selected", - selectedCandidateId=selected.candidateId, - evidenceIds=list(selected.evidenceIds), - rationale=f"Selected registered {plan.phase} evidence.", - ), - ) - - -def test_sequential_planner_caps_registered_rna_candidates() -> None: - planner = SequentialRnaTuningPlanner( - workflow_run_id="workflow:with unsafe punctuation", - assay="RNA sample", - n_cells=35, - n_features=100, - matrix_rank=27, - harmony_authorized=True, - ) - - pca = planner.pca_prefix_phase() - assert [value.dimensions for value in pca.candidates] == [10, 20, 27] - assert all(value.useHarmony is False for value in pca.candidates) - assert all( - len(value.candidateId) <= 64 and value.candidateId.replace("_", "").isalnum() - for value in pca.candidates - ) - - correction = planner.batch_correction_phase(pca.candidates[1]) - assert [value.useHarmony for value in correction.candidates] == [False, True] - assert {value.dimensions for value in correction.candidates} == {20} - - graph = planner.graph_phase(correction.candidates[1]) - assert [value.neighborsK for value in graph.candidates] == [11, 21, 34] - assert all(value.useHarmony for value in graph.candidates) - - clustering = planner.clustering_phase(graph.candidates[1]) - assert [value.leidenResolution for value in clustering.candidates] == [ - 0.25, - 0.5, - 0.75, - 1.0, - 1.25, - 1.5, - ] - assert {value.neighborsK for value in clustering.candidates} == {21} - - -def test_sequential_planner_does_not_offer_unauthorized_harmony() -> None: - planner = SequentialRnaTuningPlanner( - workflow_run_id="workflow", - assay="RNA", - n_cells=100, - n_features=50, - harmony_authorized=False, - ) - selected = planner.pca_prefix_phase().candidates[0] - - correction = planner.batch_correction_phase(selected) - - assert len(correction.candidates) == 1 - assert correction.candidates[0].useHarmony is False - - -def test_phase_contract_rejects_noncausal_and_numeric_model_output() -> None: - first = ParameterCandidate( - candidateId="pca_10", - dimensions=10, - neighborsK=11, - ) - second = ParameterCandidate( - candidateId="pca_20", - dimensions=20, - neighborsK=21, - ) - with pytest.raises(ValidationError, match="non-target parameter"): - ParameterPhasePlan( - phase="pcaPrefix", - assay="RNA", - variedParameter="dimensions", - candidates=[first, second], - ) - - with pytest.raises(ValidationError, match="Extra inputs are not permitted"): - ParameterPhaseSelection.model_validate( - { - "phase": "pcaPrefix", - "status": "selected", - "selectedCandidateId": "pca_10", - "evidenceIds": ["candidate:pca_10:clusters"], - "rationale": "Choose the exact registered option.", - "dimensions": 10, - } - ) - - -def test_phase_selection_requires_eligible_execution_and_scoped_evidence() -> None: - plan = SequentialRnaTuningPlanner( - workflow_run_id="workflow", - assay="RNA", - n_cells=100, - n_features=50, - harmony_authorized=False, - ).pca_prefix_phase() - evaluations = [_evaluation(candidate) for candidate in plan.candidates] - - with pytest.raises(ValidationError, match="outside its phase evaluations"): - ParameterPhaseEvidence( - plan=plan, - evaluations=evaluations, - selection=ParameterPhaseSelection( - phase="pcaPrefix", - status="selected", - selectedCandidateId=plan.candidates[0].candidateId, - evidenceIds=["invented:evidence"], - rationale="This cites evidence that was not executed.", - ), - ) - with pytest.raises(ValidationError, match="must cite executor evidence"): - ParameterPhaseSelection( - phase="pcaPrefix", - status="selected", - selectedCandidateId=plan.candidates[0].candidateId, - rationale="This selection omitted its evidence.", - ) - - -def test_completed_sequential_evidence_adapts_for_native_finalization() -> None: - planner = SequentialRnaTuningPlanner( - workflow_run_id="workflow", - assay="RNA", - n_cells=100, - n_features=50, - harmony_authorized=True, - ) - pca = _selected_phase(planner.pca_prefix_phase(), selected_index=1) - assert pca.selected_evaluation() is not None - correction = _selected_phase( - planner.batch_correction_phase(pca.selected_evaluation().parameters), - selected_index=1, - ) - assert correction.selected_evaluation() is not None - graph = _selected_phase( - planner.graph_phase(correction.selected_evaluation().parameters), - selected_index=1, - ) - assert graph.selected_evaluation() is not None - clustering = _selected_phase( - planner.clustering_phase(graph.selected_evaluation().parameters), - selected_index=2, - ) - final_id = clustering.selection.selectedCandidateId - evidence = SequentialAssayTuningEvidence( - assay="RNA", - phases=[pca, correction, graph, clustering], - finalCandidateId=final_id, - ) - - report = sequential_evidence_to_report(evidence) - - assert report.status == "done" - assert report.recommendedCandidateId == final_id - assert report.assayReports["RNA"].recommendedCandidateId == final_id - assert report.graphAssay == "RNA" - assert report.markerAssay == "RNA" - assert report.finalClusterArtifact == report.selectedArtifacts["clusters"] - assert report.finalClusterColumn is not None - assert report.totalCandidates == sum( - len(value.evaluations) for value in evidence.phases - ) - - -def test_pending_sequential_evidence_preserves_exact_resume_options() -> None: - planner = SequentialRnaTuningPlanner( - workflow_run_id="workflow", - assay="RNA", - n_cells=100, - n_features=50, - harmony_authorized=False, - ) - plan = planner.pca_prefix_phase() - evaluations = [_evaluation(candidate) for candidate in plan.candidates] - option_ids = ["pcaPrefix:short", "pcaPrefix:standard", "pcaPrefix:defer"] - evidence_ids = [value.evidenceIds[0] for value in evaluations] - state = SequentialAssayTuningEvidence( - assay="RNA", - phases=[ - ParameterPhaseEvidence( - plan=plan, - evaluations=evaluations, - selection=ParameterPhaseSelection( - phase="pcaPrefix", - status="needsInput", - rationale="The bounded decision run did not select an option.", - ), - ) - ], - pendingDecisionId="pcaPrefix", - pendingOptionIds=option_ids, - pendingEvidenceIds=evidence_ids, - ) - - report = sequential_evidence_to_report(state) - - assert report.status == "needsInput" - assert report.needsInput is not None - assert report.needsInput.options == option_ids - assert report.needsInput.evidenceIds == evidence_ids - - -def test_pending_correction_need_precedes_batch_phase() -> None: - planner = SequentialRnaTuningPlanner( - workflow_run_id="workflow", - assay="RNA", - n_cells=100, - n_features=50, - harmony_authorized=True, - ) - pca = _selected_phase(planner.pca_prefix_phase()) - state = SequentialAssayTuningEvidence( - assay="RNA", - phases=[pca], - correctionLicense="safe", - correctionNeed=CorrectionNeedSelection( - status="needsInput", - selectedOptionId="correctionNeed:indeterminate", - rationale="The native representation evidence is incomplete.", - ), - pendingDecisionId="correctionNeed", - pendingOptionIds=[ - "correctionNeed:needed", - "correctionNeed:notNeeded", - "correctionNeed:indeterminate", - ], - pendingEvidenceIds=["evidence:correctionNeed:design"], - ) - - report = sequential_evidence_to_report(state) - - assert report.status == "needsInput" - assert report.needsInput is not None - assert report.needsInput.options[0] == "correctionNeed:needed" - - -def test_phase_executor_adapter_preserves_registered_order( - monkeypatch: pytest.MonkeyPatch, -) -> None: - planner = SequentialRnaTuningPlanner( - workflow_run_id="workflow", - assay="RNA", - n_cells=50, - n_features=50, - harmony_authorized=False, - ) - plan = planner.pca_prefix_phase() - expected_ids = tuple(value.candidateId for value in plan.candidates) - calls: list[str] = [] - - def prepare(*args: Any, **kwargs: Any) -> tuple[object, list[str]]: - assert kwargs["candidates"] == plan.candidates - return object(), list(expected_ids) - - by_id = {value.candidateId: value for value in plan.candidates} - - def execute(deps: object, candidate_id: str) -> ParameterCandidateEvaluation: - assert deps is not None - calls.append(candidate_id) - return _evaluation(by_id[candidate_id]) - - monkeypatch.setattr( - "scarf.agent.parameter_tuning.sequential.prepare_parameter_tuning_dependencies", - prepare, - ) - monkeypatch.setattr( - "scarf.agent.parameter_tuning.sequential.execute_parameter_candidate", - execute, - ) - - evaluations = execute_parameter_phase(object(), normalized=object(), plan=plan) - - assert tuple(value.candidateId for value in evaluations) == expected_ids - assert tuple(calls) == expected_ids diff --git a/tests/test_agent_tuning_diagnostics.py b/tests/test_agent_tuning_diagnostics.py index 61341f86..51b76faa 100644 --- a/tests/test_agent_tuning_diagnostics.py +++ b/tests/test_agent_tuning_diagnostics.py @@ -1,3 +1,4 @@ +from tests.agent_examples import example from types import SimpleNamespace import numpy as np @@ -68,8 +69,8 @@ def run_doublet_detection(self, *_args: object, **_kwargs: object) -> None: evidence = score_advisory_doublets( Store(), - ParameterCandidateEvaluation.get_example(), - [ParameterCandidateEvaluation.get_example()], + example(ParameterCandidateEvaluation), + [example(ParameterCandidateEvaluation)], assay="RNA", feature_selection=ArtifactRef( scope="assay", diff --git a/tests/test_agent_tuning_report_retry.py b/tests/test_agent_tuning_report_retry.py new file mode 100644 index 00000000..5c2c75a1 --- /dev/null +++ b/tests/test_agent_tuning_report_retry.py @@ -0,0 +1,164 @@ +"""Tuning resumes numerical evidence without overwriting prior attempt reports.""" + +from types import SimpleNamespace + +import numpy as np +import pytest + +from scarf.agent.experimental_context import ExperimentalContextResult +from scarf.agent.experimental_context.study import StudyContract +from scarf.agent.orchestrator import journal, rna_tuning +from scarf.agent.orchestrator.models import ( + AutomatedPreprocessingPlan, + PreprocessedAssayHandoff, + WorkflowIdentity, +) +from scarf.agent.orchestrator.tuning import TuningStagesMixin +from scarf.agent.parameter_tuning import ParameterTuningReport +from scarf.agent.parameter_tuning.contracts import ParameterTuningNeedsInput +from tests.agent_examples import example +from tests.agent_journal_store import memory_journal + + +def test_tuning_reports_survive_pause_failure_and_completion(monkeypatch) -> None: + store, prefix, request = memory_journal("analysis") + feature_values = { + "ids": np.asarray(["gene-1", "gene-2", "gene-3"]), + "names": np.asarray(["A", "B", "C"]), + } + features = SimpleNamespace( + N=3, + _get_array=feature_values.__getitem__, + default_block_rows=lambda column: 2, + ) + store.get_assay = lambda assay: SimpleNamespace(feats=features) + workflow = WorkflowIdentity(request.workflowRunId, request.request.workspace) + plan = example(AutomatedPreprocessingPlan) + handoff = example(PreprocessedAssayHandoff) + paused = ParameterTuningReport( + status="needsInput", + fromAssay="RNA", + cellSelection=handoff.cellSelection, + rationale="The provider did not return a valid assessment.", + needsInput=ParameterTuningNeedsInput(question="Retry the assessment?"), + ) + completed = example(ParameterTuningReport) + responses = [paused, RuntimeError("Assessment provider unavailable"), completed] + calls = [] + + class Runner: + def __init__(self, *args, **kwargs): + pass + + def run(self): + response = responses[len(calls)] + calls.append(response) + if isinstance(response, Exception): + raise response + return response, self.summary() + + def summary(self): + return {"history": []} + + monkeypatch.setattr(rna_tuning, "RnaTuningRun", Runner) + owner = TuningStagesMixin() + + def run_stage(): + return owner.parameter_tuning_stage( + store, + workflow, + request, + [], + plan, + [handoff], + ExperimentalContextResult.get_blank(), + None, + None, + {}, + study_contract=StudyContract.get_blank(), + ) + + first, first_report = run_stage() + assert first.status == "needsInput" + assert first_report == paused + first_start = journal._stage_starts( + store.zw, prefix, workflow.workflowRunId, "parameter_tuning" + )[0] + # Earlier runs used this execution-owned key for an incomplete report. + _, historical_ref = journal._save_stage_report( + store, first_start, paused, expected_type=ParameterTuningReport + ) + historical_path = journal._checkpoint_key( + prefix, workflow.workflowRunId, historical_ref.key + ) + historical_bytes = journal.record_io.read_key(store.zw, historical_path) + + failed, _ = run_stage() + assert failed.status == "failed" + assert "Assessment provider unavailable" in failed.error + assert failed.reportReferences == [] + done, done_report = run_stage() + assert done.status == "done", done.error + assert done_report == completed + assert len({first.attemptId, failed.attemptId, done.attemptId}) == 3 + assert ( + len( + { + historical_ref.key, + first.reportReferences[0].key, + done.reportReferences[0].key, + } + ) + == 3 + ) + assert journal.read_stage_evidence(store, historical_ref) == paused.model_dump( + mode="json" + ) + assert journal.record_io.read_key(store.zw, historical_path) == historical_bytes + for outcome, report in ((first, paused), (done, completed)): + assert ( + journal.load_stage_report(store, outcome, ParameterTuningReport) == report + ) + + # A completed stage still resolves its exact saved reports and artifacts. + reused, reused_report = run_stage() + assert reused == done + assert reused_report == completed + assert len(calls) == 3 + handoff.nCells += 1 + with pytest.raises(ValueError, match="Tuning inputs changed"): + run_stage() + assert len(calls) == 3 + + +def test_attempt_owned_report_is_immutable_and_input_bound() -> None: + store, prefix, request = memory_journal() + started = journal._start_attempt( + store.zw, + prefix, + request.workflowRunId, + "parameter_tuning", + request, + [], + inputs={"selection": "cells-1"}, + ) + report = ParameterTuningReport(status="needsInput", rationale="Awaiting evidence") + + def save(attempt, value): + return journal._save_stage_report( + store, + attempt, + value, + expected_type=ParameterTuningReport, + attempt_owned=True, + ) + + _, reference = save(started, report) + assert save(started, report)[1] == reference + with pytest.raises(ValueError, match="different outcome"): + save(started, report.model_copy(update={"status": "done"})) + with pytest.raises(ValueError, match="different scientific inputs"): + save(started.model_copy(update={"inputs": {"selection": "cells-2"}}), report) + assert journal.read_stage_evidence(store, reference) == report.model_dump( + mode="json" + ) diff --git a/tests/test_agent_tuning_reuse.py b/tests/test_agent_tuning_reuse.py index 3d15b8a6..5efb8dd7 100644 --- a/tests/test_agent_tuning_reuse.py +++ b/tests/test_agent_tuning_reuse.py @@ -8,8 +8,8 @@ import pytest import zarr -from scarf.agent.orchestrator import tuning from scarf.agent.parameter_tuning import diagnostics, execution +from scarf.agent.parameter_tuning.selection import harmony_acceptance_gate from scarf.agent.parameter_tuning.contracts import ( ArtifactRecord, ParameterCandidate, @@ -83,9 +83,11 @@ def counts() -> Counter[str]: assert counts()["metric_proportional_batch_mixing"] == 4 +@pytest.mark.parametrize("kind", ["categorical", "continuous"]) def test_pca_diagnostic_reuse_precedes_numerical_work( tmp_path: Any, monkeypatch: pytest.MonkeyPatch, + kind: str, ) -> None: root = zarr.open_group(str(tmp_path / "diagnostic.zarr"), mode="w") reduction = root.create_group("reduction") @@ -148,13 +150,21 @@ def unexpected(*_args: Any, **_kwargs: Any) -> Any: covariate_columns=("batch",), covariate_roles=("technical",), adjacent_overlap=0.8, + column_kinds={"batch": kind}, ) assert result[0] == diagnostic_ref np.testing.assert_array_equal(result[1], payload["component_variance"]) assert planned[0]["parameters"]["covariate_fingerprints"] == {"batch": "current"} + assert planned[0]["parameters"]["covariate_kinds"] == {"batch": kind} + assert ( + planned[0]["parameters"]["covariate_method"] == "typedCompleteCaseAssociation" + ) -@pytest.mark.parametrize("damage", ["none", "doublets", "protected", "selection"]) +@pytest.mark.parametrize( + "damage", + ["none", "doublets", "protected", "selection", "clisi", "graphConnectivity"], +) def test_workflow_harmony_gate_keeps_matched_evidence_requirements(damage: str) -> None: native = ParameterCandidateEvaluation( candidateId="native", @@ -164,7 +174,9 @@ def test_workflow_harmony_gate_keeps_matched_evidence_requirements(damage: str) parameters=ParameterCandidate(candidateId="native"), metrics=ParameterMetrics( batchMixing={"batch": 0.4}, - biologicalPreservation={"condition": {"clisi": 0.8}}, + biologicalPreservation={ + "condition": {"clisi": 0.8, "graphConnectivity": 0.8} + }, markerCoherence=0.8, doubletHighScoreConcentration=0.1, ), @@ -182,7 +194,10 @@ def test_workflow_harmony_gate_keeps_matched_evidence_requirements(damage: str) corrected.cellSelection = ArtifactReferenceModel.from_artifact_ref( _cell_selection(99) ) - accepted, reasons = tuning.harmony_acceptance_gate( + elif damage in {"clisi", "graphConnectivity"}: + del native.metrics.biologicalPreservation["condition"][damage] + del corrected.metrics.biologicalPreservation["condition"][damage] + accepted, reasons = harmony_acceptance_gate( native, corrected, batch_columns=["batch", "batch"], diff --git a/tests/test_agent_work_budget.py b/tests/test_agent_work_budget.py deleted file mode 100644 index 2b82a331..00000000 --- a/tests/test_agent_work_budget.py +++ /dev/null @@ -1,250 +0,0 @@ -"""Workflow-wide reservations survive retries without charging reused passes.""" - -from types import SimpleNamespace -from pathlib import Path -from typing import Any - -import pytest -import zarr -from zarr.storage import MemoryStore - -from scarf.agent.orchestrator import journal -from scarf.agent.orchestrator import AgentOrchestrator, AutomatedWorkflowRequest -from scarf.agent.orchestrator.budget import reserve_candidate_pass -from scarf.agent.orchestrator.models import ( - AutomatedWorkflowConfig, - AutomatedPreprocessingPlan, - OrchestrationRequestRecord, - WorkflowStageName, -) -from scarf.agent.persistence.contracts import AgentWorkflowRun -from scarf.agent.persistence.reports import create_agent_workflow -from scarf.agent.experimental_context import ExperimentalContextResult -from scarf.agent.experimental_context.study import StudyContract -from scarf.datastore.datastore import DataStore -from tests.agent_orchestrator_store import create_store - - -_PREFIX = "agents/orchestrations" - - -def _context( - limit: int = 50, -) -> tuple[Any, AgentWorkflowRun, OrchestrationRequestRecord]: - store = SimpleNamespace(zw=zarr.group(store=MemoryStore())) - workflow = AgentWorkflowRun.get_example() - config = AutomatedWorkflowConfig(maxCandidateEvaluations=limit) - record = OrchestrationRequestRecord( - workflowRunId=workflow.workflowRunId, - config=config, - configSha256=journal._sha256_model(config), - requestSha256="a" * 64, - ) - return store, workflow, record - - -def _start( - store: Any, - workflow: AgentWorkflowRun, - record: OrchestrationRequestRecord, - stage: WorkflowStageName, - inputs: dict[str, Any], -) -> None: - journal._start_attempt( - store.zw, _PREFIX, workflow.workflowRunId, stage, record, [], inputs=inputs - ) - - -@pytest.mark.parametrize("limit", [1, 24]) -def test_budget_rejects_baseline_before_any_reservation_is_written(limit: int) -> None: - store, workflow, record = _context(limit) - with pytest.raises(ValueError, match=r"0 slots already reserved, 25 required"): - reserve_candidate_pass(store, _PREFIX, workflow, record, "preprocessing") - assert ( - journal._stage_starts( - store.zw, _PREFIX, workflow.workflowRunId, "preprocessing" - ) - == [] - ) - - -@pytest.mark.parametrize("limit,allow_revision", [(25, False), (49, False), (50, True)]) -def test_budget_admits_whole_passes_and_counts_interrupted_work( - limit: int, allow_revision: bool -) -> None: - store, workflow, record = _context(limit) - baseline = reserve_candidate_pass(store, _PREFIX, workflow, record, "preprocessing") - assert baseline["reserved"] == 25 - assert baseline["breakdown"]["hvg"] == 9 - # An interrupted start has no outcome but retains its reservation. - _start(store, workflow, record, "preprocessing", {"candidateBudget": baseline}) - # Starting the same logical pass again does not charge another 25 slots. - repeated = reserve_candidate_pass(store, _PREFIX, workflow, record, "preprocessing") - assert repeated == baseline - _start(store, workflow, record, "preprocessing", {"candidateBudget": repeated}) - if allow_revision: - revised = reserve_candidate_pass( - store, _PREFIX, workflow, record, "feature_policy_preprocessing" - ) - assert revised["logicalPass"] == "featureRevision" - _start( - store, - workflow, - record, - "feature_policy_preprocessing", - {"candidateBudget": revised}, - ) - assert ( - reserve_candidate_pass( - store, _PREFIX, workflow, record, "feature_policy_preprocessing" - ) - == revised - ) - else: - with pytest.raises(ValueError, match=r"25 slots already reserved, 25 required"): - reserve_candidate_pass( - store, _PREFIX, workflow, record, "feature_policy_preprocessing" - ) - - -def test_budget_does_not_charge_baseline_reuse_or_rejected_attempts() -> None: - store, workflow, record = _context(25) - baseline = reserve_candidate_pass(store, _PREFIX, workflow, record, "preprocessing") - _start(store, workflow, record, "preprocessing", {"candidateBudget": baseline}) - _start( - store, - workflow, - record, - "feature_policy_preprocessing", - {"baselineAttemptId": "reused-baseline"}, - ) - _start( - store, - workflow, - record, - "feature_policy_preprocessing", - {"candidateBudgetRejected": True}, - ) - assert ( - reserve_candidate_pass(store, _PREFIX, workflow, record, "preprocessing") - == baseline - ) - - -@pytest.mark.parametrize( - "mutation", ["count", "breakdown", "pass", "missing", "config"] -) -def test_budget_rejects_inconsistent_persisted_reservations(mutation: str) -> None: - store, workflow, record = _context() - reservation = reserve_candidate_pass( - store, _PREFIX, workflow, record, "preprocessing" - ) - _start(store, workflow, record, "preprocessing", {"candidateBudget": reservation}) - changed = {**reservation, "breakdown": dict(reservation["breakdown"])} - if mutation == "count": - changed["reserved"] = 1 - elif mutation == "breakdown": - changed["breakdown"]["hvg"] = 0 - elif mutation == "pass": - changed["logicalPass"] = "featureRevision" - elif mutation == "config": - record = record.model_copy(update={"configSha256": "b" * 64}) - inputs = {} if mutation == "missing" else {"candidateBudget": changed} - _start(store, workflow, record, "preprocessing", inputs) - with pytest.raises(ValueError, match=r"reservation.*differs|lacks its candidate"): - reserve_candidate_pass(store, _PREFIX, workflow, record, "preprocessing") - - -def test_budget_keeps_unused_reservations_and_requires_baseline_for_revision() -> None: - store, workflow, record = _context(25) - with pytest.raises(ValueError, match="requires a reserved baseline"): - reserve_candidate_pass( - store, _PREFIX, workflow, record, "feature_policy_preprocessing" - ) - - reservation = reserve_candidate_pass( - store, _PREFIX, workflow, record, "preprocessing" - ) - started = journal._start_attempt( - store.zw, - _PREFIX, - workflow.workflowRunId, - "preprocessing", - record, - [], - inputs={"candidateBudget": reservation}, - ) - journal._save_outcome( - store.zw, - _PREFIX, - journal._complete_attempt( - started, status="done", outputs={"candidateCount": 1} - ), - ) - with pytest.raises(ValueError, match="25 slots already reserved"): - reserve_candidate_pass( - store, _PREFIX, workflow, record, "feature_policy_preprocessing" - ) - - -@pytest.mark.parametrize( - "limit,stage", [(24, "preprocessing"), (25, "feature_policy_preprocessing")] -) -def test_preprocessing_budget_rejection_precedes_qc_and_candidate_execution( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - limit: int, - stage: WorkflowStageName, -) -> None: - store = DataStore(str(create_store(tmp_path / "budget.zarr")), default_assay="RNA") - workflow = create_agent_workflow(store) - orchestrator = AgentOrchestrator( - object(), config=AutomatedWorkflowConfig(maxCandidateEvaluations=limit) - ) - request = AutomatedWorkflowRequest( - sourcePath=str(store.zarr_loc), - zarrPath=str(store.zarr_loc), - studyContext="Budget admission regression.", - studyObjective="Compare RNA populations.", - primaryAssay="RNA", - markerAssay="RNA", - analysisAssays=["RNA"], - ) - record = orchestrator.initialize_request(store, workflow, request) - prefix = journal._ensure_orchestration_store(store) - if stage == "feature_policy_preprocessing": - reservation = reserve_candidate_pass( - store, prefix, workflow, record, "preprocessing" - ) - journal._start_attempt( - store.zw, - prefix, - workflow.workflowRunId, - "preprocessing", - record, - [], - inputs={"candidateBudget": reservation}, - ) - - def unexpected(*_args: Any, **_kwargs: Any) -> Any: - pytest.fail("Over-budget preprocessing must stop before numerical work") - - monkeypatch.setattr(orchestrator, "apply_cell_qc", unexpected) - monkeypatch.setattr(orchestrator, "preprocess_assay", unexpected) - outcome, handoffs, _ = orchestrator.preprocessing_stage( - store, - workflow, - record, - [], - AutomatedPreprocessingPlan.get_example(), - ExperimentalContextResult.get_example().model_copy( - update={"htoIdentityArtifacts": []} - ), - StudyContract.get_blank(), - {}, - stage_name=stage, - ) - assert outcome.status == "failed" - assert "Candidate budget exceeded" in (outcome.error or "") - assert handoffs == [] - assert outcome.inputs == {"candidateBudgetRejected": True} diff --git a/tests/test_import_architecture.py b/tests/test_import_architecture.py index f9c6b1ce..f17f3601 100644 --- a/tests/test_import_architecture.py +++ b/tests/test_import_architecture.py @@ -609,7 +609,6 @@ def test_agent_implementations_live_in_owner_packages(): "tools.py", "validation.py", }, - "hypotheses": {"__init__.py", "contracts.py", "execution.py"}, "parameter_tuning": { "__init__.py", "agent.py", @@ -619,19 +618,11 @@ def test_agent_implementations_live_in_owner_packages(): "hvg.py", "prompts.py", "selection.py", - "sequential.py", - }, - "persistence": { - "__init__.py", - "contracts.py", - "decisions.py", - "reports.py", }, "report": { "__init__.py", "artifacts.py", "contracts.py", - "decision_tree.py", "generator.py", "plots.py", "rendering.py", @@ -639,6 +630,10 @@ def test_agent_implementations_live_in_owner_packages(): } assert retired.isdisjoint(path.name for path in agent_root.glob("*.py")) + assert not list((agent_root / "persistence").glob("*.py")) + assert not list((agent_root / "hypotheses").glob("*.py")) + assert not (agent_root / "parameter_tuning/sequential.py").exists() + assert not (agent_root / "report/decision_tree.py").exists() for package, names in required.items(): package_root = agent_root / package assert package_root.is_dir() diff --git a/tests/test_registered_qc_profiles.py b/tests/test_registered_qc_profiles.py index f3e4708a..ceac64c0 100644 --- a/tests/test_registered_qc_profiles.py +++ b/tests/test_registered_qc_profiles.py @@ -10,7 +10,6 @@ from pydantic import ValidationError from zarr.storage import MemoryStore -import scarf.agent.experimental_context.validation as experimental_context_validation import scarf.agent.orchestrator.preprocessing as preprocessing_module from scarf.agent.cell_quality.execution import ( execute_auto_cell_qc, @@ -560,7 +559,7 @@ def test_pooled_reference_profile_requires_explicit_eligible_captures() -> None: assert {threshold.group for threshold in pooled.thresholds} == {"pooledReference"} -def test_experimental_context_offers_and_validates_registered_global_profile() -> None: +def test_experimental_context_offers_registered_global_profile() -> None: store = _Store() store.cells._values["RNA_nCounts"] = np.linspace(10, 100, 12) store.cells._values["RNA_nFeatures"] = np.linspace(5, 50, 12) @@ -588,15 +587,6 @@ def test_experimental_context_offers_and_validates_registered_global_profile() - "fixedCutoff": None, } - selected = experimental_context_validation._canonical_cell_qc_plan( - CellQcPlan(), - context.deps, - inspected.characterization, - ) - assert selected.registeredProfile == "globalMad5" - assert selected.profileId == offered["globalMad5"].profileId - assert selected.evidenceIds == [offered["globalMad5"].evidenceId] - with pytest.raises(ValidationError, match="must use the registeredMad action"): CellQcPlan( action="globalGaussian", @@ -1172,3 +1162,159 @@ def fake_execute( assert operations[0]["diagnosticFlags"] == ( ArtifactReferenceModel.from_artifact_ref(flags).model_dump(mode="json") ) + + +@pytest.mark.parametrize("sample_aware", [False, True]) +def test_audited_core_qc_policy_executes_exact_projected_selection( + monkeypatch: pytest.MonkeyPatch, + sample_aware: bool, +) -> None: + from types import SimpleNamespace + + from scarf.agent.decisions.rna import QcGroupingExecutorPayload + + values = _quality_values() + labels = np.asarray(["a"] * 21 + ["b"] * 21) + store, source = _memory_qc_store({**values, "capture": labels}) + action = "sampleMad" if sample_aware else "globalGaussian" + policy = "coreSampleMad3" if sample_aware else "coreGlobalGaussian" + projection = project_auto_filter_profile( + action, + values_by_metric=values, + active=np.ones(42, dtype=bool), + sample_labels=labels if sample_aware else None, + grouping_proven=sample_aware, + ) + parameters = ( + projection.parameters + if not sample_aware + else { + "nMads": 3.0, + "minCellsPerSample": 20, + "nSamples": 2, + "nSkippedSamples": 0, + } + ) + profile = CellQcProfileEvidence( + profileId=f"cellQc:RNA:{policy}", + action=action, + driverAssay="RNA", + driverAssayType="RNA", + sampleColumn="capture" if sample_aware else None, + captureColumn="capture" if sample_aware else None, + attributes=list(values), + parameters=parameters, + resolvedBounds=projection.parameters["resolvedBounds"], + activeCells=42, + retainedCells=projection.retainedCells, + retainedFraction=projection.retainedCells / 42, + activeCellsByCapture=projection.captureSizes, + sampleRetainedCells=projection.retainedByCapture, + flaggedCells=projection.flagCounts, + evidenceId=f"qcProfile:{policy}", + ) + from scarf.agent.experimental_context.contracts import ( + CovariateCharacterization, + ExperimentalContextDecision, + ) + + experimental = ExperimentalContextResult( + status="done", + decision=ExperimentalContextDecision(), + characterization=CovariateCharacterization(status="done"), + qcProfiles=[profile], + ) + orchestrator = AgentOrchestrator(object()) + + def resolve(_store, _request, definition, bundle, _answers): + option = definition.executor_option(f"cellQuality:{policy}") + assert option.payload.lowerCountMad is None + assert option.payload.upperMitoMad is None + if not sample_aware: + assert definition.spec.baselineOptionId == "cellQuality:coreGlobalGaussian" + assert ( + definition.spec.options[0].optionId == "cellQuality:coreGlobalGaussian" + ) + assert bundle.evidence[0].evidenceId == profile.evidenceId + return SimpleNamespace( + compiled=SimpleNamespace(executorPayload=option.payload), + record=SimpleNamespace( + rationale="Exact core QC preserves valid cells.", + evidenceIds=[profile.evidenceId], + ), + checkpointSha256="a" * 64, + ) + + monkeypatch.setattr(orchestrator, "_resolve_rna_decision", resolve) + payload, plan, _ = orchestrator._resolve_cell_quality_decision( + store, + None, + experimental, + QcGroupingExecutorPayload( + groupingMode="physicalCapture" if sample_aware else "global" + ), + {}, + ) + from scarf.agent.orchestrator.models import AutomatedPreprocessingPlan + + saved = AutomatedPreprocessingPlan(cellQc=plan, cellQualityPayload=payload) + restored = AutomatedPreprocessingPlan.model_validate_json(saved.model_dump_json()) + assert restored.cellQualityPayload == payload + assert restored.cellQc == plan + with pytest.raises(ValidationError, match="exact selected QC policy"): + AutomatedPreprocessingPlan( + cellQc=plan, + cellQualityPayload=payload.model_copy( + update={ + "profile": "coreGlobalGaussian" + if sample_aware + else "coreSampleMad3", + "groupByCapture": not sample_aware, + } + ), + ) + actions: list[str] = [] + operations: list[dict[str, Any]] = [] + selected = orchestrator.apply_cell_qc( + store, + experimental, + source, + actions, + operations, + selected_plan=plan, + decision_payload=payload, + ) + np.testing.assert_array_equal( + read_stored_selection_mask( + store.zw, + selected, + kind="cell_selection", + scope="datastore", + assay=None, + table_path="cellData", + ), + projection.keep, + ) + assert operations[0]["profileParameters"] == parameters + assert operations[0]["resolvedBounds"] == projection.parameters["resolvedBounds"] + assert operations[0]["diagnosticFlags"] is not None + np.testing.assert_array_equal(store.cells.fetch_all("I"), True) + + +def test_core_sample_qc_requires_exact_physical_capture_proof() -> None: + profile = CellQcProfileEvidence( + profileId="cellQc:sample", + action="sampleMad", + sampleColumn="sample", + attributes=["RNA_nCounts"], + activeCells=40, + retainedCells=39, + retainedFraction=39 / 40, + ) + assert not AgentOrchestrator._profile_is_safe(profile) + profile.captureColumn = "different" + assert not AgentOrchestrator._profile_is_safe(profile) + profile.captureColumn = "sample" + assert AgentOrchestrator._profile_is_safe(profile) + profile.unsafeRetentionGroups = ["combination:treatment,time=case,late"] + assert not AgentOrchestrator._profile_is_safe(profile) From d6400a5afcd6dccdde82d13e215f11fc4cc28eb7 Mon Sep 17 00:00:00 2001 From: gautam8387 Date: Tue, 8 Sep 2026 13:20:16 +0200 Subject: [PATCH 14/21] update evidence requirements; comparisons; report --- .../base.ipynb | 965 ++++++++++++++ .../base.ipynb | 809 ----------- docs/.jupyter_cache/global.db | Bin 36864 -> 36864 bytes docs/source/analysis_with_agents.md | 30 +- docs/source/reference/api/agent.md | 11 +- docs/source/tutorials/agent_workflow.md | 263 +++- scarf/agent/experimental_context/agent.py | 16 + .../agent/experimental_context/comparisons.py | 135 ++ scarf/agent/experimental_context/contracts.py | 29 + .../agent/experimental_context/qc_evidence.py | 134 +- .../experimental_context/requirements.py | 223 ++++ scarf/agent/experimental_context/study.py | 110 +- scarf/agent/experimental_context/tools.py | 25 + .../agent/experimental_context/validation.py | 21 + scarf/agent/orchestrator/budget.py | 13 + scarf/agent/orchestrator/context.py | 190 ++- scarf/agent/orchestrator/decisions.py | 6 +- scarf/agent/orchestrator/journal.py | 14 +- scarf/agent/orchestrator/main.py | 4 +- scarf/agent/orchestrator/models.py | 4 +- scarf/agent/orchestrator/rna.py | 64 + scarf/agent/orchestrator/rna_tuning.py | 1180 ++++++++++++++--- scarf/agent/parameter_tuning/comparisons.py | 501 +++++++ scarf/agent/parameter_tuning/diagnostics.py | 52 +- scarf/agent/report/artifacts.py | 80 +- scarf/agent/report/contracts.py | 15 + scarf/agent/report/rendering.py | 577 +++++--- tests/agent_comparison_examples.py | 341 +++++ tests/agent_examples.py | 20 + tests/test_agent_beginner.py | 4 +- tests/test_agent_design_comparisons.py | 38 + tests/test_agent_objective_requirements.py | 226 ++++ tests/test_agent_orchestrator.py | 60 +- tests/test_agent_orchestrator_stages.py | 74 +- tests/test_agent_qc_percentage_identity.py | 219 +++ tests/test_agent_report.py | 435 +++--- tests/test_agent_required_comparisons.py | 496 +++++++ tests/test_agent_rna_adaptive.py | 110 +- tests/test_agent_rna_assessment_integrity.py | 14 +- tests/test_agent_rna_evidence_mode.py | 178 ++- tests/test_agent_rna_rare_population.py | 123 +- tests/test_agent_teaching_model.py | 103 ++ tests/test_agent_tuning_reuse.py | 17 +- 43 files changed, 6224 insertions(+), 1705 deletions(-) create mode 100644 docs/.jupyter_cache/executed/515e8b0f164c54e6ce03b3b9a653116e/base.ipynb delete mode 100644 docs/.jupyter_cache/executed/6f4bdeb21a5ca0ba2f47633bf5853ebc/base.ipynb create mode 100644 scarf/agent/experimental_context/requirements.py create mode 100644 scarf/agent/parameter_tuning/comparisons.py create mode 100644 tests/agent_comparison_examples.py create mode 100644 tests/test_agent_objective_requirements.py create mode 100644 tests/test_agent_qc_percentage_identity.py create mode 100644 tests/test_agent_required_comparisons.py create mode 100644 tests/test_agent_teaching_model.py diff --git a/docs/.jupyter_cache/executed/515e8b0f164c54e6ce03b3b9a653116e/base.ipynb b/docs/.jupyter_cache/executed/515e8b0f164c54e6ce03b3b9a653116e/base.ipynb new file mode 100644 index 00000000..a51f98d9 --- /dev/null +++ b/docs/.jupyter_cache/executed/515e8b0f164c54e6ce03b3b9a653116e/base.ipynb @@ -0,0 +1,965 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "c47d4164", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
    Downloading bucket files18098007 / 18098007 complete18098007 / 18098007 complete
    " + ], + "text/plain": [ + "Downloading bucket files: 18098007 / 18098007 complete" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
    Downloading bytes18098007 / 18098007 complete18098007 / 18098007 complete
    " + ], + "text/plain": [ + "Downloading bytes: 18098007 / 18098007 complete" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "'data.h5'" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from pathlib import Path\n", + "from tempfile import TemporaryDirectory\n", + "\n", + "import pandas as pd\n", + "import scarf\n", + "from scarf.agent import analyze_rna\n", + "\n", + "scarf.configure_output(level=\"WARNING\", progress=False)\n", + "source_path = scarf.cytebase.connect(\"scarf_docs\").download(\n", + " \"tenx_5K_pbmc_rnaseq/data.h5\", destination=\"scarf_datasets\",\n", + ")[0]\n", + "teaching_directory = TemporaryDirectory(prefix=\"scarf-agent-teaching-\")\n", + "zarr_path = Path(teaching_directory.name) / \"analysis.zarr\"\n", + "study_context = (\n", + " \"Human 10x Genomics 5K PBMC 3-prime gene expression from peripheral blood, \"\n", + " \"collected from one healthy donor. The teaching cohort is a deterministic random \"\n", + " \"subset of 1,000 cells (seed 42), not the full public dataset. \"\n", + " \"No treatment comparison, trusted technical \"\n", + " \"batch column, paired modality, or independent replication metadata is available. \"\n", + " \"Do not invent missing design variables or report treatment effects.\"\n", + ")\n", + "source_path.name" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "bb9ed493", + "metadata": { + "tags": [ + "remove-cell" + ] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "WARNING: Minimum cell count (502) is lower than size factor multiplier (1000)\n" + ] + } + ], + "source": [ + "import json\n", + "from typing import Any\n", + "\n", + "import numpy as np\n", + "from IPython import get_ipython\n", + "from pydantic_ai.messages import (\n", + " ModelMessage,\n", + " ModelResponse,\n", + " ToolCallPart,\n", + " ToolReturnPart,\n", + ")\n", + "from pydantic_ai.models.function import AgentInfo, FunctionModel\n", + "\n", + "from scarf.agent.data_enrichment import (\n", + " AssayFeatureInspectionBatch,\n", + " DataEnrichmentReport,\n", + " FeatureSelectionPolicy,\n", + " StudyContextSummary,\n", + ")\n", + "from scarf.agent.experimental_context import (\n", + " BatchCorrectionPlan,\n", + " CovariateEvidence,\n", + " ExperimentalContextDecision,\n", + ")\n", + "from scarf.agent.ingest import ingest\n", + "\n", + "prepared_input = ingest(path=source_path, zarrPath=zarr_path)\n", + "if prepared_input.status != \"done\":\n", + " raise RuntimeError(f\"Teaching dataset import failed: {prepared_input.notes}\")\n", + "teaching_store = scarf.DataStore(\n", + " str(zarr_path), min_features_per_cell=-1, mito_pattern=\"\", ribo_pattern=\"\",\n", + ")\n", + "teaching_store.cells.reset_key(\"I\")\n", + "teaching_cells = np.zeros(teaching_store.cells.N, dtype=bool)\n", + "teaching_cells[np.random.default_rng(42).choice(teaching_store.cells.N, 1000, replace=False)] = True\n", + "teaching_store.cells.update_key(teaching_cells, \"I\")\n", + "source_path = zarr_path\n", + "del teaching_store\n", + "\n", + "notebook_shell = get_ipython()\n", + "if notebook_shell is not None:\n", + " notebook_shell.run_line_magic(\"matplotlib\", \"inline\")\n", + "\n", + "def _prompt_text(messages: list[ModelMessage]) -> str:\n", + " values = []\n", + " for message in messages:\n", + " for part in message.parts:\n", + " content = getattr(part, \"content\", None)\n", + " if isinstance(content, str):\n", + " values.append(content)\n", + " elif isinstance(content, tuple):\n", + " values.extend(item for item in content if isinstance(item, str))\n", + " return \"\\n\".join(values)\n", + "\n", + "\n", + "def _tool_result(\n", + " messages: list[ModelMessage],\n", + " tool_name: str,\n", + " model_type: Any,\n", + ") -> Any:\n", + " for message in reversed(messages):\n", + " for part in reversed(message.parts):\n", + " if isinstance(part, ToolReturnPart) and part.tool_name == tool_name:\n", + " if isinstance(part.content, model_type):\n", + " return part.content\n", + " if isinstance(part.content, str):\n", + " return model_type.model_validate_json(part.content)\n", + " return model_type.model_validate(part.content)\n", + " raise AssertionError(f\"Missing tool return {tool_name!r}\")\n", + "\n", + "\n", + "def _tool_call(name: str, args: dict[str, Any] | None = None) -> ModelResponse:\n", + " return ModelResponse(parts=[ToolCallPart(tool_name=name, args=args or {})])\n", + "\n", + "\n", + "def _structured_output(info: AgentInfo, value: Any) -> ModelResponse:\n", + " payload = value.model_dump() if hasattr(value, \"model_dump\") else value\n", + " return _tool_call(info.output_tools[0].name, payload)\n", + "\n", + "\n", + "def _scripted_workflow_model() -> tuple[FunctionModel, dict[str, Any]]:\n", + " state = {\n", + " \"enrichment\": 0,\n", + " \"context\": 0,\n", + " \"parameter\": 0,\n", + " \"assessments\": [],\n", + " \"requests\": 0,\n", + " }\n", + "\n", + " async def reply(\n", + " messages: list[ModelMessage],\n", + " info: AgentInfo,\n", + " ) -> ModelResponse:\n", + " state[\"requests\"] += 1\n", + " tools = {tool.name for tool in info.function_tools}\n", + "\n", + " if (\n", + " \"inspect_assay_features_batch\" in tools\n", + " or state[\"enrichment\"] == 1\n", + " or any(\n", + " tool.parameters_json_schema.get(\"title\") == \"DataEnrichmentReport\"\n", + " for tool in info.output_tools\n", + " )\n", + " ):\n", + " if state[\"enrichment\"] == 0:\n", + " state[\"enrichment\"] = 1\n", + " return _tool_call(\"inspect_assay_features_batch\")\n", + "\n", + " batch = _tool_result(\n", + " messages,\n", + " \"inspect_assay_features_batch\",\n", + " AssayFeatureInspectionBatch,\n", + " )\n", + " policies = []\n", + " for inspection in batch.inspections:\n", + " species_observed = inspection.species != \"unknown\"\n", + " policy_evidence = list(inspection.evidenceIds)\n", + " if not species_observed:\n", + " policy_evidence.append(\"context:study\")\n", + " policies.append(\n", + " FeatureSelectionPolicy(\n", + " assay=inspection.assay,\n", + " species=(\n", + " inspection.species\n", + " if species_observed\n", + " else \"homo_sapiens\"\n", + " ),\n", + " speciesConfidence=\"high\" if species_observed else \"medium\",\n", + " speciesRationale=(\n", + " inspection.speciesReason\n", + " or \"The exact study paragraph identifies a human sample.\"\n", + " ),\n", + " excludeFamilies=[\n", + " family.family\n", + " for family in inspection.families\n", + " if family.count > 0 and family.defaultExclude is True\n", + " ],\n", + " protectFamilies=[\n", + " family.family\n", + " for family in inspection.families\n", + " if family.count > 0 and family.defaultExclude is False\n", + " ],\n", + " rationale=(\n", + " \"Exclude observed technical families and preserve \"\n", + " \"observed protected families.\"\n", + " ),\n", + " evidenceIds=list(dict.fromkeys(policy_evidence)),\n", + " )\n", + " )\n", + " state[\"enrichment\"] = 2\n", + " return _structured_output(\n", + " info,\n", + " DataEnrichmentReport(\n", + " status=\"done\",\n", + " studyContextSummary=StudyContextSummary(\n", + " organismReferences=[\"Human\"],\n", + " tissueReferences=[\"peripheral blood\"],\n", + " experimentalReferences=[\n", + " \"10x Genomics 5K PBMC 3-prime gene expression\"\n", + " ],\n", + " analysisIntentReferences=[\n", + " \"Discover stable major immune-cell populations.\"\n", + " ],\n", + " ),\n", + " policies=policies,\n", + " ),\n", + " )\n", + "\n", + " if tools.intersection(\n", + " {\n", + " \"inspect_cell_covariates\",\n", + " \"analyze_experimental_design\",\n", + " \"score_current_representation\",\n", + " }\n", + " ) or state[\"context\"] in {1, 2} or any(\n", + " tool.parameters_json_schema.get(\"title\") == \"ExperimentalContextDecision\"\n", + " for tool in info.output_tools\n", + " ):\n", + " if state[\"context\"] == 0:\n", + " state[\"context\"] = 1\n", + " return _tool_call(\"inspect_cell_covariates\")\n", + " if state[\"context\"] == 1:\n", + " state[\"context\"] = 2\n", + " return _tool_call(\n", + " \"analyze_experimental_design\",\n", + " {\n", + " \"column_domains\": {},\n", + " \"coefficients_of_interest\": [],\n", + " \"units_of_inference\": {},\n", + " \"batch_columns\": [],\n", + " },\n", + " )\n", + "\n", + " design = _tool_result(\n", + " messages,\n", + " \"analyze_experimental_design\",\n", + " CovariateEvidence,\n", + " )\n", + " profile = next(\n", + " value\n", + " for value in design.qcProfiles\n", + " if value.action == \"skip\"\n", + " )\n", + " evidence_id = profile.evidenceId\n", + " state[\"context\"] = 3\n", + " return _structured_output(\n", + " info,\n", + " ExperimentalContextDecision(\n", + " batchCorrection=BatchCorrectionPlan(\n", + " action=\"skip\",\n", + " rationale=\"No trusted technical batch column was supplied.\",\n", + " evidenceIds=[evidence_id],\n", + " ),\n", + " rationale=\"No experimental covariates were supplied.\",\n", + " evidenceIds=[evidence_id],\n", + " ),\n", + " )\n", + "\n", + " prompt = _prompt_text(messages)\n", + " if any(\n", + " {\"selectedCandidateId\", \"comparisonConclusions\"}.issubset(\n", + " tool.parameters_json_schema.get(\"properties\", {})\n", + " )\n", + " for tool in info.output_tools\n", + " ):\n", + " evidence, _ = json.JSONDecoder().raw_decode(prompt[prompt.index(\"{\") :])\n", + " coverage = evidence[\"comparisonCoverage\"]\n", + " settings = coverage[\"candidateSettings\"]\n", + " candidates = {\n", + " item[\"candidateId\"]: item for item in evidence[\"candidates\"]\n", + " if item[\"status\"] == \"done\" and item[\"eligible\"]\n", + " }\n", + " if not candidates:\n", + " raise AssertionError(\"The teaching run has no supported partition\")\n", + "\n", + " def rank(identity):\n", + " metrics = settings[identity][\"metrics\"]\n", + " return tuple(\n", + " float(metrics[name]) if metrics.get(name) is not None else -1.0\n", + " for name in (\"seedStability\", \"markerCoherence\")\n", + " )\n", + "\n", + " axis_names = {\n", + " \"hvgCount\": \"variable-gene count\", \"hvgRanking\": \"gene ranking\",\n", + " \"featurePolicy\": \"gene-family policy\", \"pca\": \"PCA dimensions\",\n", + " \"neighbors\": \"neighbor count\", \"partition\": \"clustering resolution\",\n", + " }\n", + " metric_names = {\n", + " \"seedStability\": \"agreement across clustering runs\",\n", + " \"subsampleStability\": \"agreement after resampling\",\n", + " \"markerCoherence\": \"the fraction of clusters with qualifying markers\",\n", + " \"markerSpecificityMedian\": \"median marker specificity\",\n", + " \"macroF1\": \"classification agreement\",\n", + " }\n", + " axis_ids = {}\n", + " for row in coverage[\"comparisons\"]:\n", + " identities = axis_ids.setdefault(row[\"axis\"], [])\n", + " for identity in (row[\"baselineCandidateId\"], row[\"alternativeCandidateId\"]):\n", + " if identity is not None and identity not in identities:\n", + " identities.append(identity)\n", + " comparison_ids = {axis: list(identities) for axis, identities in axis_ids.items()}\n", + " if coverage[\"phase\"] != \"sensitivity\":\n", + " axis_ids[\"partition\"] = list(dict.fromkeys(\n", + " [*axis_ids[\"partition\"], *coverage[\"resolutionCandidateIds\"]]\n", + " ))\n", + " preferences = {axis: max(identities, key=rank) for axis, identities in axis_ids.items()}\n", + " pending_policy = any(row[\"status\"] == \"pending\" for row in coverage[\"comparisons\"])\n", + " experiment_id = None\n", + " if coverage[\"phase\"] == \"sensitivity\":\n", + " selected_id = evidence[\"currentCandidateId\"]\n", + " action_name = \"combine\"\n", + " else:\n", + " eligible_resolutions = [\n", + " identity for identity in coverage[\"resolutionCandidateIds\"]\n", + " if identity in candidates\n", + " ]\n", + " if not eligible_resolutions:\n", + " raise AssertionError(\"The combined representation has no supported partition\")\n", + " selected_id = max(eligible_resolutions, key=rank)\n", + " preferences[\"partition\"] = selected_id\n", + " action_name = \"accept\"\n", + " if pending_policy:\n", + " families = evidence[\"featureEvidence\"][selected_id][\"families\"]\n", + " supported = [\n", + " (key, option) for key, option in evidence[\"experiments\"].items()\n", + " if option[\"parameter\"] in {\"includeFamily\", \"excludeFamily\"}\n", + " and families.get(option[\"value\"], {}).get(\n", + " \"selectedExamples\" if option[\"parameter\"] == \"excludeFamily\"\n", + " else \"excludedExamples\"\n", + " )\n", + " ]\n", + " if not supported:\n", + " raise AssertionError(\"The teaching policy has no observed family program to nominate\")\n", + " experiment_id, option = max(supported, key=lambda item: (\n", + " families[item[1][\"value\"]].get(\"selectedGenes\", 0),\n", + " item[1][\"affectedEligibleGenes\"],\n", + " ))\n", + " action_name = \"experiment\"\n", + " selected = candidates[selected_id]\n", + " metrics = selected[\"metrics\"]\n", + " genes = list(dict.fromkeys(\n", + " gene for names in metrics.get(\"topMarkerGenes\", {}).values()\n", + " for gene in names\n", + " ))[:8]\n", + " qualitative = (\n", + " \"The saved marker preview contains \" + \", \".join(genes) + \".\"\n", + " if genes else \"The saved marker preview is empty; cell identities remain unresolved.\"\n", + " )\n", + " conclusions = []\n", + " for axis, identities in axis_ids.items():\n", + " preferred = preferences[axis]\n", + " score = settings[preferred][\"metrics\"]\n", + " conclusions.append({\n", + " \"axis\": axis,\n", + " \"candidateIds\": identities,\n", + " \"preferredCandidateId\": preferred,\n", + " \"quantitativeReason\": \"; \".join(\n", + " f\"Observed alternative {index + 1}: repeat agreement \"\n", + " f\"{settings[identity]['metrics'].get('seedStability')}, \"\n", + " f\"marker coverage {settings[identity]['metrics'].get('markerCoherence')}\"\n", + " for index, identity in enumerate(identities)\n", + " ),\n", + " \"biologicalReason\": (\n", + " \"This scripted teaching policy does not establish cell identities or \"\n", + " \"infer that a gene program is a technical artifact. \" + qualitative\n", + " ),\n", + " \"plainLanguageSummary\": (\n", + " f\"The teaching policy compared {len(identities)} observed {axis_names[axis]} \"\n", + " f\"settings and preferred repeat agreement {score.get('seedStability')}, \"\n", + " f\"using marker coverage {score.get('markerCoherence')} to break ties.\"\n", + " ),\n", + " \"tradeoffs\": [{\n", + " \"alternativeCandidateId\": identity,\n", + " \"metric\": metric,\n", + " \"preferredValue\": score[metric],\n", + " \"alternativeValue\": settings[identity][\"metrics\"][metric],\n", + " \"interpretation\": (\n", + " f\"An alternative has higher {metric_names[metric]} \"\n", + " f\"({settings[identity]['metrics'][metric]} versus {score[metric]}). \"\n", + " \"The teaching policy prioritizes repeat agreement, then marker coverage; \"\n", + " \"this loss remains an explicit limit of its choice.\"\n", + " ),\n", + " } for identity in comparison_ids[axis] if identity != preferred\n", + " for metric in (\"seedStability\", \"subsampleStability\", \"markerCoherence\",\n", + " \"markerSpecificityMedian\", \"macroF1\")\n", + " if isinstance(score.get(metric), (int, float))\n", + " and isinstance(settings[identity][\"metrics\"].get(metric), (int, float))\n", + " and settings[identity][\"metrics\"][metric] > score[metric]],\n", + " })\n", + " quantitative = (\n", + " f\"Observed resolution {selected['parameters']['leidenResolution']} has \"\n", + " f\"repeat agreement {metrics.get('seedStability')} and marker coverage \"\n", + " f\"{metrics.get('markerCoherence')}.\"\n", + " )\n", + " summary = (\n", + " \"The teaching policy proposes testing a represented gene family; \"\n", + " \"its contribution must be measured before retaining or changing the gene selection.\"\n", + " if action_name == \"experiment\" else\n", + " \"The teaching policy proposes a combination of the observed settings; \"\n", + " \"Scarf must execute that combination and compare its four resolutions.\"\n", + " if action_name == \"combine\" else\n", + " \"The teaching policy selected the measured combined representation and \"\n", + " \"its most repeatable eligible partition. Marker identities remain unvalidated.\"\n", + " )\n", + " action = {\n", + " \"action\": action_name,\n", + " \"selectedCandidateId\": selected_id,\n", + " \"experimentId\": experiment_id,\n", + " \"correctionNeed\": \"notApplicable\",\n", + " \"comparisonConclusions\": conclusions,\n", + " \"plainLanguageSummary\": summary,\n", + " \"evidenceIds\": [\n", + " f\"candidate:{selected_id}\",\n", + " *list(evidence[\"imageHashes\"])[:1],\n", + " \"studyContract\", \"qcPolicy\", \"samplingCoverage\", \"featureEvidence\",\n", + " ],\n", + " \"quantitativeFindings\": [quantitative],\n", + " \"qualitativeFindings\": [qualitative],\n", + " \"objectivePreservation\": (\n", + " \"Preserve the single-donor population structure and retain marker \"\n", + " \"uncertainty; no batch or treatment comparison is supported.\"\n", + " ),\n", + " \"rationale\": summary + \" \" + quantitative,\n", + " \"concern\": (\n", + " f\"Observed family {option['value']} contains \"\n", + " f\"{families[option['value']].get('selectedGenes', 0)} selected genes. \"\n", + " \"Test sensitivity to this program without assuming that it is technical.\"\n", + " ) if pending_policy else \"\",\n", + " \"expectedImprovement\": (\n", + " \"Measure whether changing this gene-family selection preserves the major \"\n", + " \"marker programs and improves repeat agreement.\"\n", + " ) if pending_policy else \"\",\n", + " \"populationConcerns\": [{\n", + " \"candidateId\": selected_id,\n", + " \"clusterId\": cluster,\n", + " \"status\": \"nonEssentialLimitation\",\n", + " \"evidenceIds\": [f\"candidate:{selected_id}\"],\n", + " \"explanation\": (\n", + " f\"Population {cluster} has no qualifying marker genes and remains unclassified. \"\n", + " \"This tutorial demonstrates selecting analysis settings; it does not validate \"\n", + " \"cell identities or claim that every population has been biologically resolved.\"\n", + " ),\n", + " } for cluster, names in metrics.get(\"topMarkerGenes\", {}).items() if not names],\n", + " }\n", + " if action_name == \"combine\":\n", + " action[\"combinedSettings\"] = {\n", + " field: preferences[axis] for field, axis in (\n", + " (\"hvgCountCandidateId\", \"hvgCount\"),\n", + " (\"hvgRankingCandidateId\", \"hvgRanking\"),\n", + " (\"featurePolicyCandidateId\", \"featurePolicy\"),\n", + " (\"pcaCandidateId\", \"pca\"),\n", + " (\"neighborsCandidateId\", \"neighbors\"),\n", + " )\n", + " }\n", + " state[\"assessments\"].append({\n", + " \"selection\": action,\n", + " \"alternatives\": [{\n", + " \"resolution\": settings[identity][\"parameters\"][\"leidenResolution\"],\n", + " \"clusters\": settings[identity][\"metrics\"].get(\"nClusters\"),\n", + " \"repeat_agreement\": settings[identity][\"metrics\"].get(\"seedStability\"),\n", + " \"clusters_with_markers\": settings[identity][\"metrics\"].get(\"markerCoherence\"),\n", + " \"selected\": identity == selected_id,\n", + " } for identity in (\n", + " coverage[\"resolutionCandidateIds\"] if action_name == \"accept\"\n", + " else list(candidates)\n", + " )],\n", + " })\n", + " return _structured_output(info, action)\n", + "\n", + "\n", + " payload, _ = json.JSONDecoder().raw_decode(prompt[prompt.index(\"{\") :])\n", + " decision = payload[\"spec\"]\n", + " evidence_by_class = {}\n", + " evidence_class_by_id = {}\n", + " for item in payload[\"evidence\"][\"evidence\"]:\n", + " evidence_by_class.setdefault(\n", + " item[\"evidenceClass\"],\n", + " item[\"evidenceId\"],\n", + " )\n", + " evidence_class_by_id[item[\"evidenceId\"]] = item[\"evidenceClass\"]\n", + " preferred = decision.get(\"metricPreferredOptionId\")\n", + " selected = (\n", + " next(\n", + " option\n", + " for option in decision[\"options\"]\n", + " if option[\"optionId\"] == preferred\n", + " )\n", + " if preferred is not None\n", + " else next(\n", + " option\n", + " for option in decision[\"options\"]\n", + " if option[\"status\"] in {\"apply\", \"skip\"}\n", + " )\n", + " )\n", + " evidence_ids = list(selected.get(\"requiredEvidenceIds\", []))\n", + " cited_classes = {\n", + " evidence_class_by_id[evidence_id] for evidence_id in evidence_ids\n", + " }\n", + " for evidence_class in selected[\"requiredEvidenceClasses\"]:\n", + " if evidence_class not in cited_classes:\n", + " evidence_ids.append(evidence_by_class[evidence_class])\n", + " state[\"parameter\"] += 1\n", + " return _structured_output(\n", + " info,\n", + " dict(\n", + " selectedOptionId=selected[\"optionId\"],\n", + " evidenceIds=evidence_ids,\n", + " rationale=f\"Use the offered {selected['label']} policy with its required observed evidence.\",\n", + " confidence=\"high\",\n", + " ),\n", + " )\n", + "\n", + " return FunctionModel(reply), state" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "59702e5a", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "WARNING: WARNING: Number of valid features is less than value of parameter `top_n`: 33538. Resetting `top_n` to 10744\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "WARNING: WARNING: Number of valid features is less than value of parameter `top_n`: 33538. Resetting `top_n` to 10958\n" + ] + }, + { + "data": { + "text/plain": [ + "{'status': 'completed'}" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "model, model_state = _scripted_workflow_model()\n", + "result = analyze_rna(\n", + " source_path,\n", + " model=model,\n", + " study_context=study_context,\n", + " study_objective=\"Discover stable major immune-cell populations.\",\n", + ")\n", + "{\"status\": result.status}" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "d544d4c3", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    resolutionclustersrepeat_agreementclusters_with_markersselected
    00.5061.0000001.0True
    10.7560.9900961.0False
    21.0061.0000001.0False
    31.2571.0000001.0False
    \n", + "
    " + ], + "text/plain": [ + " resolution clusters repeat_agreement clusters_with_markers selected\n", + "0 0.50 6 1.000000 1.0 True\n", + "1 0.75 6 0.990096 1.0 False\n", + "2 1.00 6 1.000000 1.0 False\n", + "3 1.25 7 1.000000 1.0 False" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "assessment = model_state[\"assessments\"][-1]\n", + "pd.DataFrame(assessment[\"alternatives\"])" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "0e0d5acb", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'why': 'The teaching policy selected the measured combined representation and its most repeatable eligible partition. Marker identities remain unvalidated. Observed resolution 0.5 has repeat agreement 1.0 and marker coverage 1.0.',\n", + " 'marker_evidence': ['The saved marker preview contains GZMK, NSG1, AQP3, TNFRSF4, CD40LG, DPP4, HNRNPLL, LYAR.'],\n", + " 'biology_to_preserve': 'Preserve the single-donor population structure and retain marker uncertainty; no batch or treatment comparison is supported.'}" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "selection = assessment[\"selection\"]\n", + "{\n", + " \"why\": selection[\"rationale\"],\n", + " \"marker_evidence\": selection[\"qualitativeFindings\"],\n", + " \"biology_to_preserve\": selection[\"objectivePreservation\"],\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "9f55e04b", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA48AAAJjCAYAAACsmCRCAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjEsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvctoD+AAAAAlwSFlzAAAPYQAAD2EBqD+naQAA7iFJREFUeJzs3Xd4VFX6B/DvtExmUia990AgBAg19F6liV3sil3XXnd1Leu66/pb17J2FBvqrmsXQVCqUqSXENJI73UmySRT7++PKwMhk0xNAb+f58kDuXPuvefeIWTee855X4kgCAKIiIiIiIiIeiDt7w4QERERERHRwMfgkYiIiIiIiBxi8EhEREREREQOMXgkIiIiIiIihxg8EhERERERkUMMHomIiIiIiMghBo9ERERERETkEINHIiIiIiIicojBIxF1UVRUhAceeACXXnop1qxZ09/d8brt27fjpptu6vZ7b9m4cSP+8Ic/eP24A0Fv3TM6e/XVz1V/n5OI6PdM3t8dIPo9qqysxKpVq1BQUICIiAjceOONGDp0aKc2+/fvx+eff47y8nKkpqbixhtvRExMTKdj3HXXXV2O/cQTT2DEiBFu900QBMybNw9TpkzBJZdcgvT09LPuGhwpKSnBt99+2+333lJYWIh169Z5/bgDgbfv2ZYtW/DJJ5/gzTff9NoxB6pz9Vr76ueqv89JRPR7xpFHoj527NgxDB06FNnZ2ZgzZw4EQUBWVhZ2795ta/Pcc8/hlltugVqtxuzZs3H48GEMGTIEhw8ftrXR6XT4/PPPMX/+fFx++eW2r8jISI/6V11djcLCQjz22GO45JJLMHz48LPuGujsU1xcjLVr1/Z3N/rE7+laiYjo3MKRR6I+9re//Q1Dhw7Ff/7zH9s2o9GI++67D7/88gsA4KqrrsLDDz9se/3aa6/FxIkT8Y9//AMfffRRp+MtWrQIcXFxTp+/uroab7zxBvLz8xEZGYnrr7/eNsr3yy+/4JlnngEA3H333VCr1fi///s/JCUlDahraG1txerVq7Fnzx5ERERg5cqVnUZIW1tb8c4772Dv3r0ICQnB0qVLMXfuXKePb7FY8Omnn2Lr1q2QyWRYsGABli9f7nbbnJwcfPTRR6ioqMCECRNw8803QyaT2V7v6T2xWCy48sor8dhjj9kC+T/96U9obGzE66+/DgCoqqrCH/7wB7z99tvYu3cvvvnmG9x+++09ntMZju7z6T7//HPs27cPzz77rG3brl278Prrr+P999/v8V7t27cPr776KhobG3HxxRcDAFasWIGLLrrI4Xu5ceNGfPPNN7j11lvx1ltvoaqqCu+88w4CAgJcvp6e3gdnr/Fkf7q7/91d6/Lly53+N+fJvT79XrjyM3LkyBE89dRTAACVSoW0tDTcdtttCAsL63YfZwykn2UiInKMI49Efay8vBxDhgzptG3IkCHYsWMHGhsbAQCxsbFd9ouNjUVTU1OX7X/6059w7bXX4tlnn0VNTU2P566rq8Po0aOxc+dOzJ49Gy0tLRg/frwt4EtMTMTixYsBAEuXLsXll1+O4ODgAXUNjY2NGD9+PN577z1kZWUhJiYGV111FcrLywEAzc3NmDBhAjZt2oTZs2cjISEBV111FV588cUej3u6xx9/HI899hhGjhyJsWPH4qOPPsILL7zgVtuqqipccskliIqKwvjx4/H00093CqodvScymQwnTpywjVSZTCa8+OKLWLVqFaqrqwEAP/30E3bt2oXg4GAUFhbi3Xff7fGcJpMJF198MTZu3Oj2fT5TTk4ONm3a1GlbeXk5vv76a4f3KjY2FhMmTIBKpbKNPmdkZDj1XhYWFuKdd97BhRdeiJSUFFx66aVQKpUuX4+j98HZa3R0/7u7Vlf+zXlyrwH3fkYiIyNt/Z07dy4OHz6MESNG2P15dtZA+1kmIiInCETUpx588EEhJiZGqK+vFwRBEEwmkzBnzhwBgLBnzx67+xQUFAgqlUp46aWXbNtycnKEjIwM4R//+Ifw1ltvCXPnzhU0Go1w8ODBbs99zz33CBkZGYLFYrFtu+qqq4SJEyfavs/PzxcACEVFRQPyGu69914hOTlZ6OjosG3T6/VCa2urrW9z5szptM/69esFlUolGAwGQRAE4cMPPxQiIyNtr5/5fWZmpvDaa691OkZdXZ3d/vTU9vXXXxckEolQUFBge+2tt94SoqKibN878548+OCDwoIFCwRBEITt27cLiYmJwrRp04RPPvlEEARBWLlypXDllVc6fc729nYBgPD666/bvSZBcHyfz7xnf/nLX4QJEyZ0OsZnn30maDQap+7V6tWrhdjY2E6vOfNevv766wIA4dChQ91eizPX48z74Mw1OnP/7V2rK//mPL3XztxX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ERERETnE4JGIBgxTbS0aP/wIHbm5/d0VIiIiIjoDS3UQ0YDRtmMH2g8ehKW5Gb5DhvR3d4iIiIjoNAweiWjA8Js0CVadDurx4/u7K0RERER0BtZ5JCIiIiIiIoe45pGIiIiIiIgcYvBIREREREREDjF4JCIiIiIiIocYPBIREREREZFDDB6JiIiIiIjIIQaPRERERERE5BCDRyIiIiIiInKIwSMRERERERE5xOCRiIiIiIiIHGLwSERERERERA4xeCQiIiIiIiKHGDwSERERERGRQwweiYiIiIiIyCEGj0REREREROQQg0ciIiIiIiJyiMEjEREREREROcTgkYiIiIiIiBxi8EhEREREREQOMXgkIiIiIiIihxg8EhERERERkUMMHomIiIiIiMghBo9ERERERETkEINHIiIiIiIicojBIxERERERETnE4JGIiIiIiIgcYvBIREREREREDjF4JCIiIiIiIocYPBIREREREZFDDB6JiIiIiIjIIQaPRERERERE5BCDRyIiIiIiInKIwSMRERERERE5xOCRiIiIiIiIHGLwSERERERERA4xeCQiIiIiIiKHGDwSERERERGRQwweiYiIiIiIyCEGj0REREREROQQg0ciIiIiIiJyiMEjEREREREROcTgkYiIiIiIiBxi8EhEREREREQOMXgkIiIiIiIihxg8EhERERERkUMMHomIiIiIiMghBo9ERERERETkEINHIiIiIiIicojBIxERERERETnE4JGIiIiIiIgcYvBIREREREREDjF4JCIiIiIiIocYPBIREREREZFDDB6JiIiIiIjIIQaPRERERERE5BCDRyIiIiIiInKIwSMRERERERE5xOCRiIiIiIiIHGLwSERERERERA4xeCQiIiIiIiKHGDwSERERERGRQwweiYiIiIiIyCEGj0REREREROQQg0ciIiIiIiJyiMEjEREREREROcTgkYiIiIiIiBxi8EhEREREREQOMXgkIiIiIiIihxg8EhERERERkUMMHomIiIiIiMghBo9ERERERETkEINHIiIiIiIicojBIxERERERETnE4JGIiIiIiIgcYvBIREREREREDjF4JCIiIiIiIocYPBIREREREZFDDB6JiIiIiIjIIQaPRERERERE5BCDRyIiIiIiInKIwSMRERERERE5xOCRiIiIiIiIHGLwSERERERERA4xeCQiIiIiIiKHGDwSERERERGRQwweiYiIiIiIyCEGj0REREREROQQg0ciIiIiIiJyiMEjEREREREROcTgkYiIiIiIiBxi8EhEREREREQOMXgkIiIiIiIihxg8EhERERERkUMMHomIiIiIiMghBo9ERERERETkEINHIiIiIiIicojBIxERERERETnE4JGIiIiIiIgcYvBIREREREREDjF4JCIiIiIiIocYPBIREREREZFDDB6JiIiIiIjIIQaPRERERERE5BCDRyIiIiIiInKIwSMRERERERE5xOCRiIiIiIiIHGLwSERERERERA4xeCQiIiIiIiKHGDwSERERERGRQwweiYiIiIiIyCEGj0REREREROQQg0ciIiIiIiJyiMEjEREREREROcTgkYiIiIiIiBxi8EhEREREREQOMXgkIiIiIiIihxg8EhERERERkUMMHomIiIiIiMghBo9ERERERETkEINHIiIiIiIicojBIxERERERETnE4JGIiIiIiIgcYvBI5xSL1YJ9NftQp6/r764QEREREZ1TGDzSWctgMWBHxQ7Ut9fbtu2u3o3/5P4H72W/1+vnb+powp7qPTBZTL1+LiIiIiKi/sbgkQaMbeXb8MLeF1CqK3Wq/ZayLfiq8Ct8lvuZbVusfyz8FH5IDUrtpV6e8mnup/gs7zNsKd/S6+ciIiIiIupvDB5pwDhYexDV+mocbzzuVPsUTQoCfQKRFpJm25YYmIjzks5DrH9sb3XTJlmTDJVchfiA+F4/FxERERFRf5MIgiD05Ql1Oh00Gg20Wi0CAwP78tQ0wFW2ViKnMQeTYyZDJVe5dYw6fR2e3/s8AOD+sfcj0i/Sm110SBAEFGmLEOUXBbVC3afnJiIiIiLqTfL+7gDRSTH+MYjxj/HoGEG+QcgIzQAAhKhCvNEtm1JdKXRGHYaHDe+2zc6qnfiq4Cska5JxW+ZtXj0/EREREVF/YvBI5xSFVIFrM671+nGNFiPePPwmTFYTbh15K1KCUuy2C/QJhAQSBPrYH1W3WC34vuh7+Mp9MS9xntf7SURERETUWxg80lnNaDFCgAClTNlju+ONx7G1bCvmJc7rNvDriUKqQEJAAho7GhHi2/2I5vCw4Xhy8pPwlfnafb28tRzbK7YDALKisqBRalzuCxERERFRf2DwSGctg8WA/9vzfzBajbhv7H09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/kkQERFR1DF4JCK6xG3btg0Wi+XCOcVnn30W27ZtQ1pa2pi2Z86cwdGjR/H1r38dl19+ObKysvDCCy9MOkZ9fT0AICsrK7KTJyIiophh8EhEdIlTKpX46le/imeffRZ+vx9//OMfce+99wZs+8wzz+C6665DdnY2BEHAfffdN+7W1ddeew0vvPACnnrqKTz88MO46aabUF5eHs1PhYiIiKKI2VaJiGYZQRAQKBfa+WuCIIy5d++992L58uXYunUrZDIZrrzySuzYsWNUG4/Hg7/85S+47bbbLqw2JiQk4PDhwzh9+jQWLFgwqv3OnTuhVquRlpaGp59+GrfffnukPkUiIiKKAwaPRESzjMlkgsVigdvthlqtvnC9t7cXQOCto3PmzMGiRYvwrW99C9/5zncgk43dmPL6669DkiQMDAyMyoY6Z84cPPPMM/jFL34xqv3vfve7gFtfiYiIaGZi8EhENMssX74cMpkM7777Lm644YYL1999911oNJoxK4TnPfnkk/jLX/6Ce+65J+D9Z555BnfeeSeefvrpUddfe+013HffffiXf/mXUcEqERERzS4MHomIZpmKigo8/vjjuOuuu/Dtb38bRUVFqKmpwa9+9Sv85Cc/QXp6esDnrr/+elx//fUB7507dw67d+/Gk08+Oebe1q1b4XQ68dprr015a+qrr74Kj8eD9vZ2+P1+vPDCC1CpVNi+ffuU+iMiIqLIY/BIRDQLPfXUU7j66qvx/vvvY+/evcjLy8NHH32Eyy677EKbnJwcfPnLX4ZSqQzYR25u7oX71dXV+MpXvoL169ePaafVavG9730P3d3do/oNZRXyrbfegt1uR2FhIYCRLbI6nY7BIxER0TQiSIGyKhARERERERF9AUt1EBERERER0aQYPBIREREREdGkGDwSERERERHRpBg8EhERERER0aQYPBIREREREdGkGDwSERERERHRpBg8EhERERER0aQYPBIREREREdGkGDwSERERERHRpBg8EhERERER0aQYPBIREREREdGkGDwSERERERHRpP5/1+DhT9XXwmcAAAAASUVORK5CYII=", + "text/plain": [ + "
    " + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "result.plot_embedding(figsize=(9, 6))" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "8e3023c5", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    group_idfeature_namescorefrac_exp
    01GZMK0.645710.50262
    11NSG10.552760.37696
    2222CSTA0.949480.96985
    2232CLEC12A0.946990.93970
    18823LRRN30.745560.54406
    18833FHIT0.596580.57088
    22034BANK10.915660.94017
    22044CD79A0.903530.97436
    28315HIST1H2BG0.505420.31148
    28325AC092683.10.443790.34426
    29276FGFBP20.893070.90625
    29286GZMH0.881870.91667
    \n", + "
    " + ], + "text/plain": [ + " group_id feature_name score frac_exp\n", + "0 1 GZMK 0.64571 0.50262\n", + "1 1 NSG1 0.55276 0.37696\n", + "222 2 CSTA 0.94948 0.96985\n", + "223 2 CLEC12A 0.94699 0.93970\n", + "1882 3 LRRN3 0.74556 0.54406\n", + "1883 3 FHIT 0.59658 0.57088\n", + "2203 4 BANK1 0.91566 0.94017\n", + "2204 4 CD79A 0.90353 0.97436\n", + "2831 5 HIST1H2BG 0.50542 0.31148\n", + "2832 5 AC092683.1 0.44379 0.34426\n", + "2927 6 FGFBP2 0.89307 0.90625\n", + "2928 6 GZMH 0.88187 0.91667" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "marker_table = result.get_markers()\n", + "marker_table.sort_values(\n", + " [\"group_id\", \"score\"], ascending=[True, False],\n", + ").groupby(\"group_id\", sort=True).head(2)[\n", + " [\"group_id\", \"feature_name\", \"score\", \"frac_exp\"]\n", + "].head(12)" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "d9807e44", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'report': 'index.html', 'exists': True}" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "report_path = result.report()\n", + "{\"report\": report_path.name, \"exists\": report_path.is_file()}" + ] + } + ], + "metadata": { + "description": "Choose, explain, and execute RNA analysis settings with Scarf agents.", + "jupytext": { + "cell_metadata_filter": "tags", + "text_representation": { + "extension": ".md", + "format_name": "myst", + "format_version": 0.13, + "jupytext_version": "1.14.1" + } + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.0" + }, + "source_map": [ + 14, + 70, + 93, + 98, + 577, + 586, + 594, + 599, + 606, + 624, + 628, + 635, + 640, + 643 + ] + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/.jupyter_cache/executed/6f4bdeb21a5ca0ba2f47633bf5853ebc/base.ipynb b/docs/.jupyter_cache/executed/6f4bdeb21a5ca0ba2f47633bf5853ebc/base.ipynb deleted file mode 100644 index a9ef3b33..00000000 --- a/docs/.jupyter_cache/executed/6f4bdeb21a5ca0ba2f47633bf5853ebc/base.ipynb +++ /dev/null @@ -1,809 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 1, - "id": "267f8fb0", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
    Downloading bucket files18098007 / 18098007 complete18098007 / 18098007 complete
    " - ], - "text/plain": [ - "Downloading bucket files: 18098007 / 18098007 complete" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "
    Downloading bytes18098007 / 18098007 complete18098007 / 18098007 complete
    " - ], - "text/plain": [ - "Downloading bytes: 18098007 / 18098007 complete" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/plain": [ - "'data.h5'" - ] - }, - "execution_count": 1, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "from pathlib import Path\n", - "from tempfile import TemporaryDirectory\n", - "\n", - "import pandas as pd\n", - "import scarf\n", - "from scarf.agent import analyze_rna\n", - "\n", - "scarf.configure_output(level=\"WARNING\", progress=False)\n", - "source_path = scarf.cytebase.connect(\"scarf_docs\").download(\n", - " \"tenx_5K_pbmc_rnaseq/data.h5\", destination=\"scarf_datasets\",\n", - ")[0]\n", - "teaching_directory = TemporaryDirectory(prefix=\"scarf-agent-teaching-\")\n", - "zarr_path = Path(teaching_directory.name) / \"analysis.zarr\"\n", - "study_context = (\n", - " \"Human 10x Genomics 5K PBMC 3-prime gene expression from peripheral blood, \"\n", - " \"collected from one healthy donor. No treatment comparison, trusted technical \"\n", - " \"batch column, paired modality, or independent replication metadata is available. \"\n", - " \"Do not invent missing design variables or report treatment effects.\"\n", - ")\n", - "source_path.name" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "2972c64f", - "metadata": { - "tags": [ - "remove-cell" - ] - }, - "outputs": [], - "source": [ - "import json\n", - "from typing import Any\n", - "\n", - "from IPython import get_ipython\n", - "from pydantic_ai.messages import (\n", - " ModelMessage,\n", - " ModelResponse,\n", - " ToolCallPart,\n", - " ToolReturnPart,\n", - ")\n", - "from pydantic_ai.models.function import AgentInfo, FunctionModel\n", - "\n", - "from scarf.agent.data_enrichment import (\n", - " AssayFeatureInspectionBatch,\n", - " DataEnrichmentReport,\n", - " FeatureSelectionPolicy,\n", - " StudyContextSummary,\n", - ")\n", - "from scarf.agent.experimental_context import (\n", - " BatchCorrectionPlan,\n", - " CovariateEvidence,\n", - " ExperimentalContextDecision,\n", - ")\n", - "\n", - "notebook_shell = get_ipython()\n", - "if notebook_shell is not None:\n", - " notebook_shell.run_line_magic(\"matplotlib\", \"inline\")\n", - "\n", - "def _prompt_text(messages: list[ModelMessage]) -> str:\n", - " values = []\n", - " for message in messages:\n", - " for part in message.parts:\n", - " content = getattr(part, \"content\", None)\n", - " if isinstance(content, str):\n", - " values.append(content)\n", - " elif isinstance(content, tuple):\n", - " values.extend(item for item in content if isinstance(item, str))\n", - " return \"\\n\".join(values)\n", - "\n", - "\n", - "def _tool_result(\n", - " messages: list[ModelMessage],\n", - " tool_name: str,\n", - " model_type: Any,\n", - ") -> Any:\n", - " for message in reversed(messages):\n", - " for part in reversed(message.parts):\n", - " if isinstance(part, ToolReturnPart) and part.tool_name == tool_name:\n", - " if isinstance(part.content, model_type):\n", - " return part.content\n", - " if isinstance(part.content, str):\n", - " return model_type.model_validate_json(part.content)\n", - " return model_type.model_validate(part.content)\n", - " raise AssertionError(f\"Missing tool return {tool_name!r}\")\n", - "\n", - "\n", - "def _tool_call(name: str, args: dict[str, Any] | None = None) -> ModelResponse:\n", - " return ModelResponse(parts=[ToolCallPart(tool_name=name, args=args or {})])\n", - "\n", - "\n", - "def _structured_output(info: AgentInfo, value: Any) -> ModelResponse:\n", - " payload = value.model_dump() if hasattr(value, \"model_dump\") else value\n", - " return _tool_call(info.output_tools[0].name, payload)\n", - "\n", - "\n", - "def _scripted_workflow_model() -> tuple[FunctionModel, dict[str, Any]]:\n", - " state = {\n", - " \"enrichment\": 0,\n", - " \"context\": 0,\n", - " \"parameter\": 0,\n", - " \"assessments\": [],\n", - " \"requests\": 0,\n", - " }\n", - "\n", - " async def reply(\n", - " messages: list[ModelMessage],\n", - " info: AgentInfo,\n", - " ) -> ModelResponse:\n", - " state[\"requests\"] += 1\n", - " tools = {tool.name for tool in info.function_tools}\n", - "\n", - " if (\n", - " \"inspect_assay_features_batch\" in tools\n", - " or state[\"enrichment\"] == 1\n", - " or any(\n", - " tool.parameters_json_schema.get(\"title\") == \"DataEnrichmentReport\"\n", - " for tool in info.output_tools\n", - " )\n", - " ):\n", - " if state[\"enrichment\"] == 0:\n", - " state[\"enrichment\"] = 1\n", - " return _tool_call(\"inspect_assay_features_batch\")\n", - "\n", - " batch = _tool_result(\n", - " messages,\n", - " \"inspect_assay_features_batch\",\n", - " AssayFeatureInspectionBatch,\n", - " )\n", - " policies = []\n", - " for inspection in batch.inspections:\n", - " species_observed = inspection.species != \"unknown\"\n", - " policy_evidence = list(inspection.evidenceIds)\n", - " if not species_observed:\n", - " policy_evidence.append(\"context:study\")\n", - " policies.append(\n", - " FeatureSelectionPolicy(\n", - " assay=inspection.assay,\n", - " species=(\n", - " inspection.species\n", - " if species_observed\n", - " else \"homo_sapiens\"\n", - " ),\n", - " speciesConfidence=\"high\" if species_observed else \"medium\",\n", - " speciesRationale=(\n", - " inspection.speciesReason\n", - " or \"The exact study paragraph identifies a human sample.\"\n", - " ),\n", - " excludeFamilies=[\n", - " family.family\n", - " for family in inspection.families\n", - " if family.count > 0 and family.defaultExclude is True\n", - " ],\n", - " protectFamilies=[\n", - " family.family\n", - " for family in inspection.families\n", - " if family.count > 0 and family.defaultExclude is False\n", - " ],\n", - " rationale=(\n", - " \"Exclude observed technical families and preserve \"\n", - " \"observed protected families.\"\n", - " ),\n", - " evidenceIds=list(dict.fromkeys(policy_evidence)),\n", - " )\n", - " )\n", - " state[\"enrichment\"] = 2\n", - " return _structured_output(\n", - " info,\n", - " DataEnrichmentReport(\n", - " status=\"done\",\n", - " studyContextSummary=StudyContextSummary(\n", - " organismReferences=[\"Human\"],\n", - " tissueReferences=[\"peripheral blood\"],\n", - " experimentalReferences=[\n", - " \"10x Genomics 5K PBMC 3-prime gene expression\"\n", - " ],\n", - " analysisIntentReferences=[\n", - " \"Discover stable major immune-cell populations.\"\n", - " ],\n", - " ),\n", - " policies=policies,\n", - " ),\n", - " )\n", - "\n", - " if tools.intersection(\n", - " {\n", - " \"inspect_cell_covariates\",\n", - " \"analyze_experimental_design\",\n", - " \"score_current_representation\",\n", - " }\n", - " ) or state[\"context\"] in {1, 2} or any(\n", - " tool.parameters_json_schema.get(\"title\") == \"ExperimentalContextDecision\"\n", - " for tool in info.output_tools\n", - " ):\n", - " if state[\"context\"] == 0:\n", - " state[\"context\"] = 1\n", - " return _tool_call(\"inspect_cell_covariates\")\n", - " if state[\"context\"] == 1:\n", - " state[\"context\"] = 2\n", - " return _tool_call(\n", - " \"analyze_experimental_design\",\n", - " {\n", - " \"column_domains\": {},\n", - " \"coefficients_of_interest\": [],\n", - " \"units_of_inference\": {},\n", - " \"batch_columns\": [],\n", - " },\n", - " )\n", - "\n", - " design = _tool_result(\n", - " messages,\n", - " \"analyze_experimental_design\",\n", - " CovariateEvidence,\n", - " )\n", - " profile = next(\n", - " value\n", - " for value in design.qcProfiles\n", - " if value.action == \"skip\"\n", - " )\n", - " evidence_id = profile.evidenceId\n", - " state[\"context\"] = 3\n", - " return _structured_output(\n", - " info,\n", - " ExperimentalContextDecision(\n", - " batchCorrection=BatchCorrectionPlan(\n", - " action=\"skip\",\n", - " rationale=\"No trusted technical batch column was supplied.\",\n", - " evidenceIds=[evidence_id],\n", - " ),\n", - " rationale=\"No experimental covariates were supplied.\",\n", - " evidenceIds=[evidence_id],\n", - " ),\n", - " )\n", - "\n", - " prompt = _prompt_text(messages)\n", - " if any(\n", - " tool.parameters_json_schema.get(\"title\") == \"TuningAction\"\n", - " for tool in info.output_tools\n", - " ):\n", - " evidence, _ = json.JSONDecoder().raw_decode(prompt[prompt.index(\"{\") :])\n", - " candidates = [\n", - " item for item in evidence[\"candidates\"]\n", - " if item[\"status\"] == \"done\" and item[\"eligible\"]\n", - " ]\n", - " if not candidates:\n", - " raise AssertionError(\"The teaching run has no supported partition\")\n", - "\n", - " def measured(item, name):\n", - " value = item[\"metrics\"].get(name)\n", - " return float(value) if value is not None else 0.0\n", - "\n", - " selected = max(\n", - " candidates,\n", - " key=lambda item: (\n", - " measured(item, \"seedStability\"),\n", - " measured(item, \"markerCoherence\"),\n", - " ),\n", - " )\n", - " metrics = selected[\"metrics\"]\n", - " genes = list(dict.fromkeys(\n", - " gene for names in metrics.get(\"topMarkerGenes\", {}).values()\n", - " for gene in names\n", - " ))[:8]\n", - " quantitative = (\n", - " f\"Compared {len(candidates)} observed partitions; selected resolution \"\n", - " f\"{selected['parameters']['leidenResolution']}, with seed stability \"\n", - " f\"{metrics.get('seedStability')} and marker coherence \"\n", - " f\"{metrics.get('markerCoherence')}.\"\n", - " )\n", - " qualitative = (\n", - " \"The saved marker preview contains \" + \", \".join(genes) + \".\"\n", - " if genes else \"The saved marker preview is empty; cell identities remain unresolved.\"\n", - " )\n", - " action = {\n", - " \"action\": \"accept\",\n", - " \"selectedCandidateId\": selected[\"candidateId\"],\n", - " \"correctionNeed\": \"notApplicable\",\n", - " \"assessedDomains\": evidence[\"assessedDomains\"],\n", - " \"evidenceIds\": [\n", - " f\"candidate:{selected['candidateId']}\",\n", - " *list(evidence[\"imageHashes\"])[:1],\n", - " \"studyContract\", \"qcPolicy\", \"samplingCoverage\", \"featureEvidence\",\n", - " ],\n", - " \"quantitativeFindings\": [quantitative],\n", - " \"qualitativeFindings\": [qualitative],\n", - " \"objectivePreservation\": (\n", - " \"Preserve the single-donor population structure and retain marker \"\n", - " \"uncertainty; no batch or treatment comparison is supported.\"\n", - " ),\n", - " \"rationale\": (\n", - " \"The teaching policy selects the observed partition with the \"\n", - " \"greatest seed stability, using marker coherence to break ties. \"\n", - " + quantitative\n", - " ),\n", - " }\n", - " state[\"assessments\"].append({\n", - " \"selection\": action,\n", - " \"alternatives\": [{\n", - " \"resolution\": item[\"parameters\"][\"leidenResolution\"],\n", - " \"clusters\": item[\"metrics\"].get(\"nClusters\"),\n", - " \"seed_stability\": item[\"metrics\"].get(\"seedStability\"),\n", - " \"marker_coherence\": item[\"metrics\"].get(\"markerCoherence\"),\n", - " \"selected\": item[\"candidateId\"] == selected[\"candidateId\"],\n", - " } for item in candidates],\n", - " })\n", - " return _structured_output(info, action)\n", - "\n", - " payload, _ = json.JSONDecoder().raw_decode(prompt[prompt.index(\"{\") :])\n", - " decision = payload[\"spec\"]\n", - " evidence_by_class = {}\n", - " evidence_class_by_id = {}\n", - " for item in payload[\"evidence\"][\"evidence\"]:\n", - " evidence_by_class.setdefault(\n", - " item[\"evidenceClass\"],\n", - " item[\"evidenceId\"],\n", - " )\n", - " evidence_class_by_id[item[\"evidenceId\"]] = item[\"evidenceClass\"]\n", - " preferred = decision.get(\"metricPreferredOptionId\")\n", - " selected = (\n", - " next(\n", - " option\n", - " for option in decision[\"options\"]\n", - " if option[\"optionId\"] == preferred\n", - " )\n", - " if preferred is not None\n", - " else next(\n", - " option\n", - " for option in decision[\"options\"]\n", - " if option[\"status\"] in {\"apply\", \"skip\"}\n", - " )\n", - " )\n", - " evidence_ids = list(selected.get(\"requiredEvidenceIds\", []))\n", - " cited_classes = {\n", - " evidence_class_by_id[evidence_id] for evidence_id in evidence_ids\n", - " }\n", - " for evidence_class in selected[\"requiredEvidenceClasses\"]:\n", - " if evidence_class not in cited_classes:\n", - " evidence_ids.append(evidence_by_class[evidence_class])\n", - " state[\"parameter\"] += 1\n", - " return _structured_output(\n", - " info,\n", - " dict(\n", - " selectedOptionId=selected[\"optionId\"],\n", - " evidenceIds=evidence_ids,\n", - " rationale=f\"Use the offered {selected['label']} policy with its required observed evidence.\",\n", - " confidence=\"high\",\n", - " ),\n", - " )\n", - "\n", - " return FunctionModel(reply), state" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "e8fa3bb4", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "WARNING: Minimum cell count (502) is lower than size factor multiplier (1000)\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "WARNING: WARNING: Number of valid features is less than value of parameter `top_n`: 33538. Resetting `top_n` to 13822\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "WARNING: WARNING: Number of valid features is less than value of parameter `top_n`: 33538. Resetting `top_n` to 14096\n" - ] - }, - { - "data": { - "text/plain": [ - "{'status': 'completed'}" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "model, model_state = _scripted_workflow_model()\n", - "result = analyze_rna(\n", - " source_path,\n", - " model=model,\n", - " study_context=study_context,\n", - " study_objective=\"Discover stable major immune-cell populations.\",\n", - " zarr_path=zarr_path,\n", - ")\n", - "{\"status\": result.status}" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "01d20e4a", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
    \n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
    resolutionclustersseed_stabilitymarker_coherenceselected
    00.50110.9310201.000000True
    10.75140.8419351.000000False
    21.00140.7216691.000000False
    31.25180.8043430.833333False
    \n", - "
    " - ], - "text/plain": [ - " resolution clusters seed_stability marker_coherence selected\n", - "0 0.50 11 0.931020 1.000000 True\n", - "1 0.75 14 0.841935 1.000000 False\n", - "2 1.00 14 0.721669 1.000000 False\n", - "3 1.25 18 0.804343 0.833333 False" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "assessment = model_state[\"assessments\"][-1]\n", - "pd.DataFrame(assessment[\"alternatives\"])" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "4fcc998d", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'why': 'The teaching policy selects the observed partition with the greatest seed stability, using marker coherence to break ties. Compared 4 observed partitions; selected resolution 0.5, with seed stability 0.9310201021058004 and marker coherence 1.0.',\n", - " 'marker_evidence': ['The saved marker preview contains ALDH1A1, VCAN, CD163, S100A12, QPCT, CLEC4E, CD14, CYP27A1.'],\n", - " 'biology_to_preserve': 'Preserve the single-donor population structure and retain marker uncertainty; no batch or treatment comparison is supported.'}" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "selection = assessment[\"selection\"]\n", - "{\n", - " \"why\": selection[\"rationale\"],\n", - " \"marker_evidence\": selection[\"qualitativeFindings\"],\n", - " \"biology_to_preserve\": selection[\"objectivePreservation\"],\n", - "}" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "d6607595", - "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAA48AAAJjCAYAAACsmCRCAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjEsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvctoD+AAAAAlwSFlzAAAPYQAAD2EBqD+naQABAABJREFUeJzs3XecXFd9///XvXd63d61RWXVe5flinshBmxiY8D0ksCXEiAmgfwSSIB0CMWExNRQQjHBYDA22HKTZVu9d+1KW7R9erl37r2/P1YaabSzTZItyfo8efBY78y5dTQaveec8zmKbds2QgghhBBCCCHEGNQLfQJCCCGEEEIIIS5+Eh6FEEIIIYQQQoxLwqMQQgghhBBCiHFJeBRCCCGEEEIIMS4Jj0IIIYQQQgghxiXhUQghhBBCCCHEuCQ8CiGEEEIIIYQYl4RHIYQQQgghhBDjkvAohBjhyJEjfOITn+DNb34zP/zhDy/06VwUnn32Wd773veO+vv58sQTT/DhD3/4vO/3YvBK3TNx6Xq13lcX+phCCPFaIeFRiPMknU7zzne+k7vuumtC7Xfv3s0DDzzA/fffz7e+9S1M0yx4Xtd1/vd//5d7772Xf/qnfyq6j66uLj7/+c9z//3382d/9mf87//+74j9TJZt29xwww309fVx9913s3DhwjHbv1auezzt7e38+te/HvX38+XQoUP87ne/O+/7vRic73u2bt063v/+95+3/V3MXqvX+mq9ry70MYUQ4rVCwqMQ58nHPvYx1q1bxy9+8Ytx2/7kJz9h6dKlJJNJbrzxRnbv3s2HPvSh/PPd3d1MmzaNX/ziF+zfv5/169eP2Ed7ezsLFixgw4YNXHfddUydOpUPfehDfOADHzin6zh+/DiHDh3iM5/5DHfffTfz5s0bs/1r5brFpaetrY1HH330Qp/Gq+JyulYhhBAXL8eFPgEhXgt+/vOfs379eh544IFxQ0x3dzfvfve7+dznPscnP/lJAO677z4GBgbybcLhMBs3bqS6upq77rqLXC43Yj+//vWvyWQy/OpXv8LhGH4r+/1+Pvaxj/Gf//mfqGrx74aOHz/ON7/5TQ4cOEB1dTXvfOc7mT9/PgDPP/88f//3fw/ARz7yEXw+H//yL/9Cc3PzJX/diUSC73znO7z88stUVVXx7ne/m9mzZxc8/9BDD7Fx40bKysq44447uP7668e8ptOZpslPfvITnn76aTRN46abbuLOO+8867Z79uzhf/7nf+js7GTlypW8733vQ9O0/PNjvY6maXLffffxmc98Jh/+//qv/5rBwUEefPBBYPj1+PCHP8x//dd/sXHjRh555BH+7M/+bMxjTsR49/l0v/jFL9i0aRNf+MIX8o9t2LCBBx98kO9973tj3qtNmzbx9a9/ncHBwXyv97333sub3vSmcV/LJ554gkceeYQPfOADfOtb36K7u5uHHnqIYDA46esZ63WY6DWePJ/R7v9o13rnnXdO+M/cudzr0+/FZN4jO3bs4O/+7u8A8Hq9tLa28sEPfpCKiopRt5mIi+m9LIQQlxvpeRTiHLW3t/PhD3+YH/3oR7jd7nHb//jHP8a2bf78z/+84PHy8vL8f/t8Pqqrq8fcz4wZM8hkMnR1deUfO3ToENOmTRs1QPX19bF48WJeeOEFrrvuOuLxOMuXL+f5558HoKmpidtuuw2AO+64g3vuuYfS0tJL/roHBwdZvnw53/3ud1mxYgV1dXW89a1vpaOjA4BIJMLKlSt58sknue6662hsbOStb30rX/7yl8e9rpM++9nP8pnPfIYFCxawdOlS/ud//od/+7d/O6u23d3d3H333dTU1LB8+XI+97nP8Zd/+Zf558d7HTVN4/Dhw/meKsMw+PKXv8x///d/c/z4cQD++Mc/smHDBkpLSzl06BDf/va3xzymYRjcddddPPHEE6Peg/Hu85n27NnDk08+WfBYR0cHv/rVr8a9V/X19axcuRKv18s999zDPffcw9y5cyf0Wh46dIiHHnqIN77xjUydOpU3v/nNRf8Mj3c9470OE73G8e7/aNc6mT9z53Kv4ezeI9XV1fnzvf7669m+fTvz589naGho1G3Gc7G9l4UQ4rJjCyHOmmEY9urVq+2vfOUrtm3b9ne+8x17vLfV29/+dnvFihX2hg0b7Pe97332e9/7Xvvb3/62ncvlirZ/05veZP/Jn/xJ0ef++7//225sbLRvueUWe/ny5fbVV19tHzp0aNRjf/SjH7Xnzp1rm6aZf+ytb32rvWrVqvzvBw4csAH7yJEjo+7nUrvuj33sY3ZLS4udyWTyj6VSKTuRSNi2bduf/OQn7de97nUF2zz22GO21+u1s9msbdu2/YMf/MCurq7OP3/m7wsXLrS/8Y1v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5cu1Zs0affvb39b3v/992WzzF6Y8+eSTsixLMzMzZdVQly9frp07d+oXv/hFWftHH310waWvAADgs4nwCACXmA0bNshms+mZZ57R7bffXrr+zDPPyOPxzJshPOvhhx/W448/rq9//esL3t+5c6e2b9+uHTt2lF3fvXu3HnroIf3sZz8rC6sAAODSQngEgEvMwMCAfvSjH+mrX/2qvve976mnp0cHDx7UL3/5S/30pz9VQ0PDgq+77bbbdNttty14b3h4WHv27NHDDz88797WrVuVSqW0e/fuf3tp6h/+8Adls1mdPHlShUJBu3btksvl0l133fVv9QcAAD56hEcAuAQ98sgjuvnmm/Xss89q79696ujo0PPPP6+NGzeW2rS1telLX/qSnE7ngn20t7eX7h84cEAPPPCArrnmmnntfD6ffvzjH2tiYqKs3w8yC/nnP/9ZiURC3d3dkopLZP1+P+ERAIBPEcNaqKoCAAAAAADn4agOAAAAAEBFhEcAAAAAQEWERwAAAABARYRHAAAAAEBFhEcAAAAAQEWERwAAAABARYRHAAAAAEBFhEcAAAAAQEWERwAAAABARYRHAAAAAEBFhEcAAAAAQEWERwAAAABARf8fsovwie56bycAAAAASUVORK5CYII=", - "text/plain": [ - "
    " - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "result.plot_embedding(figsize=(9, 6))" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "4ce21a43", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
    \n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
    group_idfeature_namescorefrac_exp
    01ALDH1A10.904730.49496
    11VCAN0.901040.98096
    53510CTSL0.775850.78667
    53610C1QA0.739800.52000
    97411SERPINF10.841460.91304
    97511LILRA40.832171.00000
    15302TNFRSF13B0.838730.71956
    15312IGHA10.824600.71956
    16333LRRN30.723250.54112
    16343NOG0.596620.26399
    16944KLRF10.854540.93539
    16954ADGRG10.793900.83989
    \n", - "
    " - ], - "text/plain": [ - " group_id feature_name score frac_exp\n", - "0 1 ALDH1A1 0.90473 0.49496\n", - "1 1 VCAN 0.90104 0.98096\n", - "535 10 CTSL 0.77585 0.78667\n", - "536 10 C1QA 0.73980 0.52000\n", - "974 11 SERPINF1 0.84146 0.91304\n", - "975 11 LILRA4 0.83217 1.00000\n", - "1530 2 TNFRSF13B 0.83873 0.71956\n", - "1531 2 IGHA1 0.82460 0.71956\n", - "1633 3 LRRN3 0.72325 0.54112\n", - "1634 3 NOG 0.59662 0.26399\n", - "1694 4 KLRF1 0.85454 0.93539\n", - "1695 4 ADGRG1 0.79390 0.83989" - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "marker_table = result.get_markers()\n", - "marker_table.sort_values(\n", - " [\"group_id\", \"score\"], ascending=[True, False],\n", - ").groupby(\"group_id\", sort=True).head(2)[\n", - " [\"group_id\", \"feature_name\", \"score\", \"frac_exp\"]\n", - "].head(12)" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "8cb1bd3e", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'report': 'index.html', 'exists': True}" - ] - }, - "execution_count": 8, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "report_path = result.report()\n", - "{\"report\": report_path.name, \"exists\": report_path.is_file()}" - ] - } - ], - "metadata": { - "description": "Choose, explain, and execute RNA analysis settings with Scarf agents.", - "jupytext": { - "cell_metadata_filter": "tags", - "text_representation": { - "extension": ".md", - "format_name": "myst", - "format_version": 0.13, - "jupytext_version": "1.14.1" - } - }, - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.14.0" - }, - "source_map": [ - 14, - 59, - 80, - 85, - 410, - 420, - 428, - 433, - 440, - 453, - 457, - 464, - 469, - 472 - ] - }, - "nbformat": 4, - "nbformat_minor": 5 -} \ No newline at end of file diff --git a/docs/.jupyter_cache/global.db b/docs/.jupyter_cache/global.db index cfad6c47c749e2b17cd976f2e235b2b4be5ae93b..e54a95f4dacaedcf828e44e361e61c63973cafcd 100644 GIT binary patch delta 2879 zcmeH}Jx`QD5XX-b5#f$&6rvTN(L}?OeVyGUv7pe71Q3r`K_RC`BbsPIc!_tI7!}c& zDWv2h7%e1`LWmaPSMVd)Skt(Bm&iUciG`Mzd#e5Y^S^m!_L-UTMz*|>eOnkfdnflL z*WJ3*@~x%o=)>mS=8hxxns%B_9-eF5X*_=DR(2s%<+!5!1M%tK9e=VPk4~HgE#*OdjVOkT6VY|^ zr%EwP_}7SI*flvH`&(xf5Z6s7RRZ{VGUXpWElbqP>pk)Qx35Om$DeYpfT&lE7{KpRZ1g0RK?=2}Fv@nTF;}|6u-VU!Uf>WM|k74r*4b63mvE zoRKr`y3a7@P{PWz?Ni((8no~~WBi3O%ak`5(3;FL;NJA9ro#99UhdiZ)7Q2N#lQV+ zK9k9GWHvK}k+!Y2j(olG9q8Nj#^=Xcz46rse7*67HRr!KzDxhm_++mT31duyc~|ak z-EZfRIKLAKVtmzzWZ1vD5N{9GItfHOha?b#KUbGxd#jwKmyIgXxc(wFtu5DD5`Ej- z2jcMJT56dwetcq{W_y4C=iL8u@44rk{AMD*nRu5T?75Tt zoa}5~YT9Y)IQpRRYh&Ay`G#V{iNkaC#roDmw-UufvMw8Ky(tvn-9dF9HGNda zoHIcfBTV%Y+e`Kl3fZBjXT3$H6(u4hcZ4!X4L?`Do)al;P>)2JLU(IpG;k?3+20SH za~<$=c+lUD8p<^feeQ^%aQpIVm0nPzOi+(R3phTq>f57)YOR8LB!(AGA47YiC3~-( zy^)N+9g$2J68gMEG|Z0{s`OM48a!;`h=G%tmCEI)*lMd);PlEwLoxHX5*`A|R>jwnTN}7-j3fmi624`h_s z6WE@(pZW7xRV*x%Dj44pEebt#_!@@XYf!TycXKAk=#-qEBDpCxE41P~H!F?gGr4J4 zoiyi}rLiKG6HPIlh>&U}WJXAQc)0D^S6CdQ53aDTTwdG z@~NdQRjZ5TDo<;5u~rvptuDIPfYtK<*2RneQWtF>lQES-DEmI&)m$m}I>!2|!_!EK zXc$;qtV|cEC$7mHtjJ|z;Mdx+A0DSQZUWs<$BtMI8!xc1?zuwFSxgK+%dF~Mf=U#32@wuhM1ezg^%t);G E4e+L#GXMYp diff --git a/docs/source/analysis_with_agents.md b/docs/source/analysis_with_agents.md index 71d46f77..a0b5196f 100644 --- a/docs/source/analysis_with_agents.md +++ b/docs/source/analysis_with_agents.md @@ -147,10 +147,32 @@ biology, including supported joint groups. Unsafe or unknown designs cannot lice Large inputs use an immutable uniform screening cohort of 50,000 cells, with one possible enlargement to 100,000. Coverage and rare-population concerns can require a full-cohort baseline. Screening selects settings; it does not replace the final QC-retained cohort. Selected settings -are executed and assessed on the full cohort. The default numerical limits admit at most 12 -screening evaluations per sample and 24 in total, four full-cohort graphs, eight full-cohort -partitions, and one targeted full-cohort repair. Reused exact work is not charged again. Limits -bound numerical work rather than promise elapsed time or provider cost. +are executed and assessed on the full cohort. Each screening population must compare the +baseline against 2,000 and 4,000 variable genes, 10 and 30 PCA dimensions, and 21 and 41 +neighbors, changing one setting at a time. The four baseline resolutions share one graph. +Supported batch-aware ranking and an evidence-nominated feature policy provide additional +comparisons. The agent interprets these results, proposes combined settings, and assesses their +actual execution and resolution alternatives before accepting them. A list of reviewed domains +or a general preference for defaults does not establish sufficient evidence. + +The default limits allow 24 screening evaluations per population and 48 overall, with four +additional final-validation graphs, eight additional partitions, and one targeted repair. +For small cohorts, discovery uses every retained cell; these are full-sized comparisons counted +in the screening allowance. Exact completed artifacts are reused for final validation without +another admission. Report the analyzed population and diagnostic operations separately; the +candidate allowance does not bound doublet calculations, elapsed time, or provider cost. + +Experimental Context records objective evidence requirements before tuning. Repeated donors and +incomplete pairing receive descriptive counts and support summaries without treating cells or +repeated samples as independent replicates. A method that cannot compute an association does +not establish that an effect is absent or unidentifiable. Essential unresolved evidence blocks +a consequential decision; measured confounding may prohibit correction while descriptive +population discovery remains possible. + +RNA percentages use explicit gene-selection artifacts. Imported percentage columns remain +available for comparison but do not override a validated definition. In particular, a +mitochondrial symbol definition matches `MT-` rather than every gene beginning with `MT`. +Changing the metric definition requires new QC thresholds and dependent evidence. All saved execution and decisions belong to the orchestration stage history. Identical calls reuse completed work or resume matching interrupted work; changed inputs and identity checks diff --git a/docs/source/reference/api/agent.md b/docs/source/reference/api/agent.md index 50d76dc0..cb4349c8 100644 --- a/docs/source/reference/api/agent.md +++ b/docs/source/reference/api/agent.md @@ -76,13 +76,16 @@ result = runner.run(AutomatedWorkflowRequest( The advanced interface exposes numerical limits, provider limits, existing-store workspaces, and explicit pauses. Inspect its returned status and questions before continuing. Defaults allow -50,000 screening cells, one enlargement to 100,000, 12 evaluations per screen and 24 across -screens, four full-cohort graphs, eight full-cohort partitions, and one targeted full-cohort -repair. These counts bound distinct admitted work, including failed attempts; exact reuse does +50,000 screening cells, one enlargement to 100,000, 24 evaluations per screen and 48 across +screens, four additional final-validation graphs, eight additional partitions, and one targeted +full-cohort repair. Screening includes every retained cell in small datasets; those comparisons +are counted in the screening allowance, and exact artifacts are reused for final validation. These counts bound distinct admitted work, including failed attempts; exact reuse does not spend another slot. They do not bound every QC, marker, I/O, or provider cost. The previous agent workflow records, result fields, candidate-budget aliases, and root imports -are unsupported. Old agent runs must be restarted. Numerical artifacts remain accessible through +are unsupported. Histories without mandatory objective requirements and completed comparison +coverage cannot be resumed or used to regenerate reports under this contract. Start a new +workflow; existing historical HTML remains readable. Numerical artifacts remain accessible through the ordinary Scarf artifact APIs; no saved records are silently migrated. For a complete executable example, see {doc}`../../tutorials/agent_workflow`. diff --git a/docs/source/tutorials/agent_workflow.md b/docs/source/tutorials/agent_workflow.md index f50de6e1..b3d1475b 100644 --- a/docs/source/tutorials/agent_workflow.md +++ b/docs/source/tutorials/agent_workflow.md @@ -53,8 +53,17 @@ The {doc}`../reference/api/agent` page describes the small public interface and ## A reproducible teaching analysis -The executable example uses the real analysis operations and a local scripted `FunctionModel`. -The script chooses among observed partitions by seed stability, then marker coherence. This makes +The executable example uses a deterministic 1,000-cell teaching cohort drawn without replacement +from the public 10x Genomics 5K PBMC dataset (random seed 42). Preparation imports the public +file into a temporary Scarf store and marks those 1,000 cells as the active input; the downloaded +dataset is unchanged. Quality filtering and every required comparison still run through the +agent workflow. This small cohort demonstrates the workflow and does not represent an analysis +of all cells in the public dataset. + +The example uses the real analysis operations and a local scripted `FunctionModel`. +The script chooses among observed partitions by agreement across clustering runs, then the +fraction of clusters with qualifying markers. Marker coverage alone does not establish biological +coherence. This makes the example reproducible without an API key. It is a teaching policy, not a substitute for a model that interprets the supplied diagnostic images and study-specific biology. @@ -74,7 +83,9 @@ teaching_directory = TemporaryDirectory(prefix="scarf-agent-teaching-") zarr_path = Path(teaching_directory.name) / "analysis.zarr" study_context = ( "Human 10x Genomics 5K PBMC 3-prime gene expression from peripheral blood, " - "collected from one healthy donor. No treatment comparison, trusted technical " + "collected from one healthy donor. The teaching cohort is a deterministic random " + "subset of 1,000 cells (seed 42), not the full public dataset. " + "No treatment comparison, trusted technical " "batch column, paired modality, or independent replication metadata is available. " "Do not invent missing design variables or report treatment effects." ) @@ -90,6 +101,7 @@ feature families, candidates, and evidence identifiers still fail the production import json from typing import Any +import numpy as np from IPython import get_ipython from pydantic_ai.messages import ( ModelMessage, @@ -110,6 +122,20 @@ from scarf.agent.experimental_context import ( CovariateEvidence, ExperimentalContextDecision, ) +from scarf.agent.ingest import ingest + +prepared_input = ingest(path=source_path, zarrPath=zarr_path) +if prepared_input.status != "done": + raise RuntimeError(f"Teaching dataset import failed: {prepared_input.notes}") +teaching_store = scarf.DataStore( + str(zarr_path), min_features_per_cell=-1, mito_pattern="", ribo_pattern="", +) +teaching_store.cells.reset_key("I") +teaching_cells = np.zeros(teaching_store.cells.N, dtype=bool) +teaching_cells[np.random.default_rng(42).choice(teaching_store.cells.N, 1000, replace=False)] = True +teaching_store.cells.update_key(teaching_cells, "I") +source_path = zarr_path +del teaching_store notebook_shell = get_ipython() if notebook_shell is not None: @@ -292,50 +318,159 @@ def _scripted_workflow_model() -> tuple[FunctionModel, dict[str, Any]]: prompt = _prompt_text(messages) if any( - tool.parameters_json_schema.get("title") == "TuningAction" + {"selectedCandidateId", "comparisonConclusions"}.issubset( + tool.parameters_json_schema.get("properties", {}) + ) for tool in info.output_tools ): evidence, _ = json.JSONDecoder().raw_decode(prompt[prompt.index("{") :]) - candidates = [ - item for item in evidence["candidates"] + coverage = evidence["comparisonCoverage"] + settings = coverage["candidateSettings"] + candidates = { + item["candidateId"]: item for item in evidence["candidates"] if item["status"] == "done" and item["eligible"] - ] + } if not candidates: raise AssertionError("The teaching run has no supported partition") - def measured(item, name): - value = item["metrics"].get(name) - return float(value) if value is not None else 0.0 + def rank(identity): + metrics = settings[identity]["metrics"] + return tuple( + float(metrics[name]) if metrics.get(name) is not None else -1.0 + for name in ("seedStability", "markerCoherence") + ) - selected = max( - candidates, - key=lambda item: ( - measured(item, "seedStability"), - measured(item, "markerCoherence"), - ), - ) + axis_names = { + "hvgCount": "variable-gene count", "hvgRanking": "gene ranking", + "featurePolicy": "gene-family policy", "pca": "PCA dimensions", + "neighbors": "neighbor count", "partition": "clustering resolution", + } + metric_names = { + "seedStability": "agreement across clustering runs", + "subsampleStability": "agreement after resampling", + "markerCoherence": "the fraction of clusters with qualifying markers", + "markerSpecificityMedian": "median marker specificity", + "macroF1": "classification agreement", + } + axis_ids = {} + for row in coverage["comparisons"]: + identities = axis_ids.setdefault(row["axis"], []) + for identity in (row["baselineCandidateId"], row["alternativeCandidateId"]): + if identity is not None and identity not in identities: + identities.append(identity) + comparison_ids = {axis: list(identities) for axis, identities in axis_ids.items()} + if coverage["phase"] != "sensitivity": + axis_ids["partition"] = list(dict.fromkeys( + [*axis_ids["partition"], *coverage["resolutionCandidateIds"]] + )) + preferences = {axis: max(identities, key=rank) for axis, identities in axis_ids.items()} + pending_policy = any(row["status"] == "pending" for row in coverage["comparisons"]) + experiment_id = None + if coverage["phase"] == "sensitivity": + selected_id = evidence["currentCandidateId"] + action_name = "combine" + else: + eligible_resolutions = [ + identity for identity in coverage["resolutionCandidateIds"] + if identity in candidates + ] + if not eligible_resolutions: + raise AssertionError("The combined representation has no supported partition") + selected_id = max(eligible_resolutions, key=rank) + preferences["partition"] = selected_id + action_name = "accept" + if pending_policy: + families = evidence["featureEvidence"][selected_id]["families"] + supported = [ + (key, option) for key, option in evidence["experiments"].items() + if option["parameter"] in {"includeFamily", "excludeFamily"} + and families.get(option["value"], {}).get( + "selectedExamples" if option["parameter"] == "excludeFamily" + else "excludedExamples" + ) + ] + if not supported: + raise AssertionError("The teaching policy has no observed family program to nominate") + experiment_id, option = max(supported, key=lambda item: ( + families[item[1]["value"]].get("selectedGenes", 0), + item[1]["affectedEligibleGenes"], + )) + action_name = "experiment" + selected = candidates[selected_id] metrics = selected["metrics"] genes = list(dict.fromkeys( gene for names in metrics.get("topMarkerGenes", {}).values() for gene in names ))[:8] - quantitative = ( - f"Compared {len(candidates)} observed partitions; selected resolution " - f"{selected['parameters']['leidenResolution']}, with seed stability " - f"{metrics.get('seedStability')} and marker coherence " - f"{metrics.get('markerCoherence')}." - ) qualitative = ( "The saved marker preview contains " + ", ".join(genes) + "." if genes else "The saved marker preview is empty; cell identities remain unresolved." ) + conclusions = [] + for axis, identities in axis_ids.items(): + preferred = preferences[axis] + score = settings[preferred]["metrics"] + conclusions.append({ + "axis": axis, + "candidateIds": identities, + "preferredCandidateId": preferred, + "quantitativeReason": "; ".join( + f"Observed alternative {index + 1}: repeat agreement " + f"{settings[identity]['metrics'].get('seedStability')}, " + f"marker coverage {settings[identity]['metrics'].get('markerCoherence')}" + for index, identity in enumerate(identities) + ), + "biologicalReason": ( + "This scripted teaching policy does not establish cell identities or " + "infer that a gene program is a technical artifact. " + qualitative + ), + "plainLanguageSummary": ( + f"The teaching policy compared {len(identities)} observed {axis_names[axis]} " + f"settings and preferred repeat agreement {score.get('seedStability')}, " + f"using marker coverage {score.get('markerCoherence')} to break ties." + ), + "tradeoffs": [{ + "alternativeCandidateId": identity, + "metric": metric, + "preferredValue": score[metric], + "alternativeValue": settings[identity]["metrics"][metric], + "interpretation": ( + f"An alternative has higher {metric_names[metric]} " + f"({settings[identity]['metrics'][metric]} versus {score[metric]}). " + "The teaching policy prioritizes repeat agreement, then marker coverage; " + "this loss remains an explicit limit of its choice." + ), + } for identity in comparison_ids[axis] if identity != preferred + for metric in ("seedStability", "subsampleStability", "markerCoherence", + "markerSpecificityMedian", "macroF1") + if isinstance(score.get(metric), (int, float)) + and isinstance(settings[identity]["metrics"].get(metric), (int, float)) + and settings[identity]["metrics"][metric] > score[metric]], + }) + quantitative = ( + f"Observed resolution {selected['parameters']['leidenResolution']} has " + f"repeat agreement {metrics.get('seedStability')} and marker coverage " + f"{metrics.get('markerCoherence')}." + ) + summary = ( + "The teaching policy proposes testing a represented gene family; " + "its contribution must be measured before retaining or changing the gene selection." + if action_name == "experiment" else + "The teaching policy proposes a combination of the observed settings; " + "Scarf must execute that combination and compare its four resolutions." + if action_name == "combine" else + "The teaching policy selected the measured combined representation and " + "its most repeatable eligible partition. Marker identities remain unvalidated." + ) action = { - "action": "accept", - "selectedCandidateId": selected["candidateId"], + "action": action_name, + "selectedCandidateId": selected_id, + "experimentId": experiment_id, "correctionNeed": "notApplicable", - "assessedDomains": evidence["assessedDomains"], + "comparisonConclusions": conclusions, + "plainLanguageSummary": summary, "evidenceIds": [ - f"candidate:{selected['candidateId']}", + f"candidate:{selected_id}", *list(evidence["imageHashes"])[:1], "studyContract", "qcPolicy", "samplingCoverage", "featureEvidence", ], @@ -345,24 +480,54 @@ def _scripted_workflow_model() -> tuple[FunctionModel, dict[str, Any]]: "Preserve the single-donor population structure and retain marker " "uncertainty; no batch or treatment comparison is supported." ), - "rationale": ( - "The teaching policy selects the observed partition with the " - "greatest seed stability, using marker coherence to break ties. " - + quantitative - ), + "rationale": summary + " " + quantitative, + "concern": ( + f"Observed family {option['value']} contains " + f"{families[option['value']].get('selectedGenes', 0)} selected genes. " + "Test sensitivity to this program without assuming that it is technical." + ) if pending_policy else "", + "expectedImprovement": ( + "Measure whether changing this gene-family selection preserves the major " + "marker programs and improves repeat agreement." + ) if pending_policy else "", + "populationConcerns": [{ + "candidateId": selected_id, + "clusterId": cluster, + "status": "nonEssentialLimitation", + "evidenceIds": [f"candidate:{selected_id}"], + "explanation": ( + f"Population {cluster} has no qualifying marker genes and remains unclassified. " + "This tutorial demonstrates selecting analysis settings; it does not validate " + "cell identities or claim that every population has been biologically resolved." + ), + } for cluster, names in metrics.get("topMarkerGenes", {}).items() if not names], } + if action_name == "combine": + action["combinedSettings"] = { + field: preferences[axis] for field, axis in ( + ("hvgCountCandidateId", "hvgCount"), + ("hvgRankingCandidateId", "hvgRanking"), + ("featurePolicyCandidateId", "featurePolicy"), + ("pcaCandidateId", "pca"), + ("neighborsCandidateId", "neighbors"), + ) + } state["assessments"].append({ "selection": action, "alternatives": [{ - "resolution": item["parameters"]["leidenResolution"], - "clusters": item["metrics"].get("nClusters"), - "seed_stability": item["metrics"].get("seedStability"), - "marker_coherence": item["metrics"].get("markerCoherence"), - "selected": item["candidateId"] == selected["candidateId"], - } for item in candidates], + "resolution": settings[identity]["parameters"]["leidenResolution"], + "clusters": settings[identity]["metrics"].get("nClusters"), + "repeat_agreement": settings[identity]["metrics"].get("seedStability"), + "clusters_with_markers": settings[identity]["metrics"].get("markerCoherence"), + "selected": identity == selected_id, + } for identity in ( + coverage["resolutionCandidateIds"] if action_name == "accept" + else list(candidates) + )], }) return _structured_output(info, action) + payload, _ = json.JSONDecoder().raw_decode(prompt[prompt.index("{") :]) decision = payload["spec"] evidence_by_class = {} @@ -416,7 +581,6 @@ result = analyze_rna( model=model, study_context=study_context, study_objective="Discover stable major immune-cell populations.", - zarr_path=zarr_path, ) {"status": result.status} ``` @@ -441,8 +605,13 @@ selection = assessment["selection"] } ``` -A live model can keep the observed settings or request one registered comparison to resolve a -specific concern. It must explain the expected improvement and which biology should be preserved. +A live model receives required comparisons of variable-gene counts (1,000, 2,000, and 4,000), +PCA dimensions (10, 21, and 30), and neighbors (11, 21, and 41) against a shared baseline. +Supported batch-aware gene ranking and evidence-nominated gene eligibility changes also receive +matched comparisons. Infeasible values and identical gene selections are recorded explicitly. +The model must explain the observed tradeoffs and which biology should be preserved. The selected +combination is then executed and assessed at all four clustering resolutions before acceptance. +Further unresolved concerns require a targeted comparison or an incomplete outcome. A metric rank alone does not authorize correction or deletion of a biological program. Batch correction requires both a supported design and a matched comparison of native and corrected representations. Confounded technical and biological variables cannot license correction. @@ -486,8 +655,11 @@ cells before finalization. Sample measurements do not prove that rare population correction will transfer. If sample coverage is inadequate, the workflow assesses a bounded full-cohort baseline instead of deleting poorly represented groups. -The default advanced limits permit 12 candidate evaluations per screening sample, 24 across -screening samples, four full-cohort graphs, eight full-cohort partitions, and one targeted repair. +The default advanced limits permit 24 candidate evaluations per screening population and 48 +across screening populations. Additional final validation permits four full-cohort graphs, +eight partitions, and one targeted repair. When screening includes every retained cell, these +are all-cell comparisons; their exact artifacts can be reused for final validation. The +additional-validation allowance is not a cap on all graphs built during all-cell comparisons. They count distinct admitted work, including failed attempts. Reuse of a complete exact artifact does not spend another slot. These limits do not promise an elapsed time: ingest, QC, diagnostics, markers, and one final UMAP also have costs. @@ -495,8 +667,9 @@ markers, and one final UMAP also have costs. One orchestration history owns the request, evidence, decisions, and final artifact references. An identical call reuses a completed result or resumes matching interrupted work. Changed data, metadata roles, model identity, or configuration cannot silently reinterpret that history. Older -agent runs with the previous saved-state contract must be restarted; their numerical artifacts -remain readable through the ordinary Scarf APIs. +agent runs without the mandatory study and comparison evidence must be restarted; they cannot +resume or regenerate a report under this contract. Their historical HTML remains available, and +their numerical artifacts remain readable through the ordinary Scarf APIs. ## Failure handling and advanced control diff --git a/scarf/agent/experimental_context/agent.py b/scarf/agent/experimental_context/agent.py index 82f9aa53..06d2744b 100644 --- a/scarf/agent/experimental_context/agent.py +++ b/scarf/agent/experimental_context/agent.py @@ -91,6 +91,22 @@ def __init__( missingness, replication, and sparse strata; do not turn these into negative findings. Explain unsupported requested comparisons in the final rationale; a proposed comparison is not a completed analysis. + Set each proposal's purpose to designCoverage for measured counts, + crossing, replication, or pairing; association when an association + coefficient is essential; effectEstimation for an explicitly requested + biological effect, which this workflow cannot deliver. Copy an exact + objectiveQuote from the supplied study text and mark explicit objective + questions essential. Do not downgrade an essential association or effect + question to descriptive coverage to obtain completion. The returned + evidenceRequirements and evidenceCoverage enforce this distinction. + For repeated donors and incomplete pairing, inspect descriptiveDesign: + it retains observation counts, distinct donors, group support and paired + coverage without collapsing a donor to its first condition. Unsupported + association methods do not establish non-identifiability. Only measured + rank and estimability for the exact tested design support that claim. + Use the single follow-up round to resolve missing design evidence. + If essential evidence remains unsupported, ask for clarification or + abstain. Optional questions must remain explicit limitations. Continuous conditioning and expression hypothesis testing are unsupported. You may call score_current_representation at most once when an exact supplied graph diff --git a/scarf/agent/experimental_context/comparisons.py b/scarf/agent/experimental_context/comparisons.py index c4082beb..d5ee77f2 100644 --- a/scarf/agent/experimental_context/comparisons.py +++ b/scarf/agent/experimental_context/comparisons.py @@ -11,6 +11,7 @@ from ...metrics.association import association_pair, coefficient_estimability from .. import record_io +from .characterization import _json_scalar, _paired_coverage from .contracts import ( CaptureProposal, CovariateCharacterization, @@ -22,6 +23,7 @@ DESIGN_ROUND_LIMITS = (8, 4) MAX_COMBINATIONS = 32 MAX_STRATA = 16 +MAX_DESCRIPTIVE_ROWS = 128 def combination_labels(cells: Any, columns: Sequence[str]) -> np.ndarray: @@ -76,6 +78,97 @@ def _association( ) +def _descriptive_design( + design: pd.DataFrame, + *, + columns: Sequence[str], + independent: str, + kinds: dict[str, Any], +) -> dict[str, Any]: + """Count observations and distinct units without treating repeats as replicates.""" + categorical = list( + dict.fromkeys(name for name in columns if kinds[name] == "categorical") + ) + output: dict[str, Any] = { + "status": "computed", + "observationUnits": len(design), + "independentUnits": int(design[independent].nunique()), + "independentUnit": independent, + "interpretation": ( + "Descriptive counts only. A unit can occur in multiple groups; group " + "memberships are not disjoint independent replicates or effect estimates." + ), + "groupSupport": {}, + "pairedCoverage": {}, + "sharedIndependentUnits": {}, + } + if any(design[name].nunique() > MAX_COMBINATIONS for name in categorical): + output.update(status="unsupported", reason="moreThanThirtyTwoCategoricalLevels") + return output + for name in categorical: + rows: list[dict[str, Any]] = [] + memberships: list[tuple[Any, set[Any]]] = [] + for label, subset in design.groupby(name, sort=False, observed=True): + memberships.append((_json_scalar(label), set(subset[independent]))) + rows.append( + { + "group": _json_scalar(label), + "observationUnits": len(subset), + "independentUnits": int(subset[independent].nunique()), + } + ) + output["groupSupport"][name] = rows + overlap = [ + { + "groups": [left, right], + "independentUnits": len(left_units & right_units), + } + for index, (left, left_units) in enumerate(memberships) + for right, right_units in memberships[index + 1 :] + ] + output["sharedIndependentUnits"][name] = { + "pairs": overlap[:MAX_DESCRIPTIVE_ROWS], + "truncated": len(overlap) > MAX_DESCRIPTIVE_ROWS, + } + if name != independent and len(rows) >= 2: + output["pairedCoverage"][name] = _paired_coverage( + design, + coefficient=name, + pair_by=independent, + group_order=list(design[name].unique()), + ) + if categorical: + groups = design.groupby(categorical, sort=False, observed=True) + if groups.ngroups > MAX_DESCRIPTIVE_ROWS: + output.update(status="unsupported", reason="moreThan128DescriptiveGroups") + else: + rows = [] + for labels, subset in groups: + if not isinstance(labels, tuple): + labels = (labels,) + rows.append( + { + "groups": dict( + zip(categorical, map(_json_scalar, labels), strict=True) + ), + "observationUnits": len(subset), + "independentUnits": int(subset[independent].nunique()), + } + ) + output["jointGroupSupport"] = rows + output["continuousSummaries"] = { + name: { + "minimum": float(design[name].min()), + "median": float(design[name].median()), + "maximum": float(design[name].max()), + "unit": "observationUnit", + } + for name in columns + if kinds[name] == "continuous" + } + return output + + def compare_covariates( cells: Any, characterization: CovariateCharacterization, @@ -161,6 +254,10 @@ def compare_covariates( ) evidence["responseAggregation"] = "medianPerObservationUnit" evidence["observationUnits"] = len(design) + if not reasons: + evidence["descriptiveDesign"] = _descriptive_design( + design, columns=columns, independent=independent, kinds=kinds + ) if independent != unit: grouped_independent = design.groupby( independent, sort=False, observed=True @@ -327,6 +424,25 @@ def evaluate_proposals( raise ValueError( "Design comparison permits eight initial and four follow-up proposals" ) + for proposal in proposals: + if deps.studyObjective and ( + not proposal.objectiveQuote or "purpose" not in proposal.model_fields_set + ): + raise ValueError( + "Objective comparisons require an exact objectiveQuote and explicit purpose" + ) + if proposal.objectiveQuote and proposal.objectiveQuote not in ( + f"{deps.studyContext}\n{deps.studyObjective}" + ): + raise ValueError("Comparison objectiveQuote must copy exact study text") + if ( + proposal.objectiveQuote + and proposal.objectiveQuote in deps.studyObjective + and not proposal.essential + ): + raise ValueError( + "A question quoting the explicit study objective must remain essential" + ) deps.designRounds += 1 previous = {_proposal_key(item.proposal): item for item in deps.comparisons} records = {record["name"]: record for record in characterization.columns} @@ -342,7 +458,26 @@ def evaluate_proposals( for column, value in prior.evidence.get(recorded, {}).items() ): del previous[key] + if prior is not None and key in previous: + declared_pairs = { + ( + record.get("observationUnit"), + record.get("independentUnit") or record.get("observationUnit"), + ) + for record in characterization.coefficients + } + declared = ( + proposal.observationUnit, + proposal.independentUnit or proposal.observationUnit, + ) in declared_pairs + if ( + prior.evidence.get("unitRoles", {}).get("declaredInCharacterization") + != declared + ): + del previous[key] if key not in previous: + if prior is not None: + deps.comparisons.remove(prior) comparison = compare_covariates( deps.cells, characterization, diff --git a/scarf/agent/experimental_context/contracts.py b/scarf/agent/experimental_context/contracts.py index 5b87f9fe..3e5eaec3 100644 --- a/scarf/agent/experimental_context/contracts.py +++ b/scarf/agent/experimental_context/contracts.py @@ -53,6 +53,11 @@ class CovariateProposal(AgentDataModel): independentUnit: str | None = None rationale: str = Field(min_length=1) protectCombination: bool = False + purpose: Literal["designCoverage", "association", "effectEstimation"] = ( + "designCoverage" + ) + essential: bool = True + objectiveQuote: str = "" @model_validator(mode="after") def validate_columns(self) -> "CovariateProposal": @@ -78,6 +83,28 @@ class CovariateComparison(AgentDataModel): evidenceId: str +class DesignEvidenceRequirement(AgentDataModel): + """One objective question whose completion is checked against measured evidence.""" + + requirementId: str = Field(min_length=1) + question: str = Field(min_length=1) + objectiveQuote: str = Field(min_length=1) + kind: Literal["studyDesign", "designCoverage", "association", "effectEstimation"] + columns: list[str] = Field(default_factory=list) + observationUnit: str | None = None + independentUnit: str | None = None + essential: bool = True + + +class DesignEvidenceCoverage(AgentDataModel): + """Measured answer to a requirement, without assigning a second workflow status.""" + + requirementId: str + status: Literal["computed", "nonIdentifiable", "unsupported", "failed"] + evidenceIds: list[str] = Field(default_factory=list) + reasons: list[str] = Field(default_factory=list) + + class CaptureProposal(AgentDataModel): """An exact capture column and optional references supported by study prose.""" @@ -607,6 +634,8 @@ class CovariateEvidence(AgentDataModel): htoIdentityColumns: list[str] = Field(default_factory=list) htoIdentityArtifacts: list[NamedArtifactSource] = Field(default_factory=list) evidenceIds: list[str] = Field(default_factory=list) + evidenceRequirements: list[DesignEvidenceRequirement] = Field(default_factory=list) + evidenceCoverage: list[DesignEvidenceCoverage] = Field(default_factory=list) class ExperimentalContextResult(AgentDataModel): diff --git a/scarf/agent/experimental_context/qc_evidence.py b/scarf/agent/experimental_context/qc_evidence.py index 02669372..b6e9051f 100644 --- a/scarf/agent/experimental_context/qc_evidence.py +++ b/scarf/agent/experimental_context/qc_evidence.py @@ -382,6 +382,77 @@ def _qc_metric_sources( source for source in sources if source.sourceType == "metadataColumn" ] artifact_sources = [source for source in sources if source.sourceType == "artifact"] + # An imported percentage has no frozen gene definition. Keep it visible for + # comparison, but let an exactly defined derived metric own filtering. + canonical_roles: set[QcMetricRole] = set() + if ( + driver[1] == "RNA" + and callable(getattr(deps.store, "get_assay", None)) + and callable(getattr(deps.store, "load_artifact", None)) + ): + expected_masks = { + role: mask + for role, _, _, mask in _rna_percentage_feature_masks( + deps.store, assay_name + ) + } + for metric_source in artifact_sources: + if metric_source.provenanceOperation != "run_feature_percentage": + continue + for reference in metric_source.inputArtifacts: + if ( + reference.kind != "feature_selection" + or reference.assay != assay_name + ): + continue + expected = expected_masks.get(metric_source.metricRole) + actual = np.asarray( + deps.store.load_artifact(core_artifact_reference(reference))[ + "values" + ][:], + dtype=bool, + ) + if expected is not None and np.array_equal(expected, actual): + canonical_roles.add(metric_source.metricRole) + for metric_source in metadata_sources: + if metric_source.metricRole not in {"mitochondrial", "ribosomal"}: + continue + metric_source.usableForFiltering = False + note = ( + f"Imported {metric_source.metricName} is retained for comparison; filtering " + "uses the derived percentage with an exact gene selection." + if metric_source.metricRole in canonical_roles + else f"Imported {metric_source.metricName} has no validated gene definition " + "and cannot drive filtering. This percentage QC axis remains unavailable." + ) + metric_source.notes.append(note) + notes.append(note) + values_by_execution_name.pop(metric_source.executionName, None) + if metric_source.metadataColumn in valid_metadata: + valid_metadata.remove(metric_source.metadataColumn) + for metric_source in artifact_sources: + if metric_source.artifact is None: + continue + execution_name = qc_metric_execution_name( + metric_source.metricName, + artifact_id=metric_source.artifact.artifactId, + collides_with_metadata=metric_source.metricName in valid_metadata, + ) + if execution_name != metric_source.executionName: + if metric_source.executionName in values_by_execution_name: + values_by_execution_name[execution_name] = ( + values_by_execution_name.pop(metric_source.executionName) + ) + metric_source.executionName = execution_name + for role in expected_masks: + if not any( + metric_source.metricRole == role and metric_source.usableForFiltering + for metric_source in artifact_sources + ): + notes.append( + f"The {role} percentage has no exact usable artifact; " + "QC conclusions cannot claim that this axis was evaluated." + ) for left in metadata_sources: for right in artifact_sources: if left.metricRole != right.metricRole or left.metricRole == "diagnostic": @@ -466,6 +537,35 @@ def _qc_attributes(store: Any, assay_name: str, assay_type: str) -> list[str]: ] +def _rna_percentage_feature_masks( + store: Any, assay_name: str +) -> list[tuple[QcMetricRole, str, str, np.ndarray]]: + """Resolve symbol-defined RNA percentages without the ambiguous MT prefix.""" + assay = store.get_assay(assay_name) + feature_ids = np.asarray(assay.feats.fetch_all("ids")).astype(str) + feature_names = np.asarray(assay.feats.fetch_all("names")).astype(str) + specifications: tuple[tuple[QcMetricRole, str, str], ...] = ( + ("mitochondrial", "percentMito", r"(?i)^MT-"), + ("ribosomal", "percentRibo", r"(?i)^(RPS|RPL|MRPS|MRPL)"), + ) + resolved = [] + for role, suffix, pattern in specifications: + compiled = re.compile(pattern) + mask = np.fromiter( + ( + compiled.search(feature_id) is not None + or compiled.search(feature_name) is not None + for feature_id, feature_name in zip( + feature_ids, feature_names, strict=True + ) + ), + dtype=bool, + count=assay.feats.N, + ) + resolved.append((role, suffix, pattern, mask)) + return resolved + + def _derive_missing_percentage_artifacts( store: Any, *, @@ -473,7 +573,7 @@ def _derive_missing_percentage_artifacts( driver: tuple[str, CellQcDriverType] | None, quality_sources: Sequence[NamedArtifactSource], ) -> list[NamedArtifactSource]: - """Derive missing RNA percentage metrics through public immutable APIs.""" + """Derive RNA percentage artifacts even when unbound metadata is present.""" sources = list(quality_sources) if driver is None or driver[1] != "RNA": return sources @@ -482,44 +582,16 @@ def _derive_missing_percentage_artifacts( ): return sources assay_name = driver[0] - available_metadata = set(store.cells.columns) supplied_roles = { registered_qc_metric_role(source.name) for source in sources if source.artifact.assay == assay_name } - assay = store.get_assay(assay_name) - feature_ids = np.asarray(assay.feats.fetch_all("ids")).astype(str) - feature_names = np.asarray(assay.feats.fetch_all("names")).astype(str) - specifications: tuple[ - tuple[QcMetricRole, str, re.Pattern[str]], - ..., - ] = ( - ("mitochondrial", "percentMito", re.compile(r"^(MT-|mt-)")), - ( - "ribosomal", - "percentRibo", - re.compile(r"^(RPS|RPL|MRPS|MRPL|Rps|Rpl|Mrps|Mrpl)"), - ), - ) existing_names = {source.name for source in sources} - for role, suffix, pattern in specifications: + for role, suffix, _, mask in _rna_percentage_feature_masks(store, assay_name): metric_name = f"{assay_name}_{suffix}" - if metric_name in available_metadata or role in supplied_roles: + if role in supplied_roles: continue - mask = np.fromiter( - ( - pattern.search(feature_id) is not None - or pattern.search(feature_name) is not None - for feature_id, feature_name in zip( - feature_ids, - feature_names, - strict=True, - ) - ), - dtype=bool, - count=assay.feats.N, - ) if not mask.any(): continue if metric_name in existing_names: diff --git a/scarf/agent/experimental_context/requirements.py b/scarf/agent/experimental_context/requirements.py new file mode 100644 index 00000000..71b33d9c --- /dev/null +++ b/scarf/agent/experimental_context/requirements.py @@ -0,0 +1,223 @@ +"""Objective evidence requirements derived from bounded, measured study designs.""" + +import hashlib +import re +from typing import Any + +from ..record_io import canonical_json_bytes +from .contracts import ( + DesignEvidenceCoverage, + DesignEvidenceRequirement, + characterization_evidence, +) + + +def objective_evidence( + *, study_context: str, study_objective: str, experimental_result: Any +) -> tuple[list[DesignEvidenceRequirement], list[DesignEvidenceCoverage]]: + """Require design coverage and retain the purpose of every proposed question.""" + result = experimental_result + characterization = result.characterization + decision = result.decision + records = {item["name"]: item for item in characterization.columns} + coefficients = {item["name"]: item for item in characterization.coefficients} + mentioned = { + name + for name, item in records.items() + if item.get("domain") == "biological" + and re.search(r"(?= 2: + paired = record.get("pairedCoverage", {}) + if not paired: + unavailable.append(f"{name}: repeated-unit coverage is unavailable") + elif paired.get("design") == "mixedOrIncomplete": + reasons.append( + f"{name}: measured pairing is mixed or incomplete; no paired effect is estimated" + ) + if batch_columns: + for name in conditions: + matched = [ + item + for item in result.batchSafety + if item.coefficient == name + and sorted(item.batchColumns) == batch_columns + ] + if not matched or any(item.status == "notComputed" for item in matched): + unavailable.append( + f"{name}: estimability for the complete exact batch set is unavailable" + ) + elif any(item.status == "unsafe" for item in matched): + non_identifiable = True + reasons.append( + f"{name}: not identifiable under the exact assessed batch design" + ) + if not conditions: + reasons.append("No coefficient-level effect inference is authorized") + if not batch_columns: + reasons.append("No technical batch correction is proposed") + if characterization.captureProvenance is None: + reasons.append( + "Physical capture identity is unresolved; capture-dependent decisions are not authorized" + ) + coverage = [ + DesignEvidenceCoverage( + requirementId="studyDesign", + status=( + "failed" + if characterization.status == "failed" + else "unsupported" + if unavailable + else "nonIdentifiable" + if non_identifiable + else "computed" + ), + evidenceIds=sorted(evidence_ids), + reasons=[*unavailable, *reasons], + ) + ] + seen: set[str] = set() + for comparison in characterization.comparisons: + proposal = comparison.proposal + identity = hashlib.sha256( + canonical_json_bytes(proposal.model_dump(exclude={"rationale"})) + ).hexdigest() + requirement_id = f"designQuestion:{identity}" + if requirement_id in seen: + raise ValueError("Objective comparisons must have unique current proposals") + seen.add(requirement_id) + quote = proposal.objectiveQuote or study_objective + if quote not in f"{study_context}\n{study_objective}": + raise ValueError( + "Objective evidence requirements must quote exact study text" + ) + if ( + proposal.objectiveQuote + and quote in study_objective + and not proposal.essential + ): + raise ValueError( + "A question quoting the explicit study objective must remain essential" + ) + requirements.append( + DesignEvidenceRequirement( + requirementId=requirement_id, + question=proposal.rationale, + objectiveQuote=quote, + kind=proposal.purpose, + columns=[ + proposal.response, + *proposal.explanatoryColumns, + *([proposal.conditionedOn] if proposal.conditionedOn else []), + ], + observationUnit=proposal.observationUnit, + independentUnit=proposal.independentUnit or proposal.observationUnit, + essential=proposal.essential, + ) + ) + descriptive = comparison.evidence.get("descriptiveDesign", {}) + computed = comparison.status == "computed" + answer_reasons = list(comparison.reasons) + if proposal.purpose == "designCoverage": + computed = descriptive.get("status") == "computed" + if computed and comparison.status == "unsupported": + answer_reasons.append( + "Descriptive support answers the design question; the association method remains unsupported" + ) + elif proposal.purpose == "effectEstimation": + computed = False + answer_reasons.append( + "This workflow does not estimate biological effects or test expression hypotheses" + ) + if any( + records.get(column, {}).get(field) != value + for field, recorded in ( + ("kind", "columnKinds"), + ("domain", "columnDomains"), + ) + for column, value in comparison.evidence.get(recorded, {}).items() + ): + computed = False + answer_reasons.append( + "Comparison evidence has different column roles or kinds from the final design" + ) + coverage.append( + DesignEvidenceCoverage( + requirementId=requirement_id, + status="computed" if computed else "unsupported", + evidenceIds=[comparison.evidenceId], + reasons=answer_reasons, + ) + ) + if len(requirements) > 13: + raise ValueError( + "Objective requirements permit one design summary and eight plus four questions" + ) + return requirements, coverage + + +def unmet_objective_requirements( + requirements: list[DesignEvidenceRequirement], + coverage: list[DesignEvidenceCoverage], +) -> list[str]: + """Return essential questions with no measured answer of the required kind.""" + measured = {item.requirementId: item for item in coverage} + unmet = [] + for requirement in requirements: + item = measured.get(requirement.requirementId) + satisfied = item is not None and ( + item.status == "computed" + or item.status == "nonIdentifiable" + and requirement.kind in {"studyDesign", "designCoverage"} + ) + if requirement.essential and not satisfied: + reasons = ( + "; ".join(item.reasons) if item is not None else "evidence is missing" + ) + unmet.append(f"{requirement.question} Unresolved: {reasons}") + return unmet diff --git a/scarf/agent/experimental_context/study.py b/scarf/agent/experimental_context/study.py index e3ab1bae..7bd9c014 100644 --- a/scarf/agent/experimental_context/study.py +++ b/scarf/agent/experimental_context/study.py @@ -6,7 +6,12 @@ from pydantic import Field, model_validator from ..types import AgentDataModel -from .contracts import CovariateComparison +from .contracts import ( + CovariateComparison, + DesignEvidenceCoverage, + DesignEvidenceRequirement, +) +from .requirements import objective_evidence, unmet_objective_requirements type AuthorLabelPolicy = Literal["holdout", "preservation"] type ProcessingGoal = Literal[ @@ -45,6 +50,10 @@ class StudyContract(AgentDataModel): unsupportedClaims: list[str] = Field(default_factory=list) evidenceIds: list[str] = Field(default_factory=list) limitations: list[str] = Field(default_factory=list) + evidenceRequirements: list[DesignEvidenceRequirement] = Field( + min_length=1, max_length=13 + ) + evidenceCoverage: list[DesignEvidenceCoverage] = Field(min_length=1, max_length=13) @model_validator(mode="after") def validate_contract(self) -> "StudyContract": @@ -77,11 +86,65 @@ def validate_contract(self) -> "StudyContract": raise ValueError( "Protected combinations require two distinct protected columns" ) + requirements = {item.requirementId: item for item in self.evidenceRequirements} + coverage = {item.requirementId: item for item in self.evidenceCoverage} + if ( + len(requirements) != len(self.evidenceRequirements) + or len(coverage) != len(self.evidenceCoverage) + or requirements.keys() != coverage.keys() + ): + raise ValueError( + "Objective requirements and measured coverage must match uniquely" + ) + mandatory = requirements.get("studyDesign") + if ( + mandatory is None + or mandatory.kind != "studyDesign" + or not mandatory.essential + ): + raise ValueError( + "The essential measured studyDesign requirement cannot be omitted" + ) + for item in self.evidenceRequirements: + if item.objectiveQuote not in f"{self.studyContext}\n{self.studyObjective}": + raise ValueError("Objective requirements must quote exact study text") + if any( + set(item.evidenceIds) - set(self.evidenceIds) + for item in self.evidenceCoverage + ): + raise ValueError( + "Objective coverage cites evidence outside the study contract" + ) + if any( + item.status in {"computed", "nonIdentifiable"} and not item.evidenceIds + for item in self.evidenceCoverage + ): + raise ValueError( + "Computed objective coverage requires measured evidence IDs" + ) return self @classmethod def get_blank(cls) -> "StudyContract": - return cls(studyContext="Study context", studyObjective="Study objective") + return cls( + studyContext="Study context", + studyObjective="Study objective", + evidenceRequirements=[ + DesignEvidenceRequirement( + requirementId="studyDesign", + question="Resolve study design", + objectiveQuote="Study objective", + kind="studyDesign", + ) + ], + evidenceCoverage=[ + DesignEvidenceCoverage( + requirementId="studyDesign", + status="unsupported", + reasons=["Study evidence is unavailable"], + ) + ], + ) def _unique(values: Iterable[str | None]) -> list[str]: @@ -108,6 +171,34 @@ def unsupported_comparison_limitations( return limitations +def validate_objective_evidence( + contract: StudyContract, experimental_result: Any | None = None +) -> None: + """Reject unresolved essential questions and mismatched saved measured coverage.""" + # model_copy can bypass Pydantic validators; authority checks cannot. + StudyContract.model_validate(contract.model_dump(mode="json")) + if experimental_result is not None: + requirements, coverage = objective_evidence( + study_context=contract.studyContext, + study_objective=contract.studyObjective, + experimental_result=experimental_result, + ) + if ( + requirements != contract.evidenceRequirements + or coverage != contract.evidenceCoverage + ): + raise ValueError( + "Study objective evidence differs from its measured context report" + ) + unmet = unmet_objective_requirements( + contract.evidenceRequirements, contract.evidenceCoverage + ) + if unmet: + raise ValueError( + "Essential objective evidence is unresolved: " + " | ".join(unmet) + ) + + def build_study_contract( *, study_context: str, @@ -174,8 +265,20 @@ def build_study_contract( *(item.evidenceId for item in experimental_result.batchSafety), ] ) + requirements, coverage = objective_evidence( + study_context=study_context, + study_objective=study_objective, + experimental_result=experimental_result, + ) + evidence_ids = _unique( + [ + *evidence_ids, + *(evidence_id for item in coverage for evidence_id in item.evidenceIds), + ] + ) limitations = [ *experimental_result.notes, + *(reason for item in coverage for reason in item.reasons), *unsupported_comparison_limitations( experimental_result.characterization.comparisons ), @@ -221,6 +324,8 @@ def build_study_contract( ], evidenceIds=evidence_ids, limitations=limitations, + evidenceRequirements=requirements, + evidenceCoverage=coverage, ) @@ -229,4 +334,5 @@ def build_study_contract( "ProcessingGoal", "StudyContract", "build_study_contract", + "validate_objective_evidence", ] diff --git a/scarf/agent/experimental_context/tools.py b/scarf/agent/experimental_context/tools.py index 99956bd7..93fc58f2 100644 --- a/scarf/agent/experimental_context/tools.py +++ b/scarf/agent/experimental_context/tools.py @@ -2,6 +2,7 @@ import math from collections.abc import Sequence +from types import SimpleNamespace from typing import Any from ...metadata.queries import reduce_observation_units @@ -26,6 +27,8 @@ CovariateCharacterization, CovariateEvidence, CovariateProposal, + BatchCorrectionPlan, + ExperimentalContextDecision, ExperimentalContextDependencies, InferenceUnit, RepresentationEvaluation, @@ -37,6 +40,7 @@ _hto_identity_columns, _offered_qc_profiles, ) +from .requirements import objective_evidence try: from pydantic_ai import ModelRetry, RunContext @@ -636,9 +640,30 @@ async def analyze_experimental_design( f"batchSafetyNotComputed={safety_counts['notComputed']}, " f"qcProfiles={len(qc_profiles)}, evidence={len(evidence_ids)}" ) + requirements, coverage = ( + objective_evidence( + study_context=ctx.deps.studyContext, + study_objective=ctx.deps.studyObjective, + experimental_result=SimpleNamespace( + characterization=characterization, + decision=ExperimentalContextDecision( + coefficientsOfInterest=directed_coefficients, + batchCorrection=BatchCorrectionPlan( + action="needsInput", + batchColumns=canonical_batch_columns, + ), + ), + batchSafety=batch_safety, + ), + ) + if ctx.deps.studyObjective + else ([], []) + ) return CovariateEvidence( characterization=characterization, batchSafety=batch_safety, + evidenceRequirements=requirements, + evidenceCoverage=coverage, qcProfiles=qc_profiles, qcMetricSources=ctx.deps.qcMetricSources, qcSourceConcordance=ctx.deps.qcSourceConcordance, diff --git a/scarf/agent/experimental_context/validation.py b/scarf/agent/experimental_context/validation.py index 3facf82e..32725a38 100644 --- a/scarf/agent/experimental_context/validation.py +++ b/scarf/agent/experimental_context/validation.py @@ -1,5 +1,6 @@ """Experimental-context canonicalization and explicit model failures.""" +from types import SimpleNamespace from typing import Any from ...utils.logging import logger @@ -22,6 +23,7 @@ _offered_qc_profiles, ) from .tools import contrast_plans_from_characterization +from .requirements import objective_evidence, unmet_objective_requirements try: from pydantic_ai import ModelRetry @@ -392,6 +394,25 @@ def validate_experimental_context( **design_choices, } ) + if deps.studyObjective: + requirements, coverage = objective_evidence( + study_context=deps.studyContext, + study_objective=deps.studyObjective, + experimental_result=SimpleNamespace( + decision=validated, + characterization=characterization, + batchSafety=list(deps.batchSafety.values()), + ), + ) + unanswered = unmet_objective_requirements(requirements, coverage) + if unanswered: + validated = validated.model_copy( + update={ + "needsInput": list( + dict.fromkeys([*validated.needsInput, *unanswered]) + ), + } + ) logger.debug( "Experimental Context decision validated: " f"domains={len(validated.columnDomains)}, " diff --git a/scarf/agent/orchestrator/budget.py b/scarf/agent/orchestrator/budget.py index 5bd64ab4..9fe9370c 100644 --- a/scarf/agent/orchestrator/budget.py +++ b/scarf/agent/orchestrator/budget.py @@ -157,6 +157,19 @@ def completed(self, admission: dict[str, Any]) -> dict[str, Any] | None: inputs=admission, ) + def completed_source( + self, inputs: dict[str, Any] + ) -> tuple[dict[str, Any], dict[str, Any]] | None: + """Find exact completed evidence before charging additional validation work.""" + identity = candidate_identity(inputs) + for rows in self.admissions.values(): + for admission in rows: + if admission["identity"] == identity: + completed = self.completed(admission) + if completed is not None: + return admission, completed + return None + def complete(self, admission: dict[str, Any], output: dict[str, Any]) -> None: journal.save_checkpoint( self.store, diff --git a/scarf/agent/orchestrator/context.py b/scarf/agent/orchestrator/context.py index 4d5a6e14..76cfe38a 100644 --- a/scarf/agent/orchestrator/context.py +++ b/scarf/agent/orchestrator/context.py @@ -1,11 +1,8 @@ """Ingest, RNA enrichment, quality metrics, and experimental-context stages.""" -import re from collections.abc import Mapping, Sequence from typing import Any, cast -import numpy as np - from ...datastore.datastore import DataStore from ...utils.logging import logger from ..data_enrichment.agent import DataEnrichmentAgent @@ -18,7 +15,11 @@ ExperimentalContextResult, NamedArtifactSource, ) -from ..experimental_context.study import build_study_contract +from ..experimental_context.study import ( + StudyContract, + build_study_contract, + validate_objective_evidence, +) from ..ingest import IngestResult from ..ingest.manifest import DatasetManifest, is_author_label_column from ..types import AgentRunInfo, ArtifactReferenceModel @@ -414,6 +415,11 @@ def _rna_quality_metrics_stage( parents, ) if existing is not None: + if "percentageDefinitions" not in existing.outputs: + raise ValueError( + "Saved RNA quality metrics lack exact percentage definitions; " + "start a new workflow. Existing analysis artifacts remain accessible." + ) self._named_stage_artifacts( existing, "qualityMetricArtifacts", @@ -455,105 +461,52 @@ def _rna_quality_metrics_stage( } artifacts: dict[str, ArtifactReferenceModel] = {"cellSelection": cell_selection} try: - inspections = {value.assay: value for value in enrichment.inspections} - for policy in enrichment.policies: - if policy.assayModality == "RNA": - inspection = inspections.get(policy.assay) - observed_families = ( - { - value.family - for value in inspection.families - if value.count > 0 - } - if inspection is not None - else set() - ) - assay = store.get_assay(policy.assay) - feature_ids = np.asarray(assay.feats.fetch_all("ids")).astype(str) - feature_names = np.asarray(assay.feats.fetch_all("names")).astype( - str - ) - family_patterns = ( - ( - "mitochondrial", - r"^(MT-|mt-)", - "percentMito", - "percent_mito", - ), - ( - "ribosomal", - r"^(RPS|RPL|MRPS|MRPL|Rps|Rpl|Mrps|Mrpl)", - "percentRibo", - "percent_ribo", - ), + from ..experimental_context.qc_evidence import _rna_percentage_feature_masks + + definitions = [] + for family, suffix, pattern, mask in _rna_percentage_feature_masks( + store, selected + ): + definition = { + "family": family, + "pattern": pattern, + "matchedGenes": int(mask.sum()), + } + definitions.append(definition) + if not mask.any(): + definition["limitation"] = ( + "No genes match this symbol-based percentage definition" ) - for ( - family, - pattern, - artifact_suffix, - action_suffix, - ) in family_patterns: - if family not in observed_families: - continue - compiled = re.compile(pattern) - mask = np.fromiter( - ( - compiled.search(feature_id) is not None - or compiled.search(feature_name) is not None - for feature_id, feature_name in zip( - feature_ids, - feature_names, - strict=True, - ) - ), - dtype=bool, - count=assay.feats.N, - ) - if not mask.any(): - continue - features_ref = store.set_feature_selection( - from_assay=policy.assay, - mask=mask, - invalidate_cache=False, - ) - metric_ref = store.run_feature_percentage( - cell_selection_ref, - features_ref, - invalidate_cache=False, - ) - features_model = ArtifactReferenceModel.from_artifact_ref( - features_ref - ) - metric_model = ArtifactReferenceModel.from_artifact_ref( - metric_ref - ) - artifact_name = f"{policy.assay}_{artifact_suffix}" - if artifact_name in artifacts: - raise ValueError( - f"Duplicate generated artifact name {artifact_name!r}" - ) - source = NamedArtifactSource( - name=artifact_name, - artifact=metric_model, - ) - artifacts[f"{artifact_name}_features"] = features_model - artifacts[artifact_name] = metric_model - cast( - list[dict[str, Any]], - outputs["qualityMetricArtifacts"], - ).append(source.model_dump(mode="json")) - cast(list[dict[str, Any]], outputs["operations"]).append( - { - "operation": "run_feature_percentage", - "assay": policy.assay, - "family": family, - "pattern": pattern, - "cellSelection": cell_selection.model_dump(mode="json"), - "features": features_model.model_dump(mode="json"), - "artifact": metric_model.model_dump(mode="json"), - } - ) - actions.append(f"compute_{action_suffix}:{policy.assay}") + continue + features_ref = store.set_feature_selection( + from_assay=selected, mask=mask, invalidate_cache=False + ) + metric_ref = store.run_feature_percentage( + cell_selection_ref, features_ref, invalidate_cache=False + ) + features_model = ArtifactReferenceModel.from_artifact_ref(features_ref) + metric_model = ArtifactReferenceModel.from_artifact_ref(metric_ref) + artifact_name = f"{selected}_{suffix}" + source = NamedArtifactSource(name=artifact_name, artifact=metric_model) + artifacts[f"{artifact_name}_features"] = features_model + artifacts[artifact_name] = metric_model + cast(list[dict[str, Any]], outputs["qualityMetricArtifacts"]).append( + source.model_dump(mode="json") + ) + cast(list[dict[str, Any]], outputs["operations"]).append( + { + "operation": "run_feature_percentage", + "assay": selected, + **definition, + "cellSelection": cell_selection.model_dump(mode="json"), + "features": features_model.model_dump(mode="json"), + "artifact": metric_model.model_dump(mode="json"), + } + ) + actions.append( + f"compute_{'percent_mito' if family == 'mitochondrial' else 'percent_ribo'}:{selected}" + ) + outputs["percentageDefinitions"] = definitions outcome = journal._complete_attempt( started, status="done", @@ -619,6 +572,10 @@ def experimental_context_stage( ) resolved_report = cast(ExperimentalContextResult, report) validate_rna_context(resolved_report, selected) + validate_objective_evidence( + StudyContract.model_validate(existing.outputs.get("studyContract")), + resolved_report, + ) if existing.artifacts != context_artifacts: raise ValueError( "Persisted Experimental Context stage artifacts are stale" @@ -976,6 +933,35 @@ def find_held_out_references(value: Any) -> None: physical_capture_column=report.decision.physicalCaptureColumn or physical_capture, ) + try: + validate_objective_evidence(study_contract, report) + except ValueError as exc: + unattended = request_record.config.inputPolicy == "unattended" + outcome = journal._complete_attempt( + started, + status="failed" if unattended else "needsInput", + report_references=[reference], + artifacts=context_artifacts, + outputs={ + "studyContract": study_contract.model_dump(mode="json") + }, + actions=actions, + error=str(exc) if unattended else None, + needs_input=None + if unattended + else WorkflowNeedsInput( + questions=[ + WorkflowQuestion( + questionId="experimentalDirections", + question=str(exc), + evidenceIds=list(study_contract.evidenceIds), + ) + ] + ), + notes=[*report.notes, str(exc)], + ) + journal._save_outcome(store.zw, prefix, outcome) + return outcome, report outcome = journal._complete_attempt( started, status="done", diff --git a/scarf/agent/orchestrator/decisions.py b/scarf/agent/orchestrator/decisions.py index e934bd5f..ead54051 100644 --- a/scarf/agent/orchestrator/decisions.py +++ b/scarf/agent/orchestrator/decisions.py @@ -250,7 +250,11 @@ def _resolve_rna_decision( system_prompt=( "Assess the offered settings against the study objective using the observed quantitative and qualitative evidence. " "The objective identifies questions and biology to protect; it does not predetermine the answer. " - "Select only an offered option and cite its required evidence. Explain the scientific consequence. " + "Select only an offered option and cite its required evidence. " + "Write the rationale as two or three plain-language sentences for the analysis report: " + "state the chosen outcome, the relevant measured comparison, and its scientific tradeoff or limitation. " + "Use readable study and measurement names. Keep option identifiers, artifact identifiers and " + "internal field names out of the rationale; cite identifiers in evidenceIds instead. " "Do not infer nuisance from gene-family names alone. Retain defaults only when evidence supports them. " "Defer essential unresolved questions. Model failure or a work limit never justifies an unsupported choice. " "For QC, distinguish retained group coverage from preserved biological structure: " diff --git a/scarf/agent/orchestrator/journal.py b/scarf/agent/orchestrator/journal.py index 28dee0c6..5f9fdc2b 100644 --- a/scarf/agent/orchestrator/journal.py +++ b/scarf/agent/orchestrator/journal.py @@ -994,10 +994,22 @@ def _analysis_review_views( "evidenceMode": mode, "visualInspection": inspection, **entry["review"], + "coverage": inputs.get("coverage", {}), + "comparisonCoverage": inputs.get("comparisonCoverage"), + "populationSupport": inputs.get("assessmentContext", {}).get( + "populationSupport", {} + ), + "harmonyGates": inputs.get("harmonyGates", {}), "candidates": [ { name: item[name] - for name in ("candidateId", "parameters", "metrics") + for name in ( + "candidateId", + "parameters", + "metrics", + "cellSelection", + "artifacts", + ) } for item in candidates ], diff --git a/scarf/agent/orchestrator/main.py b/scarf/agent/orchestrator/main.py index c1dc0101..0f1e8078 100644 --- a/scarf/agent/orchestrator/main.py +++ b/scarf/agent/orchestrator/main.py @@ -13,7 +13,7 @@ from ...datastore.summary import summarize_zarr_readonly from ...utils.logging import logger from .. import record_io -from ..experimental_context.study import StudyContract +from ..experimental_context.study import StudyContract, validate_objective_evidence from ..ingest import IngestResult, detect_format, ingest from ..ingest.manifest import DatasetManifest, inspect_h5ad_manifest from . import journal @@ -799,6 +799,7 @@ def _execute_stages( study_contract = StudyContract.model_validate( context_outcome.outputs["studyContract"] ) + validate_objective_evidence(study_contract, experimental) parents = [journal._parent_link(context_outcome)] plan_outcome, preprocessing_plan = self.preprocessing_plan_stage( @@ -870,6 +871,7 @@ def _execute_stages( tuning_outcome, study_contract=study_contract, ) + validate_objective_evidence(study_contract, experimental) parents = [journal._parent_link(tuning_outcome)] tuning_reference = tuning_outcome.reportReferences[0] selected = next( diff --git a/scarf/agent/orchestrator/models.py b/scarf/agent/orchestrator/models.py index 9b40ad00..a94b16b4 100644 --- a/scarf/agent/orchestrator/models.py +++ b/scarf/agent/orchestrator/models.py @@ -291,8 +291,8 @@ class AutomatedWorkflowConfig(AgentDataModel): ) screeningCells: int = Field(default=50_000, ge=20) maxScreeningCells: int = Field(default=100_000, ge=20) - maxScreeningEvaluations: int = Field(default=12, ge=4) - maxTotalScreeningEvaluations: int = Field(default=24, ge=4) + maxScreeningEvaluations: int = Field(default=24, ge=4) + maxTotalScreeningEvaluations: int = Field(default=48, ge=4) maxFullGraphs: int = Field(default=4, ge=1) maxFullPartitions: int = Field(default=8, ge=1) maxFullRepairs: int = Field(default=1, ge=0, le=1) diff --git a/scarf/agent/orchestrator/rna.py b/scarf/agent/orchestrator/rna.py index c07feaad..a563699c 100644 --- a/scarf/agent/orchestrator/rna.py +++ b/scarf/agent/orchestrator/rna.py @@ -117,6 +117,7 @@ def validate_saved_rna_history( """Validate single-RNA ownership before opening resumed work for writes.""" from ..data_enrichment.contracts import DataEnrichmentReport from ..experimental_context.contracts import ExperimentalContextResult + from ..experimental_context.study import StudyContract, validate_objective_evidence from ..parameter_tuning.contracts import ParameterTuningReport from . import journal from .models import ( @@ -133,6 +134,12 @@ def validate_saved_rna_history( raise ValueError( "Saved automatic HTO processing is unsupported; start a new RNA workflow" ) + if stage == "rna_quality_metrics" and outcome.status == "done": + if "percentageDefinitions" not in outcome.outputs: + raise ValueError( + "Saved RNA quality metrics lack exact percentage definitions; " + "start a new workflow. Existing analysis artifacts remain accessible." + ) for name in ("preprocessingPlan", "resolvedPreprocessingPlan"): if outcome.outputs.get(name): validate_rna_plan( @@ -167,6 +174,12 @@ def validate_saved_rna_history( ) if context.status == "done": validate_rna_context(context, selected) + if outcome.status == "done": + raw_contract = outcome.outputs.get("studyContract", {}) + _require_objective_contract(raw_contract) + validate_objective_evidence( + StudyContract.model_validate(raw_contract), context + ) elif stage == "parameter_tuning": tuning = ParameterTuningReport.model_validate( journal.read_stage_evidence(store, outcome.reportReferences[0]) @@ -179,3 +192,54 @@ def validate_saved_rna_history( raise ValueError( "Saved tuning includes unsupported assays or integration" ) + validate_analysis_evidence(journal.analysis_snapshot(store, workflow_run_id)) + + +def _require_objective_contract(value: Any) -> None: + """Reject historical conclusions without rewriting their evidence.""" + if not isinstance(value, Mapping) or not { + "evidenceRequirements", + "evidenceCoverage", + }.issubset(value): + raise ValueError( + "Saved analysis lacks mandatory objective evidence requirements; " + "start a new workflow to resume or regenerate its report. " + "Existing analysis artifacts and historical HTML remain accessible." + ) + + +def validate_analysis_evidence(snapshot: Mapping[str, Any]) -> None: + """Check scientific completion from the authenticated journal view.""" + from ..experimental_context.contracts import ExperimentalContextResult + from ..experimental_context.study import StudyContract, validate_objective_evidence + from .rna_tuning import validate_completed_comparison_evidence + + context_validated = False + for stage in snapshot.get("stages", []): + if ( + stage.get("stage") != "experimental_context" + or stage.get("status") != "done" + ): + continue + raw_contract = stage.get("outputs", {}).get("studyContract", {}) + _require_objective_contract(raw_contract) + validate_objective_evidence( + StudyContract.model_validate(raw_contract), + ExperimentalContextResult.model_validate(stage["report"]), + ) + context_validated = True + accepted = [ + review + for review in snapshot.get("analysisReviews", []) + if review.get("action") == "accept" + ] + for review in accepted: + validate_completed_comparison_evidence(review) + if snapshot.get("status") == "completed": + if not context_validated or not any( + item.get("scope") == "full" for item in accepted + ): + raise ValueError( + "Completed analysis lacks its mandatory objective and comparison evidence; " + "start a new workflow. Existing analysis artifacts remain accessible." + ) diff --git a/scarf/agent/orchestrator/rna_tuning.py b/scarf/agent/orchestrator/rna_tuning.py index 5c174604..43dc671a 100644 --- a/scarf/agent/orchestrator/rna_tuning.py +++ b/scarf/agent/orchestrator/rna_tuning.py @@ -35,6 +35,14 @@ ParameterTuningNeedsInput, ParameterTuningReport, ) +from ..parameter_tuning.comparisons import ( + CombinedSettings, + ComparisonConclusion, + PopulationConcern, + setting_changes, + partition_comparison_evidence, + validate_comparison_review, +) from ..parameter_tuning.diagnostics import ( SCARF_DEFAULT_DIAGNOSTIC_FAMILIES, _family_mask, @@ -65,17 +73,6 @@ ) -_DOMAINS = { - "qualityControl", - "featurePolicy", - "hvgRankingAndCount", - "pca", - "batchCorrection", - "neighbors", - "partition", - "rarePopulations", -} - _STRUCTURED_VISUAL_LIMITATION = ( "The model assessed structured marker, PCA loading and diagnostic evidence; " "visual inspection was unavailable because the configured model does not accept images." @@ -98,7 +95,7 @@ def _configured_image_input(model: Any) -> bool | None: class TuningAction(AgentDataModel): """An assessment of observed evidence and at most one registered experiment.""" - action: Literal["accept", "experiment", "enlarge", "defer"] = Field( + action: Literal["accept", "combine", "experiment", "enlarge", "defer"] = Field( description=( "Accept supported observed evidence, request one next experiment, " "enlarge a screening sample, or defer an unresolved essential question." @@ -118,7 +115,6 @@ class TuningAction(AgentDataModel): ), ) correctionNeed: Literal["needed", "notNeeded", "uncertain", "notApplicable"] - assessedDomains: list[str] evidenceIds: list[str] = Field( min_length=1, description=( @@ -131,6 +127,10 @@ class TuningAction(AgentDataModel): description="Describe supplied observed measurements, not predicted results.", ) qualitativeFindings: list[str] = Field(min_length=1) + comparisonConclusions: list[ComparisonConclusion] + populationConcerns: list[PopulationConcern] = Field(default_factory=list) + plainLanguageSummary: str = Field(min_length=1) + combinedSettings: CombinedSettings | None = None concern: str = "" expectedImprovement: str = Field( default="", @@ -147,6 +147,8 @@ class TuningAction(AgentDataModel): @model_validator(mode="after") def validate_action(self) -> "TuningAction": + if (self.action == "combine") != (self.combinedSettings is not None): + raise ValueError("Only a combine action proposes combined settings") if (self.action == "experiment") != (self.experimentId is not None): raise ValueError("Only an experiment action names an experiment") if self.action == "experiment" and ( @@ -155,24 +157,26 @@ def validate_action(self) -> "TuningAction": raise ValueError( "An experiment needs an observed concern and expected improvement" ) - if self.action == "accept" and set(self.assessedDomains) != _DOMAINS: - raise ValueError( - "Acceptance requires assessment of every scientific domain" - ) return self def _assessment_output_type( - candidate_ids: Sequence[str], experiment_ids: Sequence[str], *, scope: str + candidate_ids: Sequence[str], + experiment_ids: Sequence[str], + *, + scope: str, + phase: str = "combined", ) -> type[TuningAction]: """Constrain new model choices without changing the saved action contract.""" if not candidate_ids: raise ValueError("RNA assessment requires observed candidates") actions = tuple( action - for action in ("accept", "experiment", "enlarge", "defer") + for action in ("accept", "combine", "experiment", "enlarge", "defer") if (action != "enlarge" or scope != "full") and (action != "experiment" or experiment_ids) + and (action != "combine" or phase == "sensitivity") + and (action != "accept" or phase != "sensitivity") ) return create_model( "ObservedRnaAssessment", @@ -208,6 +212,52 @@ class RnaSetting(AgentDataModel): rankingColumn: str | None = None +def validate_completed_comparison_evidence(review: Mapping[str, Any]) -> None: + """Check exact completed review coverage before finalization or report reuse.""" + if not { + "comparisonCoverage", + "comparisonConclusions", + "plainLanguageSummary", + }.issubset(review): + raise ValueError( + "Saved analysis lacks mandatory comparison evidence; start a new workflow" + ) + action = TuningAction.model_validate( + { + key: value + for key, value in review.items() + if key in TuningAction.model_fields + } + ) + coverage = review.get("comparisonCoverage") + if not isinstance(coverage, Mapping): + raise ValueError( + "Saved analysis lacks mandatory comparison evidence; start a new workflow" + ) + validate_comparison_review(coverage, action.model_dump(mode="json")) + for candidate in review.get("candidates", []): + setting = coverage["candidateSettings"].get(candidate["candidateId"]) + if ( + setting is None + or any( + setting[key] != candidate[key] + for key in ("parameters", "cellSelection") + ) + or setting["features"] + != candidate.get("artifacts", {}).get("graphFeatures") + ): + raise ValueError( + "Comparison evidence differs from the exact reviewed candidate" + ) + if any( + candidate["metrics"].get(key) != value + for key, value in setting.get("metrics", {}).items() + ): + raise ValueError( + "Comparison measurements differ from the reviewed candidate" + ) + + def uniform_screening_selection( store: Any, parent: ArtifactRef, *, size: int, seed: int ) -> ArtifactRef: @@ -366,6 +416,12 @@ def __init__( self.scope_sizes: dict[str, int] = {} self.feature_evidence_cache: dict[str, dict[str, Any]] = {} self.neighbor_comparisons: dict[tuple[str, str], float] = {} + self.comparison_rows: dict[str, list[dict[str, Any]]] = {} + self.combined_candidates: dict[str, str] = {} + self.resolution_candidates: dict[str, list[str]] = {} + self.validation_sources: dict[str, dict[str, Any]] = {} + self.discovery_scope: str | None = None + self.full_repair: dict[str, Any] | None = None self.batch_columns = list(study.technicalBatchColumns) self.coverage_columns = [ value @@ -421,8 +477,19 @@ def execute( ) setting = setting.model_copy(update={"parameters": parameters}) self.settings[parameters.candidateId] = setting - admission = self.budget.admit(scope, inputs) - saved = self.budget.completed(admission) + saved: dict[str, Any] | None + source = self.budget.completed_source(inputs) if scope == "full" else None + if source is not None: + admission, saved = source + if admission["scope"] != "full": + self.validation_sources[parameters.candidateId] = { + "scope": admission["scope"], + "slot": admission["slot"], + "identity": admission["identity"], + } + else: + admission = self.budget.admit(scope, inputs) + saved = self.budget.completed(admission) if saved is not None: evaluation = ParameterCandidateEvaluation.model_validate( saved["evaluation"] @@ -476,8 +543,21 @@ def execute( technical_columns=self.batch_columns, batch_columns=self.batch_columns, protected_columns=self.study.protectedColumns, - qc_columns=self.plan.cellQc.attributes, - column_kinds=self.study.columnKinds, + qc_columns=[ + *self.plan.cellQc.attributes, + *(source.name for source in self.plan.cellQc.artifactMetrics), + ], + qc_artifacts={ + source.name: artifact_model_to_ref(source.artifact) + for source in self.plan.cellQc.artifactMetrics + }, + column_kinds={ + **self.study.columnKinds, + **{ + source.name: "continuous" + for source in self.plan.cellQc.artifactMetrics + }, + }, )[0] native = next( ( @@ -549,8 +629,12 @@ def execute_matched( self.budget.admit_many( scope, [ - self.execution_inputs(cells, native), - self.execution_inputs(cells, setting), + value + for value in ( + self.execution_inputs(cells, native), + self.execution_inputs(cells, setting), + ) + if scope != "full" or self.budget.completed_source(value) is None ], ) self.execute(scope, cells, native) @@ -633,34 +717,79 @@ def experiments( "parameter": "hvgRanking", "value": "global", } - for family in self.family_patterns: - for operation in ("includeFamily", "excludeFamily"): - options[f"{operation}:{family}"] = { - "parameter": operation, - "value": family, - } - feature_policy = self.plan.assays[0].featureParameters - for feature in dict.fromkeys( - [ - *feature_policy.get("proposedExcludeFeatures", []), - *feature_policy.get("protectFeatures", []), - ] - ): - for operation in ("includeFeature", "excludeFeature"): - if operation == "excludeFeature" and feature in feature_policy.get( - "protectFeatures", [] - ): - continue - options[f"{operation}:{feature}"] = { - "parameter": operation, - "value": feature, - } + options.update(self._feature_experiments(setting)) if self.study.correctionLicense == "safe" and self.batch_columns: options["useHarmony:true"] = {"parameter": "useHarmony", "value": True} if setting.parameters.useHarmony: options["useHarmony:false"] = {"parameter": "useHarmony", "value": False} return options + def _feature_experiments(self, setting: RnaSetting) -> dict[str, dict[str, Any]]: + """Offer policy changes only when exact eligible genes can change safely.""" + policy = self.plan.assays[0].featureParameters + assay = self.store.get_assay(self.handoff.assay) + names = np.asarray(assay.feats.fetch_all("names")).astype(str) + ids = np.asarray(assay.feats.fetch_all("ids")).astype(str) + eligible = np.asarray( + self.store.load_artifact(artifact_model_to_ref(setting.eligibleFeatures))[ + "values" + ][:], + dtype=bool, + ) + allowed = np.asarray( + self.store.load_artifact( + artifact_model_to_ref( + self.handoff.graphFeatureCandidates["eligibleAll"] + ) + )["values"][:], + dtype=bool, + ) + protected = np.isin(names, policy.get("protectFeatures", [])) | np.isin( + ids, policy.get("protectFeatures", []) + ) + for family in policy.get("protectFamilies", []): + mask = _family_mask(names, family) + if mask is not None: + protected |= mask + masks = { + ("Family", family): np.asarray( + [ + re.search(pattern, name, flags=re.IGNORECASE) is not None + for name in names + ] + ) + for family, pattern in self.family_patterns.items() + } + masks.update( + { + ("Feature", feature): (names == feature) | (ids == feature) + for feature in dict.fromkeys( + [ + *policy.get("proposedExcludeFeatures", []), + *policy.get("protectFeatures", []), + ] + ) + } + ) + options = {} + for (kind, value), mask in masks.items(): + for operation in ("include", "exclude"): + if operation == "exclude" and (mask & protected).any(): + continue + changed = ( + mask & allowed & ~eligible + if operation == "include" + else mask & eligible + ) + if changed.any(): + field = operation + kind + options[f"{field}:{value}"] = { + "parameter": field, + "value": value, + "affectedEligibleGenes": int(changed.sum()), + } + return options + def batch_ranking( self, eligible: ArtifactRef, count: int, column: str ) -> np.ndarray: @@ -842,6 +971,567 @@ def apply_experiment( } ) + def _prepared_setting( + self, + scope: str, + key: str, + selected: ParameterCandidateEvaluation, + experiment: dict[str, Any], + cells: ArtifactRef, + ) -> RnaSetting: + """Commit feature work separately so interrupted reviews do not repeat it.""" + baseline = self.settings[selected.candidateId] + inputs = { + "baseline": baseline.model_dump(mode="json"), + "experiment": experiment, + "cells": self.cells.to_dict(), + } + checkpoint = f"parameter_tuning/{scope}/{key}/setting" + saved = journal.load_checkpoint( + self.store, + self.prefix, + self.workflow.workflowRunId, + checkpoint, + inputs, + ) + if saved is not None: + return RnaSetting.model_validate(saved["setting"]) + proposed = self.execution_inputs(cells, baseline) + proposed["features"] = {"requestedExperiment": inputs} + self.budget.check_many(scope, [proposed]) + setting = self.apply_experiment(selected, experiment) + journal.save_checkpoint( + self.store, + self.prefix, + self.workflow.workflowRunId, + checkpoint, + inputs, + {"setting": setting.model_dump(mode="json")}, + ) + return setting + + def _feature_nomination(self, baseline: RnaSetting) -> dict[str, Any] | None: + """Find one meaningful, previously justified policy intervention.""" + policy = self.plan.assays[0].featureParameters + eligible = np.asarray( + self.store.load_artifact(artifact_model_to_ref(baseline.eligibleFeatures))[ + "values" + ][:], + dtype=bool, + ) + all_eligible = np.asarray( + self.store.load_artifact( + artifact_model_to_ref( + self.handoff.graphFeatureCandidates["eligibleAll"] + ) + )["values"][:], + dtype=bool, + ) + names = np.asarray( + self.store.get_assay(self.handoff.assay).feats.fetch_all("names") + ).astype(str) + ids = np.asarray( + self.store.get_assay(self.handoff.assay).feats.fetch_all("ids") + ).astype(str) + nominations = [ + *(("includeFeature", value) for value in policy.get("protectFeatures", [])), + *(("includeFamily", value) for value in policy.get("protectFamilies", [])), + *( + ("excludeFeature", value) + for value in policy.get("proposedExcludeFeatures", []) + ), + *( + ("excludeFamily", value) + for value in policy.get("proposedExcludeFamilies", []) + ), + ] + protected = np.isin(names, policy.get("protectFeatures", [])) | np.isin( + ids, policy.get("protectFeatures", []) + ) + for family in policy.get("protectFamilies", []): + mask = _family_mask(names, family) + if mask is not None: + protected |= mask + for operation, value in nominations: + if operation.endswith("Family"): + mask = _family_mask(names, value) + if mask is None: + continue + if value not in self.family_patterns: + value = next( + ( + family + for family, pattern in self.family_patterns.items() + if np.array_equal( + mask, + np.asarray( + [ + re.search(pattern, name, flags=re.IGNORECASE) + is not None + for name in names + ] + ), + ) + ), + None, + ) + if value is None: + continue + else: + mask = (names == value) | (ids == value) + if operation.startswith("include"): + changed = mask & all_eligible & ~eligible + else: + if (mask & protected).any(): + continue + changed = mask & eligible + if changed.any(): + return {"parameter": operation, "value": value} + return None + + def _sensitivity_panel( + self, + scope: str, + cells: ArtifactRef, + baseline: ParameterCandidateEvaluation, + ) -> None: + """Execute a small single-axis floor, never a Cartesian product.""" + setting = self.settings[baseline.candidateId] + n_cells = self.scope_sizes[scope] + plans: list[tuple[str, str, dict[str, Any] | None, str]] = [] + for field, axis, values in ( + ("hvgCount", "hvgCount", (2000, 4000)), + ("dimensions", "pca", (10, 30)), + ("neighborsK", "neighbors", (21, 41)), + ): + for value in values: + reason = "" + if field == "dimensions" and value >= min(setting.hvgCount, n_cells): + reason = "The requested PCA dimension exceeds the observed cell or selected-feature rank." + if field == "neighborsK" and value >= n_cells: + reason = ( + "The requested neighbor count exceeds the observed cell count." + ) + plans.append( + ( + f"{field}:{value}", + axis, + None if reason else {"parameter": field, "value": value}, + reason, + ) + ) + columns = list( + dict.fromkeys( + [ + *( + [self.study.physicalCaptureColumn] + if self.study.physicalCaptureColumn in self.batch_columns + else [] + ), + *self.batch_columns, + ] + ) + ) + ranking = None + ranking_groups = {} + for column in columns: + if self.study.columnKinds.get(column) == "continuous": + continue + indices = read_stored_selection_indices( + self.store.zw, + self.cells, + kind="cell_selection", + scope="datastore", + assay=None, + table_path="cellData", + ) + group_values = read_metadata_rows_chunkwise( + self.store.cells, column, indices + ) + _, counts = np.unique(group_values, return_counts=True) + ranking_groups[column] = int((counts >= 20).sum()) + if int((counts >= 20).sum()) >= 2: + ranking = { + "parameter": "hvgRanking", + "value": "batchAware", + "column": column, + } + break + plans.append( + ( + "hvgRanking", + "hvgRanking", + ranking, + "No approved categorical technical grouping has two groups with at least 20 cells." + if ranking is None + else "", + ) + ) + nomination = self._feature_nomination(setting) + pending_feature = nomination is None and bool( + self._feature_experiments(setting) + ) + plans.append( + ( + "featurePolicy", + "featurePolicy", + nomination, + "Baseline loading, marker and objective evidence must nominate one of the offered policy interventions before combining." + if pending_feature + else "Every registered inclusion/exclusion is a no-op on the exact eligible genes or would remove objective-protected genes." + if nomination is None + else "", + ) + ) + proposed: list[dict[str, Any]] = [] + for identifier, _, experiment, _ in plans: + if experiment is None: + continue + proposal = self.execution_inputs(cells, setting) + if experiment["parameter"] in {"dimensions", "neighborsK"}: + proposal["parameters"] = { + **proposal["parameters"], + experiment["parameter"]: experiment["value"], + } + else: + prepared = journal.load_checkpoint( + self.store, + self.prefix, + self.workflow.workflowRunId, + f"parameter_tuning/{scope}/sensitivity/{identifier}/setting", + { + "baseline": setting.model_dump(mode="json"), + "experiment": experiment, + "cells": self.cells.to_dict(), + }, + ) + if prepared is not None: + known = RnaSetting.model_validate(prepared["setting"]) + if ( + known.features == setting.features + or np.array_equal( + self.store.load_artifact( + artifact_model_to_ref(known.features) + )["values"][:], + self.store.load_artifact( + artifact_model_to_ref(setting.features) + )["values"][:], + ) + or ( + identifier == "featurePolicy" + and known.hvgCount != setting.hvgCount + ) + ): + continue + proposal = self.execution_inputs(cells, known) + else: + proposal["features"] = {"requiredSensitivity": identifier} + proposed.append(proposal) + self.budget.check_many(scope, proposed) + rows: list[dict[str, Any]] = [ + { + "comparisonId": f"defaultResolution:{item.parameters.leidenResolution}", + "axis": "partition", + "status": "completed", + "baselineCandidateId": baseline.candidateId, + "alternativeCandidateId": item.candidateId, + "reason": "", + } + for item in self.evaluations[scope] + if item.candidateId != baseline.candidateId + and item.parameters.leidenResolution in {0.5, 0.75, 1.25} + ] + for identifier, axis, experiment, reason in plans: + alternative = None + proof: dict[str, Any] = { + "baselineFeatures": setting.features.model_dump(mode="json") + } + if axis in {"pca", "neighbors"}: + proof["kind"] = "numericalBound" + elif axis == "hvgRanking": + proof.update( + kind="insufficientTechnicalGroups", + eligibleGroupsByColumn=ranking_groups, + ) + elif axis == "featurePolicy": + proof.update( + kind="noPermittedPolicy", + meaningfulPermittedInterventions=0, + registeredFamilies=list(self.family_patterns), + ) + if experiment is not None: + if experiment["parameter"] in {"dimensions", "neighborsK"}: + alternative_setting = self.apply_experiment(baseline, experiment) + else: + alternative_setting = self._prepared_setting( + scope, + f"sensitivity/{identifier}", + baseline, + experiment, + cells, + ) + same_genes = alternative_setting.features == setting.features + if not same_genes and axis in { + "hvgCount", + "hvgRanking", + "featurePolicy", + }: + same_genes = np.array_equal( + self.store.load_artifact( + artifact_model_to_ref(alternative_setting.features) + )["values"][:], + self.store.load_artifact( + artifact_model_to_ref(setting.features) + )["values"][:], + ) + if ( + axis == "featurePolicy" + and alternative_setting.hvgCount != setting.hvgCount + ): + reason = "The nominated policy leaves too few eligible genes to hold the baseline HVG count fixed." + proof.update( + kind="insufficientEligibleGenes", + eligibleFeatureCount=alternative_setting.hvgCount, + ) + elif same_genes and axis in {"hvgCount", "hvgRanking", "featurePolicy"}: + reason = "The requested intervention produces exactly the same selected genes as the baseline; its numerical representation is already evaluated." + proof.update( + kind="identicalSelectedGenes", + verifiedEqualMasks=True, + alternativeSetting=alternative_setting.model_dump(mode="json"), + ) + elif ( + axis in {"pca", "neighbors"} + and setting_changes( + setting.model_dump(mode="json"), + alternative_setting.model_dump(mode="json"), + ) + == {} + ): + reason = ( + "The requested value is already the exact baseline setting." + ) + else: + alternative = self.execute(scope, cells, alternative_setting) + rows.append( + { + "comparisonId": identifier, + "axis": axis, + "status": "completed" + if alternative is not None + else "pending" + if identifier == "featurePolicy" and pending_feature + else "notApplicable", + "baselineCandidateId": baseline.candidateId, + "alternativeCandidateId": alternative.candidateId + if alternative is not None + else None, + "reason": reason, + "observedProof": proof if alternative is None else None, + } + ) + self.comparison_rows[scope] = rows + + def comparison_coverage(self, scope: str, cells: ArtifactRef) -> dict[str, Any]: + source = self.discovery_scope if scope == "full" else scope + source = source or scope + settings = { + item.candidateId: { + **self.settings[item.candidateId].model_dump(mode="json"), + "scope": name, + "status": item.status, + "nCells": self.scope_sizes.get(name, self.handoff.nCells), + "cellSelection": item.cellSelection.model_dump(mode="json") + if item.cellSelection is not None + else None, + "metrics": item.metrics.model_dump( + mode="json", + include={ + "nClusters", + "minClusterCells", + "seedStability", + "subsampleStability", + "markerCoherence", + "markerSpecificityMedian", + "macroF1", + "weightedF1", + "graphSilhouetteMedian", + "clusterConnectivity", + "crossUnitSupport", + "batchMixing", + "biologicalPreservation", + "topMarkerGenes", + }, + ), + } + for name, rows in self.evaluations.items() + for item in rows + } + rows = list(self.comparison_rows.get(source, [])) + combined_id = self.combined_candidates.get(source) + resolution_ids = self.resolution_candidates.get(source, []) + if combined_id is not None: + rows.extend( + { + "comparisonId": f"combinedResolution:{settings[identifier]['parameters']['leidenResolution']}", + "axis": "partition", + "status": "completed", + "baselineCandidateId": combined_id, + "alternativeCandidateId": identifier, + "reason": "", + } + for identifier in resolution_ids + if identifier != combined_id + ) + return { + "phase": "validation" + if scope == "full" and self.discovery_scope is not None + else "combined" + if source in self.combined_candidates + else "sensitivity", + "population": "allCells" if cells == self.cells else "subset", + "comparisons": rows, + "candidateSettings": settings, + "combinedCandidateId": combined_id, + "resolutionCandidateIds": resolution_ids, + "validationSources": dict(self.validation_sources), + "fullRepair": self.full_repair, + } + + def _combined_setting( + self, + scope: str, + review_index: int, + action: TuningAction, + cells: ArtifactRef, + ) -> RnaSetting: + assert action.combinedSettings is not None + choices = action.combinedSettings + policy = self.settings[choices.featurePolicyCandidateId] + count = self.settings[choices.hvgCountCandidateId].hvgCount + ranking = self.settings[choices.hvgRankingCandidateId] + parameters = policy.parameters.model_copy( + update={ + "dimensions": self.settings[ + choices.pcaCandidateId + ].parameters.dimensions, + "neighborsK": self.settings[ + choices.neighborsCandidateId + ].parameters.neighborsK, + "leidenResolution": 1.0, + "useHarmony": False, + } + ) + inputs = { + "choices": choices.model_dump(mode="json"), + "settings": { + identifier: self.settings[identifier].model_dump(mode="json") + for identifier in choices.model_dump().values() + }, + "cells": self.cells.to_dict(), + } + key = f"parameter_tuning/{scope}/review{review_index}/combined_setting" + saved = journal.load_checkpoint( + self.store, self.prefix, self.workflow.workflowRunId, key, inputs + ) + if saved is not None: + return RnaSetting.model_validate(saved["setting"]) + eligible = artifact_model_to_ref(policy.eligibleFeatures) + if ( + count == policy.hvgCount + and ranking.ranking == policy.ranking + and ranking.rankingColumn == policy.rankingColumn + ): + features = policy.features + else: + proposed = self.execution_inputs(cells, policy) + proposed["features"] = {"combinedSettings": inputs} + proposed["parameters"] = parameters.model_dump(mode="json") + self.budget.check_many(scope, [proposed]) + order = ( + self.batch_ranking(eligible, count, ranking.rankingColumn) + if ranking.ranking == "batchAware" and ranking.rankingColumn is not None + else None + ) + features = ArtifactReferenceModel.from_artifact_ref( + rank_core_hvgs( + self.store, + eligible=eligible, + statistics=artifact_model_to_ref( + self.handoff.graphFeatureCandidates["eligibleAll"] + ), + top_n=count, + ranking=order, + ) + ) + actual_count = int( + np.asarray( + self.store.load_artifact(artifact_model_to_ref(features))["values"][:], + dtype=bool, + ).sum() + ) + if parameters.dimensions >= min(actual_count, self.scope_sizes[scope]): + raise ValueError( + "Combined settings cannot support the selected PCA dimension" + ) + if actual_count != count: + raise ValueError( + "The proposed combined HVG count was not retained by the selected feature policy" + ) + setting = RnaSetting( + parameters=parameters, + features=features, + eligibleFeatures=policy.eligibleFeatures, + hvgCount=actual_count, + ranking=ranking.ranking, + rankingColumn=ranking.rankingColumn, + ) + journal.save_checkpoint( + self.store, + self.prefix, + self.workflow.workflowRunId, + key, + inputs, + {"setting": setting.model_dump(mode="json")}, + ) + return setting + + def _resolution_panel( + self, + scope: str, + cells: ArtifactRef, + setting: RnaSetting, + ) -> ParameterCandidateEvaluation: + settings = [ + setting.model_copy( + update={ + "parameters": setting.parameters.model_copy( + update={"leidenResolution": resolution} + ) + } + ) + for resolution in (0.5, 0.75, 1.0, 1.25) + ] + proposals = [self.execution_inputs(cells, item) for item in settings] + if setting.parameters.useHarmony: + proposals += [ + {**row, "parameters": {**row["parameters"], "useHarmony": False}} + for row in proposals + ] + self.budget.check_many( + scope, + [ + row + for row in proposals + if scope != "full" or self.budget.completed_source(row) is None + ], + ) + evaluations = [self.execute_matched(scope, cells, item) for item in settings] + self.resolution_candidates[scope] = [item.candidateId for item in evaluations] + return next( + item for item in evaluations if item.parameters.leidenResolution == 1.0 + ) + def feature_evidence( self, selected: ParameterCandidateEvaluation ) -> dict[str, Any]: @@ -890,7 +1580,7 @@ def feature_evidence( np.flatnonzero(~eligible & membership)[:8] ].tolist(), } - evidence = { + evidence: dict[str, Any] = { "statistics": reference.to_dict(), "statisticsCells": self.cells.to_dict(), "basis": "Core Scarf corrected variance on the full QC-retained cells; feature-axis summaries are descriptive, not a substitute for downstream comparisons.", @@ -944,6 +1634,13 @@ def review( raise ValueError( "Saved review has different candidate evidence or settings" ) + if ( + previous_review is not None + and "comparisonCoverage" not in previous_review["inputs"] + ): + raise ValueError( + "Saved analysis lacks mandatory sensitivity coverage; start a new workflow" + ) experiments = ( previous_review["inputs"]["experiments"] if previous_review is not None @@ -957,25 +1654,13 @@ def review( if ( candidate.candidateId == selected.candidateId or candidate.cellSelection != selected.cellSelection - or other.features != current_setting.features or other.parameters.reductionMethod != current_setting.parameters.reductionMethod ): continue - changes = { - field: { - "current": getattr(current_setting.parameters, field), - "alternative": getattr(other.parameters, field), - } - for field in ( - "dimensions", - "neighborsK", - "leidenResolution", - "useHarmony", - ) - if getattr(current_setting.parameters, field) - != getattr(other.parameters, field) - } + changes = setting_changes( + current_setting.model_dump(mode="json"), other.model_dump(mode="json") + ) if len(changes) != 1: continue matched_comparisons.append( @@ -983,15 +1668,31 @@ def review( "currentCandidateId": selected.candidateId, "alternativeCandidateId": candidate.candidateId, "changedParameter": changes, - "basis": "Same frozen cells and graph features; all other analysis parameters match. Compare these exact candidates rather than mixing dimensions and resolution effects.", + "partitionEvidence": partition_comparison_evidence( + self.store, selected, candidate + ) + if previous_review is None + else {}, + "basis": "Same frozen cells and every unaffected logical setting; the named intervention alone changes. Feature comparisons hold ranking/count/eligible genes fixed except for their declared axis.", } ) if previous_review is None and candidate.status == "done": field, values = next(iter(changes.items())) + field = { + "pca": "dimensions", + "neighbors": "neighborsK", + "partition": "leidenResolution", + "correction": "useHarmony", + }.get(field, field) for experiment_id, experiment in experiments.items(): + requested_value = ( + [experiment["value"], experiment.get("column")] + if field == "hvgRanking" + else experiment["value"] + ) if ( experiment["parameter"] == field - and experiment["value"] == values["alternative"] + and requested_value == values["alternative"] ): completed_experiments[experiment_id] = candidate.candidateId if previous_review is None: @@ -1114,6 +1815,7 @@ def review( "featureEvidence", "neighborComparisons", "assessmentContext", + "comparisonCoverage", ] ) ) @@ -1122,7 +1824,7 @@ def review( not isinstance(value, str) for value in evidence_ids ): raise ValueError("Review evidence IDs must be a list of strings") - evidence = { + evidence: dict[str, Any] = { "studyContract": self.study.model_dump(mode="json"), "qcPolicy": self.plan.cellQc.model_dump(mode="json"), "scope": scope, @@ -1145,7 +1847,6 @@ def review( "experiments": experiments, "availableEvidenceIds": evidence_ids, "imageHashes": image_hashes, - "assessedDomains": sorted(_DOMAINS), "budget": { "visibleEvaluations": { name: len(rows) for name, rows in self.evaluations.items() @@ -1153,10 +1854,16 @@ def review( "limits": self.budget.summary()["limits"], }, "fullRepairsUsed": self.full_repairs, + "comparisonCoverage": self.comparison_coverage( + scope, artifact_model_to_ref(selected.cellSelection) + ) + if selected.cellSelection is not None + else {}, "pilotPopulationWarnings": { item.candidateId: "This sampled partition includes fewer than 20 cells in a population. Assess its relevance and support; it is not evidence of an invalid biological group. Accepting this partition requires a larger sample or full-cohort assessment." for item in candidates if scope != "full" + and selected.cellSelection != self.handoff.cellSelection and item.metrics.minClusterCells is not None and item.metrics.minClusterCells < 20 }, @@ -1186,9 +1893,11 @@ def review( item.model_dump(mode="json") for item in self.design_comparisons ], "populationSupport": { - selected.candidateId: population_support_evidence( - self.store, selected, support_columns + item.candidateId: population_support_evidence( + self.store, item, support_columns ) + for item in candidates + if scope == "full" or item.candidateId == selected.candidateId } if support_columns else {}, @@ -1256,6 +1965,19 @@ def validate(action: TuningAction, *, replay: bool = False) -> TuningAction: raise ValueError( "An offered experiment must use the current candidate as its fixed baseline" ) + validate_comparison_review( + evidence["comparisonCoverage"], action.model_dump(mode="json") + ) + if action.combinedSettings is not None: + choices = action.combinedSettings + count = self.settings[choices.hvgCountCandidateId].hvgCount + available = evidence["featureEvidence"][ + choices.featurePolicyCandidateId + ]["eligibleGenes"] + if count > available: + raise ValueError( + "The combined feature policy cannot supply the requested HVG count; choose a compatible observed count or policy" + ) if ( self.study.correctionLicense == "unsafeConfounded" and action.correctionNeed in {"needed", "notNeeded"} @@ -1403,7 +2125,15 @@ def validate(action: TuningAction, *, replay: bool = False) -> TuningAction: "Assess this RNA analysis as a computational biologist against the exact study objective. " "Start from Scarf defaults; keep them when observed quantitative evidence and biological interpretation support them. " "Do not execute a search grid or favor a default solely because it is a default. " - "Assess every named scientific domain before acceptance. Explain observed marker programs and relevant PCA loading genes, " + "Explain observed marker programs and relevant PCA loading genes, " + "The supplied comparisonCoverage records which sensitivity comparisons actually ran. If featurePolicy is pending, nominate an offered family/feature experiment from observed baseline loading, marker and objective evidence before combining; missing nomination is not evidence of inapplicability. " + "Conclude every comparisonCoverage axis using its exact completed baseline and alternatives, including candidates with better stability or marker coverage than your preference. " + "In phase=sensitivity, use action=combine and choose each combinedSettings field from an observed candidate on that axis. A combination is a proposed hypothesis, not an observed result. " + "Scarf will execute it and compare four resolutions on its exact graph before acceptance. Do not request already covered settings as experiments. " + "comparisonConclusions must include quantitativeReason, biologicalReason and a short plainLanguageSummary for each axis. Explain how split or merged marker programs serve the stated objective, not just larger clusters or a single numerical maximum. " + "For each alternative with higher seedStability, subsampleStability, markerCoherence, markerSpecificityMedian or macroF1 than the stated preference, include a tradeoffs entry naming alternativeCandidateId, metric, exact preferredValue/alternativeValue and interpretation. These measurements require explanation, not automatic winner selection. " + "For each selected cluster with empty topMarkerGenes, populationConcerns must name candidateId, clusterId, cited evidenceIds and explain whether it is a nonEssentialLimitation or unresolvedEssential. An unresolved essential population blocks acceptance; do not invent marker support. " + "The action plainLanguageSummary should state the selected settings, what evidence changed the choice, and any unresolved population interpretations without workflow jargon. " "QC/capture retention, batch associations per PC and protected biological structure. " "Family dominance alone never proves nuisance; inclusion, exclusion and HVG bans need evidence and objective justification. " "If a specific concern warrants testing, choose exactly one offered experiment and state its expected improvement and " @@ -1424,7 +2154,7 @@ def validate(action: TuningAction, *, replay: bool = False) -> TuningAction: "A QC association is correlation. Check featureEvidence for actual selected genes: marker-family enrichment cannot show that an excluded family drives PCA. " "More retained cells, balanced group counts, or cross-unit support alone do not prove healthy cells or biological preservation. " "Check proposed cell identities against the tissue context. Unexpected marker programs require capture/donor and provenance investigation; do not declare them ordinary tissue populations or assert contamination without evidence. " - "Detailed populationSupport is supplied for currentCandidateId only; do not claim to have compared unprovided distributions for alternatives. Inspect its capture/donor distribution and missing metadata. Broad support does not prove a biological identity; concentration alone does not prove contamination. " + "Detailed populationSupport is supplied for currentCandidateId during screening and every final validation candidate; do not claim to have compared unprovided distributions for alternatives. Inspect its capture/donor distribution and missing metadata. Broad support does not prove a biological identity; concentration alone does not prove contamination. " "Unsupported design comparisons establish neither association nor absence; keep their unresolved requirements visible. " "Previous actions are history, not scientific authority. Reassess their claims against the exact current evidence. " "Request enlarge when sample evidence is insufficient; on full cells there is one targeted repair, then defer. " @@ -1444,6 +2174,7 @@ def validate(action: TuningAction, *, replay: bool = False) -> TuningAction: [item.candidateId for item in candidates], list(experiments), scope=scope, + phase=evidence["comparisonCoverage"]["phase"], ), system_prompt=prompt, user_prompt=build_visual_evidence_prompt( @@ -1506,7 +2237,8 @@ def validate(action: TuningAction, *, replay: bool = False) -> TuningAction: if action.action == "experiment" else f"{action.action} {action.selectedCandidateId}" ) - logger.info(f"Analysis assessment ({operation}): {action.rationale}") + logger.info(f"Analysis assessment: {action.plainLanguageSummary}") + logger.debug(f"Analysis assessment ({operation}): {action.rationale}") return action def assess_scope( @@ -1528,39 +2260,29 @@ def assess_scope( self.history.append( {"scope": scope, "coverage": coverage, "coverageConcerns": insufficient} ) - if scope != "full" and insufficient: + if cells != self.cells and insufficient: return "enlarge", None if initial is None: - settings = [ - self.baseline(resolution) for resolution in (0.5, 0.75, 1.0, 1.25) - ] - settings = [ - value.model_copy( - update={ - "parameters": value.parameters.model_copy( - update={ - "dimensions": min( - value.parameters.dimensions, - coverage["screeningCells"] - 1, - ), - "neighborsK": min( - value.parameters.neighborsK, - coverage["screeningCells"] - 1, - ), - } - ) - } - ) - for value in settings - ] - self.budget.admit_many( - scope, [self.execution_inputs(cells, value) for value in settings] - ) - baseline = [self.execute(scope, cells, value) for value in settings] - selected = next( - (item for item in baseline if item.parameters.leidenResolution == 1.0), - baseline[0], + baseline = self.baseline().model_copy( + update={ + "parameters": self.baseline().parameters.model_copy( + update={ + "dimensions": min( + self.baseline().parameters.dimensions, + coverage["screeningCells"] - 1, + ), + "neighborsK": min( + self.baseline().parameters.neighborsK, + coverage["screeningCells"] - 1, + ), + } + ) + } ) + selected = self._resolution_panel(scope, cells, baseline) + # The first panel describes defaults, not an accepted combined recipe. + self.resolution_candidates.pop(scope, None) + self._sensitivity_panel(scope, cells, selected) else: selected = self.execute_matched(scope, cells, initial) limit = ( @@ -1576,9 +2298,17 @@ def assess_scope( if item.candidateId == action.selectedCandidateId ) if action.action in {"accept", "enlarge", "defer"}: + if action.action == "enlarge" and cells == self.cells: + self.history.append( + { + "scope": scope, + "reason": "All retained cells were already assessed; more sampling cannot supply the unresolved evidence.", + } + ) + return "defer", selected if ( action.action == "accept" - and scope != "full" + and cells != self.cells and selected.metrics.minClusterCells is not None and selected.metrics.minClusterCells < 20 ): @@ -1589,10 +2319,40 @@ def assess_scope( } ) return "enlarge", selected + if action.action == "accept" and scope != "full": + self.discovery_scope = scope return action.action, selected + if action.action == "combine": + setting = self._combined_setting(scope, review_index, action, cells) + selected = self.execute(scope, cells, setting) + selected = self._resolution_panel( + scope, cells, self.settings[selected.candidateId] + ) + self.combined_candidates[scope] = selected.candidateId + if self.study.correctionLicense == "safe" and action.correctionNeed in { + "needed", + "uncertain", + }: + setting = self.settings[selected.candidateId].model_copy( + update={ + "parameters": selected.parameters.model_copy( + update={"useHarmony": True} + ) + } + ) + selected = self._resolution_panel(scope, cells, setting) + self.combined_candidates[scope] = selected.candidateId + continue assert action.experimentId is not None experiment = self.experiments(selected)[action.experimentId] - if scope == "full" and experiment["parameter"] != "useHarmony": + if ( + scope == "full" + and ( + self.discovery_scope is not None + or scope in self.combined_candidates + ) + and experiment["parameter"] != "useHarmony" + ): if self.full_repairs >= self.request.config.maxFullRepairs: raise CandidateBudgetExceeded( "The allowed full-cohort repair has been used; scientific acceptance remains unresolved" @@ -1606,65 +2366,93 @@ def assess_scope( "includeFeature", "excludeFeature", }: - feature_key = ( - f"parameter_tuning/{scope}/review{review_index}/feature_experiment" + setting = self._prepared_setting( + scope, + f"review{review_index}/feature_experiment", + selected, + experiment, + cells, ) - feature_inputs = { - "baseline": self.settings[selected.candidateId].model_dump( - mode="json" - ), - "experiment": experiment, - "cells": self.cells.to_dict(), - } - saved_feature = journal.load_checkpoint( - self.store, - self.prefix, - self.workflow.workflowRunId, - feature_key, - inputs=feature_inputs, - ) - if saved_feature is None: - proposed = self.execution_inputs( - cells, self.settings[selected.candidateId] - ) - proposed["features"] = { - "requestedFeatureExperiment": feature_inputs - } - proposals = [proposed] - if selected.parameters.useHarmony: - proposals.append( - { - **proposed, - "parameters": { - **proposed["parameters"], - "useHarmony": False, - }, - } - ) - self.budget.check_many(scope, proposals) - setting = self.apply_experiment(selected, experiment) - journal.save_checkpoint( - self.store, - self.prefix, - self.workflow.workflowRunId, - feature_key, - inputs=feature_inputs, - outputs={"setting": setting.model_dump(mode="json")}, - ) - else: - setting = RnaSetting.model_validate(saved_feature["setting"]) else: setting = self.apply_experiment(selected, experiment) - next_selected = self.execute_matched(scope, cells, setting) - if next_selected.candidateId == selected.candidateId: - self.history.append( - { - "scope": scope, - "experiment": action.experimentId, - "result": "The intervention did not change the selected genes or numerical representation; exact artifacts were reused.", - } + prior = selected + pending_policy = next( + ( + row + for row in self.comparison_rows.get(scope, []) + if row["axis"] == "featurePolicy" and row["status"] == "pending" + ), + None, + ) + policy_experiment = experiment["parameter"] in { + "includeFamily", + "excludeFamily", + "includeFeature", + "excludeFeature", + } + if pending_policy is not None and policy_experiment: + if prior.candidateId != pending_policy["baselineCandidateId"]: + raise ValueError( + "The required policy comparison must retain the exact default baseline" + ) + prior_setting = self.settings[prior.candidateId] + if setting.hvgCount != prior_setting.hvgCount or np.array_equal( + self.store.load_artifact(artifact_model_to_ref(setting.features))[ + "values" + ][:], + self.store.load_artifact( + artifact_model_to_ref(prior_setting.features) + )["values"][:], + ): + pending_policy.update( + status="notApplicable", + reason="The nominated policy cannot change selected genes while retaining the fixed baseline HVG count.", + observedProof={ + "baselineFeatures": prior_setting.features.model_dump( + mode="json" + ), + **( + { + "kind": "insufficientEligibleGenes", + "eligibleFeatureCount": setting.hvgCount, + } + if setting.hvgCount != prior_setting.hvgCount + else { + "kind": "identicalSelectedGenes", + "verifiedEqualMasks": True, + "alternativeSetting": setting.model_dump( + mode="json" + ), + } + ), + }, + ) + continue + selected = self.execute_matched(scope, cells, setting) + if pending_policy is not None and policy_experiment: + pending_policy.update( + status="completed", + alternativeCandidateId=selected.candidateId, + reason="Baseline loading, marker and objective evidence nominated this exact policy comparison.", + ) + if ( + scope == "full" + and ( + self.discovery_scope is not None + or scope in self.combined_candidates + ) + and experiment["parameter"] != "useHarmony" + ): + self.full_repair = { + "baselineCandidateId": prior.candidateId, + "selectedCandidateId": selected.candidateId, + "experimentId": action.experimentId, + } + if scope != "full" and scope in self.combined_candidates: + selected = self._resolution_panel( + scope, cells, self.settings[selected.candidateId] ) - selected = next_selected + self.combined_candidates[scope] = selected.candidateId return "defer", selected def run(self) -> tuple[ParameterTuningReport, dict[str, Any]]: @@ -1673,41 +2461,48 @@ def run(self) -> tuple[ParameterTuningReport, dict[str, Any]]: reason = "Required scientific evidence remains unresolved." try: initial: RnaSetting | None = None - if self.handoff.nCells > self.request.config.screeningCells: - for index, size in enumerate( - ( - self.request.config.screeningCells, - self.request.config.maxScreeningCells, - ) - ): - sample = uniform_screening_selection( - self.store, - self.cells, - size=size, - seed=self.request.config.randomSeed, - ) - if sample == self.cells: - break - status, screened = self.assess_scope(f"sample{index}", sample, None) - if status == "accept" and screened is not None: - initial = self.settings[screened.candidateId] - break - if status == "defer": - return self.report( - None, - self.last_action.rationale - if self.last_action is not None - else reason, - ), self.summary() - else: - logger.info( - "Screening evidence remains insufficient; assessing the bounded full Scarf baseline." - ) + previous_sample = None + for index, size in enumerate( + ( + self.request.config.screeningCells, + self.request.config.maxScreeningCells, + ) + ): + sample = uniform_screening_selection( + self.store, + self.cells, + size=size, + seed=self.request.config.randomSeed, + ) + if sample == previous_sample: + break + previous_sample = sample + status, screened = self.assess_scope(f"sample{index}", sample, None) + if status == "accept" and screened is not None: + initial = self.settings[screened.candidateId] + self.discovery_scope = f"sample{index}" + break + if status == "defer": + return self.report( + None, + self.last_action.rationale + if self.last_action is not None + else reason, + ), self.summary() + if sample == self.cells: + return self.report( + None, + "All retained cells were assessed, but the required scientific evidence remains unresolved; another sample cannot resolve this concern.", + ), self.summary() + if initial is None: + logger.info( + "Both bounded discovery populations lack adequate support; assessing the bounded full baseline." + ) final_status, selected = self.assess_scope("full", self.cells, initial) if final_status != "accept" and self.last_action is not None: reason = self.last_action.rationale except CandidateBudgetExceeded as exc: - reason = f"Scientific assessment paused: {exc}" + reason = f"Required comparison coverage is incomplete within the configured work limits: {exc}" if final_status != "accept": selected = None return self.report(selected, reason), self.summary() @@ -1830,6 +2625,11 @@ def report( needsInput=ParameterTuningNeedsInput(question=reason), ) assert self.last_action is not None + common["limitations"].extend( + f"Population {row.clusterId}: {row.explanation}" + for row in self.last_action.populationConcerns + if row.candidateId == selected.candidateId + ) report = ParameterTuningReport( **common, status="done", diff --git a/scarf/agent/parameter_tuning/comparisons.py b/scarf/agent/parameter_tuning/comparisons.py new file mode 100644 index 00000000..bfee0445 --- /dev/null +++ b/scarf/agent/parameter_tuning/comparisons.py @@ -0,0 +1,501 @@ +"""Exact RNA sensitivity comparisons and their review requirements.""" + +from collections.abc import Mapping +from collections import Counter +from typing import Any, Literal + +from pydantic import Field +import numpy as np + +from ..types import AgentDataModel +from ...storage.refs import ArtifactRef +from .contracts import ParameterCandidateEvaluation + + +type ComparisonAxis = Literal[ + "hvgCount", "hvgRanking", "featurePolicy", "pca", "neighbors", "partition" +] + + +class ComparisonConclusion(AgentDataModel): + """Explain an observed comparison against the scientific objective.""" + + axis: ComparisonAxis + candidateIds: list[str] = Field(min_length=1) + preferredCandidateId: str + quantitativeReason: str = Field(min_length=1) + biologicalReason: str = Field(min_length=1) + plainLanguageSummary: str = Field(min_length=1) + tradeoffs: list["ComparisonTradeoff"] = Field(default_factory=list) + + +class ComparisonTradeoff(AgentDataModel): + """A measured advantage of an alternative that the preference must explain.""" + + alternativeCandidateId: str + metric: Literal[ + "seedStability", + "subsampleStability", + "markerCoherence", + "markerSpecificityMedian", + "macroF1", + ] + preferredValue: float = Field(allow_inf_nan=False) + alternativeValue: float = Field(allow_inf_nan=False) + interpretation: str = Field(min_length=1) + + +class PopulationConcern(AgentDataModel): + """Keep unsupported population interpretations explicit in acceptance.""" + + candidateId: str + clusterId: str + status: Literal["nonEssentialLimitation", "unresolvedEssential"] + evidenceIds: list[str] = Field(min_length=1) + explanation: str = Field(min_length=1) + + +class CombinedSettings(AgentDataModel): + """Propose one combination using settings from actual completed candidates.""" + + hvgCountCandidateId: str + hvgRankingCandidateId: str + featurePolicyCandidateId: str + pcaCandidateId: str + neighborsCandidateId: str + + +_REQUIRED_COMPARISONS = { + "defaultResolution:0.5", + "defaultResolution:0.75", + "defaultResolution:1.25", + "hvgCount:2000", + "hvgCount:4000", + "dimensions:10", + "dimensions:30", + "neighborsK:21", + "neighborsK:41", + "hvgRanking", + "featurePolicy", +} +_CHOICE_AXES = { + "hvgCountCandidateId": "hvgCount", + "hvgRankingCandidateId": "hvgRanking", + "featurePolicyCandidateId": "featurePolicy", + "pcaCandidateId": "pca", + "neighborsCandidateId": "neighbors", +} + + +def partition_comparison_evidence( + store: Any, + left: ParameterCandidateEvaluation, + right: ParameterCandidateEvaluation, +) -> dict[str, Any]: + """Describe bounded observed splits and merges on exactly matched cells.""" + if left.cellSelection is None or left.cellSelection != right.cellSelection: + raise ValueError("Partition comparisons require the same frozen cells") + arrays = [] + for candidate in (left, right): + reference = candidate.artifacts["clusters"] + ref = ArtifactRef( + scope=reference.scope, + assay=reference.assay, + kind=reference.kind, + artifact_id=reference.artifactId, + ) + status = store.inspect_artifact(ref) + cells = left.cellSelection + cell_ref = ArtifactRef( + scope=cells.scope, + assay=cells.assay, + kind=cells.kind, + artifact_id=cells.artifactId, + ) + if ( + not status.complete + or status.inputs.get("cell_selection") != cell_ref.to_dict() + ): + raise ValueError( + "Partition evidence does not bind its exact selected cells" + ) + arrays.append(store.load_artifact(ref)["values"]) + if len(arrays[0].shape) != 1 or arrays[0].shape != arrays[1].shape: + raise ValueError("Partition labels do not align") + counts: Counter[tuple[str, str]] = Counter() + for start in range(0, arrays[0].shape[0], 65_536): + a, b = ( + np.asarray(values[start : start + 65_536]).astype(str) for values in arrays + ) + pairs, numbers = np.unique(np.column_stack((a, b)), axis=0, return_counts=True) + counts.update( + { + (str(pair[0]), str(pair[1])): int(number) + for pair, number in zip(pairs, numbers, strict=True) + } + ) + + def summarize(reverse: bool) -> list[dict[str, Any]]: + by_source: dict[str, Counter[str]] = {} + for (a, b), count in counts.items(): + source, target = (b, a) if reverse else (a, b) + by_source.setdefault(source, Counter())[target] += count + source_metrics = right.metrics if reverse else left.metrics + target_metrics = left.metrics if reverse else right.metrics + rows: list[dict[str, Any]] = [] + for source, targets in by_source.items(): + total = sum(targets.values()) + ordered = targets.most_common() + rows.append( + { + "cluster": source, + "cells": total, + "fractionOutsideLargestMatch": 1 - ordered[0][1] / total, + "markerGenes": source_metrics.topMarkerGenes.get(source, [])[:5], + "matches": [ + { + "cluster": target, + "cells": number, + "fractionOfSource": number / total, + "markerGenes": target_metrics.topMarkerGenes.get( + target, [] + )[:5], + } + for target, number in ordered[:3] + ], + "omittedMatches": max(0, len(ordered) - 3), + "omittedCells": sum(number for _, number in ordered[3:]), + } + ) + return sorted( + rows, key=lambda row: (-row["fractionOutsideLargestMatch"], row["cells"]) + )[:5] + + return { + "matchedCells": arrays[0].shape[0], + "splits": summarize(False), + "merges": summarize(True), + "interpretation": "Observed same-cell partition overlap, with up to five split and merge examples and three matches per example. Marker names are bounded summaries; this is not independent stability or a validated cell-type identity.", + } + + +def setting_changes( + left: Mapping[str, Any], right: Mapping[str, Any] +) -> dict[str, dict[str, Any]]: + """Describe logical interventions, allowing the resulting gene set to change.""" + changes = {} + if left["hvgCount"] != right["hvgCount"] and left["features"] == right["features"]: + raise ValueError( + "One frozen feature artifact cannot have different selected-gene counts" + ) + for field, axis in ( + ("dimensions", "pca"), + ("neighborsK", "neighbors"), + ("leidenResolution", "partition"), + ("useHarmony", "correction"), + ): + a, b = left["parameters"][field], right["parameters"][field] + if a != b: + changes[axis] = {"current": a, "alternative": b} + for field, axis in ( + ("hvgCount", "hvgCount"), + ("eligibleFeatures", "featurePolicy"), + ): + if left[field] != right[field]: + changes[axis] = {"current": left[field], "alternative": right[field]} + a = (left["ranking"], left["rankingColumn"]) + b = (right["ranking"], right["rankingColumn"]) + if a != b: + changes["hvgRanking"] = {"current": list(a), "alternative": list(b)} + if left["features"] != right["features"] and not { + "hvgCount", + "hvgRanking", + "featurePolicy", + }.intersection(changes): + changes["unexplainedFeatures"] = { + "current": left["features"], + "alternative": right["features"], + } + return changes + + +def validate_comparison_review( + coverage: Mapping[str, Any], action: Mapping[str, Any] +) -> None: + """Validate saved or new conclusions against exact completed comparison inputs.""" + required = { + "phase", + "population", + "comparisons", + "candidateSettings", + "combinedCandidateId", + "resolutionCandidateIds", + } + if not required.issubset(coverage): + raise ValueError( + "Saved analysis lacks mandatory comparison coverage; start a new workflow" + ) + if coverage["phase"] not in {"sensitivity", "combined", "validation"}: + raise ValueError("Unknown RNA comparison phase") + if coverage["population"] not in {"subset", "allCells"}: + raise ValueError("Comparison population must identify sampled or all cells") + settings = coverage["candidateSettings"] + rows = coverage["comparisons"] + if not isinstance(settings, Mapping) or not isinstance(rows, list): + raise ValueError("Comparison coverage must contain exact settings and rows") + if len({row["comparisonId"] for row in rows}) != len(rows): + raise ValueError("Comparison IDs must be unique") + if not _REQUIRED_COMPARISONS.issubset({row["comparisonId"] for row in rows}): + raise ValueError("Required RNA sensitivity comparisons are missing") + axis_candidates: dict[str, set[str]] = {} + for row in rows: + axis = row["axis"] + left_id, right_id = row["baselineCandidateId"], row["alternativeCandidateId"] + if left_id not in settings or settings[left_id]["status"] != "done": + raise ValueError("A comparison baseline is not completed evidence") + axis_candidates.setdefault(axis, set()).add(left_id) + if row["status"] == "pending" and axis == "featurePolicy": + if action["action"] in {"combine", "accept"}: + raise ValueError( + "A feature-policy comparison still needs an evidence-based nomination and execution" + ) + continue + if row["status"] == "notApplicable": + if not str(row.get("reason", "")).strip(): + raise ValueError( + "An unavailable comparison needs its observed eligibility reason" + ) + if right_id is not None and right_id != left_id: + raise ValueError( + "An unavailable comparison cannot claim a different evaluated alternative" + ) + proof = row.get("observedProof", {}) + baseline = settings[left_id] + kind = proof.get("kind") + if proof.get("baselineFeatures") != baseline["features"]: + raise ValueError( + "Unavailable comparisons require observed proof bound to the exact baseline features" + ) + valid = False + if axis in {"pca", "neighbors"}: + requested = int(row["comparisonId"].split(":")[1]) + field = "dimensions" if axis == "pca" else "neighborsK" + bound = ( + min(baseline["hvgCount"], baseline["nCells"]) + if axis == "pca" + else baseline["nCells"] + ) + valid = kind == "numericalBound" and ( + requested >= bound or requested == baseline["parameters"][field] + ) + elif kind == "identicalSelectedGenes": + alternative = proof.get("alternativeSetting", {}) + differences = setting_changes( + baseline, {**alternative, "features": baseline["features"]} + ) + valid = ( + set(differences) <= {axis} + and alternative["hvgCount"] == baseline["hvgCount"] + and proof.get("verifiedEqualMasks") is True + ) + elif axis == "hvgRanking" and kind == "insufficientTechnicalGroups": + groups = proof.get("eligibleGroupsByColumn") + valid = isinstance(groups, dict) and all( + isinstance(count, int) and count < 2 for count in groups.values() + ) + elif axis == "featurePolicy" and kind == "noPermittedPolicy": + valid = proof.get( + "meaningfulPermittedInterventions" + ) == 0 and isinstance(proof.get("registeredFamilies"), list) + elif axis == "featurePolicy" and kind == "insufficientEligibleGenes": + valid = ( + isinstance(proof.get("eligibleFeatureCount"), int) + and proof["eligibleFeatureCount"] < baseline["hvgCount"] + ) + if not valid: + raise ValueError( + "An unavailable comparison needs a valid observed equivalence or infeasibility proof for its exact axis" + ) + continue + if row["status"] != "completed" or right_id not in settings: + raise ValueError("A required comparison lacks a completed alternative") + left, right = settings[left_id], settings[right_id] + if right["status"] != "done" or left["cellSelection"] != right["cellSelection"]: + raise ValueError( + "Comparison candidates must be complete on the exact same cells" + ) + differences = setting_changes(left, right) + if set(differences) != {axis}: + raise ValueError( + "A sensitivity comparison must change only its declared setting" + ) + axis_candidates[axis].add(right_id) + if ( + "comparisonConclusions" not in action + or not str(action.get("plainLanguageSummary", "")).strip() + ): + raise ValueError( + "Analysis review lacks explicit comparison conclusions and reader summary" + ) + conclusions = [ + ComparisonConclusion.model_validate(row) + for row in action["comparisonConclusions"] + ] + if action["action"] in {"accept", "combine"}: + by_axis: dict[str, ComparisonConclusion] = { + row.axis: row for row in conclusions + } + if len(by_axis) != len(conclusions) or set(by_axis) != set(axis_candidates): + raise ValueError( + "Conclude every required comparison axis before combining or accepting" + ) + for axis, ids in axis_candidates.items(): + conclusion = by_axis[axis] + if not ids.issubset(conclusion.candidateIds) or not set( + conclusion.candidateIds + ).issubset(settings): + raise ValueError( + "A conclusion must address the actual baseline and all its observed alternatives" + ) + if conclusion.preferredCandidateId not in ids: + raise ValueError( + "A comparison preference must name its observed candidate" + ) + preferred = settings[conclusion.preferredCandidateId]["metrics"] + required_tradeoffs = { + (identifier, metric): (float(preferred[metric]), float(value)) + for identifier in ids - {conclusion.preferredCandidateId} + for metric, value in settings[identifier]["metrics"].items() + if metric + in { + "seedStability", + "subsampleStability", + "markerCoherence", + "markerSpecificityMedian", + "macroF1", + } + and isinstance(value, (int, float)) + and isinstance(preferred.get(metric), (int, float)) + and value > preferred[metric] + } + supplied = { + (row.alternativeCandidateId, row.metric): row + for row in conclusion.tradeoffs + } + if not required_tradeoffs.keys() <= supplied.keys(): + raise ValueError( + "The comparison preference must explain each observed alternative's better stability, marker or separability measurement" + ) + for key, row in supplied.items(): + if ( + key not in required_tradeoffs + or (row.preferredValue, row.alternativeValue) + != required_tradeoffs[key] + ): + raise ValueError( + "Comparison tradeoffs must quote the exact preferred and alternative measurements" + ) + if action["action"] == "combine": + if coverage["phase"] != "sensitivity": + raise ValueError( + "Only the sensitivity assessment may propose combined settings" + ) + proposal = CombinedSettings.model_validate(action.get("combinedSettings")) + for field, axis in _CHOICE_AXES.items(): + if getattr(proposal, field) not in axis_candidates[axis]: + raise ValueError( + "Combined settings must use values from the matching observed sensitivity axis" + ) + if getattr(proposal, field) != by_axis[axis].preferredCandidateId: + raise ValueError( + "The proposed combination must agree with its comparison conclusions" + ) + if action["action"] == "accept": + if ( + coverage["phase"] == "sensitivity" + or coverage["combinedCandidateId"] not in settings + ): + raise ValueError( + "Acceptance requires execution of the proposed combined settings" + ) + combined = settings[coverage["combinedCandidateId"]] + partition_ids = coverage["resolutionCandidateIds"] + if not isinstance(partition_ids, list) or len(partition_ids) != 4: + raise ValueError( + "Acceptance requires four resolutions on the combined representation" + ) + resolutions = set() + for identifier in partition_ids: + if identifier not in settings: + raise ValueError("A final resolution candidate is unavailable") + row = settings[identifier] + if ( + row["status"] != "done" + or row["cellSelection"] != combined["cellSelection"] + ): + raise ValueError( + "Final resolutions must be completed on the same cells" + ) + if set(setting_changes(combined, row)) - {"partition", "correction"}: + raise ValueError( + "Final resolution evidence changed the combined representation" + ) + resolutions.add(row["parameters"]["leidenResolution"]) + if resolutions != {0.5, 0.75, 1.0, 1.25}: + raise ValueError("Final resolution coverage is incomplete") + selected = settings.get(action.get("selectedCandidateId")) + if selected is None: + raise ValueError("Accepted settings lack exact completed evidence") + concerns = [ + PopulationConcern.model_validate(row) + for row in action.get("populationConcerns", []) + ] + if any(row.status == "unresolvedEssential" for row in concerns): + raise ValueError( + "An essential population interpretation remains unresolved" + ) + missing_markers = { + str(cluster) + for cluster, genes in selected["metrics"].get("topMarkerGenes", {}).items() + if not genes + } + if selected["metrics"].get("nClusters") != len( + selected["metrics"].get("topMarkerGenes", {}) + ): + raise ValueError( + "Acceptance needs marker support or explicit missing-marker evidence for every selected cluster" + ) + addressed = { + row.clusterId + for row in concerns + if row.candidateId == action["selectedCandidateId"] + } + if not missing_markers <= addressed: + raise ValueError( + "Acceptance must explicitly resolve each selected population without qualifying markers against the objective" + ) + for concern in concerns: + if concern.candidateId not in settings or not set( + concern.evidenceIds + ) <= set(action["evidenceIds"]): + raise ValueError( + "Population concerns must cite supplied candidate evidence used by the assessment" + ) + if not any( + set(setting_changes(selected, settings[identifier])) <= {"correction"} + for identifier in partition_ids + ): + repair = coverage.get("fullRepair") + if ( + coverage["phase"] != "validation" + or not isinstance(repair, Mapping) + or repair.get("selectedCandidateId") != action["selectedCandidateId"] + or repair.get("baselineCandidateId") not in settings + or len( + setting_changes(settings[repair["baselineCandidateId"]], selected) + ) + != 1 + ): + raise ValueError( + "Accepted settings were not validated by the final comparison panel or its one targeted full repair" + ) diff --git a/scarf/agent/parameter_tuning/diagnostics.py b/scarf/agent/parameter_tuning/diagnostics.py index 1eff9f4f..4fcbc55b 100644 --- a/scarf/agent/parameter_tuning/diagnostics.py +++ b/scarf/agent/parameter_tuning/diagnostics.py @@ -15,6 +15,7 @@ read_metadata_missing_rows_chunkwise, read_metadata_rows_chunkwise, ) +from ...metadata.selection import resolve_cell_aligned_artifact from ...quality_control.cell_cycle_genes import ( g2m_phase_genes, g2m_phase_genes_mouse, @@ -512,6 +513,7 @@ def _covariate_associations( roles: Sequence[str], column_kinds: Mapping[str, str] | None = None, support: dict[str, Any] | None = None, + column_artifacts: Mapping[str, ArtifactRef] | None = None, ) -> np.ndarray: if len(columns) != len(roles): raise ValueError("PCA covariate columns and roles must align") @@ -519,8 +521,28 @@ def _covariate_associations( from ..experimental_context.characterization import _infer_kind for index, column in enumerate(columns): - values = _aligned_metadata_values(store, cell_selection, column) - kind = (column_kinds or {}).get(column) or _infer_kind(values) + artifact = (column_artifacts or {}).get(column) + values = ( + _aligned_metadata_values(store, cell_selection, column) + if artifact is None + else np.asarray( + resolve_cell_aligned_artifact( + store.zw, + artifact, + cell_selection=cell_selection, + expected_kind="quality_metric", + ).values + ) + ) + if values.shape != (coordinates.shape[0],): + raise ValueError(f"Covariate {column!r} does not align with PCA rows") + kind = (column_kinds or {}).get(column) or ( + "continuous" if artifact is not None else _infer_kind(values) + ) + if artifact is not None and kind != "continuous": + raise ValueError( + "QC percentage artifacts require continuous covariate evidence" + ) if kind not in {"continuous", "categorical"}: raise ValueError(f"Unknown covariate kind for {column!r}: {kind!r}") valid = np.asarray(~pd.isna(values), dtype=bool) @@ -581,6 +603,7 @@ def _write_pca_diagnostic( covariate_roles: Sequence[str], adjacent_overlap: float | None, column_kinds: Mapping[str, str] | None = None, + column_artifacts: Mapping[str, ArtifactRef] | None = None, ) -> tuple[ ArtifactRef, np.ndarray, @@ -590,6 +613,17 @@ def _write_pca_diagnostic( np.ndarray, np.ndarray, ]: + if column_artifacts: + column_kinds = dict(column_kinds or {}) + for column, artifact in column_artifacts.items(): + if ( + artifact.kind != "quality_metric" + or column_kinds.get(column, "continuous") != "continuous" + ): + raise ValueError( + "QC percentage artifacts require continuous covariate evidence" + ) + column_kinds[column] = "continuous" reduction = _artifact_ref(evaluation, "pca") neighbors = _artifact_ref(evaluation, "neighbors") reduction_status = store.inspect_artifact(reduction) @@ -628,7 +662,11 @@ def _write_pca_diagnostic( for family, mask in family_masks.items() }, "covariate_fingerprints": { - column: _metadata_column_fingerprint(store.cells, column) + column: ( + column_artifacts[column].to_dict() + if column_artifacts and column in column_artifacts + else _metadata_column_fingerprint(store.cells, column) + ) for column in covariate_columns }, "adjacent_neighbor_overlap": adjacent_overlap, @@ -638,6 +676,7 @@ def _write_pca_diagnostic( "reduction": reduction, "neighbors": neighbors, "feature_selection": feature_selection, + "covariate_artifacts": dict(column_artifacts or {}), }, execution_options={}, invalidate_cache=False, @@ -742,6 +781,7 @@ def _write_pca_diagnostic( covariate_roles, column_kinds, covariate_support, + column_artifacts, ) if evaluation.cellSelection is not None else np.zeros((len(covariate_columns), coordinates.shape[1]), dtype=np.float64) @@ -803,6 +843,7 @@ def augment_pca_evaluations( qc_columns: Sequence[str], batch_columns: Sequence[str] = (), column_kinds: Mapping[str, str] | None = None, + qc_artifacts: Mapping[str, ArtifactRef] | None = None, ) -> tuple[ParameterCandidateEvaluation, ...]: """Attach persisted PCA loading, variance, topology, and covariate evidence.""" selected_indices, selected_names = _selected_feature_names( @@ -830,7 +871,9 @@ def augment_pca_evaluations( ("qc", qc_columns), ): for column in values: - if column in store.cells.columns and column not in columns: + if ( + column in store.cells.columns or column in (qc_artifacts or {}) + ) and column not in columns: columns.append(column) roles.append(role) completed = [ @@ -883,6 +926,7 @@ def augment_pca_evaluations( covariate_roles=roles, adjacent_overlap=previous_by_id[evaluation.candidateId], column_kinds=column_kinds, + column_artifacts=qc_artifacts, ) family_maxima = { family: float(family_enrichment[index].max(initial=0.0)) diff --git a/scarf/agent/report/artifacts.py b/scarf/agent/report/artifacts.py index 9364fb11..6e30eccb 100644 --- a/scarf/agent/report/artifacts.py +++ b/scarf/agent/report/artifacts.py @@ -7,7 +7,7 @@ from ...storage.refs import ArtifactRef from ...storage.stores import zarr_root_path from ..types import ArtifactReferenceModel -from .contracts import mapping, mappings, texts +from .contracts import label, mapping, mappings, texts if TYPE_CHECKING: from ...datastore.datastore import DataStore @@ -58,6 +58,9 @@ def artifact_ref(value: Any) -> ArtifactRef: def scientific_summary(snapshot: Mapping[str, Any]) -> dict[str, Any]: """Select recorded scientific values without inferring decision rationales.""" + from ..orchestrator.rna import validate_analysis_evidence + + validate_analysis_evidence(snapshot) final = mapping(snapshot.get("finalAnalysis")) stages = mappings(snapshot.get("stages")) decisions = [ @@ -65,21 +68,22 @@ def scientific_summary(snapshot: Mapping[str, Any]) -> dict[str, Any]: ] limitations = texts(final.get("limitations")) + texts(snapshot.get("limitations")) assessments = mappings(snapshot.get("analysisReviews")) - full_assessments = [item for item in assessments if item["scope"] == "full"] + full_assessments = [ + item + for item in assessments + if item["scope"] == "full" and item.get("action") == "accept" + ] accepted = full_assessments[-1] if full_assessments else {} - findings = texts(accepted.get("quantitativeFindings")) + texts( - accepted.get("qualitativeFindings") - ) selected: dict[str, Any] = {} - alternatives: list[dict[str, Any]] = [] qc_profiles: list[dict[str, Any]] = [] qc_profile_id: Any = None + context: dict[str, Any] = {} + study: dict[str, Any] = {} for stage in stages: report = mapping(stage.get("report")) stage_name = str(stage.get("stage", "")) if stage_name.startswith("parameter_tuning"): evaluations = mappings(report.get("evaluations")) - alternatives = evaluations recommended = report.get("recommendedCandidateId") selected = next( ( @@ -90,6 +94,8 @@ def scientific_summary(snapshot: Mapping[str, Any]) -> dict[str, Any]: selected, ) if stage_name == "experimental_context": + context = report + study = mapping(mapping(stage.get("outputs")).get("studyContract")) qc_profile_id = mapping(report.get("cellQc")).get("profileId") qc_profiles = mappings(report.get("qcProfiles")) outputs = mapping(stage.get("outputs")) @@ -100,14 +106,67 @@ def scientific_summary(snapshot: Mapping[str, Any]) -> dict[str, Any]: qc = next( (item for item in qc_profiles if item.get("profileId") == qc_profile_id), {} ) + limitations.extend(texts(study.get("limitations"))) + limitations.extend( + str(item["explanation"]) + for item in mappings(accepted.get("populationConcerns")) + if item.get("explanation") + ) + comparison_limits = {} + for comparison in mappings( + mapping(context.get("characterization")).get("comparisons") + ): + if comparison.get("status") != "unsupported": + continue + proposal = mapping(comparison.get("proposal")) + question = ( + f"{label(str(proposal.get('response', 'Study factor')))} against " + + ", ".join( + label(name) for name in texts(proposal.get("explanatoryColumns")) + ) + ) + if proposal.get("conditionedOn"): + question += f" within {label(str(proposal['conditionedOn']))} groups" + reasons = "; ".join( + label(reason) for reason in texts(comparison.get("reasons")) + ) + comparison_limits[str(comparison.get("evidenceId"))] = ( + f"{question}: the requested association was not computed. {reasons}." + ) + displayed_limits = [] + for limitation in limitations: + match = next( + ( + text + for identity, text in comparison_limits.items() + if identity and identity in limitation + ), + None, + ) + displayed_limits.append(match or limitation) + population = mapping( + mapping(accepted.get("populationSupport")).get( + str(accepted.get("selectedCandidateId")) + ) + ) + if population: + for key in ("cellSelection", "clusters"): + if artifact_ref(population.get(key)) != artifact_ref(final.get(key)): + raise ValueError( + "Reported population support differs from the final analysis" + ) + if population.get("candidateId") != accepted.get("selectedCandidateId"): + raise ValueError("Reported population support belongs to another candidate") return { "request": mapping(snapshot.get("request")), "finalAnalysis": final, "decisions": decisions, "assessments": assessments, - "alternatives": alternatives, - "findings": list(dict.fromkeys(findings)), - "limitations": list(dict.fromkeys(limitations)), + "accepted": accepted, + "context": context, + "study": study, + "populationSupport": population, + "limitations": list(dict.fromkeys(displayed_limits)), "selectedParameters": mapping(selected.get("parameters")), "selectedMetrics": mapping(selected.get("metrics")), "selectedSetting": mapping( @@ -121,4 +180,5 @@ def scientific_summary(snapshot: Mapping[str, Any]) -> dict[str, Any]: ) ), "qc": qc, + "qcProfiles": qc_profiles, } diff --git a/scarf/agent/report/contracts.py b/scarf/agent/report/contracts.py index c2bfe98f..1b920ee2 100644 --- a/scarf/agent/report/contracts.py +++ b/scarf/agent/report/contracts.py @@ -26,6 +26,21 @@ def texts(value: Any) -> list[str]: def label(value: str) -> str: + names = { + "donor_id": "Donor", + "sample_id": "Sample", + "library_id": "Capture", + "T2D": "T2D status", + "hvgCount": "Number of variable genes", + "hvgRanking": "Variable-gene ranking", + "observationAndIndependentUnitsMustBeDesignOrTechnical": "The saved analysis did not support the declared study-unit roles", + "withinIndependentUnitComparisonsAreUnsupported": "Association testing for repeated observations from one study unit is unavailable", + "continuousConditioningIsUnsupported": "Conditioning on a continuous variable is unavailable", + "fewerThanFourIndependentUnits": "Fewer than four independent study units", + "unsupportedConditioningStrata": "Some conditioned groups do not support the requested comparison", + } + if value in names: + return names[value] return re.sub(r"(?<=[a-z])(?=[A-Z])", " ", value.replace("_", " ")).capitalize() diff --git a/scarf/agent/report/rendering.py b/scarf/agent/report/rendering.py index cbeb92bb..21173032 100644 --- a/scarf/agent/report/rendering.py +++ b/scarf/agent/report/rendering.py @@ -1,6 +1,7 @@ """A single readable analysis page, using only recorded scientific evidence.""" import html +import math from collections.abc import Mapping, Sequence from typing import Any @@ -13,6 +14,8 @@ header{border-bottom:2px solid #237e6a;padding-bottom:22px}h1,h2,h3{line-height:1.25;color:#164c40} h1{font-size:2.2rem;margin:8px 0}h2{font-size:1.4rem;margin-top:36px}h3{font-size:1.05rem} p{max-width:90ch}a{color:#17644f}small,.muted{color:#586763}.numbers{font-size:1.25rem;font-weight:600} +.fraction{white-space:nowrap}progress{width:86px;height:12px;accent-color:#237e6a}.notice{background:#fff6df;border-left:4px solid #bd8b22;padding:12px 18px;margin:20px 0}.notice h2{margin-top:0} +.population-overview{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.3fr);gap:20px;align-items:start}.population-overview figure{position:sticky;top:20px}.population-overview>div{min-width:0} figure{margin:24px 0;background:white;padding:12px;border-radius:8px}figure img{width:100%;height:auto} figcaption{font-size:.9rem;text-align:center}.decision{border-top:1px solid #ccd7d1;padding:14px 0} .decision h3{margin:0}.decision p{margin:8px 0}details{margin:12px 0}summary{cursor:pointer;color:#17644f} @@ -20,6 +23,7 @@ th,td{text-align:left;vertical-align:top;padding:9px 12px;border-bottom:1px solid #d9e0dc} th{background:#e9efeb}td p{margin:0}li{margin:6px 0} footer{margin-top:36px;border-top:1px solid #ccd7d1;padding-top:18px;font-size:.85rem} +@media(max-width:800px){.population-overview{display:block}.population-overview figure{position:static}} @media(max-width:600px){main{padding:20px 14px}h1{font-size:1.7rem}th,td{padding:7px}} @media print{body{background:white}main{padding:0}details{break-inside:avoid}} """ @@ -48,60 +52,361 @@ def _table(headers: Sequence[str], rows: Sequence[Sequence[Any]]) -> str: return f'
    {head}{body}
    ' -def _decision(decision: Mapping[str, Any]) -> str: - spec, record = mapping(decision.get("spec")), mapping(decision.get("record")) - options = mappings(spec.get("options")) - selected = next( - ( - option - for option in options - if option.get("optionId") == record.get("selectedOptionId") - ), - {}, +def _fraction_bar(value: Any) -> str: + if ( + not isinstance(value, int | float) + or not math.isfinite(value) + or not 0 <= value <= 1 + ): + return "Unavailable" + return f' {value:.1%}' + + +def _percentage(value: Any) -> str: + return ( + f"{value:.1%}" + if isinstance(value, int | float) and math.isfinite(value) and 0 <= value <= 1 + else "Unavailable" ) - question = str( - spec.get("question") or label(str(record.get("decisionId", "Analysis setting"))) + + +def _scope(assessment: Mapping[str, Any]) -> str: + coverage = mapping(assessment.get("coverage")) + all_cells = ( + assessment.get("scope") == "full" + or mapping(assessment.get("comparisonCoverage")).get("population") == "allCells" ) - chosen = str(selected.get("label") or "Selection unavailable") - rationale = str(record.get("rationale") or "No rationale was recorded.") - source = { - "agent": "Agent choice", - "rule": "Scarf rule", - "human": "User choice", - }.get(str(record.get("source", "")), "") - alternatives = _table( - ("Option", "Action", "Description"), - [ - [ - option.get("label"), - "Selected" if option is selected else "Not selected", - option.get("description"), - ] - for option in options - ], + name = "Full cohort" if all_cells else "Screening sample" + size = coverage.get("screeningCells") + return f"{name} ({size:,} cells)" if isinstance(size, int) else name + + +def _population_table(payload: Mapping[str, Any]) -> str: + counts = mapping(payload.get("clusterCounts")) + total = sum(counts.values()) + markers = mappings(payload.get("markers")) + support = mapping(payload.get("populationSupport")) + units = texts(mapping(payload.get("study")).get("independentUnitColumns")) + columns = mapping(support.get("columns")) + unit = next((name for name in units if name in columns), None) + evidence = mapping(columns.get(unit)) if unit is not None else {} + populations = { + str(row["cluster"]): row for row in mappings(evidence.get("populations")) + } + rows = [] + for cluster, count in counts.items(): + row = populations.get(str(cluster), {}) + genes = ", ".join( + str(item["feature"]) + for item in markers + if str(item.get("cluster")) == str(cluster) + ) + observed = row.get("groupsWithAtLeast5Cells") + unit_count = str(observed) if isinstance(observed, int) else "Unavailable" + rows.append( + f"{_escape(cluster)}{count:,}
    {_fraction_bar(count / total if total else None)}" + f"{_escape(genes or 'No marker preview available')}" + f"{unit_count}{_fraction_bar(row.get('largestGroupFraction'))}" + ) + unit_label = label(unit) if unit else "Study unit" + omitted = evidence.get("omittedPopulations", 0) + notes = [] + if omitted: + notes.append( + f"Support details were not saved for {omitted} populations; their support is unavailable here." + ) + if evidence.get("missingCells"): + notes.append( + f"{evidence['missingCells']:,} cells lack the recorded study-unit metadata." + ) + if not evidence: + notes.append("No per-population study-unit evidence was saved.") + return ( + '
    ' + f"" + + "".join(rows) + + "
    PopulationCellsTop marker genes{_escape(unit_label)} groups with ≥5 cellsLargest group contribution
    " + + '

    Markers describe gene programs, not validated cell identities. Study-unit counts and concentration describe observed support; five cells is not a replication threshold.

    ' + + _list(notes) ) - evidence = mappings(mapping(decision.get("evidence")).get("evidence")) - evidence_markup = _list( - [str(item["summary"]) for item in evidence if item.get("summary")] + + +def _qc_section(payload: Mapping[str, Any]) -> str: + qc = mapping(payload.get("qc")) + profiles = mappings(payload.get("qcProfiles")) + names = { + "coreGlobalGaussian": "Scarf default global filter", + "globalGaussian": "Scarf default global filter", + "coreSampleMad3": "Scarf filter within samples", + "sampleMad": "Scarf filter within samples", + "retainWithFlags": "Retain cells with quality flags", + "skip": "Retain cells without filtering", + "globalMad5": "Lenient global filter", + "captureMad5": "Lenient filter within captures", + "captureMad3Sensitivity": "Stricter filter within captures", + "pooledReferenceMad5": "Filter using reference captures", + } + rows = [] + for profile in profiles: + name = names.get( + str(profile.get("registeredProfile") or profile.get("action")), + "Recorded quality filter", + ) + if profile.get("sampleColumn"): + name += f" ({label(str(profile['sampleColumn']))})" + chosen = profile.get("profileId") == qc.get("profileId") + rows.append( + f"{_escape(name)}{' (selected)' if chosen else ''}" + f"{_escape(profile.get('retainedCells'))}" + f"{_fraction_bar(profile.get('retainedFraction'))}" + ) + table = ( + '
    ' + + "".join(rows) + + "
    Compared policyProjected cells retainedRetention
    " + if rows + else "

    Quality-policy comparisons are unavailable.

    " ) - checks = mappings(decision.get("checks")) - check_markup = _table( - ("Check", "Outcome", "Finding"), - [ + grouped = [] + for column, groups in mapping(qc.get("retainedCellsByColumn")).items(): + values = list(mapping(groups).values()) + if not values: + continue + description = ( + "; ".join(f"{name}: {count:,}" for name, count in groups.items()) + if len(values) <= 8 + else f"{len(values)} groups; smallest {min(values):,}, largest {max(values):,} cells" + ) + grouped.append([label(column), description]) + decisions = [ + mapping(item.get("record")) + for item in mappings(payload.get("decisions")) + if mapping(item.get("record")).get("decisionId") + in {"cellQuality", "qcGrouping"} + ] + quality = [item for item in decisions if item.get("decisionId") == "cellQuality"] + chosen_reason = f"

    {_escape(quality[-1].get('rationale'))}

    " if quality else "" + explanations = "".join( + f"

    {_escape(item.get('rationale'))}

    " + for item in decisions + if item.get("decisionId") == "qcGrouping" + ) + if explanations: + explanations = f"
    Study grouping for quality filtering{explanations}
    " + return f'

    Cell quality

    {chosen_reason}{table}{_table(("Retained study groups", "Cells"), grouped)}{explanations}
    ' + + +def _design_section(payload: Mapping[str, Any]) -> str: + study = mapping(payload.get("study")) + context = mapping(payload.get("context")) + characterization = mapping(context.get("characterization")) + correction = mapping(payload.get("selectedParameters")).get("useHarmony") + license = study.get("correctionLicense") + if correction is True: + correction_text = "Batch correction was applied to the selected representation." + elif license == "unsafeConfounded": + correction_text = "Batch correction was not applied: the recorded design cannot separate the proposed batch effects from protected biology." + elif correction is False: + correction_text = "The selected representation uses no batch correction." + else: + correction_text = "The correction decision is unavailable." + rows = [] + for coefficient in mappings(characterization.get("coefficients")): + replication = mapping(coefficient.get("replication")) + groups = mappings(replication.get("independentUnitsByGroup")) + counts = "; ".join( + f"{item.get('group')}: {item.get('count')}" for item in groups + ) + paired = mapping(coefficient.get("pairedCoverage")) + pairing = ( + f"{paired['completePairs']} of {paired.get('pairs', 'unavailable')} complete pairs" + if paired.get("design") == "mixedOrIncomplete" + else "Between study units" + if paired.get("betweenIndependentUnits") + else "" + ) + rows.append([label(str(coefficient.get("name", ""))), counts, pairing]) + requirements = { + item["requirementId"]: item + for item in mappings(study.get("evidenceRequirements")) + } + questions = [] + for item in mappings(study.get("evidenceCoverage")): + requirement = requirements.get(item.get("requirementId"), {}) + status = { + "computed": "Assessed", + "nonIdentifiable": "Not identifiable", + "unsupported": "Not assessed", + "failed": "Failed", + }.get(str(item.get("status")), "Unavailable") + questions.append( [ - item.get("label", item.get("name")), - item.get("status"), - item.get("reason", item.get("summary")), + requirement.get("question"), + status, + "; ".join(label(reason) for reason in texts(item.get("reasons"))), ] - for item in checks - ], - ) - return f"""
    -

    {html.escape(question)}

    -

    {html.escape(chosen)}. {html.escape(rationale)}

    -{"" + source + "" if source else ""} -
    Alternatives and supporting evidence{alternatives}{evidence_markup}{check_markup}
    -
    """ + ) + claims = texts(study.get("unsupportedClaims")) + return f'

    Study design and correction

    {_escape(correction_text)}

    {_table(("Study factor", "Independent units per group", "Design"), rows)}{_table(("Objective question", "Evidence", "Limit"), questions)}{_list(claims)}
    ' + + +_AXIS_LABELS = { + "hvgCount": "Number of variable genes", + "hvgRanking": "Variable-gene ranking", + "featurePolicy": "Gene families", + "pca": "PCA dimensions", + "dimensions": "PCA dimensions", + "neighbors": "Neighbors", + "resolution": "Clustering resolution", + "partition": "Clustering resolution", + "batchCorrection": "Batch correction", + "harmony": "Batch correction", +} + + +def _comparison_sections(payload: Mapping[str, Any]) -> str: + assessments = mappings(payload.get("assessments")) + scope_evidence = {str(item["scope"]): item for item in assessments} + sections = [] + for title, partition in (("Genes and representation", False), ("Clustering", True)): + scopes: dict[str, dict[str, Any]] = {} + for assessment in assessments: + coverage = mapping(assessment.get("comparisonCoverage")) + settings = mapping(coverage.get("candidateSettings")) + for conclusion in mappings(assessment.get("comparisonConclusions")): + if (conclusion.get("axis") == "partition") != partition: + continue + identities = texts(conclusion.get("candidateIds")) + for identity in identities: + candidate = mapping(settings.get(identity)) + scope = str(candidate.get("scope", "")) + if not candidate or scope not in {"sample0", "sample1", "full"}: + raise ValueError( + "Reported comparison lacks exact candidate scope and settings" + ) + group = scopes.setdefault( + scope, + { + "assessment": scope_evidence.get(scope, {"scope": scope}), + "conclusions": {}, + "candidates": {}, + "unavailable": {}, + }, + ) + key = (conclusion["axis"], tuple(sorted(identities))) + group["conclusions"][key] = conclusion + group["candidates"][identity] = candidate + for item in mappings(coverage.get("comparisons")): + if ( + item.get("status") != "notApplicable" + or (item.get("axis") == "partition") != partition + ): + continue + candidate = mapping(settings.get(str(item.get("baselineCandidateId")))) + scope = str(candidate.get("scope", "")) + if scope in scopes: + scopes[scope]["unavailable"][ + (item.get("axis"), item.get("reason")) + ] = item + content = [] + for group in scopes.values(): + content.append(f"

    {_escape(_scope(group['assessment']))}

    ") + for conclusion in group["conclusions"].values(): + axis = str(conclusion["axis"]) + content.append( + f"

    {_escape(_AXIS_LABELS.get(axis, label(axis)))}. {_escape(conclusion.get('plainLanguageSummary'))}

    " + ) + content.append( + f"
    Evidence behind this choice

    {_escape(conclusion.get('quantitativeReason'))}

    {_escape(conclusion.get('biologicalReason'))}

    " + ) + content.append( + _list( + texts( + [ + item.get("interpretation") + for item in mappings(conclusion.get("tradeoffs")) + ] + ) + ) + ) + rows = [] + for identity, candidate in group["candidates"].items(): + setting = candidate + parameters = mapping(candidate.get("parameters")) + metrics = mapping(candidate.get("metrics")) + preferred = [ + _AXIS_LABELS.get(item["axis"], label(item["axis"])) + for item in group["conclusions"].values() + if item.get("preferredCandidateId") == identity + ] + choice = ( + "Preferred: " + ", ".join(dict.fromkeys(preferred)) + if preferred + else "Compared" + ) + if identity == mapping(payload.get("accepted")).get( + "selectedCandidateId" + ): + choice = "Selected final settings" + if partition: + rows.append( + [ + parameters.get("leidenResolution"), + metrics.get("nClusters"), + metrics.get("minClusterCells"), + metrics.get("seedStability"), + metrics.get("subsampleStability"), + _percentage(metrics.get("markerCoherence")), + choice, + ] + ) + else: + ranking = {"batchAware": "Within batches", "global": "Global"}.get( + str(setting.get("ranking")), "Unavailable" + ) + rows.append( + [ + setting.get("hvgCount"), + ranking, + parameters.get("dimensions"), + parameters.get("neighborsK"), + metrics.get("nClusters"), + metrics.get("seedStability"), + _percentage(metrics.get("markerCoherence")), + choice, + ] + ) + headers = ( + ( + "Resolution", + "Populations", + "Smallest population", + "Repeat agreement", + "Subsample agreement", + "Clusters with qualifying markers", + "Choice", + ) + if partition + else ( + "Variable genes", + "Ranking", + "PCA dimensions", + "Neighbors", + "Populations", + "Repeat agreement", + "Clusters with qualifying markers", + "Choice", + ) + ) + content.append(_table(headers, rows)) + for item in group["unavailable"].values(): + axis = str(item["axis"]) + content.append( + f"

    {_escape(_AXIS_LABELS.get(axis, label(axis)))}: not compared. {_escape(item.get('reason'))}

    " + ) + if content: + sections.append(f"

    {title}

    {''.join(content)}
    ") + return "".join(sections) def render_analysis_document(payload: Mapping[str, Any]) -> str: @@ -109,7 +414,7 @@ def render_analysis_document(payload: Mapping[str, Any]) -> str: request = mapping(payload.get("request")) counts = mapping(payload.get("clusterCounts")) total = sum(int(value) for value in counts.values()) - context = request.get("studyObjective") or request.get("studyContext") or "" + objective = request.get("studyObjective") or request.get("studyContext") or "" assay = final.get("primaryAssay") or request.get("primaryAssay") or "RNA" qc = mapping(payload.get("qc")) qc_text = "" @@ -117,164 +422,46 @@ def render_analysis_document(payload: Mapping[str, Any]) -> str: qc.get("retainedFraction"), int | float ): qc_text = f"

    QC retained {_escape(qc['retainedCells'])} cells ({float(qc['retainedFraction']):.1%}).

    " + accepted = mapping(payload.get("accepted")) + outcome = str( + accepted.get("plainLanguageSummary") + or "The selected populations and their marker programs are recorded below." + ) map_markup = "" if payload.get("umap"): display = int(payload.get("displayedCells") or total) - map_markup = f'
    Final UMAP colored by saved cluster labels
    {display:,} of {total:,} cells shown. Counts and marker statistics use the complete selection.
    ' - decisions = "".join(_decision(item) for item in mappings(payload.get("decisions"))) - assessments = mappings(payload.get("assessments")) - for assessment in assessments: - scope = ( - "Full cohort" if assessment.get("scope") == "full" else "Screening sample" - ) - alternatives = mappings(assessment.get("candidates")) - settings = mapping(assessment.get("settings")) - comparison = _table( - ( - "Resolution", - "PCA dimensions", - "Neighbors", - "HVGs", - "HVG ranking", - "Harmony", - "Clusters", - "Seed stability", - "Marker coherence", - "Selected", - ), - [ - [ - mapping(item.get("parameters")).get("leidenResolution"), - mapping(item.get("parameters")).get("dimensions"), - mapping(item.get("parameters")).get("neighborsK"), - mapping(settings.get(str(item.get("candidateId")))).get("hvgCount"), - mapping(settings.get(str(item.get("candidateId")))).get("ranking"), - mapping(item.get("parameters")).get("useHarmony"), - mapping(item.get("metrics")).get("nClusters"), - mapping(item.get("metrics")).get("seedStability"), - mapping(item.get("metrics")).get("markerCoherence"), - item.get("candidateId") == assessment.get("selectedCandidateId"), - ] - for item in alternatives - ], - ) - assessment_findings = texts(assessment.get("quantitativeFindings")) + texts( - assessment.get("qualitativeFindings") - ) - details = _list(assessment_findings) - if assessment.get("evidenceMode") == "structured": - details = ( - "

    The model assessed structured loading, marker and diagnostic evidence. " - "No plots were supplied for visual inspection.

    " + details - ) - rationale = html.escape(str(assessment.get("rationale", ""))) - action = { - "accept": "Accepted settings", - "experiment": "Selected a targeted experiment", - "enlarge": "Requested more cells", - "defer": "Required more evidence", - }.get(str(assessment.get("action", "")), "Analysis assessment") - protection = html.escape(str(assessment.get("objectivePreservation", ""))) - experiment = "" - if assessment.get("experimentId"): - experiment = ( - f"

    Experiment: {_escape(assessment['experimentId'])}

    " - f"

    Observed concern: {_escape(assessment.get('concern'))}

    " - f"

    Expected improvement: {_escape(assessment.get('expectedImprovement'))}

    " - ) - correction = assessment.get("correctionNeed") - correction_text = ( - f"

    Correction necessity: {_escape(label(str(correction)))}.

    " - if correction - else "" - ) - decisions += f'

    {scope}: {action}

    {rationale}

    {experiment}
    Compared settings and evidence{comparison}{details}{correction_text}

    {protection}

    ' - if not decisions: - decisions = "

    No consequential decisions were recorded.

    " - findings = _list(texts(payload.get("findings"))) - marker_rows = mappings(payload.get("markers")) - cluster_table = _table( - ("Cluster", "Cells", "Top marker genes"), - [ - [ - cluster, - count, - ", ".join( - str(row["feature"]) - for row in marker_rows - if row.get("cluster") == cluster - ) - or "No markers passed the saved-table filters", - ] - for cluster, count in counts.items() - ], + map_markup = f'
    Final UMAP colored by saved population labels
    {display:,} of {total:,} cells shown. Counts and markers use the complete selection.
    ' + limitations = _list(texts(payload.get("limitations"))) + display_notes = _list(texts(payload.get("displayNotes"))) + mode_note = ( + "

    The model assessed structured evidence. No plots were supplied for visual inspection.

    " + if accepted.get("evidenceMode") == "structured" + else "" ) parameters = mapping(payload.get("selectedParameters")) - metrics = mapping(payload.get("selectedMetrics")) - selected_setting = mapping(payload.get("selectedSetting")) - selected_features = mapping(payload.get("selectedFeatures")) + setting = mapping(payload.get("selectedSetting")) methods = _table( - ("Setting", "Selected value"), - [ - [name, parameters[key]] - for key, name in ( - ("dimensions", "PCA dimensions"), - ("neighborsK", "Neighbors"), - ("leidenResolution", "Clustering resolution"), - ("useHarmony", "Harmony correction"), - ) - if key in parameters - ], - ) - methods += _table( - ("Gene selection", "Selected value"), - [ - [name, selected_setting[key]] - for key, name in ( - ("hvgCount", "HVG count"), - ("ranking", "HVG ranking"), - ("rankingColumn", "Ranking technical column"), - ) - if selected_setting.get(key) is not None - ], - ) - methods += _table( - ("Feature family", "Eligible genes", "Selected HVGs"), - [ - [ - label(name), - mapping(values).get("eligibleGenes"), - mapping(values).get("selectedGenes"), - ] - for name, values in mapping(selected_features.get("families")).items() - ], - ) - measurements = _table( - ("Measure", "Recorded value"), + ("Selected setting", "Value"), [ - [name, metrics[key]] - for key, name in ( - ("seedStability", "Clustering stability across seeds"), - ("subsampleStability", "Clustering stability across subsamples"), - ("markerCoherence", "Marker coherence"), - ("crossUnitSupport", "Support across study units"), - ("minClusterCells", "Smallest cluster"), + [name, value] + for name, value in ( + ("Variable genes", setting.get("hvgCount")), + ("PCA dimensions", parameters.get("dimensions")), + ("Neighbors", parameters.get("neighborsK")), + ("Clustering resolution", parameters.get("leidenResolution")), + ("Batch correction", parameters.get("useHarmony")), ) - if key in metrics and metrics[key] is not None ], ) - limitations = _list(texts(payload.get("limitations"))) - display_notes = _list(texts(payload.get("displayNotes"))) return f""" Scarf analysis summary
    -
    Scarf analysis

    Analysis summary

    {_escape(context)}

    -

    {total:,} cells · {len(counts):,} clusters · {_escape(assay)}

    {qc_text}
    -{map_markup}

    Analysis decisions

    {decisions}
    -{"

    What the evidence shows

    " + findings + "
    " if findings else ""} -

    Clusters and markers

    {cluster_table}

    Marker genes describe the saved clusters; they do not establish cell identities.

    -{"

    Limitations

    " + limitations + "
    " if limitations else ""} -
    Selected methods and measurements{methods}{measurements}

    All measurements and explanations are read from the completed analysis. The map uses saved coordinates and a bounded display sample. No analysis or model calls run when this report is generated.

    +
    Scarf analysis

    Analysis summary

    {_escape(objective)}

    +

    {total:,} cells · {len(counts):,} clusters · {_escape(assay)}

    {qc_text}

    {_escape(outcome)}

    +{'" if limitations else ""} +

    Populations and markers

    {map_markup}
    {_population_table(payload)}
    +{_qc_section(payload)}{_design_section(payload)}{_comparison_sections(payload)} +
    Selected methods and evidence{methods}{mode_note}

    Repeat and subsample agreement use adjusted Rand index. Marker coverage is the fraction of clusters with qualifying markers. These describe the selected analysis; they are not probabilities of biological correctness.

    {"
    Unavailable displays" + display_notes + "
    " if display_notes else ""} -
    +
    Generated locally by Scarf. All numerical evidence is read from the saved analysis; report generation makes no analysis or model calls.
    """ diff --git a/tests/agent_comparison_examples.py b/tests/agent_comparison_examples.py new file mode 100644 index 00000000..f503434c --- /dev/null +++ b/tests/agent_comparison_examples.py @@ -0,0 +1,341 @@ +"""Exact, completed RNA comparison evidence for contract and report tests.""" + +from copy import deepcopy + + +def observed_action(evidence: dict, *, selected: str | None = None) -> dict: + """A deterministic synthetic-study reviewer grounded in its supplied rows.""" + coverage = evidence["comparisonCoverage"] + rows = coverage["comparisons"] + settings = coverage["candidateSettings"] + baseline = rows[0]["baselineCandidateId"] + chosen = selected or evidence["currentCandidateId"] + combining = coverage["phase"] == "sensitivity" + pending = next((row for row in rows if row["status"] == "pending"), None) + experiment = ( + next( + ( + key + for key, value in evidence.get("experiments", {}).items() + if value["parameter"] + in { + "includeFamily", + "excludeFamily", + "includeFeature", + "excludeFeature", + } + ), + None, + ) + if pending + else None + ) + conclusions = [] + for axis in dict.fromkeys(row["axis"] for row in rows): + ids = list( + dict.fromkeys( + identifier + for row in rows + if row["axis"] == axis + for identifier in ( + row["baselineCandidateId"], + row["alternativeCandidateId"], + ) + if identifier + ) + ) + preferred = ( + chosen + if axis == "partition" and chosen in ids and not combining + else baseline + ) + metrics = settings[preferred]["metrics"] + tradeoffs = [] + for identifier in ids: + if identifier == preferred: + continue + for metric in ( + "seedStability", + "subsampleStability", + "markerCoherence", + "markerSpecificityMedian", + "macroF1", + ): + value = settings[identifier]["metrics"].get(metric) + current = metrics.get(metric) + if ( + isinstance(value, (int, float)) + and isinstance(current, (int, float)) + and value > current + ): + tradeoffs.append( + { + "alternativeCandidateId": identifier, + "metric": metric, + "preferredValue": current, + "alternativeValue": value, + "interpretation": "The synthetic reference's marker program and exact population partition remain the objective; this measured improvement alone does not justify replacing that partition.", + } + ) + conclusions.append( + { + "axis": axis, + "candidateIds": ids, + "preferredCandidateId": preferred, + "quantitativeReason": f"Compare the actual stability and marker measurements for {len(ids)} observed settings.", + "biologicalReason": "Preserve the synthetic reference marker programs and population membership.", + "plainLanguageSummary": "The reference population remains represented with its marker program.", + "tradeoffs": tradeoffs, + } + ) + evidence_ids = [f"candidate:{chosen}", *evidence.get("imageHashes", {})] + return { + "action": "experiment" if pending else "combine" if combining else "accept", + "selectedCandidateId": chosen, + "experimentId": experiment, + "combinedSettings": { + field: baseline + for field in ( + "hvgCountCandidateId", + "hvgRankingCandidateId", + "featurePolicyCandidateId", + "pcaCandidateId", + "neighborsCandidateId", + ) + } + if combining and not pending + else None, + "correctionNeed": "notApplicable", + "evidenceIds": evidence_ids, + "quantitativeFindings": [ + "Use the supplied same-cell marker and stability measurements." + ], + "qualitativeFindings": [ + "Interpret the observed marker names against the known synthetic population program." + ], + "comparisonConclusions": conclusions, + "populationConcerns": [ + { + "candidateId": chosen, + "clusterId": cluster, + "status": "nonEssentialLimitation", + "evidenceIds": evidence_ids, + "explanation": "No cell identity is assigned to this marker-poor synthetic background group; the reference marker-defined group remains supported.", + } + for cluster, genes in settings[chosen]["metrics"] + .get("topMarkerGenes", {}) + .items() + if not genes + ], + "plainLanguageSummary": "The observed reference population and its marker genes are retained.", + "concern": "Test the nominated family contribution to the selected population program." + if pending + else "", + "expectedImprovement": "Determine whether this exact gene policy changes marker-supported separation." + if pending + else "", + "objectivePreservation": "Retain the known synthetic population and marker program.", + "rationale": "Use the measured comparisons and preserve the explicitly defined synthetic reference population.", + } + + +def comparison_review(scope: str = "full") -> dict: + cells = { + "scope": "datastore", + "assay": None, + "kind": "cell_selection", + "artifactId": "c" * 64, + "type": "artifact", + } + features = { + "scope": "assay", + "assay": "RNA2", + "kind": "feature_selection", + "artifactId": "4" * 64, + "type": "artifact", + } + baseline = { + "scope": scope, + "status": "done", + "nCells": 621200, + "cellSelection": cells, + "features": features, + "eligibleFeatures": {**features, "artifactId": "6" * 64}, + "hvgCount": 1000, + "ranking": "global", + "rankingColumn": None, + "parameters": { + "dimensions": 20, + "neighborsK": 15, + "leidenResolution": 1.0, + "useHarmony": False, + }, + "metrics": { + "nClusters": 2, + "minClusterCells": 1200, + "seedStability": 0.92, + "subsampleStability": 0.88, + "markerCoherence": 0.84, + "topMarkerGenes": {"0": ["MS4A1"], "1": ["CD3D"]}, + }, + } + settings = {"baseline": baseline} + rows = [] + for comparison_id, axis, identity, field, value in ( + ( + "defaultResolution:0.5", + "partition", + "resolution-half", + "leidenResolution", + 0.5, + ), + ( + "defaultResolution:0.75", + "partition", + "candidate-two", + "leidenResolution", + 0.75, + ), + ( + "defaultResolution:1.25", + "partition", + "resolution-high", + "leidenResolution", + 1.25, + ), + ("hvgCount:2000", "hvgCount", "genes-two", "hvgCount", 2000), + ("hvgCount:4000", "hvgCount", "genes-four", "hvgCount", 4000), + ("dimensions:10", "pca", "dimensions-ten", "dimensions", 10), + ("dimensions:30", "pca", "dimensions-thirty", "dimensions", 30), + ("neighborsK:21", "neighbors", "neighbors-twenty-one", "neighborsK", 21), + ("neighborsK:41", "neighbors", "neighbors-forty-one", "neighborsK", 41), + ): + setting = deepcopy(baseline) + target = setting if field == "hvgCount" else setting["parameters"] + target[field] = value + if field == "hvgCount": + setting["features"] = { + **features, + "artifactId": ("8" if value == 2000 else "9") * 64, + } + settings[identity] = setting + rows.append( + { + "comparisonId": comparison_id, + "axis": axis, + "status": "completed", + "baselineCandidateId": "baseline", + "alternativeCandidateId": identity, + "reason": "Observed sensitivity comparison", + } + ) + for axis, reason in ( + ("hvgRanking", "No technical grouping supports a batch-specific ranking."), + ( + "featurePolicy", + "No observed unprotected gene family supports an exclusion comparison.", + ), + ): + rows.append( + { + "comparisonId": axis, + "axis": axis, + "status": "notApplicable", + "baselineCandidateId": "baseline", + "alternativeCandidateId": None, + "reason": reason, + "observedProof": { + "baselineFeatures": features, + **( + { + "kind": "insufficientTechnicalGroups", + "eligibleGroupsByColumn": {}, + } + if axis == "hvgRanking" + else { + "kind": "noPermittedPolicy", + "meaningfulPermittedInterventions": 0, + "registeredFamilies": [], + } + ), + }, + } + ) + conclusions = [] + for axis in dict.fromkeys(row["axis"] for row in rows): + identities = [ + "baseline", + *[ + row["alternativeCandidateId"] + for row in rows + if row["axis"] == axis and row["alternativeCandidateId"] + ], + ] + conclusions.append( + { + "axis": axis, + "candidateIds": identities, + "preferredCandidateId": "candidate-two" + if axis == "partition" + else "baseline", + "quantitativeReason": "Repeat agreement was 0.92 and 84% of clusters had qualifying markers.", + "biologicalReason": "The recorded marker programs remain available for interpretation.", + "plainLanguageSummary": "Resolution 0.75 retains a small population with clear markers." + if axis == "partition" + else f"The observed {axis} alternatives did not justify changing this setting.", + } + ) + return { + "scope": scope, + "action": "accept", + "selectedCandidateId": "candidate-two", + "experimentId": None, + "correctionNeed": "notApplicable", + "evidenceIds": ["candidate:candidate-two:clusters"], + "quantitativeFindings": ["Repeat agreement was 0.92."], + "qualitativeFindings": ["MS4A1 and CD79A support the same marker program."], + "objectivePreservation": "Retain the small marker-supported population.", + "rationale": "Original detailed model reasoning retained in the journal.", + "plainLanguageSummary": "The selected settings retain a small population with clear marker genes.", + "comparisonConclusions": conclusions, + "evidenceMode": "structured", + "visualInspection": "unavailable", + "coverage": { + "populationCells": 621200, + "screeningCells": 621200 if scope == "full" else 50000, + }, + "comparisonCoverage": { + "phase": "validation" if scope == "full" else "combined", + "population": "allCells" if scope == "full" else "subset", + "comparisons": rows, + "candidateSettings": settings, + "combinedCandidateId": "baseline", + "resolutionCandidateIds": [ + "resolution-half", + "candidate-two", + "baseline", + "resolution-high", + ], + }, + "settings": { + identity: deepcopy(setting) for identity, setting in settings.items() + }, + "featureEvidence": { + identity: { + "selectedGenes": setting["hvgCount"], + "eligibleGenes": 12000, + "families": {"hla": {"eligibleGenes": 12, "selectedGenes": 6}}, + } + for identity, setting in settings.items() + }, + "candidates": [ + { + "candidateId": identity, + "parameters": setting["parameters"], + "metrics": setting["metrics"], + "cellSelection": cells, + "artifacts": {"graphFeatures": setting["features"]}, + } + for identity, setting in settings.items() + ], + } diff --git a/tests/agent_examples.py b/tests/agent_examples.py index 65f027a3..1fbf4e4b 100644 --- a/tests/agent_examples.py +++ b/tests/agent_examples.py @@ -693,6 +693,26 @@ def _example_45_StudyContract(cls): "This workflow does not test differential-expression hypotheses." ], evidenceIds=["column:batch", "column:donor", "column:treatment"], + evidenceRequirements=[ + { + "requirementId": "studyDesign", + "question": "Which independent units and design constraints support population discovery?", + "objectiveQuote": "Discover stable populations while preserving treatment-associated structure.", + "kind": "studyDesign", + "columns": ["batch", "donor", "treatment"], + "observationUnit": "sample", + "independentUnit": "donor", + "essential": True, + } + ], + evidenceCoverage=[ + { + "requirementId": "studyDesign", + "status": "computed", + "evidenceIds": ["column:batch", "column:donor", "column:treatment"], + "reasons": [], + } + ], ) diff --git a/tests/test_agent_beginner.py b/tests/test_agent_beginner.py index 3448cdf8..610a113f 100644 --- a/tests/test_agent_beginner.py +++ b/tests/test_agent_beginner.py @@ -73,8 +73,8 @@ def run(self, request: AutomatedWorkflowRequest) -> AutomatedWorkflowResult: assert config.inputPolicy == "unattended" assert config.screeningCells == 50_000 assert config.maxScreeningCells == 100_000 - assert config.maxScreeningEvaluations == 12 - assert config.maxTotalScreeningEvaluations == 24 + assert config.maxScreeningEvaluations == 24 + assert config.maxTotalScreeningEvaluations == 48 assert config.maxFullGraphs == 4 assert config.maxFullPartitions == 8 assert config.maxFullRepairs == 1 diff --git a/tests/test_agent_design_comparisons.py b/tests/test_agent_design_comparisons.py index fb2e9322..b639d9ca 100644 --- a/tests/test_agent_design_comparisons.py +++ b/tests/test_agent_design_comparisons.py @@ -267,6 +267,44 @@ def test_two_round_limit_counts_retries_and_reuses_identical_proposals() -> None evaluate_proposals(deps, characterization, []) +def test_objective_questions_require_explicit_grounded_purpose_before_a_round() -> None: + cells, characterization = _design() + deps = _deps(cells) + deps.studyObjective = "Explain treatment and time design coverage" + with pytest.raises(ValueError, match="explicit purpose"): + evaluate_proposals(deps, characterization, [_proposal()]) + assert deps.designRounds == 0 + proposal = _proposal(purpose="designCoverage", objectiveQuote=deps.studyObjective) + with pytest.raises(ValueError, match="must remain essential"): + evaluate_proposals( + deps, characterization, [proposal.model_copy(update={"essential": False})] + ) + assert deps.designRounds == 0 + evaluate_proposals(deps, characterization, [proposal]) + assert deps.designRounds == 1 + + +def test_changed_declared_unit_pair_recomputes_one_current_comparison() -> None: + cells, characterization = _design() + cells.frame["donor"] = cells.frame["sample"] + cells.columns.append("donor") + characterization.columns.append( + {"name": "donor", "domain": "biological", "kind": "categorical"} + ) + deps = _deps(cells) + proposal = _proposal(independentUnit="donor") + evaluate_proposals(deps, characterization, [proposal]) + assert deps.comparisons[0].status == "unsupported" + prior_id = deps.comparisons[0].evidenceId + characterization.coefficients = [ + {"name": "response", "observationUnit": "sample", "independentUnit": "donor"} + ] + evaluate_proposals(deps, characterization, [proposal]) + assert len(deps.comparisons) == 1 + assert deps.comparisons[0].status == "computed" + assert deps.comparisons[0].evidenceId != prior_id + + def test_unsupported_explanation_does_not_discard_protected_biology() -> None: cells, characterization = _design() cells.frame.loc[:2, "response"] = None diff --git a/tests/test_agent_objective_requirements.py b/tests/test_agent_objective_requirements.py new file mode 100644 index 00000000..556a333b --- /dev/null +++ b/tests/test_agent_objective_requirements.py @@ -0,0 +1,226 @@ +"""Measured design coverage is required before an objective can be completed.""" + +from types import SimpleNamespace + +import pandas as pd +import pytest +from pydantic import ValidationError + +from scarf.agent.experimental_context.comparisons import compare_covariates +from scarf.agent.experimental_context.contracts import ( + CovariateCharacterization, + CovariateProposal, + ExperimentalContextDecision, + InferenceUnit, +) +from scarf.agent.experimental_context.study import ( + StudyContract, + build_study_contract, + validate_objective_evidence, +) + + +def _repeated_design(*, purpose: str = "designCoverage", essential: bool = True): + observations = pd.DataFrame( + { + "sample": ["a0", "b0", "a1", "b2", "a3", "b3"], + "donor": ["d0", "d0", "d1", "d2", "d3", "d3"], + "tissue": ["A", "B", "A", "B", "A", "B"], + "condition": ["yes", "yes", "no", "no", "yes", "yes"], + } + ) + cells = observations.loc[observations.index.repeat(3)].reset_index(drop=True) + characterization = CovariateCharacterization( + status="done", + columns=[ + { + "name": name, + "kind": "categorical", + "domain": "design" if name == "sample" else "biological", + } + for name in cells.columns + ], + coefficients=[ + { + "name": "tissue", + "scope": "betweenUnit", + "kind": "categorical", + "observationUnit": "sample", + "independentUnit": "donor", + "groupOrder": ["A", "B"], + "unitLevelCounts": { + "observationUnit": {"levels": 6}, + "independentUnit": {"levels": 4}, + }, + "replication": {"sufficient": True, "minimumPerGroup": 3}, + "pairedCoverage": { + "design": "mixedOrIncomplete", + "completePairs": 2, + "incompletePairs": 2, + }, + } + ], + ) + proposal = CovariateProposal.model_validate( + { + "response": "tissue", + "explanatoryColumns": ["condition"], + "observationUnit": "sample", + "independentUnit": "donor", + "rationale": "Assess observed tissue and condition support across donors.", + "purpose": purpose, + "essential": essential, + "objectiveQuote": "Describe supported populations", + } + ) + comparison = compare_covariates( + SimpleNamespace( + columns=list(cells.columns), fetch=lambda name: cells[name].to_numpy() + ), + characterization, + proposal, + selection_identity={"cells": "fixed"}, + ) + characterization.comparisons = [comparison] + result = SimpleNamespace( + status="done", + characterization=characterization, + batchSafety=[], + notes=[], + decision=ExperimentalContextDecision( + coefficientsOfInterest=["tissue"], + unitsOfInference={ + "tissue": InferenceUnit( + observationUnit="sample", independentUnit="donor" + ) + }, + ), + ) + return result + + +def _contract(result): + return build_study_contract( + study_context="The observations contain repeated donors and incomplete pairing.", + study_objective="Describe supported populations", + experimental_result=result, + ) + + +def test_repeated_donors_have_descriptive_coverage_without_an_association(): + result = _repeated_design() + comparison = result.characterization.comparisons[0] + assert comparison.status == "unsupported" + assert comparison.reasons == ["withinIndependentUnitComparisonsAreUnsupported"] + evidence = comparison.evidence["descriptiveDesign"] + assert evidence["observationUnits"] == 6 + assert evidence["independentUnits"] == 4 + assert evidence["groupSupport"]["tissue"] == [ + {"group": "A", "observationUnits": 3, "independentUnits": 3}, + {"group": "B", "observationUnits": 3, "independentUnits": 3}, + ] + assert evidence["pairedCoverage"]["tissue"]["completePairs"] == 2 + assert evidence["pairedCoverage"]["tissue"]["incompletePairs"] == 2 + assert evidence["sharedIndependentUnits"]["tissue"]["pairs"] == [ + {"groups": ["A", "B"], "independentUnits": 2}, + ] + assert "singleAssociations" not in comparison.evidence + contract = _contract(result) + validate_objective_evidence(contract, result) + assert [item.status for item in contract.evidenceCoverage] == [ + "computed", + "computed", + ] + assert any( + "association method remains unsupported" in text + for text in contract.limitations + ) + + +@pytest.mark.parametrize("purpose", ["association", "effectEstimation"]) +def test_descriptive_counts_cannot_satisfy_an_essential_effect_or_association(purpose): + result = _repeated_design(purpose=purpose) + contract = _contract(result) + assert contract.evidenceCoverage[1].status == "unsupported" + with pytest.raises(ValueError, match="Essential objective evidence is unresolved"): + validate_objective_evidence(contract, result) + + +def test_optional_unsupported_question_remains_an_explicit_limitation(): + result = _repeated_design(purpose="association", essential=False) + result.characterization.comparisons[ + 0 + ].proposal.objectiveQuote = ( + "The observations contain repeated donors and incomplete pairing." + ) + contract = _contract(result) + validate_objective_evidence(contract, result) + assert contract.evidenceCoverage[1].status == "unsupported" + assert any( + "no supported association or absence finding" in text + for text in contract.limitations + ) + + +def test_missing_replication_blocks_completion_even_without_proposals(): + result = _repeated_design() + result.characterization.comparisons = [] + result.characterization.coefficients[0]["replication"] = {} + contract = _contract(result) + with pytest.raises(ValueError, match="replication evidence is unavailable"): + validate_objective_evidence(contract, result) + + +def test_nonidentifiable_exact_batch_design_answers_design_question_only(): + result = _repeated_design() + result.batchSafety = [ + SimpleNamespace( + coefficient="tissue", + batchColumns=["batch"], + status="unsafe", + evidenceId="batchSafety:tissue:batch", + ) + ] + result.characterization.columns.append( + {"name": "batch", "domain": "technical", "kind": "categorical"} + ) + contract = _contract(result) + assert contract.evidenceCoverage[0].status == "nonIdentifiable" + validate_objective_evidence(contract, result) + assert contract.correctionLicense == "unsafeConfounded" + + +def test_recomputed_context_coverage_rejects_tampered_or_stale_answer(): + result = _repeated_design() + contract = _contract(result) + result.characterization.coefficients[0]["replication"] = {} + with pytest.raises(ValueError, match="differs from its measured context report"): + validate_objective_evidence(contract, result) + + +@pytest.mark.parametrize("field", ["evidenceRequirements", "evidenceCoverage"]) +def test_old_contracts_without_objective_evidence_are_incompatible(field): + values = _contract(_repeated_design()).model_dump(mode="json") + del values[field] + with pytest.raises(ValidationError, match=field): + StudyContract.model_validate(values) + + +def test_empty_requirements_and_fabricated_citations_cannot_bypass_gate(): + contract = _contract(_repeated_design()) + with pytest.raises(ValueError): + validate_objective_evidence( + contract.model_copy(update={"evidenceRequirements": []}) + ) + contract.evidenceCoverage[0].evidenceIds.append("unmeasured:claim") + with pytest.raises(ValueError, match="outside the study contract"): + validate_objective_evidence(contract) + + +def test_context_role_change_invalidates_previously_computed_design_question(): + result = _repeated_design() + result.characterization.columns[1]["domain"] = "technical" + contract = _contract(result) + assert contract.evidenceCoverage[1].status == "unsupported" + with pytest.raises(ValueError, match="different column roles"): + validate_objective_evidence(contract, result) diff --git a/tests/test_agent_orchestrator.py b/tests/test_agent_orchestrator.py index b358634c..f175c06b 100644 --- a/tests/test_agent_orchestrator.py +++ b/tests/test_agent_orchestrator.py @@ -1,6 +1,7 @@ """Public facade, model, and end-to-end orchestrator contracts.""" from tests.agent_examples import example +from tests.agent_comparison_examples import observed_action import json from pathlib import Path @@ -37,7 +38,7 @@ load_checkpoint, _ensure_orchestration_store, ) -from scarf.agent.orchestrator.rna_tuning import TuningAction, _DOMAINS +from scarf.agent.orchestrator.rna_tuning import TuningAction from scarf.agent.orchestrator import ( AgentOrchestrator, AutomatedWorkflowConfig, @@ -212,7 +213,7 @@ async def reply( prompt = prompt_text(messages) payload, _ = json.JSONDecoder().raw_decode(prompt[prompt.index("{") :]) if any( - {"selectedCandidateId", "correctionNeed", "assessedDomains"}.issubset( + {"selectedCandidateId", "correctionNeed", "comparisonConclusions"}.issubset( tool.parameters_json_schema.get("properties", {}) ) for tool in info.output_tools @@ -226,30 +227,20 @@ async def reply( ), payload["currentCandidateId"], ) - action = TuningAction( - action="defer" if state["pca_pauses"] == 0 else "accept", - selectedCandidateId=selected, - correctionNeed="notApplicable", - assessedDomains=sorted(_DOMAINS), - evidenceIds=[f"candidate:{selected}", *payload["imageHashes"]], - quantitativeFindings=[ - "The measured stability and marker evidence supports the observed population partition." - ], - qualitativeFindings=[ - "The diagnostic board shows the distinct CD3D and MS4A1 marker programs." - ], - objectivePreservation="Retain marker-supported populations and every QC-retained cell.", - rationale="Review the supplied evidence before continuing." - if state["pca_pauses"] == 0 - else "Full-cell quantitative and visual evidence supports the selected partition.", + if payload["comparisonCoverage"]["phase"] == "sensitivity": + selected = payload["currentCandidateId"] + action = TuningAction.model_validate( + observed_action(payload, selected=selected) ) - state["pca_pauses"] += 1 - state["answer"] = action.model_copy( - update={ - "action": "accept", - "rationale": "Accept the observed screening evidence and validate these settings on the full cohort.", - } - ).model_dump(mode="json") + if action.action == "accept" and state["pca_pauses"] == 0: + state["answer"] = action.model_dump(mode="json") + action = action.model_copy( + update={ + "action": "defer", + "rationale": "Review the completed comparisons before validating the selected combination on all retained cells.", + } + ) + state["pca_pauses"] += 1 return ModelResponse( parts=[ ToolCallPart( @@ -482,7 +473,7 @@ def interrupt_before_full(self: Any, scope: str, *args: Any, **kwargs: Any) -> A assert result.status == "completed", result.notes assert len(pca_diagnostic_calls) == len(pca_diagnostics_before_resume) + 1 - assert state["pca_prompts"] == 2 + assert state["pca_prompts"] >= 3 assert result.currentStage == "analysis_finalization" assert result.workflowRunId is not None report_path = result.report() @@ -570,7 +561,22 @@ def interrupt_before_full(self: Any, scope: str, *args: Any, **kwargs: Any) -> A evidence = next( stage for stage in snapshot["stages"] if stage["stage"] == "parameter_tuning" )["outputs"]["tuningEvidence"] - assert evidence["budget"]["scopes"]["sample0"]["reserved"]["partitions"] == 4 + assert 4 < evidence["budget"]["scopes"]["sample0"]["reserved"]["partitions"] <= 24 + compared = { + row["comparisonId"] + for review in snapshot["analysisReviews"] + for row in review["comparisonCoverage"]["comparisons"] + } + assert { + "hvgCount:2000", + "hvgCount:4000", + "dimensions:10", + "dimensions:30", + "neighborsK:21", + "neighborsK:41", + "hvgRanking", + "featurePolicy", + } <= compared assert evidence["budget"]["scopes"]["full"]["reserved"]["graphs"] == 1 assert evidence["budget"]["scopes"]["full"]["reserved"]["partitions"] == 1 sample_record = load_checkpoint( diff --git a/tests/test_agent_orchestrator_stages.py b/tests/test_agent_orchestrator_stages.py index 1d3ed8d7..d7f86d1a 100644 --- a/tests/test_agent_orchestrator_stages.py +++ b/tests/test_agent_orchestrator_stages.py @@ -71,6 +71,41 @@ def _save_input_evidence(store, workflow, request, enrichment): )[1] +def _measured_context_characterization(store, cell_selection): + from scarf.agent.experimental_context.characterization import ( + characterize_covariates, + ) + from scarf.agent.orchestrator.models import artifact_model_to_ref + + for name, values in { + "sample": ["s1", "s1", "s2", "s2"], + "donor": ["d1", "d1", "d2", "d2"], + "treatment": ["control", "control", "treated", "treated"], + "batch": ["b1", "b1", "b2", "b2"], + }.items(): + store.cells.insert(name, np.asarray(values)) + return characterize_covariates( + store, + cellSelection=artifact_model_to_ref(cell_selection), + model=None, + directions={ + "columnDomains": { + "sample": "design", + "donor": "design", + "treatment": "biological", + "batch": "technical", + }, + "coefficientsOfInterest": ["treatment"], + "unitsOfInference": { + "treatment": { + "observationUnit": "sample", + "independentUnit": "donor", + } + }, + }, + ) + + def _cell_selection_model() -> ArtifactReferenceModel: return ArtifactReferenceModel( scope="datastore", @@ -227,9 +262,11 @@ def _build_plan( return AgentOrchestrator(object()).build_preprocessing_plan(*inputs) +@pytest.mark.parametrize("missing_replication", [False, True]) def test_unsafe_experimental_context_pauses_and_explicit_skip_reuses_evidence( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + missing_replication: bool, ) -> None: path = create_store(tmp_path / "unsafe-context.zarr") store = DataStore(str(path), default_assay="RNA", min_features_per_cell=0) @@ -269,6 +306,9 @@ def test_unsafe_experimental_context_pauses_and_explicit_skip_reuses_evidence( ) unsafe_report = sample_context.model_copy( update={ + "characterization": _measured_context_characterization( + store, cell_selection + ), "cellSelection": cell_selection, "qualityMetricArtifacts": [], "htoIdentityArtifacts": [], @@ -293,6 +333,8 @@ def test_unsafe_experimental_context_pauses_and_explicit_skip_reuses_evidence( ), } ) + if missing_replication: + unsafe_report.characterization.coefficients[0]["replication"] = {} class UnsafeAgent: calls = 0 @@ -356,7 +398,15 @@ def run(self, *_args: Any, **_kwargs: Any) -> ExperimentalContextResult: {"experimentalDirections": "skipHarmony"}, ) - assert done_outcome.status == "done" + if missing_replication: + assert done_outcome.status == "needsInput" + assert done_outcome.needsInput is not None + assert ( + "replication evidence is unavailable" + in done_outcome.needsInput.questions[0].question + ) + else: + assert done_outcome.status == "done" assert done_outcome.actions == ["resolve_unsafe_batch_correction:skip"] assert resolved_report.decision.batchCorrection.action == "skip" assert resolved_report.decision.batchCorrection.batchColumns == [] @@ -409,6 +459,9 @@ def test_explicit_no_inference_skip_resolves_context_without_provider_rerun( ) needs_input_report = sample_context.model_copy( update={ + "characterization": _measured_context_characterization( + store, cell_selection + ), "status": "needsInput", "cellSelection": cell_selection, "cellQc": CellQcPlan(), @@ -599,7 +652,7 @@ def test_converted_input_preserves_exact_selection_and_typed_qc() -> None: assert isinstance(plan.cellQc, CellQcPlan) -def test_percent_features_follow_deterministic_inspection_not_policy_lists( +def test_percent_features_use_exact_symbols_even_when_enrichment_omits_a_family( tmp_path: Path, ) -> None: path = create_store(tmp_path / "inspected-families.zarr") @@ -668,11 +721,24 @@ def test_percent_features_follow_deterministic_inspection_not_policy_lists( assert metric_source.name == "RNA_percentMito" assert metric_source.artifact.kind == "quality_metric" assert outcome.artifacts[metric_source.name] == metric_source.artifact - assert orchestrator._named_stage_artifacts( + metric_sources = orchestrator._named_stage_artifacts( outcome, "qualityMetricArtifacts", "quality_metric", - ) == [metric_source] + ) + assert metric_sources[0] == metric_source + assert [item.name for item in metric_sources] == [ + "RNA_percentMito", + "RNA_percentRibo", + ] + assert outcome.outputs["percentageDefinitions"] == [ + {"family": "mitochondrial", "pattern": r"(?i)^MT-", "matchedGenes": 1}, + { + "family": "ribosomal", + "pattern": r"(?i)^(RPS|RPL|MRPS|MRPL)", + "matchedGenes": 1, + }, + ] operation = outcome.outputs["operations"][0] assert operation["operation"] == "run_feature_percentage" assert operation["cellSelection"] == cell_selection.model_dump(mode="json") diff --git a/tests/test_agent_qc_percentage_identity.py b/tests/test_agent_qc_percentage_identity.py new file mode 100644 index 00000000..a07ef9a3 --- /dev/null +++ b/tests/test_agent_qc_percentage_identity.py @@ -0,0 +1,219 @@ +"""Exact gene ownership for percentage evidence and QC execution.""" + +from pathlib import Path + +import numpy as np +import pytest + +from scarf.agent.experimental_context.characterization import _SelectionBoundCells +from scarf.agent.experimental_context.contracts import ExperimentalContextDependencies +from scarf.agent.experimental_context.qc_evidence import ( + _derive_missing_percentage_artifacts, + _offered_qc_profiles, + _qc_metric_sources, +) +from scarf.agent.cell_quality.execution import execute_registered_cell_qc +from scarf.agent.tools import core_artifact_reference +from scarf.agent.parameter_tuning.diagnostics import _covariate_associations +from scarf.datastore.datastore import DataStore +from scarf.metadata.selection import NamedCellArtifact +from tests.agent_orchestrator_store import create_store + + +def test_exact_mitochondrial_artifact_owns_filtering_without_overwriting_metadata( + tmp_path: Path, +) -> None: + path = create_store(tmp_path / "percentages.zarr") + store = DataStore( + str(path), + default_assay="RNA", + min_features_per_cell=-1, + mito_pattern="", + ribo_pattern="", + zarr_mode="r+", + ) + store.get_assay("RNA").feats.insert( + "names", + np.asarray(["MT-CO1", "MTAP", "MTOR", "RPS3"]), + overwrite=True, + ) + imported = np.asarray([100.0, 60.0, 100.0, 83.3333333333]) + store.cells.insert("RNA_percentMito", imported) + selected = store.snapshot_cell_selection("I") + sources = _derive_missing_percentage_artifacts( + store, + cell_selection=selected, + driver=("RNA", "RNA"), + quality_sources=[], + ) + deps = ExperimentalContextDependencies( + store=store, + cellSelection=selected, + cells=_SelectionBoundCells(store.zw, store.cells, selected), + qcAssay="RNA", + studyContext="Human nuclei with mitochondrial QC.", + qualityMetricArtifacts=sources, + ) + values, metadata, artifacts, evidence, concordance, _, _ = _qc_metric_sources( + deps, ("RNA", "RNA") + ) + np.testing.assert_array_equal(store.cells.fetch_all("RNA_percentMito"), imported) + np.testing.assert_allclose(values["RNA_percentMito"], [80, 0, 200 / 3, 0]) + assert "RNA_percentMito" not in metadata + assert {item.name for item in artifacts} == {"RNA_percentMito", "RNA_percentRibo"} + mito_sources = [item for item in evidence if item.metricRole == "mitochondrial"] + assert [(item.sourceType, item.usableForFiltering) for item in mito_sources] == [ + ("metadataColumn", False), + ("artifact", True), + ] + assert mito_sources[1].executionName == "RNA_percentMito" + assert concordance[0].exactlyEqual is False + support = {} + correlation = _covariate_associations( + store, + selected, + np.asarray(values["RNA_percentMito"])[:, None], + ["RNA_percentMito"], + ["qc"], + support=support, + column_artifacts={ + "RNA_percentMito": core_artifact_reference(sources[0].artifact) + }, + ) + np.testing.assert_allclose(correlation, [[1.0]]) + assert support["RNA_percentMito"]["kind"] == "continuous" + feature_ref = next( + ref for ref in mito_sources[1].inputArtifacts if ref.kind == "feature_selection" + ) + np.testing.assert_array_equal( + store.load_artifact(core_artifact_reference(feature_ref))["values"][:], + [True, False, False, False], + ) + profile = next( + value + for value in _offered_qc_profiles(deps) + if value.registeredProfile == "globalMad5" + ) + assert "RNA_percentMito" not in profile.attributes + assert any(bound["metric"] == "RNA_percentMito" for bound in profile.resolvedBounds) + # The projected thresholds must also execute under the same metric names. + filtered, _ = execute_registered_cell_qc( + store, + "globalMad5", + profile_parameters=profile.parameters, + expected_active_cells=profile.activeCells, + expected_retained_cells=profile.retainedCells, + expected_flag_counts=profile.flaggedCells, + attrs=profile.attributes, + artifact_metrics=[ + NamedCellArtifact( + name=item.name, artifact=core_artifact_reference(item.artifact) + ) + for item in profile.artifactMetrics + ], + cell_selection=selected, + ) + assert ( + int(np.asarray(store.load_artifact(filtered)["values"][:]).sum()) + == profile.retainedCells + ) + + +def test_changed_gene_definition_invalidates_percentage_artifact( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + path = create_store(tmp_path / "changed.zarr") + store = DataStore( + str(path), + default_assay="RNA", + min_features_per_cell=-1, + mito_pattern="", + ribo_pattern="", + zarr_mode="r+", + ) + selected = store.snapshot_cell_selection("I") + assay = store.get_assay("RNA") + compute = assay._compute_feature_percentage + calculated = [] + + def counted(cells, genes): + calculated.append(genes.copy()) + return compute(cells, genes) + + monkeypatch.setattr(assay, "_compute_feature_percentage", counted) + first = _derive_missing_percentage_artifacts( + store, + cell_selection=selected, + driver=("RNA", "RNA"), + quality_sources=[], + ) + repeated = _derive_missing_percentage_artifacts( + store, + cell_selection=selected, + driver=("RNA", "RNA"), + quality_sources=[], + ) + assert repeated == first + assert len(calculated) == 2 + store.get_assay("RNA").feats.insert( + "names", + np.asarray(["MT-CO1", "RPS3", "MT-ND1", "GENE2"]), + overwrite=True, + ) + changed = _derive_missing_percentage_artifacts( + store, + cell_selection=selected, + driver=("RNA", "RNA"), + quality_sources=[], + ) + assert changed[0].artifact != first[0].artifact + assert changed[1].artifact == first[1].artifact + assert len(calculated) == 3 + + +def test_unresolved_mitochondrial_definition_cannot_use_imported_percentages( + tmp_path: Path, +) -> None: + store = DataStore( + str(create_store(tmp_path / "unresolved.zarr")), + default_assay="RNA", + min_features_per_cell=-1, + mito_pattern="", + ribo_pattern="", + zarr_mode="r+", + ) + store.get_assay("RNA").feats.insert( + "names", np.asarray(["MTAP", "MTOR", "RPS3", "ACTB"]), overwrite=True + ) + imported = np.asarray([90.0, 0.0, 0.0, 0.0]) + store.cells.insert("RNA_percentMito", imported) + selected = store.snapshot_cell_selection("I") + sources = _derive_missing_percentage_artifacts( + store, + cell_selection=selected, + driver=("RNA", "RNA"), + quality_sources=[], + ) + deps = ExperimentalContextDependencies( + store=store, + cellSelection=selected, + cells=_SelectionBoundCells(store.zw, store.cells, selected), + qcAssay="RNA", + studyContext="Mitochondrial genes cannot be identified in these annotations.", + qualityMetricArtifacts=sources, + ) + values, metadata, _, evidence, _, notes, _ = _qc_metric_sources( + deps, ("RNA", "RNA") + ) + assert "RNA_percentMito" not in values + assert "RNA_percentMito" not in metadata + assert all( + not item.usableForFiltering + for item in evidence + if item.metricRole == "mitochondrial" + ) + assert any( + "mitochondrial percentage has no exact usable artifact" in n for n in notes + ) + np.testing.assert_array_equal(store.cells.fetch_all("RNA_percentMito"), imported) diff --git a/tests/test_agent_report.py b/tests/test_agent_report.py index f9ce0b80..82c19bf0 100644 --- a/tests/test_agent_report.py +++ b/tests/test_agent_report.py @@ -22,39 +22,50 @@ def snapshot() -> dict[str, Any]: - state = { + from scarf.agent.experimental_context import ExperimentalContextResult + from scarf.agent.experimental_context.study import build_study_contract + from tests.agent_comparison_examples import comparison_review + + request = { + "studyContext": "Human RNA cells", + "studyObjective": "Find stable populations batch artifacts.", + } + context = ExperimentalContextResult.get_blank().model_dump(mode="json") + context["status"] = "done" + context["characterization"].update( + status="done", + columns=[{"name": "sample", "kind": "categorical", "domain": "design"}], + ) + context["cellQc"]["profileId"] = "selected" + context["qcProfiles"] = [ + { + "profileId": "selected", + "action": "globalGaussian", + "driverAssay": "RNA2", + "driverAssayType": "RNA", + "attributes": ["RNA2_nCounts", "RNA2_nFeatures", "RNA2_percentMito"], + "activeCells": 675218, + "retainedCells": 621200, + "retainedFraction": 621200 / 675218, + "retainedCellsByColumn": { + "condition": {"control": 320000, "treated": 301200} + }, + } + ] + context = ExperimentalContextResult.model_validate(context) + study = build_study_contract( + study_context=request["studyContext"], + study_objective=request["studyObjective"], + experimental_result=context, + ) + review = comparison_review() + return { "runId": "exact-analysis", "status": "completed", - "request": { - "studyContext": "Human RNA cells", - "studyObjective": "Find stable populations batch artifacts.", - }, + "request": request, "finalAnalysis": { "primaryAssay": "RNA2", "limitations": ["Condition and batch are confounded."], - "analysisEvidence": { - "analysisReview": { - "tuningEvidence": { - "history": [ - { - "scope": "full", - "review": { - "action": "accept", - "selectedCandidateId": "candidate-two", - "quantitativeFindings": [ - "The chosen partition has seed stability 0.92." - ], - "qualitativeFindings": [ - "MS4A1 and CD79A support the same partition." - ], - "rationale": "The selected partition preserves a small marker-supported population.", - "objectivePreservation": "Retain the rare marker program.", - }, - } - ] - } - } - }, }, "stages": [ { @@ -62,97 +73,28 @@ def snapshot() -> dict[str, Any]: "status": "done", "report": { "recommendedCandidateId": "candidate-two", - "evaluations": [ - { - "candidateId": "candidate-two", - "parameters": { - "dimensions": 20, - "neighborsK": 15, - "leidenResolution": 0.75, - "useHarmony": False, - }, - "metrics": {"seedStability": 0.92, "markerCoherence": 0.84}, - } - ], + "evaluations": review["candidates"], }, + "decisions": [], + }, + { + "stage": "experimental_context", + "status": "done", + "report": context.model_dump(mode="json"), + "outputs": {"studyContract": study.model_dump(mode="json")}, "decisions": [ { - "spec": { - "question": "Which clustering resolution preserves supported populations?", - "options": [ - { - "optionId": "low", - "label": "Resolution 0.5", - "description": "Compare the coarser partition.", - }, - { - "optionId": "chosen", - "label": "Resolution 0.75", - "description": "Compare the marker-supported partition.", - }, - ], - }, "record": { - "selectedOptionId": "chosen", - "decisionId": "clustering", - "rationale": "Selected 0.75 because seed stability was 0.92 and B-cell markers remained coherent.", - "modelName": "not-in-report", - "recordId": "hidden-record-id", - }, - "evidence": { - "evidence": [ - { - "summary": "Resolution 0.5: stability 0.96; marker coherence 0.67." - }, - { - "summary": "Resolution 0.75: stability 0.92; marker coherence 0.84." - }, - ] + "decisionId": "cellQuality", + "rationale": "The selected quality policy retains supported study groups.", }, - "checks": [ - { - "name": "Protected condition", - "status": "passed", - "reason": "Condition representation was retained.", - } - ], } ], }, - { - "stage": "experimental_context", - "report": { - "cellQc": {"profileId": "selected"}, - "qcProfiles": [ - { - "profileId": "selected", - "retainedCells": 920, - "retainedFraction": 0.92, - } - ], - }, - }, ], + "analysisReviews": [review], } - review = state["finalAnalysis"]["analysisEvidence"]["analysisReview"][ - "tuningEvidence" - ]["history"][0]["review"] - state["analysisReviews"] = [ - { - "scope": "full", - **review, - "candidates": state["stages"][0]["report"]["evaluations"], - "settings": {"candidate-two": {"hvgCount": 2000, "ranking": "global"}}, - "featureEvidence": { - "candidate-two": { - "families": {"hla": {"eligibleGenes": 12, "selectedGenes": 6}} - } - }, - } - ] - return state - def display_payload() -> dict[str, Any]: return { @@ -176,13 +118,19 @@ def test_one_page_shows_recorded_choices_evidence_and_qualitative_findings( assert state == original assert "621,200 cells" in document and "2 clusters" in document assert "50,000 of 621,200" in document - assert "QC retained 920 cells (92.0%)" in document - assert "Selected 0.75 because seed stability was 0.92" in document - assert "Resolution 0.5: stability 0.96; marker coherence 0.67." in document - assert "MS4A1 and CD79A support the same partition." in document - assert "small marker-supported population" in document + assert "QC retained 621,200 cells (92.0%)" in document + assert "Resolution 0.75 retains a small population with clear markers." in document + assert ( + "Repeat agreement was 0.92 and 84% of clusters had qualifying markers." + in document + ) + assert "Original detailed model reasoning retained in the journal." not in document + assert "small population with clear markers" in document assert "Condition and batch are confounded." in document - assert "Condition representation was retained." in document + assert "The selected quality policy retains supported study groups." in document + assert document.index( + "The selected quality policy retains supported study groups." + ) < document.index("Compared policy") assert "<without>" in document and "" not in document assert "hidden-record-id" not in document and "not-in-report" not in document assert "technical.html" not in document and "decision-tree" not in document @@ -190,13 +138,17 @@ def test_one_page_shows_recorded_choices_evidence_and_qualitative_findings( assert ("No plots were supplied for visual inspection" in document) == ( mode == "structured" ) - assert document.index("final_umap.png") < document.index("Analysis decisions") + assert ( + document.index("Limits of this analysis") + < document.index("final_umap.png") + < document.index("Cell quality") + ) def test_all_untrusted_scientific_text_is_escaped() -> None: state = snapshot() injection = '' - state["stages"][0]["decisions"][0]["record"]["rationale"] = injection + state["stages"][1]["decisions"][0]["record"]["rationale"] = injection state["finalAnalysis"]["limitations"] = [injection] payload = scientific_summary(state) | display_payload() payload["markers"][0]["feature"] = injection @@ -205,41 +157,213 @@ def test_all_untrusted_scientific_text_is_escaped() -> None: assert document.count("<img src=x onerror="alert(1)">") == 3 -def test_missing_evidence_is_reported_without_inventing_selection_reasons() -> None: +def test_missing_evidence_rejects_regeneration_without_inventing_reasons() -> None: state = snapshot() - state["stages"] = [] - state["finalAnalysis"]["analysisEvidence"] = {} state["analysisReviews"] = [] + with pytest.raises(ValueError, match="mandatory objective and comparison evidence"): + scientific_summary(state) + + +def test_report_distinguishes_actual_comparisons_and_unavailable_choices() -> None: + state = snapshot() document = render_analysis_document(scientific_summary(state) | display_payload()) - assert "No consequential decisions were recorded" in document - assert "What the evidence shows" not in document - assert "seed stability was 0.92" not in document + assert "Number of variable genes" in document and "4,000" in document + assert "No technical grouping supports a batch-specific ranking." in document + assert "Variable-gene ranking: not compared" in document + assert "Genes and representation" in document and "Clustering" in document + assert "Clusters with qualifying markers" in document and "84.0%" in document + assert "candidate-two" not in document and "hvgCount:4000" not in document + assert "Full cohort (621,200 cells)" in document -def test_report_distinguishes_gene_correction_and_experiment_evidence() -> None: +def test_repeated_reviews_do_not_repeat_candidate_inventory_or_raw_history() -> None: state = snapshot() - accepted = state["analysisReviews"][0] - accepted["correctionNeed"] = "needed" - accepted["candidates"][0]["parameters"]["useHarmony"] = True - accepted["settings"]["candidate-two"].update( - ranking="batchAware", rankingColumn="library" + earlier = copy.deepcopy(state["analysisReviews"][0]) + earlier.update( + action="combine", + rationale="Historical model claim about an unexecuted correction.", + ) + state["analysisReviews"].insert(0, earlier) + document = render_analysis_document(scientific_summary(state) | display_payload()) + assert document.count("41") == 1 + assert document.count("1.25") == 1 + assert "Historical model claim" not in document + assert "Complete recorded reasoning" not in document + assert document.index("Populations and markers") < document.index( + "Genes and representation" ) - experiment = copy.deepcopy(accepted) - experiment.update( - action="experiment", - experimentId="includeFamily:hla", - concern="HLA markers distinguish the objective-relevant activation state.", - expectedImprovement="Restoring HLA genes may retain that state.", + + +def population_snapshot() -> dict[str, Any]: + state = snapshot() + review = state["analysisReviews"][0] + cells = review["candidates"][0]["cellSelection"] + clusters = { + "scope": "assay", + "assay": "RNA2", + "kind": "cluster_labels", + "artifactId": "3" * 64, + } + state["finalAnalysis"].update(cellSelection=cells, clusters=clusters) + state["stages"][1]["outputs"]["studyContract"]["independentUnitColumns"] = ["donor"] + review["populationSupport"] = { + "candidate-two": { + "candidateId": "candidate-two", + "cellSelection": cells, + "clusters": clusters, + "columns": { + "donor": { + "status": "computed", + "observedGroups": 19, + "missingCells": 200, + "omittedPopulations": 1, + "populations": [ + { + "cluster": "1", + "cells": 1200, + "groupsWithAtLeast5Cells": 4, + "largestGroupFraction": 0.938, + } + ], + } + }, + } + } + return state + + +def test_population_support_is_descriptive_and_missing_rows_are_unavailable() -> None: + document = render_analysis_document( + scientific_summary(population_snapshot()) | display_payload() ) - state["analysisReviews"].insert(0, experiment) + assert "93.8%" in document and "4" in document + assert "five cells is not a replication threshold" in document + assert "200 cells lack" in document + assert "not saved for 1 populations" in document + assert "Unavailable" in document + assert "not validated cell identities" in document + assert '' in document + assert "@media(max-width:800px)" in document + + +def test_selected_population_concerns_and_study_limits_remain_prominent() -> None: + state = snapshot() + explanation = "Population 1 lacks qualifying markers and remains unclassified." + state["analysisReviews"][0]["populationConcerns"] = [ + { + "candidateId": "candidate-two", + "clusterId": "1", + "status": "nonEssentialLimitation", + "evidenceIds": ["candidate:candidate-two:clusters"], + "explanation": explanation, + } + ] + study_limit = "The study contains only one independent donor." + state["stages"][1]["outputs"]["studyContract"]["limitations"].append(study_limit) + state["finalAnalysis"]["limitations"].append(explanation) document = render_analysis_document(scientific_summary(state) | display_payload()) - assert "includeFamily:hla" in document - assert experiment["concern"] in document - assert experiment["expectedImprovement"] in document - assert "HVG count" in document and "2,000" in document - assert "batchAware" in document and "library" in document - assert "Feature family" in document and "Selected HVGs" in document - assert "Correction necessity: Needed" in document + assert document.count(explanation) == 1 + assert document.index(explanation) < document.index("final_umap.png") + assert document.index(study_limit) < document.index("final_umap.png") + + +def test_report_keeps_the_recorded_tradeoff_beside_its_comparison() -> None: + payload = scientific_summary(snapshot()) | display_payload() + payload["assessments"][0]["comparisonConclusions"][0]["tradeoffs"] = [ + {"interpretation": "The preferred setting loses some repeat agreement."} + ] + document = render_analysis_document(payload) + assert "The preferred setting loses some repeat agreement." in document + assert document.index("Genes and representation") < document.index( + "The preferred setting loses some repeat agreement." + ) + + +@pytest.mark.parametrize("field", ["clusters", "cellSelection", "candidateId"]) +def test_population_support_must_match_the_final_candidate_and_artifacts(field) -> None: + state = population_snapshot() + population = state["analysisReviews"][0]["populationSupport"]["candidate-two"] + population[field] = ( + "different-candidate" + if field == "candidateId" + else {**population[field], "artifactId": "f" * 64} + ) + with pytest.raises(ValueError, match="Reported population support"): + scientific_summary(state) + + +@pytest.mark.parametrize( + "missing", ["evidenceRequirements", "comparisonCoverage", "comparisonConclusions"] +) +def test_incompatible_report_evidence_fails_before_rendering_or_replacing_files( + monkeypatch, tmp_path, missing +) -> None: + state = snapshot() + if missing == "evidenceRequirements": + state["stages"][1]["outputs"]["studyContract"].pop(missing) + else: + state["analysisReviews"][0].pop(missing) + old_page = tmp_path / "index.html" + old_page.write_text("Existing historical report") + numerical = tmp_path / "saved-artifact" + numerical.write_bytes(b"original numerical values") + + def unexpected(*args, **kwargs): + pytest.fail("An incompatible report must fail before artifact display reads") + + monkeypatch.setattr(generator, "collect_analysis_artifacts", unexpected) + with pytest.raises(ValueError, match="start a new workflow"): + generator.render_analysis_report(SimpleNamespace(), state, tmp_path) + assert old_page.read_text() == "Existing historical report" + assert numerical.read_bytes() == b"original numerical values" + + +def test_invalid_or_missing_fractions_never_render_as_zero() -> None: + state = population_snapshot() + population = state["analysisReviews"][0]["populationSupport"]["candidate-two"][ + "columns" + ]["donor"]["populations"][0] + population["largestGroupFraction"] = float("nan") + payload = scientific_summary(state) | display_payload() + payload["qcProfiles"][0]["retainedFraction"] = None + document = render_analysis_document(payload) + assert "0.0%" not in document + assert ' None: + from tests.agent_comparison_examples import comparison_review + + state = snapshot() + subset = comparison_review("sample0") + payload = scientific_summary(state) | display_payload() + payload["assessments"].insert(0, subset) + # A later full review can also cite earlier screening comparisons. + for setting in subset["comparisonCoverage"]["candidateSettings"].values(): + assert setting["scope"] == "sample0" + subset["scope"] = "full" + payload["assessments"].insert( + 0, {"scope": "sample0", "coverage": {"screeningCells": 50000}} + ) + document = render_analysis_document(payload) + assert "Screening sample (50,000 cells)" in document + assert "Full cohort (621,200 cells)" in document + assert "Screening sample (621,200 cells)" not in document + + +def test_screening_that_uses_all_cells_is_not_labeled_as_a_sample() -> None: + from tests.agent_comparison_examples import comparison_review + + payload = scientific_summary(snapshot()) | display_payload() + all_cells = comparison_review("sample0") + all_cells["coverage"]["screeningCells"] = 621200 + all_cells["comparisonCoverage"]["population"] = "allCells" + payload["assessments"] = [all_cells] + document = render_analysis_document(payload) + assert "Full cohort (621,200 cells)" in document + assert "Screening sample" not in document @pytest.mark.parametrize("mode", ["visual", "structured"]) @@ -251,18 +375,23 @@ def test_review_view_requires_exact_checkpoint_bindings( from scarf.agent import record_io from scarf.agent.orchestrator.models import AutomatedWorkflowConfig + from scarf.agent.orchestrator.rna_tuning import TuningAction state = snapshot() view = state["analysisReviews"][0] - candidate = copy.deepcopy(view["candidates"][0]) + candidate = copy.deepcopy( + next( + item + for item in view["candidates"] + if item["candidateId"] == "candidate-two" + ) + ) features = ArtifactReferenceModel( assay="RNA2", kind="feature_selection", artifactId="4" * 64 ).model_dump(mode="json") candidate["artifacts"] = {"graphFeatures": features} action = { - key: value - for key, value in view.items() - if key not in {"scope", "candidates", "settings", "featureEvidence"} + key: value for key, value in view.items() if key in TuningAction.model_fields } payload = { "inputs": { @@ -278,7 +407,11 @@ def test_review_view_requires_exact_checkpoint_bindings( "features": features, } }, - "featureEvidence": view["featureEvidence"], + "featureEvidence": { + "candidate-two": view["featureEvidence"]["candidate-two"] + }, + "comparisonCoverage": view["comparisonCoverage"], + "coverage": view["coverage"], }, "outputs": {"action": action}, } @@ -334,8 +467,10 @@ def test_review_view_requires_exact_checkpoint_bindings( ) assert result[0]["rationale"] == action["rationale"] assert result[0]["evidenceMode"] == mode - assert result[0]["settings"]["candidate-two"]["hvgCount"] == 2000 - assert "artifacts" not in result[0]["candidates"][0] + assert result[0]["settings"]["candidate-two"]["hvgCount"] == 1000 + assert result[0]["candidates"][0]["artifacts"] == candidate["artifacts"] + assert result[0]["comparisonCoverage"] == view["comparisonCoverage"] + assert result[0]["coverage"] == view["coverage"] def test_report_regeneration_only_replaces_derived_files( @@ -509,7 +644,7 @@ def unavailable(*_args: Any, **_kwargs: Any) -> Any: document = path.read_text() assert "12 cells" in document assert "matplotlib unavailable" in document - assert "Selected 0.75 because seed stability was 0.92" in document + assert "Resolution 0.75 retains a small population with clear markers." in document assert 'src="plots/final_umap.png"' not in document diff --git a/tests/test_agent_required_comparisons.py b/tests/test_agent_required_comparisons.py new file mode 100644 index 00000000..45d35b7c --- /dev/null +++ b/tests/test_agent_required_comparisons.py @@ -0,0 +1,496 @@ +"""Executed sensitivity coverage and exact scientific comparison conclusions.""" + +from copy import deepcopy +from types import SimpleNamespace +from typing import Any + +import numpy as np +import pytest + +from scarf.agent.orchestrator import journal, rna_tuning +from scarf.agent.orchestrator.budget import candidate_identity, CandidateBudgetExceeded +from scarf.agent.orchestrator.models import ( + AutomatedWorkflowConfig, + PreprocessedAssayHandoff, + AutomatedPreprocessingPlan, +) +from scarf.agent.experimental_context.study import StudyContract +from scarf.agent.parameter_tuning.contracts import ( + ParameterCandidateEvaluation, + ArtifactRecord, +) +from scarf.agent.parameter_tuning.comparisons import ( + validate_comparison_review, + setting_changes, +) +from scarf.agent.types import ArtifactReferenceModel +from tests.agent_comparison_examples import comparison_review +from tests.agent_examples import example +from tests.test_agent_rna_adaptive import checkpoints as memory_checkpoints # noqa: F401 + + +@pytest.mark.parametrize( + "damage", + [ + "missing_row", + "different_cells", + "two_axes", + "failed_alternative", + "missing_conclusion", + "unexecuted_combination", + "wrong_final_graph", + "wrong_preference", + ], +) +def test_acceptance_requires_actual_matched_coverage(damage: str) -> None: + review = comparison_review() + coverage = review["comparisonCoverage"] + if damage == "missing_row": + coverage["comparisons"].pop(0) + elif damage == "different_cells": + coverage["candidateSettings"]["genes-two"]["cellSelection"] = {"changed": True} + elif damage == "two_axes": + coverage["candidateSettings"]["genes-two"]["parameters"]["dimensions"] += 1 + elif damage == "failed_alternative": + coverage["candidateSettings"]["genes-two"]["status"] = "failed" + elif damage == "missing_conclusion": + review["comparisonConclusions"].pop() + elif damage == "unexecuted_combination": + coverage["combinedCandidateId"] = "unexecuted" + elif damage == "wrong_final_graph": + coverage["candidateSettings"]["resolution-half"]["parameters"][ + "neighborsK" + ] += 1 + else: + review["selectedCandidateId"] = "genes-two" + with pytest.raises(ValueError): + validate_comparison_review(coverage, review) + + +def test_changed_gene_sets_are_matched_only_on_the_declared_axis() -> None: + coverage = comparison_review()["comparisonCoverage"] + left = coverage["candidateSettings"]["baseline"] + right = deepcopy(left) + right["hvgCount"] = 2000 + right["features"] = {"distinct": "genes"} + assert set(setting_changes(left, right)) == {"hvgCount"} + right["ranking"] = "batchAware" + right["rankingColumn"] = "capture" + assert set(setting_changes(left, right)) == {"hvgCount", "hvgRanking"} + + +def test_old_checkpoints_fail_with_actionable_contract_error() -> None: + review = comparison_review() + review.pop("comparisonConclusions") + with pytest.raises(ValueError, match="start a new workflow"): + rna_tuning.validate_completed_comparison_evidence(review) + + +def test_better_alternative_requires_exact_observed_tradeoff() -> None: + review = comparison_review() + coverage = review["comparisonCoverage"] + coverage["candidateSettings"]["genes-two"]["metrics"]["seedStability"] = 0.99 + with pytest.raises(ValueError, match="better stability"): + validate_comparison_review(coverage, review) + conclusion = next( + row for row in review["comparisonConclusions"] if row["axis"] == "hvgCount" + ) + conclusion["tradeoffs"] = [ + { + "alternativeCandidateId": "genes-two", + "metric": "seedStability", + "preferredValue": 0.92, + "alternativeValue": 0.99, + "interpretation": "The measured gain requires weighing preserved marker programs against extra selected genes.", + } + ] + validate_comparison_review(coverage, review) + conclusion["tradeoffs"][0]["alternativeValue"] = 1.0 + with pytest.raises(ValueError, match="exact preferred"): + validate_comparison_review(coverage, review) + + +def test_marker_poor_selected_population_needs_explicit_objective_resolution() -> None: + review = comparison_review() + coverage = review["comparisonCoverage"] + coverage["candidateSettings"]["candidate-two"]["metrics"]["topMarkerGenes"][ + "1" + ] = [] + with pytest.raises(ValueError, match="without qualifying markers"): + validate_comparison_review(coverage, review) + review["populationConcerns"] = [ + { + "candidateId": "candidate-two", + "clusterId": "1", + "status": "unresolvedEssential", + "evidenceIds": review["evidenceIds"], + "explanation": "This is the target population and its identity is unsupported.", + } + ] + with pytest.raises(ValueError, match="essential population"): + validate_comparison_review(coverage, review) + review["populationConcerns"][0]["status"] = "nonEssentialLimitation" + review["populationConcerns"][0]["explanation"] = ( + "Retain this unlabelled background group without assigning a cell identity." + ) + validate_comparison_review(coverage, review) + + +def test_generic_not_applicable_and_off_axis_preferences_cannot_pass() -> None: + review = comparison_review() + coverage = review["comparisonCoverage"] + row = next(row for row in coverage["comparisons"] if row["axis"] == "pca") + row.update( + status="notApplicable", + reason="Defaults look sufficient", + alternativeCandidateId=None, + ) + with pytest.raises(ValueError, match="observed proof"): + validate_comparison_review(coverage, review) + review = comparison_review() + conclusion = next( + row for row in review["comparisonConclusions"] if row["axis"] == "pca" + ) + conclusion["candidateIds"].append("genes-two") + conclusion["preferredCandidateId"] = "genes-two" + with pytest.raises(ValueError, match="observed candidate"): + validate_comparison_review(review["comparisonCoverage"], review) + + +def test_equivalent_core_feature_masks_can_have_distinct_artifact_operations() -> None: + review = comparison_review() + coverage = review["comparisonCoverage"] + baseline = coverage["candidateSettings"]["baseline"] + alternative = deepcopy(baseline) + alternative["features"] = {**baseline["features"], "artifactId": "7" * 64} + row = next( + row for row in coverage["comparisons"] if row["comparisonId"] == "hvgCount:2000" + ) + row.update( + status="notApplicable", + alternativeCandidateId=None, + reason="The exact observed selected-gene masks are equal.", + observedProof={ + "kind": "identicalSelectedGenes", + "baselineFeatures": baseline["features"], + "verifiedEqualMasks": True, + "alternativeSetting": alternative, + }, + ) + validate_comparison_review(coverage, review) + + +@pytest.fixture +def panel_run(monkeypatch: pytest.MonkeyPatch) -> rna_tuning.RnaTuningRun: + saved: dict[str, dict] = {} + monkeypatch.setattr(journal, "_ensure_orchestration_store", lambda store: "test") + + def load( + store: Any, prefix: str, workflow: str, key: str, inputs: Any = None + ) -> Any: + row = saved.get(key) + if row is None: + return None + assert inputs is None or inputs == row["inputs"] + return deepcopy(row["outputs"]) + + def save( + store: Any, prefix: str, workflow: str, key: str, inputs: Any, outputs: Any + ) -> Any: + row = {"inputs": deepcopy(inputs), "outputs": deepcopy(outputs)} + assert key not in saved or saved[key] == row + saved[key] = row + return deepcopy(outputs) + + monkeypatch.setattr(journal, "load_checkpoint", load) + monkeypatch.setattr(journal, "save_checkpoint", save) + handoff = example(PreprocessedAssayHandoff) + handoff.nCells = 100 + handoff.nFeatures = 1000 + handoff.graphFeatureCandidates = { + "eligibleDefault": handoff.graphFeatures, + "eligibleAll": handoff.graphFeatures, + } + masks = {handoff.graphFeatures.artifactId: np.arange(5000) < 1000} + store = SimpleNamespace( + zw=None, + load_artifact=lambda ref: {"values": masks[ref.artifact_id]}, + inspect_artifact=lambda ref: SimpleNamespace(exists=True, complete=True), + ) + run = rna_tuning.RnaTuningRun( + SimpleNamespace(model=object()), + store, + SimpleNamespace(workflowRunId="panel"), + SimpleNamespace(config=AutomatedWorkflowConfig()), + example(AutomatedPreprocessingPlan), + handoff, + StudyContract.get_blank(), + {}, + {}, + ) + run.batch_columns = ["capture"] + run.scope_sizes["sample0"] = 100 + monkeypatch.setattr( + rna_tuning, "read_stored_selection_indices", lambda *a, **kw: np.arange(100) + ) + monkeypatch.setattr( + rna_tuning, + "read_metadata_rows_chunkwise", + lambda *a, **kw: np.repeat(["a", "b"], 50), + ) + store.cells = SimpleNamespace() + monkeypatch.setattr( + run, + "_feature_nomination", + lambda setting: {"parameter": "excludeFeature", "value": "nominated"}, + ) + + def prepared( + scope: str, key: str, baseline: Any, experiment: dict, cells: Any + ) -> Any: + setting = run.settings[baseline.candidateId] + field = experiment["parameter"] + number = { + "hvgCount": int(experiment["value"]) if field == "hvgCount" else 1000, + "hvgRanking": 1000, + "excludeFeature": 1000, + }[field] + identity = f"{len(masks) + 1:064x}" + mask = np.arange(5000) < number + updates: dict[str, Any] = {"hvgCount": number} + if field != "hvgCount": + mask[[0, number]] = [False, True] + if field == "hvgRanking": + updates.update(ranking="batchAware", rankingColumn="capture") + if field == "excludeFeature": + updates["eligibleFeatures"] = setting.eligibleFeatures.model_copy( + update={"artifactId": "f" * 64} + ) + masks[identity] = mask + updates["features"] = setting.features.model_copy( + update={"artifactId": identity} + ) + return setting.model_copy(update=updates) + + monkeypatch.setattr(run, "_prepared_setting", prepared) + + def execute(scope: str, cells: Any, setting: Any) -> Any: + inputs = run.execution_inputs(cells, setting) + admission = run.budget.admit(scope, inputs) + candidate_id = "rna_" + candidate_identity(inputs)[:24] + old = next( + ( + item + for item in run.evaluations[scope] + if item.candidateId == candidate_id + ), + None, + ) + if old is not None: + return old + parameters = setting.parameters.model_copy(update={"candidateId": candidate_id}) + run.settings[candidate_id] = setting.model_copy( + update={"parameters": parameters} + ) + evaluation = example(ParameterCandidateEvaluation) + evaluation.candidateId = candidate_id + evaluation.parameters = parameters + evaluation.status = "done" + evaluation.cellSelection = ArtifactReferenceModel.from_artifact_ref(cells) + evaluation.artifacts["graphFeatures"] = ArtifactRecord.model_validate( + setting.features.model_dump() + ) + run.evaluations[scope].append(evaluation) + run.budget.complete( + admission, {"evaluation": evaluation.model_dump(mode="json")} + ) + return evaluation + + monkeypatch.setattr(run, "execute", execute) + return run + + +def test_required_panel_executes_twelve_rows_and_holds_other_settings_fixed( + panel_run: rna_tuning.RnaTuningRun, +) -> None: + run = panel_run + baseline = run._resolution_panel("sample0", run.cells, run.baseline()) + run.resolution_candidates.clear() + run._sensitivity_panel("sample0", run.cells, baseline) + assert len(run.evaluations["sample0"]) == 12 + assert run.budget.summary()["scopes"]["sample0"]["completed"] == { + "graphs": 9, + "partitions": 12, + } + coverage = run.comparison_coverage("sample0", run.cells) + assert len(coverage["comparisons"]) == 11 + for row in coverage["comparisons"]: + assert row["status"] == "completed" + assert set( + setting_changes( + coverage["candidateSettings"][row["baselineCandidateId"]], + coverage["candidateSettings"][row["alternativeCandidateId"]], + ) + ) == {row["axis"]} + assert { + (item.parameters.dimensions, item.parameters.neighborsK) + for item in run.evaluations["sample0"] + } == {(21, 11), (10, 11), (30, 11), (21, 21), (21, 41)} + + +def test_full_validation_reuses_exact_all_cell_discovery_without_charge( + panel_run: rna_tuning.RnaTuningRun, +) -> None: + run = panel_run + baseline = run.execute("sample0", run.cells, run.baseline()) + observed = rna_tuning.RnaTuningRun.execute( + run, "full", run.cells, run.settings[baseline.candidateId] + ) + assert observed == baseline + assert run.budget.summary()["scopes"]["full"]["reserved"] == { + "graphs": 0, + "partitions": 0, + } + assert run.validation_sources[baseline.candidateId]["scope"] == "sample0" + + +def test_full_fallback_declines_unfinishable_panel_before_additional_work( + panel_run: rna_tuning.RnaTuningRun, + monkeypatch: pytest.MonkeyPatch, +) -> None: + run = panel_run + run.scope_sizes["full"] = 100 + baseline = run._resolution_panel("full", run.cells, run.baseline()) + + def forbidden(*args: Any, **kwargs: Any) -> Any: + pytest.fail("Feature preparation must follow admission of the required panel") + + monkeypatch.setattr(run, "_prepared_setting", forbidden) + with pytest.raises(CandidateBudgetExceeded): + run._sensitivity_panel("full", run.cells, baseline) + assert run.budget.summary()["scopes"]["full"]["completed"] == { + "graphs": 1, + "partitions": 4, + } + + +def test_feature_policy_waits_for_evidence_nomination_before_combining( + panel_run: rna_tuning.RnaTuningRun, + monkeypatch: pytest.MonkeyPatch, +) -> None: + run = panel_run + monkeypatch.setattr(run, "_feature_nomination", lambda setting: None) + monkeypatch.setattr( + run, + "_feature_experiments", + lambda setting: { + "excludeFeature:nominated": { + "parameter": "excludeFeature", + "value": "nominated", + "affectedEligibleGenes": 1, + } + }, + ) + baseline = run._resolution_panel("sample0", run.cells, run.baseline()) + run.resolution_candidates.clear() + run._sensitivity_panel("sample0", run.cells, baseline) + coverage = run.comparison_coverage("sample0", run.cells) + row = next(row for row in coverage["comparisons"] if row["axis"] == "featurePolicy") + assert row["status"] == "pending" + with pytest.raises(ValueError, match="nomination and execution"): + validate_comparison_review(coverage, {"action": "combine"}) + + +def test_completed_feature_comparisons_replay_at_exact_candidate_limit( + panel_run: rna_tuning.RnaTuningRun, + monkeypatch: pytest.MonkeyPatch, +) -> None: + run = panel_run + run.request.config.maxScreeningEvaluations = 12 + prepare = run._prepared_setting + + def persisted( + scope: str, key: str, baseline: Any, experiment: dict, cells: Any + ) -> Any: + checkpoint = f"parameter_tuning/{scope}/{key}/setting" + inputs = { + "baseline": run.settings[baseline.candidateId].model_dump(mode="json"), + "experiment": experiment, + "cells": run.cells.to_dict(), + } + saved = journal.load_checkpoint( + run.store, run.prefix, run.workflow.workflowRunId, checkpoint, inputs + ) + if saved is not None: + return rna_tuning.RnaSetting.model_validate(saved["setting"]) + setting = prepare(scope, key, baseline, experiment, cells) + journal.save_checkpoint( + run.store, + run.prefix, + run.workflow.workflowRunId, + checkpoint, + inputs, + {"setting": setting.model_dump(mode="json")}, + ) + return setting + + monkeypatch.setattr(run, "_prepared_setting", persisted) + baseline = run._resolution_panel("sample0", run.cells, run.baseline()) + run.resolution_candidates.clear() + run._sensitivity_panel("sample0", run.cells, baseline) + before = run.budget.summary() + assert before["scopes"]["sample0"]["completed"]["partitions"] == 12 + observed = run.comparison_coverage("sample0", run.cells) + run._sensitivity_panel("sample0", run.cells, baseline) + assert run.budget.summary() == before + assert run.comparison_coverage("sample0", run.cells) == observed + + +def test_evaluated_ranking_matches_both_mode_and_column( + monkeypatch: pytest.MonkeyPatch, + request: pytest.FixtureRequest, +) -> None: + import json + + from tests.test_agent_rna_evidence_mode import assess, make_run, comparison_coverage + + request.getfixturevalue("memory_checkpoints") + run, selected = make_run(monkeypatch, SimpleNamespace(supports_image_input=False)) + run.batch_columns = ["capture", "other_capture"] + other = selected.model_copy(deep=True) + other.candidateId = "batch-aware-observed" + other.parameters.candidateId = other.candidateId + other.artifacts["graphFeatures"] = ArtifactRecord.model_validate( + { + **run.settings[selected.candidateId].features.model_dump(), + "artifactId": "d" * 64, + } + ) + run.evaluations["full"].append(other) + run.settings[other.candidateId] = run.settings[selected.candidateId].model_copy( + update={ + "parameters": other.parameters, + "features": other.artifacts["graphFeatures"], + "ranking": "batchAware", + "rankingColumn": "capture", + } + ) + coverage = comparison_coverage(run, "full") + row = next(row for row in coverage["comparisons"] if row["axis"] == "hvgRanking") + row.update(status="completed", alternativeCandidateId=other.candidateId) + monkeypatch.setattr(run, "comparison_coverage", lambda *a: coverage) + + def inspect(**kwargs: Any) -> Any: + evidence = json.loads(kwargs["user_prompt"]) + assert "hvgRanking:batchAware:capture" not in evidence["experiments"] + assert "hvgRanking:batchAware:other_capture" in evidence["experiments"] + assert ( + evidence["assessmentContext"]["alreadyEvaluatedExperiments"][ + "hvgRanking:batchAware:capture" + ] + == other.candidateId + ) + return assess(**kwargs) + + monkeypatch.setattr(rna_tuning, "run_agent_sync", inspect) + assert run.review("full", 0, selected, {}).action == "accept" diff --git a/tests/test_agent_rna_adaptive.py b/tests/test_agent_rna_adaptive.py index 755c6a89..8f695e63 100644 --- a/tests/test_agent_rna_adaptive.py +++ b/tests/test_agent_rna_adaptive.py @@ -1,6 +1,7 @@ """Bounded RNA admission, uniform sampling and evidence-driven acceptance.""" from tests.agent_examples import example +from tests.agent_comparison_examples import observed_action import copy import hashlib @@ -10,7 +11,7 @@ import numpy as np import pytest -from scarf.agent.orchestrator import journal, rna_tuning, tuning +from scarf.agent.orchestrator import journal, rna_tuning from scarf.agent import record_io from scarf.agent.orchestrator.budget import CandidateBudget, CandidateBudgetExceeded from scarf.agent.orchestrator.models import ( @@ -18,7 +19,6 @@ AutomatedWorkflowConfig, PreprocessedAssayHandoff, ) -from scarf.agent.config.agent_exec import ImageEvidence from scarf.agent.experimental_context.study import StudyContract from scarf.agent.parameter_tuning.contracts import ParameterCandidateEvaluation from scarf.agent.parameter_tuning.hvg import core_hvg_evidence, rank_core_hvgs @@ -289,62 +289,26 @@ def test_native_acceptance_requires_resolved_supported_correction_need( monkeypatch: pytest.MonkeyPatch, unsupported: bool, ) -> None: - handoff = example(PreprocessedAssayHandoff) - handoff.graphFeatureCandidates = {"eligibleDefault": handoff.graphFeatures} - study = StudyContract.get_blank().model_copy( + from tests.test_agent_rna_evidence_mode import assess, make_run + + run, evaluation = make_run(monkeypatch, object()) + run.study = run.study.model_copy( update={ "correctionLicense": "safe", "technicalBatchColumns": ["batch"], "unsupportedProtection": ["age"] if unsupported else [], } ) - run = rna_tuning.RnaTuningRun( - SimpleNamespace(model=object()), - SimpleNamespace(), - SimpleNamespace(workflowRunId="workflow"), - SimpleNamespace(config=AutomatedWorkflowConfig()), - example(AutomatedPreprocessingPlan), - handoff, - study, - {}, - {}, - ) - evaluation = example(ParameterCandidateEvaluation) - evaluation.parameters.useHarmony = False - for field in ( - "seedStability", - "subsampleStability", - "markerCoherence", - "membershipStrengthMean", - "clusterConnectivity", - ): - setattr(evaluation.metrics, field, 0.9) - monkeypatch.setattr(run, "feature_evidence", lambda selected: {}) + run.batch_columns = ["batch"] run.evaluations["sample0"] = [evaluation] - run.settings[evaluation.candidateId] = run.baseline() - monkeypatch.setattr( - tuning, - "_analysis_visual_content", - lambda *a, **kw: [ - ImageEvidence( - identifier="observed-plot", data=b"image", media_type="image/png" - ) - ], - ) - action = rna_tuning.TuningAction( - action="accept", - selectedCandidateId=evaluation.candidateId, - correctionNeed="needed", - assessedDomains=sorted(rna_tuning._DOMAINS), - evidenceIds=[f"candidate:{evaluation.candidateId}", "observed-plot"], - quantitativeFindings=["Observed separation requires a matched comparison."], - qualitativeFindings=["Batch colors separate within a comparable population."], - objectivePreservation="Preserve condition-associated populations.", - rationale="Inspect correction.", - ) - monkeypatch.setattr( - rna_tuning, "run_agent_sync", lambda **kwargs: SimpleNamespace(output=action) - ) + + def propose_native(**kwargs: Any) -> Any: + action = assess(**{**kwargs, "output_validator": lambda value: value}).output + return SimpleNamespace( + output=action.model_copy(update={"correctionNeed": "needed"}) + ) + + monkeypatch.setattr(rna_tuning, "run_agent_sync", propose_native) with pytest.raises( ValueError, match="biological protection is unsupported" @@ -356,7 +320,7 @@ def test_native_acceptance_requires_resolved_supported_correction_need( @pytest.mark.slow -def test_full_execution_repair_and_resume_reuse_augmented_evidence( +def test_required_comparisons_and_resume_reuse_augmented_evidence( datastore_ephemeral: Any, checkpoints: dict[str, Any], monkeypatch: pytest.MonkeyPatch, @@ -404,32 +368,8 @@ def test_full_execution_repair_and_resume_reuse_augmented_evidence( def assess(**kwargs: Any) -> Any: evidence = json.loads(kwargs["user_prompt"][0]) selected = evidence["currentCandidateId"] - repair = not model_calls model_calls.append(selected) - action = rna_tuning.TuningAction( - action="experiment" if repair else "accept", - selectedCandidateId=selected, - experimentId="leidenResolution:1.5" if repair else None, - correctionNeed="notApplicable", - assessedDomains=sorted(rna_tuning._DOMAINS), - evidenceIds=[f"candidate:{selected}", *evidence["imageHashes"]], - quantitativeFindings=[ - "Compare the registered finer partition against measured stability and markers." - ], - qualitativeFindings=[ - "Inspect the observed PCA and marker diagnostic panels." - ], - concern="Test whether the current partition merges marker-supported groups." - if repair - else "", - expectedImprovement="A finer partition may preserve distinct marker programs." - if repair - else "", - objectivePreservation="Retain coherent populations and all selected cells.", - rationale="Run one targeted partition comparison." - if repair - else "The full-cohort diagnostics support this partition.", - ) + action = rna_tuning.TuningAction.model_validate(observed_action(evidence)) return SimpleNamespace(output=kwargs["output_validator"](action)) monkeypatch.setattr(rna_tuning, "run_agent_sync", assess) @@ -451,20 +391,26 @@ def runner() -> rna_tuning.RnaTuningRun: assert first.status == "done" assert first.cellSelection == handoff.cellSelection assert first.selectedArtifacts["normalized"] - assert len(model_calls) == 2 - assert history["budget"]["scopes"]["full"]["reserved"]["graphs"] == 1 - assert history["budget"]["scopes"]["full"]["reserved"]["partitions"] == 5 - assert history["fullRepairs"] == 1 + assert len(model_calls) >= 3 + expected_calls = len(model_calls) + assert history["budget"]["scopes"]["sample0"]["reserved"]["partitions"] >= 8 + assert history["budget"]["scopes"]["full"]["reserved"] == { + "graphs": 0, + "partitions": 0, + } + assert history["fullRepairs"] == 0 def no_recomputation(*args: Any, **kwargs: Any) -> Any: pytest.fail("A fully augmented candidate must not be recomputed on resume") monkeypatch.setattr(rna_tuning, "augment_pca_evaluations", no_recomputation) monkeypatch.setattr(rna_tuning, "augment_cluster_evaluations", no_recomputation) + monkeypatch.setattr(rna_tuning, "execute_parameter_candidate", no_recomputation) + monkeypatch.setattr(rna_tuning, "partition_comparison_evidence", no_recomputation) resumed, resumed_history = runner().run() assert resumed == first assert resumed_history == history - assert len(model_calls) == 2 + assert len(model_calls) == expected_calls def test_failed_execution_retries_and_doublets_bind_exact_feature_mask( diff --git a/tests/test_agent_rna_assessment_integrity.py b/tests/test_agent_rna_assessment_integrity.py index e69fd4f4..31214799 100644 --- a/tests/test_agent_rna_assessment_integrity.py +++ b/tests/test_agent_rna_assessment_integrity.py @@ -181,14 +181,22 @@ def record(**kwargs: Any) -> Any: "currentCandidateId": selected.candidateId, "alternativeCandidateId": alternative.candidateId, "changedParameter": { - "dimensions": { + "pca": { "current": selected.parameters.dimensions, "alternative": alternative.parameters.dimensions, } }, + "partitionEvidence": context["matchedComparisons"][0][ + "partitionEvidence" + ], "basis": context["matchedComparisons"][0]["basis"], } ] + partition = context["matchedComparisons"][0]["partitionEvidence"] + assert partition["matchedCells"] == 100 + assert all( + row["fractionOutsideLargestMatch"] == 0.0 for row in partition["splits"] + ) assert experiment_id not in observed["experiments"] else: assert not context["matchedComparisons"] @@ -242,9 +250,9 @@ def support(_store: Any, candidate: Any, columns: Any) -> dict[str, Any]: expected = run.review("full", 0, selected, {}) key = "parameter_tuning/full/review0" assert len(run.evaluations["full"]) == 2 - assert support_calls == [selected.candidateId] + assert support_calls == [item.candidateId for item in run.evaluations["full"]] assert set(saved[key]["inputs"]["assessmentContext"]["populationSupport"]) == { - selected.candidateId + item.candidateId for item in run.evaluations["full"] } # Prior reviews could offer an already-completed comparison and lacked this context. saved[key]["inputs"]["experiments"][experiment_id] = old_experiment diff --git a/tests/test_agent_rna_evidence_mode.py b/tests/test_agent_rna_evidence_mode.py index db64c4cc..aca2f2b8 100644 --- a/tests/test_agent_rna_evidence_mode.py +++ b/tests/test_agent_rna_evidence_mode.py @@ -1,9 +1,12 @@ """RNA assessments preserve scientific checks for visual and text-only models.""" import json +import hashlib +from copy import deepcopy from types import SimpleNamespace from typing import Any +import numpy as np import pytest from pydantic import ValidationError from pydantic_ai.exceptions import ModelHTTPError, UnexpectedModelBehavior @@ -26,12 +29,88 @@ ) from scarf.agent.parameter_tuning.contracts import ParameterCandidateEvaluation from tests.agent_examples import example +from tests.agent_comparison_examples import comparison_review from tests.test_agent_rna_adaptive import checkpoints as memory_checkpoints # noqa: F401 pytestmark = pytest.mark.usefixtures("memory_checkpoints") +def comparison_coverage(run: rna_tuning.RnaTuningRun, scope: str) -> dict[str, Any]: + """A completed sensitivity fixture with the exact candidate under assessment.""" + panel = comparison_review(scope) + coverage = panel["comparisonCoverage"] + selected = run.evaluations[scope][0] + selected_id = selected.candidateId + setting = run.settings[selected_id].model_dump(mode="json") + baseline = { + **setting, + "scope": scope, + "status": selected.status, + "cellSelection": selected.cellSelection.model_dump(mode="json"), + "metrics": selected.metrics.model_dump(mode="json"), + } + template_baseline = coverage["candidateSettings"]["baseline"] + settings = {} + for identifier, template in coverage["candidateSettings"].items(): + row = deepcopy(baseline) + for field in ("hvgCount", "ranking", "rankingColumn", "eligibleFeatures"): + if template[field] != template_baseline[field]: + row[field] = deepcopy(template[field]) + if row["hvgCount"] != baseline["hvgCount"]: + row["features"]["artifactId"] = hashlib.sha256( + identifier.encode() + ).hexdigest() + for field, value in template["parameters"].items(): + if value != template_baseline["parameters"][field]: + row["parameters"][field] = value + settings[selected_id if identifier == "baseline" else identifier] = row + coverage["candidateSettings"] = settings + coverage["combinedCandidateId"] = selected_id + coverage["resolutionCandidateIds"] = [ + selected_id if identifier == "baseline" else identifier + for identifier in coverage["resolutionCandidateIds"] + ] + for row in coverage["comparisons"]: + row["baselineCandidateId"] = selected_id + if "observedProof" in row: + row["observedProof"]["baselineFeatures"] = deepcopy(baseline["features"]) + for candidate in run.evaluations[scope][1:]: + settings[candidate.candidateId] = { + **run.settings[candidate.candidateId].model_dump(mode="json"), + "scope": scope, + "status": candidate.status, + "cellSelection": candidate.cellSelection.model_dump(mode="json"), + "metrics": candidate.metrics.model_dump(mode="json"), + } + coverage["fullRepair"] = { + "baselineCandidateId": selected_id, + "selectedCandidateId": candidate.candidateId, + } + return coverage + + +def comparison_conclusions(evidence: dict[str, Any]) -> list[dict[str, Any]]: + coverage = evidence["comparisonCoverage"] + by_axis: dict[str, set[str]] = {} + for row in coverage["comparisons"]: + ids = by_axis.setdefault(row["axis"], set()) + ids.add(row["baselineCandidateId"]) + if row["alternativeCandidateId"] is not None: + ids.add(row["alternativeCandidateId"]) + return [ + { + "axis": axis, + "candidateIds": sorted(ids), + "preferredCandidateId": evidence["currentCandidateId"], + "quantitativeReason": "The supplied fixture metrics support retaining the observed baseline.", + "biologicalReason": "The supplied marker program remains represented.", + "plainLanguageSummary": "The observed alternatives do not justify changing this setting.", + } + for axis, ids in by_axis.items() + ] + + def make_run( monkeypatch: pytest.MonkeyPatch, model: Any ) -> tuple[rna_tuning.RnaTuningRun, ParameterCandidateEvaluation]: @@ -50,6 +129,14 @@ def make_run( ) selected = example(ParameterCandidateEvaluation) selected.parameters.useHarmony = False + selected.parameters.dimensions = 20 + selected.parameters.neighborsK = 11 + selected.parameters.leidenResolution = 1.0 + selected.metrics.nClusters = 2 + selected.metrics.topMarkerGenes = { + "0": ["NKG7", "GNLY"], + "1": ["MS4A1", "CD79A"], + } for field in ( "seedStability", "subsampleStability", @@ -62,6 +149,25 @@ def make_run( run.settings[selected.candidateId] = run.baseline().model_copy( update={"parameters": selected.parameters} ) + run.store = SimpleNamespace( + inspect_artifact=lambda _ref: SimpleNamespace( + exists=True, + complete=True, + inputs={"cell_selection": run.cells.to_dict()}, + ), + load_artifact=lambda _ref: {"values": np.repeat([0, 1], 50)}, + ) + monkeypatch.setattr( + rna_tuning, + "uniform_screening_selection", + lambda _store, cells, **_kwargs: cells, + ) + monkeypatch.setattr( + run, + "comparison_coverage", + lambda scope, _cells: comparison_coverage(run, scope), + ) + monkeypatch.setattr(run, "_feature_experiments", lambda _setting: {}) monkeypatch.setattr( run, "feature_evidence", @@ -93,7 +199,8 @@ def assess(**kwargs: Any) -> Any: action="accept", selectedCandidateId=selected, correctionNeed="notApplicable", - assessedDomains=sorted(rna_tuning._DOMAINS), + comparisonConclusions=comparison_conclusions(evidence), + plainLanguageSummary="The observed settings preserve the reported cytotoxic program.", evidenceIds=[ f"candidate:{selected}", "featureEvidence", @@ -504,7 +611,8 @@ async def reply(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: action="accept", selectedCandidateId=selected_id, correctionNeed="notApplicable", - assessedDomains=sorted(rna_tuning._DOMAINS), + comparisonConclusions=comparison_conclusions(evidence), + plainLanguageSummary="The observed settings preserve the reported cytotoxic program.", evidenceIds=[invalid_id if requests == 1 else metric], quantitativeFindings=["The supplied seed stability is 0.9."], qualitativeFindings=["NKG7 and GNLY support a cytotoxic program."], @@ -535,12 +643,32 @@ def test_saved_scientific_defer_replays_completed_candidates_without_new_work( run.handoff.nCells = 100 run.evaluations["full"] = [] run.settings = {} + monkeypatch.setattr( + run, + "comparison_coverage", + rna_tuning.RnaTuningRun.comparison_coverage.__get__(run), + ) + run.handoff.graphFeatureCandidates["eligibleAll"] = ( + run.handoff.graphFeatureCandidates["eligibleDefault"] + ) def unexpected(*args: Any, **kwargs: Any) -> Any: pytest.fail("A committed defer cannot trigger new model or scientific work") run.store = SimpleNamespace( - inspect_artifact=lambda _: SimpleNamespace(exists=True, complete=True), + inspect_artifact=lambda _: SimpleNamespace( + exists=True, complete=True, inputs={"cell_selection": run.cells.to_dict()} + ), + load_artifact=lambda ref: { + "values": np.ones(1000, dtype=bool) + if ref.kind == "feature_selection" + else np.repeat([0, 1], 50) + }, + get_assay=lambda _: SimpleNamespace( + feats=SimpleNamespace( + fetch_all=lambda _: np.asarray([f"G{i}" for i in range(1000)]) + ) + ), run_normalization=unexpected, ) monkeypatch.setattr( @@ -548,8 +676,35 @@ def unexpected(*args: Any, **kwargs: Any) -> Any: "screening_coverage", lambda *args, **kwargs: ({"screeningCells": 100}, []), ) - for resolution in (0.5, 0.75, 1.0, 1.25): - setting = run.baseline(resolution) + baseline = run.baseline() + baseline_identity = rna_tuning.candidate_identity( + run.execution_inputs(run.cells, baseline) + ) + prepared_baseline = baseline.model_copy( + update={ + "parameters": baseline.parameters.model_copy( + update={"candidateId": f"rna_{baseline_identity[:24]}"} + ) + } + ) + for count in (2000, 4000): + saved[f"parameter_tuning/sample0/sensitivity/hvgCount:{count}/setting"] = { + "inputs": { + "baseline": prepared_baseline.model_dump(mode="json"), + "experiment": {"parameter": "hvgCount", "value": count}, + "cells": run.cells.to_dict(), + }, + "outputs": {"setting": prepared_baseline.model_dump(mode="json")}, + } + settings = [run.baseline(resolution) for resolution in (0.5, 0.75, 1.0, 1.25)] + settings += [ + baseline.model_copy( + update={"parameters": baseline.parameters.model_copy(update={field: value})} + ) + for field, values in (("dimensions", (10, 30)), ("neighborsK", (21, 41))) + for value in values + ] + for setting in settings: inputs = run.execution_inputs(run.cells, setting) candidate_id = f"rna_{rna_tuning.candidate_identity(inputs)[:24]}" candidate = prototype.model_copy(deep=True) @@ -558,7 +713,7 @@ def unexpected(*args: Any, **kwargs: Any) -> Any: update={"candidateId": candidate_id} ) candidate.evidenceIds = [f"candidate:{candidate_id}:seedStability"] - admission = run.budget.admit("full", inputs) + admission = run.budget.admit("sample0", inputs) run.budget.complete( admission, {"evaluation": candidate.model_dump(mode="json")} ) @@ -581,6 +736,11 @@ def defer(**kwargs: Any) -> Any: resumed.store = run.store resumed.evaluations["full"] = [] resumed.settings = {} + monkeypatch.setattr( + resumed, + "comparison_coverage", + rna_tuning.RnaTuningRun.comparison_coverage.__get__(resumed), + ) monkeypatch.setattr(rna_tuning, "run_agent_sync", unexpected) monkeypatch.setattr(tuning, "_analysis_visual_content", unexpected) resumed_report, resumed_summary = resumed.run() @@ -643,7 +803,8 @@ def test_assessment_schema_limits_choices_without_changing_saved_fields( action="defer", selectedCandidateId="observed_a", correctionNeed="notApplicable", - assessedDomains=[], + comparisonConclusions=[], + plainLanguageSummary="Independent evidence is still needed.", evidenceIds=["candidate:observed_a"], quantitativeFindings=["Observed stability needs further assessment."], qualitativeFindings=["Marker support remains unresolved."], @@ -695,7 +856,8 @@ async def reply(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: selectedCandidateId=selected_id, experimentId=chosen_experiment, correctionNeed="notApplicable", - assessedDomains=[], + comparisonConclusions=[], + plainLanguageSummary="Request one observed-evidence-driven comparison.", evidenceIds=[f"candidate:{selected_id}"], quantitativeFindings=["The supplied stability metric is 0.9."], qualitativeFindings=["Reported markers support a cytotoxic program."], diff --git a/tests/test_agent_rna_rare_population.py b/tests/test_agent_rna_rare_population.py index 947be108..ddf21c0a 100644 --- a/tests/test_agent_rna_rare_population.py +++ b/tests/test_agent_rna_rare_population.py @@ -4,6 +4,7 @@ from pathlib import Path from types import SimpleNamespace from typing import Any +from time import perf_counter import numpy as np import pytest @@ -20,18 +21,23 @@ from scarf.agent.parameter_tuning.hvg import core_hvg_evidence from scarf.agent.types import ArtifactReferenceModel from scarf.datastore.datastore import DataStore +from scarf.storage.selections import read_stored_selection_indices from tests.agent_examples import example +from tests.agent_comparison_examples import observed_action from tests.test_agent_ingest import _write_h5ad from tests.test_agent_rna_adaptive import checkpoints # noqa: F401 @pytest.mark.slow @pytest.mark.usefixtures("checkpoints") +@pytest.mark.parametrize("maximum_sample", [350, 400]) def test_rare_study_group_enlarges_then_retains_full_reference_markers( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + maximum_sample: int, ) -> None: """A 6% group needs full evidence, without being discarded as invalid.""" + started = perf_counter() rng = np.random.default_rng(4444) values = rng.poisson(0.2, (400, 90)).astype(np.uint16) values[:188, :12] += rng.poisson(9.0, (188, 12)).astype(np.uint16) @@ -136,40 +142,41 @@ def test_rare_study_group_enlarges_then_retains_full_reference_markers( def assess(**kwargs: Any) -> Any: evidence = json.loads(kwargs["user_prompt"][0]) assessments.append(evidence["scope"]) - assert evidence["scope"] == "full" + assert evidence["scope"] in {"sample1", "full"} chosen = next( row for row in evidence["candidates"] if row["parameters"]["leidenResolution"] == 0.5 ) assert chosen["eligible"] and chosen["metrics"]["markerCoherence"] is not None - action = rna_tuning.TuningAction( - action="accept", - selectedCandidateId=chosen["candidateId"], - correctionNeed="notApplicable", - assessedDomains=sorted(rna_tuning._DOMAINS), - evidenceIds=[ - f"candidate:{chosen['candidateId']}", - *evidence["imageHashes"], - ], - quantitativeFindings=[ - "The full reference retains the 24-cell condition group and the candidate has marker and stability evidence." - ], - qualitativeFindings=[ - "The observed marker/PCA board supports the distinct NKG7/GNLY cytotoxic program." - ], - objectivePreservation="Retain the rare condition group; do not merge or discard its cells because screening underrepresents it.", - rationale="Accept the supported full-cohort partition after both samples lacked rare-group evidence.", + action = rna_tuning.TuningAction.model_validate( + observed_action( + evidence, + selected=chosen["candidateId"] + if evidence["comparisonCoverage"]["phase"] != "sensitivity" + else None, + ) ) return SimpleNamespace(output=kwargs["output_validator"](action)) monkeypatch.setattr(rna_tuning, "run_agent_sync", assess) + execute = rna_tuning.execute_parameter_candidate + executions = [] + + def measured_execute(*args: Any, **kwargs: Any) -> Any: + result = execute(*args, **kwargs) + executions.append(result.candidateId) + return result + + monkeypatch.setattr(rna_tuning, "execute_parameter_candidate", measured_execute) run = rna_tuning.RnaTuningRun( SimpleNamespace(model=object()), store, SimpleNamespace(workflowRunId="rare-test"), SimpleNamespace( - config=AutomatedWorkflowConfig(screeningCells=100, maxScreeningCells=200) + config=AutomatedWorkflowConfig( + screeningCells=100, maxScreeningCells=maximum_sample + ) ), plan, handoff, @@ -179,7 +186,7 @@ def assess(**kwargs: Any) -> Any: ) report, summary = run.run() assert report.status == "done", report.rationale - assert assessments == ["full"] + assert assessments == ["sample1", "sample1", "full"] coverage = [row for row in summary["history"] if "coverage" in row] assert [row["scope"] for row in coverage] == ["sample0", "sample1", "full"] rare_rows = [ @@ -190,15 +197,18 @@ def assess(**kwargs: Any) -> Any: ) for row in coverage ] - assert 0 < rare_rows[0]["screeningCells"] < rare_rows[1]["screeningCells"] < 20 + assert 0 < rare_rows[0]["screeningCells"] < 20 <= rare_rows[1]["screeningCells"] assert rare_rows[2]["screeningCells"] == 24 - assert coverage[0]["coverageConcerns"] and coverage[1]["coverageConcerns"] - assert run.evaluations["sample0"] == run.evaluations["sample1"] == [] + assert coverage[0]["coverageConcerns"] and not coverage[1]["coverageConcerns"] + assert run.evaluations["sample0"] == [] assert summary["budget"]["scopes"]["sample0"]["reserved"]["partitions"] == 0 - assert summary["budget"]["scopes"]["sample1"]["reserved"]["partitions"] == 0 + assert summary["budget"]["scopes"]["sample1"]["completed"] == { + "graphs": 5, + "partitions": 8, + } assert summary["budget"]["scopes"]["full"]["completed"] == { - "graphs": 1, - "partitions": 4, + "graphs": 1 if maximum_sample < 400 else 0, + "partitions": 1 if maximum_sample < 400 else 0, } assert report.cellSelection == handoff.cellSelection assert artifact_model_to_ref(report.finalClusterArtifact) == reference @@ -215,3 +225,64 @@ def assess(**kwargs: Any) -> Any: ) rare_table = markers[markers.group_id.astype(str) == str(rare_label[0])] assert {"NKG7", "GNLY", "PRF1"}.issubset(set(rare_table.feature_name)) + from sklearn.metrics import adjusted_rand_score + + screened = next( + row + for row in run.evaluations["sample1"] + if row.parameters.leidenResolution == 0.5 + and row.parameters.dimensions == 21 + and row.parameters.neighborsK == 11 + ) + sample_ref = artifact_model_to_ref(screened.cellSelection) + rows = read_stored_selection_indices( + store.zw, + sample_ref, + kind="cell_selection", + scope="datastore", + assay=None, + table_path="cellData", + ) + sample_labels = np.asarray( + store.load_artifact(artifact_model_to_ref(screened.artifacts["clusters"]))[ + "values" + ][:] + ) + # This checks numerical transfer on exactly shared cells, independently of the scripted preference. + assert adjusted_rand_score(reference_labels[rows], sample_labels) > 0.95 + sample_rare_labels = np.unique(sample_labels[rows >= 376]) + assert len(sample_rare_labels) == 1 + assert int((sample_labels == sample_rare_labels[0]).sum()) == int( + (rows >= 376).sum() + ) + assert {"NKG7", "GNLY", "PRF1"}.issubset( + screened.metrics.topMarkerGenes[str(sample_rare_labels[0])] + ) + assert len(executions) == (9 if maximum_sample < 400 else 8) + (tmp_path / "rna_comparison_measurements.json").write_text( + json.dumps( + { + "screeningCells": maximum_sample, + "fullCells": 400, + "candidateExecutorCalls": len(executions), + "screeningCompleted": summary["budget"]["scopes"]["sample1"][ + "completed" + ], + "additionalFullCompleted": summary["budget"]["scopes"]["full"][ + "completed" + ], + "sharedCellAdjustedRandIndex": adjusted_rand_score( + reference_labels[rows], sample_labels + ), + "rareScreeningCells": int((rows >= 376).sum()), + "rareFullCells": 24, + "observedRareMarkerGenes": screened.metrics.topMarkerGenes[ + str(sample_rare_labels[0]) + ], + "testElapsedSeconds": perf_counter() - started, + "interpretation": "Local synthetic test including the independent core reference and assertions. This is not model decision agreement or a large-cohort runtime estimate.", + }, + sort_keys=True, + indent=2, + ) + ) diff --git a/tests/test_agent_teaching_model.py b/tests/test_agent_teaching_model.py new file mode 100644 index 00000000..01b52acb --- /dev/null +++ b/tests/test_agent_teaching_model.py @@ -0,0 +1,103 @@ +"""The documentation's offline provider follows the actual comparison protocol.""" + +import ast +import json +import re +from pathlib import Path + +from pydantic_ai import Agent + +from scarf.agent.orchestrator.rna_tuning import _assessment_output_type +from scarf.agent.parameter_tuning.comparisons import validate_comparison_review +from tests.agent_comparison_examples import comparison_review + + +def test_teaching_provider_nominates_combines_and_assesses_actual_evidence() -> None: + source = ( + Path(__file__).parents[1] / "docs/source/tutorials/agent_workflow.md" + ).read_text() + hidden = re.search( + r"```\{code-cell\} ipython3\n:tags: \[remove-cell\]\n(.*?)\n```", source, re.S + ) + assert hidden is not None + parsed = ast.parse(hidden[1]) + definitions = ast.Module( + body=[ + node + for node in parsed.body + if isinstance( + node, + ast.Import | ast.ImportFrom | ast.FunctionDef | ast.AsyncFunctionDef, + ) + ], + type_ignores=[], + ) + namespace = {} + # Only imports and definitions run; no notebook setup, dataset, or analysis cells. + exec(compile(definitions, "agent_workflow teaching fixture", "exec"), namespace) + model, state = namespace["_scripted_workflow_model"]() + review = comparison_review() + review["comparisonCoverage"]["candidateSettings"]["resolution-half"][ + "metrics" + ].update(seedStability=0.91, markerCoherence=0.99) + for item in review["comparisonCoverage"]["candidateSettings"].values(): + item["metrics"]["topMarkerGenes"] = {"0": ["MS4A1"], "1": []} + evidence = { + "currentCandidateId": "baseline", + "candidates": [ + {**item, "status": "done", "eligible": True} + for item in review["candidates"] + ], + "comparisonCoverage": review["comparisonCoverage"], + "imageHashes": {}, + "featureEvidence": { + "baseline": { + "families": {"hla": {"selectedGenes": 6, "selectedExamples": ["HLA-A"]}} + } + }, + "experiments": { + "excludeFamily:hla": { + "parameter": "excludeFamily", + "value": "hla", + "affectedEligibleGenes": 12, + } + }, + } + identities = tuple(item["candidateId"] for item in evidence["candidates"]) + policy = next( + row + for row in evidence["comparisonCoverage"]["comparisons"] + if row["axis"] == "featurePolicy" + ) + for phase, expected in ( + ("sensitivity", "experiment"), + ("sensitivity", "combine"), + ("validation", "accept"), + ): + evidence["comparisonCoverage"]["phase"] = phase + policy["status"] = "pending" if expected == "experiment" else "notApplicable" + output_type = _assessment_output_type( + identities, ("excludeFamily:hla",), scope="full", phase=phase + ) + result = ( + Agent(model, output_type=output_type).run_sync(json.dumps(evidence)).output + ) + assert result.action == expected + validate_comparison_review( + evidence["comparisonCoverage"], result.model_dump(mode="json") + ) + assert len(result.comparisonConclusions) == 6 + assert any(row.tradeoffs for row in result.comparisonConclusions) + if expected == "accept": + assert ( + result.selectedCandidateId + in evidence["comparisonCoverage"]["resolutionCandidateIds"] + ) + assert result.populationConcerns[0].clusterId == "1" + elif expected == "combine": + assert result.combinedSettings is not None + else: + assert result.experimentId == "excludeFamily:hla" + assert "6 selected genes" in result.concern + assert state["requests"] == 3 + assert len(state["assessments"][-1]["alternatives"]) == 4 diff --git a/tests/test_agent_tuning_reuse.py b/tests/test_agent_tuning_reuse.py index 5efb8dd7..6d31dae7 100644 --- a/tests/test_agent_tuning_reuse.py +++ b/tests/test_agent_tuning_reuse.py @@ -83,11 +83,15 @@ def counts() -> Counter[str]: assert counts()["metric_proportional_batch_mixing"] == 4 -@pytest.mark.parametrize("kind", ["categorical", "continuous"]) +@pytest.mark.parametrize( + ("kind", "artifact_covariate"), + [("categorical", False), ("continuous", False), ("continuous", True)], +) def test_pca_diagnostic_reuse_precedes_numerical_work( tmp_path: Any, monkeypatch: pytest.MonkeyPatch, kind: str, + artifact_covariate: bool, ) -> None: root = zarr.open_group(str(tmp_path / "diagnostic.zarr"), mode="w") reduction = root.create_group("reduction") @@ -136,6 +140,9 @@ def unexpected(*_args: Any, **_kwargs: Any) -> Any: "_covariate_associations", ): monkeypatch.setattr(diagnostics, name, unexpected) + covariate_artifacts = ( + {"batch": _artifact("quality_metric", 6)} if artifact_covariate else {} + ) result = diagnostics._write_pca_diagnostic( store, ParameterCandidateEvaluation( @@ -151,10 +158,16 @@ def unexpected(*_args: Any, **_kwargs: Any) -> Any: covariate_roles=("technical",), adjacent_overlap=0.8, column_kinds={"batch": kind}, + column_artifacts=covariate_artifacts, ) assert result[0] == diagnostic_ref np.testing.assert_array_equal(result[1], payload["component_variance"]) - assert planned[0]["parameters"]["covariate_fingerprints"] == {"batch": "current"} + assert planned[0]["parameters"]["covariate_fingerprints"] == { + "batch": covariate_artifacts["batch"].to_dict() + if artifact_covariate + else "current" + } + assert planned[0]["inputs"]["covariate_artifacts"] == covariate_artifacts assert planned[0]["parameters"]["covariate_kinds"] == {"batch": kind} assert ( planned[0]["parameters"]["covariate_method"] == "typedCompleteCaseAssociation" From cfd1d6ab2df9e8043efa8022ad13dc2ba6a2ec4e Mon Sep 17 00:00:00 2001 From: gautam8387 Date: Tue, 8 Sep 2026 16:38:17 +0200 Subject: [PATCH 15/21] experiment update --- scarf/agent/experimental_context/agent.py | 14 +- scarf/agent/experimental_context/contracts.py | 80 +++++++- scarf/agent/experimental_context/tools.py | 6 + .../agent/experimental_context/validation.py | 14 ++ scarf/agent/orchestrator/context.py | 29 +++ scarf/agent/orchestrator/journal.py | 2 +- tests/test_agent_design_comparisons.py | 172 +++++++++++++++++- tests/test_agent_orchestrator_stages.py | 116 ++++++++++++ tests/test_agent_provider_edges.py | 44 ++++- 9 files changed, 462 insertions(+), 15 deletions(-) diff --git a/scarf/agent/experimental_context/agent.py b/scarf/agent/experimental_context/agent.py index 06d2744b..9d6b6c74 100644 --- a/scarf/agent/experimental_context/agent.py +++ b/scarf/agent/experimental_context/agent.py @@ -78,8 +78,18 @@ def __init__( coefficients, every unit of inference, and the complete exact batch column set. Nominate up to eight comparisons that explain the study objective: single variables, two-column joint effects, or associations - within categorical strata. A comparison uses at most three observed - columns and a justified observation/independent unit. You may make one + within categorical strata. Each comparison must have a distinct + response and either one or two explanatoryColumns with conditionedOn + null, or exactly one explanatory column and one distinct categorical + conditionedOn column. These are the at most three measured columns; + observationUnit and independentUnit are separate unit fields and do + not count toward that limit. Never repeat response among explanatory + or conditioning columns. Never append unit identifiers to explanatory + columns just to identify replication. A joint explanation within + strata is unsupported; separate simpler comparisons do not establish + that joint conditional finding. If a tool rejects a proposal, correct + the named fields while preserving its scientific question or record + the unsupported requirement explicitly. You may make one follow-up call with at most four new or revised comparisons after reading the first evidence. Never split the exact batch-column set. A donor can carry biological variation and also be the explicitly diff --git a/scarf/agent/experimental_context/contracts.py b/scarf/agent/experimental_context/contracts.py index 3e5eaec3..6fac21e4 100644 --- a/scarf/agent/experimental_context/contracts.py +++ b/scarf/agent/experimental_context/contracts.py @@ -44,13 +44,54 @@ class CovariateProposal(AgentDataModel): - """One objective-led comparison of observed metadata, without expression tests.""" - - response: str - explanatoryColumns: list[str] = Field(min_length=1, max_length=2) - conditionedOn: str | None = None - observationUnit: str - independentUnit: str | None = None + """Compare at most three distinct measured columns, without expression tests. + + Use one response with either one or two explanatory columns and no conditioning, + or one response with one explanatory column and one categorical conditioning + column. Observation and independent units are separate and do not count toward + this limit. A joint comparison within strata is unsupported. + """ + + response: str = Field( + description=( + "Exact observed outcome column. It must differ from every explanatory " + "and conditioning column; do not compare a column with itself." + ) + ) + explanatoryColumns: list[str] = Field( + min_length=1, + max_length=2, + description=( + "One or two distinct observed columns, each different from response. " + "With two explanatory columns, conditionedOn must be null. With a " + "conditioning column, supply exactly one explanatory column. Unit " + "identifiers belong in observationUnit/independentUnit unless they " + "are themselves the explicitly requested scientific comparison." + ), + ) + conditionedOn: str | None = Field( + default=None, + description=( + "Optional exact categorical column defining strata, different from " + "response and the single explanatory column. Set null for a two-column " + "joint explanation. Continuous conditioning is unsupported." + ), + ) + observationUnit: str = Field( + description=( + "Exact observed column identifying the observation unit, such as a " + "sample. This unit field does not count toward the three measured " + "columns; it does not automatically belong in explanatoryColumns." + ) + ) + independentUnit: str | None = Field( + default=None, + description=( + "Exact observed independent-unit column, such as donor, or null to " + "use observationUnit. It may equal observationUnit and does not count " + "toward the three measured columns. Preserve repeated-donor identity." + ), + ) rationale: str = Field(min_length=1) protectCombination: bool = False purpose: Literal["designCoverage", "association", "effectEstimation"] = ( @@ -64,8 +105,29 @@ def validate_columns(self) -> "CovariateProposal": columns = [self.response, *self.explanatoryColumns] if self.conditionedOn is not None: columns.append(self.conditionedOn) - if len(columns) > 3 or len(columns) != len(set(columns)): - raise ValueError("A comparison requires at most three distinct columns") + repeated = sorted({name for name in columns if columns.count(name) > 1}) + if repeated: + raise ValueError( + "Comparison columns must be distinct; repeated columns " + f"{repeated!r} occur in response={self.response!r}, " + f"explanatoryColumns={self.explanatoryColumns!r}, " + f"conditionedOn={self.conditionedOn!r}. A column cannot explain " + "itself or also define its conditioning strata. Choose the actual " + "distinct measured columns. Observation and independent units may " + "share names and are not part of this uniqueness check." + ) + if len(columns) > 3: + raise ValueError( + "A comparison requires at most three distinct measured columns; " + f"received response={self.response!r}, " + f"explanatoryColumns={self.explanatoryColumns!r}, " + f"conditionedOn={self.conditionedOn!r}. With two explanatory " + "columns set conditionedOn=null for a joint comparison. A " + "within-stratum comparison requires one explanatory column. " + "Observation and independent units do not count toward this " + "limit. A joint explanation within strata is unsupported; do not " + "claim that separate simpler comparisons answer that question." + ) if any(not value.strip() for value in [*columns, self.observationUnit]): raise ValueError( "Comparison columns and observation unit must be non-empty" diff --git a/scarf/agent/experimental_context/tools.py b/scarf/agent/experimental_context/tools.py index 93fc58f2..b5ff6683 100644 --- a/scarf/agent/experimental_context/tools.py +++ b/scarf/agent/experimental_context/tools.py @@ -431,6 +431,12 @@ async def analyze_experimental_design( units_of_inference: Observation and independent units for each coefficient. batch_columns: Exact technical columns proposed for Harmony evaluation. proposals: Up to eight initial or four follow-up objective-led comparisons. + Each uses distinct response/explanatory/conditioning columns: one or + two explanatory columns without conditioning, or one explanatory + column with one categorical conditioning column. Observation and + independent units do not count toward the three-column limit. + Joint explanations within strata are unsupported. Do not discard a + scientific question or a unit identity just to fit this schema. capture_proposal: Exact capture and baseline identities supported by study prose. """ logger.info( diff --git a/scarf/agent/experimental_context/validation.py b/scarf/agent/experimental_context/validation.py index 32725a38..9e7540cf 100644 --- a/scarf/agent/experimental_context/validation.py +++ b/scarf/agent/experimental_context/validation.py @@ -26,6 +26,7 @@ from .requirements import objective_evidence, unmet_objective_requirements try: + from pydantic import ValidationError from pydantic_ai import ModelRetry except ImportError as exc: raise ImportError(AGENT_INSTALL_HINT) from exc @@ -436,6 +437,18 @@ def failed_experimental_context_result( notes=["Deterministic covariate characterization is unavailable."], ) model_detail = str(error).replace("\n", " ").strip()[:500] + failure_notes = [] + cause = error.__cause__ + if isinstance(cause, ValidationError): + details = [ + f"{'.'.join(str(part) for part in item['loc'])}: {item['msg']}" + for item in cause.errors( + include_url=False, include_input=False, include_context=False + )[:8] + ] + failure_notes.append("Invalid tool arguments: " + "; ".join(details)) + elif isinstance(cause, ModelRetry): + failure_notes.append("Rejected tool arguments: " + str(cause)[:1000]) return ExperimentalContextResult( status="failed", decision=ExperimentalContextDecision( @@ -457,6 +470,7 @@ def failed_experimental_context_result( notes=[ "The model did not produce a validated experimental-context decision.", f"Model failure: {model_detail}", + *failure_notes, ], runInfo=AgentRunInfo( agentName="experimental_context_failed", diff --git a/scarf/agent/orchestrator/context.py b/scarf/agent/orchestrator/context.py index 76cfe38a..2e5cdaa0 100644 --- a/scarf/agent/orchestrator/context.py +++ b/scarf/agent/orchestrator/context.py @@ -655,6 +655,34 @@ def find_held_out_references(value: Any) -> None: *existing_exclusion_list, } ) + retry_inputs: dict[str, Any] = {} + failed = journal._validated_done_outcome( + store, + prefix, + workflow.workflowRunId, + "experimental_context", + request_record, + parents, + required_status="failed", + ) + if failed is not None and "retryAfterFailedReport" in failed.inputs: + # A later persistence or validation error must keep the same retry + # identity so its already committed decision remains recoverable. + retry_inputs["retryAfterFailedReport"] = failed.inputs[ + "retryAfterFailedReport" + ] + if failed is not None and failed.reportReferences: + failed_report = journal.load_stage_report( + store, failed, ExperimentalContextResult + ) + if cast(ExperimentalContextResult, failed_report).status == "failed": + # A failed model report is evidence of an attempt, not a decision + # to replay. Keep it immutable and address the retry separately. + # A committed successful retry still has a stable recovery key. + retry_inputs["retryAfterFailedReport"] = failed.reportReferences[ + 0 + ].model_dump(mode="json") + logger.info("Retrying experimental context after the previous failure") started = journal._start_attempt( store.zw, prefix, @@ -663,6 +691,7 @@ def find_held_out_references(value: Any) -> None: request_record, parents, inputs={ + **retry_inputs, "studyContext": request_record.request.studyContext, "studyObjective": request_record.request.studyObjective, "cellSelection": cell_selection.model_dump(mode="json"), diff --git a/scarf/agent/orchestrator/journal.py b/scarf/agent/orchestrator/journal.py index 5f9fdc2b..5c9d6c83 100644 --- a/scarf/agent/orchestrator/journal.py +++ b/scarf/agent/orchestrator/journal.py @@ -515,7 +515,7 @@ def _validated_done_outcome( request_record: OrchestrationRequestRecord, parent_attempts: Sequence[WorkflowStageLink], *, - required_status: Literal["done", "needsInput"] = "done", + required_status: Literal["done", "needsInput", "failed"] = "done", ) -> WorkflowStageAttempt | None: """Return the newest lineage-matching stage whose persisted outputs resolve.""" diff --git a/tests/test_agent_design_comparisons.py b/tests/test_agent_design_comparisons.py index b639d9ca..42052c1b 100644 --- a/tests/test_agent_design_comparisons.py +++ b/tests/test_agent_design_comparisons.py @@ -7,7 +7,16 @@ import pandas as pd import pytest from pydantic import ValidationError -from pydantic_ai import ModelRetry +from pydantic_ai import Agent, ModelRetry +from pydantic_ai.messages import ( + ModelMessage, + ModelResponse, + RetryPromptPart, + TextPart, + ToolCallPart, +) +from pydantic_ai.models.function import AgentInfo, FunctionModel +from pydantic_ai.tools import Tool from scarf.agent.experimental_context import tools from scarf.agent.experimental_context import qc_evidence @@ -315,8 +324,167 @@ def test_unsupported_explanation_does_not_discard_protected_biology() -> None: def test_proposals_cannot_exceed_three_columns() -> None: - with pytest.raises(ValidationError, match="three distinct"): + with pytest.raises(ValidationError, match="three distinct measured") as caught: _proposal(conditionedOn="age") + message = str(caught.value) + assert "response='response'" in message + assert "explanatoryColumns=['treatment', 'time']" in message + assert "conditionedOn='age'" in message + assert "set conditionedOn=null" in message + assert "joint explanation within strata is unsupported" in message + + +@pytest.mark.parametrize( + ("explanatory", "condition"), + [(["treatment"], None), (["treatment", "time"], None), (["treatment"], "time")], +) +def test_proposal_measurement_limit_excludes_observation_and_independent_units( + explanatory: list[str], condition: str | None +) -> None: + proposal = _proposal( + explanatoryColumns=explanatory, + conditionedOn=condition, + observationUnit="sample", + independentUnit="donor", + ) + assert proposal.explanatoryColumns == explanatory + assert proposal.conditionedOn == condition + assert proposal.independentUnit == "donor" + assert _proposal(independentUnit="sample").independentUnit == "sample" + + +@pytest.mark.parametrize( + ("changes", "repeated"), + [ + ({"explanatoryColumns": ["treatment", "treatment"]}, "treatment"), + ({"explanatoryColumns": ["response"]}, "response"), + ( + {"explanatoryColumns": ["treatment"], "conditionedOn": "response"}, + "response", + ), + ( + {"explanatoryColumns": ["treatment"], "conditionedOn": "treatment"}, + "treatment", + ), + ], +) +def test_proposal_duplicate_errors_identify_the_repeated_measurement( + changes: dict[str, object], repeated: str +) -> None: + with pytest.raises( + ValidationError, match="Comparison columns must be distinct" + ) as caught: + _proposal(**changes) + message = str(caught.value) + assert f"repeated columns ['{repeated}']" in message + assert "A column cannot explain itself" in message + assert "at most three" not in message + + +@pytest.mark.parametrize( + ("invalid_changes", "correction_hint"), + [ + ({"conditionedOn": "age"}, "set conditionedOn=null"), + ({"explanatoryColumns": ["response", "time"]}, "repeated columns ['response']"), + ], +) +def test_design_tool_schema_and_retry_correct_proposals_before_computation( + monkeypatch: pytest.MonkeyPatch, + invalid_changes: dict[str, object], + correction_hint: str, +) -> None: + cells, characterization = _design() + batch_columns = ["batch_a", "batch_b"] + for batch, values in zip(batch_columns, ["treatment", "time"], strict=True): + cells.frame[batch] = cells.frame[values] + cells.columns.append(batch) + characterization.columns.append( + {"name": batch, "kind": "categorical", "domain": "technical"} + ) + deps = _deps(cells) + deps.characterization = characterization + scans: list[dict[str, object]] = [] + safety_columns: list[list[str]] = [] + batch_safety = tools._batch_safety_evidence + + def characterize(*_args: object, **kwargs: object) -> CovariateCharacterization: + scans.append(kwargs) + return characterization + + def record_batch_safety(*args: object, **kwargs: object) -> object: + safety_columns.append(kwargs["batch_columns"]) + return batch_safety(*args, **kwargs) + + monkeypatch.setattr(tools, "characterize_covariates", characterize) + monkeypatch.setattr(tools, "_offered_qc_profiles", lambda *_args: []) + monkeypatch.setattr(tools, "_batch_safety_evidence", record_batch_safety) + requests = 0 + + async def reply(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: + nonlocal requests + schema = info.function_tools[0].parameters_json_schema + proposal_schema = schema["$defs"]["CovariateProposal"] + properties = proposal_schema["properties"] + assert ( + "joint comparison within strata is unsupported" + in proposal_schema["description"] + ) + assert ( + "conditionedOn must be null" + in properties["explanatoryColumns"]["description"] + ) + assert "does not count" in properties["observationUnit"]["description"] + assert "does not count" in properties["independentUnit"]["description"] + assert "must differ" in properties["response"]["description"] + request = requests + requests += 1 + if request == 1: + retry_parts = [ + part + for message in messages + for part in message.parts + if isinstance(part, RetryPromptPart) + ] + assert correction_hint in str(retry_parts[-1].content) + assert scans == [] + assert deps.designRounds == 0 + if request < 2: + proposal = _proposal().model_dump() + if request == 0: + proposal.update(invalid_changes) + return ModelResponse( + parts=[ + ToolCallPart( + tool_name="analyze_experimental_design", + args={ + "column_domains": { + batch: "technical" for batch in batch_columns + }, + "coefficients_of_interest": [], + "units_of_inference": {}, + "batch_columns": batch_columns, + "proposals": [proposal], + }, + ) + ] + ) + return ModelResponse(parts=[TextPart("Comparison evidence computed.")]) + + agent = Agent( + FunctionModel(reply), + deps_type=ExperimentalContextDependencies, + tools=[Tool(tools.analyze_experimental_design, max_retries=3)], + ) + result = agent.run_sync("Compare the observed study design.", deps=deps) + assert result.output == "Comparison evidence computed." + assert requests == 3 + assert len(scans) == 1 + assert deps.designRounds == 1 + assert deps.toolCalls == ["analyze_experimental_design"] + assert safety_columns == [batch_columns] + assert len(deps.comparisons) == 1 + assert deps.comparisons[0].status == "computed" + assert deps.comparisons[0].proposal == _proposal() def test_combinations_preserve_typed_values_and_reject_missing() -> None: diff --git a/tests/test_agent_orchestrator_stages.py b/tests/test_agent_orchestrator_stages.py index d7f86d1a..3e5b36c2 100644 --- a/tests/test_agent_orchestrator_stages.py +++ b/tests/test_agent_orchestrator_stages.py @@ -416,6 +416,122 @@ def run(self, *_args: Any, **_kwargs: Any) -> ExperimentalContextResult: assert UnsafeAgent.calls == 1 +@pytest.mark.parametrize("after_commit", ["none", "interrupt", "exception"]) +def test_failed_context_retries_without_overwriting_or_replaying_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + after_commit: str, +) -> None: + path = create_store(tmp_path / "retry-context.zarr") + store = DataStore(str(path), default_assay="RNA", min_features_per_cell=0) + workflow = WorkflowIdentity("retry-context") + selection = ArtifactReferenceModel.from_artifact_ref( + store.snapshot_cell_selection("I") + ) + request = OrchestrationRequestRecord( + inputIdentity={}, + modelIdentity="test-model", + workflowRunId=workflow.workflowRunId, + config=AutomatedWorkflowConfig(inputPolicy="unattended"), + request=AutomatedWorkflowRequest( + sourcePath=str(path), + zarrPath=str(path), + studyContext="Treatment is confounded with batch.", + studyObjective="Preserve treatment while discovering populations.", + ), + ) + enrichment_ref = _save_input_evidence( + store, workflow, request, example(DataEnrichmentReport) + ) + report = example(ExperimentalContextResult).model_copy( + update={ + "characterization": _measured_context_characterization(store, selection), + "cellSelection": selection, + "cellQc": CellQcPlan(), + "qcProfiles": [], + "qualityMetricArtifacts": [], + "htoIdentityColumns": [], + "htoIdentityArtifacts": [], + } + ) + report.decision.batchCorrection.action = "unsafe" + failed_report = report.model_copy( + update={"status": "failed", "notes": ["Invalid design proposal"]}, deep=True + ) + failed_report.decision.batchCorrection.action = "needsInput" + + class RecoveringAgent: + calls = 0 + + def __init__(self, *_args: Any, **_kwargs: Any) -> None: + pass + + def run(self, *_args: Any, **_kwargs: Any) -> ExperimentalContextResult: + type(self).calls += 1 + return failed_report if self.calls <= 2 else report + + monkeypatch.setattr(context_module, "ExperimentalContextAgent", RecoveringAgent) + orchestrator = AgentOrchestrator(object()) + + def execute(): + return orchestrator.experimental_context_stage( + store, workflow, request, [], selection, enrichment_ref, [], [], {} + ) + + first, _ = execute() + second, _ = execute() + assert first.status == second.status == "failed" + assert RecoveringAgent.calls == 2 + assert first.reportReferences != second.reportReferences + assert second.inputs["retryAfterFailedReport"] == first.reportReferences[ + 0 + ].model_dump(mode="json") + original_failure = journal_module.read_stage_evidence( + store, first.reportReferences[0] + ) + + if after_commit != "none": + save_outcome = journal_module._save_outcome + + def interrupt(_group, _prefix, outcome): + if outcome.status == "done": + if after_commit == "exception": + raise RuntimeError( + "persistence failed after committing the decision" + ) + raise KeyboardInterrupt("interrupted after committing the decision") + save_outcome(_group, _prefix, outcome) + + monkeypatch.setattr(journal_module, "_save_outcome", interrupt) + if after_commit == "interrupt": + with pytest.raises(KeyboardInterrupt, match="committing"): + execute() + else: + exception_outcome, _ = execute() + assert exception_outcome.status == "failed" + assert exception_outcome.reportReferences == [] + monkeypatch.setattr(journal_module, "_save_outcome", save_outcome) + else: + done, _ = execute() + assert done.status == "done" + + resumed, resumed_report = execute() + assert resumed.status == "done" + assert resumed_report.status == "done" + assert resumed_report.decision.batchCorrection.action == "unsafe" + assert RecoveringAgent.calls == 3 + if after_commit != "none": + assert "recover_persisted_experimental_context_report" in resumed.actions + assert ( + journal_module.read_stage_evidence(store, first.reportReferences[0]) + == original_failure + ) + assert ( + journal_module.read_stage_evidence(store, second.reportReferences[0])["status"] + == "failed" + ) + + def test_explicit_no_inference_skip_resolves_context_without_provider_rerun( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_agent_provider_edges.py b/tests/test_agent_provider_edges.py index f8ae01e4..06329a1c 100644 --- a/tests/test_agent_provider_edges.py +++ b/tests/test_agent_provider_edges.py @@ -8,6 +8,7 @@ from scarf.storage.refs import ArtifactRef import pytest +from pydantic import ValidationError from pydantic_ai import ModelRetry, UnexpectedModelBehavior import scarf.agent.biological_interpretation.tools as biological_tools @@ -37,7 +38,10 @@ CellQcProfileEvidence, ExperimentalContextDependencies, ) -from scarf.agent.experimental_context.contracts import CovariateCharacterization +from scarf.agent.experimental_context.contracts import ( + CovariateCharacterization, + CovariateProposal, +) def test_data_enrichment_cache_rollback_and_pending_branches( @@ -229,6 +233,44 @@ def test_experimental_context_rejects_invalid_batches_and_preserves_failed_evide assert failed.runInfo.agentName == "experimental_context_failed" +@pytest.mark.parametrize("validation_failure", [False, True]) +def test_context_failure_preserves_actionable_cause_without_argument_payload( + validation_failure: bool, +) -> None: + deps = ExperimentalContextDependencies( + cellSelection=ArtifactRef( + scope="datastore", kind="cell_selection", artifact_id="c" * 64 + ) + ) + try: + if validation_failure: + CovariateProposal( + response="tissue", + explanatoryColumns=["tissue", "condition"], + observationUnit="sample", + independentUnit="donor", + rationale="PRIVATE STUDY TEXT MUST NOT APPEAR IN ERROR NOTES", + ) + else: + raise ModelRetry("Unknown batch column 'missing_batch'") + except (ValidationError, ModelRetry) as cause: + error = UnexpectedModelBehavior("Design tool retry limit reached") + error.__cause__ = cause + result = experimental_validation.failed_experimental_context_result( + deps, error=error, model_name="test-model" + ) + notes = " ".join(result.notes) + assert result.status == "failed" + assert result.decision.batchCorrection.action == "needsInput" + assert "Design tool retry limit reached" in notes + if validation_failure: + assert "Invalid tool arguments" in notes + assert "tissue" in notes + else: + assert "Unknown batch column 'missing_batch'" in notes + assert "PRIVATE STUDY TEXT" not in notes + + def test_agent_execution_logs_nested_failures_for_sync_and_async_runners( monkeypatch: pytest.MonkeyPatch, ) -> None: From af64102cc0708355c77e3bf87e4ee5e9c4c14f38 Mon Sep 17 00:00:00 2001 From: gautam8387 Date: Tue, 8 Sep 2026 20:27:37 +0200 Subject: [PATCH 16/21] budget fix; reliable decisions; capture fails --- .../base.ipynb | 47 +- docs/.jupyter_cache/global.db | Bin 36864 -> 36864 bytes docs/source/analysis_with_agents.md | 50 +- docs/source/tutorials/agent_workflow.md | 59 +- .../biological_interpretation/contracts.py | 11 +- .../biological_interpretation/validation.py | 103 +-- scarf/agent/config/agent_exec.py | 448 ++++++--- scarf/agent/data_enrichment/agent.py | 27 +- scarf/agent/data_enrichment/contracts.py | 51 +- scarf/agent/data_enrichment/validation.py | 24 +- scarf/agent/decisions/selection.py | 62 +- scarf/agent/experimental_context/agent.py | 133 ++- .../experimental_context/characterization.py | 71 +- scarf/agent/experimental_context/contracts.py | 16 +- .../agent/experimental_context/qc_evidence.py | 296 ++++-- .../experimental_context/requirements.py | 106 ++- scarf/agent/experimental_context/study.py | 8 +- scarf/agent/experimental_context/tools.py | 299 +++++- .../agent/experimental_context/validation.py | 55 +- scarf/agent/orchestrator/budget.py | 134 ++- scarf/agent/orchestrator/context.py | 144 ++- scarf/agent/orchestrator/decisions.py | 12 + scarf/agent/orchestrator/journal.py | 260 +++++- scarf/agent/orchestrator/main.py | 61 +- scarf/agent/orchestrator/models.py | 11 +- scarf/agent/orchestrator/preprocessing.py | 48 + scarf/agent/orchestrator/rna_tuning.py | 872 ++++++++++++++---- scarf/agent/orchestrator/tuning.py | 122 ++- scarf/agent/parameter_tuning/agent.py | 62 +- scarf/agent/parameter_tuning/comparisons.py | 132 ++- scarf/agent/parameter_tuning/contracts.py | 53 +- scarf/agent/parameter_tuning/diagnostics.py | 150 ++- scarf/agent/parameter_tuning/execution.py | 202 +++- scarf/agent/parameter_tuning/prompts.py | 8 +- scarf/agent/parameter_tuning/selection.py | 8 +- scarf/agent/report/artifacts.py | 1 + scarf/agent/report/rendering.py | 19 +- scarf/agent/types.py | 20 + tests/test_agent_attempt_audit.py | 393 ++++++++ tests/test_agent_beginner.py | 2 +- tests/test_agent_biological_interpretation.py | 17 +- tests/test_agent_comparison_boundaries.py | 176 ++++ tests/test_agent_context_computation_reuse.py | 310 +++++++ tests/test_agent_context_design_limits.py | 178 ++++ tests/test_agent_context_efficiency.py | 376 ++++++++ tests/test_agent_context_journal_revision.py | 208 +++++ tests/test_agent_context_resume_boundaries.py | 280 ++++++ tests/test_agent_data_enrichment.py | 2 +- tests/test_agent_decide.py | 17 +- tests/test_agent_decision_replay.py | 203 ++++ tests/test_agent_design_comparisons.py | 7 +- tests/test_agent_diagnostic_accounting.py | 332 +++++++ tests/test_agent_diagnostic_boundaries.py | 850 +++++++++++++++++ tests/test_agent_diagnostic_journal.py | 183 ++++ tests/test_agent_experimental_context.py | 3 +- tests/test_agent_feature_interventions.py | 237 +++++ tests/test_agent_global_repairs.py | 237 +++++ tests/test_agent_harmony_reference.py | 213 +++++ tests/test_agent_harmony_required.py | 279 ++++++ tests/test_agent_hvg_boundaries.py | 211 +++++ tests/test_agent_interpretation_boundaries.py | 179 ++++ .../test_agent_journal_recovery_boundaries.py | 366 ++++++++ tests/test_agent_journal_usage.py | 181 ++++ tests/test_agent_notebook_interrupt.py | 215 +++++ tests/test_agent_orchestrator.py | 13 +- tests/test_agent_parameter_tuning.py | 6 +- tests/test_agent_plot_boundaries.py | 139 +++ tests/test_agent_provider_contracts.py | 84 ++ tests/test_agent_provider_edges.py | 4 +- tests/test_agent_qc_decision_evidence.py | 87 ++ tests/test_agent_qc_policy_execution.py | 209 +++++ tests/test_agent_qc_source_boundaries.py | 177 ++++ tests/test_agent_report.py | 9 +- tests/test_agent_required_comparisons.py | 5 + tests/test_agent_rna_adaptive.py | 22 +- tests/test_agent_rna_assessment_integrity.py | 64 ++ tests/test_agent_rna_evidence_mode.py | 18 +- tests/test_agent_runtime_boundaries.py | 146 +++ tests/test_agent_sampling_recovery.py | 466 ++++++++++ tests/test_agent_screening_reference.py | 210 +++++ tests/test_agent_selection_boundaries.py | 543 +++++++++++ tests/test_agent_teaching_model.py | 25 + tests/test_agent_tuning_reuse.py | 36 + tests/test_agent_visual_adjudication.py | 209 +++++ tests/test_registered_qc_profiles.py | 7 +- 85 files changed, 11417 insertions(+), 902 deletions(-) rename docs/.jupyter_cache/executed/{515e8b0f164c54e6ce03b3b9a653116e => a6a607beb555941202828f405e85c50f}/base.ipynb (99%) create mode 100644 tests/test_agent_attempt_audit.py create mode 100644 tests/test_agent_comparison_boundaries.py create mode 100644 tests/test_agent_context_computation_reuse.py create mode 100644 tests/test_agent_context_design_limits.py create mode 100644 tests/test_agent_context_efficiency.py create mode 100644 tests/test_agent_context_journal_revision.py create mode 100644 tests/test_agent_context_resume_boundaries.py create mode 100644 tests/test_agent_decision_replay.py create mode 100644 tests/test_agent_diagnostic_accounting.py create mode 100644 tests/test_agent_diagnostic_boundaries.py create mode 100644 tests/test_agent_diagnostic_journal.py create mode 100644 tests/test_agent_feature_interventions.py create mode 100644 tests/test_agent_global_repairs.py create mode 100644 tests/test_agent_harmony_reference.py create mode 100644 tests/test_agent_harmony_required.py create mode 100644 tests/test_agent_hvg_boundaries.py create mode 100644 tests/test_agent_interpretation_boundaries.py create mode 100644 tests/test_agent_journal_recovery_boundaries.py create mode 100644 tests/test_agent_journal_usage.py create mode 100644 tests/test_agent_notebook_interrupt.py create mode 100644 tests/test_agent_plot_boundaries.py create mode 100644 tests/test_agent_provider_contracts.py create mode 100644 tests/test_agent_qc_policy_execution.py create mode 100644 tests/test_agent_qc_source_boundaries.py create mode 100644 tests/test_agent_runtime_boundaries.py create mode 100644 tests/test_agent_sampling_recovery.py create mode 100644 tests/test_agent_screening_reference.py create mode 100644 tests/test_agent_selection_boundaries.py create mode 100644 tests/test_agent_visual_adjudication.py diff --git a/docs/.jupyter_cache/executed/515e8b0f164c54e6ce03b3b9a653116e/base.ipynb b/docs/.jupyter_cache/executed/a6a607beb555941202828f405e85c50f/base.ipynb similarity index 99% rename from docs/.jupyter_cache/executed/515e8b0f164c54e6ce03b3b9a653116e/base.ipynb rename to docs/.jupyter_cache/executed/a6a607beb555941202828f405e85c50f/base.ipynb index a51f98d9..9875f8ba 100644 --- a/docs/.jupyter_cache/executed/515e8b0f164c54e6ce03b3b9a653116e/base.ipynb +++ b/docs/.jupyter_cache/executed/a6a607beb555941202828f405e85c50f/base.ipynb @@ -3,7 +3,7 @@ { "cell_type": "code", "execution_count": 1, - "id": "c47d4164", + "id": "f5df67f2", "metadata": {}, "outputs": [ { @@ -69,7 +69,7 @@ { "cell_type": "code", "execution_count": 2, - "id": "bb9ed493", + "id": "195fb81d", "metadata": { "tags": [ "remove-cell" @@ -106,7 +106,6 @@ ")\n", "from scarf.agent.experimental_context import (\n", " BatchCorrectionPlan,\n", - " CovariateEvidence,\n", " ExperimentalContextDecision,\n", ")\n", "from scarf.agent.ingest import ingest\n", @@ -150,6 +149,8 @@ " if isinstance(part, ToolReturnPart) and part.tool_name == tool_name:\n", " if isinstance(part.content, model_type):\n", " return part.content\n", + " if model_type is dict:\n", + " return json.loads(part.content) if isinstance(part.content, str) else part.content\n", " if isinstance(part.content, str):\n", " return model_type.model_validate_json(part.content)\n", " return model_type.model_validate(part.content)\n", @@ -281,14 +282,14 @@ " design = _tool_result(\n", " messages,\n", " \"analyze_experimental_design\",\n", - " CovariateEvidence,\n", + " dict,\n", " )\n", " profile = next(\n", " value\n", - " for value in design.qcProfiles\n", - " if value.action == \"skip\"\n", + " for value in design[\"qcProfiles\"]\n", + " if value[\"action\"] == \"skip\"\n", " )\n", - " evidence_id = profile.evidenceId\n", + " evidence_id = profile[\"evidenceId\"]\n", " state[\"context\"] = 3\n", " return _structured_output(\n", " info,\n", @@ -563,7 +564,7 @@ { "cell_type": "code", "execution_count": 3, - "id": "59702e5a", + "id": "5ea20c2c", "metadata": {}, "outputs": [ { @@ -605,7 +606,7 @@ { "cell_type": "code", "execution_count": 4, - "id": "d544d4c3", + "id": "038a18ff", "metadata": {}, "outputs": [ { @@ -694,7 +695,7 @@ { "cell_type": "code", "execution_count": 5, - "id": "0e0d5acb", + "id": "e257c620", "metadata": {}, "outputs": [ { @@ -722,7 +723,7 @@ { "cell_type": "code", "execution_count": 6, - "id": "9f55e04b", + "id": "9cce9a08", "metadata": {}, "outputs": [ { @@ -743,7 +744,7 @@ { "cell_type": "code", "execution_count": 7, - "id": "8e3023c5", + "id": "a679be75", "metadata": {}, "outputs": [ { @@ -895,7 +896,7 @@ { "cell_type": "code", "execution_count": 8, - "id": "d9807e44", + "id": "91aebd1b", "metadata": {}, "outputs": [ { @@ -948,16 +949,16 @@ 70, 93, 98, - 577, - 586, - 594, - 599, - 606, - 624, - 628, - 635, - 640, - 643 + 578, + 587, + 595, + 600, + 607, + 627, + 631, + 638, + 643, + 646 ] }, "nbformat": 4, diff --git a/docs/.jupyter_cache/global.db b/docs/.jupyter_cache/global.db index e54a95f4dacaedcf828e44e361e61c63973cafcd..efa8205bdcb04fb59e6c1200c14d63a638bab550 100644 GIT binary patch delta 2831 zcmeH}Jx^3Y6o!`-5n*?4Oc0_KNDOMi&3vC35(^5A79@PGu!|rlXf$Af9b$Hg6pHvY zg(f!q0%NqmL>m$ zpHf||%Pl)Corjm2zcjZWT5Q^GI(BfOal5hY!1d&IGS!evY&|O$?fb#yQ@W3`KA}Qw zBnZj~Q@zCW65UDvAll}K9#+MiQqH|%B!b!W@CJ{*2W6N5pV$#8irsC^iGhph>HdD( zwa{sIhezU#l;HwJ9#@WlvNuN7s$xk%DX$m_aC>xgEq0}d(29A*NPrekA3;}RC09?K ztI>&gBPkgN??#SjX&1-t$6}$i_8;F7DeUoVA$BG3`oMc$N3wd z#ANiN$WSZHJ8xu1fY{HI)ySGCN<6ubVUSR1Z(WWuMTWqs!Mm$T_A-&wc1*3ro)u6+ zy*35(b!WY>Q&5At@m{vnB*=N)YuFcNPN-0gLAZVuN1(9=FUX^6t zu1=eN5F!3*nCZ+&V}QbnFSg_$jn|^Id(+)*>Alp0)V0>H!9s8$c%J%}`5AnF$F95% zvVSv?P9zc?iLFGC&3wqTr|TuJwrZ<-$rJUG=X!g+<5uu&%9~WrAbGzscw(m9^5yYN- S@+uxOa7GcnCagk7fcyf(IEdc> delta 2739 zcmeH}zfTlV5XYAj5#f$)5TX@m6dN}0=e+kwOr#*OA>o&R{8aGNXutwH#2iEmMKn5v zCN}&9#%LjlHb=BT8xvdq1shuu9#?9h$eZm!TU9qc??-@x}nt7loC6nz3B z;L2((n1Ej9dYSFwwFCrC4L?f7+9)XvC60-bm>=27#8PX=NO4RImPhWTVr2oOpu`no z@%re4OsovIq_c{N#rClpG1HuL(m5s$%VYObXQ>o$@&#ie1TI~y5&Pc@TI`7wcznE+ zItu{9$S^8IV|hG_SqP;Wc_}gB43{S|XKtOdmU_XMIEfvTn`s!_`x-}zJ+TV+CO0xM zXHo(w_C#@9xs=7sz+VX|j)~;hHMP-`)D{!UaYZ@DMBv_3Qgk|U7PORqYpX;tT%Atp z$R8=iEMZ(Fj$!vqQu{ix&MF}4=zsuzn#r8we~BgT#EU)g&NpY1((p%|DEm5rE4v3{nZIT#!+%=Y(du8VeoUE`n@wJO45 zk;w(Q;I8<5at=jYpLgfEi!^BAKgRfKF>91H7|@!`D&YS7srK+kVL$&cf3@{nxD;Lp zpXa}~{eCAdzYfR$6+&g6O%qnp^k|2Hw& zCq%>;(_r3~PPe9`&-oQa5JLx|m`H|$n@IzQ$XX|X=*d-L6#m*w=7>L1&eDrOg=l>F zI2)>#`-c&Y*W1Lz;lNh52pB(gG%-gYst|)ex3byp_cbAjapuLI_^JBhNfxtVN?CGi Vh>25p?&+&EbpW7&+;l3${sHntZL0tP diff --git a/docs/source/analysis_with_agents.md b/docs/source/analysis_with_agents.md index a0b5196f..82628577 100644 --- a/docs/source/analysis_with_agents.md +++ b/docs/source/analysis_with_agents.md @@ -140,12 +140,20 @@ variables without a supported preservation measure remain explicitly unresolved. Scarf starts from its RNA settings and four partitions of the same graph. The model reviews quantitative diagnostics, marker and loading-gene evidence, and supplied images before accepting or requesting one registered experiment. The model cannot generate executable analysis code or -arbitrary `DataStore` calls. Batch correction requires both an eligible design and measured need; -acceptance additionally requires a matched native/corrected comparison preserving protected -biology, including supported joint groups. Unsafe or unknown designs cannot license correction. - -Large inputs use an immutable uniform screening cohort of 50,000 cells, with one possible -enlargement to 100,000. Coverage and rare-population concerns can require a full-cohort baseline. +arbitrary `DataStore` calls. When the design permits correction, a matched native/Harmony +evaluation is required even when correction initially appears unnecessary. Accepting correction +requires measured improvement while preserving protected biology, including supported joint +groups, and passing the existing doublet checks. Unsafe or unknown designs cannot license +correction. + +New workflows use an immutable uniform screening cohort containing 10% of retained cells, +rounded up and bounded to 10,000–100,000 cells, never exceeding the retained population. +One larger nested screen may use up to 100,000 cells when the first is smaller. Existing +explicit integer screening sizes retain their exact meaning on resume. A compatible interrupted +run configured for 50,000 screening cells keeps that setting and its matching admitted work; +it does not switch to the new fractional default. Coverage and +rare-population concerns can require targeted full-cohort recovery of measured settings; +a missing screening comparison does not authorize an unbounded full-cohort search. Screening selects settings; it does not replace the final QC-retained cohort. Selected settings are executed and assessed on the full cohort. Each screening population must compare the baseline against 2,000 and 4,000 variable genes, 10 and 30 PCA dimensions, and 21 and 41 @@ -161,8 +169,24 @@ For small cohorts, discovery uses every retained cell; these are full-sized comp in the screening allowance. Exact completed artifacts are reused for final validation without another admission. Report the analyzed population and diagnostic operations separately; the candidate allowance does not bound doublet calculations, elapsed time, or provider cost. - -Experimental Context records objective evidence requirements before tuning. Repeated donors and +A proposed recovery panel and its matched native controls must fit together before execution. +Four corrected resolutions and four native controls consume all eight additional partitions; +the one-repair limit does not reserve a ninth partition. + +Advanced history records attempted, completed and failed operation calls separately from metric +cache hits, saved-evidence restores and confirmed artifact reuse, for each invocation. These +observed calls differ from counts of unique saved artifacts, and a core operation may itself +reuse earlier work. Older histories without operation records have unknown counts, not zero. + +Experimental Context records objective evidence requirements before tuning. Explicit joint +or conditional covariate requests remain unresolved when only marginal comparisons were +nominated. Full study text is retained; compact model views deduplicate shared sources and +capture-design evidence while complete measurements remain in the stage journal. These +summaries retain adverse findings, missingness, protected-group loss and design constraints. +The agent can retrieve one exact saved policy/capture or design record when detailed thresholds +or donor examples are needed. This lookup performs no scientific recomputation. Completed +metadata inspection and design rounds are checkpointed before further model requests, so +interruption does not reset the eight-initial/four-follow-up allowance. Repeated donors and incomplete pairing receive descriptive counts and support summaries without treating cells or repeated samples as independent replicates. A method that cannot compute an association does not establish that an effect is absent or unidentifiable. Essential unresolved evidence blocks @@ -182,9 +206,13 @@ full counts and provenance. `report()` returns or regenerates one local analysis saved evidence, with no new model calls or numerical analysis. This release deliberately breaks the earlier agent imports and persistence contracts. The root -agent facade exports only `analyze_rna`, `AutomatedWorkflowResult`, and `AnalysisError`. Old agent -runs must be restarted; their numerical artifacts remain readable through ordinary Scarf APIs. -There are no implicit migrations. Standalone scientific agent APIs remain in their concrete +agent facade exports only `analyze_rna`, `AutomatedWorkflowResult`, and `AnalysisError`. Runs using removed configuration or incompatible saved contracts must be restarted; their +numerical artifacts remain readable through ordinary Scarf APIs. +There are no implicit migrations. Compatible histories with newly uncovered objective questions +receive an explicit context-evidence revision, preserving prior records and artifacts; essential +unanswered questions still prevent completion. Model attempt records include known provider +usage, output-validation feedback and failures, with unavailable usage labeled explicitly. +Standalone scientific agent APIs remain in their concrete packages, such as `scarf.agent.biological_interpretation`. ### When to use the pipeline diff --git a/docs/source/tutorials/agent_workflow.md b/docs/source/tutorials/agent_workflow.md index b3d1475b..52676a65 100644 --- a/docs/source/tutorials/agent_workflow.md +++ b/docs/source/tutorials/agent_workflow.md @@ -119,7 +119,6 @@ from scarf.agent.data_enrichment import ( ) from scarf.agent.experimental_context import ( BatchCorrectionPlan, - CovariateEvidence, ExperimentalContextDecision, ) from scarf.agent.ingest import ingest @@ -163,6 +162,8 @@ def _tool_result( if isinstance(part, ToolReturnPart) and part.tool_name == tool_name: if isinstance(part.content, model_type): return part.content + if model_type is dict: + return json.loads(part.content) if isinstance(part.content, str) else part.content if isinstance(part.content, str): return model_type.model_validate_json(part.content) return model_type.model_validate(part.content) @@ -294,14 +295,14 @@ def _scripted_workflow_model() -> tuple[FunctionModel, dict[str, Any]]: design = _tool_result( messages, "analyze_experimental_design", - CovariateEvidence, + dict, ) profile = next( value - for value in design.qcProfiles - if value.action == "skip" + for value in design["qcProfiles"] + if value["action"] == "skip" ) - evidence_id = profile.evidenceId + evidence_id = profile["evidenceId"] state["context"] = 3 return _structured_output( info, @@ -612,9 +613,11 @@ matched comparisons. Infeasible values and identical gene selections are recorde The model must explain the observed tradeoffs and which biology should be preserved. The selected combination is then executed and assessed at all four clustering resolutions before acceptance. Further unresolved concerns require a targeted comparison or an incomplete outcome. -A metric rank alone does not authorize correction or deletion of a biological program. Batch -correction requires both a supported design and a matched comparison of native and corrected -representations. Confounded technical and biological variables cannot license correction. +A metric rank alone does not authorize correction or deletion of a biological program. When the +design permits correction, a matched native/Harmony comparison is required even when correction +initially appears unnecessary. Accepting correction requires measured improvement, +preservation of protected biology, and the existing doublet checks. Confounded technical and +biological variables cannot license correction. ## Inspect the analysis @@ -648,12 +651,20 @@ visible. There is no separate technical-report application. ## Large datasets and saved work -Above 50,000 retained cells, candidate settings are screened on an immutable uniform sample. -Insufficient representation can trigger one enlargement to 100,000 cells. The sample is a tuning -cohort, not a new final cohort: the selected settings are executed and assessed on all QC-retained +New workflows screen settings on an immutable uniform sample of 10% of retained cells, +rounded up and bounded to 10,000–100,000 cells. The sample never exceeds the retained cohort. +For example, 62,721 retained cells use 10,000 initially; one million use 100,000. +Insufficient representation can trigger one enlargement up to 100,000 cells when the initial +population is smaller. An explicitly saved integer screening size is preserved on resume: +a compatible interrupted run configured for 50,000 cells keeps that setting and any matching +admitted work. The sample is a tuning cohort, not a new final cohort: the selected settings are +executed and assessed on all QC-retained cells before finalization. Sample measurements do not prove that rare populations or batch -correction will transfer. If sample coverage is inadequate, the workflow assesses a bounded -full-cohort baseline instead of deleting poorly represented groups. +correction will transfer. When a measured combined recipe needs more population support, a targeted full-cohort +recovery panel can address that concern. Its comparisons and matched controls must fit the +remaining allowance before execution. If no supported recipe exists or admission fails, the +workflow remains incomplete; it does not delete poorly represented groups or restart a broad +full-cohort grid. The default advanced limits permit 24 candidate evaluations per screening population and 48 across screening populations. Additional final validation permits four full-cohort graphs, @@ -662,14 +673,24 @@ are all-cell comparisons; their exact artifacts can be reused for final validati additional-validation allowance is not a cap on all graphs built during all-cell comparisons. They count distinct admitted work, including failed attempts. Reuse of a complete exact artifact does not spend another slot. These limits do not promise an elapsed time: ingest, QC, diagnostics, -markers, and one final UMAP also have costs. +markers, and one final UMAP also have costs. One repair is a maximum, not an extra reserved +partition: four corrected resolutions plus four matched native controls use the entire +eight-partition additional allowance. + +Advanced history separates attempted, completed and failed operation calls from metric cache +hits, restored evidence and confirmed artifact reuse for each invocation. Unique saved artifacts +are reported separately and do not establish how much computation ran. A called core operation +may itself reuse work; older histories without operation records have unknown counts, not zero. One orchestration history owns the request, evidence, decisions, and final artifact references. An identical call reuses a completed result or resumes matching interrupted work. Changed data, metadata roles, model identity, or configuration cannot silently reinterpret that history. Older agent runs without the mandatory study and comparison evidence must be restarted; they cannot resume or regenerate a report under this contract. Their historical HTML remains available, and -their numerical artifacts remain readable through the ordinary Scarf APIs. +their numerical artifacts remain readable through the ordinary Scarf APIs. Compatible histories +can append an explicit context-evidence revision when a requested joint/conditional question +was left unanswered; previous records remain immutable and changed scientific evidence must +be reassessed. ## Failure handling and advanced control @@ -696,6 +717,14 @@ explicit questions. The advanced result still carries status and resume informat grounded answers to the saved questions. A work limit pauses or fails the analysis; it does not turn an unsupported candidate into an accepted result. +Experimental Context preserves the complete study text and saves completed inspection and +design evidence before the next model request. Compact model-facing views remove repeated +source and capture-design tables; the saved evidence remains complete. A final model response +does not select QC or copy already validated capture/protection identities. Failed model +attempts retain available usage and validation feedback in advanced history; unavailable +provider usage is not reported as measured zero. These records do not impose a whole-workflow +provider-spend limit. + For live analysis, replace the `FunctionModel` with your configured Pydantic AI model and use the same `analyze_rna` call. Scarf sends diagnostic images when the model supports them. Other models assess the structured marker, loading-gene, and numerical evidence, with that limitation recorded diff --git a/scarf/agent/biological_interpretation/contracts.py b/scarf/agent/biological_interpretation/contracts.py index ec76959f..e154d853 100644 --- a/scarf/agent/biological_interpretation/contracts.py +++ b/scarf/agent/biological_interpretation/contracts.py @@ -3,6 +3,7 @@ from typing import Any, Literal from pydantic import Field +from pydantic.json_schema import SkipJsonSchema from ..types import ( AgentDataModel, @@ -146,15 +147,15 @@ class BiologicalInterpretationReport(AgentDataModel): clusterInterpretations: list[ClusterInterpretation] = Field(default_factory=list) treatmentObservations: list[TreatmentObservation] = Field(default_factory=list) followUps: list[FollowUpRecommendation] = Field(default_factory=list) - clusterArtifact: ArtifactReferenceModel | None = None - markerArtifact: ArtifactReferenceModel | None = None - graphAssay: str | None = None - markerAssay: str | None = None + clusterArtifact: SkipJsonSchema[ArtifactReferenceModel | None] = None + markerArtifact: SkipJsonSchema[ArtifactReferenceModel | None] = None + graphAssay: SkipJsonSchema[str | None] = None + markerAssay: SkipJsonSchema[str | None] = None evidenceIds: list[str] = Field(default_factory=list) limitations: list[str] = Field(default_factory=list) stopReason: str = "" needsInput: BiologicalInterpretationNeedsInput | None = None - runInfo: AgentRunInfo = Field(default_factory=AgentRunInfo) + runInfo: SkipJsonSchema[AgentRunInfo] = Field(default_factory=AgentRunInfo) class BiologicalInterpretationDependencies(AgentDataModel): diff --git a/scarf/agent/biological_interpretation/validation.py b/scarf/agent/biological_interpretation/validation.py index 4e734b76..4d160a72 100644 --- a/scarf/agent/biological_interpretation/validation.py +++ b/scarf/agent/biological_interpretation/validation.py @@ -15,7 +15,6 @@ _MAX_CLUSTERS, _MAX_MARKERS, BiologicalInterpretationDependencies, - BiologicalInterpretationNeedsInput, BiologicalInterpretationReport, ClusterInterpretation, TreatmentDirection, @@ -325,83 +324,41 @@ def fallback_biological_interpretation_report( error: UnexpectedModelBehavior | UsageLimitExceeded, model_name: str, ) -> BiologicalInterpretationReport: - """Return exact unresolved identities when structured interpretation fails.""" + """Keep measured evidence without claiming completed model interpretation.""" if not deps.clusterValues: raise error - error_detail = str(error).replace("\n", " ").strip()[:500] - interpretations = [ - ClusterInterpretation( - clusterId=cluster_id, - proposedIdentity="unresolved", - identityIsHypothesis=True, - confidence="low", - rationale=( - "Exact marker evidence was available, but structured biological " - "interpretation was unavailable." - ), - evidenceIds=[evidence_id], - ) - for cluster_id, evidence_id in sorted(deps.markerEvidenceIds.items()) - ] - if interpretations: - report = BiologicalInterpretationReport( - status="done", - clusterInterpretations=interpretations, - evidenceIds=[ - evidence_id - for interpretation in interpretations - for evidence_id in interpretation.evidenceIds - ], - limitations=[ - "Cluster identities remain unresolved because structured model " - "interpretation exhausted its bounded correction budget.", - "No treatment observations were generated by the fallback.", - error_detail, - ], - stopReason=( - "Exact marker-bearing clusters were retained as unresolved " - "low-confidence hypotheses." - ), - runInfo=AgentRunInfo( - agentName="biological_interpretation_fallback", - modelName=model_name, - ), - ) - else: - composition_evidence = sorted( - evidence_id - for evidence_id in deps.evidenceIds - if evidence_id.startswith("composition:") - ) - report = BiologicalInterpretationReport( - status="needsInput", - evidenceIds=composition_evidence, - limitations=[ - "No non-empty marker evidence was available for a grounded cluster " - "interpretation.", - error_detail, - ], - stopReason="Biological interpretation requires marker evidence.", - needsInput=BiologicalInterpretationNeedsInput( - question=( - "Provide an exact marker artifact with non-empty cluster markers " - "or revise the authorized marker thresholds." - ), - requiredInputs=["markerArtifactOrThresholds"], - evidenceIds=composition_evidence, - ), - runInfo=AgentRunInfo( - agentName="biological_interpretation_fallback", - modelName=model_name, + from ..config.agent_exec import describe_agent_error + + error_detail = describe_agent_error(error) + report = BiologicalInterpretationReport( + status="failed", + clusterArtifact=artifact_reference(deps.cluster) + if deps.cluster is not None + else None, + markerArtifact=artifact_reference(deps.marker) + if deps.marker is not None + else None, + graphAssay=deps.graphAssay, + markerAssay=deps.markerAssay, + evidenceIds=sorted({*deps.evidenceIds, *deps.markerEvidenceIds.values()}), + limitations=[ + "Measured composition and marker evidence remain available. No biological " + "interpretation or treatment observation was accepted after model failure.", + error_detail, + ], + stopReason="The model's biological interpretation could not be validated.", + runInfo=getattr( + error, + "agent_run_info", + AgentRunInfo( + agentName="biological_interpretation_failed", modelName=model_name ), - ) - validated = validate_biological_interpretation_report(report, deps) + ), + ) logger.warning( - "Biological Interpretation used its conservative fallback: " - f"status={validated.status}, clusters=" - f"{len(validated.clusterInterpretations)}, reason={error_detail}" + f"Biological interpretation failed; observed evidence was retained: {error_detail}" ) - return validated + return report def _prepare_biological_interpretation_dependencies( diff --git a/scarf/agent/config/agent_exec.py b/scarf/agent/config/agent_exec.py index 66f6a89e..1906a888 100644 --- a/scarf/agent/config/agent_exec.py +++ b/scarf/agent/config/agent_exec.py @@ -1,12 +1,15 @@ """Common bounded execution for the four Scarf domain agents.""" import asyncio +import json import sys import time +import uuid from collections.abc import Callable, Sequence from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from inspect import isawaitable, iscoroutinefunction +from threading import Event, Lock from typing import TYPE_CHECKING, Any, Literal from ...utils.logging import logger @@ -15,6 +18,7 @@ AgentExecutionResult, AgentRunInfo, AgentUsageInfo, + AgentValidationRetry, ToolCallInfo, ) from . import AgentRunConfig, get_model_settings, get_usage_limits @@ -56,6 +60,24 @@ class ImageInputUnsupportedError(RuntimeError): """The configured model or provider rejected image input.""" +def describe_agent_error(error: BaseException, *, limit: int = 8000) -> str: + """Describe the concrete cause chain without traceback or response payloads.""" + parts: list[str] = [] + seen: set[int] = set() + current: BaseException | None = error + while current is not None and id(current) not in seen and len(parts) < 8: + seen.add(id(current)) + detail = " ".join(str(current).split()) + notes = getattr(current, "__notes__", ()) + if notes: + detail += "; " + "; ".join(str(note) for note in notes) + parts.append(f"{type(current).__name__}: {detail[:2000]}") + current = current.__cause__ or ( + current.__context__ if not current.__suppress_context__ else None + ) + return "; caused by ".join(parts)[:limit] + + def _image_input_is_unsupported(exc: Exception) -> bool: from pydantic_ai.exceptions import ModelHTTPError, UserError @@ -230,11 +252,15 @@ def _tool_calls( continue if part.tool_name not in allowed_names: continue + try: + arguments = part.args_as_dict() + except (TypeError, ValueError): + arguments = {"unparsedArguments": str(part.args)} calls.append( ToolCallInfo( toolName=part.tool_name, callId=part.tool_call_id or "", - arguments=part.args_as_dict(), + arguments=arguments, ) ) return calls @@ -267,9 +293,10 @@ def _build_agent( name: str | None, output_validator: Callable[[Any], Any] | None, normalize_sync_function_model: bool, + validation_failures: list[AgentValidationRetry] | None = None, ) -> Any: require_pydantic_ai() - from pydantic_ai import Agent + from pydantic_ai import Agent, RunContext agent = Agent( _normalize_model(model) if normalize_sync_function_model else model, @@ -285,21 +312,44 @@ def _build_agent( if output_validator is not None: @agent.output_validator - async def validate_output(output: Any) -> Any: + async def validate_output(context: RunContext[Any], output: Any) -> Any: from pydantic_ai import ModelRetry + submitted = ( + output.model_dump(mode="json") + if hasattr(output, "model_dump") + else str(output) + ) try: validated = output_validator(output) if isawaitable(validated): return await validated return validated except ModelRetry as exc: + if validation_failures is not None: + validation_failures.append( + AgentValidationRetry( + source="output", + requestIndex=context.usage.requests, + message=str(exc), + response=submitted, + ) + ) logger.warning( f"Agent {name or 'unnamed'} requested a structured-output " f"retry: {str(exc)[:500]}" ) raise except (TypeError, ValueError) as exc: + if validation_failures is not None: + validation_failures.append( + AgentValidationRetry( + source="output", + requestIndex=context.usage.requests, + message=str(exc), + response=submitted, + ) + ) logger.warning( f"Agent {name or 'unnamed'} rejected structured output: " f"{str(exc)[:500]}" @@ -309,35 +359,225 @@ async def validate_output(output: Any) -> Any: return agent -def _execution_result( +def _run_info( *, - result: Any, + messages: Sequence[Any], + usage: Any, model: Any, name: str | None, started: float, tools: Sequence[Callable[..., Any] | Any], -) -> AgentExecutionResult: - messages = result.new_messages() + validation_failures: Sequence[AgentValidationRetry], + error: BaseException | None = None, +) -> AgentRunInfo: + from pydantic import ValidationError + from pydantic_ai.messages import ModelResponse, RetryPromptPart, ToolCallPart + calls = _tool_calls(messages, allowed_names=_tool_names(tools)) - execution = AgentExecutionResult( - output=result.output, - runInfo=AgentRunInfo( - agentName=name or "", - modelName=_model_name(model), - runId=str(getattr(result, "run_id", "")), - durationSeconds=time.monotonic() - started, - usage=_usage_info(result.usage, tool_calls=len(calls)), - toolCalls=calls, + reported_usage = _usage_info(usage, tool_calls=len(calls)) + responses = [message for message in messages if isinstance(message, ModelResponse)] + measured = [message for message in responses if message.usage.has_values()] + reported_usage.availability = ( + "reported" + if measured + and len(measured) == len(responses) == reported_usage.requests + and not ( + error is not None + and messages + and not isinstance(messages[-1], ModelResponse) + ) + and all( + getattr(message, "state", "complete") == "complete" for message in responses + ) + else "partial" + if measured + else "unavailable" + ) + retries = list(validation_failures) + semantic_messages = {retry.message for retry in retries} + pending: dict[str, Any] = {} + tool_names = _tool_names(tools) + request_index = 0 + for message in messages: + if isinstance(message, ModelResponse): + request_index += 1 + for part in getattr(message, "parts", ()): + if isinstance(part, ToolCallPart): + pending[part.tool_call_id] = part.args + elif isinstance(part, RetryPromptPart): + detail = ( + part.content + if isinstance(part.content, str) + else json.dumps(part.content, default=str, sort_keys=True) + ) + if detail not in semantic_messages: + retries.append( + AgentValidationRetry( + source="tool" if part.tool_name in tool_names else "schema", + requestIndex=request_index, + message=detail, + response=pending.get(part.tool_call_id), + ) + ) + error_detail = describe_agent_error(error) if error is not None else None + if ( + error is not None + and isinstance(error.__cause__, ValidationError) + and messages + and isinstance(messages[-1], ModelResponse) + ): + # The SDK does not append feedback after the last schema retry is exhausted. + calls_in_response = [ + part for part in messages[-1].parts if isinstance(part, ToolCallPart) + ] + if len(calls_in_response) == 1: + rejected = calls_in_response[0] + retries.append( + AgentValidationRetry( + source="tool" if rejected.tool_name in tool_names else "schema", + requestIndex=len(responses), + message=str(error.__cause__), + response=rejected.args, + ) + ) + return AgentRunInfo( + agentName=name or "unnamed", + modelName=_model_name(model), + runId=next( + ( + str(message.run_id) + for message in reversed(messages) + if getattr(message, "run_id", None) + ), + uuid.uuid4().hex, ), + durationSeconds=time.monotonic() - started, + usage=reported_usage, + toolCalls=calls, + status="failed" if error is not None else "done", + validationRetries=sorted(retries, key=lambda retry: retry.requestIndex), + errorType=type(error).__name__ if error is not None else None, + error=error_detail, ) - usage = execution.runInfo.usage + + +async def _execute_agent( + *, + model: Any, + output_type: Any, + system_prompt: str, + user_prompt: AgentUserPrompt, + tools: Sequence[Callable[..., Any] | Any], + deps_type: type[Any] | None, + deps: Any, + config: AgentRunConfig | None, + name: str | None, + output_validator: Callable[[Any], Any] | None, + message_history: Sequence[Any], + on_attempt: Callable[[AgentRunInfo], None] | None, + normalize_sync_function_model: bool, + cancel_requested: Event | None = None, +) -> AgentExecutionResult: + from pydantic_ai import capture_run_messages + from pydantic_ai.usage import RunUsage + + run_config = config or AgentRunConfig() + agent_name = name or "unnamed" + usage_limits = get_usage_limits(run_config) + usage = RunUsage() + failures: list[AgentValidationRetry] = [] + started = time.monotonic() logger.debug( - f"Agent {name or 'unnamed'} completed in " - f"{execution.runInfo.durationSeconds:.2f}s: requests={usage.requests}, " - f"tool_calls={usage.toolCalls}, input_tokens={usage.inputTokens}, " - f"output_tokens={usage.outputTokens}" + f"Starting agent {agent_name}: model={_model_name(model)}, " + f"tools={len(tools)}, request_limit={run_config.requestLimit}, " + f"tool_call_limit={run_config.toolCallLimit}, retries={run_config.retries}, " + f"per_response_output_limit={run_config.outputTokenLimit}, " + f"run_output_limit={usage_limits.output_tokens_limit}" ) - return execution + with capture_run_messages() as messages: + try: + if cancel_requested is not None and cancel_requested.is_set(): + raise asyncio.CancelledError( + "Analysis interrupted before model execution" + ) + agent = _build_agent( + model=model, + output_type=output_type, + system_prompt=system_prompt, + tools=tools, + deps_type=deps_type, + config=run_config, + name=name, + output_validator=output_validator, + normalize_sync_function_model=normalize_sync_function_model, + validation_failures=failures, + ) + async with agent: + try: + result = await agent.run( + user_prompt, + deps=deps, + message_history=message_history, + usage_limits=usage_limits, + usage=usage, + ) + except Exception as exc: + if not isinstance(user_prompt, str) and _image_input_is_unsupported( + exc + ): + raise ImageInputUnsupportedError( + "The configured model does not accept image input" + ) from exc + raise + except (Exception, asyncio.CancelledError) as exc: + info = _run_info( + messages=messages[len(message_history) :], + usage=usage, + model=model, + name=name, + started=started, + tools=tools, + validation_failures=failures, + error=exc, + ) + setattr(exc, "agent_run_info", info) + logger.error( + f"Agent {agent_name} failed after {info.durationSeconds:.2f}s: " + f"{info.error}" + ) + if on_attempt is not None: + try: + on_attempt(info) + except Exception as callback_error: + exc.add_note( + f"Saving agent execution evidence also failed: {type(callback_error).__name__}: {callback_error}" + ) + raise + info = _run_info( + messages=result.new_messages(), + usage=result.usage, + model=model, + name=name, + started=started, + tools=tools, + validation_failures=failures, + ) + if on_attempt is not None: + try: + on_attempt(info) + except Exception as exc: + setattr(exc, "agent_run_info", info) + exc.add_note( + "The model completed, but saving its execution evidence failed." + ) + raise + logger.debug( + f"Agent {agent_name} completed in {info.durationSeconds:.2f}s: " + f"requests={info.usage.requests}, tool_calls={info.usage.toolCalls}, " + f"input_tokens={info.usage.inputTokens}, output_tokens={info.usage.outputTokens}, " + f"usage={info.usage.availability}" + ) + return AgentExecutionResult(output=result.output, runInfo=info) def run_agent_sync( @@ -353,6 +593,7 @@ def run_agent_sync( name: str | None = None, output_validator: Callable[[Any], Any] | None = None, message_history: Sequence[Any] = (), + on_attempt: Callable[[AgentRunInfo], None] | None = None, ) -> AgentExecutionResult: """Run one synchronous agent loop and return its bounded audit record. @@ -360,67 +601,38 @@ def run_agent_sync( asynchronous runner cannot drive that loop synchronously, so this hops to a worker thread in that case. Entering the agent context ensures provider HTTP clients are closed on that worker's event loop before it exits. + + ``on_attempt`` receives invocation evidence once, including failure usage and + rejected responses. It runs on the agent's event-loop thread. A failed run + re-raises its original exception with this evidence in ``agent_run_info``. """ - async def execute() -> AgentExecutionResult: - run_config = config or AgentRunConfig() - agent_name = name or "unnamed" - usage_limits = get_usage_limits(run_config) - logger.debug( - f"Starting agent {agent_name}: model={_model_name(model)}, " - f"tools={len(tools)}, request_limit={run_config.requestLimit}, " - f"tool_call_limit={run_config.toolCallLimit}, retries={run_config.retries}, " - f"per_response_output_limit={run_config.outputTokenLimit}, " - f"run_output_limit={usage_limits.output_tokens_limit}" - ) - agent = _build_agent( + cancel_requested = Event() + worker_lock = Lock() + worker: tuple[asyncio.AbstractEventLoop, asyncio.Task[Any]] | None = None + + async def execute(*, notebook_worker: bool = False) -> AgentExecutionResult: + nonlocal worker + if notebook_worker: + task = asyncio.current_task() + assert task is not None + with worker_lock: + worker = (asyncio.get_running_loop(), task) + return await _execute_agent( model=model, output_type=output_type, system_prompt=system_prompt, + user_prompt=user_prompt, tools=tools, deps_type=deps_type, - config=run_config, + deps=deps, + config=config, name=name, output_validator=output_validator, normalize_sync_function_model=True, - ) - started = time.monotonic() - try: - async with agent: - try: - result = await agent.run( - user_prompt, - deps=deps, - message_history=message_history, - usage_limits=usage_limits, - ) - except Exception as exc: - if not isinstance(user_prompt, str) and _image_input_is_unsupported( - exc - ): - raise ImageInputUnsupportedError( - "The configured model does not accept image input" - ) from exc - raise - except Exception as exc: - error_detail = str(exc).replace("\n", " ").strip()[:500] - cause = exc.__cause__ - if cause is not None and cause is not exc: - cause_detail = str(cause).replace("\n", " ").strip()[:500] - error_detail = ( - f"{error_detail}; caused by {type(cause).__name__}: {cause_detail}" - ) - logger.error( - f"Agent {agent_name} failed after {time.monotonic() - started:.2f}s: " - f"{type(exc).__name__}: {error_detail}" - ) - raise - return _execution_result( - result=result, - model=model, - name=name, - started=started, - tools=tools, + message_history=message_history, + on_attempt=on_attempt, + cancel_requested=cancel_requested if notebook_worker else None, ) try: @@ -429,7 +641,38 @@ async def execute() -> AgentExecutionResult: return asyncio.run(execute()) with ThreadPoolExecutor(max_workers=1) as pool: - return pool.submit(asyncio.run, execute()).result() + future = pool.submit(asyncio.run, execute(notebook_worker=True)) + try: + return future.result() + except KeyboardInterrupt as interrupted: + cancel_requested.set() + with worker_lock: + running = worker + if running is not None: + loop, task = running + try: + loop.call_soon_threadsafe(task.cancel, "Analysis interrupted") + except RuntimeError as cancellation_error: + # The worker may have finished and closed its loop already. + interrupted.add_note( + "Cancellation raced with worker shutdown: " + + describe_agent_error(cancellation_error) + ) + try: + completed = future.result() + except BaseException as worker_error: + info = getattr(worker_error, "agent_run_info", None) + if info is not None: + setattr(interrupted, "agent_run_info", info) + interrupted.add_note( + "Worker shutdown reported: " + describe_agent_error(worker_error) + ) + else: + setattr(interrupted, "agent_run_info", completed.runInfo) + interrupted.add_note( + "The model invocation completed before cancellation was delivered." + ) + raise async def run_agent_async( @@ -445,66 +688,23 @@ async def run_agent_async( name: str | None = None, output_validator: Callable[[Any], Any] | None = None, message_history: Sequence[Any] = (), + on_attempt: Callable[[AgentRunInfo], None] | None = None, ) -> AgentExecutionResult: """Run one asynchronous agent loop and return its bounded audit record.""" - run_config = config or AgentRunConfig() - agent_name = name or "unnamed" - usage_limits = get_usage_limits(run_config) - logger.debug( - f"Starting agent {agent_name}: model={_model_name(model)}, " - f"tools={len(tools)}, request_limit={run_config.requestLimit}, " - f"tool_call_limit={run_config.toolCallLimit}, retries={run_config.retries}, " - f"per_response_output_limit={run_config.outputTokenLimit}, " - f"run_output_limit={usage_limits.output_tokens_limit}" - ) - agent = _build_agent( + return await _execute_agent( model=model, output_type=output_type, system_prompt=system_prompt, + user_prompt=user_prompt, tools=tools, deps_type=deps_type, - config=run_config, + deps=deps, + config=config, name=name, output_validator=output_validator, normalize_sync_function_model=False, - ) - started = time.monotonic() - try: - async with agent: - try: - result = await agent.run( - user_prompt, - deps=deps, - message_history=message_history, - usage_limits=usage_limits, - ) - except Exception as exc: - if not isinstance(user_prompt, str) and _image_input_is_unsupported( - exc - ): - raise ImageInputUnsupportedError( - "The configured model does not accept image input" - ) from exc - raise - except Exception as exc: - error_detail = str(exc).replace("\n", " ").strip()[:500] - cause = exc.__cause__ - if cause is not None and cause is not exc: - cause_detail = str(cause).replace("\n", " ").strip()[:500] - error_detail = ( - f"{error_detail}; caused by {type(cause).__name__}: {cause_detail}" - ) - logger.error( - f"Agent {agent_name} failed after {time.monotonic() - started:.2f}s: " - f"{type(exc).__name__}: {error_detail}" - ) - raise - return _execution_result( - result=result, - model=model, - name=name, - started=started, - tools=tools, + message_history=message_history, + on_attempt=on_attempt, ) diff --git a/scarf/agent/data_enrichment/agent.py b/scarf/agent/data_enrichment/agent.py index 7ab7d696..239c34a6 100644 --- a/scarf/agent/data_enrichment/agent.py +++ b/scarf/agent/data_enrichment/agent.py @@ -1,6 +1,6 @@ """Data enrichment prompt and agent runner.""" -from collections.abc import Sequence +from collections.abc import Callable, Sequence from pathlib import Path from textwrap import dedent from typing import Any @@ -9,6 +9,7 @@ from .._deps import AGENT_INSTALL_HINT from ..config import AgentRunConfig from ..config.agent_exec import run_agent_sync +from ..types import AgentRunInfo from .contracts import ( DataEnrichmentContext, DataEnrichmentDependencies, @@ -58,8 +59,8 @@ Persisted assay types determine modality routes; never infer a route from an assay label. The validator fills assay type, modality eligibility, ADT controls, HTO tags, ATAC-coordinate status, inspections, tool calls, and - report-level evidence. Leave those derived fields at their defaults instead - of copying them into the output. Treat Ensembl release misses as unresolved, + report-level evidence. Do not return those derived fields. + Treat Ensembl release misses as unresolved, not artificial. Mitochondrial, ribosomal, and histone families may be sensitivity candidates. Sex-linked and cell-cycle families are protected by default. Marker testing retains conditional biological families. @@ -69,9 +70,11 @@ paraphrase, infer, or invent an organism, tissue, cell type, experiment, hypothesis, or analysis intent. Empty optional hint lists do not mean that the paragraph lacks - those references. When a category is explicitly present in the paragraph, - include its exact span in the corresponding summary list. The validator - binds the original paragraph and exact caller references. Return a bounded + those references. Select at most 12 objective-relevant spans per category, + each at most 240 characters. These lists are excerpts, not an exhaustive + replacement for the full context and objective that Scarf preserves and + passes downstream. The validator binds the original paragraph and exact + caller references. Return a bounded report with citations copied from tool or context evidence IDs. Do not write code, mutate the datastore, or request arbitrary Scarf calls. """ @@ -120,6 +123,7 @@ def run( assays: Sequence[str] | None = None, cache_dir: Path | str | None = None, allow_download: bool = False, + on_attempt: Callable[[AgentRunInfo], None] | None = None, ) -> DataEnrichmentReport: """Run the bounded tool loop without mutating the supplied datastore.""" available_assays = [str(value) for value in store.assay_names] @@ -193,11 +197,11 @@ def run( Populate studyContextSummary only with exact verbatim spans from the paragraph or caller references. Empty optional hint fields do not erase references present in the paragraph. Before returning, - verify that every explicit organism, tissue, cell population, - experiment, hypothesis, and analysis intent has been placed in its - corresponding summary list. Leave inspections, modality-derived - fields, exact controls and tags, toolCalls, and report evidence at - their defaults because validation fills them from exact tool state. + select at most 12 verbatim references per category, each no more + than 240 characters, prioritizing the objective. These bounded + excerpts do not replace the preserved full context. Do not return + inspections, modality-derived fields, exact controls and tags, + toolCalls, runInfo, or report evidence; validation attaches them. """ ) .strip() @@ -240,6 +244,7 @@ def run( deps=deps, config=self.config, name="data_enrichment", + on_attempt=on_attempt, output_validator=lambda report: validate_data_enrichment_report( deps, report, diff --git a/scarf/agent/data_enrichment/contracts.py b/scarf/agent/data_enrichment/contracts.py index 935fd71e..edabd158 100644 --- a/scarf/agent/data_enrichment/contracts.py +++ b/scarf/agent/data_enrichment/contracts.py @@ -8,6 +8,7 @@ try: from pydantic import ConfigDict, Field, model_validator + from pydantic.json_schema import SkipJsonSchema except ImportError as exc: raise ImportError(AGENT_INSTALL_HINT) from exc @@ -30,15 +31,15 @@ def get_blank(cls) -> "DataEnrichmentContext": class StudyContextSummary(AgentDataModel): """Verbatim, evidence-backed references extracted from the study context.""" - studyContext: str = "" - studyObjective: str = "" + studyContext: SkipJsonSchema[str] = "" + studyObjective: SkipJsonSchema[str] = "" organismReferences: list[str] = Field(default_factory=list) tissueReferences: list[str] = Field(default_factory=list) cellTypeReferences: list[str] = Field(default_factory=list) experimentalReferences: list[str] = Field(default_factory=list) hypothesisReferences: list[str] = Field(default_factory=list) analysisIntentReferences: list[str] = Field(default_factory=list) - evidenceIds: list[str] = Field(default_factory=list) + evidenceIds: SkipJsonSchema[list[str]] = Field(default_factory=list) @classmethod def get_blank(cls) -> "StudyContextSummary": @@ -272,7 +273,7 @@ class FeatureSelectionPolicy(AgentDataModel): assay: str species: str = "unknown" - organismName: str = "unknown" + organismName: SkipJsonSchema[str] = "unknown" speciesConfidence: Literal["high", "medium", "low", "unknown"] = "unknown" speciesRationale: str = "" excludeFamilies: list[str] = Field(default_factory=list) @@ -280,19 +281,25 @@ class FeatureSelectionPolicy(AgentDataModel): excludeFeatures: list[str] = Field(default_factory=list) protectFeatures: list[str] = Field(default_factory=list) artificialFeatures: list[str] = Field(default_factory=list) - tissueReferences: list[str] = Field(default_factory=list) - cellTypeReferences: list[str] = Field(default_factory=list) - experimentalReferences: list[str] = Field(default_factory=list) - assayType: str = "Assay" - assayModality: Literal["RNA", "ATAC", "ADT", "HTO", "unsupported"] = "unsupported" - graphEligible: bool = False - markerEligible: bool = False - demultiplexEligible: bool = False - exactControlFeatures: list[FeatureReference] = Field(default_factory=list) - exactTagFeatures: list[FeatureReference] = Field(default_factory=list) - peakCoordinateStatus: Literal["notApplicable", "valid", "partial", "invalid"] = ( - "notApplicable" + tissueReferences: SkipJsonSchema[list[str]] = Field(default_factory=list) + cellTypeReferences: SkipJsonSchema[list[str]] = Field(default_factory=list) + experimentalReferences: SkipJsonSchema[list[str]] = Field(default_factory=list) + assayType: SkipJsonSchema[str] = "Assay" + assayModality: SkipJsonSchema[ + Literal["RNA", "ATAC", "ADT", "HTO", "unsupported"] + ] = "unsupported" + graphEligible: SkipJsonSchema[bool] = False + markerEligible: SkipJsonSchema[bool] = False + demultiplexEligible: SkipJsonSchema[bool] = False + exactControlFeatures: SkipJsonSchema[list[FeatureReference]] = Field( + default_factory=list ) + exactTagFeatures: SkipJsonSchema[list[FeatureReference]] = Field( + default_factory=list + ) + peakCoordinateStatus: SkipJsonSchema[ + Literal["notApplicable", "valid", "partial", "invalid"] + ] = "notApplicable" rationale: str = "" evidenceIds: list[str] = Field(default_factory=list) @@ -334,15 +341,19 @@ class DataEnrichmentReport(AgentDataModel): status: StageStatus policies: list[FeatureSelectionPolicy] = Field(default_factory=list) - inspections: list[AssayFeatureInspection] = Field(default_factory=list) + inspections: SkipJsonSchema[list[AssayFeatureInspection]] = Field( + default_factory=list + ) studyContextSummary: StudyContextSummary = Field( default_factory=StudyContextSummary.get_blank ) unresolvedQuestions: list[str] = Field(default_factory=list) limitations: list[str] = Field(default_factory=list) - evidenceIds: list[str] = Field(default_factory=list) - toolCalls: list[DataEnrichmentToolCall] = Field(default_factory=list) - runInfo: AgentRunInfo = Field(default_factory=AgentRunInfo) + evidenceIds: SkipJsonSchema[list[str]] = Field(default_factory=list) + toolCalls: SkipJsonSchema[list[DataEnrichmentToolCall]] = Field( + default_factory=list + ) + runInfo: SkipJsonSchema[AgentRunInfo] = Field(default_factory=AgentRunInfo) @model_validator(mode="after") def validate_status(self) -> "DataEnrichmentReport": diff --git a/scarf/agent/data_enrichment/validation.py b/scarf/agent/data_enrichment/validation.py index a7eca3cd..ff791696 100644 --- a/scarf/agent/data_enrichment/validation.py +++ b/scarf/agent/data_enrichment/validation.py @@ -4,6 +4,7 @@ from ...features.gene_reference import species_registry from ...utils.logging import logger +from ..config.agent_exec import describe_agent_error from ..types import AgentRunInfo from .contracts import ( DataEnrichmentContext, @@ -53,9 +54,14 @@ def _ground_study_context_summary( if value.strip() ) ) - if len(combined) > 12: + excerpts = [ + value for value in proposed_values if value.strip() not in exact_supplied + ] + if len(excerpts) > 12: raise ValueError( - f"studyContextSummary.{field_name} may contain at most 12 values" + f"studyContextSummary.{field_name} may contain at most 12 proposed excerpts; " + f"received {len(excerpts)}. Keep objective-relevant verbatim spans; " + "the complete original context and exact caller references remain available." ) supplied = set(exact_supplied) invalid = [ @@ -67,7 +73,11 @@ def _ground_study_context_summary( raise ValueError( f"Study-context references must be verbatim caller text: {invalid}" ) - oversized = [value for value in combined if len(value) > 240] + oversized = [ + value + for value in proposed_values + if value not in supplied and len(value) > 240 + ] if oversized: raise ValueError("Study-context references may not exceed 240 characters") grounded[field_name] = combined @@ -297,7 +307,11 @@ def failed_data_enrichment_report( evidenceIds=sorted({*deps.evidenceIds, *context.evidenceIds}), limitations=[ "No scientific feature policy was selected after model failure.", - str(error).replace("\n", " ").strip()[:500], + describe_agent_error(error), ], - runInfo=AgentRunInfo(agentName="data_enrichment_failed", modelName=model_name), + runInfo=getattr( + error, + "agent_run_info", + AgentRunInfo(agentName="data_enrichment_failed", modelName=model_name), + ), ) diff --git a/scarf/agent/decisions/selection.py b/scarf/agent/decisions/selection.py index 345c38f2..067f386e 100644 --- a/scarf/agent/decisions/selection.py +++ b/scarf/agent/decisions/selection.py @@ -22,25 +22,6 @@ class DecisionValidationError(ValueError): """Raised when a model decision cites unknown or invalid evidence.""" -def _coerce_evidence_id(evidence_id: str, allowed: set[str]) -> str: - """Map a model-emitted id onto an allowed evidence id when unambiguous. - - Live models often echo prompt scaffolding such as ``id=domain:biological`` - instead of the bare id. Accept that when exactly one allowed id is embedded. - """ - if evidence_id in allowed: - return evidence_id - stripped = evidence_id.strip() - if stripped.startswith("id="): - stripped = stripped[3:].strip() - if stripped in allowed: - return stripped - matches = [allowed_id for allowed_id in allowed if allowed_id in evidence_id] - if len(matches) == 1: - return matches[0] - return evidence_id - - def validate_decision( decision: Decision, evidence: Sequence[EvidenceItem], @@ -48,19 +29,6 @@ def validate_decision( allowed = {item.id for item in evidence} if not allowed: raise DecisionValidationError("evidence must contain at least one item") - selected_id = _coerce_evidence_id(decision.selectedId, allowed) - evidence_ids = [ - _coerce_evidence_id(evidence_id, allowed) - for evidence_id in decision.evidenceIds - ] - if selected_id not in evidence_ids and selected_id in allowed: - evidence_ids = [selected_id, *evidence_ids] - if selected_id != decision.selectedId or evidence_ids != list(decision.evidenceIds): - decision = Decision( - selectedId=selected_id, - rationale=decision.rationale, - evidenceIds=evidence_ids, - ) if decision.selectedId not in allowed: raise DecisionValidationError( f"selectedId {decision.selectedId!r} is not in evidence ids {sorted(allowed)}" @@ -130,11 +98,27 @@ def decide( f"evidence ids must be unique; duplicates: {sorted(duplicates)}" ) - execution = run_agent_sync( - model=model, - output_type=Decision, - system_prompt=system_prompt, - user_prompt=_format_user_prompt(question, evidence), - name="decision", - ) + from pydantic_ai import UnexpectedModelBehavior + + try: + execution = run_agent_sync( + model=model, + output_type=Decision, + system_prompt=system_prompt, + user_prompt=_format_user_prompt(question, evidence), + name="decision", + output_validator=lambda value: validate_decision(value, evidence), + ) + except UnexpectedModelBehavior as exc: + cause: BaseException | None = exc.__cause__ + seen_causes: set[int] = set() + while cause is not None and id(cause) not in seen_causes: + seen_causes.add(id(cause)) + if isinstance(cause, DecisionValidationError): + invalid = DecisionValidationError(str(cause)) + if hasattr(exc, "agent_run_info"): + setattr(invalid, "agent_run_info", exc.agent_run_info) + raise invalid from exc + cause = cause.__cause__ + raise return validate_decision(execution.output, evidence) diff --git a/scarf/agent/experimental_context/agent.py b/scarf/agent/experimental_context/agent.py index 9d6b6c74..7d9acb10 100644 --- a/scarf/agent/experimental_context/agent.py +++ b/scarf/agent/experimental_context/agent.py @@ -33,6 +33,10 @@ analyze_experimental_design, contrast_plans_from_characterization, inspect_cell_covariates, + inspect_context_evidence, + compact_context_evidence, + model_evidence_tool, + restore_context_evidence, score_current_representation, ) from .validation import ( @@ -48,8 +52,6 @@ except ImportError as exc: raise ImportError(AGENT_INSTALL_HINT) from exc -_CONTEXT_LIMIT = 1200 - class ExperimentalContextAgent: """A narrow agent for study design and batch-correction planning.""" @@ -73,7 +75,8 @@ def __init__( You are Scarf's Experimental Context Agent. Work only through the provided read-only tools and return the structured decision schema. - Call inspect_cell_covariates exactly once. Then call + Call inspect_cell_covariates exactly once unless its committed evidence + is already supplied on resume. Then call analyze_experimental_design with all explicit domains, all biological coefficients, every unit of inference, and the complete exact batch column set. Nominate up to eight comparisons that explain the study @@ -125,11 +128,17 @@ def __init__( quote the study statement identifying it as a physical capture. An optional reference pool also needs an exact quote identifying the observed reference captures. Sample uniqueness is not capture proof. - Leave unresolved capture provenance explicit. Copy the supported - capture and protected combinations into the final decision. + Leave unresolved capture provenance explicit. Validated tools own + capture identities and protected combinations; do not copy them into + the final decision. Nominate any new combination through a design tool. The tools return bounded cell-QC profiles projected against the exact - shared cell selection. Do not choose a profile and leave cellQc blank. + shared cell selection. Do not choose a profile or return cellQc. + Summaries retain adverse findings, missingness, protected group loss, + replication and correction constraints. Use inspect_context_evidence + to inspect one exact saved policy/capture or design record when its + details are needed. Omitted donor examples and detailed thresholds + remain available; do not interpret their omission as passing evidence. A later audited checkpoint compares the Scarf default with eligible alternatives and selects one exact policy. Never author or alter numeric quality bounds. RNA is the preferred QC driver and @@ -158,7 +167,8 @@ def __init__( Cite only evidenceIds returned by tools. Ask for input when study design cannot be resolved. The study objective is authoritative: use it to identify protected biological variables and the intended unit - of inference, but do not broaden it or claim to test a hypothesis. + of inference. Study-design explanations are in scope; biological + expression hypothesis testing, effect inference and causal claims are not. Never propose Python, shell commands, direct Zarr access, or any datastore mutation. Every rationale and question must be plain prose. Never place serialized JSON, schema @@ -184,14 +194,14 @@ def run( quality_metric_artifacts: Sequence[NamedArtifactSource] = (), hto_identity_artifacts: Sequence[NamedArtifactSource] = (), qc_assay: str | None = None, + checkpoint_read: Any = None, + checkpoint_write: Any = None, + on_attempt: Any = None, + previous_context: ExperimentalContextResult | None = None, ) -> ExperimentalContextResult: """Inspect one datastore and return a validated experimental-context report.""" study_context = (study_context or "").strip() study_objective = (study_objective or "").strip() - if len(study_context) > _CONTEXT_LIMIT: - study_context = study_context[: _CONTEXT_LIMIT - 3] + "..." - if len(study_objective) > _CONTEXT_LIMIT: - study_objective = study_objective[: _CONTEXT_LIMIT - 3] + "..." direction_map = dict(directions or {}) if run is not None: if ( @@ -303,13 +313,63 @@ def run( directions=direction_map, qualityMetricArtifacts=quality_sources, htoIdentityArtifacts=hto_sources, + checkpointRead=checkpoint_read, + checkpointWrite=checkpoint_write, ) + if checkpoint_read is not None: + saved_result = checkpoint_read("result") + if saved_result is not None: + report = ExperimentalContextResult.model_validate( + saved_result["report"] + ) + if report.cellSelection != artifact_reference(cell_selection): + raise ValueError( + "Committed context decision has a different cell selection" + ) + return report + restored = restore_context_evidence(deps) + if not restored and previous_context is not None: + # A completed older stage remains immutable. Its measurements seed + # an explicitly requested evidence revision under new stage inputs. + deps.characterization = previous_context.characterization + deps.comparisons = list(previous_context.characterization.comparisons) + deps.captureProposal = previous_context.characterization.captureProvenance + deps.protectedCombinations = list( + previous_context.decision.protectedCombinations + ) + deps.qcProfiles = { + item.profileId: item for item in previous_context.qcProfiles + } + deps.qcMetricSources = list(previous_context.qcMetricSources) + deps.qcSourceConcordance = list(previous_context.qcSourceConcordance) + deps.batchSafety = { + item.evidenceId: item for item in previous_context.batchSafety + } + deps.contrastPlans = { + item.coefficient: item for item in previous_context.contrastPlans + } + deps.evidenceIds.update(previous_context.decision.evidenceIds) + deps.evidenceIds.update( + item.evidenceId for item in previous_context.batchSafety + ) + deps.toolCalls = ["inspect_cell_covariates", "analyze_experimental_design"] + deps.designRounds = min( + 2, + max( + 1, + sum( + item.toolName == "analyze_experimental_design" + for item in previous_context.runInfo.toolCalls + ), + ), + ) + restored = True user_prompt = ( dedent( """ Characterize this experiment's metadata and decide whether Harmony should be evaluated. Return cell-QC candidates as tool evidence; - leave cellQc blank for the later audited filtering checkpoint. + do not return cellQc; the later filtering checkpoint owns it. Study context: {study_context} Study objective: {study_objective} @@ -335,6 +395,40 @@ def run( directions=json.dumps(direction_map, sort_keys=True, default=str), ) ) + if restored: + from .contracts import CovariateEvidence + from .requirements import requested_design_questions + + assert deps.characterization is not None + + user_prompt += ( + "\nCommitted evidence already measured; do not repeat completed tools:\n" + + json.dumps( + compact_context_evidence( + CovariateEvidence( + characterization=deps.characterization, + batchSafety=list(deps.batchSafety.values()), + qcProfiles=list(deps.qcProfiles.values()), + qcMetricSources=deps.qcMetricSources, + qcSourceConcordance=deps.qcSourceConcordance, + contrastPlans=list(deps.contrastPlans.values()), + evidenceIds=sorted(deps.evidenceIds), + ) + ), + sort_keys=True, + ) + + f"\nCompleted design rounds: {deps.designRounds} of 2." + ) + user_prompt += ( + "\nExplicit requested questions requiring matched evidence: " + + json.dumps( + requested_design_questions( + study_context, + study_objective, + [row["name"] for row in deps.characterization.columns], + ) + ) + ) try: execution = run_agent_sync( model=self.model, @@ -343,18 +437,23 @@ def run( user_prompt=user_prompt, tools=( Tool( - inspect_cell_covariates, + model_evidence_tool(inspect_cell_covariates), prepare=_prepare_experimental_context_tool, sequential=self.config.sequentialTools, timeout=self.config.timeoutSeconds, ), Tool( - analyze_experimental_design, + model_evidence_tool(analyze_experimental_design), max_retries=3, prepare=_prepare_experimental_context_tool, sequential=self.config.sequentialTools, timeout=self.config.timeoutSeconds, ), + Tool( + inspect_context_evidence, + sequential=self.config.sequentialTools, + timeout=self.config.timeoutSeconds, + ), Tool( score_current_representation, prepare=_prepare_experimental_context_tool, @@ -366,6 +465,7 @@ def run( deps=deps, config=self.config, name="experimental_context", + on_attempt=on_attempt, output_validator=lambda decision: validate_experimental_context( decision, deps, @@ -406,7 +506,7 @@ def run( contrast_plans = list(deps.contrastPlans.values()) if not contrast_plans: contrast_plans = contrast_plans_from_characterization(characterization) - return ExperimentalContextResult( + report = ExperimentalContextResult( status=status, decision=decision, characterization=characterization, @@ -436,3 +536,6 @@ def run( ], runInfo=run_info, ) + if checkpoint_write is not None: + checkpoint_write("result", {"report": report.model_dump(mode="json")}) + return report diff --git a/scarf/agent/experimental_context/characterization.py b/scarf/agent/experimental_context/characterization.py index f1b52304..3b3b0014 100644 --- a/scarf/agent/experimental_context/characterization.py +++ b/scarf/agent/experimental_context/characterization.py @@ -1,6 +1,7 @@ """Characterize cell covariates and study-design confounding.""" import re +import hashlib from collections.abc import Iterator, Mapping, Sequence from dataclasses import dataclass, field from typing import Any, Literal, cast @@ -23,6 +24,7 @@ from ...metadata.selection import resolve_cell_aligned_artifact from ...metrics.association import directional_mapping, report_confounding from ...storage.refs import ArtifactRef +from ...storage.artifacts import fingerprint_array from ...storage.selections import read_stored_selection_indices from ..decisions.selection import DecisionValidationError, decide from ..tools import artifact_reference @@ -59,7 +61,6 @@ _ONTOLOGY_SUFFIX = "_ontology_term_id" _SAMPLE_LEVELS = 8 _ASSOCIATION_FLOOR = 0.1 -_CONTEXT_LIMIT = 1200 _DROP_REASONS = { "dropAssayStat": "Scarf assay statistic column", "dropProvenance": "analysis-linked column", @@ -156,7 +157,14 @@ def _bounded_level_counts( class _SelectionBoundCells: """Read metadata through one validated immutable cell selection.""" - __slots__ = ("_artifact_sources", "_artifact_values", "_indices", "_source") + __slots__ = ( + "_artifact_sources", + "_artifact_values", + "_indices", + "_source", + "_metadata_values", + "_cache_values", + ) def __init__( self, @@ -165,8 +173,11 @@ def __init__( selection: ArtifactRef, *, artifacts: Mapping[str, ArtifactRef] | None = None, + cache_values: bool = False, ) -> None: self._source = source + self._cache_values = cache_values + self._metadata_values: dict[str, np.ndarray] = {} self._indices = read_stored_selection_indices( root, selection, @@ -208,10 +219,16 @@ def artifact_source(self, column: str) -> ArtifactRef | None: def fetch(self, column: str, key: str = "I") -> np.ndarray: if key != "I": raise ValueError("A bound metadata view accepts only its stored selection") - return self._read(column, self._indices) + if not self._cache_values: + return self._read(column, self._indices) + if column not in self._metadata_values: + self._metadata_values[column] = self._read(column, self._indices) + return self._metadata_values[column] def _read(self, column: str, indices: np.ndarray) -> np.ndarray: - artifact_values = self._artifact_values.get(column) + artifact_values = self._metadata_values.get(column) + if artifact_values is None: + artifact_values = self._artifact_values.get(column) if artifact_values is not None: positions = np.searchsorted(self._indices, indices) if np.any(positions >= len(self._indices)) or not np.array_equal( @@ -283,6 +300,7 @@ def __init__( store.cells, selection, artifacts=artifacts, + cache_values=True, ) self.assay_names = store.assay_names @@ -406,17 +424,34 @@ def _profile_column( *, cell_key: str, kind: ColumnKind | None = None, + inventory: dict[str, Any] | None = None, ) -> _ColumnProfile: values = store.cells.fetch(name, key=cell_key) resolved_kind = kind or _infer_kind(values) + artifact_source = getattr(store.cells, "artifact_source", lambda _name: None)(name) + identity = None + if inventory is not None: + # Metadata are mutable even when the selected cell identities are frozen. + value_identity = ( + hashlib.sha256( + "\n".join( + repr((type(value).__name__, value)) for value in values.tolist() + ).encode() + ).hexdigest() + if values.dtype.hasobject + else fingerprint_array(values) + ) + identity = (str(values.dtype), value_identity, resolved_kind, artifact_source) + saved = inventory.get(name) + if saved is not None and saved[0] == identity: + return cast(_ColumnProfile, saved[1]) summary = _summarize(values, resolved_kind) digest = column_partition_digest(store.cells, name, cell_key=cell_key) level_counts: tuple[dict[str, Any], ...] = () level_counts_truncated = False if resolved_kind == "categorical": level_counts, level_counts_truncated = _bounded_level_counts(values) - artifact_source = getattr(store.cells, "artifact_source", lambda _name: None)(name) - return _ColumnProfile( + profile = _ColumnProfile( kind=resolved_kind, summary=summary, digest=digest, @@ -424,6 +459,9 @@ def _profile_column( levelCounts=level_counts, levelCountsTruncated=level_counts_truncated, ) + if inventory is not None: + inventory[name] = (identity, profile) + return profile def _triage_columns( @@ -497,11 +535,6 @@ def _collapse_ontology_aliases( return [name for name in columns if name not in dropped], aliases, notes -def _bounded_context(study_context: str | None) -> str: - text = (study_context or "").strip() - return text if len(text) <= _CONTEXT_LIMIT else text[: _CONTEXT_LIMIT - 3] + "..." - - def _validate_directions( directions: Mapping[str, Any], available: set[str], @@ -1577,6 +1610,7 @@ def characterize_covariates( model: Any | None = None, directions: Mapping[str, Any] | None = None, groupingArtifacts: Mapping[str, ArtifactRef] | None = None, + inventory: dict[str, Any] | None = None, ) -> CovariateCharacterization: """Label cell covariates and record design-level confounding.""" if ( @@ -1590,6 +1624,12 @@ def characterize_covariates( cellSelection, artifacts=grouping_artifacts, ) + if inventory is not None and inventory.get("cellSelection") != cellSelection: + inventory.clear() + inventory["cellSelection"] = cellSelection + column_inventory = ( + inventory.setdefault("columns", {}) if inventory is not None else None + ) cell_key = "I" direction_map = dict(directions or {}) available = set(bound_store.cells.columns) @@ -1621,6 +1661,7 @@ def characterize_covariates( name, cell_key=cell_key, kind=cast(ColumnKind, directed_kind) if directed_kind in _KINDS else None, + inventory=column_inventory, ) profiles[name] = profile n_rows = profile.digest.nRows @@ -1632,7 +1673,11 @@ def characterize_covariates( for name, reason in dropped: if reason == "dropAssayStat" and name not in profiles: profiles[name] = _profile_column( - bound_store, name, cell_key=cell_key, kind="continuous" + bound_store, + name, + cell_key=cell_key, + kind="continuous", + inventory=column_inventory, ) candidates, aliases, alias_notes = _collapse_ontology_aliases( bound_store, @@ -1645,7 +1690,7 @@ def characterize_covariates( store=bound_store, cell_key=cell_key, n_rows=n_rows, - context=_bounded_context(studyContext), + context=(studyContext or "").strip(), model=model, profiles=profiles, ) diff --git a/scarf/agent/experimental_context/contracts.py b/scarf/agent/experimental_context/contracts.py index 6fac21e4..2937aa88 100644 --- a/scarf/agent/experimental_context/contracts.py +++ b/scarf/agent/experimental_context/contracts.py @@ -18,6 +18,7 @@ try: from pydantic import ConfigDict, Field, model_validator + from pydantic.json_schema import SkipJsonSchema except ImportError as exc: raise ImportError(AGENT_INSTALL_HINT) from exc @@ -648,14 +649,14 @@ class ExperimentalContextDecision(AgentDataModel): columnDomains: dict[str, ColumnDomain] = Field(default_factory=dict) coefficientsOfInterest: list[str] = Field(default_factory=list) unitsOfInference: dict[str, InferenceUnit] = Field(default_factory=dict) - protectedCombinations: list[list[str]] = Field(default_factory=list) - physicalCaptureColumn: str | None = None - pooledReferenceCaptures: list[str] = Field(default_factory=list) - unsupportedProtection: list[str] = Field(default_factory=list) + protectedCombinations: SkipJsonSchema[list[list[str]]] = Field(default_factory=list) + physicalCaptureColumn: SkipJsonSchema[str | None] = None + pooledReferenceCaptures: SkipJsonSchema[list[str]] = Field(default_factory=list) + unsupportedProtection: SkipJsonSchema[list[str]] = Field(default_factory=list) batchCorrection: BatchCorrectionPlan = Field( default_factory=BatchCorrectionPlan.get_blank ) - cellQc: CellQcPlan = Field(default_factory=CellQcPlan.get_blank) + cellQc: SkipJsonSchema[CellQcPlan] = Field(default_factory=CellQcPlan.get_blank) rationale: str = "" evidenceIds: list[str] = Field(default_factory=list) needsInput: list[str] = Field(default_factory=list) @@ -854,6 +855,11 @@ class ExperimentalContextDependencies(AgentDataModel): studyContext: str = "" studyObjective: str = "" directions: dict[str, Any] = Field(default_factory=dict) + characterizationInputs: dict[str, Any] = Field(default_factory=dict, exclude=True) + inventoryData: dict[str, Any] = Field(default_factory=dict, exclude=True) + qcDesignData: Any = Field(default=None, exclude=True) + checkpointRead: Any = Field(default=None, exclude=True) + checkpointWrite: Any = Field(default=None, exclude=True) evidenceIds: set[str] = Field(default_factory=set) characterization: CovariateCharacterization | None = None designRounds: int = 0 diff --git a/scarf/agent/experimental_context/qc_evidence.py b/scarf/agent/experimental_context/qc_evidence.py index b6e9051f..782475ad 100644 --- a/scarf/agent/experimental_context/qc_evidence.py +++ b/scarf/agent/experimental_context/qc_evidence.py @@ -3,10 +3,12 @@ import json import math import re +from dataclasses import dataclass, field from collections.abc import Mapping, Sequence from typing import Any, Literal, cast import numpy as np +import pandas as pd from ...metadata.selection import resolve_cell_aligned_artifact from ...quality_control.filtering import ( @@ -760,7 +762,7 @@ def _directed_pooled_reference_captures( def _provenance_label(value: Any) -> str | None: if isinstance(value, np.generic): value = value.item() - if value is None: + if value is None or value is pd.NA or value is pd.NaT: return None if isinstance(value, float) and not math.isfinite(value): return None @@ -774,16 +776,46 @@ def _provenance_label(value: Any) -> str | None: return str(value) -def _ordered_labels(values: np.ndarray, mask: np.ndarray) -> list[str]: - output: list[str] = [] - seen: set[str] = set() - for raw in values[mask]: - label = _provenance_label(raw) - if label is None or label in seen: - continue - seen.add(label) - output.append(label) - return output +@dataclass +class _QcDesignData: + """Frozen metadata shared only while projecting one set of QC policies.""" + + cells: Any + values: dict[str, np.ndarray] = field(default_factory=dict) + labels: dict[str, np.ndarray] = field(default_factory=dict) + captureSource: np.ndarray | None = None + captures: np.ndarray | None = None + units: dict[tuple[str, str, str | None], list[tuple[Any, ...]]] = field( + default_factory=dict + ) + exclusions: dict[str, tuple[list[dict[str, Any]], bool, bool]] = field( + default_factory=dict + ) + combinations: dict[tuple[str, ...], np.ndarray] = field(default_factory=dict) + + @property + def columns(self) -> list[str]: + return list(self.cells.columns) + + def fetch(self, column: str) -> np.ndarray: + if column not in self.values: + self.values[column] = np.asarray(self.cells.fetch(column)).copy() + return self.values[column] + + def encoded(self, column: str) -> np.ndarray: + if column not in self.labels: + self.labels[column] = np.asarray( + [_provenance_label(value) for value in self.fetch(column)], dtype=object + ) + return self.labels[column] + + def combined(self, columns: Sequence[str]) -> np.ndarray: + from .comparisons import combination_labels + + key = tuple(columns) + if key not in self.combinations: + self.combinations[key] = combination_labels(self, list(columns)) + return self.combinations[key] def _capture_design_safety( @@ -794,19 +826,39 @@ def _capture_design_safety( ) -> tuple[list[dict[str, Any]], bool, bool]: if characterization is None: return [], False, False - active = np.ones(len(capture_labels), dtype=bool) - normalized = _validated_sample_labels( - capture_labels, - active, - label_name="physical capture labels", - ) - encoded = np.asarray( - [ - value.decode("utf-8") if isinstance(value, bytes) else str(value) - for value in normalized - ], - dtype=object, - ) + data = deps.qcDesignData + if not isinstance(data, _QcDesignData): + data = _QcDesignData(deps.cells) + if data.captureSource is not capture_labels: + data = _QcDesignData(deps.cells) if data.captureSource is not None else data + active = np.ones(len(capture_labels), dtype=bool) + normalized = _validated_sample_labels( + capture_labels, active, label_name="physical capture labels" + ) + data.captures = np.asarray( + [ + value.decode("utf-8") if isinstance(value, bytes) else str(value) + for value in normalized + ], + dtype=object, + ) + data.captureSource = capture_labels + if capture not in data.exclusions: + data.exclusions[capture] = _compute_capture_design_safety( + data, characterization, deps.protectedCombinations, capture + ) + return data.exclusions[capture] + + +def _compute_capture_design_safety( + data: _QcDesignData, + characterization: CovariateCharacterization, + protected_combinations: Sequence[Sequence[str]], + capture: str, +) -> tuple[list[dict[str, Any]], bool, bool]: + assert data.captures is not None + encoded = data.captures + kinds = {record["name"]: record.get("kind") for record in characterization.columns} after = encoded != capture safety: list[dict[str, Any]] = [] for record in characterization.coefficients: @@ -817,50 +869,130 @@ def _capture_design_safety( not isinstance(coefficient, str) or not isinstance(observation, str) or record.get("scope") != "betweenUnit" - or coefficient not in deps.cells.columns - or observation not in deps.cells.columns ): continue - condition_values = np.asarray(deps.cells.fetch(coefficient), dtype=object) - observation_values = np.asarray(deps.cells.fetch(observation), dtype=object) + if any( + name not in data.columns + for name in (coefficient, observation, independent) + if isinstance(name, str) + ): + safety.append( + { + "coefficient": coefficient, + "reason": "missingDesignColumn", + "preservesConditionCoverage": False, + "preservesIndependentUnitCoverage": False, + } + ) + continue + condition_values = data.encoded(coefficient) + observation_values = data.encoded(observation) if ( condition_values.shape != after.shape or observation_values.shape != after.shape ): raise ValueError("Capture safety columns do not align with cellSelection") - required_groups = _ordered_labels(condition_values, active) - remaining_groups = _ordered_labels(condition_values, after) + if kinds.get(coefficient) not in {"categorical", "continuous"}: + safety.append( + { + "coefficient": coefficient, + "reason": "unknownCovariateKind", + "preservesConditionCoverage": False, + "preservesIndependentUnitCoverage": False, + } + ) + continue + if kinds.get(coefficient) == "continuous": + values = np.asarray( + pd.to_numeric(data.fetch(coefficient), errors="coerce"), dtype=float + ) + observed = np.isfinite(values) + matched_after = observed & after + safety.append( + { + "coefficient": coefficient, + "conditionColumn": coefficient, + "kind": "continuous", + "observationUnit": observation, + "independentUnit": independent, + "matchedRowsBeforeExclusion": int(observed.sum()), + "matchedRowsAfterExclusion": int(matched_after.sum()), + "missingRowsAfterExclusion": int((after & ~observed).sum()), + "quantilesBeforeExclusion": np.quantile( + values[observed], [0, 0.25, 0.5, 0.75, 1] + ).tolist() + if observed.any() + else [], + "quantilesAfterExclusion": np.quantile( + values[matched_after], [0, 0.25, 0.5, 0.75, 1] + ).tolist() + if matched_after.any() + else [], + "independentUnitsAfterExclusion": len( + { + value + for value in data.encoded(independent or observation)[ + matched_after + ] + if value is not None + } + ), + "reason": "Continuous distributions and unit support are descriptive; preservation under capture exclusion has not been established.", + "preservesConditionCoverage": False, + "preservesIndependentUnitCoverage": False, + } + ) + continue + required_groups = [ + value for value in dict.fromkeys(condition_values) if value is not None + ] + remaining_groups = [ + value + for value in dict.fromkeys(condition_values[after]) + if value is not None + ] preserves_conditions = set(remaining_groups) == set(required_groups) observation_counts: list[dict[str, Any]] = [] independent_counts: list[dict[str, Any]] = [] independent_values: np.ndarray | None = None if isinstance(independent, str): - if independent not in deps.cells.columns: - continue - independent_values = np.asarray( - deps.cells.fetch(independent), - dtype=object, - ) + independent_values = data.encoded(independent) if independent_values.shape != after.shape: raise ValueError( "Capture independent-unit column does not align with cellSelection" ) - for group in required_groups: - group_mask = np.asarray( - [_provenance_label(value) == group for value in condition_values], - dtype=bool, - ) - observation_levels = set( - _ordered_labels(observation_values, after & group_mask) + unit_key = (coefficient, observation, independent) + if unit_key not in data.units: + data.units[unit_key] = list( + dict.fromkeys( + zip( + encoded, + condition_values, + observation_values, + independent_values + if independent_values is not None + else np.full(len(after), None), + strict=True, + ) + ) ) + remaining_units = [row for row in data.units[unit_key] if row[0] != capture] + for group in required_groups: + observation_levels = { + row[2] + for row in remaining_units + if row[1] == group and row[2] is not None + } observation_counts.append( {"group": group, "count": len(observation_levels)} ) if independent_values is not None: - independent_levels = set( - _ordered_labels(independent_values, after & group_mask) - ) + independent_levels = { + row[3] + for row in remaining_units + if row[1] == group and row[3] is not None + } independent_counts.append( {"group": group, "count": len(independent_levels)} ) @@ -878,10 +1010,7 @@ def _capture_design_safety( single_group_pairs = 0 if independent_values is not None: pair_groups: dict[str, dict[str, set[str]]] = {} - for index in np.flatnonzero(after): - pair = _provenance_label(independent_values[index]) - pair_group = _provenance_label(condition_values[index]) - observation_value = _provenance_label(observation_values[index]) + for _, pair_group, observation_value, pair in remaining_units: if pair is None or pair_group is None or observation_value is None: continue pair_groups.setdefault(pair, {}).setdefault(pair_group, set()).add( @@ -936,13 +1065,11 @@ def _capture_design_safety( "preservesIndependentUnitCoverage": preserves_units, } ) - from .comparisons import combination_labels - - for columns in deps.protectedCombinations: + for columns in protected_combinations: label = json.dumps(columns, separators=(",", ":")) try: - combined = combination_labels(deps.cells, columns) - except ValueError: + combined = data.combined(columns) + except (KeyError, ValueError): safety.append( { "conditionColumns": columns, @@ -962,9 +1089,19 @@ def _capture_design_safety( units.discard(None) independent_safe = coverage and bool(units) for unit in units: - values = np.asarray(deps.cells.fetch(unit)) + if not isinstance(unit, str) or unit not in data.columns: + independent_safe = False + continue + values = data.encoded(unit) independent_safe = independent_safe and all( - len(np.unique(values[after & (combined == group)])) >= 2 + len( + { + value + for value in values[after & (combined == group)] + if value is not None + } + ) + >= 2 for group in joint_groups ) safety.append( @@ -1082,7 +1219,13 @@ def _design_retention( keep: np.ndarray, ) -> dict[str, Any]: """Check exact categorical conditions, units, and protected joint groups.""" - cells = deps.cells if deps.cells is not None else deps.store.cells + cells = ( + deps.qcDesignData + if isinstance(deps.qcDesignData, _QcDesignData) + else deps.cells + if deps.cells is not None + else deps.store.cells + ) retention_columns: list[str] = [] if characterization is not None: kinds = { @@ -1101,13 +1244,23 @@ def _design_retention( unsafe_groups: list[str] = [] retained = np.asarray(keep, dtype=bool) & np.asarray(active, dtype=bool) for column in dict.fromkeys(retention_columns): - labels = np.asarray(cells.fetch(column)) + labels = ( + cells.encoded(column) + if isinstance(cells, _QcDesignData) + else np.asarray( + [_provenance_label(value) for value in cells.fetch(column)], + dtype=object, + ) + ) if labels.shape != retained.shape: raise ValueError( f"QC retention column {column!r} does not align with cellSelection" ) counts: dict[str, int] = {} - for raw_label in np.unique(labels[np.asarray(active, dtype=bool)]): + present = labels != None # noqa: E711 + if (np.asarray(active, dtype=bool) & ~present).any(): + unsafe_groups.append(f"{column}:missingValues") + for raw_label in np.unique(labels[np.asarray(active, dtype=bool) & present]): label = raw_label.item() if isinstance(raw_label, np.generic) else raw_label key = label.decode("utf-8") if isinstance(label, bytes) else str(label) count = int((retained & (labels == raw_label)).sum()) @@ -1121,8 +1274,12 @@ def _design_retention( for columns in deps.protectedCombinations: key = json.dumps(columns, separators=(",", ":")) try: - labels = combination_labels(cells, columns) - except ValueError: + labels = ( + cells.combined(columns) + if isinstance(cells, _QcDesignData) + else combination_labels(cells, columns) + ) + except (KeyError, ValueError): unsafe_groups.append(f"combination:{key}:missingValues") continue counts = { @@ -1557,6 +1714,21 @@ def _sample_qc_profiles( def _offered_qc_profiles( deps: ExperimentalContextDependencies, characterization: CovariateCharacterization | None = None, +) -> list[CellQcProfileEvidence]: + """Share frozen design summaries across policies, never across changed inputs.""" + previous = deps.qcDesignData + deps.qcDesignData = _QcDesignData( + deps.cells if deps.cells is not None else deps.store.cells + ) + try: + return _project_qc_profiles(deps, characterization) + finally: + deps.qcDesignData = previous + + +def _project_qc_profiles( + deps: ExperimentalContextDependencies, + characterization: CovariateCharacterization | None, ) -> list[CellQcProfileEvidence]: """Project bounded QC profiles against the exact shared cell selection.""" active_cells = _active_cell_count(deps) diff --git a/scarf/agent/experimental_context/requirements.py b/scarf/agent/experimental_context/requirements.py index 71b33d9c..028bb418 100644 --- a/scarf/agent/experimental_context/requirements.py +++ b/scarf/agent/experimental_context/requirements.py @@ -2,7 +2,7 @@ import hashlib import re -from typing import Any +from typing import Any, Literal from ..record_io import canonical_json_bytes from .contracts import ( @@ -12,6 +12,56 @@ ) +def active_batch_safety(result: Any) -> list[Any]: + """Select the exact final assessed design without unioning alternatives.""" + all_assessments = list(result.batchSafety) + columns = sorted(result.decision.batchCorrection.batchColumns) + if not columns: + tested = {tuple(sorted(item.batchColumns)) for item in all_assessments} + if len(tested) > 1: + raise ValueError( + "The final correction plan must identify one exact assessed batch set; " + "separate alternatives cannot be combined into an untested design" + ) + columns = list(next(iter(tested), ())) + return [item for item in all_assessments if sorted(item.batchColumns) == columns] + + +def requested_design_questions( + study_context: str, study_objective: str, columns: list[str] +) -> list[tuple[str, list[str], bool]]: + """Keep explicit named joint/conditional requests visible before proposals.""" + output: list[tuple[str, list[str], bool]] = [] + for text in (study_context, study_objective): + for raw in re.split(r"[\n.!?]+", text): + quote = raw.strip() + joint = bool( + re.search( + r"\b(joint|jointly|combined|combination|interaction)\b", quote, re.I + ) + ) + conditional = bool( + re.search(r"\b(within|conditioned|stratif\w*)\b", quote, re.I) + ) + if not (joint or conditional) or re.search( + r"\b(?:do not|not requested|outside scope)\b", quote, re.I + ): + continue + named = sorted( + name + for name in columns + if re.search(r"(?= 2 or not named and generic) and ( + quote, + named, + conditional, + ) not in output: + output.append((quote, named, conditional)) + return output + + def objective_evidence( *, study_context: str, study_objective: str, experimental_result: Any ) -> tuple[list[DesignEvidenceRequirement], list[DesignEvidenceCoverage]]: @@ -28,10 +78,11 @@ def objective_evidence( and re.search(r"(? 13: raise ValueError( "Objective requirements permit one design summary and eight plus four questions" diff --git a/scarf/agent/experimental_context/study.py b/scarf/agent/experimental_context/study.py index 7bd9c014..840f4e84 100644 --- a/scarf/agent/experimental_context/study.py +++ b/scarf/agent/experimental_context/study.py @@ -11,7 +11,11 @@ DesignEvidenceCoverage, DesignEvidenceRequirement, ) -from .requirements import objective_evidence, unmet_objective_requirements +from .requirements import ( + active_batch_safety, + objective_evidence, + unmet_objective_requirements, +) type AuthorLabelPolicy = Literal["holdout", "preservation"] type ProcessingGoal = Literal[ @@ -213,7 +217,7 @@ def build_study_contract( raise ValueError("Experimental Context must be done before contract creation") decision = experimental_result.decision batch_plan = decision.batchCorrection - batch_safety = list(experimental_result.batchSafety) + batch_safety = active_batch_safety(experimental_result) conditions = list(decision.coefficientsOfInterest) independent_units = _unique( unit.independentUnit for unit in decision.unitsOfInference.values() diff --git a/scarf/agent/experimental_context/tools.py b/scarf/agent/experimental_context/tools.py index b5ff6683..738f0933 100644 --- a/scarf/agent/experimental_context/tools.py +++ b/scarf/agent/experimental_context/tools.py @@ -1,17 +1,23 @@ """Read-only Pydantic AI tools for experimental context.""" +import hashlib import math +from copy import deepcopy +from functools import wraps from collections.abc import Sequence from types import SimpleNamespace -from typing import Any +from typing import Any, Literal +import numpy as np from ...metadata.queries import reduce_observation_units from ...metrics.association import coefficient_estimability from ...storage.refs import ArtifactRef +from ...storage.artifacts import fingerprint_array from ...utils.logging import logger from .._deps import AGENT_INSTALL_HINT from ..tools import artifact_reference, core_artifact_reference from ..types import BatchSafetyEvidence, BatchSafetyStatus +from ..record_io import canonical_json_bytes from .characterization import characterize_covariates from .comparisons import ( DESIGN_ROUND_LIMITS, @@ -40,7 +46,7 @@ _hto_identity_columns, _offered_qc_profiles, ) -from .requirements import objective_evidence +from .requirements import objective_evidence, requested_design_questions try: from pydantic_ai import ModelRetry, RunContext @@ -49,6 +55,271 @@ raise ImportError(AGENT_INSTALL_HINT) from exc +def compact_context_evidence(evidence: CovariateEvidence) -> dict[str, Any]: + """Present design findings; retain detailed policy measurements in the journal.""" + payload = evidence.model_dump(mode="json") + characterization = payload["characterization"] + coefficients = { + record["name"]: record for record in characterization["coefficients"] + } + for index, report in enumerate(characterization["confounding"]): + source = coefficients.get(report.get("coefficient"), {}) + for name in list(report): + if name in source and report[name] == source[name]: + report.pop(name) + report["coefficientDetails"] = f"coefficient:{report.get('coefficient')}" + report["details"] = f"confounding:{index}" + for plan in payload["contrastPlans"]: + source = coefficients.get(plan["coefficient"], {}) + for name in ("replication", "estimability", "pairedCoverage"): + if plan[name] == source.get(name): + plan.pop(name) + plan["coefficientDetails"] = f"coefficient:{plan['coefficient']}" + # These tables duplicate the named coefficient records exactly. + for name in ( + "unitLevelCounts", + "groupImbalance", + "missingness", + "designStructures", + "pairedCoverage", + "coefficientEstimability", + ): + characterization.pop(name, None) + for column in characterization["columns"]: + counts = column.get("levelCounts", []) + if len(counts) > 8: + column["levelCounts"] = counts[:8] + column["levelCountsOmitted"] = len(counts) - 8 + column["details"] = f"column:{column['name']}" + + def omit_examples(value: Any) -> None: + if isinstance(value, dict): + examples = value.pop("incompleteExamples", None) + if examples is not None: + value["incompleteExamplesInSavedDetails"] = len(examples) + for child in value.values(): + omit_examples(child) + elif isinstance(value, list): + for child in value: + omit_examples(child) + + omit_examples(characterization) + omit_examples(payload["contrastPlans"]) + for record in characterization["coefficients"]: + record.pop("unitLevelCounts", None) + record["details"] = f"coefficient:{record['name']}" + shared_safety: dict[str, Any] = {} + for profile in payload["qcProfiles"]: + # The later QC decision receives complete thresholds and retention tables. + # Context needs capture provenance, adverse evidence and design constraints. + profile.pop("metricSources", None) + profile.pop("sourceConcordance", None) + profile.pop("resolvedBounds", None) + profile["parameters"] = { + key: value + for key, value in profile["parameters"].items() + if key not in {"resolvedBounds", "captureComparisons", "captureSizes"} + } + profile["details"] = f"qcProfile:{profile['profileId']}" + for failure in profile.get("captureFailureEvidence", []): + safety = failure.pop("conditionAndUnitSafety", []) + for check in safety: + if "requiredGroups" in check and "remainingGroups" in check: + check["lostGroups"] = [ + group + for group in check["requiredGroups"] + if group not in check["remainingGroups"] + ] + for name in ( + "requiredGroups", + "remainingGroups", + "observationUnitsByGroup", + "independentUnitsByGroup", + "quantilesBeforeExclusion", + "quantilesAfterExclusion", + ): + check.pop(name, None) + identity = hashlib.sha256(canonical_json_bytes(safety)).hexdigest() + shared_safety[identity] = safety + failure["designSafetyRef"] = identity + missingness = failure.pop("metricMissingFractions", {}) + failure["metricMissingness"] = { + "measuredSources": len(missingness), + "nonzeroOrUnavailable": { + key: value for key, value in missingness.items() if value != 0 + }, + } + payload["captureDesignSafety"] = shared_safety + payload["savedDetails"] = ( + "inspect_context_evidence returns one exact saved column, coefficient, " + "comparison, confounding record, or QC policy/capture. Policy thresholds, " + "individual donor examples and complete distributions remain saved; " + "summaries are not a substitute for required evidence." + ) + return payload + + +async def inspect_context_evidence( + ctx: RunContext[ExperimentalContextDependencies], + section: Literal["column", "coefficient", "comparison", "confounding", "qcProfile"], + record_id: str, + capture: str | None = None, +) -> dict[str, Any]: + """Read one saved evidence record without model calls or recomputation. + + Use a column/coefficient name, comparison evidenceId, zero-based confounding + index, or profileId. For capture-level QC detail supply one exact capture. + """ + characterization = ctx.deps.characterization + if characterization is None: + raise ModelRetry("Inspect covariates before requesting saved evidence") + if section == "qcProfile": + profile = ctx.deps.qcProfiles.get(record_id) + if profile is None: + raise ModelRetry("Choose an offered profileId") + if capture is not None: + failure = next( + ( + row + for row in profile.captureFailureEvidence + if row.capture == capture + ), + None, + ) + if failure is None: + raise ModelRetry("Choose one capture recorded in this QC profile") + bounds = profile.resolvedBounds + return { + "profileId": record_id, + "capture": failure.model_dump(mode="json"), + "resolvedBounds": [row for row in bounds if row.get("group") == capture] + if isinstance(bounds, list) + else deepcopy(bounds), + } + result = profile.model_dump(mode="json") + result.pop("captureFailureEvidence", None) + result.pop("metricSources", None) + result.pop("sourceConcordance", None) + result["parameters"].pop("captureComparisons", None) + result["parameters"].pop("resolvedBounds", None) + if profile.sampleColumn is not None or profile.sampleArtifact is not None: + result.pop("resolvedBounds", None) + result["captureDetailsRequired"] = ( + "Supply one capture to retrieve its exact thresholds and exclusion safety" + ) + return result + if capture is not None: + raise ModelRetry("A capture can be requested only for a QC profile") + if section == "comparison": + for comparison in characterization.comparisons: + if comparison.evidenceId == record_id: + return comparison.model_dump(mode="json") + elif section == "confounding": + if record_id.isdecimal() and int(record_id) < len(characterization.confounding): + return deepcopy(characterization.confounding[int(record_id)]) + else: + records = ( + characterization.columns + if section == "column" + else characterization.coefficients + ) + for record in records: + if record["name"] == record_id: + return deepcopy(record) + raise ModelRetry("Choose an exact record from the saved context summary") + + +def model_evidence_tool(function: Any) -> Any: + """Keep complete tool results in state and send a deduplicated model view.""" + + @wraps(function) + async def invoke(*args: Any, **kwargs: Any) -> dict[str, Any]: + result = await function(*args, **kwargs) + payload = compact_context_evidence(result) + context = args[0] if args else kwargs["ctx"] + payload["requestedComparisons"] = [ + {"question": quote, "columns": columns, "conditional": conditional} + for quote, columns, conditional in requested_design_questions( + context.deps.studyContext, + context.deps.studyObjective, + [row["name"] for row in result.characterization.columns], + ) + ] + return payload + + return invoke + + +def persist_context_evidence(deps: ExperimentalContextDependencies, key: str) -> None: + """Commit completed context measurements to the owning stage journal.""" + if deps.checkpointWrite is not None: + deps.checkpointWrite( + key, + { + "state": deps.model_dump(mode="json"), + "characterizationInputs": deps.characterizationInputs, + }, + ) + + +def restore_context_evidence(deps: ExperimentalContextDependencies) -> bool: + """Restore complete evidence rounds without resetting proposal allowances.""" + if deps.checkpointRead is None: + return False + restored = False + for key in ("inspection", "design1", "design2"): + saved = deps.checkpointRead(key) + if saved is None: + continue + state = ExperimentalContextDependencies.model_validate(saved["state"]) + for name, field in ExperimentalContextDependencies.model_fields.items(): + if not field.exclude: + setattr(deps, name, getattr(state, name)) + deps.characterizationInputs = saved["characterizationInputs"] + restored = True + return restored + + +def characterize_context( + deps: ExperimentalContextDependencies, directions: dict[str, Any] +) -> CovariateCharacterization: + """Reuse characterization for the exact frozen stage and declared design.""" + metadata = {} + for column in deps.cells.columns: + values = np.asarray(deps.cells.fetch(column)) + metadata[column] = ( + hashlib.sha256( + repr( + [(type(value).__name__, value) for value in values.tolist()] + ).encode() + ).hexdigest() + if values.dtype.hasobject + else fingerprint_array(values) + ) + inputs = { + "directions": deepcopy(directions), + "cellSelection": deps.cellSelection.to_dict(), + "studyContext": deps.studyContext, + "studyObjective": deps.studyObjective, + "metadata": metadata, + } + if deps.characterization is not None and deps.characterizationInputs == inputs: + return deps.characterization + result = characterize_covariates( + deps.store, + cellSelection=deps.cellSelection, + studyContext=f"{deps.studyContext}\nStudy objective: {deps.studyObjective}", + model=None, + directions=directions, + groupingArtifacts=_hto_artifact_map(deps), + inventory=deps.inventoryData, + ) + if result.status != "failed": + deps.characterization = result + deps.characterizationInputs = inputs + return result + + def _prepare_experimental_context_tool( ctx: RunContext[ExperimentalContextDependencies], tool_definition: ToolDefinition, @@ -215,16 +486,7 @@ async def inspect_cell_covariates( f"cellSelection={ctx.deps.cellSelection.artifact_id}" ) ctx.deps.htoIdentityColumns = _hto_identity_columns(ctx.deps) - characterization = characterize_covariates( - ctx.deps.store, - cellSelection=ctx.deps.cellSelection, - studyContext=( - f"{ctx.deps.studyContext}\nStudy objective: {ctx.deps.studyObjective}" - ), - model=None, - directions=ctx.deps.directions, - groupingArtifacts=_hto_artifact_map(ctx.deps), - ) + characterization = characterize_context(ctx.deps, ctx.deps.directions) ctx.deps.characterization = characterization qc_profiles = _offered_qc_profiles(ctx.deps) contrast_plans = contrast_plans_from_characterization(characterization) @@ -247,6 +509,7 @@ async def inspect_cell_covariates( ) ctx.deps.evidenceIds.update(evidence_ids) ctx.deps.toolCalls.append("inspect_cell_covariates") + persist_context_evidence(ctx.deps, "inspection") logger.info( "Experimental Context covariate inspection completed: " f"status={characterization.status}, " @@ -538,16 +801,7 @@ async def analyze_experimental_design( f"Batch column {batch_column!r} must be categorical for Harmony" ) - characterization = characterize_covariates( - ctx.deps.store, - cellSelection=ctx.deps.cellSelection, - studyContext=( - f"{ctx.deps.studyContext}\nStudy objective: {ctx.deps.studyObjective}" - ), - model=None, - directions=directions, - groupingArtifacts=_hto_artifact_map(ctx.deps), - ) + characterization = characterize_context(ctx.deps, directions) if characterization.status == "failed": rejection = "; ".join(characterization.notes).strip() logger.warning( @@ -634,6 +888,7 @@ async def analyze_experimental_design( evidence_ids.update(item.evidenceId for item in batch_safety) ctx.deps.evidenceIds.update(evidence_ids) ctx.deps.toolCalls.append("analyze_experimental_design") + persist_context_evidence(ctx.deps, f"design{ctx.deps.designRounds}") safety_counts = { status: sum(item.status == status for item in batch_safety) for status in ("safe", "unsafe", "notComputed") diff --git a/scarf/agent/experimental_context/validation.py b/scarf/agent/experimental_context/validation.py index 9e7540cf..1661a6e2 100644 --- a/scarf/agent/experimental_context/validation.py +++ b/scarf/agent/experimental_context/validation.py @@ -7,7 +7,6 @@ from .._deps import AGENT_INSTALL_HINT from ..tools import artifact_reference from ..types import AgentRunInfo, BatchSafetyEvidence -from .characterization import characterize_covariates from .comparisons import canonical_design_choices from .contracts import ( CellQcPlan, @@ -19,11 +18,14 @@ characterization_evidence, ) from .qc_evidence import ( - _hto_artifact_map, _offered_qc_profiles, ) -from .tools import contrast_plans_from_characterization -from .requirements import objective_evidence, unmet_objective_requirements +from .tools import characterize_context, contrast_plans_from_characterization +from .requirements import ( + active_batch_safety, + objective_evidence, + unmet_objective_requirements, +) try: from pydantic import ValidationError @@ -48,6 +50,14 @@ def _validate_batch_correction_plan( if isinstance(report.get("coefficient"), str) } plan = decision.batchCorrection + try: + active_batch_safety( + SimpleNamespace( + decision=decision, batchSafety=list(deps.batchSafety.values()) + ) + ) + except ValueError as exc: + raise ModelRetry(str(exc)) from exc directed_batch_columns = deps.directions.get("batchColumns") if directed_batch_columns is not None: if not isinstance(directed_batch_columns, list) or any( @@ -247,6 +257,11 @@ def validate_experimental_context( deps: ExperimentalContextDependencies, ) -> ExperimentalContextDecision: """Recompute and validate every model-authored design choice.""" + if decision.cellQc != CellQcPlan.get_blank(): + raise ModelRetry( + "Experimental Context must leave cellQc blank; the audited filtering " + "checkpoint selects from qcProfiles" + ) narrative_fields = { "rationale": decision.rationale, "batchCorrection.rationale": decision.batchCorrection.rationale, @@ -274,6 +289,13 @@ def validate_experimental_context( "Narrative fields must contain plain prose without serialized sibling " f"fields: {invalid_narratives}" ) + for required in ("inspect_cell_covariates", "analyze_experimental_design"): + if required not in deps.toolCalls: + raise ModelRetry(f"Call {required} before returning a decision") + try: + canonical_design_choices(deps, decision) + except ValueError as exc: + raise ModelRetry(str(exc)) from exc directions = dict(deps.directions) column_domains = dict(decision.columnDomains) column_domains.update(dict(directions.get("columnDomains") or {})) @@ -293,14 +315,8 @@ def validate_experimental_context( units_of_inference.update(dict(directions.get("unitsOfInference") or {})) directions["unitsOfInference"] = units_of_inference - characterization = characterize_covariates( - deps.store, - cellSelection=deps.cellSelection, - studyContext=f"{deps.studyContext}\nStudy objective: {deps.studyObjective}", - model=None, - directions=directions, - groupingArtifacts=_hto_artifact_map(deps), - ) + previous_inputs = deps.characterizationInputs + characterization = characterize_context(deps, directions) if characterization.status == "failed": raise ModelRetry("; ".join(characterization.notes)) characterization.comparisons = list(deps.comparisons) @@ -311,17 +327,7 @@ def validate_experimental_context( deps.contrastPlans = {plan.coefficient: plan for plan in contrast_plans} deps.evidenceIds.update(plan.evidenceId for plan in contrast_plans) - if "inspect_cell_covariates" not in deps.toolCalls: - raise ModelRetry("Call inspect_cell_covariates before returning a decision") - if "analyze_experimental_design" not in deps.toolCalls: - raise ModelRetry("Call analyze_experimental_design before returning a decision") - - if decision.cellQc != CellQcPlan.get_blank(): - raise ModelRetry( - "Experimental Context must leave cellQc blank; the audited filtering " - "checkpoint selects from qcProfiles" - ) - if not deps.qcProfiles: + if not deps.qcProfiles or previous_inputs != deps.characterizationInputs: _offered_qc_profiles(deps, characterization) deps.evidenceIds.update(profile.evidenceId for profile in deps.qcProfiles.values()) deps.evidenceIds.update(source.sourceId for source in deps.qcMetricSources) @@ -472,7 +478,8 @@ def failed_experimental_context_result( f"Model failure: {model_detail}", *failure_notes, ], - runInfo=AgentRunInfo( + runInfo=getattr(error, "agent_run_info", None) + or AgentRunInfo( agentName="experimental_context_failed", modelName=model_name, ), diff --git a/scarf/agent/orchestrator/budget.py b/scarf/agent/orchestrator/budget.py index 9fe9370c..604cfd1a 100644 --- a/scarf/agent/orchestrator/budget.py +++ b/scarf/agent/orchestrator/budget.py @@ -1,7 +1,7 @@ """Write-ahead admissions for bounded RNA experiments in the workflow journal.""" import hashlib -from typing import Any +from typing import Any, cast from .. import record_io from . import journal @@ -33,6 +33,8 @@ def __init__( workflow_run_id: str, config: AutomatedWorkflowConfig, provenance: dict[str, Any], + *, + previous_provenances: tuple[dict[str, Any], ...] = (), ) -> None: self.store = store self.prefix = prefix @@ -48,15 +50,19 @@ def __init__( ) rows: list[dict[str, Any]] = [] for slot in range(limit): - row = journal.load_checkpoint( + record = journal.read_checkpoint( store, prefix, workflow_run_id, self._key(scope, slot, "admission"), - inputs=provenance, ) - if row is None: + if record is None: continue + if record["inputs"] not in (provenance, *previous_provenances): + raise ValueError( + "Candidate admission has changed checkpoint inputs" + ) + row = record["outputs"] if ( row.get("slot") != slot or row.get("scope") != scope @@ -65,6 +71,126 @@ def __init__( raise ValueError("Candidate admission history is inconsistent") rows.append(row) self.admissions[scope] = rows + self.repairs = self._read_repairs(previous_provenances) + + @staticmethod + def _repair_inputs( + cells: dict[str, Any], setting: dict[str, Any], experiment: dict[str, Any] + ) -> dict[str, Any]: + """Identify a scientific intervention independently of review attempts.""" + baseline = dict(setting) + baseline["parameters"] = { + key: value + for key, value in setting["parameters"].items() + if key != "candidateId" + } + return {"cells": cells, "baseline": baseline, "experiment": experiment} + + def _read_repairs( + self, previous_provenances: tuple[dict[str, Any], ...] + ) -> dict[str, dict[str, Any]]: + """Derive reserved repairs from this journal, including earlier decisions.""" + repairs: dict[str, dict[str, Any]] = {} + explicit_admission = False + repair_keys = [ + f"parameter_tuning/full/repairs/{slot}" + for slot in range(self.config.maxFullRepairs + 1) + ] + scopes = {"parameter_tuning/full"} | { + "parameter_tuning/evidence_revisions/" + + hashlib.sha256(record_io.canonical_json_bytes(provenance)).hexdigest() + + "/full" + for provenance in (self.provenance, *previous_provenances) + } + review_keys = [ + f"{scope}/review{index}{suffix}" + for scope in sorted(scopes) + for index in range(self.config.maxFullPartitions + 1) + for suffix in ("", "/answer") + ] + for key in [*repair_keys, *review_keys]: + record = journal.read_checkpoint( + self.store, self.prefix, self.workflow_run_id, key + ) + if record is None: + continue + if key in repair_keys: + inputs = record["inputs"] + identity = hashlib.sha256( + record_io.canonical_json_bytes(inputs) + ).hexdigest() + if record["outputs"] != {"identity": identity, "reserved": True}: + raise ValueError("Full-cohort repair admission is inconsistent") + repairs[identity] = inputs + explicit_admission = True + continue + # At most one repair is supported. Once explicitly admitted, later + # unadmitted proposals must not masquerade as additional work. + if explicit_admission: + continue + evidence, action = record["inputs"], record["outputs"]["action"] + if ( + action.get("action") != "experiment" + or evidence.get("comparisonCoverage", {}).get("phase") != "validation" + ): + continue + experiment = evidence["experiments"][action["experimentId"]] + if experiment["parameter"] == "useHarmony": + continue + candidate_id = action["selectedCandidateId"] + candidate = next( + row + for row in evidence["candidates"] + if row["candidateId"] == candidate_id + ) + inputs = self._repair_inputs( + candidate["cellSelection"], + evidence["settings"][candidate_id], + experiment, + ) + identity = hashlib.sha256( + record_io.canonical_json_bytes(inputs) + ).hexdigest() + repairs[identity] = inputs + if len(repairs) > self.config.maxFullRepairs: + raise CandidateBudgetExceeded( + "Saved full-cohort repairs exceed the configured allowance" + ) + return repairs + + def admit_repair( + self, cells: dict[str, Any], setting: dict[str, Any], experiment: dict[str, Any] + ) -> None: + """Reserve one global full-cohort repair before executing its intervention.""" + inputs = self._repair_inputs(cells, setting, experiment) + identity = hashlib.sha256(record_io.canonical_json_bytes(inputs)).hexdigest() + if identity in self.repairs: + return + if len(self.repairs) >= self.config.maxFullRepairs: + raise CandidateBudgetExceeded( + "The allowed full-cohort repair has been used; scientific acceptance remains unresolved" + ) + journal.save_checkpoint( + self.store, + self.prefix, + self.workflow_run_id, + f"parameter_tuning/full/repairs/{len(self.repairs)}", + inputs, + {"identity": identity, "reserved": True}, + ) + self.repairs[identity] = inputs + + def admission_provenance(self, admission: dict[str, Any]) -> dict[str, Any]: + """Return the verified original inputs without changing an admission.""" + record = journal.read_checkpoint( + self.store, + self.prefix, + self.workflow_run_id, + self._key(admission["scope"], admission["slot"], "admission"), + ) + if record is None or record["outputs"] != admission: + raise ValueError("Candidate admission is missing or changed") + return cast(dict[str, Any], record["inputs"]) @staticmethod def _key(scope: str, slot: int, kind: str) -> str: diff --git a/scarf/agent/orchestrator/context.py b/scarf/agent/orchestrator/context.py index 2e5cdaa0..df958a48 100644 --- a/scarf/agent/orchestrator/context.py +++ b/scarf/agent/orchestrator/context.py @@ -1,6 +1,7 @@ """Ingest, RNA enrichment, quality metrics, and experimental-context stages.""" from collections.abc import Mapping, Sequence +import hashlib from typing import Any, cast from ...datastore.datastore import DataStore @@ -20,9 +21,11 @@ build_study_contract, validate_objective_evidence, ) +from ..experimental_context.requirements import objective_evidence from ..ingest import IngestResult from ..ingest.manifest import DatasetManifest, is_author_label_column from ..types import AgentRunInfo, ArtifactReferenceModel +from ..record_io import canonical_json_bytes from . import journal from .models import ( WorkflowIdentity, @@ -42,6 +45,23 @@ ) +def _context_metadata_identity( + store: DataStore, request_record: OrchestrationRequestRecord +) -> dict[str, str]: + """Bind added metadata without repeating the original resume fingerprint scan.""" + from ..parameter_tuning.execution import _metadata_column_fingerprint + + original = request_record.inputIdentity.get("data", {}).get("metadata", {}) + # Public resume already validates every original column against this identity. + # Enrichment may add columns afterward; those also bind committed context work. + return { + column: original[column] + if column in original + else _metadata_column_fingerprint(store.cells, column) + for column in sorted(store.cells.columns) + } + + class ContextStagesMixin: """Stages that establish study and experimental context.""" @@ -295,6 +315,20 @@ def data_enrichment_stage( assays=selected_assays, cache_dir=request_record.config.cacheDir, allow_download=request_record.config.allowDownloads, + on_attempt=journal.model_attempt_callback( + store, + prefix, + workflow.workflowRunId, + "data_enrichment", + { + "requestSha256": request_record.requestSha256, + "configSha256": request_record.configSha256, + "parents": [ + parent.model_dump(mode="json") for parent in parents + ], + "inputs": started.inputs, + }, + ), ) saved_report, reference = journal._save_stage_report( store, @@ -543,6 +577,8 @@ def experimental_context_stage( *, resume_record: OrchestrationResumeRecord | None = None, ) -> tuple[WorkflowStageAttempt, ExperimentalContextResult]: + context_revision: dict[str, Any] = {} + prior_context: ExperimentalContextResult | None = None selected = selected_store_rna_assay(store, request_record.request) if hto_identity_artifacts: raise ValueError( @@ -554,6 +590,7 @@ def experimental_context_stage( quality_metric_artifacts, hto_identity_artifacts, ) + metadata_identity = _context_metadata_identity(store, request_record) existing = journal._validated_done_outcome( store, prefix, @@ -563,6 +600,27 @@ def experimental_context_stage( parents, ) if existing is not None: + saved_metadata = existing.inputs.get("metadataFingerprints") + if saved_metadata is not None and saved_metadata != metadata_identity: + raise ValueError( + "Experimental Context metadata changed; start a new analysis" + ) + if saved_metadata is None: + # Older compatible stages may already have committed downstream + # metadata identities. Verify them; do not rewrite the old report. + for tuning in journal._stage_starts( + store.zw, prefix, workflow.workflowRunId, "parameter_tuning" + ): + if any( + metadata_identity.get(column) != digest + for column, digest in tuning.inputs.get( + "metadataFingerprints", {} + ).items() + ): + raise ValueError( + "Experimental Context metadata differs from saved tuning evidence; " + "restore the original inputs or start a new analysis" + ) logger.debug( f"Workflow {workflow.workflowRunId}: reusing Experimental Context " "report" @@ -572,10 +630,38 @@ def experimental_context_stage( ) resolved_report = cast(ExperimentalContextResult, report) validate_rna_context(resolved_report, selected) - validate_objective_evidence( - StudyContract.model_validate(existing.outputs.get("studyContract")), - resolved_report, + saved_contract = StudyContract.model_validate( + existing.outputs.get("studyContract") + ) + validate_objective_evidence(saved_contract) + current_requirements, current_coverage = objective_evidence( + study_context=request_record.request.studyContext, + study_objective=request_record.request.studyObjective, + experimental_result=resolved_report, ) + missing_questions = [ + item.model_dump(mode="json") + for item in current_requirements + if item.requirementId.startswith("requestedDesign:") + and any( + row.requirementId == item.requirementId + and row.status == "unsupported" + for row in current_coverage + ) + ] + if missing_questions: + context_revision = { + "reassessContextReport": existing.reportReferences[0].model_dump( + mode="json" + ), + "requiredDesignQuestions": missing_questions, + } + prior_context = resolved_report + logger.info( + "Experimental context: reassessing explicit study questions missing from prior evidence" + ) + else: + validate_objective_evidence(saved_contract, resolved_report) if existing.artifacts != context_artifacts: raise ValueError( "Persisted Experimental Context stage artifacts are stale" @@ -592,7 +678,8 @@ def experimental_context_stage( raise ValueError( "Persisted Experimental Context HTO artifacts are stale" ) - return existing, resolved_report + if not context_revision: + return existing, resolved_report cell_selection_ref = artifact_model_to_ref(cell_selection) paused = journal._validated_done_outcome( store, @@ -692,10 +779,12 @@ def find_held_out_references(value: Any) -> None: parents, inputs={ **retry_inputs, + **context_revision, "studyContext": request_record.request.studyContext, "studyObjective": request_record.request.studyObjective, "cellSelection": cell_selection.model_dump(mode="json"), "directions": directions, + "metadataFingerprints": metadata_identity, "qualityMetricArtifacts": [ source.model_dump(mode="json") for source in quality_metric_artifacts @@ -833,6 +922,43 @@ def find_held_out_references(value: Any) -> None: self.model, config=request_record.config.agentRunConfig, ) + evidence_inputs = { + "request": request_record.model_dump(mode="json"), + "parents": [ + parent.model_dump(mode="json") for parent in parents + ], + "context": { + key: value + for key, value in started.inputs.items() + if key != "retryAfterFailedReport" + }, + } + evidence_key = ( + "experimental_context/evidence/" + + hashlib.sha256( + canonical_json_bytes(evidence_inputs) + ).hexdigest() + ) + + def read_evidence(key: str) -> dict[str, Any] | None: + return journal.load_checkpoint( + store, + prefix, + workflow.workflowRunId, + evidence_key + "/" + key, + evidence_inputs, + ) + + def write_evidence(key: str, output: dict[str, Any]) -> None: + journal.save_checkpoint( + store, + prefix, + workflow.workflowRunId, + evidence_key + "/" + key, + evidence_inputs, + output, + ) + report = agent.run( store, qc_assay=selected, @@ -842,6 +968,16 @@ def find_held_out_references(value: Any) -> None: directions=directions, quality_metric_artifacts=quality_metric_artifacts, hto_identity_artifacts=hto_identity_artifacts, + checkpoint_read=read_evidence, + checkpoint_write=write_evidence, + on_attempt=journal.model_attempt_callback( + store, + prefix, + workflow.workflowRunId, + evidence_key, + evidence_inputs, + ), + previous_context=prior_context, ) saved_report, reference = journal._save_stage_report( store, diff --git a/scarf/agent/orchestrator/decisions.py b/scarf/agent/orchestrator/decisions.py index ead54051..1fded9cd 100644 --- a/scarf/agent/orchestrator/decisions.py +++ b/scarf/agent/orchestrator/decisions.py @@ -164,6 +164,7 @@ def _resolve_rna_decision( rule_selection: DecisionSelection | None = None, agent_selection: DecisionSelection | None = None, agent_model_name: str | None = None, + qc_evidence: Mapping[str, Any] | None = None, ) -> DecisionResolution: evidence = ( evidence if evidence.contentSha256 else evidence.with_content_sha256() @@ -196,6 +197,8 @@ def _resolve_rna_decision( if agent_selection else None, } + if qc_evidence is not None: + identity["qcPolicyEvidence"] = dict(qc_evidence) digest = _sha256(identity) key = f"{stage}/decisions/{decision_id}/{digest}" prefix = journal._ensure_orchestration_store(store) @@ -242,6 +245,8 @@ def _resolve_rna_decision( "spec": definition.spec.model_dump(mode="json"), "evidence": evidence.model_dump(mode="json"), } + if qc_evidence is not None: + payload["qcPolicyEvidence"] = dict(qc_evidence) prompt = json.dumps(payload, indent=2, sort_keys=True) prompt_hash = hashlib.sha256(prompt.encode()).hexdigest() execution = run_agent_sync( @@ -261,6 +266,10 @@ def _resolve_rna_decision( "group counts and absence of unsafe flags do not establish balanced retention, " "cell validity, or preservation of marker programs. Compare retention fractions " "and metric-specific flags where supplied; unmeasured effects remain unknown. " + "Use qcPolicyEvidence for exact thresholds, distributions, retention " + "and limitations. Its Ref fields point to sharedMeasurements in the " + "same payload. Do not substitute the short evidence summaries for " + "these measurements or dismiss an unsafe rejected policy as untested. " "Within-capture QC does not require an independent biological unit or a healthy reference. " "An override needs the independent evidence required by the supplied specification." ), @@ -270,6 +279,9 @@ def _resolve_rna_decision( output_validator=lambda value: _validate_selection( definition, evidence, value ), + on_attempt=journal.model_attempt_callback( + store, prefix, request_record.workflowRunId, key, payload + ), ) selection = execution.output if not isinstance(selection, DecisionSelection): diff --git a/scarf/agent/orchestrator/journal.py b/scarf/agent/orchestrator/journal.py index 5c9d6c83..598714db 100644 --- a/scarf/agent/orchestrator/journal.py +++ b/scarf/agent/orchestrator/journal.py @@ -5,7 +5,8 @@ import re import time import uuid -from collections.abc import Mapping, Sequence +from collections.abc import Callable, Iterator, Mapping, Sequence +from contextlib import contextmanager from pathlib import Path from typing import Any, Literal, cast @@ -17,7 +18,7 @@ from ...utils.logging import logger from .. import record_io from ..experimental_context.study import StudyContract -from ..types import AgentDataModel, ArtifactReferenceModel +from ..types import AgentDataModel, AgentRunInfo, ArtifactReferenceModel from .models import ( _STAGE_ORDER, AutomatedWorkflowConfig, @@ -47,6 +48,109 @@ def _sha256_model(value: AgentDataModel) -> str: ).hexdigest() +def model_attempt_callback( + store: Any, + prefix: str, + workflow_run_id: str, + key: str, + inputs: Mapping[str, Any], +) -> Callable[[AgentRunInfo], None]: + """Save provider attempts beside their exact owning scientific evidence.""" + identity = hashlib.sha256(record_io.canonical_json_bytes(dict(inputs))).hexdigest() + + def save(run_info: AgentRunInfo) -> None: + save_checkpoint( + store, + prefix, + workflow_run_id, + f"{key}/model_attempts/{uuid.uuid4().hex}", + inputs={"evidenceSha256": identity}, + outputs={ + "runInfo": run_info.model_dump(mode="json"), + "recordedAtNs": time.time_ns(), + }, + ) + + return save + + +@contextmanager +def diagnostic_attempt( + store: Any, + prefix: str, + workflow_run_id: str, + inputs: Mapping[str, Any], +) -> Iterator[dict[str, dict[str, int]]]: + """Record observed diagnostic calls, including interrupted and repeated work.""" + from ..parameter_tuning.execution import diagnostic_work + + key = f"parameter_tuning/diagnostic_attempts/{uuid.uuid4().hex}" + identity = dict(inputs) + started = time.time_ns() + save_checkpoint( + store, + prefix, + workflow_run_id, + key + "/started", + identity, + {"recordedAtNs": started}, + ) + with diagnostic_work() as counts: + status = "completed" + error = None + failure: BaseException | None = None + try: + yield counts + except BaseException as exc: + failure = exc + status = "failed" if isinstance(exc, Exception) else "interrupted" + error = f"{type(exc).__name__}: {exc}" + raise + finally: + try: + logger.info( + f"Diagnostic work: {sum(row['completed'] for row in counts.values())}/" + f"{sum(row['attempted'] for row in counts.values())} " + "observed calls completed/attempted, including " + f"{counts.get('core.doubletDetection', {}).get('attempted', 0)} " + "doublet-scoring calls; " + f"{sum(row['cacheHits'] for row in counts.values())} metric cache hits, " + f"{sum(row['restored'] for row in counts.values())} saved-evidence restores. " + "Composite diagnostics include their underlying calls; these counts " + "do not measure internal rebuilds or simulated-doublet writes." + ) + save_checkpoint( + store, + prefix, + workflow_run_id, + key + "/finished", + identity, + { + "recordedAtNs": time.time_ns(), + "elapsedSeconds": (time.time_ns() - started) / 1e9, + "status": status, + "error": error, + "operations": counts, + "interpretation": ( + "Counts are observed agent calls and explicit reuse events. " + "Composite diagnostics include underlying calls; elapsed time " + "covers the tuning invocation, including model waiting. " + "A core call may reuse artifacts or still repeat internal work; " + "internal numerical rebuilds and simulated-doublet writes are " + "not observable from returned references. Historical attempts " + "without these records have unknown operation counts." + ), + }, + ) + except BaseException as persistence_error: + if failure is None: + raise + failure.add_note( + "Saving diagnostic work also failed: " + f"{type(persistence_error).__name__}: {persistence_error}" + ) + + def _record_checksum(value: AgentDataModel) -> str: return hashlib.sha256( record_io.canonical_json_bytes( @@ -861,12 +965,18 @@ def finish_exception( outputs: Mapping[str, Any] | None = None, notes: Sequence[str] = (), ) -> WorkflowStageAttempt: - error = f"{type(exc).__name__}: {exc}" + from ..config.agent_exec import describe_agent_error + + error = describe_agent_error(exc) + saved_outputs = dict(outputs or {}) + run_info = getattr(exc, "agent_run_info", None) + if isinstance(run_info, AgentRunInfo): + saved_outputs["runInfo"] = run_info.model_dump(mode="json") outcome = _complete_attempt( started, status="failed", artifacts=artifacts, - outputs=outputs, + outputs=saved_outputs, actions=actions, notes=notes, error=error, @@ -925,7 +1035,8 @@ def _analysis_review_views( continue key = entry.get("checkpointKey", "") match = re.fullmatch( - r"parameter_tuning/(sample0|sample1|full)/review([0-9]+)(?:/answer)?", + r"parameter_tuning/(?:evidence_revisions/[a-f0-9]{64}/)?" + r"(sample0|sample1|full)/review([0-9]+)(?:/answer)?", key, ) if match is None or match[1] != entry.get("scope"): @@ -1043,16 +1154,100 @@ def _analysis_review_views( return views +def _model_usage( + model_attempts: Sequence[Mapping[str, Any]], + saved_views: Sequence[Mapping[str, Any]], +) -> dict[str, Any]: + """Count measured invocations once across callbacks and copied report views.""" + invocations: dict[str, AgentRunInfo] = {} + + def collect(value: Any) -> None: + if isinstance(value, Mapping): + raw = value.get("runInfo") + if isinstance(raw, Mapping): + info = AgentRunInfo.model_validate(raw) + # Empty, code-created outcomes are not model invocations. + if ( + info.runId + or info.modelName + or info.status + or any( + ( + info.usage.requests, + info.usage.inputTokens, + info.usage.outputTokens, + info.usage.totalTokens, + info.usage.toolCalls, + ) + ) + ): + identity = info.runId or _sha256_model(info) + invocations.setdefault(identity, info) + for key, child in value.items(): + if key != "runInfo": + collect(child) + elif isinstance(value, list): + for child in value: + collect(child) + + # Invocation callbacks own measured usage; reports can repeat these records. + collect(list(model_attempts)) + collect(list(saved_views)) + infos = list(invocations.values()) + partial = sum(info.usage.availability == "partial" for info in infos) + unavailable = sum(info.usage.availability == "unavailable" for info in infos) + unspecified = sum(info.usage.availability is None for info in infos) + known = any( + info.usage.inputTokens or info.usage.outputTokens or info.usage.totalTokens + for info in infos + ) + return { + "invocations": len(infos), + "failedInvocations": sum(info.status == "failed" for info in infos), + "validationRetries": sum(len(info.validationRetries) for info in infos), + "durationSeconds": sum(info.durationSeconds for info in infos), + **{ + field: sum(getattr(info.usage, field) for info in infos) + for field in ( + "requests", + "toolCalls", + "inputTokens", + "outputTokens", + "totalTokens", + ) + }, + "availability": ( + "reported" + if infos and not (partial or unavailable or unspecified) + else "partial" + if known + else "unavailable" + ), + "partialUsageInvocations": partial, + "unavailableUsageInvocations": unavailable, + "unspecifiedUsageInvocations": unspecified, + } + + def analysis_snapshot(store: DataStore, workflow_run_id: str) -> dict[str, Any]: """Validate one journal and derive its status, final artifacts, and report view.""" prefix = _orchestration_prefix(store) request = read_request(store.zw, prefix, workflow_run_id) + outcomes_by_stage = { + stage: _stage_outcomes(store.zw, prefix, workflow_run_id, stage) + for stage in _STAGE_ORDER + } + usage_views: list[dict[str, Any]] = [ + outcome.outputs + for outcomes in outcomes_by_stage.values() + for outcome in outcomes + ] stages: list[dict[str, Any]] = [] parents: list[WorkflowStageLink] = [] final: dict[str, Any] | None = None status = "running" for stage in _STAGE_ORDER: - outcomes = _stage_outcomes(store.zw, prefix, workflow_run_id, stage) + outcomes = outcomes_by_stage[stage] matching = [v for v in outcomes if v.parentAttempts == parents] if not matching: break @@ -1118,13 +1313,54 @@ def analysis_snapshot(store: DataStore, workflow_run_id: str) -> dict[str, Any]: # Decisions belong to this same history and retain their offered evidence. checkpoint_prefix = record_io.join_key(prefix, workflow_run_id, "checkpoints") decisions: list[dict[str, Any]] = [] + model_attempts: list[dict[str, Any]] = [] + diagnostic_attempts: dict[str, dict[str, Any]] = {} for path in _list_keys(store.zw, checkpoint_prefix): - if "/decisions/" not in path or not path.endswith(".json"): + if not path.endswith(".json") or not any( + part in path + for part in ( + "/decisions/", + "/model_attempts/", + "/report/", + "/diagnostic_attempts/", + ) + ): continue key = path[len(checkpoint_prefix) + 1 : -5] - value = load_checkpoint(store, prefix, workflow_run_id, key, None) + checkpoint = read_checkpoint(store, prefix, workflow_run_id, key) + value = checkpoint["outputs"] if checkpoint is not None else None + if value is not None: + usage_views.append(value) if value is not None and "record" in value: decisions.append(value) + if value is not None and "/model_attempts/" in path: + run_info = AgentRunInfo.model_validate(value["runInfo"]) + model_attempts.append( + { + "checkpoint": key, + "recordedAtNs": value.get("recordedAtNs"), + "runInfo": run_info.model_dump(mode="json"), + } + ) + if value is not None and "/diagnostic_attempts/" in path: + assert checkpoint is not None + attempt, _, phase = key.rpartition("/") + if phase not in {"started", "finished"}: + raise ValueError("Unknown diagnostic attempt checkpoint") + entry = diagnostic_attempts.setdefault( + attempt, {"checkpoint": attempt, "inputs": checkpoint["inputs"]} + ) + if entry["inputs"] != checkpoint["inputs"]: + raise ValueError( + "Diagnostic attempt inputs changed between start and finish" + ) + entry[phase] = value + + if any( + "finished" in row and "started" not in row + for row in diagnostic_attempts.values() + ): + raise ValueError("Completed diagnostic attempt has no matching start record") def contains(value: Any, digest: str) -> bool: if isinstance(value, Mapping): @@ -1174,6 +1410,14 @@ def contains(value: Any, digest: str) -> bool: "status": status, "request": request.request.model_dump(mode="json"), "stages": stages, + "modelAttempts": sorted( + model_attempts, key=lambda row: row["recordedAtNs"] or 0 + ), + "modelUsage": _model_usage(model_attempts, usage_views), + "diagnosticAttempts": sorted( + diagnostic_attempts.values(), + key=lambda row: row.get("started", {}).get("recordedAtNs", 0), + ), "finalAnalysis": final, "modelIdentity": request.modelIdentity, "analysisReviews": reviews, diff --git a/scarf/agent/orchestrator/main.py b/scarf/agent/orchestrator/main.py index 0f1e8078..2f30f5dc 100644 --- a/scarf/agent/orchestrator/main.py +++ b/scarf/agent/orchestrator/main.py @@ -13,6 +13,7 @@ from ...datastore.summary import summarize_zarr_readonly from ...utils.logging import logger from .. import record_io +from ..config.agent_exec import describe_agent_error from ..experimental_context.study import StudyContract, validate_objective_evidence from ..ingest import IngestResult, detect_format, ingest from ..ingest.manifest import DatasetManifest, inspect_h5ad_manifest @@ -128,12 +129,23 @@ def __init__( ) -> None: self.model = model self.config = config or AutomatedWorkflowConfig() + self._explicit_config_fields = ( + set(config.model_fields_set) if config is not None else set() + ) + + def _validate_resume_config(self, saved: AutomatedWorkflowConfig) -> None: + """Saved defaults remain authoritative unless a caller overrides them.""" + if any( + getattr(saved, field) != getattr(self.config, field) + for field in self._explicit_config_fields + ): + raise ValueError("Resume execution settings differ from the saved workflow") def run(self, request: AutomatedWorkflowRequest) -> AutomatedWorkflowResult: try: result = self._run(request) except Exception as exc: - result = AutomatedWorkflowResult(notes=[f"{type(exc).__name__}: {exc}"]) + result = AutomatedWorkflowResult(notes=[describe_agent_error(exc)]) if result.status != "completed": logger.error( f"RNA analysis {result.status} during {result.currentStage}: " @@ -146,7 +158,7 @@ def _run(self, request: AutomatedWorkflowRequest) -> AutomatedWorkflowResult: try: validate_rna_request_fields(request) except ValueError as exc: - return AutomatedWorkflowResult(notes=[str(exc)]) + return AutomatedWorkflowResult(notes=[describe_agent_error(exc)]) reused = self._reuse_or_resume(request) if reused is not None: return reused @@ -197,7 +209,9 @@ def _run(self, request: AutomatedWorkflowRequest) -> AutomatedWorkflowResult: return AutomatedWorkflowResult( status="failed", currentStage="ingest", - notes=[f"CELLxGENE manifest inspection failed: {exc}"], + notes=[ + f"CELLxGENE manifest inspection failed: {describe_agent_error(exc)}" + ], ) if dataset_manifest.declaredBatchColumns: experimental_directions = dict(request.experimentalDirections) @@ -297,7 +311,9 @@ def _run(self, request: AutomatedWorkflowRequest) -> AutomatedWorkflowResult: status="failed", currentStage="ingest", zarrPath=zarr_path, - notes=[f"Opening the requested RNA store failed: {exc}"], + notes=[ + f"Opening the requested RNA store failed: {describe_agent_error(exc)}" + ], ) ingest_result = IngestResult( status="done", @@ -377,7 +393,7 @@ def _run(self, request: AutomatedWorkflowRequest) -> AutomatedWorkflowResult: store = self.open_store(ingest_result.zarrPath, effective_request) except (OSError, KeyError, RuntimeError, TypeError, ValueError) as exc: return AutomatedWorkflowResult( - zarrPath=ingest_result.zarrPath, notes=[str(exc)] + zarrPath=ingest_result.zarrPath, notes=[describe_agent_error(exc)] ) ignored = [name for name in store.assay_names if name != selected] logger.info( @@ -446,9 +462,8 @@ def _reuse_or_resume( ) if matches: saved = matches[0] - if saved.config != self.config or saved.modelIdentity != _model_identity( - self.model - ): + self._validate_resume_config(saved.config) + if saved.modelIdentity != _model_identity(self.model): raise ValueError( "The destination contains this request with different model or execution settings; use a new destination or exact advanced workflow" ) @@ -508,8 +523,7 @@ def load_request_for_resume( record = journal.read_request(store.zw, prefix, request.workflowRunId) if record.modelIdentity != _model_identity(self.model): raise ValueError("Resume model differs from the saved workflow") - if record.config != self.config: - raise ValueError("Resume execution settings differ from the saved workflow") + self._validate_resume_config(record.config) selected = selected_store_rna_assay(store, record.request) validate_saved_rna_history(store, prefix, request.workflowRunId, selected) expected = record.inputIdentity @@ -530,6 +544,7 @@ def load_request_for_resume( def resume( self, request: AutomatedWorkflowResumeRequest ) -> AutomatedWorkflowResult: + original_config = self.config try: result = self._resume(request) except Exception as exc: @@ -537,8 +552,10 @@ def resume( zarrPath=request.zarrPath, workspace=request.workspace, workflowRunId=request.workflowRunId, - notes=[f"{type(exc).__name__}: {exc}"], + notes=[describe_agent_error(exc)], ) + finally: + self.config = original_config if result.status != "completed": logger.error( f"RNA analysis {result.status} during {result.currentStage}: " @@ -550,6 +567,7 @@ def _resume( self, request: AutomatedWorkflowResumeRequest ) -> AutomatedWorkflowResult: record, store = self.load_request_for_resume(request) + self.config = record.config workflow = WorkflowIdentity(record.workflowRunId, record.request.workspace) snapshot = journal.analysis_snapshot(store, workflow.workflowRunId) if snapshot["status"] == "completed": @@ -575,7 +593,7 @@ def _resume( update={ "status": "failed", "currentStage": "report", - "notes": [str(exc)], + "notes": [describe_agent_error(exc)], } ) return result @@ -694,12 +712,25 @@ def _continue( latest = ( max(starts, key=lambda value: value.startedAtNs) if starts else None ) + if latest is not None and not any( + value.attemptId == latest.attemptId + for value in journal._stage_outcomes( + store.zw, prefix, workflow.workflowRunId, latest.stage + ) + ): + try: + journal.finish_exception(store, prefix, workflow, latest, exc) + except Exception as persistence_error: + exc.add_note( + "Saving the failed stage also failed: " + + describe_agent_error(persistence_error) + ) return AutomatedWorkflowResult( currentStage=latest.stage if latest else "ingest", zarrPath=str(store.zarr_loc), workspace=workflow.workspace, workflowRunId=workflow.workflowRunId, - notes=[f"{type(exc).__name__}: {exc}"], + notes=[describe_agent_error(exc)], ) def _execute_stages( @@ -932,13 +963,13 @@ def _execute_stages( try: path = generate_agent_report(store, workflow.workflowRunId) except Exception as exc: - logger.error(f"Analysis report failed: {type(exc).__name__}: {exc}") + logger.error(f"Analysis report failed: {describe_agent_error(exc)}") return completed.model_copy( update={ "status": "failed", "currentStage": "report", "notes": [ - f"Report generation failed: {exc}; the validated analysis is saved and can be resumed." + f"Report generation failed: {describe_agent_error(exc)}; the validated analysis is saved and can be resumed." ], } ) diff --git a/scarf/agent/orchestrator/models.py b/scarf/agent/orchestrator/models.py index a94b16b4..3daa84df 100644 --- a/scarf/agent/orchestrator/models.py +++ b/scarf/agent/orchestrator/models.py @@ -289,7 +289,11 @@ class AutomatedWorkflowConfig(AgentDataModel): default="pause", exclude_if=lambda value: value == "pause", ) - screeningCells: int = Field(default=50_000, ge=20) + screeningCells: int | None = Field( + default=None, + ge=20, + description="Fixed initial screening size; null uses 10% of retained cells bounded to 10,000–100,000.", + ) maxScreeningCells: int = Field(default=100_000, ge=20) maxScreeningEvaluations: int = Field(default=24, ge=4) maxTotalScreeningEvaluations: int = Field(default=48, ge=4) @@ -343,7 +347,10 @@ def reject_obsolete_configuration(cls, value: Any) -> Any: @model_validator(mode="after") def validate_work_limits(self) -> "AutomatedWorkflowConfig": - if self.maxScreeningCells < self.screeningCells: + if ( + self.screeningCells is not None + and self.maxScreeningCells < self.screeningCells + ): raise ValueError("maxScreeningCells must be at least screeningCells") if self.maxTotalScreeningEvaluations < self.maxScreeningEvaluations: raise ValueError( diff --git a/scarf/agent/orchestrator/preprocessing.py b/scarf/agent/orchestrator/preprocessing.py index 2590c6fe..2054ccd6 100644 --- a/scarf/agent/orchestrator/preprocessing.py +++ b/scarf/agent/orchestrator/preprocessing.py @@ -167,6 +167,52 @@ def _profile_is_safe(profile: CellQcProfileEvidence) -> bool: return False return True + @staticmethod + def _qc_decision_evidence( + profiles: Sequence[CellQcProfileEvidence], + ) -> dict[str, Any]: + """Keep exact QC measurements while sharing repeated source/design evidence.""" + shared: dict[str, Any] = {} + + def reference(value: Any) -> str: + digest = hashlib.sha256(record_io.canonical_json_bytes(value)).hexdigest() + shared[digest] = value + return digest + + policies = [] + for profile in profiles: + policy = profile.model_dump(mode="json") + for field in ("metricSources", "sourceConcordance"): + policy[field + "Ref"] = reference(policy.pop(field)) + parameters = policy["parameters"] + if "captureComparisons" in parameters: + parameters["captureComparisonsRef"] = reference( + parameters.pop("captureComparisons") + ) + for duplicate, canonical in ( + ("resolvedBounds", "resolvedBounds"), + ("captureSizes", "activeCellsByCapture"), + ): + if ( + duplicate in parameters + and parameters[duplicate] == policy[canonical] + ): + parameters.pop(duplicate) + for capture in policy["captureFailureEvidence"]: + capture["conditionAndUnitSafetyRef"] = reference( + capture.pop("conditionAndUnitSafety") + ) + policies.append(policy) + return { + "policies": policies, + "sharedMeasurements": shared, + "interpretation": ( + "Each Ref identifies the exact record in sharedMeasurements. " + "Thresholds, distributions, flags and protected-group retention are " + "measured policy evidence; short evidence summaries do not replace them." + ), + } + @staticmethod def _profile_evidence( profile: CellQcProfileEvidence, @@ -373,6 +419,7 @@ def _resolve_qc_grouping_decision( bundle, answers, rule_selection=rule_selection, + qc_evidence=self._qc_decision_evidence(profiles), ) if resolution.compiled is None: raise _DecisionNeedsInput( @@ -447,6 +494,7 @@ def _resolve_cell_quality_decision( definition, bundle, answers, + qc_evidence=self._qc_decision_evidence(all_profiles), ) if resolution.compiled is None: raise _DecisionNeedsInput( diff --git a/scarf/agent/orchestrator/rna_tuning.py b/scarf/agent/orchestrator/rna_tuning.py index 43dc671a..5d36a948 100644 --- a/scarf/agent/orchestrator/rna_tuning.py +++ b/scarf/agent/orchestrator/rna_tuning.py @@ -1,13 +1,15 @@ """Objective-led RNA experiments on frozen screening and full-cohort cells.""" import hashlib +import base64 import json import re from collections.abc import Mapping, Sequence -from typing import Any, Literal +from typing import Annotated, Any, Literal, cast import numpy as np from pydantic import Field, create_model, model_validator +from pydantic.json_schema import SkipJsonSchema from ...metadata.rows import read_metadata_rows_chunkwise from ...storage.refs import ArtifactRef @@ -18,6 +20,7 @@ from ...utils.logging import logger from .. import record_io from ..config.agent_exec import ( + ImageEvidence, ImageInputUnsupportedError, build_visual_evidence_prompt, run_agent_sync, @@ -38,7 +41,10 @@ from ..parameter_tuning.comparisons import ( CombinedSettings, ComparisonConclusion, + ComparisonTradeoff, PopulationConcern, + bind_comparison_measurements, + comparison_advantages, setting_changes, partition_comparison_evidence, validate_comparison_review, @@ -50,9 +56,15 @@ augment_cluster_evaluations, augment_pca_evaluations, population_support_evidence, + restore_advisory_doublets, score_advisory_doublets, ) -from ..parameter_tuning.execution import execute_parameter_candidate +from ..parameter_tuning.execution import ( + diagnostic_call, + diagnostic_reuse, + execute_parameter_candidate, + refresh_candidate_design_evidence, +) from ..parameter_tuning.hvg import ( HvgGroupVariability, aggregate_hvg_rankings, @@ -92,6 +104,19 @@ def _configured_image_input(model: Any) -> bool | None: return None +def screening_sizes(n_cells: int, config: Any) -> tuple[int, ...]: + """Resolve the saved fixed policy or the bounded ten-percent default.""" + if n_cells < 1: + raise ValueError("Screening requires a non-empty retained cohort") + if config.screeningCells is None: + maximum = min(n_cells, config.maxScreeningCells, 100_000) + initial = min(maximum, max(10_000, (n_cells + 9) // 10)) + else: + initial = min(n_cells, config.screeningCells) + maximum = min(n_cells, config.maxScreeningCells) + return tuple(dict.fromkeys((initial, maximum))) + + class TuningAction(AgentDataModel): """An assessment of observed evidence and at most one registered experiment.""" @@ -170,6 +195,17 @@ def _assessment_output_type( """Constrain new model choices without changing the saved action contract.""" if not candidate_ids: raise ValueError("RNA assessment requires observed candidates") + tradeoff_type = create_model( + "ObservedTradeoffInterpretation", + __base__=ComparisonTradeoff, + preferredValue=(Annotated[float | None, SkipJsonSchema()], None), + alternativeValue=(Annotated[float | None, SkipJsonSchema()], None), + ) + conclusion_type = create_model( + "ObservedComparisonInterpretation", + __base__=ComparisonConclusion, + tradeoffs=(cast(Any, list)[tradeoff_type], Field(default_factory=list)), + ) actions = tuple( action for action in ("accept", "combine", "experiment", "enlarge", "defer") @@ -181,6 +217,7 @@ def _assessment_output_type( return create_model( "ObservedRnaAssessment", __base__=TuningAction, + comparisonConclusions=(cast(Any, list)[conclusion_type], ...), action=( Literal[actions], Field(description=TuningAction.model_fields["action"].description), @@ -379,6 +416,7 @@ def __init__( provenance: dict[str, Any], *, design_comparisons: Sequence[CovariateComparison] = (), + previous_provenances: Sequence[dict[str, Any]] = (), ) -> None: if ( handoff.cellSelection is None @@ -396,12 +434,23 @@ def __init__( study, ) self.answers, self.provenance = answers, provenance + self.previous_provenances = tuple(previous_provenances) + self.evidence_revision = ( + hashlib.sha256(record_io.canonical_json_bytes(provenance)).hexdigest() + if previous_provenances + else None + ) self.design_comparisons = tuple(design_comparisons) self.prefix = journal._ensure_orchestration_store(store) self.cells = artifact_model_to_ref(handoff.cellSelection) self.marker_features = artifact_model_to_ref(handoff.markerFeatures) self.budget = CandidateBudget( - store, self.prefix, workflow.workflowRunId, request.config, provenance + store, + self.prefix, + workflow.workflowRunId, + request.config, + provenance, + previous_provenances=self.previous_provenances, ) self.settings: dict[str, RnaSetting] = {} self.history: list[dict[str, Any]] = [] @@ -411,16 +460,18 @@ def __init__( "full": [], } self.last_action: TuningAction | None = None - self.full_repairs = 0 + self.full_repairs = len(self.budget.repairs) self.answer_consumed = False self.scope_sizes: dict[str, int] = {} self.feature_evidence_cache: dict[str, dict[str, Any]] = {} self.neighbor_comparisons: dict[tuple[str, str], float] = {} + self.diagnostic_counts: dict[str, dict[str, int]] = {} self.comparison_rows: dict[str, list[dict[str, Any]]] = {} self.combined_candidates: dict[str, str] = {} self.resolution_candidates: dict[str, list[str]] = {} self.validation_sources: dict[str, dict[str, Any]] = {} self.discovery_scope: str | None = None + self.recovery_scope: str | None = None self.full_repair: dict[str, Any] | None = None self.batch_columns = list(study.technicalBatchColumns) self.coverage_columns = [ @@ -459,13 +510,29 @@ def baseline(self, resolution: float = 1.0) -> RnaSetting: hvgCount=self.handoff.nFeatures, ) - @staticmethod - def execution_inputs(cells: ArtifactRef, setting: RnaSetting) -> dict[str, Any]: - return { + def checkpoint_scope(self, scope: str) -> str: + """Separate revised interpretations while retaining numerical admissions.""" + if self.evidence_revision is not None: + return ( + f"parameter_tuning/evidence_revisions/{self.evidence_revision}/{scope}" + ) + return f"parameter_tuning/{scope}" + + def execution_inputs( + self, cells: ArtifactRef, setting: RnaSetting + ) -> dict[str, Any]: + inputs: dict[str, Any] = { "cells": cells.to_dict(), "features": setting.features.model_dump(mode="json"), "parameters": setting.parameters.model_dump(mode="json"), } + if setting.parameters.useHarmony and any( + previous.get("studyContract", {}).get("technicalBatchColumns") + != self.batch_columns + for previous in self.previous_provenances + ): + inputs["harmonyBatchColumns"] = self.batch_columns + return inputs def execute( self, scope: str, cells: ArtifactRef, setting: RnaSetting @@ -500,10 +567,18 @@ def execute( raise ValueError( "Saved candidate evidence is unavailable or incomplete" ) + diagnostic_reuse("diagnostic.primaryCandidateEvidence", "restored") + evaluation = self._reassess_saved_evaluation( + scope, setting, admission, evaluation + ) else: features = artifact_model_to_ref(setting.features) - normalized = self.store.run_normalization( - cells, features=features, invalidate_cache=False + normalized = diagnostic_call( + "core.primaryNormalization", + self.store.run_normalization, + cells, + features=features, + invalidate_cache=False, ) deps, ids = prepare_parameter_tuning_dependencies( self.store, @@ -531,74 +606,7 @@ def execute( "graphFeatures": ArtifactRecord.from_ref(features), } ) - if evaluation.status == "done": - evaluation = augment_pca_evaluations( - self.store, - [evaluation], - feature_selection=features, - nominated_families=SCARF_DEFAULT_DIAGNOSTIC_FAMILIES, - protected_families=self.plan.assays[0].featureParameters.get( - "protectFamilies", [] - ), - technical_columns=self.batch_columns, - batch_columns=self.batch_columns, - protected_columns=self.study.protectedColumns, - qc_columns=[ - *self.plan.cellQc.attributes, - *(source.name for source in self.plan.cellQc.artifactMetrics), - ], - qc_artifacts={ - source.name: artifact_model_to_ref(source.artifact) - for source in self.plan.cellQc.artifactMetrics - }, - column_kinds={ - **self.study.columnKinds, - **{ - source.name: "continuous" - for source in self.plan.cellQc.artifactMetrics - }, - }, - )[0] - native = next( - ( - item - for item in self.evaluations[scope] - if not item.parameters.useHarmony - and self.settings[item.candidateId].features == setting.features - and item.parameters.model_dump( - exclude={"candidateId", "useHarmony"} - ) - == parameters.model_dump(exclude={"candidateId", "useHarmony"}) - ), - None, - ) - doublets = score_advisory_doublets( - self.store, - native or evaluation, - [ - item - for item in [*self.evaluations[scope], evaluation] - if item.artifacts.get("graphFeatures") - == evaluation.artifacts["graphFeatures"] - and item.cellSelection == evaluation.cellSelection - ], - assay=self.handoff.assay, - feature_selection=features, - capture_column=self.study.physicalCaptureColumn, - ) - evaluation = augment_cluster_evaluations( - self.store, - [evaluation], - marker_assay=self.handoff.assay, - marker_features=self.marker_features, - independent_unit_columns=self.study.independentUnitColumns, - technical_columns=self.batch_columns, - nominated_families=SCARF_DEFAULT_DIAGNOSTIC_FAMILIES, - protected_families=self.plan.assays[0].featureParameters.get( - "protectFamilies", [] - ), - doublet_evidence=doublets, - )[0] + evaluation = self._augment_evaluation(scope, setting, evaluation) evaluation = ParameterCandidateEvaluation.model_validate_json( record_io.canonical_json_bytes(evaluation.model_dump(mode="json")) ) @@ -611,6 +619,172 @@ def execute( self.evaluations[scope].append(evaluation) return evaluation + def _augment_evaluation( + self, + scope: str, + setting: RnaSetting, + evaluation: ParameterCandidateEvaluation, + *, + preserve_doublets: bool = False, + ) -> ParameterCandidateEvaluation: + """Attach required loading, population, stability and doublet evidence.""" + features = artifact_model_to_ref(setting.features) + parameters = setting.parameters + evaluation = augment_pca_evaluations( + self.store, + [evaluation], + feature_selection=features, + nominated_families=SCARF_DEFAULT_DIAGNOSTIC_FAMILIES, + protected_families=self.plan.assays[0].featureParameters.get( + "protectFamilies", [] + ), + technical_columns=self.batch_columns, + batch_columns=self.batch_columns, + protected_columns=self.study.protectedColumns, + qc_columns=[ + *self.plan.cellQc.attributes, + *(source.name for source in self.plan.cellQc.artifactMetrics), + ], + qc_artifacts={ + source.name: artifact_model_to_ref(source.artifact) + for source in self.plan.cellQc.artifactMetrics + }, + column_kinds={ + **self.study.columnKinds, + **{ + source.name: "continuous" + for source in self.plan.cellQc.artifactMetrics + }, + }, + )[0] + native = next( + ( + item + for item in self.evaluations[scope] + if not item.parameters.useHarmony + and self.settings[item.candidateId].features == setting.features + and item.parameters.model_dump(exclude={"candidateId", "useHarmony"}) + == parameters.model_dump(exclude={"candidateId", "useHarmony"}) + ), + None, + ) + doublets = ( + restore_advisory_doublets( + evaluation, capture_column=self.study.physicalCaptureColumn + ) + if preserve_doublets and "doubletNativeGraph" in evaluation.artifacts + else score_advisory_doublets( + self.store, + native or evaluation, + [ + item + for item in [*self.evaluations[scope], evaluation] + if item.artifacts.get("graphFeatures") + == evaluation.artifacts["graphFeatures"] + and item.cellSelection == evaluation.cellSelection + ], + assay=self.handoff.assay, + feature_selection=features, + capture_column=self.study.physicalCaptureColumn, + ) + ) + evaluation = augment_cluster_evaluations( + self.store, + [evaluation], + marker_assay=self.handoff.assay, + marker_features=self.marker_features, + independent_unit_columns=self.study.independentUnitColumns, + technical_columns=self.batch_columns, + nominated_families=SCARF_DEFAULT_DIAGNOSTIC_FAMILIES, + protected_families=self.plan.assays[0].featureParameters.get( + "protectFamilies", [] + ), + doublet_evidence=doublets, + )[0] + return evaluation + + def _reassess_saved_evaluation( + self, + scope: str, + setting: RnaSetting, + admission: dict[str, Any], + evaluation: ParameterCandidateEvaluation, + ) -> ParameterCandidateEvaluation: + original = self.budget.admission_provenance(admission) + if original == self.provenance: + return evaluation + key = f"{self.checkpoint_scope(scope)}/evaluation{admission['slot']}/augmented" + inputs = {"admission": admission, "provenance": self.provenance} + saved = journal.load_checkpoint( + self.store, self.prefix, self.workflow.workflowRunId, key, inputs + ) + if saved is not None: + restored = ParameterCandidateEvaluation.model_validate(saved["evaluation"]) + for artifact in restored.artifacts.values(): + status = self.store.inspect_artifact(artifact_model_to_ref(artifact)) + if not status.exists or not status.complete: + raise ValueError( + "Revised candidate evidence is unavailable or incomplete" + ) + return restored + old = StudyContract.model_validate(original["studyContract"]) + fields = ( + "technicalBatchColumns", + "protectedColumns", + "protectedCombinations", + "columnKinds", + ) + if any(getattr(old, field) != getattr(self.study, field) for field in fields): + normalized = artifact_model_to_ref(evaluation.artifacts["normalized"]) + deps, _ = prepare_parameter_tuning_dependencies( + self.store, + normalized=normalized, + candidates=[evaluation.parameters], + batch_columns=self.batch_columns, + preservation_columns=self.study.protectedColumns, + pair_harmony_candidates=False, + max_candidates=1, + min_cluster_cells=1, + ) + deps.protectedCombinations = tuple( + tuple(columns) for columns in self.study.protectedCombinations + ) + deps.columnKinds = self.study.columnKinds + evaluation = refresh_candidate_design_evidence(deps, evaluation) + if any( + getattr(old, field) != getattr(self.study, field) + for field in ( + "technicalBatchColumns", + "protectedColumns", + "columnKinds", + "independentUnitColumns", + "physicalCaptureColumn", + ) + ): + evaluation = self._augment_evaluation( + scope, + setting, + evaluation, + preserve_doublets=old.physicalCaptureColumn + == self.study.physicalCaptureColumn, + ) + evaluation = ParameterCandidateEvaluation.model_validate_json( + record_io.canonical_json_bytes(evaluation.model_dump(mode="json")) + ) + journal.save_checkpoint( + self.store, + self.prefix, + self.workflow.workflowRunId, + key, + inputs, + { + "evaluation": evaluation.model_dump(mode="json"), + "primaryArtifactsReused": True, + "priorProvenance": original, + }, + ) + return evaluation + def execute_matched( self, scope: str, cells: ArtifactRef, setting: RnaSetting ) -> ParameterCandidateEvaluation: @@ -971,28 +1145,56 @@ def apply_experiment( } ) - def _prepared_setting( + def _setting_checkpoint( self, scope: str, key: str, - selected: ParameterCandidateEvaluation, + baseline: RnaSetting, experiment: dict[str, Any], - cells: ArtifactRef, - ) -> RnaSetting: - """Commit feature work separately so interrupted reviews do not repeat it.""" - baseline = self.settings[selected.candidateId] - inputs = { + ) -> tuple[str, dict[str, Any], dict[str, Any] | None]: + """Reuse exact feature work, with a new address for a revised intervention.""" + inputs: dict[str, Any] = { "baseline": baseline.model_dump(mode="json"), "experiment": experiment, "cells": self.cells.to_dict(), } checkpoint = f"parameter_tuning/{scope}/{key}/setting" - saved = journal.load_checkpoint( - self.store, - self.prefix, - self.workflow.workflowRunId, + previous = journal.read_checkpoint( + self.store, self.prefix, self.workflow.workflowRunId, checkpoint + ) + if self.evidence_revision is not None: + ranking_changed = experiment.get("parameter") == "hvgRanking" and any( + item["studyContract"]["technicalBatchColumns"] != self.batch_columns + for item in self.previous_provenances + ) + if previous is None or previous["inputs"] != inputs or ranking_changed: + checkpoint = f"{self.checkpoint_scope(scope)}/{key}/setting" + if ranking_changed: + inputs["rankingColumns"] = self.batch_columns + return ( checkpoint, inputs, + journal.load_checkpoint( + self.store, + self.prefix, + self.workflow.workflowRunId, + checkpoint, + inputs, + ), + ) + + def _prepared_setting( + self, + scope: str, + key: str, + selected: ParameterCandidateEvaluation, + experiment: dict[str, Any], + cells: ArtifactRef, + ) -> RnaSetting: + """Commit feature work separately so interrupted reviews do not repeat it.""" + baseline = self.settings[selected.candidateId] + checkpoint, inputs, saved = self._setting_checkpoint( + scope, key, baseline, experiment ) if saved is not None: return RnaSetting.model_validate(saved["setting"]) @@ -1194,16 +1396,8 @@ def _sensitivity_panel( experiment["parameter"]: experiment["value"], } else: - prepared = journal.load_checkpoint( - self.store, - self.prefix, - self.workflow.workflowRunId, - f"parameter_tuning/{scope}/sensitivity/{identifier}/setting", - { - "baseline": setting.model_dump(mode="json"), - "experiment": experiment, - "cells": self.cells.to_dict(), - }, + _, _, prepared = self._setting_checkpoint( + scope, f"sensitivity/{identifier}", setting, experiment ) if prepared is not None: known = RnaSetting.model_validate(prepared["setting"]) @@ -1333,7 +1527,9 @@ def _sensitivity_panel( self.comparison_rows[scope] = rows def comparison_coverage(self, scope: str, cells: ArtifactRef) -> dict[str, Any]: - source = self.discovery_scope if scope == "full" else scope + source = ( + (self.discovery_scope or self.recovery_scope) if scope == "full" else scope + ) source = source or scope settings = { item.candidateId: { @@ -1368,8 +1564,11 @@ def comparison_coverage(self, scope: str, cells: ArtifactRef) -> dict[str, Any]: for item in rows } rows = list(self.comparison_rows.get(source, [])) - combined_id = self.combined_candidates.get(source) - resolution_ids = self.resolution_candidates.get(source, []) + panel_source = ( + "full" if scope == "full" and "full" in self.combined_candidates else source + ) + combined_id = self.combined_candidates.get(panel_source) + resolution_ids = self.resolution_candidates.get(panel_source, []) if combined_id is not None: rows.extend( { @@ -1385,7 +1584,8 @@ def comparison_coverage(self, scope: str, cells: ArtifactRef) -> dict[str, Any]: ) return { "phase": "validation" - if scope == "full" and self.discovery_scope is not None + if scope == "full" + and (self.discovery_scope is not None or self.recovery_scope is not None) else "combined" if source in self.combined_candidates else "sensitivity", @@ -1430,7 +1630,7 @@ def _combined_setting( }, "cells": self.cells.to_dict(), } - key = f"parameter_tuning/{scope}/review{review_index}/combined_setting" + key = f"{self.checkpoint_scope(scope)}/review{review_index}/combined_setting" saved = journal.load_checkpoint( self.store, self.prefix, self.workflow.workflowRunId, key, inputs ) @@ -1515,8 +1715,17 @@ def _resolution_panel( proposals = [self.execution_inputs(cells, item) for item in settings] if setting.parameters.useHarmony: proposals += [ - {**row, "parameters": {**row["parameters"], "useHarmony": False}} - for row in proposals + self.execution_inputs( + cells, + item.model_copy( + update={ + "parameters": item.parameters.model_copy( + update={"useHarmony": False} + ) + } + ), + ) + for item in settings ] self.budget.check_many( scope, @@ -1526,6 +1735,35 @@ def _resolution_panel( if scope != "full" or self.budget.completed_source(row) is None ], ) + if scope == "full" and self.recovery_scope is not None: + recovery_inputs = { + "sourceScope": self.recovery_scope, + "sourceReview": self.last_action.model_dump(mode="json") + if self.last_action is not None + else None, + "cells": cells.to_dict(), + "setting": setting.model_dump(mode="json"), + } + journal.save_checkpoint( + self.store, + self.prefix, + self.workflow.workflowRunId, + "parameter_tuning/full/targeted_recovery/" + + hashlib.sha256( + record_io.canonical_json_bytes(recovery_inputs) + ).hexdigest(), + inputs=recovery_inputs, + outputs={ + "purpose": "Resolve the recorded screening concern using full-cohort population, marker, stability and preservation evidence for the proposed settings.", + "screeningAccepted": False, + "plannedEvaluations": proposals, + "limits": self.budget.summary()["limits"], + }, + ) + self.budget.admit_many( + scope, + [row for row in proposals if self.budget.completed_source(row) is None], + ) evaluations = [self.execute_matched(scope, cells, item) for item in settings] self.resolution_candidates[scope] = [item.candidateId for item in evaluations] return next( @@ -1615,7 +1853,7 @@ def review( from .tuning import _analysis_visual_content candidates = self.evaluations[scope] - key = f"parameter_tuning/{scope}/review{review_index}" + key = f"{self.checkpoint_scope(scope)}/review{review_index}" previous_review = journal.read_checkpoint( self.store, self.prefix, @@ -1627,12 +1865,38 @@ def review( item.candidateId: self.settings[item.candidateId].model_dump(mode="json") for item in candidates } + committed_review = previous_review + prepared_inputs = { + "candidatesSha256": hashlib.sha256( + record_io.canonical_json_bytes(candidate_evidence) + ).hexdigest(), + "settingsSha256": hashlib.sha256( + record_io.canonical_json_bytes(setting_evidence) + ).hexdigest(), + "coverage": coverage, + "provenance": self.provenance, + } + prepared = None + if previous_review is None: + for evidence_mode in ("structured", "visual"): + prepared = journal.load_checkpoint( + self.store, + self.prefix, + self.workflow.workflowRunId, + f"{key}/evidence/{evidence_mode}", + inputs=prepared_inputs, + ) + if prepared is not None: + previous_review = {"inputs": prepared["evidence"]} + break if previous_review is not None and ( previous_review["inputs"].get("candidates") != candidate_evidence or previous_review["inputs"].get("settings") != setting_evidence ): raise ValueError( - "Saved review has different candidate evidence or settings" + "Saved review has different candidate evidence or settings. " + "If this review predates required Harmony comparisons for safe designs, " + "start a new workflow in a different destination; the saved history and artifacts remain intact." ) if ( previous_review is not None @@ -1641,6 +1905,8 @@ def review( raise ValueError( "Saved analysis lacks mandatory sensitivity coverage; start a new workflow" ) + if previous_review is not None: + diagnostic_reuse("diagnostic.reviewEvidence", "restored") experiments = ( previous_review["inputs"]["experiments"] if previous_review is not None @@ -1668,8 +1934,12 @@ def review( "currentCandidateId": selected.candidateId, "alternativeCandidateId": candidate.candidateId, "changedParameter": changes, - "partitionEvidence": partition_comparison_evidence( - self.store, selected, candidate + "partitionEvidence": diagnostic_call( + "diagnostic.partitionComparison", + partition_comparison_evidence, + self.store, + selected, + candidate, ) if previous_review is None else {}, @@ -1721,11 +1991,15 @@ def review( ) pair = (left_id, right_id) if pair not in self.neighbor_comparisons: - self.neighbor_comparisons[pair] = _neighbor_overlap( + self.neighbor_comparisons[pair] = diagnostic_call( + "diagnostic.neighborOverlap", + _neighbor_overlap, self.store, artifact_model_to_ref(selected_neighbors), artifact_model_to_ref(other_neighbors), ) + else: + diagnostic_reuse("diagnostic.neighborOverlap", "cacheHits") comparisons.append( { "leftCandidateId": selected.candidateId, @@ -1737,7 +2011,11 @@ def review( declared_image_input = _configured_image_input(self.owner.model) capability_key = "parameter_tuning/structured_evidence" capability_inputs = { - **self.provenance, + **( + self.previous_provenances[0] + if self.previous_provenances + else self.provenance + ), "configuredImageInput": declared_image_input, } capability = journal.load_checkpoint( @@ -1762,10 +2040,12 @@ def review( if capability is not None and capability.get("evidenceMode") != "structured": raise ValueError("The recorded model capability is invalid") mode = ( - previous_review["inputs"].get("evidenceMode") - if previous_review is not None + committed_review["inputs"].get("evidenceMode") + if committed_review is not None else "structured" if capability is not None + else previous_review["inputs"].get("evidenceMode") + if previous_review is not None else "visual" ) if mode not in {"visual", "structured"}: @@ -1774,8 +2054,27 @@ def review( ) visual_inspection = "available" if mode == "visual" else "unavailable" images = [] - if previous_review is not None: + if ( + previous_review is not None + and previous_review["inputs"].get("evidenceMode") == mode + ): image_hashes = previous_review["inputs"].get("imageHashes", {}) + if prepared is not None: + images = [ + ImageEvidence( + identifier=item["identifier"], + data=base64.b64decode(item["dataBase64"], validate=True), + media_type=item["mediaType"], + ) + for item in prepared["images"] + ] + if { + item.identifier: hashlib.sha256(item.data).hexdigest() + for item in images + } != image_hashes: + raise ValueError( + "Saved review images do not match their exact evidence hashes" + ) elif mode == "visual": images = _analysis_visual_content( self.store, @@ -1798,7 +2097,12 @@ def review( ): raise ValueError("Review images do not match its evidence mode") evidence_ids = ( - previous_review["inputs"]["availableEvidenceIds"] + [ + identifier + for identifier in previous_review["inputs"]["availableEvidenceIds"] + if identifier not in previous_review["inputs"].get("imageHashes", {}) + or identifier in image_hashes + ] if previous_review is not None else list( dict.fromkeys( @@ -1835,9 +2139,9 @@ def review( "currentCandidateId": selected.candidateId, "candidates": candidate_evidence, "settings": setting_evidence, - "featureEvidence": { - item.candidateId: self.feature_evidence(item) for item in candidates - }, + "featureEvidence": previous_review["inputs"]["featureEvidence"] + if previous_review is not None + else {item.candidateId: self.feature_evidence(item) for item in candidates}, "neighborComparisons": comparisons, "harmonyGates": { item.candidateId: self.harmony_gate(scope, item) @@ -1853,7 +2157,9 @@ def review( }, "limits": self.budget.summary()["limits"], }, - "fullRepairsUsed": self.full_repairs, + "fullRepairsUsed": previous_review["inputs"]["fullRepairsUsed"] + if previous_review is not None + else self.full_repairs, "comparisonCoverage": self.comparison_coverage( scope, artifact_model_to_ref(selected.cellSelection) ) @@ -1893,8 +2199,12 @@ def review( item.model_dump(mode="json") for item in self.design_comparisons ], "populationSupport": { - item.candidateId: population_support_evidence( - self.store, item, support_columns + item.candidateId: diagnostic_call( + "diagnostic.populationSupport", + population_support_evidence, + self.store, + item, + support_columns, ) for item in candidates if scope == "full" or item.candidateId == selected.candidateId @@ -1922,8 +2232,22 @@ def review( evidence["assessmentContext"] = previous_review["inputs"][ "assessmentContext" ] + if committed_review is None: + evidence["comparisonAdvantages"] = comparison_advantages( + evidence["comparisonCoverage"] + ) + elif "comparisonAdvantages" in committed_review["inputs"]: + evidence["comparisonAdvantages"] = committed_review["inputs"][ + "comparisonAdvantages" + ] def validate(action: TuningAction, *, replay: bool = False) -> TuningAction: + if not replay: + action = TuningAction.model_validate( + bind_comparison_measurements( + evidence["comparisonCoverage"], action.model_dump(mode="json") + ) + ) by_id = {item.candidateId: item for item in candidates} if action.selectedCandidateId not in by_id: raise ValueError("Choose an observed candidate from the current cells") @@ -2051,14 +2375,13 @@ def validate(action: TuningAction, *, replay: bool = False) -> TuningAction: raise ValueError( "Native acceptance leaves required correction unresolved; provide more evidence or defer" ) - if self.study.correctionLicense == "safe" and action.correctionNeed in { - "needed", - "uncertain", - }: + if self.study.correctionLicense == "safe": chosen_setting = self.settings[chosen.candidateId] has_comparison = any( item.parameters.useHarmony and item.status == "done" + and item.cellSelection == chosen.cellSelection + and item.harmonyBatchColumns == self.batch_columns and self.settings[item.candidateId].features == chosen_setting.features and item.parameters.model_dump( @@ -2071,7 +2394,10 @@ def validate(action: TuningAction, *, replay: bool = False) -> TuningAction: ) if not has_comparison: raise ValueError( - "Safe but uncertain/needed correction requires a matched Harmony experiment" + "A safe correction design requires a completed, matched Harmony experiment " + "before acceptance, including a notNeeded conclusion. " + "Evaluate the offered useHarmony:true experiment or defer; " + "the comparison must use the same cells, features, settings, and approved batch columns." ) if self.study.correctionLicense == "indeterminate": raise ValueError( @@ -2121,6 +2447,24 @@ def validate(action: TuningAction, *, replay: bool = False) -> TuningAction: action = validate(TuningAction.model_validate(answer)) self.answer_consumed = True else: + journal.save_checkpoint( + self.store, + self.prefix, + self.workflow.workflowRunId, + f"{key}/evidence/{mode}", + inputs=prepared_inputs, + outputs={ + "evidence": evidence, + "images": [ + { + "identifier": item.identifier, + "mediaType": item.media_type, + "dataBase64": base64.b64encode(item.data).decode("ascii"), + } + for item in images + ], + }, + ) prompt = ( "Assess this RNA analysis as a computational biologist against the exact study objective. " "Start from Scarf defaults; keep them when observed quantitative evidence and biological interpretation support them. " @@ -2131,7 +2475,7 @@ def validate(action: TuningAction, *, replay: bool = False) -> TuningAction: "In phase=sensitivity, use action=combine and choose each combinedSettings field from an observed candidate on that axis. A combination is a proposed hypothesis, not an observed result. " "Scarf will execute it and compare four resolutions on its exact graph before acceptance. Do not request already covered settings as experiments. " "comparisonConclusions must include quantitativeReason, biologicalReason and a short plainLanguageSummary for each axis. Explain how split or merged marker programs serve the stated objective, not just larger clusters or a single numerical maximum. " - "For each alternative with higher seedStability, subsampleStability, markerCoherence, markerSpecificityMedian or macroF1 than the stated preference, include a tradeoffs entry naming alternativeCandidateId, metric, exact preferredValue/alternativeValue and interpretation. These measurements require explanation, not automatic winner selection. " + "comparisonAdvantages enumerates the exact measured advantages for each possible preference on every axis. For each row belonging to your preference, include a tradeoffs entry naming alternativeCandidateId, metric and interpretation. Scarf attaches the exact saved values; do not transcribe numbers into tradeoff fields. Address every listed advantage, including small differences; these require explanation, not automatic winner selection. " "For each selected cluster with empty topMarkerGenes, populationConcerns must name candidateId, clusterId, cited evidenceIds and explain whether it is a nonEssentialLimitation or unresolvedEssential. An unresolved essential population blocks acceptance; do not invent marker support. " "The action plainLanguageSummary should state the selected settings, what evidence changed the choice, and any unresolved population interpretations without workflow jargon. " "QC/capture retention, batch associations per PC and protected biological structure. " @@ -2139,8 +2483,9 @@ def validate(action: TuningAction, *, replay: bool = False) -> TuningAction: "If a specific concern warrants testing, choose exactly one offered experiment and state its expected improvement and " "what objective-relevant biology must be preserved. Family policy remains revisable when later evidence warrants it. " "PCA, HVG, k and resolution changes are separate comparisons. Do not equate a small sampled cluster with an artifact. " - "For a safe design license, needed or uncertain correction requires a matched Harmony experiment. " - "A notNeeded choice needs observed native batch/PC and biological evidence. Unsafe or unknown design is never authorization. " + "For a safe design license, a matched Harmony experiment is required before acceptance, even when correction initially appears unnecessary. " + "A notNeeded conclusion must follow the observed native/Harmony comparison, including batch/PC and biological evidence. " + "Keep the native representation when the comparison supports it; evaluating Harmony does not require selecting it. Unsafe or unknown design is never authorization. " "Only accepting a Harmony representation requires a matched Harmony gate; a native representation does not require one. " "When correctionLicense is unsafeConfounded, do not infer correction necessity from batch mixing or PCA association. " "Use notApplicable for the prohibited correction, retain the confounding limitation, and assess whether native descriptive population discovery satisfies the objective. " @@ -2185,6 +2530,13 @@ def validate(action: TuningAction, *, replay: bool = False) -> TuningAction: config=self.request.config.agentRunConfig, name=f"rna_{scope}_assessment", output_validator=validate, + on_attempt=journal.model_attempt_callback( + self.store, + self.prefix, + self.workflow.workflowRunId, + key, + evidence, + ), ) except ImageInputUnsupportedError: if mode != "visual": @@ -2249,12 +2601,41 @@ def assess_scope( ) -> tuple[ Literal["accept", "enlarge", "defer"], ParameterCandidateEvaluation | None ]: - coverage, insufficient = screening_coverage( + coverage_inputs = { + "parentCells": self.cells.to_dict(), + "cells": cells.to_dict(), + "columns": self.coverage_columns, + "protectedCombinations": self.study.protectedCombinations, + "columnKinds": self.study.columnKinds, + "provenance": self.provenance, + } + measured = journal.load_checkpoint( self.store, - self.cells, - cells, - self.coverage_columns, - self.study.protectedCombinations, + self.prefix, + self.workflow.workflowRunId, + f"{self.checkpoint_scope(scope)}/coverage", + inputs=coverage_inputs, + ) + if measured is None: + coverage, insufficient = screening_coverage( + self.store, + self.cells, + cells, + self.coverage_columns, + self.study.protectedCombinations, + ) + measured = journal.save_checkpoint( + self.store, + self.prefix, + self.workflow.workflowRunId, + f"{self.checkpoint_scope(scope)}/coverage", + inputs=coverage_inputs, + outputs={"coverage": coverage, "insufficient": insufficient}, + ) + coverage, insufficient = measured["coverage"], measured["insufficient"] + logger.info( + f"Analysis population: {coverage['screeningCells']:,} of {coverage.get('populationCells', self.handoff.nCells):,} retained cells; " + + ("all retained cells." if cells == self.cells else "screening subset.") ) self.scope_sizes[scope] = coverage["screeningCells"] self.history.append( @@ -2263,6 +2644,12 @@ def assess_scope( if cells != self.cells and insufficient: return "enlarge", None if initial is None: + if scope == "full": + raise CandidateBudgetExceeded( + "No supported screening proposal is available for targeted full-cohort recovery. " + "The mandatory discovery comparisons cannot be replaced by a full-cohort baseline; " + "saved results and unmet coverage requirements are preserved." + ) baseline = self.baseline().model_copy( update={ "parameters": self.baseline().parameters.model_copy( @@ -2284,7 +2671,24 @@ def assess_scope( self.resolution_candidates.pop(scope, None) self._sensitivity_panel(scope, cells, selected) else: - selected = self.execute_matched(scope, cells, initial) + requested = initial + if self.study.correctionLicense == "safe": + initial = initial.model_copy( + update={ + "parameters": initial.parameters.model_copy( + update={"useHarmony": True} + ) + } + ) + selected = ( + self._resolution_panel(scope, cells, initial) + if scope == "full" and self.recovery_scope is not None + else self.execute_matched(scope, cells, initial) + ) + if scope == "full" and self.recovery_scope is not None: + self.combined_candidates[scope] = selected.candidateId + if not requested.parameters.useHarmony and initial.parameters.useHarmony: + selected = self.execute(scope, cells, requested) limit = ( self.request.config.maxFullPartitions if scope == "full" @@ -2324,24 +2728,19 @@ def assess_scope( return action.action, selected if action.action == "combine": setting = self._combined_setting(scope, review_index, action, cells) - selected = self.execute(scope, cells, setting) - selected = self._resolution_panel( - scope, cells, self.settings[selected.candidateId] - ) - self.combined_candidates[scope] = selected.candidateId - if self.study.correctionLicense == "safe" and action.correctionNeed in { - "needed", - "uncertain", - }: - setting = self.settings[selected.candidateId].model_copy( + if self.study.correctionLicense == "safe": + setting = setting.model_copy( update={ - "parameters": selected.parameters.model_copy( + "parameters": setting.parameters.model_copy( update={"useHarmony": True} ) } ) - selected = self._resolution_panel(scope, cells, setting) - self.combined_candidates[scope] = selected.candidateId + else: + selected = self.execute(scope, cells, setting) + setting = self.settings[selected.candidateId] + selected = self._resolution_panel(scope, cells, setting) + self.combined_candidates[scope] = selected.candidateId continue assert action.experimentId is not None experiment = self.experiments(selected)[action.experimentId] @@ -2353,11 +2752,14 @@ def assess_scope( ) and experiment["parameter"] != "useHarmony" ): - if self.full_repairs >= self.request.config.maxFullRepairs: - raise CandidateBudgetExceeded( - "The allowed full-cohort repair has been used; scientific acceptance remains unresolved" - ) - self.full_repairs += 1 + self.budget.admit_repair( + ArtifactReferenceModel.from_artifact_ref(cells).model_dump( + mode="json" + ), + self.settings[selected.candidateId].model_dump(mode="json"), + experiment, + ) + self.full_repairs = len(self.budget.repairs) if experiment["parameter"] in { "hvgRanking", "hvgCount", @@ -2428,7 +2830,19 @@ def assess_scope( }, ) continue - selected = self.execute_matched(scope, cells, setting) + selected = ( + self._resolution_panel(scope, cells, setting) + if scope == "full" + and self.recovery_scope is not None + and experiment["parameter"] != "leidenResolution" + else self.execute_matched(scope, cells, setting) + ) + if ( + scope == "full" + and self.recovery_scope is not None + and experiment["parameter"] != "leidenResolution" + ): + self.combined_candidates[scope] = selected.candidateId if pending_policy is not None and policy_experiment: pending_policy.update( status="completed", @@ -2456,56 +2870,92 @@ def assess_scope( return "defer", selected def run(self) -> tuple[ParameterTuningReport, dict[str, Any]]: - selected: ParameterCandidateEvaluation | None = None - final_status = "defer" - reason = "Required scientific evidence remains unresolved." - try: - initial: RnaSetting | None = None - previous_sample = None - for index, size in enumerate( - ( - self.request.config.screeningCells, - self.request.config.maxScreeningCells, - ) - ): - sample = uniform_screening_selection( + with journal.diagnostic_attempt( + self.store, + self.prefix, + self.workflow.workflowRunId, + {"provenance": self.provenance, "cells": self.cells.to_dict()}, + ) as counts: + self.diagnostic_counts = counts + selected: ParameterCandidateEvaluation | None = None + final_status = "defer" + reason = "Required scientific evidence remains unresolved." + try: + initial: RnaSetting | None = None + recovery_candidate: ParameterCandidateEvaluation | None = None + previous_sample = None + sizes = screening_sizes(self.handoff.nCells, self.request.config) + journal.save_checkpoint( self.store, - self.cells, - size=size, - seed=self.request.config.randomSeed, - ) - if sample == previous_sample: - break - previous_sample = sample - status, screened = self.assess_scope(f"sample{index}", sample, None) - if status == "accept" and screened is not None: - initial = self.settings[screened.candidateId] - self.discovery_scope = f"sample{index}" - break - if status == "defer": - return self.report( - None, - self.last_action.rationale - if self.last_action is not None - else reason, - ), self.summary() - if sample == self.cells: - return self.report( - None, - "All retained cells were assessed, but the required scientific evidence remains unresolved; another sample cannot resolve this concern.", - ), self.summary() - if initial is None: - logger.info( - "Both bounded discovery populations lack adequate support; assessing the bounded full baseline." + self.prefix, + self.workflow.workflowRunId, + "parameter_tuning/sampling_policy", + inputs={ + "cells": self.cells.to_dict(), + "config": self.request.config.model_dump(mode="json"), + }, + outputs={ + "policy": "fixed" + if self.request.config.screeningCells is not None + else "boundedTenPercent", + "retainedCells": self.handoff.nCells, + "populationSizes": list(sizes), + "seed": self.request.config.randomSeed, + }, ) - final_status, selected = self.assess_scope("full", self.cells, initial) - if final_status != "accept" and self.last_action is not None: - reason = self.last_action.rationale - except CandidateBudgetExceeded as exc: - reason = f"Required comparison coverage is incomplete within the configured work limits: {exc}" - if final_status != "accept": - selected = None - return self.report(selected, reason), self.summary() + for index, size in enumerate(sizes): + sample = uniform_screening_selection( + self.store, + self.cells, + size=size, + seed=self.request.config.randomSeed, + ) + if sample == previous_sample: + break + previous_sample = sample + status, screened = self.assess_scope(f"sample{index}", sample, None) + if ( + status == "enlarge" + and screened is not None + and f"sample{index}" in self.combined_candidates + ): + recovery_candidate = screened + self.recovery_scope = f"sample{index}" + if status == "accept" and screened is not None: + initial = self.settings[screened.candidateId] + self.discovery_scope = f"sample{index}" + self.recovery_scope = None + break + if status == "defer": + return self.report( + None, + self.last_action.rationale + if self.last_action is not None + else reason, + ), self.summary() + if sample == self.cells: + return self.report( + None, + "All retained cells were assessed, but the required scientific evidence remains unresolved; another sample cannot resolve this concern.", + ), self.summary() + if initial is None: + if recovery_candidate is None: + return self.report( + None, + "Screening coverage remains inadequate and no measured combined proposal is available for targeted recovery within the full-cohort allowance. Saved evidence is preserved; essential comparisons remain unresolved.", + ), self.summary() + initial = self.settings[recovery_candidate.candidateId] + logger.info( + "Screening evidence requires targeted full-cohort validation of the agent's proposed settings; previous comparisons remain available." + ) + final_status, selected = self.assess_scope("full", self.cells, initial) + if final_status != "accept" and self.last_action is not None: + reason = self.last_action.rationale + except CandidateBudgetExceeded as exc: + reason = f"Required comparison coverage is incomplete within the configured work limits: {exc}" + if final_status != "accept": + selected = None + return self.report(selected, reason), self.summary() def summary(self) -> dict[str, Any]: budget = self.budget.summary() @@ -2568,6 +3018,14 @@ def summary(self) -> dict[str, Any]: f"{sum(subsample_evaluations.values())} subsample-stability evaluations. " "Saved evidence may be reused; these are not computation counts." ) + for operation, counts in sorted(self.diagnostic_counts.items()): + logger.debug( + f"Diagnostic work this invocation, {operation}: " + f"{counts['completed']}/{counts['attempted']} calls completed/attempted, " + f"{counts['failed']} failed; {counts['cacheHits']} metric cache hits, " + f"{counts['restored']} saved evidence restores, " + f"{counts['artifactReuses']} confirmed artifact reuses." + ) return { "history": self.history, "budget": budget, @@ -2580,6 +3038,16 @@ def summary(self) -> dict[str, Any]: ), }, "fullRepairs": self.full_repairs, + "diagnosticOperations": { + "scope": "thisInvocation", + "operations": self.diagnostic_counts, + "interpretation": ( + "Observed agent calls and explicit reuse events, not inferred core " + "numerical rebuilds or simulated-doublet writes. Earlier invocations " + "are recorded separately in the stage journal; absent historical " + "operation records mean unknown counts." + ), + }, } def report( diff --git a/scarf/agent/orchestrator/tuning.py b/scarf/agent/orchestrator/tuning.py index 7414227e..c1cc1ed8 100644 --- a/scarf/agent/orchestrator/tuning.py +++ b/scarf/agent/orchestrator/tuning.py @@ -711,6 +711,103 @@ def tagged_gene(gene: str) -> str: return content +def _tuning_revision_provenances( + store: DataStore, + prefix: str, + workflow_run_id: str, + request: OrchestrationRequestRecord, + experimental_reference: StageEvidenceReference, + inputs: dict[str, Any], + previous_starts: Sequence[WorkflowStageAttempt], +) -> list[dict[str, Any]]: + """Authorize changed interpretation only along committed context revisions.""" + previous_inputs = [ + { + key: value + for key, value in previous.inputs.items() + if key not in {"resumeAnswers", "answeredAttempt"} + } + for previous in previous_starts + ] + changed = [value for value in previous_inputs if value != inputs] + if not changed: + return [] + contexts = journal._stage_outcomes( + store.zw, prefix, workflow_run_id, "experimental_context" + ) + current = next( + (row for row in contexts if experimental_reference in row.reportReferences), + None, + ) + if ( + current is None + or current.status != "done" + or current.outputs.get("studyContract") != inputs["studyContract"] + or current.requestSha256 != request.requestSha256 + or current.configSha256 != request.configSha256 + ): + raise ValueError( + "Tuning inputs changed; changed evidence requires its exact completed context revision" + ) + ancestors: list[dict[str, Any]] = [] + visited: set[str] = set() + while current is not None and "reassessContextReport" in current.inputs: + if current.attemptId in visited: + raise ValueError("Context revision ancestry contains a cycle") + visited.add(current.attemptId) + previous_ref = current.inputs["reassessContextReport"] + current = next( + ( + row + for row in contexts + if any( + ref.model_dump(mode="json") == previous_ref + for ref in row.reportReferences + ) + ), + None, + ) + if current is None or current.status != "done": + raise ValueError( + "Context revision is missing its committed previous evidence" + ) + if ( + current.requestSha256 != request.requestSha256 + or current.configSha256 != request.configSha256 + ): + raise ValueError( + "Context revision belongs to different request or configuration inputs" + ) + ancestors.append(current.outputs["studyContract"]) + authorized: list[dict[str, Any]] = [] + for previous in changed: + if ( + previous.get("studyContract") not in ancestors + or previous.get("preprocessedAssays") != inputs["preprocessedAssays"] + or previous.get("featureMetadataFingerprints") + != inputs["featureMetadataFingerprints"] + or any( + inputs["metadataFingerprints"].get(column) != fingerprint + for column, fingerprint in previous.get( + "metadataFingerprints", {} + ).items() + ) + or set(previous) != set(inputs) + ): + raise ValueError( + "Tuning inputs changed beyond an explicit context-evidence revision; " + "restore the original cohort, feature inputs and metadata or start a new workflow" + ) + provenance = { + **previous, + "requestSha256": request.requestSha256, + "configSha256": request.configSha256, + } + if provenance not in authorized: + authorized.append(provenance) + return authorized + + class TuningStagesMixin(DecisionStagesMixin): """Run bounded experiments and return validated full-cohort artifacts.""" @@ -733,7 +830,7 @@ def parameter_tuning_stage( ) -> tuple[WorkflowStageAttempt, ParameterTuningReport]: from .rna_tuning import RnaTuningRun - del enrichment_reference, experimental_reference + del enrichment_reference if len(preprocessed) != 1 or study_contract is None: raise ValueError("RNA tuning requires one assay and a study contract") handoff = preprocessed[0] @@ -765,19 +862,15 @@ def parameter_tuning_stage( "featureMetadataFingerprints": feature_fingerprints, } prefix = journal._ensure_orchestration_store(store) - for previous in journal._stage_starts( - store.zw, prefix, workflow.workflowRunId, stage_name - ): - scientific_inputs = { - key: value - for key, value in previous.inputs.items() - if key not in {"resumeAnswers", "answeredAttempt"} - } - if scientific_inputs != inputs: - raise ValueError( - "Tuning inputs changed since saved evidence was computed; " - "restore the original metadata or start a new workflow" - ) + previous_provenances = _tuning_revision_provenances( + store, + prefix, + workflow.workflowRunId, + request_record, + experimental_reference, + inputs, + journal._stage_starts(store.zw, prefix, workflow.workflowRunId, stage_name), + ) existing = journal._validated_done_outcome( store, prefix, @@ -816,6 +909,7 @@ def parameter_tuning_stage( "configSha256": request_record.configSha256, }, design_comparisons=experimental.characterization.comparisons, + previous_provenances=previous_provenances, ) try: with candidate_metric_cache(): diff --git a/scarf/agent/parameter_tuning/agent.py b/scarf/agent/parameter_tuning/agent.py index f80cd137..792ef0cb 100644 --- a/scarf/agent/parameter_tuning/agent.py +++ b/scarf/agent/parameter_tuning/agent.py @@ -528,7 +528,6 @@ def tune_parameters_batch( output_token_limit=32768, timeout_seconds=600.0, ) - refinement_planning_failed = False if any(max_refined_by_assay.values()): try: logger.info( @@ -561,7 +560,11 @@ def tune_parameters_batch( "Batched parameter refinement model run failed within its bounds " f"({type(exc).__name__}); pausing without a refinement decision" ) - refinement_planning_failed = True + failed_info = getattr( + exc, + "agent_run_info", + AgentRunInfo(agentName="parameter_batch_search_planning_needs_input"), + ) failed_plans = { assay: ParameterSearchPlan( status="complete", @@ -594,18 +597,19 @@ def tune_parameters_batch( stoppingCriteria=[ "Obtain a grounded refinement disposition before selection." ], - runInfo=AgentRunInfo( - agentName="parameter_batch_search_planning_needs_input" - ), + runInfo=failed_info, ) for assay in assay_names } batch_plan = ParameterTuningBatchSearchPlan( assayPlans=failed_plans, - runInfo=AgentRunInfo( - agentName="parameter_batch_search_planning_needs_input" - ), + runInfo=failed_info, ) + return pending_parameter_tuning_batch_report( + dependencies, + search_plans=batch_plan.assayPlans, + primary_assay=resolved_primary, + ).model_copy(update={"runInfo": failed_info}) else: if not isinstance( planning_execution.output, ParameterTuningBatchSearchPlan @@ -695,15 +699,17 @@ def tune_parameters_batch( dependencies, search_plans=batch_plan.assayPlans, primary_assay=resolved_primary, + ).model_copy( + update={ + "runInfo": getattr( + exc, + "agent_run_info", + AgentRunInfo(agentName="parameter_tuning_batch_needs_input"), + ) + } ) if not isinstance(selection_execution.output, ParameterTuningReport): raise TypeError("Batched parameter tuning returned an unexpected type") - if refinement_planning_failed: - return pending_parameter_tuning_batch_report( - dependencies, - search_plans=batch_plan.assayPlans, - primary_assay=resolved_primary, - ) report = validate_parameter_tuning_batch_report( selection_execution.output, dependencies, @@ -768,7 +774,6 @@ def tune_parameters( deps.evaluations[candidate_id] for candidate_id in initial_candidate_ids ] - refinement_planning_failed = False if max_refined_candidates == 0: logger.info( f"Skipping parameter refinement for assay {from_assay!r} because it " @@ -843,10 +848,17 @@ def tune_parameters( stoppingCriteria=[ "Obtain a grounded refinement disposition before selection." ], - runInfo=AgentRunInfo(agentName="parameter_search_planning_needs_input"), + runInfo=getattr( + exc, + "agent_run_info", + AgentRunInfo(agentName="parameter_search_planning_needs_input"), + ), ) - refinement_planning_failed = True - plan = failed_plan + return pending_parameter_tuning_report( + deps, + search_plan=failed_plan, + agent_name="parameter_search_planning_needs_input", + ).model_copy(update={"runInfo": failed_plan.runInfo}) else: if not isinstance(planning_execution.output, ParameterSearchPlan): raise TypeError( @@ -908,15 +920,17 @@ def tune_parameters( deps, search_plan=plan, agent_name="parameter_tuning_needs_input", + ).model_copy( + update={ + "runInfo": getattr( + exc, + "agent_run_info", + AgentRunInfo(agentName="parameter_tuning_needs_input"), + ) + } ) if not isinstance(selection_execution.output, ParameterTuningReport): raise TypeError("Parameter tuning agent returned an unexpected output type") - if refinement_planning_failed: - return pending_parameter_tuning_report( - deps, - search_plan=plan, - agent_name="parameter_tuning_needs_input", - ) report = validate_parameter_tuning_report( selection_execution.output, deps, diff --git a/scarf/agent/parameter_tuning/comparisons.py b/scarf/agent/parameter_tuning/comparisons.py index bfee0445..8d91e3b2 100644 --- a/scarf/agent/parameter_tuning/comparisons.py +++ b/scarf/agent/parameter_tuning/comparisons.py @@ -2,7 +2,7 @@ from collections.abc import Mapping from collections import Counter -from typing import Any, Literal +from typing import Any, Literal, get_args from pydantic import Field import numpy as np @@ -45,6 +45,91 @@ class ComparisonTradeoff(AgentDataModel): interpretation: str = Field(min_length=1) +def comparison_advantages(coverage: Mapping[str, Any]) -> list[dict[str, Any]]: + """Enumerate exact measured counterevidence for each observed preference.""" + settings = coverage["candidateSettings"] + axes: dict[str, set[str]] = {} + for comparison in coverage["comparisons"]: + identifiers = axes.setdefault(comparison["axis"], set()) + identifiers.add(comparison["baselineCandidateId"]) + if comparison["status"] == "completed": + identifiers.add(comparison["alternativeCandidateId"]) + rows = [] + metrics = get_args(ComparisonTradeoff.model_fields["metric"].annotation) + for axis, identifiers in sorted(axes.items()): + for preferred_id in sorted(identifiers): + preferred = settings[preferred_id]["metrics"] + for alternative_id in sorted(identifiers - {preferred_id}): + alternative = settings[alternative_id]["metrics"] + for metric in metrics: + left, right = preferred.get(metric), alternative.get(metric) + if ( + isinstance(left, (int, float)) + and not isinstance(left, bool) + and isinstance(right, (int, float)) + and not isinstance(right, bool) + and np.isfinite(left) + and np.isfinite(right) + and right > left + ): + rows.append( + { + "axis": axis, + "preferredCandidateId": preferred_id, + "alternativeCandidateId": alternative_id, + "metric": metric, + "preferredValue": float(left), + "alternativeValue": float(right), + "difference": float(right - left), + } + ) + return rows + + +def bind_comparison_measurements( + coverage: Mapping[str, Any], action: Mapping[str, Any] +) -> dict[str, Any]: + """Attach measured values to model-authored interpretations, never rationales.""" + inventory = { + ( + row["axis"], + row["preferredCandidateId"], + row["alternativeCandidateId"], + row["metric"], + ): row + for row in comparison_advantages(coverage) + } + conclusions = [] + for conclusion in action.get("comparisonConclusions", []): + tradeoffs = [] + for interpretation in conclusion.get("tradeoffs", []): + key = ( + conclusion["axis"], + conclusion["preferredCandidateId"], + interpretation["alternativeCandidateId"], + interpretation["metric"], + ) + measured = inventory.get(key) + if measured is None: + raise ValueError( + f"Tradeoff does not identify an observed advantage: {key!r}" + ) + values = { + field: measured[field] + for field in ("preferredValue", "alternativeValue") + } + if any( + interpretation.get(field) is not None and interpretation[field] != value + for field, value in values.items() + ): + raise ValueError( + f"Tradeoff must use exact preferred and alternative measurements for {key!r}: {values!r}" + ) + tradeoffs.append({**interpretation, **values}) + conclusions.append({**conclusion, "tradeoffs": tradeoffs}) + return {**action, "comparisonConclusions": conclusions} + + class PopulationConcern(AgentDataModel): """Keep unsupported population interpretations explicit in acceptance.""" @@ -349,6 +434,8 @@ def validate_comparison_review( raise ValueError( "Conclude every required comparison axis before combining or accepting" ) + inventory = comparison_advantages(coverage) + tradeoff_errors: list[str] = [] for axis, ids in axis_candidates.items(): conclusion = by_axis[axis] if not ids.issubset(conclusion.candidateIds) or not set( @@ -361,30 +448,27 @@ def validate_comparison_review( raise ValueError( "A comparison preference must name its observed candidate" ) - preferred = settings[conclusion.preferredCandidateId]["metrics"] required_tradeoffs = { - (identifier, metric): (float(preferred[metric]), float(value)) - for identifier in ids - {conclusion.preferredCandidateId} - for metric, value in settings[identifier]["metrics"].items() - if metric - in { - "seedStability", - "subsampleStability", - "markerCoherence", - "markerSpecificityMedian", - "macroF1", - } - and isinstance(value, (int, float)) - and isinstance(preferred.get(metric), (int, float)) - and value > preferred[metric] + (row["alternativeCandidateId"], row["metric"]): ( + row["preferredValue"], + row["alternativeValue"], + ) + for row in inventory + if row["axis"] == axis + and row["preferredCandidateId"] == conclusion.preferredCandidateId } supplied = { (row.alternativeCandidateId, row.metric): row for row in conclusion.tradeoffs } - if not required_tradeoffs.keys() <= supplied.keys(): - raise ValueError( - "The comparison preference must explain each observed alternative's better stability, marker or separability measurement" + prefix = f"{axis}, preferred {conclusion.preferredCandidateId}" + if len(supplied) != len(conclusion.tradeoffs): + tradeoff_errors.append(f"{prefix}: duplicate tradeoff entries") + for key in sorted(required_tradeoffs.keys() - supplied.keys()): + left, right = required_tradeoffs[key] + tradeoff_errors.append( + f"{prefix}: explain alternative {key[0]} on {key[1]} " + f"(preferred={left!r}, alternative={right!r})" ) for key, row in supplied.items(): if ( @@ -392,9 +476,15 @@ def validate_comparison_review( or (row.preferredValue, row.alternativeValue) != required_tradeoffs[key] ): - raise ValueError( - "Comparison tradeoffs must quote the exact preferred and alternative measurements" + tradeoff_errors.append( + f"{prefix}: use exact preferred and alternative measurements " + f"for {key!r}; expected {required_tradeoffs.get(key)!r}" ) + if tradeoff_errors: + raise ValueError( + "Explain each observed alternative's better stability, marker or " + "separability measurement: " + "; ".join(tradeoff_errors) + ) if action["action"] == "combine": if coverage["phase"] != "sensitivity": raise ValueError( diff --git a/scarf/agent/parameter_tuning/contracts.py b/scarf/agent/parameter_tuning/contracts.py index cc18a20d..9284798e 100644 --- a/scarf/agent/parameter_tuning/contracts.py +++ b/scarf/agent/parameter_tuning/contracts.py @@ -14,6 +14,7 @@ try: from pydantic import Field + from pydantic.json_schema import SkipJsonSchema except ImportError as exc: raise ImportError(AGENT_INSTALL_HINT) from exc @@ -216,11 +217,11 @@ class FinalGraphSelection(AgentDataModel): status: StageStatus = "needsInput" selectedOptionId: str | None = None - graphMethod: Literal["native", "snn", "wnn"] | None = None - nativeAssay: str | None = None - nativeCandidateId: str | None = None - integrationId: str | None = None - markerAssay: str = "" + graphMethod: SkipJsonSchema[Literal["native", "snn", "wnn"] | None] = None + nativeAssay: SkipJsonSchema[str | None] = None + nativeCandidateId: SkipJsonSchema[str | None] = None + integrationId: SkipJsonSchema[str | None] = None + markerAssay: SkipJsonSchema[str] = "" confidence: TuningConfidence = "low" rationale: str = "" evidenceIds: list[str] = Field(default_factory=list) @@ -228,7 +229,7 @@ class FinalGraphSelection(AgentDataModel): tradeoffs: list[str] = Field(default_factory=list) limitations: list[str] = Field(default_factory=list) needsInput: FinalGraphNeedsInput | None = None - runInfo: AgentRunInfo = Field(default_factory=AgentRunInfo) + runInfo: SkipJsonSchema[AgentRunInfo] = Field(default_factory=AgentRunInfo) @classmethod def get_blank(cls) -> "FinalGraphSelection": @@ -270,7 +271,7 @@ class ParameterSearchPlan(AgentDataModel): rationale: str = "" evidenceIds: list[str] = Field(default_factory=list) stoppingCriteria: list[str] = Field(default_factory=list) - runInfo: AgentRunInfo = Field(default_factory=AgentRunInfo) + runInfo: SkipJsonSchema[AgentRunInfo] = Field(default_factory=AgentRunInfo) @classmethod def get_blank(cls) -> "ParameterSearchPlan": @@ -281,7 +282,7 @@ class ParameterTuningBatchSearchPlan(AgentDataModel): """One bounded refinement plan for every assay in a batched screen.""" assayPlans: dict[str, ParameterSearchPlan] = Field(default_factory=dict) - runInfo: AgentRunInfo = Field(default_factory=AgentRunInfo) + runInfo: SkipJsonSchema[AgentRunInfo] = Field(default_factory=AgentRunInfo) @classmethod def get_blank(cls) -> "ParameterTuningBatchSearchPlan": @@ -304,11 +305,15 @@ class ParameterTuningReport(AgentDataModel): """Grounded recommendation over candidate branches actually executed.""" status: StageStatus = "failed" - fromAssay: str = "" - cellSelection: ArtifactReferenceModel | None = None - evaluations: list[ParameterCandidateEvaluation] = Field(default_factory=list) + fromAssay: SkipJsonSchema[str] = "" + cellSelection: SkipJsonSchema[ArtifactReferenceModel | None] = None + evaluations: SkipJsonSchema[list[ParameterCandidateEvaluation]] = Field( + default_factory=list + ) recommendedCandidateId: str | None = None - selectedArtifacts: dict[str, ArtifactRecord] = Field(default_factory=dict) + selectedArtifacts: SkipJsonSchema[dict[str, ArtifactRecord]] = Field( + default_factory=dict + ) confidence: TuningConfidence = "low" rationale: str = "" evidenceIds: list[str] = Field(default_factory=list) @@ -317,20 +322,20 @@ class ParameterTuningReport(AgentDataModel): limitations: list[str] = Field(default_factory=list) stopReason: str = "" needsInput: ParameterTuningNeedsInput | None = None - searchPlan: ParameterSearchPlan | None = None + searchPlan: SkipJsonSchema[ParameterSearchPlan | None] = None assayReports: dict[str, "ParameterTuningReport"] = Field(default_factory=dict) - recommendedByAssay: dict[str, str] = Field(default_factory=dict) - totalCandidates: int = 0 - integrationEvaluations: list[IntegrationCandidateEvaluation] = Field( - default_factory=list + recommendedByAssay: SkipJsonSchema[dict[str, str]] = Field(default_factory=dict) + totalCandidates: SkipJsonSchema[int] = 0 + integrationEvaluations: SkipJsonSchema[list[IntegrationCandidateEvaluation]] = ( + Field(default_factory=list) ) - recommendedIntegrationId: str | None = None - finalClusterColumn: str | None = None - finalClusterArtifact: ArtifactRecord | None = None - graphAssay: str | None = None - markerAssay: str | None = None - finalSelection: FinalGraphSelection | None = None - runInfo: AgentRunInfo = Field(default_factory=AgentRunInfo) + recommendedIntegrationId: SkipJsonSchema[str | None] = None + finalClusterColumn: SkipJsonSchema[str | None] = None + finalClusterArtifact: SkipJsonSchema[ArtifactRecord | None] = None + graphAssay: SkipJsonSchema[str | None] = None + markerAssay: SkipJsonSchema[str | None] = None + finalSelection: SkipJsonSchema[FinalGraphSelection | None] = None + runInfo: SkipJsonSchema[AgentRunInfo] = Field(default_factory=AgentRunInfo) @classmethod def get_blank(cls) -> "ParameterTuningReport": diff --git a/scarf/agent/parameter_tuning/diagnostics.py b/scarf/agent/parameter_tuning/diagnostics.py index 4fcbc55b..96ee5779 100644 --- a/scarf/agent/parameter_tuning/diagnostics.py +++ b/scarf/agent/parameter_tuning/diagnostics.py @@ -37,7 +37,12 @@ from ...storage.types import as_zarr_array from ...utils.logging import logger from .contracts import ArtifactRecord, ParameterCandidateEvaluation -from .execution import _cached_candidate_metric, _metadata_column_fingerprint +from .execution import ( + _cached_candidate_metric, + _metadata_column_fingerprint, + diagnostic_call, + diagnostic_reuse, +) from .selection import annotate_candidate_dominance _PCA_DIAGNOSTIC_ARRAYS = ( @@ -93,7 +98,7 @@ def restore_advisory_doublets( captures = tuple(evaluation.metrics.doubletScoreByCapture) if len(captures) != len(score_keys): raise ValueError("Persisted doublet capture summaries do not align") - return AdvisoryDoubletScores( + restored = AdvisoryDoubletScores( scores=tuple(_artifact_ref(evaluation, key) for key in score_keys), cell_selections=tuple( _artifact_ref(evaluation, f"doubletCellSelection:{index}") @@ -113,6 +118,8 @@ def restore_advisory_doublets( warning for warning in evaluation.warnings if "doublet" in warning.lower() ), ) + diagnostic_reuse("diagnostic.advisoryDoublets", "restored") + return restored def _artifact_ref( @@ -720,7 +727,7 @@ def _write_pca_diagnostic( != group.attrs["payload_fingerprint"] ): raise ValueError("Stored PCA diagnostic payload fingerprint does not match") - return ( + restored = ( planned.ref, np.asarray( as_zarr_array(group["component_variance"], name="component_variance")[:] @@ -748,8 +755,14 @@ def _write_pca_diagnostic( )[:] ), ) - component_variance = _component_variance(coordinates) - total_scaled_variance = _scaled_total_variance( + diagnostic_reuse("diagnostic.pca", "artifactReuses") + return restored + component_variance = diagnostic_call( + "metric.pcaVariance", _component_variance, coordinates + ) + total_scaled_variance = diagnostic_call( + "metric.pcaScaledVariance", + _scaled_total_variance, store, reduction_status, n_rows=int(coordinates.shape[0]), @@ -761,14 +774,18 @@ def _write_pca_diagnostic( 0.0, 1.0, ) - top_indices, top_values, family_enrichment = _top_loadings( + top_indices, top_values, family_enrichment = diagnostic_call( + "metric.pcaLoadings", + _top_loadings, loadings, selected_indices, family_masks, ) covariate_support: dict[str, Any] = {} associations = ( - _covariate_associations( + diagnostic_call( + "metric.pcaCovariates", + _covariate_associations, store, ArtifactRef( scope=evaluation.cellSelection.scope, @@ -892,7 +909,9 @@ def augment_pca_evaluations( key=lambda value: value.parameters.dimensions, ): overlap = ( - _neighbor_overlap( + diagnostic_call( + "metric.neighborOverlap", + _neighbor_overlap, store, _artifact_ref(previous, "neighbors"), _artifact_ref(evaluation, "neighbors"), @@ -916,7 +935,9 @@ def augment_pca_evaluations( _top_values, family_enrichment, associations, - ) = _write_pca_diagnostic( + ) = diagnostic_call( + "diagnostic.pca", + _write_pca_diagnostic, store, evaluation, feature_selection=feature_selection, @@ -1124,7 +1145,9 @@ def _build_advisory_doublet_scores( ) if values.shape != selection_indices.shape: raise ValueError("Doublet scores do not align with their cell selection") - summary, sample = _bounded_score_summary( + summary, sample = diagnostic_call( + "metric.doubletScoreSummary", + _bounded_score_summary, values, maximum_sample_size=max( 1, @@ -1177,7 +1200,9 @@ def _select_capture_cells( selected = labels == str(value) expected = np.zeros(store.cells.N, dtype=bool) expected[active_indices] = selected - reference = store.filter_cells( + reference = diagnostic_call( + "core.captureSelection", + store.filter_cells, [column], [value], [value], @@ -1215,36 +1240,48 @@ def resolve_native_doublet_inputs( None, ) if exact_native is not None: - return ( + inputs = ( _artifact_ref(exact_native, "clusters"), _artifact_ref(exact_native, "connectivityMap"), ) + diagnostic_reuse("diagnostic.nativeDoubletInputs", "artifactReuses") + return inputs if not selected.parameters.useHarmony: - return ( + inputs = ( _artifact_ref(selected, "clusters"), _artifact_ref(selected, "connectivityMap"), ) + diagnostic_reuse("diagnostic.nativeDoubletInputs", "artifactReuses") + return inputs reduction = _artifact_ref(selected, "pca") - ann = store.build_ann_index( + ann = diagnostic_call( + "core.nativeDoubletAnn", + store.build_ann_index, reduction, ann_metric="l2", ann_parallel=False, rand_state=4444, invalidate_cache=False, ) - neighbors = store.query_neighbors( + neighbors = diagnostic_call( + "core.nativeDoubletNeighbors", + store.query_neighbors, ann, coordinates=reduction, k=parameters.neighborsK, invalidate_cache=False, ) - graph = store.build_connectivity_map( + graph = diagnostic_call( + "core.nativeDoubletGraph", + store.build_connectivity_map, neighbors, local_connectivity=1.0, bandwidth=1.5, invalidate_cache=False, ) - clusters = store.run_leiden_clustering( + clusters = diagnostic_call( + "core.nativeDoubletPartition", + store.run_leiden_clustering, graph, resolution=parameters.leidenResolution, backend="igraph", @@ -1301,7 +1338,9 @@ def score_advisory_doublets( ) limitations: list[str] = [] if capture_column is None or capture_column not in store.cells.columns: - score = store.run_doublet_detection( + score = diagnostic_call( + "core.doubletDetection", + store.run_doublet_detection, native_clusters, native_graph, from_assay=assay, @@ -1356,7 +1395,9 @@ def score_advisory_doublets( f"{_MAX_DOUBLET_CAPTURES} values" ) if len(capture_groups) == 1: - score = store.run_doublet_detection( + score = diagnostic_call( + "core.doubletDetection", + store.run_doublet_detection, native_clusters, native_graph, from_assay=assay, @@ -1400,39 +1441,51 @@ def score_advisory_doublets( "selected cells." ) continue - normalized = store.run_normalization( + normalized = diagnostic_call( + "core.captureNormalization", + store.run_normalization, capture_selection, features=feature_selection, log_transform=True, renormalize_subset=True, invalidate_cache=False, ) - reduction = store.run_pca( + reduction = diagnostic_call( + "core.capturePca", + store.run_pca, normalized, dims=dimensions, feat_scaling=True, invalidate_cache=False, ) - ann = store.build_ann_index( + ann = diagnostic_call( + "core.captureAnn", + store.build_ann_index, reduction, ann_metric="l2", ann_parallel=False, rand_state=4444, invalidate_cache=False, ) - neighbors = store.query_neighbors( + neighbors = diagnostic_call( + "core.captureNeighbors", + store.query_neighbors, ann, coordinates=reduction, k=neighbors_k, invalidate_cache=False, ) - graph = store.build_connectivity_map( + graph = diagnostic_call( + "core.captureGraph", + store.build_connectivity_map, neighbors, local_connectivity=1.0, bandwidth=1.5, invalidate_cache=False, ) - clusters = store.run_leiden_clustering( + clusters = diagnostic_call( + "core.capturePartition", + store.run_leiden_clustering, graph, resolution=selected.parameters.leidenResolution, backend="igraph", @@ -1442,7 +1495,9 @@ def score_advisory_doublets( invalidate_cache=False, ) scores.append( - store.run_doublet_detection( + diagnostic_call( + "core.doubletDetection", + store.run_doublet_detection, clusters, graph, from_assay=assay, @@ -1514,7 +1569,9 @@ def _doublet_concentration( summary = ( evidence.score_summaries[score_index] if evidence.score_summaries - else _bounded_score_summary( + else diagnostic_call( + "metric.doubletScoreSummary", + _bounded_score_summary, score_values, maximum_sample_size=65_536, )[0] @@ -1778,13 +1835,22 @@ def _subsample_partition_stability( selected = np.arange(len(labels)) % 5 != 0 if int(selected.sum()) < 3: selected = np.ones(len(labels), dtype=bool) - subsample_labels = leiden_membership( + subsample_labels = diagnostic_call( + "core.subsamplePartition", + leiden_membership, graph[selected][:, selected], resolution, 4444, backend="igraph", ) - return float(adjusted_rand_score(labels[selected], subsample_labels)) + return float( + diagnostic_call( + "metric.partitionAgreement", + adjusted_rand_score, + labels[selected], + subsample_labels, + ) + ) def augment_cluster_evaluations( @@ -1818,7 +1884,9 @@ def augment_cluster_evaluations( graph_ref = _artifact_ref(evaluation, "connectivityMap") clusters_ref = _artifact_ref(evaluation, "clusters") labels = _cluster_labels(store, clusters_ref) - alternative_ref = store.run_leiden_clustering( + alternative_ref = diagnostic_call( + "core.alternateSeedPartition", + store.run_leiden_clustering, graph_ref, resolution=evaluation.parameters.leidenResolution, backend="igraph", @@ -1830,7 +1898,11 @@ def augment_cluster_evaluations( alternative = _cluster_labels(store, alternative_ref) if alternative.shape != labels.shape: raise ValueError("Alternate-seed clusters do not align with the candidate") - seed_stability = float(adjusted_rand_score(labels, alternative)) + seed_stability = float( + diagnostic_call( + "metric.partitionAgreement", adjusted_rand_score, labels, alternative + ) + ) subsample_stability = _cached_candidate_metric( ( id(store), @@ -1850,7 +1922,9 @@ def augment_cluster_evaluations( marker_ref = None markers = pd.DataFrame() if cluster_count >= 2: - marker_ref = store.run_marker_search( + marker_ref = diagnostic_call( + "core.markers", + store.run_marker_search, clusters_ref, from_assay=marker_assay, features=marker_features, @@ -1939,7 +2013,9 @@ def augment_cluster_evaluations( artifact_id=evaluation.cellSelection.artifactId, ) doublet_concentration = ( - _doublet_concentration( + diagnostic_call( + "metric.doubletConcentration", + _doublet_concentration, store, labels, selection_ref, @@ -1953,7 +2029,9 @@ def augment_cluster_evaluations( for column in independent_unit_columns if column in store.cells.columns for score in [ - _cross_unit_support( + diagnostic_call( + "metric.crossUnitSupport", + _cross_unit_support, labels, _aligned_metadata(store, selection_ref, column), ) @@ -1963,7 +2041,9 @@ def augment_cluster_evaluations( cross_unit_support = min(unit_scores) if unit_scores else None technical_association = { column: float( - normalized_mutual_info_score( + diagnostic_call( + "metric.technicalAssociation", + normalized_mutual_info_score, labels, _aligned_metadata(store, selection_ref, column), ) diff --git a/scarf/agent/parameter_tuning/execution.py b/scarf/agent/parameter_tuning/execution.py index 126abc0f..64a245d8 100644 --- a/scarf/agent/parameter_tuning/execution.py +++ b/scarf/agent/parameter_tuning/execution.py @@ -3,7 +3,7 @@ from collections.abc import Callable, Iterator, Sequence from contextlib import contextmanager from contextvars import ContextVar -from typing import Any, cast +from typing import Any, Literal, cast import numpy as np @@ -36,6 +36,63 @@ _METRIC_CACHE: ContextVar[dict[tuple[Any, ...], Any] | None] = ContextVar( "scarf_candidate_metric_cache", default=None ) +_DIAGNOSTIC_WORK: ContextVar[dict[str, dict[str, int]] | None] = ContextVar( + "scarf_diagnostic_work", default=None +) + + +@contextmanager +def diagnostic_work() -> Iterator[dict[str, dict[str, int]]]: + """Count agent operation calls, not internal core numerical rebuilds.""" + counts: dict[str, dict[str, int]] = {} + token = _DIAGNOSTIC_WORK.set(counts) + try: + yield counts + finally: + _DIAGNOSTIC_WORK.reset(token) + + +def _diagnostic_count(name: str, event: str, count: int = 1) -> None: + counts = _DIAGNOSTIC_WORK.get() + if counts is not None: + row = counts.setdefault( + name, + dict.fromkeys( + ( + "attempted", + "completed", + "failed", + "cacheHits", + "restored", + "artifactReuses", + ), + 0, + ), + ) + row[event] += count + + +def diagnostic_call[**P, T]( + name: str, operation: Callable[P, T], *args: P.args, **kwargs: P.kwargs +) -> T: + """Record an invoked operation while preserving its result and exception.""" + _diagnostic_count(name, "attempted") + try: + result = operation(*args, **kwargs) + except BaseException: + _diagnostic_count(name, "failed") + raise + _diagnostic_count(name, "completed") + return result + + +def diagnostic_reuse( + name: str, + kind: Literal["cacheHits", "restored", "artifactReuses"] = "cacheHits", + count: int = 1, +) -> None: + """Record reuse only when the agent has directly established it.""" + _diagnostic_count(name, kind, count) @contextmanager @@ -49,11 +106,14 @@ def candidate_metric_cache() -> Iterator[None]: def _cached_candidate_metric[T](key: tuple[Any, ...], compute: Callable[[], T]) -> T: + name = f"metric.{key[1]}" cache = _METRIC_CACHE.get() if cache is None: - return compute() + return diagnostic_call(name, compute) if key not in cache: - cache[key] = compute() + cache[key] = diagnostic_call(name, compute) + else: + diagnostic_reuse(name) return cast(T, cache[key]) @@ -268,7 +328,9 @@ def run_candidate_reduction( identity_feature_limit=identity_feature_limit, ) if candidate.reductionMethod == "pca": - ref = store.run_pca( + ref = diagnostic_call( + "core.pca", + store.run_pca, normalized, dims=candidate.dimensions, feat_scaling=True, @@ -277,7 +339,9 @@ def run_candidate_reduction( ) return ref, "pca", effective_dimensions if candidate.reductionMethod == "lsi": - ref = store.run_lsi( + ref = diagnostic_call( + "core.lsi", + store.run_lsi, normalized, dims=candidate.dimensions, skip_first=True, @@ -286,7 +350,9 @@ def run_candidate_reduction( ) return ref, "lsi", effective_dimensions loadings = np.eye(normalized_shape[1], dtype=np.float64) - ref = store.run_custom_reduction( + ref = diagnostic_call( + "core.customReduction", + store.run_custom_reduction, loadings, normalized, invalidate_cache=False, @@ -348,7 +414,9 @@ def _collect_cluster_structure_metrics( return None membership_ref: ArtifactRef | None = None try: - membership_ref = calculate_membership( + membership_ref = diagnostic_call( + "core.membershipStrength", + calculate_membership, cluster_ref, graph_ref, invalidate_cache=False, @@ -439,7 +507,9 @@ def _collect_parameter_candidate_metrics( _RANDOM_SEED, 11, ), - lambda: store.metric_graph_silhouette( + lambda: diagnostic_call( + "core.graphSilhouette", + store.metric_graph_silhouette, neighbors_ref, cluster_ref, random_seed=_RANDOM_SEED, @@ -459,7 +529,9 @@ def _collect_parameter_candidate_metrics( try: def separability_values() -> dict[str, Any]: - separability = store.metric_cluster_separability( + separability = diagnostic_call( + "core.clusterSeparability", + store.metric_cluster_separability, reduction_ref, {cluster_column: cluster_ref}, random_seed=_RANDOM_SEED, @@ -491,6 +563,41 @@ def separability_values() -> dict[str, Any]: except (KeyError, TypeError, ValueError) as exc: warnings.append(f"PCA cluster separability unavailable: {exc}") + _collect_covariate_metrics( + deps, + candidate=candidate, + candidate_id=candidate_id, + neighbors_ref=neighbors_ref, + graph_ref=graph_ref, + metrics=metrics, + evidence_ids=evidence_ids, + warnings=warnings, + ) + + eligibility_reasons: list[str] = [] + if n_clusters < 2: + eligibility_reasons.append("fewer than two clusters") + if min_cluster_cells < deps.minClusterCells: + eligibility_reasons.append( + f"smallest cluster has {min_cluster_cells} cells; " + f"minimum is {deps.minClusterCells}" + ) + return metrics, eligibility_reasons, membership_ref + + +def _collect_covariate_metrics( + deps: ParameterTuningDependencies, + *, + candidate: ParameterCandidate, + candidate_id: str, + neighbors_ref: ArtifactRef, + graph_ref: ArtifactRef, + metrics: ParameterMetrics, + evidence_ids: list[str], + warnings: list[str], +) -> None: + """Measure exact typed design effects on an existing graph and neighborhood.""" + store = deps.store perplexity = max(1.0, float(candidate.neighborsK // 3)) for column in deps.batchColumns: try: @@ -504,7 +611,9 @@ def separability_values() -> dict[str, Any]: _metric_metadata_key(store, column), perplexity, ), - lambda: store.metric_proportional_batch_mixing( + lambda: diagnostic_call( + "core.batchMixing", + store.metric_proportional_batch_mixing, column, neighbors_ref, perplexity=perplexity, @@ -537,7 +646,9 @@ def separability_values() -> dict[str, Any]: None, True, ), - lambda: store.metric_clisi( + lambda: diagnostic_call( + "core.clisi", + store.metric_clisi, column, neighbors_ref, perplexity=None, @@ -560,7 +671,9 @@ def separability_values() -> dict[str, Any]: column, _metric_metadata_key(store, column), ), - lambda: store.metric_graph_connectivity( + lambda: diagnostic_call( + "core.graphConnectivity", + store.metric_graph_connectivity, column, graph_ref, ), @@ -624,15 +737,41 @@ def separability_values() -> dict[str, Any]: metrics.biologicalPreservation[name] = scores evidence_ids.append(f"candidate:{candidate_id}:{name}") - eligibility_reasons: list[str] = [] - if n_clusters < 2: - eligibility_reasons.append("fewer than two clusters") - if min_cluster_cells < deps.minClusterCells: - eligibility_reasons.append( - f"smallest cluster has {min_cluster_cells} cells; " - f"minimum is {deps.minClusterCells}" - ) - return metrics, eligibility_reasons, membership_ref + +def refresh_candidate_design_evidence( + deps: ParameterTuningDependencies, + evaluation: ParameterCandidateEvaluation, +) -> ParameterCandidateEvaluation: + """Reassess a revised design without repeating primary analysis or doublets.""" + evaluation = evaluation.model_copy(deep=True) + evaluation.metrics.batchMixing = {} + evaluation.metrics.biologicalPreservation = {} + identifiers = (":batchMixing:", ":clisi:", ":graphConnectivity:", ":joint:") + evaluation.evidenceIds = [ + item + for item in evaluation.evidenceIds + if not any(identifier in item for identifier in identifiers) + ] + prefixes = ( + "Batch mixing for ", + "cLISI for ", + "Graph connectivity for ", + "Matched graph preservation for continuous column ", + ) + evaluation.warnings = [ + item for item in evaluation.warnings if not item.startswith(prefixes) + ] + _collect_covariate_metrics( + deps, + candidate=evaluation.parameters, + candidate_id=evaluation.candidateId, + neighbors_ref=core_artifact_reference(evaluation.artifacts["neighbors"]), + graph_ref=core_artifact_reference(evaluation.artifacts["connectivityMap"]), + metrics=evaluation.metrics, + evidence_ids=evaluation.evidenceIds, + warnings=evaluation.warnings, + ) + return evaluation def execute_parameter_candidate( @@ -643,6 +782,7 @@ def execute_parameter_candidate( with deps.executionLock: if candidate_id in deps.evaluations: + diagnostic_reuse("candidate.evaluation") logger.debug( f"Parameter candidate {candidate_id!r} for assay " f"{deps.fromAssay!r} reused its completed evaluation" @@ -737,7 +877,9 @@ def execute_parameter_candidate( coordinates_ref = reduction_ref if candidate.useHarmony: - coordinates_ref = store.run_harmony( + coordinates_ref = diagnostic_call( + "core.harmony", + store.run_harmony, reduction_ref, list(deps.batchColumns), invalidate_cache=False, @@ -748,7 +890,9 @@ def execute_parameter_candidate( f"using {len(deps.batchColumns)} batch column(s)" ) - ann_ref = store.build_ann_index( + ann_ref = diagnostic_call( + "core.ann", + store.build_ann_index, coordinates_ref, ann_metric="l2", ann_parallel=False, @@ -760,7 +904,9 @@ def execute_parameter_candidate( f"Parameter candidate {candidate_id!r}: completed ANN indexing" ) - neighbors_ref = store.query_neighbors( + neighbors_ref = diagnostic_call( + "core.neighbors", + store.query_neighbors, ann_ref, coordinates=coordinates_ref, k=candidate.neighborsK, @@ -771,7 +917,9 @@ def execute_parameter_candidate( f"Parameter candidate {candidate_id!r}: completed neighbor query" ) - graph_ref = store.build_connectivity_map( + graph_ref = diagnostic_call( + "core.graph", + store.build_connectivity_map, neighbors_ref, local_connectivity=1.0, bandwidth=1.5, @@ -782,7 +930,9 @@ def execute_parameter_candidate( f"Parameter candidate {candidate_id!r}: completed connectivity map" ) - cluster_ref = store.run_leiden_clustering( + cluster_ref = diagnostic_call( + "core.partition", + store.run_leiden_clustering, graph_ref, resolution=candidate.leidenResolution, backend="igraph", diff --git a/scarf/agent/parameter_tuning/prompts.py b/scarf/agent/parameter_tuning/prompts.py index 129e4b3a..f3ac9bc4 100644 --- a/scarf/agent/parameter_tuning/prompts.py +++ b/scarf/agent/parameter_tuning/prompts.py @@ -199,8 +199,8 @@ def parameter_tuning_system_prompt(min_cluster_cells: int) -> str: comparison must cite evidence from both the selected candidate and that comparator. Return only model-owned selection fields. Leave evaluations, selectedArtifacts, searchPlan, assayReports, integration fields, final - graph fields, and runInfo at their defaults because validation fills them - from executor state. Return a concise structured report. + graph fields, and runInfo out of the response because validation fills + them from executor state. Return a concise structured report. """ ) .strip() @@ -336,7 +336,7 @@ def parameter_batch_selection_system_prompt() -> str: model-owned selection, rationale, comparison, trade-off, limitation, evidence, and stop fields. Leave evaluations, selectedArtifacts, searchPlan, nested assayReports, integration fields, final graph fields, - and runInfo at their defaults because validation fills them from + and runInfo out of the response because validation fills them from executor state. """ ) @@ -409,6 +409,8 @@ def final_graph_selection_system_prompt() -> str: UMAP appearance, native-neighbor LISI on an integrated graph, and absent metric fields are not evidence. Return one comparison for every eligible non-selected option, citing evidence from both options. + Select an option and explain it; Scarf attaches its exact graph, + assay, and execution identities. Do not return those derived fields. """ ) .strip() diff --git a/scarf/agent/parameter_tuning/selection.py b/scarf/agent/parameter_tuning/selection.py index 97d226cd..b854cc5f 100644 --- a/scarf/agent/parameter_tuning/selection.py +++ b/scarf/agent/parameter_tuning/selection.py @@ -1413,8 +1413,12 @@ def select_final_parameter_graph( } ), ), - runInfo=AgentRunInfo( - agentName="parameter_tuning_final_graph_needs_input" + runInfo=getattr( + exc, + "agent_run_info", + AgentRunInfo( + agentName="parameter_tuning_final_graph_needs_input" + ), ), ), report, diff --git a/scarf/agent/report/artifacts.py b/scarf/agent/report/artifacts.py index 6e30eccb..b71628dc 100644 --- a/scarf/agent/report/artifacts.py +++ b/scarf/agent/report/artifacts.py @@ -159,6 +159,7 @@ def scientific_summary(snapshot: Mapping[str, Any]) -> dict[str, Any]: raise ValueError("Reported population support belongs to another candidate") return { "request": mapping(snapshot.get("request")), + "modelUsage": mapping(snapshot.get("modelUsage")), "finalAnalysis": final, "decisions": decisions, "assessments": assessments, diff --git a/scarf/agent/report/rendering.py b/scarf/agent/report/rendering.py index 21173032..6f01b167 100644 --- a/scarf/agent/report/rendering.py +++ b/scarf/agent/report/rendering.py @@ -453,6 +453,23 @@ def render_analysis_document(payload: Mapping[str, Any]) -> str: ) ], ) + usage = mapping(payload.get("modelUsage")) + usage_note = "" + if usage.get("invocations"): + usage_note = ( + f"

    Recorded model work: {int(usage['invocations']):,} invocations, " + f"{int(usage.get('failedInvocations', 0)):,} failed; " + f"{int(usage.get('requests', 0)):,} completed responses and " + f"{int(usage.get('validationRetries', 0)):,} validation corrections. " + f"Reported tokens: {int(usage.get('inputTokens', 0)):,} input and " + f"{int(usage.get('outputTokens', 0)):,} output, including failed invocations.

    " + ) + if usage.get("availability") != "reported": + usage_note += ( + "

    Provider usage is incomplete or unavailable for some invocations. " + "Reported totals are known usage only; missing usage is not zero. " + "Failed requests without a response are not included in the response count.

    " + ) return f""" Scarf analysis summary
    @@ -461,7 +478,7 @@ def render_analysis_document(payload: Mapping[str, Any]) -> str: {'" if limitations else ""}

    Populations and markers

    {map_markup}
    {_population_table(payload)}
    {_qc_section(payload)}{_design_section(payload)}{_comparison_sections(payload)} -
    Selected methods and evidence{methods}{mode_note}

    Repeat and subsample agreement use adjusted Rand index. Marker coverage is the fraction of clusters with qualifying markers. These describe the selected analysis; they are not probabilities of biological correctness.

    +
    Selected methods and evidence{methods}{mode_note}{usage_note}

    Repeat and subsample agreement use adjusted Rand index. Marker coverage is the fraction of clusters with qualifying markers. These describe the selected analysis; they are not probabilities of biological correctness.

    {"
    Unavailable displays" + display_notes + "
    " if display_notes else ""}
    Generated locally by Scarf. All numerical evidence is read from the saved analysis; report generation makes no analysis or model calls.
    """ diff --git a/scarf/agent/types.py b/scarf/agent/types.py index 3e9d946a..29a1c67a 100644 --- a/scarf/agent/types.py +++ b/scarf/agent/types.py @@ -118,6 +118,18 @@ class AgentUsageInfo(AgentDataModel): totalTokens: int = 0 requests: int = 0 toolCalls: int = 0 + availability: Literal["reported", "partial", "unavailable"] | None = Field( + default=None, exclude_if=lambda value: value is None + ) + + +class AgentValidationRetry(AgentDataModel): + """A rejected response or tool call and the feedback supplied for repair.""" + + source: Literal["output", "tool", "schema"] + requestIndex: int = 0 + message: str + response: dict[str, Any] | str | None = None class AgentRunInfo(AgentDataModel): @@ -127,6 +139,14 @@ class AgentRunInfo(AgentDataModel): durationSeconds: float = 0.0 usage: AgentUsageInfo = Field(default_factory=AgentUsageInfo) toolCalls: list[ToolCallInfo] = Field(default_factory=list) + status: Literal["done", "failed"] | None = Field( + default=None, exclude_if=lambda value: value is None + ) + validationRetries: list[AgentValidationRetry] = Field( + default_factory=list, exclude_if=lambda value: not value + ) + errorType: str | None = Field(default=None, exclude_if=lambda value: value is None) + error: str | None = Field(default=None, exclude_if=lambda value: value is None) class AgentExecutionResult(AgentDataModel): diff --git a/tests/test_agent_attempt_audit.py b/tests/test_agent_attempt_audit.py new file mode 100644 index 00000000..2de00a65 --- /dev/null +++ b/tests/test_agent_attempt_audit.py @@ -0,0 +1,393 @@ +"""Model attempts retain measured usage and rejected decisions without execution.""" + +import asyncio +from typing import Any + +import pytest +from pydantic_ai import UnexpectedModelBehavior +from pydantic_ai.messages import ( + ModelResponse, + RetryPromptPart, + TextPart, + ThinkingPart, + ToolCallPart, +) +from pydantic_ai.models.function import FunctionModel +from pydantic_ai.usage import RequestUsage + +from scarf.agent.config import AgentRunConfig +from scarf.agent.config.agent_exec import run_agent_async, run_agent_sync +from scarf.agent.decisions.selection import decide +from scarf.agent.types import AgentDataModel, AgentRunInfo, AgentUsageInfo, EvidenceItem + + +class CountDecision(AgentDataModel): + value: int + + +@pytest.mark.parametrize("host", ["sync", "async", "notebook"]) +def test_repaired_attempt_preserves_usage_and_rejection(host: str) -> None: + requests = 0 + attempts: list[AgentRunInfo] = [] + + async def respond(messages: Any, info: Any) -> ModelResponse: + nonlocal requests + requests += 1 + if requests == 2: + assert any( + isinstance(part, RetryPromptPart) + and "exact measured count is 2" in str(part.content) + for message in messages + for part in message.parts + ) + return ModelResponse( + parts=[ToolCallPart(info.output_tools[0].name, {"value": requests})], + usage=RequestUsage(input_tokens=11, output_tokens=3), + ) + + def validate(output: CountDecision) -> CountDecision: + if output.value != 2: + raise ValueError("The exact measured count is 2") + return output + + kwargs = dict( + model=FunctionModel(respond), + output_type=CountDecision, + system_prompt="Use measured counts.", + user_prompt="Choose the measured count.", + output_validator=validate, + on_attempt=attempts.append, + ) + if host == "async": + result = asyncio.run(run_agent_async(**kwargs)) + elif host == "notebook": + + async def notebook() -> Any: + return run_agent_sync(**kwargs) + + result = asyncio.run(notebook()) + else: + result = run_agent_sync(**kwargs) + assert requests == 2 + assert attempts == [result.runInfo] + assert result.runInfo.status == "done" + assert result.runInfo.usage.inputTokens == 22 + assert result.runInfo.usage.outputTokens == 6 + assert result.runInfo.usage.availability == "reported" + assert len(result.runInfo.validationRetries) == 1 + assert result.runInfo.validationRetries[0].response == {"value": 1} + assert result.runInfo.validationRetries[0].requestIndex == 1 + + +def test_exhausted_attempt_keeps_exact_error_usage_and_every_rejection() -> None: + attempts: list[AgentRunInfo] = [] + + async def respond(_messages: Any, info: Any) -> ModelResponse: + return ModelResponse( + parts=[ToolCallPart(info.output_tools[0].name, {"value": 1})], + usage=RequestUsage(input_tokens=7, output_tokens=2), + ) + + def reject(_output: CountDecision) -> CountDecision: + raise ValueError("Missing the measured alternative's marker advantage") + + with pytest.raises(UnexpectedModelBehavior) as caught: + run_agent_sync( + model=FunctionModel(respond), + output_type=CountDecision, + system_prompt="Assess evidence.", + user_prompt="Select only supported settings.", + config=AgentRunConfig(retries=1), + output_validator=reject, + on_attempt=attempts.append, + ) + info = caught.value.agent_run_info + assert attempts == [info] + assert info.status == "failed" + assert info.usage.requests == 2 + assert info.usage.inputTokens == 14 + assert info.usage.outputTokens == 4 + assert info.usage.availability == "reported" + assert len(info.validationRetries) == 2 + assert [row.requestIndex for row in info.validationRetries] == [1, 2] + assert all(item.response == {"value": 1} for item in info.validationRetries) + assert "marker advantage" in info.error + + +def test_failed_request_does_not_invent_provider_usage() -> None: + attempts: list[AgentRunInfo] = [] + + async def fail(_messages: Any, _info: Any) -> ModelResponse: + raise RuntimeError("Provider unavailable") + + with pytest.raises(RuntimeError, match="Provider unavailable") as caught: + run_agent_sync( + model=FunctionModel(fail), + output_type=CountDecision, + system_prompt="Assess evidence.", + user_prompt="Choose a count.", + on_attempt=attempts.append, + ) + assert attempts == [caught.value.agent_run_info] + assert attempts[0].usage.availability == "unavailable" + assert attempts[0].validationRetries == [] + + +@pytest.mark.parametrize("provider_fails", [False, True]) +def test_journal_callback_failure_preserves_model_outcome(provider_fails: bool) -> None: + from scarf.agent.config.agent_exec import describe_agent_error + + calls = [] + + async def respond(_messages: Any, info: Any) -> ModelResponse: + if provider_fails: + raise RuntimeError("Original provider outage") + return ModelResponse( + parts=[ToolCallPart(info.output_tools[0].name, {"value": 2})], + usage=RequestUsage(input_tokens=11, output_tokens=2), + ) + + def failed_journal(info: AgentRunInfo) -> None: + calls.append(info) + raise OSError("Test history store is unavailable") + + with pytest.raises(RuntimeError if provider_fails else OSError) as caught: + run_agent_sync( + model=FunctionModel(respond), + output_type=CountDecision, + system_prompt="Interpret saved evidence.", + user_prompt="Choose the measured count.", + on_attempt=failed_journal, + ) + assert len(calls) == 1 + assert caught.value.agent_run_info == calls[0] + assert calls[0].status == ("failed" if provider_fails else "done") + assert calls[0].runId + detail = describe_agent_error(caught.value) + assert "history store is unavailable" in detail + if provider_fails: + assert "Original provider outage" in detail + else: + assert "model completed" in detail + + +def test_provider_outage_retains_partial_usage_and_completed_tool_evidence() -> None: + requests = 0 + operations = 0 + + async def measure() -> int: + nonlocal operations + operations += 1 + return 2 + + async def respond(_messages: Any, _info: Any) -> ModelResponse: + nonlocal requests + requests += 1 + if requests > 1: + raise RuntimeError("Provider unavailable after the measured tool result") + return ModelResponse( + parts=[ToolCallPart("measure", {})], + usage=RequestUsage(input_tokens=11, output_tokens=2), + ) + + with pytest.raises(RuntimeError, match="after the measured") as caught: + run_agent_sync( + model=FunctionModel(respond), + output_type=CountDecision, + system_prompt="Measure once before interpreting.", + user_prompt="Assess the measured count.", + tools=[measure], + ) + info = caught.value.agent_run_info + assert operations == 1 + assert requests == 2 + assert info.usage.requests == 1 # The SDK reported only the completed response. + assert info.usage.inputTokens == 11 + assert info.usage.availability == "partial" + assert [call.toolName for call in info.toolCalls] == ["measure"] + + +def test_cancelled_attempt_retains_known_execution_information() -> None: + attempts: list[AgentRunInfo] = [] + + async def respond(_messages: Any, _info: Any) -> ModelResponse: + raise asyncio.CancelledError("Analysis interrupted") + + with pytest.raises(asyncio.CancelledError) as caught: + asyncio.run( + run_agent_async( + model=FunctionModel(respond), + output_type=CountDecision, + system_prompt="Assess evidence.", + user_prompt="Select a count.", + on_attempt=attempts.append, + ) + ) + assert attempts == [caught.value.agent_run_info] + assert attempts[0].errorType == "CancelledError" + assert attempts[0].usage.availability == "unavailable" + + +def test_old_run_information_serializes_without_added_default_fields() -> None: + payload = { + "agentName": "prior", + "modelName": "model", + "runId": "id", + "durationSeconds": 12.0, + "usage": { + "inputTokens": 1, + "outputTokens": 2, + "totalTokens": 3, + "requests": 1, + "toolCalls": 0, + }, + "toolCalls": [], + } + assert AgentRunInfo.model_validate(payload).model_dump(mode="json") == payload + assert "availability" not in AgentUsageInfo().model_dump(mode="json") + + +def test_schema_repair_does_not_repeat_a_successful_tool_operation() -> None: + operations = 0 + requests = 0 + + async def measure(value: int) -> int: + nonlocal operations + operations += 1 + return value + + async def respond(_messages: Any, info: Any) -> ModelResponse: + nonlocal requests + requests += 1 + if requests <= 2: + part = ToolCallPart("measure", {"value": "invalid" if requests == 1 else 2}) + else: + part = ToolCallPart(info.output_tools[0].name, {"value": 2}) + return ModelResponse( + parts=[part], usage=RequestUsage(input_tokens=5, output_tokens=1) + ) + + result = run_agent_sync( + model=FunctionModel(respond), + output_type=CountDecision, + system_prompt="Use one successful measured value.", + user_prompt="Measure the value.", + tools=[measure], + ) + assert operations == 1 + assert result.output.value == 2 + assert requests == 3 + assert len(result.runInfo.validationRetries) == 1 + assert result.runInfo.validationRetries[0].source == "tool" + assert result.runInfo.validationRetries[0].response == {"value": "invalid"} + + +@pytest.mark.parametrize("content", ["none", "text", "thinking", "both"]) +def test_failed_schema_attempt_retains_the_last_invalid_response(content: str) -> None: + attempts: list[AgentRunInfo] = [] + + async def respond(_messages: Any, info: Any) -> ModelResponse: + parts: list[Any] = [] + if content in {"text", "both"}: + parts.append(TextPart("Here is the requested structured result.")) + if content in {"thinking", "both"}: + parts.append(ThinkingPart("Checking the observed count.")) + parts.append(ToolCallPart(info.output_tools[0].name, {"value": "invalid"})) + return ModelResponse( + parts=parts, usage=RequestUsage(input_tokens=5, output_tokens=2) + ) + + with pytest.raises(UnexpectedModelBehavior) as caught: + run_agent_sync( + model=FunctionModel(respond), + output_type=CountDecision, + system_prompt="Use measured counts.", + user_prompt="Choose a count.", + config=AgentRunConfig(retries=1), + on_attempt=attempts.append, + ) + info = caught.value.agent_run_info + assert attempts == [info] + assert len(info.validationRetries) == 2 + assert all(row.response == {"value": "invalid"} for row in info.validationRetries) + assert [row.requestIndex for row in info.validationRetries] == [1, 2] + assert info.usage.inputTokens == 10 + assert info.usage.requests == 2 + assert info.status == "failed" + + +def test_exhausted_schema_with_multiple_calls_keeps_error_without_guessing_attribution() -> ( + None +): + async def respond(_messages: Any, info: Any) -> ModelResponse: + return ModelResponse( + parts=[ + TextPart("Two proposed results."), + ToolCallPart( + info.output_tools[0].name, + {"value": "first-invalid"}, + tool_call_id="first", + ), + ToolCallPart( + info.output_tools[0].name, + {"value": "second-invalid"}, + tool_call_id="second", + ), + ], + usage=RequestUsage(input_tokens=5, output_tokens=2), + ) + + with pytest.raises(UnexpectedModelBehavior) as caught: + run_agent_sync( + model=FunctionModel(respond), + output_type=CountDecision, + system_prompt="Use one measured count.", + user_prompt="Choose a count.", + config=AgentRunConfig(retries=0), + ) + info = caught.value.agent_run_info + assert "ValidationError" in info.error + assert info.validationRetries == [] + assert info.status == "failed" + assert info.usage.requests == 1 + assert info.usage.inputTokens == 5 + + +def test_exact_evidence_id_errors_repair_inside_the_model_loop() -> None: + requests = 0 + + async def respond(messages: Any, info: Any) -> ModelResponse: + nonlocal requests + requests += 1 + if requests == 2: + assert any( + isinstance(part, RetryPromptPart) + and "not in evidence ids" in str(part.content) + for message in messages + for part in message.parts + ) + identifier = "unrelated:matrix:raw/X" if requests == 1 else "matrix:raw/X" + return ModelResponse( + parts=[ + ToolCallPart( + info.output_tools[0].name, + { + "selectedId": identifier, + "rationale": "The observed raw matrix contains integer counts.", + "evidenceIds": [identifier], + }, + ) + ] + ) + + result = decide( + model=FunctionModel(respond), + question="Which measured matrix contains raw counts?", + evidence=[ + EvidenceItem( + id="matrix:raw/X", label="Raw counts", summary="Integer counts" + ) + ], + ) + assert requests == 2 + assert result.selectedId == "matrix:raw/X" diff --git a/tests/test_agent_beginner.py b/tests/test_agent_beginner.py index 610a113f..db99b7b0 100644 --- a/tests/test_agent_beginner.py +++ b/tests/test_agent_beginner.py @@ -71,7 +71,7 @@ def run(self, request: AutomatedWorkflowRequest) -> AutomatedWorkflowResult: assert called["model"] is model config = called["config"] assert config.inputPolicy == "unattended" - assert config.screeningCells == 50_000 + assert config.screeningCells is None assert config.maxScreeningCells == 100_000 assert config.maxScreeningEvaluations == 24 assert config.maxTotalScreeningEvaluations == 48 diff --git a/tests/test_agent_biological_interpretation.py b/tests/test_agent_biological_interpretation.py index eeaf53c3..8afa9470 100644 --- a/tests/test_agent_biological_interpretation.py +++ b/tests/test_agent_biological_interpretation.py @@ -902,7 +902,7 @@ async def reply(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: } -def test_biological_interpretation_falls_back_to_unresolved_hypotheses( +def test_biological_interpretation_preserves_evidence_without_completed_fallback( monkeypatch: pytest.MonkeyPatch, ) -> None: store = FakeStore() @@ -938,15 +938,12 @@ def unavailable_structured_output(**kwargs: object) -> None: marker=store.marker, ) - assert result.status == "done" - assert result.runInfo.agentName == "biological_interpretation_fallback" - assert {item.clusterId for item in result.clusterInterpretations} == {"0", "1"} - assert all( - item.proposedIdentity == "unresolved" - and item.identityIsHypothesis - and item.confidence == "low" - for item in result.clusterInterpretations - ) + assert result.status == "failed" + assert result.runInfo.agentName == "biological_interpretation_failed" + assert result.clusterInterpretations == [] + assert result.clusterArtifact == artifact_model(store.cluster) + assert result.markerArtifact == artifact_model(store.marker) + assert result.evidenceIds assert result.treatmentObservations == [] assert marker_retries == [1] diff --git a/tests/test_agent_comparison_boundaries.py b/tests/test_agent_comparison_boundaries.py new file mode 100644 index 00000000..c43c7a36 --- /dev/null +++ b/tests/test_agent_comparison_boundaries.py @@ -0,0 +1,176 @@ +"""Corrupt, incomplete and mismatched measurements cannot authorize acceptance.""" + +from copy import deepcopy +from typing import Any + +import pytest + +from scarf.agent.orchestrator.rna_tuning import validate_completed_comparison_evidence +from scarf.agent.parameter_tuning.comparisons import validate_comparison_review +from tests.agent_comparison_examples import comparison_review + + +@pytest.mark.parametrize( + ("field", "value", "reason"), + [ + ("phase", "unmeasured", "Unknown RNA comparison phase"), + ("population", "unknown", "population must identify"), + ("candidateSettings", [], "exact settings and rows"), + ("comparisons", {}, "exact settings and rows"), + ("resolutionCandidateIds", [], "four resolutions"), + ], +) +def test_review_rejects_incomplete_coverage( + field: str, value: Any, reason: str +) -> None: + review = comparison_review() + coverage = review["comparisonCoverage"] + coverage[field] = value + with pytest.raises(ValueError, match=reason): + validate_comparison_review(coverage, review) + + +@pytest.mark.parametrize( + ("failure", "reason"), + [ + ("duplicate", "IDs must be unique"), + ("missingBaseline", "baseline is not completed"), + ("unfinishedBaseline", "baseline is not completed"), + ("missingAlternative", "lacks a completed alternative"), + ("unfinishedAlternative", "complete on the exact same cells"), + ("differentCells", "complete on the exact same cells"), + ("missingReason", "observed eligibility reason"), + ("inventedAlternative", "cannot claim a different"), + ("unsupportedEquivalence", "valid observed equivalence"), + ("missingConclusion", "explicit comparison conclusions"), + ("unaddressedAlternative", "all its observed alternatives"), + ], +) +def test_each_comparison_requires_complete_attributable_evidence( + failure: str, reason: str +) -> None: + review = comparison_review() + coverage = review["comparisonCoverage"] + rows, settings = coverage["comparisons"], coverage["candidateSettings"] + if failure == "duplicate": + rows.append(deepcopy(rows[0])) + elif failure == "missingBaseline": + rows[0]["baselineCandidateId"] = "absent" + elif failure == "unfinishedBaseline": + settings["baseline"]["status"] = "failed" + elif failure == "missingAlternative": + rows[0]["alternativeCandidateId"] = "absent" + elif failure == "unfinishedAlternative": + settings["resolution-half"]["status"] = "failed" + elif failure == "differentCells": + settings["resolution-half"]["cellSelection"]["artifactId"] = "d" * 64 + elif failure == "missingReason": + rows[-1]["reason"] = " " + elif failure == "inventedAlternative": + rows[-1]["alternativeCandidateId"] = "genes-two" + elif failure == "unsupportedEquivalence": + rows[-1]["observedProof"]["meaningfulPermittedInterventions"] = 1 + elif failure == "missingConclusion": + review.pop("comparisonConclusions") + elif failure == "unaddressedAlternative": + review["comparisonConclusions"][0]["candidateIds"] = ["baseline"] + with pytest.raises(ValueError, match=reason): + validate_comparison_review(coverage, review) + + +@pytest.mark.parametrize( + ("failure", "reason"), + [ + ("absent", "candidate is unavailable"), + ("failed", "completed on the same cells"), + ("cells", "completed on the same cells"), + ("representation", "changed the combined representation"), + ("resolution", "coverage is incomplete"), + ("missingSelected", "lack exact completed evidence"), + ("missingClusterMarkers", "every selected cluster"), + ("inventedConcern", "must cite supplied candidate evidence"), + ("unvalidatedRepair", "one targeted full repair"), + ], +) +def test_final_panel_requires_exact_selected_combination( + failure: str, reason: str +) -> None: + review = comparison_review() + coverage = review["comparisonCoverage"] + settings = coverage["candidateSettings"] + replacement = deepcopy(settings["resolution-half"]) + settings["final-half"] = replacement + coverage["resolutionCandidateIds"][0] = "final-half" + if failure == "absent": + del settings["final-half"] + elif failure == "failed": + replacement["status"] = "failed" + elif failure == "cells": + replacement["cellSelection"]["artifactId"] = "a" * 64 + elif failure == "representation": + replacement["parameters"]["dimensions"] = 30 + elif failure == "resolution": + replacement["parameters"]["leidenResolution"] = 0.9 + elif failure == "missingSelected": + review["selectedCandidateId"] = "missing" + elif failure == "missingClusterMarkers": + settings["candidate-two"]["metrics"]["nClusters"] = 3 + elif failure == "inventedConcern": + review["populationConcerns"] = [ + { + "candidateId": "candidate-two", + "clusterId": "0", + "status": "nonEssentialLimitation", + "evidenceIds": ["invented-marker-proof"], + "explanation": "A proposed interpretation needs observed support.", + } + ] + elif failure == "unvalidatedRepair": + repaired = deepcopy(settings["baseline"]) + repaired["parameters"]["dimensions"] = 30 + repaired["parameters"]["neighborsK"] = 41 + settings["unvalidated"] = repaired + review["selectedCandidateId"] = "unvalidated" + with pytest.raises(ValueError, match=reason): + validate_comparison_review(coverage, review) + + +@pytest.mark.parametrize("failure", ["coverage", "candidate", "measurement"]) +def test_report_cannot_detach_accepted_review_from_saved_measurements( + failure: str, +) -> None: + review = comparison_review() + if failure == "coverage": + review["comparisonCoverage"] = None + reason = "mandatory comparison evidence" + elif failure == "candidate": + review["candidates"][0]["cellSelection"] = None + reason = "exact reviewed candidate" + else: + review["candidates"][0] = deepcopy(review["candidates"][0]) + review["candidates"][0]["metrics"]["seedStability"] = 1.0 + reason = "measurements differ" + with pytest.raises(ValueError, match=reason): + validate_completed_comparison_evidence(review) + + +def test_duplicate_or_fabricated_tradeoffs_cannot_justify_preference() -> None: + review = comparison_review() + coverage = review["comparisonCoverage"] + coverage["candidateSettings"]["genes-two"]["metrics"]["seedStability"] = 0.99 + conclusion = next( + c for c in review["comparisonConclusions"] if c["axis"] == "hvgCount" + ) + tradeoff = { + "alternativeCandidateId": "genes-two", + "metric": "seedStability", + "preferredValue": 0.92, + "alternativeValue": 0.99, + "interpretation": "The measured stability advantage is weighed against marker preservation.", + } + conclusion["tradeoffs"] = [tradeoff, deepcopy(tradeoff)] + with pytest.raises(ValueError, match="duplicate"): + validate_comparison_review(coverage, review) + conclusion["tradeoffs"] = [{**tradeoff, "alternativeValue": 1.0}] + with pytest.raises(ValueError, match="exact preferred and alternative"): + validate_comparison_review(coverage, review) diff --git a/tests/test_agent_context_computation_reuse.py b/tests/test_agent_context_computation_reuse.py new file mode 100644 index 00000000..6d80474b --- /dev/null +++ b/tests/test_agent_context_computation_reuse.py @@ -0,0 +1,310 @@ +"""Scientific metadata reuse keeps typed inputs and QC policies attributable.""" + +from collections import Counter +from copy import deepcopy +from typing import Any + +import numpy as np +import pandas as pd +import pytest + +from scarf.agent.experimental_context import characterization as characterization_module +from scarf.agent.experimental_context import qc_evidence +from scarf.agent.experimental_context.contracts import CovariateCharacterization +from tests.test_agent_experimental_context import _Store, _context, _replace_store_cells + + +def _capture_context() -> tuple[Any, CovariateCharacterization]: + store = _Store() + donors = np.repeat(["d1", "d2", "d3"], 8) + condition = np.tile(np.repeat(["case", "control"], 4), 3) + values = { + "I": np.ones(24, dtype=bool), + "ids": np.asarray([f"cell{i}" for i in range(24)]), + "names": np.asarray([f"cell{i}" for i in range(24)]), + "capture": donors.copy(), + "donor": donors, + "condition": condition, + "sample": np.asarray( + [f"{d}:{c}" for d, c in zip(donors, condition, strict=True)] + ), + "RNA_nCounts": np.tile(np.arange(8, dtype=float) + 100, 3), + "RNA_nFeatures": np.tile(np.arange(8, dtype=float) + 50, 3), + } + _replace_store_cells(store, values) + deps = _context(store, directions={"physicalCaptureColumn": "capture"}).deps + characterized = CovariateCharacterization( + status="done", + columns=[ + {"name": name, "kind": "categorical"} + for name in ("condition", "sample", "donor") + ], + coefficients=[ + { + "name": "condition", + "scope": "betweenUnit", + "observationUnit": "sample", + "independentUnit": "donor", + "pairedCoverage": {"design": "paired"}, + } + ], + ) + return deps, characterized + + +def test_capture_design_is_computed_once_across_real_policy_projections( + monkeypatch: pytest.MonkeyPatch, +) -> None: + deps, characterized = _capture_context() + captures = deps.cells.fetch("capture") + expected = { + label: qc_evidence._capture_design_safety(deps, characterized, captures, label) + for label in ("d1", "d2", "d3") + } + calls: Counter[str] = Counter() + reads: Counter[str] = Counter() + compute = qc_evidence._compute_capture_design_safety + fetch = qc_evidence._QcDesignData.fetch + + def counted(*args: Any) -> Any: + calls[args[-1]] += 1 + return compute(*args) + + def fetched(self: Any, column: str) -> Any: + if column not in self.values: + reads[column] += 1 + return fetch(self, column) + + monkeypatch.setattr(qc_evidence, "_compute_capture_design_safety", counted) + monkeypatch.setattr(qc_evidence._QcDesignData, "fetch", fetched) + profiles = qc_evidence._offered_qc_profiles(deps, characterized) + assert sum(bool(profile.captureFailureEvidence) for profile in profiles) >= 4 + assert calls == {"d1": 1, "d2": 1, "d3": 1} + assert reads == {"condition": 1, "sample": 1, "donor": 1} + assert deps.qcDesignData is None + for profile in profiles: + for failure in profile.captureFailureEvidence: + rows, condition_safe, unit_safe = expected[failure.capture] + assert failure.conditionAndUnitSafety == rows + assert failure.preservesConditionCoverage == condition_safe + assert failure.preservesIndependentUnitCoverage == unit_safe + # A later projection sees changed metadata instead of stale cached safety. + deps.store.cells._values["condition"][:8] = "only_here" + updated = qc_evidence._offered_qc_profiles(deps, characterized) + assert calls == {"d1": 2, "d2": 2, "d3": 2} + assert all( + not failure.preservesConditionCoverage + for profile in updated + for failure in profile.captureFailureEvidence + if failure.capture == "d1" + ) + + +@pytest.mark.parametrize("kind", ["continuous", "unknown"]) +def test_continuous_or_unknown_capture_protection_cannot_become_categorical( + kind: str, +) -> None: + deps, characterized = _capture_context() + deps.store.cells._values["condition"] = np.linspace(20, 80, 24) + deps.store.cells._values["condition"][0] = np.nan + characterized.columns[0]["kind"] = kind + rows, preserves_conditions, preserves_units = qc_evidence._capture_design_safety( + deps, characterized, deps.cells.fetch("capture"), "d1" + ) + assert not preserves_conditions and not preserves_units + assert "requiredGroups" not in rows[0] + if kind == "continuous": + assert rows[0]["matchedRowsBeforeExclusion"] == 23 + assert rows[0]["matchedRowsAfterExclusion"] == 16 + assert rows[0]["independentUnitsAfterExclusion"] == 2 + assert len(rows[0]["quantilesAfterExclusion"]) == 5 + assert "not been established" in rows[0]["reason"] + else: + assert rows[0]["reason"] == "unknownCovariateKind" + + +def test_column_inventory_reuse_checks_values_kinds_missingness_and_selection( + monkeypatch: pytest.MonkeyPatch, +) -> None: + store = _Store() + inventory: dict[str, Any] = {} + calls: Counter[str] = Counter() + digest = characterization_module.column_partition_digest + + def counted(cells: Any, column: str, **kwargs: Any) -> Any: + calls[column] += 1 + return digest(cells, column, **kwargs) + + monkeypatch.setattr(characterization_module, "column_partition_digest", counted) + + def characterize(**directions: Any) -> Any: + return characterization_module.characterize_covariates( + store, + cellSelection=store.cell_selection, + directions=directions, + inventory=inventory, + ) + + first = characterize() + baseline = calls.copy() + assert first.status == "done" + characterize(columnDomains={"sequencing_depth": "technical"}) + assert calls == baseline + store.cells._values["sequencing_depth"][0] += 1 + characterize() + assert calls == baseline + Counter({"sequencing_depth": 1}) + characterize(columnKinds={"sequencing_depth": "categorical"}) + assert calls == baseline + Counter({"sequencing_depth": 2}) + store.cells._values["sequencing_depth"][0] = np.nan + characterize(columnKinds={"sequencing_depth": "categorical"}) + assert calls == baseline + Counter({"sequencing_depth": 3}) + store.cells._values["I"][0] = False + store.refresh_cell_selection() + before = calls.copy() + characterize() + assert all(calls[column] > before[column] for column in baseline) + + +def test_qc_design_scope_is_cleared_after_projection_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + deps, characterized = _capture_context() + + def fail(*args: Any, **kwargs: Any) -> Any: + assert isinstance(deps.qcDesignData, qc_evidence._QcDesignData) + raise ValueError("invalid quality metric") + + monkeypatch.setattr(qc_evidence, "_project_qc_profiles", fail) + with pytest.raises(ValueError, match="invalid quality metric"): + qc_evidence._offered_qc_profiles(deps, characterized) + assert deps.qcDesignData is None + + +def test_nullable_design_metadata_retains_missingness_as_unresolved_protection() -> ( + None +): + deps, characterized = _capture_context() + deps.store.cells._values["condition"] = np.linspace(20, 80, 24).astype(object) + deps.store.cells._values["condition"][0] = pd.NA + characterized.columns[0]["kind"] = "continuous" + rows, protected, _ = qc_evidence._capture_design_safety( + deps, characterized, deps.cells.fetch("capture"), "d3" + ) + assert not protected and rows[0]["missingRowsAfterExclusion"] == 1 + deps.store.cells._values["donor"] = deps.store.cells._values["donor"].astype(object) + deps.store.cells._values["donor"][0] = None + retention = qc_evidence._design_retention( + deps, characterized, np.ones(24, dtype=bool), np.ones(24, dtype=bool) + ) + assert "donor:missingValues" in retention["unsafeRetentionGroups"] + assert "None" not in retention["retainedCellsByColumn"]["donor"] + + +@pytest.mark.parametrize("missing", [pd.NaT, b"", b" ", b"\xff"]) +def test_unusable_imported_unit_labels_remain_missing_in_retention( + missing: Any, +) -> None: + deps, characterized = _capture_context() + deps.store.cells._values["donor"] = deps.store.cells._values["donor"].astype(object) + deps.store.cells._values["donor"][0] = missing + retention = qc_evidence._design_retention( + deps, characterized, np.ones(24, dtype=bool), np.ones(24, dtype=bool) + ) + assert "donor:missingValues" in retention["unsafeRetentionGroups"] + assert retention["retainedCellsByColumn"]["donor"] == {"d1": 7, "d2": 8, "d3": 8} + + +def _joint_capture_context() -> tuple[Any, CovariateCharacterization]: + deps, characterized = _capture_context() + deps.store.cells._values["condition"][:8] = "case" + deps.store.cells._values["time"] = np.concatenate( + [np.repeat("late", 8), np.tile(np.repeat(["early", "late"], 4), 2)] + ) + characterized.columns.append({"name": "time", "kind": "categorical"}) + time = deepcopy(characterized.coefficients[0]) + time["name"] = "time" + characterized.coefficients.append(time) + deps.protectedCombinations = [["condition", "time"]] + return deps, characterized + + +def test_joint_population_loss_is_visible_despite_preserved_marginal_conditions() -> ( + None +): + deps, characterized = _joint_capture_context() + captures = deps.cells.fetch("capture") + rows, condition_safe, unit_safe = qc_evidence._capture_design_safety( + deps, characterized, captures, "d1" + ) + assert all(row["preservesConditionCoverage"] for row in rows[:-1]) + assert all(row["preservesIndependentUnitCoverage"] for row in rows[:-1]) + assert not rows[-1]["preservesConditionCoverage"] + assert not condition_safe and not unit_safe + retention = qc_evidence._design_retention( + deps, characterized, np.ones(24, dtype=bool), captures != "d1" + ) + for column in ("condition", "time"): + assert all(retention["retainedCellsByColumn"][column].values()) + assert any( + value.startswith("combination:") for value in retention["unsafeRetentionGroups"] + ) + deps.qcDesignData = qc_evidence._QcDesignData(deps.cells) + cached = qc_evidence._design_retention( + deps, characterized, np.ones(24, dtype=bool), captures != "d1" + ) + assert cached == retention + + +@pytest.mark.parametrize("missing", ["value", "column", "independentColumn"]) +def test_missing_joint_design_inputs_cannot_establish_safe_capture_exclusion( + missing: str, +) -> None: + deps, characterized = _joint_capture_context() + if missing == "value": + deps.store.cells._values["time"] = deps.store.cells._values["time"].astype( + object + ) + deps.store.cells._values["time"][0] = None + elif missing == "column": + del deps.store.cells._values["time"] + else: + del deps.store.cells._values["donor"] + rows, condition_safe, unit_safe = qc_evidence._capture_design_safety( + deps, characterized, deps.cells.fetch("capture"), "d1" + ) + assert not unit_safe + if missing != "independentColumn": + assert not condition_safe + assert rows[-1]["reason"] == "missingProtectedCombination" + retention = qc_evidence._design_retention( + deps, characterized, np.ones(24, dtype=bool), np.ones(24, dtype=bool) + ) + assert any( + entry.startswith("combination:") and entry.endswith(":missingValues") + for entry in retention["unsafeRetentionGroups"] + ) + else: + assert all(row["reason"] == "missingDesignColumn" for row in rows[:-1]) + + +def test_joint_replication_uses_independent_donors_and_scoped_capture_identity() -> ( + None +): + deps, characterized = _capture_context() + deps.store.cells._values["time"] = np.tile(["early", "late"], 12) + characterized.columns.append({"name": "time", "kind": "categorical"}) + deps.protectedCombinations = [["condition", "time"]] + deps.qcDesignData = qc_evidence._QcDesignData(deps.cells) + captures = deps.cells.fetch("capture") + rows, condition_safe, unit_safe = qc_evidence._capture_design_safety( + deps, characterized, captures, "d1" + ) + assert condition_safe and unit_safe + assert rows[-1]["preservesIndependentUnitCoverage"] + changed_captures = captures.copy() + changed_captures[changed_captures == "d2"] = "d1" + rows, condition_safe, unit_safe = qc_evidence._capture_design_safety( + deps, characterized, changed_captures, "d1" + ) + assert condition_safe and not unit_safe + assert not rows[-1]["preservesIndependentUnitCoverage"] diff --git a/tests/test_agent_context_design_limits.py b/tests/test_agent_context_design_limits.py new file mode 100644 index 00000000..8431a760 --- /dev/null +++ b/tests/test_agent_context_design_limits.py @@ -0,0 +1,178 @@ +"""Repeated-unit aggregation and sparse design limits remain explicit evidence.""" + +import numpy as np +import pandas as pd +import pytest + +from scarf.agent.experimental_context.comparisons import ( + compare_covariates, + combination_labels, +) +from scarf.agent.experimental_context.contracts import ( + CovariateCharacterization, + CovariateProposal, +) +from tests.test_agent_design_comparisons import _Cells + + +def _comparison(frame, *, conditioned=False, joint=False): + kinds = { + name: "continuous" + if name == "response" and frame[name].dtype.kind == "f" + else "categorical" + for name in frame.columns + } + known = CovariateCharacterization( + status="done", + columns=[ + { + "name": name, + "kind": kinds[name], + "domain": "design" if name in {"sample", "donor"} else "biological", + } + for name in frame.columns + ], + coefficients=[ + { + "name": "response", + "observationUnit": "sample", + "independentUnit": "donor", + } + ], + ) + proposal = CovariateProposal( + response="response", + explanatoryColumns=["treatment", "stratum"] if joint else ["treatment"], + conditionedOn="stratum" if conditioned else None, + observationUnit="sample", + independentUnit="donor", + rationale="Check the supported comparison across independent donors.", + ) + return compare_covariates( + _Cells(frame), known, proposal, selection_identity={"cells": "frozen"} + ) + + +def test_continuous_response_uses_observation_medians_then_independent_donors(): + frame = pd.DataFrame( + [ + { + "sample": f"s{i}-{j}", + "donor": f"d{i}", + "treatment": str(i % 2), + "response": float(i * 10 + j + c), + } + for i in range(8) + for j in range(2) + for c in range(3) + ] + ) + result = _comparison(frame) + assert result.status == "computed" + assert result.evidence["responseAggregation"] == "medianPerObservationUnit" + assert result.evidence["independentAggregation"] == "medianOfObservationMedians" + assert result.evidence["observationUnits"] == 16 + assert result.evidence["independentUnits"] == 8 + assert ( + result.evidence["descriptiveDesign"]["continuousSummaries"]["response"][ + "minimum" + ] + == 1.0 + ) + + +@pytest.mark.parametrize( + "damage,reason", + [ + ("missing", "noCompleteObservations"), + ("cellIdentity", "observationUnitIsCellIdentifier"), + ( + "inconsistentObservation", + "explanatoryColumnsMustBeConstantWithinObservationUnit", + ), + ("fewDonors", "fewerThanFourIndependentUnits"), + ], +) +def test_unsupported_designs_cannot_be_relabelled_negative_associations(damage, reason): + frame = pd.DataFrame( + [ + { + "sample": f"s{i}", + "donor": f"d{i}", + "treatment": str(i % 2), + "response": float(i + c), + } + for i in range(8) + for c in range(3) + ] + ) + if damage == "missing": + frame["response"] = np.nan + elif damage == "cellIdentity": + frame["sample"] = [str(i) for i in range(len(frame))] + elif damage == "inconsistentObservation": + frame.loc[0, "treatment"] = "different" + else: + frame = frame.iloc[:9] + result = _comparison(frame) + assert result.status == "unsupported" + assert reason in result.reasons + assert "singleAssociations" not in result.evidence + + +@pytest.mark.parametrize( + "case,reason", + [ + ("levels", "moreThanThirtyTwoCategoricalLevels"), + ("strata", "moreThanSixteenConditioningStrata"), + ("sparseStrata", "unsupportedConditioningStrata"), + ("joint", "moreThanThirtyTwoJointGroups"), + ], +) +def test_large_or_sparse_combinations_preserve_the_unsupported_reason(case, reason): + n = ( + 68 + if case == "strata" + else 49 + if case == "joint" + else 34 + if case == "levels" + else 8 + ) + rows = [ + { + "sample": f"s{i}", + "donor": f"d{i}", + "treatment": str( + i if case == "levels" else i % 7 if case == "joint" else i % 2 + ), + "stratum": str( + i // 7 if case == "joint" else i // 4 if case == "strata" else i // 2 + ), + "response": str(i % 2), + } + for i in range(n) + ] + frame = pd.DataFrame(rows).loc[np.repeat(np.arange(n), 3)].reset_index(drop=True) + result = _comparison( + frame, conditioned=case in {"strata", "sparseStrata"}, joint=case == "joint" + ) + assert result.status == "unsupported" + assert reason in result.reasons + assert result.evidence["independentUnits"] == n + if case == "sparseStrata": + assert all( + row["association"]["reason"] == "fewerThanFourIndependentUnits" + for row in result.evidence["strata"] + ) + + +def test_combination_identity_accepts_utf8_and_rejects_misaligned_columns(): + cells = _Cells(pd.DataFrame({"one": [b"alpha", b"beta"], "two": ["yes", "no"]})) + labels = combination_labels(cells, ["one", "two"]) + assert '"alpha"' in labels[0] + with pytest.raises(ValueError, match="two distinct"): + combination_labels(cells, ["one", "one"]) + cells.fetch = lambda name: np.asarray(["a", "b"] if name == "one" else ["c"]) + with pytest.raises(ValueError, match="align"): + combination_labels(cells, ["one", "two"]) diff --git a/tests/test_agent_context_efficiency.py b/tests/test_agent_context_efficiency.py new file mode 100644 index 00000000..5abae578 --- /dev/null +++ b/tests/test_agent_context_efficiency.py @@ -0,0 +1,376 @@ +"""Context retries retain measured evidence and cannot erase study questions.""" + +from types import SimpleNamespace + +import pytest +from pydantic_ai import ModelRetry + +from scarf.agent.experimental_context import tools, validation +from scarf.agent.experimental_context.contracts import ( + CaptureFailureEvidence, + CellQcPlan, + CellQcProfileEvidence, + CovariateCharacterization, + CovariateEvidence, + ExperimentalContextDecision, + ExperimentalContextDependencies, +) +from scarf.agent.experimental_context.requirements import ( + active_batch_safety, + objective_evidence, +) +from tests.test_agent_objective_requirements import _repeated_design + + +def test_provider_schema_omits_derived_fields_without_changing_serialization(): + decision = ExperimentalContextDecision() + before = decision.model_dump(mode="json") + schema = ExperimentalContextDecision.model_json_schema()["properties"] + derived = { + "cellQc", + "protectedCombinations", + "physicalCaptureColumn", + "pooledReferenceCaptures", + "unsupportedProtection", + } + assert not derived.intersection(schema) + assert derived <= before.keys() + assert ( + ExperimentalContextDecision.model_validate(before).model_dump(mode="json") + == before + ) + + +def test_illegal_qc_is_rejected_before_characterization(monkeypatch): + def forbidden(*args, **kwargs): + pytest.fail("An illegal field must be rejected before metadata work") + + monkeypatch.setattr(validation, "characterize_context", forbidden) + with pytest.raises(ModelRetry, match="cellQc blank"): + validation.validate_experimental_context( + ExperimentalContextDecision(cellQc=CellQcPlan(rationale="skip")), + ExperimentalContextDependencies(), + ) + + +def test_model_evidence_deduplicates_design_without_dropping_contradictions(): + safety = [ + { + "coefficient": "condition", + "preservesConditionCoverage": False, + "reason": "A protected group would disappear", + } + ] + failure = CaptureFailureEvidence(capture="library", conditionAndUnitSafety=safety) + profiles = [ + CellQcProfileEvidence(profileId=name, captureFailureEvidence=[failure]) + for name in ("global", "capture") + ] + full = CovariateEvidence(qcProfiles=profiles) + original = full.model_dump(mode="json") + compact = tools.compact_context_evidence(full) + assert len(compact["captureDesignSafety"]) == 1 + for profile in compact["qcProfiles"]: + ref = profile["captureFailureEvidence"][0]["designSafetyRef"] + assert compact["captureDesignSafety"][ref] == safety + assert full.model_dump(mode="json") == original + + +def test_context_details_restore_exact_capture_bounds_and_missingness(monkeypatch): + import asyncio + + failure = CaptureFailureEvidence( + capture="capture-a", + metricMissingFractions={"mitochondrial": 0.2, "counts": 0.0}, + conditionAndUnitSafety=[ + { + "coefficient": "condition", + "requiredGroups": ["rare", "common"], + "remainingGroups": ["common"], + "preservesConditionCoverage": False, + "preservesIndependentUnitCoverage": False, + } + ], + ) + profile = CellQcProfileEvidence( + profileId="capture-policy", + action="sampleMad", + attributes=["counts"], + sampleColumn="capture", + resolvedBounds=[ + {"group": "capture-a", "upperRemoval": 12.0}, + {"group": "capture-b", "upperRemoval": 15.0}, + ], + captureFailureEvidence=[failure], + ) + full = CovariateEvidence(qcProfiles=[profile]) + view = tools.compact_context_evidence(full) + capture = view["qcProfiles"][0]["captureFailureEvidence"][0] + assert capture["metricMissingness"] == { + "measuredSources": 2, + "nonzeroOrUnavailable": {"mitochondrial": 0.2}, + } + assert view["captureDesignSafety"][capture["designSafetyRef"]][0]["lostGroups"] == [ + "rare" + ] + deps = ExperimentalContextDependencies( + characterization=CovariateCharacterization(status="done"), + qcProfiles={profile.profileId: profile}, + ) + monkeypatch.setattr( + tools, + "characterize_covariates", + lambda *a, **k: pytest.fail("Details must not recompute"), + ) + detail = asyncio.run( + tools.inspect_context_evidence( + SimpleNamespace(deps=deps), "qcProfile", profile.profileId, "capture-a" + ) + ) + assert detail["capture"] == failure.model_dump(mode="json") + assert detail["resolvedBounds"] == [profile.resolvedBounds[0]] + with pytest.raises(ModelRetry, match="Choose one capture"): + asyncio.run( + tools.inspect_context_evidence( + SimpleNamespace(deps=deps), "qcProfile", profile.profileId, "unknown" + ) + ) + + +def test_compact_context_keeps_repeated_donor_failures_and_full_details(): + import asyncio + + record = { + "name": "condition", + "kind": "categorical", + "missingness": [{"missing": 3}], + "pairedCoverage": { + "complete": False, + "incompletePairs": 2, + "incompleteExamples": [{"pair": "donor-a"}, {"pair": "donor-b"}], + }, + } + characterization = CovariateCharacterization(status="done", coefficients=[record]) + view = tools.compact_context_evidence( + CovariateEvidence(characterization=characterization) + ) + coefficient = view["characterization"]["coefficients"][0] + assert coefficient["pairedCoverage"]["complete"] is False + assert coefficient["pairedCoverage"]["incompletePairs"] == 2 + assert coefficient["pairedCoverage"]["incompleteExamplesInSavedDetails"] == 2 + assert coefficient["missingness"] == [{"missing": 3}] + detail = asyncio.run( + tools.inspect_context_evidence( + SimpleNamespace( + deps=ExperimentalContextDependencies(characterization=characterization) + ), + "coefficient", + "condition", + ) + ) + assert detail == record + + +def test_completed_context_round_resumes_without_resetting_allowance(): + saved = {} + deps = ExperimentalContextDependencies( + characterization=CovariateCharacterization(status="done"), + characterizationInputs={"exact": "inputs"}, + designRounds=1, + toolCalls=["inspect_cell_covariates", "analyze_experimental_design"], + checkpointWrite=lambda key, value: saved.update({key: value}), + ) + tools.persist_context_evidence(deps, "design1") + resumed = ExperimentalContextDependencies(checkpointRead=saved.get) + assert tools.restore_context_evidence(resumed) + assert resumed.designRounds == 1 + assert resumed.characterizationInputs == deps.characterizationInputs + assert resumed.toolCalls == deps.toolCalls + assert resumed.characterization == deps.characterization + + +def test_explicit_joint_request_cannot_be_satisfied_by_marginal_proposal(): + result = _repeated_design() + objective = "Describe supported populations. Assess tissue and condition jointly" + requirements, coverage = objective_evidence( + study_context="The observations contain repeated donors and incomplete pairing.", + study_objective=objective, + experimental_result=result, + ) + missing = [ + item + for item in requirements + if item.requirementId.startswith("requestedDesign:") + ] + assert len(missing) == 1 + assert missing[0].columns == ["condition", "tissue"] + assert ( + next( + item for item in coverage if item.requirementId == missing[0].requirementId + ).status + == "unsupported" + ) + + +def test_batch_alternatives_do_not_create_untested_union_or_stale_license(): + first = SimpleNamespace(batchColumns=["library"], status="unsafe") + second = SimpleNamespace(batchColumns=["chemistry"], status="safe") + result = SimpleNamespace( + batchSafety=[first, second], decision=ExperimentalContextDecision() + ) + with pytest.raises(ValueError, match="exact assessed batch set"): + active_batch_safety(result) + result.decision.batchCorrection.batchColumns = ["chemistry"] + assert active_batch_safety(result) == [second] + result.decision.batchCorrection.batchColumns = ["library"] + assert active_batch_safety(result) == [first] + + +def test_exact_context_characterization_reuses_work_and_invalidates_changes( + monkeypatch, +): + from tests.test_agent_experimental_context import _Store, _context + + store = _Store() + deps = _context(store).deps + calls = [] + + def measured(*args, **kwargs): + calls.append(kwargs["directions"]) + return CovariateCharacterization(status="done") + + monkeypatch.setattr(tools, "characterize_covariates", measured) + first = tools.characterize_context(deps, {"columnDomains": {"batch": "technical"}}) + assert ( + tools.characterize_context(deps, {"columnDomains": {"batch": "technical"}}) + is first + ) + assert len(calls) == 1 + store.cells._values["batch"][0] = "b2" + tools.characterize_context(deps, {"columnDomains": {"batch": "technical"}}) + assert len(calls) == 2 + tools.characterize_context(deps, {"columnDomains": {"batch": "design"}}) + assert len(calls) == 3 + + +def test_context_identity_binds_added_columns_without_repeating_resume_scan( + monkeypatch, +): + from scarf.agent.orchestrator.context import _context_metadata_identity + from scarf.agent.parameter_tuning import execution + + values = {"capture": "first"} + measured = [] + + def fingerprint(metadata, column): + measured.append(column) + return values[column] + + monkeypatch.setattr(execution, "_metadata_column_fingerprint", fingerprint) + store = SimpleNamespace(cells=SimpleNamespace(columns=["original", "capture"])) + request = SimpleNamespace( + inputIdentity={"data": {"metadata": {"original": "validated"}}} + ) + first = _context_metadata_identity(store, request) + assert first == {"original": "validated", "capture": "first"} + assert measured == ["capture"] + values["capture"] = "changed" + assert _context_metadata_identity(store, request) != first + + +def test_generic_joint_request_also_requires_a_joint_proposal(): + result = _repeated_design() + requirements, coverage = objective_evidence( + study_context="The observations contain repeated donors and incomplete pairing.", + study_objective="Describe supported populations. Assess individual and combined covariates", + experimental_result=result, + ) + assert any( + item.requirementId.startswith("requestedDesign:") for item in requirements + ) + assert any( + item.requirementId.startswith("requestedDesign:") + and item.status == "unsupported" + for item in coverage + ) + + +@pytest.mark.parametrize( + "change", [None, "metadata", "features", "cohort", "noRevision"] +) +def test_tuning_context_revision_preserves_only_exact_numerical_inputs( + monkeypatch, change +): + from copy import deepcopy + from scarf.agent.orchestrator import journal + from scarf.agent.orchestrator.models import StageEvidenceReference + from scarf.agent.orchestrator.tuning import _tuning_revision_provenances + + request = SimpleNamespace(requestSha256="request", configSha256="config") + + def reference(name): + return StageEvidenceReference( + workflowRunId="workflow", + stage="experimental_context", + key=name, + contentSha256=name, + ) + + old_ref, new_ref = reference("old"), reference("new") + previous = { + "preprocessedAssays": [{"cells": "frozen", "features": "genes"}], + "featureMetadataFingerprints": {"names": "same"}, + "metadataFingerprints": {"condition": "same"}, + "studyContract": {"protectedCombinations": []}, + } + current = deepcopy(previous) + current["studyContract"] = {"protectedCombinations": [["condition", "tissue"]]} + current["metadataFingerprints"]["tissue"] = "newly measured" + old = SimpleNamespace( + attemptId="old", + status="done", + reportReferences=[old_ref], + inputs={}, + outputs={"studyContract": previous["studyContract"]}, + requestSha256="request", + configSha256="config", + ) + new = SimpleNamespace( + attemptId="new", + status="done", + reportReferences=[new_ref], + inputs={"reassessContextReport": old_ref.model_dump(mode="json")}, + outputs={"studyContract": current["studyContract"]}, + requestSha256="request", + configSha256="config", + ) + if change == "metadata": + current["metadataFingerprints"]["condition"] = "changed" + elif change == "features": + current["featureMetadataFingerprints"]["names"] = "changed" + elif change == "cohort": + current["preprocessedAssays"][0]["cells"] = "different" + elif change == "noRevision": + new.inputs = {} + monkeypatch.setattr(journal, "_stage_outcomes", lambda *args: [old, new]) + before = deepcopy(previous) + + def invoke(): + return _tuning_revision_provenances( + SimpleNamespace(zw=None), + "prefix", + "workflow", + request, + new_ref, + current, + [SimpleNamespace(inputs=previous)], + ) + + if change is not None: + with pytest.raises(ValueError, match="changed beyond"): + invoke() + else: + assert invoke() == [ + {**previous, "requestSha256": "request", "configSha256": "config"} + ] + assert previous == before diff --git a/tests/test_agent_context_journal_revision.py b/tests/test_agent_context_journal_revision.py new file mode 100644 index 00000000..d1e3524f --- /dev/null +++ b/tests/test_agent_context_journal_revision.py @@ -0,0 +1,208 @@ +"""Context evidence revisions append history and reject changed measured metadata.""" + +from types import SimpleNamespace + +import pytest + +from scarf.agent.experimental_context import agent as context_agent +from scarf.agent.experimental_context.contracts import ( + ExperimentalContextDecision, + ExperimentalContextResult, +) +from scarf.agent.experimental_context.characterization import characterize_covariates +from scarf.agent.experimental_context.study import build_study_contract +from scarf.agent.orchestrator import ( + AgentOrchestrator, + AutomatedWorkflowConfig, + AutomatedWorkflowRequest, +) +from scarf.agent.orchestrator import context, journal +from scarf.agent.orchestrator.models import ( + OrchestrationRequestRecord, + StageEvidenceReference, + WorkflowIdentity, +) +from scarf.agent.types import ArtifactReferenceModel +from scarf.datastore.datastore import DataStore +from tests.agent_orchestrator_store import create_store + + +@pytest.mark.parametrize("damage", [None, "currentMetadata", "historicalMetadata"]) +def test_missing_joint_question_appends_revision_without_replacing_completed_context( + tmp_path, monkeypatch, damage +): + path = create_store(tmp_path / "context-revision.zarr") + store = DataStore(str(path), default_assay="RNA", min_features_per_cell=0) + selected = store.snapshot_cell_selection("I") + selection = ArtifactReferenceModel.from_artifact_ref(selected) + known = characterize_covariates( + store, cellSelection=selected, model=None, directions={} + ) + report = ExperimentalContextResult( + status="done", + decision=ExperimentalContextDecision(), + characterization=known, + cellSelection=selection, + ) + contract = build_study_contract( + study_context="Known observed covariates.", + study_objective="Describe populations.", + experimental_result=report, + ) + request = OrchestrationRequestRecord( + workflowRunId="context-revision", + modelIdentity="test", + inputIdentity={}, + config=AutomatedWorkflowConfig(inputPolicy="unattended"), + request=AutomatedWorkflowRequest( + sourcePath=str(path), + zarrPath=str(path), + studyContext="Known observed covariates. Assess individual and combined covariates.", + studyObjective="Describe populations.", + ), + ) + workflow = WorkflowIdentity(request.workflowRunId) + prefix = journal._ensure_orchestration_store(store) + old_inputs = ( + {"metadataFingerprints": {"ids": "changed"}} + if damage == "currentMetadata" + else {} + ) + started = journal._start_attempt( + store.zw, + prefix, + workflow.workflowRunId, + "experimental_context", + request, + [], + inputs=old_inputs, + ) + _, reference = journal._save_stage_report( + store, started, report, expected_type=ExperimentalContextResult + ) + old = journal._complete_attempt( + started, + status="done", + report_references=[reference], + artifacts={"cellSelection": selection}, + outputs={"studyContract": contract.model_dump(mode="json")}, + ) + journal._save_outcome(store.zw, prefix, old) + original = journal.read_stage_evidence(store, reference) + if damage == "historicalMetadata": + journal._start_attempt( + store.zw, + prefix, + workflow.workflowRunId, + "parameter_tuning", + request, + [], + inputs={"metadataFingerprints": {"ids": "changed"}}, + ) + observed = [] + + class Review: + def __init__(self, *args, **kwargs): + pass + + def run(self, *args, **kwargs): + observed.append(kwargs["previous_context"]) + return report.model_copy( + update={ + "status": "needsInput", + "notes": ["Joint evidence remains unresolved"], + } + ) + + monkeypatch.setattr(context, "ExperimentalContextAgent", Review) + + def execute(): + return AgentOrchestrator(object()).experimental_context_stage( + store, + workflow, + request, + [], + selection, + StageEvidenceReference( + workflowRunId=workflow.workflowRunId, + stage="data_enrichment", + key="unused", + contentSha256="a" * 64, + ), + [], + [], + {}, + ) + + if damage: + with pytest.raises(ValueError, match="metadata"): + execute() + assert not observed + else: + revised, result = execute() + assert revised.status == "failed" + assert result.status == "needsInput" + assert revised.attemptId != old.attemptId + assert revised.inputs["reassessContextReport"] == reference.model_dump( + mode="json" + ) + assert ( + revised.inputs["requiredDesignQuestions"][0]["question"] + == "Assess individual and combined covariates" + ) + assert observed == [report] + assert journal.read_stage_evidence(store, reference) == original + + +@pytest.mark.parametrize( + "directions,error", + [ + ({"excludeColumns": "cell_type"}, "excludeColumns must be a list"), + ( + {"coefficientsOfInterest": ["cell_type"], "excludeColumns": ["cell_type"]}, + "forbids runtime use", + ), + ({"nested": {"protected": ["cell_type"]}}, "forbids runtime use"), + ], +) +def test_held_out_author_annotations_cannot_reenter_context_through_nested_directions( + tmp_path, monkeypatch, directions, error +): + import numpy as np + + path = create_store(tmp_path / "holdout.zarr") + store = DataStore(str(path), default_assay="RNA", min_features_per_cell=0) + store.cells.insert("cell_type", np.asarray(["A", "A", "B", "B"])) + request = OrchestrationRequestRecord( + workflowRunId="holdout", + modelIdentity="test", + inputIdentity={}, + request=AutomatedWorkflowRequest( + sourcePath=str(path), + zarrPath=str(path), + studyContext="Observed samples.", + studyObjective="Describe populations.", + experimentalDirections=directions, + ), + ) + monkeypatch.setattr( + context_agent, + "run_agent_sync", + lambda **k: pytest.fail( + "Held-out annotations must fail before model execution" + ), + ) + with pytest.raises(ValueError, match=error): + AgentOrchestrator(object()).experimental_context_stage( + store, + WorkflowIdentity("holdout"), + request, + [], + ArtifactReferenceModel.from_artifact_ref( + store.snapshot_cell_selection("I") + ), + SimpleNamespace(), + [], + [], + {}, + ) diff --git a/tests/test_agent_context_resume_boundaries.py b/tests/test_agent_context_resume_boundaries.py new file mode 100644 index 00000000..ecc9acc7 --- /dev/null +++ b/tests/test_agent_context_resume_boundaries.py @@ -0,0 +1,280 @@ +"""Committed context decisions and bounded details retain exact scientific inputs.""" + +import asyncio +from types import SimpleNamespace + +import pytest +from pydantic import ValidationError +from pydantic_ai import ModelRetry, UnexpectedModelBehavior + +from scarf.agent.experimental_context import agent as context_agent +from scarf.agent.experimental_context import tools +from scarf.agent.experimental_context.contracts import ( + CellQcProfileEvidence, + CovariateCharacterization, + CovariateEvidence, + ExperimentalContextDependencies, + ExperimentalContextResult, + QcMetricSourceEvidence, + NamedArtifactSource, +) +from scarf.storage.refs import ArtifactRef +from scarf.agent.types import AgentRunInfo, ArtifactReferenceModel, ToolCallInfo +from tests.test_agent_design_comparisons import _design, _proposal +from tests.test_agent_experimental_context import _Store + + +def test_committed_context_result_replays_without_a_model_and_rejects_other_selection( + monkeypatch, +): + store = _Store() + selected = ArtifactReferenceModel.from_artifact_ref(store.cell_selection) + report = ExperimentalContextResult.get_blank().model_copy( + update={"cellSelection": selected} + ) + monkeypatch.setattr( + context_agent, "_derive_missing_percentage_artifacts", lambda *a, **k: [] + ) + monkeypatch.setattr( + context_agent, + "run_agent_sync", + lambda **k: pytest.fail("Committed result must not invoke a model"), + ) + agent = context_agent.ExperimentalContextAgent(object()) + result = agent.run( + store, + cell_selection=store.cell_selection, + checkpoint_read=lambda key: ( + {"report": report.model_dump(mode="json")} if key == "result" else None + ), + ) + assert result == report + report.cellSelection = selected.model_copy(update={"artifactId": "f" * 64}) + with pytest.raises(ValueError, match="different cell selection"): + agent.run( + store, + cell_selection=store.cell_selection, + checkpoint_read=lambda key: ( + {"report": report.model_dump(mode="json")} if key == "result" else None + ), + ) + + +@pytest.mark.parametrize("rounds", [1, 2]) +def test_explicit_context_revision_preserves_rounds_and_complete_study_text( + monkeypatch, rounds +): + store = _Store() + previous = ExperimentalContextResult.get_blank().model_copy( + update={ + "cellSelection": ArtifactReferenceModel.from_artifact_ref( + store.cell_selection + ), + "characterization": CovariateCharacterization( + status="done", columns=[{"name": "condition"}, {"name": "batch"}] + ), + "runInfo": AgentRunInfo( + agentName="context", + modelName="test", + toolCalls=[ + ToolCallInfo(toolName="analyze_experimental_design") + for _ in range(rounds) + ], + ), + } + ) + text = "Study provenance. " * 150 + "Assess condition and batch jointly." + monkeypatch.setattr( + context_agent, "_derive_missing_percentage_artifacts", lambda *a, **k: [] + ) + seen = [] + + def inspect(**kwargs): + deps = kwargs["deps"] + seen.append(deps.designRounds) + assert deps.characterization == previous.characterization + assert text in kwargs["user_prompt"] + assert "Committed evidence already measured" in kwargs["user_prompt"] + assert "Explicit requested questions" in kwargs["user_prompt"] + raise UnexpectedModelBehavior("No additional provider attempt is available") + + monkeypatch.setattr(context_agent, "run_agent_sync", inspect) + result = context_agent.ExperimentalContextAgent(object()).run( + store, + cell_selection=store.cell_selection, + study_context=text, + previous_context=previous, + ) + assert result.status == "failed" + assert seen == [rounds] + assert previous.runInfo.toolCalls[0].toolName == "analyze_experimental_design" + + +def test_saved_details_return_complete_inventory_and_policy_without_computation(): + cells, characterization = _design() + from scarf.agent.experimental_context.comparisons import compare_covariates + + measured = compare_covariates( + cells, characterization, _proposal(), selection_identity={} + ) + characterization.comparisons = [measured] + characterization.confounding = [ + {"coefficient": "response", "reason": "exact joint alias"} + ] + characterization.columns[0]["levelCounts"] = [ + {"value": str(i), "count": 2} for i in range(12) + ] + profile = CellQcProfileEvidence( + profileId="capture", + action="sampleMad", + sampleColumn="sample", + attributes=["counts"], + parameters={ + "resolvedBounds": [{"upper": 8}], + "captureComparisons": [{"unsafe": True}], + }, + resolvedBounds=[{"upper": 8}], + ) + deps = ExperimentalContextDependencies( + characterization=characterization, qcProfiles={"capture": profile} + ) + ctx = SimpleNamespace(deps=deps) + compact = tools.compact_context_evidence( + CovariateEvidence(characterization=characterization, qcProfiles=[profile]) + ) + assert compact["characterization"]["columns"][0]["levelCountsOmitted"] == 4 + detail = asyncio.run(tools.inspect_context_evidence(ctx, "column", "sample")) + assert len(detail["levelCounts"]) == 12 + assert asyncio.run( + tools.inspect_context_evidence(ctx, "comparison", measured.evidenceId) + ) == measured.model_dump(mode="json") + assert ( + asyncio.run(tools.inspect_context_evidence(ctx, "confounding", "0")) + == characterization.confounding[0] + ) + policy = asyncio.run(tools.inspect_context_evidence(ctx, "qcProfile", "capture")) + assert "captureDetailsRequired" in policy + assert "resolvedBounds" not in policy + assert profile.resolvedBounds == [{"upper": 8}] + for section, key, capture in [ + ("qcProfile", "missing", None), + ("column", "missing", None), + ("confounding", "99", None), + ("column", "sample", "capture"), + ]: + with pytest.raises(ModelRetry): + asyncio.run(tools.inspect_context_evidence(ctx, section, key, capture)) + with pytest.raises(ModelRetry, match="Inspect covariates"): + asyncio.run( + tools.inspect_context_evidence( + SimpleNamespace(deps=ExperimentalContextDependencies()), + "column", + "sample", + ) + ) + + +@pytest.mark.parametrize( + "change,reason", + [ + ({"metadataColumn": None}, "only metadataColumn"), + ({"origin": "derivedArtifact"}, "ingestionMetadata"), + ({"sourceType": "artifact", "metadataColumn": None}, "only artifact"), + ({"activeCells": 2, "missingCells": 3}, "counts are inconsistent"), + ( + {"activeCells": 3, "missingCells": 1, "usableForFiltering": True}, + "cannot drive filtering", + ), + ], +) +def test_qc_metric_source_cannot_claim_valid_filtering_with_inconsistent_provenance( + change, reason +): + with pytest.raises(ValidationError, match=reason): + QcMetricSourceEvidence.model_validate( + { + "sourceId": "source", + "metricName": "counts", + "metadataColumn": "counts", + **change, + } + ) + + +@pytest.mark.parametrize( + "change,reason", + [ + ({"response": ""}, "non-empty"), + ( + {"explanatoryColumns": ["treatment"], "protectCombination": True}, + "two explanatory", + ), + ], +) +def test_proposal_cannot_omit_its_question_or_invent_combination_protection( + change, reason +): + with pytest.raises(ValidationError, match=reason): + _proposal(**change) + + +@pytest.mark.parametrize( + "damage,reason", + [ + ("runAndSelection", "mutually exclusive"), + ("foreignRun", "opened from this datastore"), + ("notSelection", "datastore cell_selection"), + ("wrongNeighbors", "neighbors ArtifactRef"), + ("foreignNeighbors", "same cell selection"), + ("wrongConnectivity", "connectivity graph"), + ("foreignConnectivity", "same cell selection"), + ("cellKey", "cellQc.cellKey"), + ("duplicateMetric", "source names must be unique"), + ], +) +def test_context_rejects_foreign_artifacts_and_ambiguous_sources_before_model_execution( + monkeypatch, damage, reason +): + store = _Store() + wrong = ArtifactRef("datastore", "cell_selection", "f" * 64) + metric = NamedArtifactSource( + name="mitochondrial", + artifact=ArtifactReferenceModel( + scope="assay", assay="RNA", kind="quality_metric", artifactId="c" * 64 + ), + ) + monkeypatch.setattr( + context_agent, + "_derive_missing_percentage_artifacts", + lambda *a, **k: [metric, metric] if damage == "duplicateMetric" else [], + ) + monkeypatch.setattr( + context_agent, "resolve_cell_aligned_artifact", lambda *a, **k: None + ) + monkeypatch.setattr(context_agent, "graph_cell_selection", lambda *a: wrong) + monkeypatch.setattr( + context_agent, + "run_agent_sync", + lambda **k: pytest.fail("Invalid artifacts cannot reach the model"), + ) + kwargs = {"cell_selection": store.cell_selection} + if damage == "runAndSelection": + kwargs["run"] = SimpleNamespace(_owner=store) + elif damage == "foreignRun": + kwargs = {"run": SimpleNamespace(_owner=object())} + elif damage == "notSelection": + kwargs["cell_selection"] = ArtifactRef("assay", "neighbors", "a" * 64, "RNA") + elif damage == "wrongNeighbors": + kwargs["neighbors"] = store.cell_selection + elif damage == "foreignNeighbors": + kwargs["neighbors"] = ArtifactRef("assay", "neighbors", "a" * 64, "RNA") + elif damage == "wrongConnectivity": + kwargs["connectivity_map"] = store.cell_selection + elif damage == "foreignConnectivity": + kwargs["connectivity_map"] = ArtifactRef( + "assay", "connectivity_map", "a" * 64, "RNA" + ) + elif damage == "cellKey": + kwargs["directions"] = {"cellQc": {"cellKey": "I"}} + with pytest.raises((ValueError, TypeError), match=reason): + context_agent.ExperimentalContextAgent(object()).run(store, **kwargs) diff --git a/tests/test_agent_data_enrichment.py b/tests/test_agent_data_enrichment.py index 4ec5aa44..c421757b 100644 --- a/tests/test_agent_data_enrichment.py +++ b/tests/test_agent_data_enrichment.py @@ -1137,4 +1137,4 @@ def test_failed_enrichment_retains_partial_evidence_without_inventing_policy() - assert failed.status == "failed" assert failed.policies == [] assert failed.inspections == list(inspections.values()) - assert "model failed" in failed.limitations + assert "RuntimeError: model failed" in failed.limitations diff --git a/tests/test_agent_decide.py b/tests/test_agent_decide.py index 2cdd454b..1ef7d5ea 100644 --- a/tests/test_agent_decide.py +++ b/tests/test_agent_decide.py @@ -72,26 +72,24 @@ def test_decide_with_test_model_returns_schema_valid_decision() -> None: validate_decision(result, _evidence()) -def test_validate_decision_coerces_selected_id_embedded_in_line() -> None: +def test_validate_decision_rejects_selected_id_embedded_in_line() -> None: decision = Decision( selectedId="id=matrix:raw/X | label=raw/X | summary=integer-like", rationale="integer-like", evidenceIds=[], ) - result = validate_decision(decision, _evidence()) - assert result.selectedId == "matrix:raw/X" - assert result.evidenceIds == ["matrix:raw/X"] + with pytest.raises(DecisionValidationError, match="not in evidence ids"): + validate_decision(decision, _evidence()) -def test_validate_decision_coerces_id_equals_prefix_in_evidence_ids() -> None: +def test_validate_decision_rejects_id_equals_prefix_in_evidence_ids() -> None: decision = Decision( selectedId="id=matrix:raw/X", rationale="echoed prompt scaffolding", evidenceIds=["id=matrix:raw/X"], ) - result = validate_decision(decision, _evidence()) - assert result.selectedId == "matrix:raw/X" - assert result.evidenceIds == ["matrix:raw/X"] + with pytest.raises(DecisionValidationError, match="not in evidence ids"): + validate_decision(decision, _evidence()) def test_decide_rejects_unknown_selected_id() -> None: @@ -100,12 +98,13 @@ def test_decide_rejects_unknown_selected_id() -> None: rationale="guess", evidenceIds=["matrix:missing"], ) - with pytest.raises(DecisionValidationError, match="selectedId"): + with pytest.raises(DecisionValidationError) as caught: decide( model=_function_model(bad), question="Which matrix looks like raw counts?", evidence=_evidence(), ) + assert "selectedId" in caught.value.agent_run_info.error def test_validate_decision_rejects_unknown_evidence_ids() -> None: diff --git a/tests/test_agent_decision_replay.py b/tests/test_agent_decision_replay.py new file mode 100644 index 00000000..436bd0aa --- /dev/null +++ b/tests/test_agent_decision_replay.py @@ -0,0 +1,203 @@ +"""QC decision ownership, human answers and replay retain exact verified evidence.""" + +from types import SimpleNamespace +from typing import Any +import json + +import pytest + +from scarf.agent.decisions.kernel import DecisionSelection +from scarf.agent.decisions.rna import build_qc_grouping_decision +from scarf.agent.orchestrator import decisions +from tests.test_agent_rna_adaptive import checkpoints as memory_checkpoints # noqa: F401 +from tests.test_agent_rna_decisions import _bundle + + +@pytest.fixture +def decision_case(request: pytest.FixtureRequest) -> Any: + request.getfixturevalue("memory_checkpoints") + definition = build_qc_grouping_decision( + evidence_bundle_id="bundle:qc", + physical_capture_eligible=True, + pooled_reference_eligible=False, + ) + evidence = _bundle("qcGrouping", "bundle:qc", ["qualityControl", "design"]) + selection = DecisionSelection( + selectedOptionId="qcGrouping:global", + evidenceIds=[item.evidenceId for item in evidence.evidence], + rationale="Global thresholds preserve the measured groups without losing a capture.", + confidence="medium", + ) + request = SimpleNamespace( + workflowRunId="workflow", requestSha256="a" * 64, configSha256="b" * 64 + ) + owner = decisions.DecisionStagesMixin() + return owner, request, definition, evidence, selection + + +@pytest.mark.parametrize("owner_name", ["rule", "agent", "human"]) +@pytest.mark.parametrize("defer", [False, True]) +def test_committed_qc_choice_replays_exact_owner_and_pending_outcome( + decision_case: Any, + request: pytest.FixtureRequest, + monkeypatch: pytest.MonkeyPatch, + owner_name: str, + defer: bool, +) -> None: + saved = request.getfixturevalue("memory_checkpoints") + owner, request, definition, evidence, selection = decision_case + if defer: + selection = selection.model_copy( + update={"selectedOptionId": "qcGrouping:defer"} + ) + answers, kwargs = {}, {} + if owner_name == "human": + answers["decision:qcGrouping"] = { + "decisionId": "qcGrouping", + "optionId": selection.selectedOptionId, + "rationale": selection.rationale, + } + else: + kwargs[f"{owner_name}_selection"] = selection + monkeypatch.setattr( + decisions, + "run_agent_sync", + lambda **kw: pytest.fail( + "Committed or explicitly owned decisions must not call a model" + ), + ) + result = owner._resolve_rna_decision( + None, request, definition, evidence, answers, **kwargs + ) + assert result.record.source == owner_name + assert (result.pending is not None) == defer + assert (result.compiled is None) == defer + replay = owner._resolve_rna_decision( + None, request, definition, evidence, answers, **kwargs + ) + assert result == replay + if defer: + assert owner._pending_decision_question(replay, definition).options == [ + item.optionId for item in definition.spec.options + ] + else: + with pytest.raises(ValueError, match="no pending question"): + owner._pending_decision_question(replay, definition) + next(iter(saved.values()))["outputs"]["checks"] = [] + with pytest.raises(ValueError, match="checks differ"): + owner._resolve_rna_decision( + None, request, definition, evidence, answers, **kwargs + ) + + +@pytest.mark.parametrize( + "failure", + [ + "twoOwners", + "ruleAnswer", + "agentAnswer", + "notMapping", + "wrongDecision", + "unoffered", + "wrongIdentity", + ], +) +def test_ambiguous_or_unbound_decision_requests_fail_before_persistence( + decision_case: Any, request: pytest.FixtureRequest, failure: str +) -> None: + saved = request.getfixturevalue("memory_checkpoints") + owner, request, definition, evidence, selection = decision_case + answer = { + "decisionId": "qcGrouping", + "optionId": "qcGrouping:global", + "rationale": selection.rationale, + } + answers, kwargs = {}, {} + if failure == "twoOwners": + kwargs = {"rule_selection": selection, "agent_selection": selection} + reason = "two supplied owners" + elif failure in {"ruleAnswer", "agentAnswer"}: + kwargs = {f"{failure[:-6]}_selection": selection} + answers["decision:qcGrouping"] = answer + reason = "cannot accept a human answer" + elif failure == "notMapping": + answers["decision:qcGrouping"] = "global" + reason = "must be a mapping" + elif failure == "wrongDecision": + answers["decision:qcGrouping"] = {**answer, "decisionId": "other"} + reason = "must name this decisionId" + elif failure == "unoffered": + answers["decision:qcGrouping"] = {**answer, "optionId": "global"} + reason = "offered option" + else: + evidence = evidence.model_copy(update={"decisionId": "other"}) + reason = "exact evidence identities differ" + with pytest.raises(ValueError, match=reason): + owner._resolve_rna_decision( + None, request, definition, evidence, answers, **kwargs + ) + assert not saved + + +def test_programmatic_record_creation_requires_digest_and_exact_option( + decision_case: Any, +) -> None: + _, _, definition, evidence, selection = decision_case + with pytest.raises(ValueError, match="content digest"): + decisions._record_from_selection( + definition, evidence, selection, "rule", None, None, 0 + ) + selection = selection.model_copy(update={"selectedOptionId": "global"}) + with pytest.raises(ValueError, match="not offered"): + decisions._record_from_selection( + definition, evidence.with_content_sha256(), selection, "rule", None, None, 0 + ) + + +def test_qc_model_and_checkpoint_receive_exact_policy_evidence( + decision_case: Any, request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch +) -> None: + from scarf.agent.config import AgentRunConfig + + saved = request.getfixturevalue("memory_checkpoints") + owner, request, definition, evidence, selection = decision_case + owner.model = object() + request.config = SimpleNamespace(agentRunConfig=AgentRunConfig()) + request.request = SimpleNamespace( + studyContext="Observed captures", studyObjective="Preserve the rare joint group" + ) + policy_evidence = { + "policies": [ + { + "evidenceId": evidence.evidence[0].evidenceId, + "resolvedBounds": {"mitochondrial": {"upper": 7.125}}, + "retainedCellsByCombination": {"condition/sex": {"treated/F": 12}}, + } + ] + } + calls = [] + + def provider(**kwargs: Any) -> Any: + payload = json.loads(kwargs["user_prompt"]) + assert payload["qcPolicyEvidence"] == policy_evidence + assert "exact thresholds" in kwargs["system_prompt"] + calls.append(payload) + return SimpleNamespace( + output=kwargs["output_validator"](selection), + runInfo=SimpleNamespace(modelName="offline-reviewer"), + ) + + monkeypatch.setattr(decisions, "run_agent_sync", provider) + result = owner._resolve_rna_decision( + None, request, definition, evidence, {}, qc_evidence=policy_evidence + ) + checkpoint = next(iter(saved.values())) + assert checkpoint["inputs"]["qcPolicyEvidence"] == policy_evidence + assert result.record.rationale == selection.rationale + assert ( + owner._resolve_rna_decision( + None, request, definition, evidence, {}, qc_evidence=policy_evidence + ) + == result + ) + assert len(calls) == 1 diff --git a/tests/test_agent_design_comparisons.py b/tests/test_agent_design_comparisons.py index 42052c1b..85d4475e 100644 --- a/tests/test_agent_design_comparisons.py +++ b/tests/test_agent_design_comparisons.py @@ -473,7 +473,12 @@ async def reply(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: agent = Agent( FunctionModel(reply), deps_type=ExperimentalContextDependencies, - tools=[Tool(tools.analyze_experimental_design, max_retries=3)], + tools=[ + Tool( + tools.model_evidence_tool(tools.analyze_experimental_design), + max_retries=3, + ) + ], ) result = agent.run_sync("Compare the observed study design.", deps=deps) assert result.output == "Comparison evidence computed." diff --git a/tests/test_agent_diagnostic_accounting.py b/tests/test_agent_diagnostic_accounting.py new file mode 100644 index 00000000..11abf186 --- /dev/null +++ b/tests/test_agent_diagnostic_accounting.py @@ -0,0 +1,332 @@ +"""Diagnostic accounting records calls and verified reuse without inventing work.""" + +from types import SimpleNamespace +from typing import Any + +import numpy as np +import pytest + +from scarf.agent.parameter_tuning import diagnostics as d +from scarf.agent.parameter_tuning import execution as e +from scarf.agent.parameter_tuning.contracts import ( + ArtifactRecord, + ParameterCandidateEvaluation, +) +from tests.test_agent_diagnostic_boundaries import _capture_scoring_store, _ref +from tests.test_agent_parameter_tuning import _FakeStore, _dependencies + + +def test_operation_counts_preserve_arguments_results_failures_and_scope() -> None: + value = object() + error = RuntimeError("diagnostic failed") + + def success(argument: Any, *, flag: bool) -> Any: + assert argument is value and flag is False + return value + + def failure() -> Any: + raise error + + assert e.diagnostic_call("core.operation", success, value, flag=False) is value + with e.diagnostic_work() as outer: + assert e.diagnostic_call("core.operation", success, value, flag=False) is value + with e.diagnostic_work() as inner: + with pytest.raises(RuntimeError) as caught: + e.diagnostic_call("core.operation", failure) + assert caught.value is error + e.diagnostic_call("core.operation", success, value, flag=False) + assert inner["core.operation"] == { + "attempted": 1, + "completed": 0, + "failed": 1, + "cacheHits": 0, + "restored": 0, + "artifactReuses": 0, + } + assert outer["core.operation"]["attempted"] == 2 + assert outer["core.operation"]["completed"] == 2 + assert outer["core.operation"]["failed"] == 0 + assert outer["core.operation"]["artifactReuses"] == 0 + e.diagnostic_reuse("outside", "restored") + assert "outside" not in outer + + +def test_failed_metric_is_retried_and_exact_cache_hits_are_counted() -> None: + attempts = [] + value = object() + + def calculate() -> Any: + attempts.append(1) + if len(attempts) == 1: + raise ValueError("metric unavailable") + return value + + with e.diagnostic_work() as counts, e.candidate_metric_cache(): + key = (123, "graph_connectivity", "exact graph", "exact labels") + with pytest.raises(ValueError, match="unavailable"): + e._cached_candidate_metric(key, calculate) + assert e._cached_candidate_metric(key, calculate) is value + assert e._cached_candidate_metric(key, calculate) is value + assert ( + e._cached_candidate_metric((*key, "changed selection"), calculate) is value + ) + assert attempts == [1, 1, 1] + assert counts["metric.graph_connectivity"] == { + "attempted": 3, + "completed": 2, + "failed": 1, + "cacheHits": 1, + "restored": 0, + "artifactReuses": 0, + } + with e.diagnostic_work() as uncached: + e._cached_candidate_metric(key, calculate) + e._cached_candidate_metric(key, calculate) + assert uncached["metric.graph_connectivity"]["completed"] == 2 + assert uncached["metric.graph_connectivity"]["cacheHits"] == 0 + + +@pytest.mark.parametrize("fails", [False, True]) +def test_capture_diagnostic_operations_are_counted_including_partial_failure( + monkeypatch: pytest.MonkeyPatch, fails: bool +) -> None: + labels = np.asarray(["tiny"] * 2 + ["small"] * 3 + ["large"] * 5) + store, selected, _, calls = _capture_scoring_store(monkeypatch, labels) + original = store.run_doublet_detection + attempted = [] + + def score(*args: Any, **kwargs: Any) -> Any: + attempted.append(1) + if fails and len(attempted) == 2: + raise RuntimeError("second capture interrupted") + return original(*args, **kwargs) + + store.run_doublet_detection = score + with e.diagnostic_work() as counts: + if fails: + with pytest.raises(RuntimeError, match="interrupted"): + d.score_advisory_doublets( + store, + selected, + [selected], + assay="RNA", + feature_selection=_ref("feature_selection"), + capture_column="capture", + ) + else: + evidence = d.score_advisory_doublets( + store, + selected, + [selected], + assay="RNA", + feature_selection=_ref("feature_selection"), + capture_column="capture", + ) + assert evidence.capture_coverage == 0.8 + assert counts["core.doubletDetection"] == { + "attempted": 2, + "completed": 1 if fails else 2, + "failed": int(fails), + "cacheHits": 0, + "restored": 0, + "artifactReuses": 0, + } + for name in ("Normalization", "Pca", "Ann", "Neighbors", "Graph", "Partition"): + assert counts[f"core.capture{name}"]["completed"] == 2 + assert counts["core.captureSelection"]["completed"] == (2 if fails else 3) + assert [row[2]["dims"] for row in calls if row[0] == "pca"] == [4, 2] + assert [row[2]["k"] for row in calls if row[0] == "neighbors"] == [4, 2] + + +def test_candidate_reuse_does_not_add_core_calls_or_claim_numerical_rebuilds() -> None: + store = _FakeStore() + deps = _dependencies(store) + identifier = next(iter(deps.candidates)) + with e.diagnostic_work() as counts: + first = e.execute_parameter_candidate(deps, identifier) + assert first.status == "done" + before = {name: dict(row) for name, row in counts.items()} + assert e.execute_parameter_candidate(deps, identifier) is first + assert counts["candidate.evaluation"]["cacheHits"] == 1 + for name, row in before.items(): + assert counts[name] == row + for name in ("pca", "ann", "neighbors", "graph", "partition"): + assert counts[f"core.{name}"]["completed"] == 1 + assert counts[f"core.{name}"]["artifactReuses"] == 0 + + +def test_doublet_restoration_is_recorded_only_after_complete_inventory_validation() -> ( + None +): + evaluation = ParameterCandidateEvaluation( + artifacts={ + "doubletScore:0": ArtifactRecord.from_ref(_ref("quality_metric")), + "doubletCellSelection:0": ArtifactRecord.from_ref(_ref("cell_selection")), + "doubletNativeGraph": ArtifactRecord.from_ref(_ref("connectivity_map")), + "doubletNativeClusters": ArtifactRecord.from_ref(_ref("cluster_labels")), + } + ) + evaluation.metrics.doubletScoreByCapture = {"capture": {"p90": 0.8}} + with e.diagnostic_work() as counts: + evidence = d.restore_advisory_doublets(evaluation, capture_column="capture") + assert len(evidence.scores) == 1 + del evaluation.artifacts["doubletCellSelection:0"] + with pytest.raises(ValueError, match="lacks"): + d.restore_advisory_doublets(evaluation, capture_column="capture") + assert counts == { + "diagnostic.advisoryDoublets": { + "attempted": 0, + "completed": 0, + "failed": 0, + "cacheHits": 0, + "restored": 1, + "artifactReuses": 0, + } + } + + +def test_validated_pca_artifact_reuse_precedes_numerical_arrays( + monkeypatch: pytest.MonkeyPatch, +) -> None: + payload = { + "component_variance": np.ones(2), + "explained_variance_ratio": np.ones(2), + "top_loading_feature_indices": np.tile(np.arange(3), (2, 1)), + "top_loading_values": np.ones((2, 3)), + "family_enrichment": np.ones((0, 2)), + "covariate_association": np.ones((0, 2)), + } + + class Saved(dict): + attrs = {"payload_fingerprint": "validated"} + + saved = Saved(payload) + store = SimpleNamespace( + zw=None, + cells=None, + inspect_artifact=lambda _: SimpleNamespace(parameters={"feat_scaling": True}), + load_artifact=lambda ref: ( + {"data": np.ones((4, 2)), "loadings": np.ones((3, 2))} + if ref.kind == "reduction" + else saved + ), + ) + evaluation = ParameterCandidateEvaluation( + artifacts={ + "pca": ArtifactRecord.from_ref(_ref("reduction")), + "neighbors": ArtifactRecord.from_ref(_ref("neighbors")), + } + ) + monkeypatch.setattr(d, "as_zarr_array", lambda value, **_: value) + monkeypatch.setattr(d, "fingerprint_stored_arrays", lambda *_: "validated") + monkeypatch.setattr( + d, + "plan_artifact", + lambda *_, **__: SimpleNamespace(reused=True, ref=_ref("feature_summary")), + ) + + def forbidden(*args: Any, **kwargs: Any) -> Any: + pytest.fail("Validated PCA diagnostics must bypass numerical arrays") + + for name in ("_component_variance", "_scaled_total_variance", "_top_loadings"): + monkeypatch.setattr(d, name, forbidden) + with e.diagnostic_work() as counts: + result = e.diagnostic_call( + "diagnostic.pca", + d._write_pca_diagnostic, + store, + evaluation, + feature_selection=_ref("feature_selection"), + selected_indices=np.arange(3), + family_masks={}, + covariate_columns=[], + covariate_roles=[], + adjacent_overlap=None, + ) + assert result[0] == _ref("feature_summary") + assert counts == { + "diagnostic.pca": { + "attempted": 1, + "completed": 1, + "failed": 0, + "cacheHits": 0, + "restored": 0, + "artifactReuses": 1, + } + } + + +def test_stability_markers_and_local_metric_reuse_are_separate_operations( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import pandas as pd + from scipy.sparse import block_diag, csr_matrix + from tests.agent_examples import example + + labels = np.asarray([1] * 8 + [2] * 8) + community = csr_matrix(np.ones((8, 8)) - np.eye(8)) + graph = block_diag((community, community), format="csr") + calls = [] + + def partition(*args: Any, **kwargs: Any) -> Any: + calls.append(("partition", kwargs)) + return _ref("cluster_labels", 2) + + def marker(*args: Any, **kwargs: Any) -> Any: + calls.append(("markers", kwargs)) + return _ref("marker_table") + + store = SimpleNamespace( + cells=SimpleNamespace(columns=["donor", "batch"]), + load_graph=lambda _: graph, + run_leiden_clustering=partition, + run_marker_search=marker, + get_markers=lambda *_, **__: pd.DataFrame( + { + "group_id": ["1", "2"], + "feature_name": ["A", "B"], + "score": [1.0, 1.0], + "auc": [0.9, 0.9], + } + ), + ) + monkeypatch.setattr(d, "_cluster_labels", lambda *_: labels) + monkeypatch.setattr( + d, "_selected_feature_names", lambda *_: (np.arange(2), np.asarray(["A", "B"])) + ) + monkeypatch.setattr(d, "_aligned_metadata", lambda *_: np.tile(["d1", "d2"], 8)) + evaluation = example(ParameterCandidateEvaluation) + evaluation.artifacts.update( + { + "clusters": ArtifactRecord.from_ref(_ref("cluster_labels")), + "connectivityMap": ArtifactRecord.from_ref(_ref("connectivity_map")), + } + ) + with e.diagnostic_work() as counts, e.candidate_metric_cache(): + results = [ + d.augment_cluster_evaluations( + store, + [evaluation], + marker_assay="RNA", + marker_features=_ref("feature_selection"), + independent_unit_columns=["donor"], + technical_columns=["batch"], + )[0] + for _ in range(2) + ] + assert results[0].metrics == results[1].metrics + assert results[0].metrics.subsampleStability == pytest.approx(1.0) + assert counts["metric.subsample_stability"]["completed"] == 1 + assert counts["metric.subsample_stability"]["cacheHits"] == 1 + assert counts["core.subsamplePartition"]["completed"] == 1 + assert counts["core.alternateSeedPartition"]["completed"] == 2 + assert counts["core.markers"]["completed"] == 2 + assert counts["core.markers"]["artifactReuses"] == 0 + assert counts["metric.crossUnitSupport"]["completed"] == 2 + assert counts["metric.technicalAssociation"]["completed"] == 2 + assert all(row[1]["random_seed"] == 9173 for row in calls if row[0] == "partition") + assert all( + row[1]["features"] == _ref("feature_selection") + for row in calls + if row[0] == "markers" + ) diff --git a/tests/test_agent_diagnostic_boundaries.py b/tests/test_agent_diagnostic_boundaries.py new file mode 100644 index 00000000..52036e26 --- /dev/null +++ b/tests/test_agent_diagnostic_boundaries.py @@ -0,0 +1,850 @@ +"""Numerical evidence remains bounded and rejects mismatched scientific inputs.""" + +from types import SimpleNamespace +from typing import Any + +import numpy as np +import pytest + +from scarf.agent.parameter_tuning import diagnostics as d +from scarf.agent.parameter_tuning.contracts import ( + ArtifactRecord, + ParameterCandidateEvaluation, +) +from scarf.storage.refs import ArtifactRef +from tests.agent_examples import example + + +class _Blocks: + def __init__(self, values: np.ndarray, limit: int = 65_536) -> None: + self.values = values + self.shape, self.dtype = values.shape, values.dtype + self.limit = limit + self.reads: list[int] = [] + + def __getitem__(self, rows: slice) -> np.ndarray: + output = self.values[rows] + assert len(output) <= self.limit + self.reads.append(len(output)) + return output + + def __array__(self, *_args: Any, **_kwargs: Any) -> Any: + raise AssertionError("Scientific arrays must use bounded reads") + + +def _ref(kind: str, value: int = 1) -> ArtifactRef: + return ArtifactRef( + "datastore" if kind == "cell_selection" else "assay", + kind, + f"{value:064x}", + None if kind == "cell_selection" else "RNA", + ) + + +def test_streamed_component_and_loading_statistics_match_dense_reference() -> None: + rng = np.random.default_rng(7) + coordinates = _Blocks(rng.normal(size=(70_000, 3))) + np.testing.assert_allclose( + d._component_variance(coordinates), coordinates.values.var(axis=0), rtol=1e-12 + ) + assert coordinates.reads == [65_536, 4464] + loadings = _Blocks(rng.normal(size=(9000, 3)), 8192) + indices = np.arange(9000) * 2 + family = np.arange(9000) % 3 == 0 + top, magnitude, enrichment = d._top_loadings(loadings, indices, {"family": family}) + expected = np.argsort(-np.abs(loadings.values), axis=0, kind="stable")[:20].T + np.testing.assert_array_equal(top, indices[expected]) + for column in range(3): + np.testing.assert_allclose( + magnitude[column], np.abs(loadings.values[expected[column], column]) + ) + assert enrichment[0, column] == pytest.approx( + family[expected[column]].mean() / family.mean() + ) + assert loadings.reads == [8192, 808] + + +@pytest.mark.parametrize( + "values", [np.ones(3), np.ones((0, 2)), np.asarray([[1.0, np.nan]])] +) +def test_pca_variance_cannot_come_from_invalid_coordinates(values: np.ndarray) -> None: + with pytest.raises(ValueError): + d._component_variance(values) + + +@pytest.mark.parametrize("damage", ["shape", "empty", "familyMask", "nonfinite"]) +def test_loading_programs_require_exact_finite_feature_axis(damage: str) -> None: + values = np.ones((3, 2)) + indices = np.arange(3) + family = np.ones(3, dtype=bool) + if damage == "shape": + values = values[:2] + elif damage == "empty": + values = values[:, :0] + elif damage == "familyMask": + family = family[:2] + else: + values[0, 0] = np.inf + with pytest.raises(ValueError): + d._top_loadings(values, indices, {"family": family}) + + +def test_family_influence_distinguishes_representation_programs_without_removing_genes() -> ( + None +): + names = np.asarray( + [ + "MT-CO1", + "MTOR", + "RPS3", + "MRPS3", + "MKI67", + "HBA1", + "IGHM", + "FOS", + "ATF3", + "XIST", + "OTHER", + ] + ) + expected = { + "mitochondrial": {0}, + "ribosomal": {2}, + "mitoribosomal": {3}, + "cellCycle": {4}, + "hemoglobin": {5}, + "immuneReceptor": {6}, + "stress": {7}, + "dissociation": {7, 8}, + "sex": {9}, + } + for family, positions in expected.items(): + assert set(np.flatnonzero(d._family_mask(names, family))) == positions + assert d._family_mask(names, "unregisteredFamily") is None + np.testing.assert_array_equal( + names, + [ + "MT-CO1", + "MTOR", + "RPS3", + "MRPS3", + "MKI67", + "HBA1", + "IGHM", + "FOS", + "ATF3", + "XIST", + "OTHER", + ], + ) + + +def _variance_inputs( + monkeypatch: pytest.MonkeyPatch, +) -> tuple[Any, Any, ArtifactRef, dict[str, Any]]: + normalized, cells, features = ( + _ref("normalized"), + _ref("cell_selection"), + _ref("feature_selection"), + ) + data = _Blocks( + np.column_stack([np.arange(9000), np.ones(9000), np.arange(9000) % 3]).astype( + float + ), + 8192, + ) + payload: dict[str, Any] = {"data": data} + inputs = {"cell_selection": cells, "feature_selection": features} + store = SimpleNamespace( + inspect_artifact=lambda _: SimpleNamespace(inputs=inputs), + load_artifact=lambda _: payload, + ) + status = SimpleNamespace( + inputs={"normalized": normalized, "pca_cell_selection": cells} + ) + monkeypatch.setattr(d, "as_zarr_array", lambda value, **_: value) + return store, status, features, payload + + +def test_scaled_pca_variance_reuses_saved_feature_summaries( + monkeypatch: pytest.MonkeyPatch, +) -> None: + store, status, features, payload = _variance_inputs(monkeypatch) + + def evaluate() -> float: + return d._scaled_total_variance( + store, status, n_rows=9000, n_features=3, feature_selection=features + ) + + assert evaluate() == 2 + assert payload["data"].reads == [8192, 808] + values = payload["data"].values + payload.update( + feature_sum=values.sum(axis=0), + feature_squared_sum=np.square(values).sum(axis=0), + ) + payload["data"].reads.clear() + assert evaluate() == 2 + assert payload["data"].reads == [] + + +@pytest.mark.parametrize( + "damage", + [ + "missingInput", + "differentCells", + "differentGenes", + "differentShape", + "nonfinite", + "invalidSummaries", + "constantGenes", + ], +) +def test_scaled_variance_denominator_requires_exact_normalization( + monkeypatch: pytest.MonkeyPatch, damage: str +) -> None: + store, status, features, payload = _variance_inputs(monkeypatch) + if damage == "missingInput": + status.inputs.pop("normalized") + elif damage == "differentCells": + status.inputs["pca_cell_selection"] = _ref("cell_selection", 2) + elif damage == "differentGenes": + features = _ref("feature_selection", 2) + elif damage == "differentShape": + payload["data"].shape = (9000, 4) + elif damage == "nonfinite": + payload["data"].values[0, 0] = np.nan + elif damage == "invalidSummaries": + payload.update(feature_sum=np.ones(2), feature_squared_sum=np.ones(2)) + else: + payload["data"].values[:] = 1 + with pytest.raises(ValueError): + d._scaled_total_variance( + store, status, n_rows=9000, n_features=3, feature_selection=features + ) + + +@pytest.mark.parametrize("damage", ["roles", "rows", "kind", "artifactKind"]) +def test_pca_covariate_evidence_rejects_wrong_type_or_selection( + monkeypatch: pytest.MonkeyPatch, damage: str +) -> None: + monkeypatch.setattr( + d, + "_aligned_metadata_values", + lambda *_: np.arange(3 if damage == "rows" else 4), + ) + monkeypatch.setattr( + d, + "resolve_cell_aligned_artifact", + lambda *_args, **_kwargs: SimpleNamespace(values=np.arange(4)), + ) + with pytest.raises(ValueError): + d._covariate_associations( + SimpleNamespace(zw=None), + _ref("cell_selection"), + np.ones((4, 2)), + ["covariate"], + [] if damage == "roles" else ["technical"], + column_kinds={ + "covariate": "unknown" if damage == "kind" else "categorical" + }, + column_artifacts={"covariate": _ref("quality_metric")} + if damage == "artifactKind" + else {}, + ) + + +def test_missing_categorical_covariates_have_zero_matched_support_without_coordinate_reads() -> ( + None +): + coordinates = _Blocks(np.ones((4, 2))) + np.testing.assert_array_equal( + d._categorical_association(coordinates, np.asarray([None] * 4)), [0, 0] + ) + assert coordinates.reads == [] + + +def test_doublet_summary_uses_bounded_scores_and_preserves_extremes() -> None: + values = _Blocks(np.linspace(0, 1, 70_000)) + summary, sample = d._bounded_score_summary(values, maximum_sample_size=1000) + assert len(sample) <= 1000 and summary["sampleSize"] == len(sample) + assert summary["minimum"] == 0 and summary["maximum"] == 1 + assert summary["p90"] == pytest.approx(np.quantile(sample, 0.9)) + assert values.reads == [65_536, 4464] + + +@pytest.mark.parametrize( + "values,limit", + [ + (np.ones(3), 0), + (np.ones((2, 2)), 10), + (np.ones(0), 10), + (np.asarray([1.0, np.nan]), 10), + ], +) +def test_doublet_summary_cannot_hide_invalid_scores( + values: np.ndarray, limit: int +) -> None: + with pytest.raises(ValueError): + d._bounded_score_summary(values, maximum_sample_size=limit) + + +@pytest.mark.parametrize("damage", ["gap", "captureSummary", "selection"]) +def test_resumed_doublets_require_complete_capture_inventory(damage: str) -> None: + evaluation = ParameterCandidateEvaluation( + artifacts={"doubletScore:0": ArtifactRecord.from_ref(_ref("quality_metric"))} + ) + evaluation.metrics.doubletScoreByCapture = {"capture": {"p90": 0.8}} + if damage == "gap": + evaluation.artifacts["doubletScore:1"] = evaluation.artifacts.pop( + "doubletScore:0" + ) + elif damage == "captureSummary": + evaluation.metrics.doubletScoreByCapture = {} + with pytest.raises(ValueError): + d.restore_advisory_doublets(evaluation, capture_column="capture") + + +def _score_evidence( + monkeypatch: pytest.MonkeyPatch, +) -> tuple[Any, Any, dict[ArtifactRef, np.ndarray]]: + parent, selected, score = ( + _ref("cell_selection"), + _ref("cell_selection", 2), + _ref("quality_metric"), + ) + selections = {parent: np.arange(4), selected: np.arange(4)} + store = SimpleNamespace( + zw=None, load_artifact=lambda _: {"values": np.asarray([0.1, 0.2, 0.8, 0.9])} + ) + monkeypatch.setattr(d, "as_zarr_array", lambda value, **_: value) + monkeypatch.setattr( + d, "read_stored_selection_indices", lambda _root, ref, **_: selections[ref] + ) + evidence = d.AdvisoryDoubletScores( + (score,), + (selected,), + _ref("connectivity_map"), + _ref("cluster_labels"), + score_summaries=({"p90": 0.75},), + ) + return store, evidence, selections + + +@pytest.mark.parametrize( + "damage", + [ + "labels", + "parentOrder", + "summaryCount", + "scoreShape", + "outsideParent", + "overlap", + "nonfinite", + ], +) +def test_doublet_concentration_cannot_compare_different_or_overlapping_cells( + monkeypatch: pytest.MonkeyPatch, damage: str +) -> None: + from dataclasses import replace + + store, evidence, selections = _score_evidence(monkeypatch) + labels = np.asarray([1, 1, 2, 2]) + if damage == "labels": + labels = labels[:3] + elif damage == "parentOrder": + selections[_ref("cell_selection")] = np.asarray([1, 0, 2, 3]) + elif damage == "summaryCount": + evidence = replace(evidence, score_summaries=({"p90": 0.5}, {"p90": 0.5})) + elif damage == "scoreShape": + store.load_artifact = lambda _: {"values": np.ones(3)} + elif damage == "outsideParent": + selections[_ref("cell_selection", 2)] = np.asarray([0, 1, 2, 4]) + elif damage == "overlap": + evidence = replace( + evidence, + scores=evidence.scores * 2, + cell_selections=evidence.cell_selections * 2, + score_summaries=evidence.score_summaries * 2, + ) + else: + store.load_artifact = lambda _: {"values": np.asarray([np.nan, 0, 0, 1])} + with pytest.raises(ValueError): + d._doublet_concentration(store, labels, _ref("cell_selection"), evidence) + + +def test_doublet_concentration_uses_matched_capture_thresholds_and_empty_evidence_is_unavailable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from dataclasses import replace + + store, evidence, _ = _score_evidence(monkeypatch) + assert ( + d._doublet_concentration( + store, np.asarray([1, 1, 2, 2]), _ref("cell_selection"), evidence + ) + == 2 + ) + missing = replace(evidence, scores=(), cell_selections=(), score_summaries=()) + assert ( + d._doublet_concentration( + store, np.asarray([1, 1, 2, 2]), _ref("cell_selection"), missing + ) + is None + ) + + +def test_native_doublet_reconstruction_uses_pca_and_preserves_selected_parameters() -> ( + None +): + selected = example(ParameterCandidateEvaluation) + selected.parameters.useHarmony = True + selected.artifacts["pca"] = ArtifactRecord.from_ref(_ref("reduction")) + pca = d._artifact_ref(selected, "pca") + calls = [] + + def operation(name: str, kind: str) -> Any: + def execute(*args: Any, **kwargs: Any) -> ArtifactRef: + calls.append((name, args, kwargs)) + return _ref(kind) + + return execute + + store = SimpleNamespace( + build_ann_index=operation("ann", "ann_index"), + query_neighbors=operation("neighbors", "neighbors"), + build_connectivity_map=operation("graph", "connectivity_map"), + run_leiden_clustering=operation("clusters", "cluster_labels"), + ) + clusters, graph = d.resolve_native_doublet_inputs(store, selected, []) + assert [row[0] for row in calls] == ["ann", "neighbors", "graph", "clusters"] + assert calls[0][1] == (pca,) + assert calls[1][2]["k"] == selected.parameters.neighborsK + assert calls[-1][2]["resolution"] == selected.parameters.leidenResolution + assert clusters == _ref("cluster_labels") and graph == _ref("connectivity_map") + + +def _capture_scoring_store( + monkeypatch: pytest.MonkeyPatch, labels: np.ndarray, *, capture_known: bool = True +) -> tuple[Any, Any, dict[ArtifactRef, np.ndarray], list[Any]]: + parent = _ref("cell_selection") + selections = {parent: np.arange(len(labels))} + payloads: dict[ArtifactRef, dict[str, np.ndarray]] = {} + calls = [] + current = [parent] + serial = [10] + + def reference(kind: str) -> ArtifactRef: + serial[0] += 1 + return _ref(kind, serial[0]) + + def record(name: str, kind: str) -> Any: + def run(*args: Any, **kwargs: Any) -> ArtifactRef: + calls.append((name, args, kwargs)) + if name == "normalize": + current[0] = args[0] + return reference(kind) + + return run + + def filter_cells( + columns: Any, lower: Any, upper: Any, **kwargs: Any + ) -> ArtifactRef: + assert columns == ["capture"] and lower == upper + assert kwargs["cell_selection"] == parent + mask = labels == lower[0] + selected = reference("cell_selection") + selections[selected] = np.flatnonzero(mask) + payloads[selected] = {"values": mask} + calls.append(("filter", (), {"capture": lower[0]})) + return selected + + def score(*args: Any, **kwargs: Any) -> ArtifactRef: + calls.append(("doublet", args, kwargs)) + result = reference("quality_metric") + payloads[result] = {"values": np.linspace(0, 1, len(selections[current[0]]))} + return result + + store = SimpleNamespace( + zw=None, + cells=SimpleNamespace( + N=len(labels), columns=["capture"] if capture_known else [] + ), + get_assay=lambda _: SimpleNamespace( + feats=SimpleNamespace( + fetch_all=lambda _: np.asarray(["g1", "g2", "g3", "g4", "g5"]) + ) + ), + load_artifact=lambda ref: payloads[ref], + filter_cells=filter_cells, + run_normalization=record("normalize", "normalized"), + run_pca=record("pca", "reduction"), + build_ann_index=record("ann", "ann_index"), + query_neighbors=record("neighbors", "neighbors"), + build_connectivity_map=record("graph", "connectivity_map"), + run_leiden_clustering=record("cluster", "cluster_labels"), + run_doublet_detection=score, + ) + monkeypatch.setattr(d, "as_zarr_array", lambda value, **_: value) + monkeypatch.setattr( + d, "read_stored_selection_indices", lambda _root, ref, **_: selections[ref] + ) + monkeypatch.setattr( + d, "read_metadata_rows_chunkwise", lambda _cells, _column, rows: labels[rows] + ) + monkeypatch.setattr(d, "read_feature_selection_indices", lambda *_: np.arange(5)) + monkeypatch.setattr( + d, + "resolve_native_doublet_inputs", + lambda *_: (_ref("cluster_labels"), _ref("connectivity_map")), + ) + selected = example(ParameterCandidateEvaluation) + from scarf.agent.tools import artifact_reference + + selected.cellSelection = artifact_reference(parent) + selected.parameters.dimensions = 21 + selected.parameters.neighborsK = 11 + return store, selected, selections, calls + + +def test_capture_doublet_routing_caps_rank_and_preserves_unscored_small_capture( + monkeypatch: pytest.MonkeyPatch, +) -> None: + labels = np.asarray(["tiny"] * 2 + ["small"] * 3 + ["large"] * 5) + store, selected, selections, calls = _capture_scoring_store(monkeypatch, labels) + evidence = d.score_advisory_doublets( + store, + selected, + [selected], + assay="RNA", + feature_selection=_ref("feature_selection"), + capture_column="capture", + ) + assert evidence.capture_values == ("large", "small") + assert evidence.capture_coverage == 0.8 + assert any("tiny" in note and "only 2" in note for note in evidence.limitations) + assert [row[2]["dims"] for row in calls if row[0] == "pca"] == [4, 2] + assert [row[2]["k"] for row in calls if row[0] == "neighbors"] == [4, 2] + assert sum(row[0] == "doublet" for row in calls) == 2 + np.testing.assert_array_equal(selections[_ref("cell_selection")], np.arange(10)) + for ref, capture in zip( + evidence.cell_selections, evidence.capture_values, strict=True + ): + assert np.all(labels[selections[ref]] == capture) + + +@pytest.mark.parametrize("capture_known", [False, True]) +def test_single_or_unknown_capture_uses_one_native_reference_with_visible_scope( + monkeypatch: pytest.MonkeyPatch, capture_known: bool +) -> None: + store, selected, _, calls = _capture_scoring_store( + monkeypatch, np.asarray(["one"] * 5), capture_known=capture_known + ) + evidence = d.score_advisory_doublets( + store, + selected, + [selected], + assay="RNA", + feature_selection=_ref("feature_selection"), + capture_column="capture" if capture_known else None, + ) + assert len(evidence.scores) == 1 and evidence.capture_coverage == 1 + assert [row[0] for row in calls] == ["doublet"] + assert evidence.capture_values == ("one" if capture_known else "allSelectedCells",) + assert bool(evidence.limitations) == (not capture_known) + + +@pytest.mark.parametrize( + "damage", ["missingSelection", "tooManyCaptures", "allCapturesTooSmall"] +) +def test_doublets_fail_visibly_when_scoring_scope_is_unavailable( + monkeypatch: pytest.MonkeyPatch, damage: str +) -> None: + labels = ( + np.arange(513).astype(str) + if damage == "tooManyCaptures" + else np.asarray(["a", "a", "b", "b"]) + ) + store, selected, _, calls = _capture_scoring_store(monkeypatch, labels) + if damage == "missingSelection": + selected.cellSelection = None + with pytest.raises(ValueError): + d.score_advisory_doublets( + store, + selected, + [selected], + assay="RNA", + feature_selection=_ref("feature_selection"), + capture_column="capture", + ) + assert not any(row[0] == "doublet" for row in calls) + + +@pytest.mark.parametrize( + "damage", ["inventory", "emptyParent", "emptyScores", "unalignedScores"] +) +def test_doublet_summary_requires_aligned_nonempty_capture_artifacts( + monkeypatch: pytest.MonkeyPatch, damage: str +) -> None: + store, evidence, selections = _score_evidence(monkeypatch) + scores = evidence.scores + if damage == "inventory": + scores = () + elif damage == "emptyParent": + selections[_ref("cell_selection")] = np.asarray([], dtype=int) + elif damage == "emptyScores": + store.load_artifact = lambda _: {"values": np.ones(0)} + else: + store.load_artifact = lambda _: {"values": np.ones(3)} + with pytest.raises(ValueError): + d._build_advisory_doublet_scores( + store, + scores=scores, + cell_selections=evidence.cell_selections, + native_graph=evidence.native_graph, + native_clusters=evidence.native_clusters, + parent_selection=_ref("cell_selection"), + capture_values=("one",), + capture_column="capture", + limitations=(), + ) + + +def test_large_doublet_summary_identifies_sampled_quantiles( + monkeypatch: pytest.MonkeyPatch, +) -> None: + store, evidence, selections = _score_evidence(monkeypatch) + selections[_ref("cell_selection")] = np.arange(70_000) + selections[evidence.cell_selections[0]] = np.arange(70_000) + values = _Blocks(np.linspace(0, 1, 70_000)) + store.load_artifact = lambda _: {"values": values} + result = d._build_advisory_doublet_scores( + store, + scores=evidence.scores, + cell_selections=evidence.cell_selections, + native_graph=evidence.native_graph, + native_clusters=evidence.native_clusters, + parent_selection=_ref("cell_selection"), + capture_values=("one",), + capture_column="capture", + limitations=(), + ) + assert result.capture_coverage == 1 + assert result.score_summaries[0]["sampleSize"] < 70_000 + assert any("deterministic bounded samples" in note for note in result.limitations) + + +def test_metadata_diagnostics_preserve_missing_masks_and_reject_misalignment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + d, "read_stored_selection_indices", lambda *_args, **_kwargs: np.asarray([1, 3]) + ) + monkeypatch.setattr( + d, "read_metadata_rows_chunkwise", lambda *_: np.asarray([20.0, 40]) + ) + monkeypatch.setattr( + d, "read_metadata_missing_rows_chunkwise", lambda *_: np.asarray([False, True]) + ) + store = SimpleNamespace(zw=None, cells=None) + values = d._aligned_metadata_values(store, _ref("cell_selection"), "age") + assert values.tolist() == [20.0, None] + monkeypatch.setattr( + d, "read_metadata_rows_chunkwise", lambda *_: np.asarray([20.0]) + ) + for read in (d._aligned_metadata_values, d._aligned_metadata): + with pytest.raises(ValueError, match="align"): + read(store, _ref("cell_selection"), "age") + + +def test_topology_overlap_requires_same_neighborhood_axis( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(d, "as_zarr_array", lambda value, **_: value) + left = _ref("neighbors") + store = SimpleNamespace( + load_artifact=lambda ref: { + "indices": np.ones((4, 2) if ref == left else (3, 2), dtype=int) + } + ) + with pytest.raises(ValueError, match="align"): + d._neighbor_overlap(store, left, _ref("neighbors", 2)) + + +@pytest.mark.parametrize("damage", ["artifactKind", "scaling", "loadingShape"]) +def test_pca_diagnostic_rejects_unmatched_method_or_feature_axis_before_writing( + monkeypatch: pytest.MonkeyPatch, damage: str +) -> None: + evaluation = ParameterCandidateEvaluation( + artifacts={ + "pca": ArtifactRecord.from_ref(_ref("reduction")), + "neighbors": ArtifactRecord.from_ref(_ref("neighbors")), + } + ) + store = SimpleNamespace( + inspect_artifact=lambda _: SimpleNamespace( + parameters={"feat_scaling": damage != "scaling"} + ), + load_artifact=lambda _: {"data": np.ones((4, 2)), "loadings": np.ones((4, 2))}, + ) + monkeypatch.setattr(d, "as_zarr_array", lambda value, **_: value) + with pytest.raises(ValueError): + d._write_pca_diagnostic( + store, + evaluation, + feature_selection=_ref("feature_selection"), + selected_indices=np.arange(3), + family_masks={}, + covariate_columns=[], + covariate_roles=[], + adjacent_overlap=None, + column_artifacts={"counts": _ref("cluster_labels")} + if damage == "artifactKind" + else {}, + ) + + +def test_reused_pca_evidence_rejects_a_changed_saved_payload( + tmp_path: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + import zarr + from scarf.storage.artifacts import fingerprint_stored_arrays + + root = zarr.open_group(str(tmp_path / "diagnostics.zarr"), mode="w") + pca = root.create_group("pca") + pca.create_array("data", data=np.ones((4, 2))) + pca.create_array("loadings", data=np.ones((3, 2))) + saved = root.create_group("saved") + payload = { + "component_variance": np.ones(2), + "explained_variance_ratio": np.ones(2), + "top_loading_feature_indices": np.tile(np.arange(3), (2, 1)), + "top_loading_values": np.ones((2, 3)), + "family_enrichment": np.ones((0, 2)), + "covariate_association": np.ones((0, 2)), + "adjacent_neighbor_overlap": np.ones(1), + } + for name, values in payload.items(): + saved.create_array(name, data=values) + saved.attrs["payload_fingerprint"] = fingerprint_stored_arrays( + saved, d._PCA_DIAGNOSTIC_ARRAYS + ) + saved["component_variance"][0] = 2 + evaluation = ParameterCandidateEvaluation( + artifacts={ + "pca": ArtifactRecord.from_ref(_ref("reduction")), + "neighbors": ArtifactRecord.from_ref(_ref("neighbors")), + } + ) + store = SimpleNamespace( + zw=root, + cells=None, + inspect_artifact=lambda _: SimpleNamespace(parameters={"feat_scaling": True}), + load_artifact=lambda ref: pca if ref.kind == "reduction" else saved, + ) + monkeypatch.setattr( + d, + "plan_artifact", + lambda *_args, **_kwargs: SimpleNamespace( + reused=True, ref=_ref("feature_summary") + ), + ) + with pytest.raises(ValueError, match="fingerprint"): + d._write_pca_diagnostic( + store, + evaluation, + feature_selection=_ref("feature_selection"), + selected_indices=np.arange(3), + family_masks={}, + covariate_columns=[], + covariate_roles=[], + adjacent_overlap=None, + ) + + +def test_incomplete_candidates_keep_their_failure_without_running_new_diagnostics( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + d, + "_selected_feature_names", + lambda *_: (np.arange(3), np.asarray(["a", "b", "c"])), + ) + failed = ParameterCandidateEvaluation( + status="failed", error="PCA evidence unavailable" + ) + store = SimpleNamespace(cells=SimpleNamespace(columns=[])) + pca = d.augment_pca_evaluations( + store, + [failed], + feature_selection=_ref("feature_selection"), + nominated_families=[], + protected_families=[], + technical_columns=[], + protected_columns=[], + qc_columns=[], + ) + clusters = d.augment_cluster_evaluations( + store, + pca, + marker_assay="RNA", + marker_features=_ref("feature_selection"), + independent_unit_columns=[], + technical_columns=[], + ) + assert clusters[0].status == "failed" + assert clusters[0].error == "PCA evidence unavailable" + assert clusters[0].artifacts == {} + + +def test_tiny_population_stability_reclusters_the_entire_supported_graph() -> None: + from scipy.sparse import csr_matrix + + graph = csr_matrix(np.ones((3, 3)) - np.eye(3)) + assert d._subsample_partition_stability(graph, np.ones(3), 0.5) == 1 + + +@pytest.mark.parametrize("damage", ["labelMatrix", "seedSelection"]) +def test_cluster_diagnostics_reject_unmatched_partition_evidence_before_markers( + monkeypatch: pytest.MonkeyPatch, damage: str +) -> None: + from scarf.agent.tools import artifact_reference + + monkeypatch.setattr( + d, + "_selected_feature_names", + lambda *_: (np.arange(3), np.asarray(["a", "b", "c"])), + ) + monkeypatch.setattr(d, "as_zarr_array", lambda value, **_: value) + selected = ParameterCandidateEvaluation( + status="done", + eligible=True, + cellSelection=artifact_reference(_ref("cell_selection")), + artifacts={ + "clusters": ArtifactRecord.from_ref(_ref("cluster_labels")), + "connectivityMap": ArtifactRecord.from_ref(_ref("connectivity_map")), + }, + ) + initial = np.ones((3, 2)) if damage == "labelMatrix" else np.asarray([1, 1, 2]) + store = SimpleNamespace( + load_artifact=lambda ref: { + "values": initial if ref == _ref("cluster_labels") else np.asarray([1, 2]) + }, + run_leiden_clustering=lambda *_args, **_kwargs: _ref("cluster_labels", 2), + ) + with pytest.raises(ValueError, match="one-dimensional|align"): + d.augment_cluster_evaluations( + store, + [selected], + marker_assay="RNA", + marker_features=_ref("feature_selection"), + independent_unit_columns=[], + technical_columns=[], + ) + + +def test_pca_feature_evidence_requires_an_assay_owned_selection() -> None: + with pytest.raises(ValueError, match="belong to one assay"): + d._selected_feature_names( + None, ArtifactRef("datastore", "feature_selection", "a" * 64) + ) diff --git a/tests/test_agent_diagnostic_journal.py b/tests/test_agent_diagnostic_journal.py new file mode 100644 index 00000000..3260a7fe --- /dev/null +++ b/tests/test_agent_diagnostic_journal.py @@ -0,0 +1,183 @@ +"""Observed diagnostic calls and reuse remain honest across failed attempts.""" + +import pytest + +from scarf.agent.orchestrator import journal +from scarf.agent.parameter_tuning.execution import diagnostic_call, diagnostic_reuse +from tests.agent_journal_store import memory_journal + + +@pytest.mark.parametrize("interrupted", [False, True]) +def test_failed_diagnostic_attempt_preserves_actual_calls_and_partial_work( + interrupted: bool, +) -> None: + store, prefix, request = memory_journal("analysis") + error = ( + KeyboardInterrupt("Interrupted diagnostic") + if interrupted + else ValueError("Capture evidence failed") + ) + calls = [] + + def score(capture: str) -> str: + calls.append(capture) + if capture == "bad": + raise error + return "the-same-core-artifact" + + with pytest.raises(type(error)) as caught: + with journal.diagnostic_attempt( + store, prefix, request.workflowRunId, {"cells": "frozen"} + ) as counts: + assert ( + diagnostic_call("core.doubletDetection", score, "a") + == "the-same-core-artifact" + ) + assert ( + diagnostic_call("core.doubletDetection", score, "a") + == "the-same-core-artifact" + ) + diagnostic_reuse("metric.batch", "cacheHits") + diagnostic_call("core.doubletDetection", score, "bad") + assert caught.value is error + assert calls == ["a", "a", "bad"] + assert counts["core.doubletDetection"]["attempted"] == 3 + assert counts["core.doubletDetection"]["completed"] == 2 + assert counts["core.doubletDetection"]["failed"] == 1 + state = journal.analysis_snapshot(store, request.workflowRunId) + attempt = state["diagnosticAttempts"][0] + assert attempt["finished"]["status"] == ("interrupted" if interrupted else "failed") + assert attempt["finished"]["operations"] == counts + assert "not observable" in attempt["finished"]["interpretation"] + assert "unknown operation counts" in attempt["finished"]["interpretation"] + assert attempt["started"]["recordedAtNs"] <= attempt["finished"]["recordedAtNs"] + + with journal.diagnostic_attempt( + store, prefix, request.workflowRunId, {"cells": "frozen"} + ): + diagnostic_reuse("diagnostic.augmentedEvaluation", "restored") + resumed = journal.analysis_snapshot(store, request.workflowRunId) + assert resumed["diagnosticAttempts"][0] == attempt + assert len(resumed["diagnosticAttempts"]) == 2 + restored = resumed["diagnosticAttempts"][1]["finished"]["operations"] + assert "core.doubletDetection" not in restored + assert restored["diagnostic.augmentedEvaluation"]["restored"] == 1 + + +def test_unfinished_and_legacy_attempts_do_not_manufacture_zero_computation() -> None: + store, prefix, request = memory_journal("analysis") + assert ( + journal.analysis_snapshot(store, request.workflowRunId)["diagnosticAttempts"] + == [] + ) + journal.save_checkpoint( + store, + prefix, + request.workflowRunId, + "parameter_tuning/diagnostic_attempts/interrupted/started", + {"cells": "frozen"}, + {"recordedAtNs": 1}, + ) + attempt = journal.analysis_snapshot(store, request.workflowRunId)[ + "diagnosticAttempts" + ][0] + assert "finished" not in attempt + assert "operations" not in attempt["started"] + journal.save_checkpoint( + store, + prefix, + request.workflowRunId, + "parameter_tuning/diagnostic_attempts/interrupted/unrecognized", + {"cells": "frozen"}, + {}, + ) + with pytest.raises(ValueError, match="Unknown diagnostic attempt"): + journal.analysis_snapshot(store, request.workflowRunId) + + +@pytest.mark.parametrize("missing_start", [False, True]) +def test_diagnostic_finish_requires_the_exact_original_start( + missing_start: bool, +) -> None: + store, prefix, request = memory_journal("analysis") + key = "parameter_tuning/diagnostic_attempts/attempt" + if not missing_start: + journal.save_checkpoint( + store, + prefix, + request.workflowRunId, + key + "/started", + {"cells": "original"}, + {"recordedAtNs": 1}, + ) + journal.save_checkpoint( + store, + prefix, + request.workflowRunId, + key + "/finished", + {"cells": "different"}, + {"recordedAtNs": 2, "status": "completed", "operations": {}}, + ) + with pytest.raises( + ValueError, match="matching start" if missing_start else "inputs changed" + ): + journal.analysis_snapshot(store, request.workflowRunId) + + +def test_primary_evidence_restore_and_review_replay_count_no_new_diagnostic_calls( + monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest +) -> None: + from scarf.agent.parameter_tuning.execution import diagnostic_work + from scarf.agent.orchestrator import rna_tuning + from tests.test_agent_rna_evidence_mode import make_run, assess + + request.getfixturevalue("memory_checkpoints") + run, selected = make_run(monkeypatch, object()) + monkeypatch.setattr(rna_tuning, "run_agent_sync", assess) + with diagnostic_work() as first: + run.review("full", 0, selected, {}) + with diagnostic_work() as replay: + run.review("full", 0, selected, {}) + assert "diagnostic.reviewEvidence" not in first + assert replay["diagnostic.reviewEvidence"]["restored"] == 1 + assert all(row["attempted"] == 0 for row in replay.values()) + + +@pytest.mark.parametrize( + "failure", + [ + None, + ValueError("Scientific evidence failed"), + KeyboardInterrupt("Interrupted diagnostic"), + ], +) +def test_diagnostic_persistence_failure_cannot_replace_the_original_error( + monkeypatch: pytest.MonkeyPatch, failure: BaseException | None +) -> None: + store, prefix, request = memory_journal("analysis") + original_save = journal.save_checkpoint + + def save(store, prefix, workflow, key, inputs, outputs): + if key.endswith("/finished"): + raise OSError("Store is full") + return original_save(store, prefix, workflow, key, inputs, outputs) + + monkeypatch.setattr(journal, "save_checkpoint", save) + with pytest.raises(type(failure) if failure is not None else OSError) as caught: + with journal.diagnostic_attempt( + store, prefix, request.workflowRunId, {"cells": "frozen"} + ): + if failure is not None: + raise failure + if failure is not None: + assert caught.value is failure + assert "Store is full" in " ".join(failure.__notes__) + else: + assert str(caught.value) == "Store is full" + attempt = journal.analysis_snapshot(store, request.workflowRunId)[ + "diagnosticAttempts" + ][0] + assert "started" in attempt and "finished" not in attempt + + +from tests.test_agent_rna_adaptive import checkpoints as memory_checkpoints # noqa: E402, F401 diff --git a/tests/test_agent_experimental_context.py b/tests/test_agent_experimental_context.py index 520d3cbc..c8d5314e 100644 --- a/tests/test_agent_experimental_context.py +++ b/tests/test_agent_experimental_context.py @@ -632,6 +632,7 @@ async def reply( ] assert tool_names == { "inspect_cell_covariates", + "inspect_context_evidence", "analyze_experimental_design", "score_current_representation", } @@ -1861,7 +1862,7 @@ def test_harmony_requires_resolved_units_and_estimability( ) monkeypatch.setattr( module, - "characterize_covariates", + "characterize_context", lambda *_args, **_kwargs: characterization, ) store = _Store() diff --git a/tests/test_agent_feature_interventions.py b/tests/test_agent_feature_interventions.py new file mode 100644 index 00000000..be661a4b --- /dev/null +++ b/tests/test_agent_feature_interventions.py @@ -0,0 +1,237 @@ +"""Representation interventions use exact eligible genes and protected programs.""" + +import hashlib +from types import SimpleNamespace +from typing import Any + +import numpy as np +import pytest + +from scarf.agent.orchestrator import rna_tuning +from scarf.agent.orchestrator.models import artifact_model_to_ref +from scarf.agent.types import ArtifactReferenceModel +from scarf.storage.refs import ArtifactRef +from tests.test_agent_rna_adaptive import checkpoints as memory_checkpoints # noqa: F401 +from tests.test_agent_rna_evidence_mode import make_run + + +@pytest.fixture +def feature_run(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest) -> Any: + request.getfixturevalue("memory_checkpoints") + run, selected = make_run(monkeypatch, object()) + names = np.array(["MT-CO1", "RPL3", "XIST", "CD3D", *[f"G{i}" for i in range(96)]]) + ids = np.array([f"ENSG{i}" for i in range(len(names))]) + arrays: dict[str, dict[str, np.ndarray]] = {} + + def selection(mask: Any, **kwargs: Any) -> ArtifactRef: + mask = np.asarray(mask, dtype=bool) + identifier = hashlib.sha256(mask.tobytes()).hexdigest() + arrays[identifier] = { + "values": mask.copy(), + "corrected_variance": np.arange(100, dtype=float), + } + return ArtifactRef( + scope="assay", assay="RNA", kind="feature_selection", artifact_id=identifier + ) + + allowed = selection(np.arange(100) != 99) + eligible = selection((np.arange(100) != 99) & (np.arange(100) != 0)) + setting = run.settings[selected.candidateId] + setting.features = ArtifactReferenceModel.from_artifact_ref(eligible) + setting.eligibleFeatures = setting.features + setting.hvgCount = 98 + run.handoff.graphFeatureCandidates["eligibleAll"] = ( + ArtifactReferenceModel.from_artifact_ref(allowed) + ) + run.plan.assays[0].featureParameters = {} + run.family_patterns = { + "mitochondrial": "^MT-", + "ribosomal": "^RP[LS]", + "sexLinked": "^XIST$", + } + run.store = SimpleNamespace( + load_artifact=lambda reference: { + key: value.copy() for key, value in arrays[reference.artifact_id].items() + }, + get_assay=lambda _: SimpleNamespace( + feats=SimpleNamespace( + N=100, fetch_all=lambda column: names if column == "names" else ids + ) + ), + set_feature_selection=selection, + ) + + def rank( + store: Any, *, eligible: ArtifactRef, top_n: int, **kwargs: Any + ) -> ArtifactRef: + mask = store.load_artifact(eligible)["values"].copy() + mask[np.flatnonzero(mask)[top_n:]] = False + return selection(mask) + + monkeypatch.setattr(rna_tuning, "rank_core_hvgs", rank) + return run, selected, arrays, selection + + +@pytest.mark.parametrize( + "intervention", + ["includeFamily", "includeFeature", "excludeFamily", "excludeFeature"], +) +def test_feature_policy_changes_only_representation_eligibility( + feature_run: Any, intervention: str +) -> None: + run, selected, arrays, _ = feature_run + marker_features = run.marker_features + previous = run.settings[selected.candidateId].model_copy(deep=True) + value = { + "includeFamily": "mitochondrial", + "includeFeature": "ENSG0", + "excludeFamily": "ribosomal", + "excludeFeature": "ENSG1", + }[intervention] + changed = run.apply_experiment( + selected, {"parameter": intervention, "value": value} + ) + old_mask = arrays[previous.eligibleFeatures.artifactId]["values"] + new_mask = arrays[changed.eligibleFeatures.artifactId]["values"] + expected = 0 if intervention.startswith("include") else 1 + assert np.flatnonzero(old_mask != new_mask).tolist() == [expected] + assert not new_mask[99], "Ineligible genes cannot be reinstated" + assert changed.parameters == previous.parameters + assert run.settings[selected.candidateId] == previous + assert run.marker_features == marker_features + + +@pytest.mark.parametrize( + "protection", + [ + {"protectFeatures": ["ENSG1"]}, + {"protectFamilies": ["ribosomal"]}, + {"protectFamilies": ["sexLinked"]}, + ], +) +def test_objective_protection_blocks_exclusion_before_feature_execution( + feature_run: Any, protection: dict[str, Any] +) -> None: + run, selected, arrays, _ = feature_run + run.plan.assays[0].featureParameters = protection + value = "XIST" if protection.get("protectFamilies") == ["sexLinked"] else "RPL3" + before = len(arrays) + with pytest.raises(ValueError, match="objective-protected"): + run.apply_experiment(selected, {"parameter": "excludeFeature", "value": value}) + assert len(arrays) == before + + +def test_missing_feature_or_underpowered_representation_cannot_be_executed( + feature_run: Any, +) -> None: + run, selected, _, _ = feature_run + with pytest.raises(ValueError, match="absent from the assay"): + run.apply_experiment( + selected, {"parameter": "excludeFeature", "value": "missing"} + ) + with pytest.raises(ValueError, match="fixed PCA dimension"): + run.apply_experiment(selected, {"parameter": "hvgCount", "value": 10}) + with pytest.raises(ValueError, match="explicit technical column"): + run.apply_experiment( + selected, {"parameter": "hvgRanking", "value": "batchAware"} + ) + + +def test_nominations_follow_eligible_protected_genes_and_skip_unknown_families( + feature_run: Any, +) -> None: + run, selected, _, _ = feature_run + setting = run.settings[selected.candidateId] + nominate = rna_tuning.RnaTuningRun._feature_nomination + run.plan.assays[0].featureParameters = { + "protectFamilies": ["notRegistered", "mitochondrial"] + } + assert nominate(run, setting) == { + "parameter": "includeFamily", + "value": "mitochondrial", + } + run.plan.assays[0].featureParameters = { + "protectFeatures": ["ENSG1"], + "proposedExcludeFeatures": ["RPL3", "missing"], + } + assert nominate(run, setting) is None + run.plan.assays[0].featureParameters = {"proposedExcludeFamilies": ["ribosomal"]} + assert nominate(run, setting) == { + "parameter": "excludeFamily", + "value": "ribosomal", + } + + +def test_offered_experiments_respect_population_rank_and_covariate_kind( + feature_run: Any, +) -> None: + run, selected, _, _ = feature_run + setting = run.settings[selected.candidateId] + setting.hvgCount = 22 + setting.ranking = "batchAware" + setting.rankingColumn = "capture" + setting.parameters.useHarmony = True + run.scope_sizes["full"] = 22 + run.batch_columns = ["capture", "age"] + run.study.columnKinds = {"capture": "categorical", "age": "continuous"} + options = run.experiments(selected) + assert "dimensions:30" not in options + assert "neighborsK:41" not in options + assert "hvgRanking:batchAware:age" not in options + assert "hvgRanking:batchAware:capture" not in options + assert "hvgRanking:global" in options and "useHarmony:false" in options + + +def test_batch_ranking_requires_supported_groups_and_reuses_core_variability( + feature_run: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + from scarf.agent.experimental_context import characterization + + run, selected, _, selection = feature_run + eligible = artifact_model_to_ref( + run.settings[selected.candidateId].eligibleFeatures + ) + labels = np.repeat(["a", "b", "small"], [25, 25, 5]) + calls: list[dict[str, Any]] = [] + run.batch_columns = ["capture"] + run.store.zw = None + run.store.cells = object() + monkeypatch.setattr( + characterization, + "_SelectionBoundCells", + lambda *a: SimpleNamespace(fetch=lambda _: labels), + ) + + def filter_cells(columns: Any, lower: Any, upper: Any, **kwargs: Any) -> str: + assert columns == ["capture"] and lower == upper + assert kwargs["cell_selection"] == run.cells + return lower[0] + + core_features = selection(np.ones(100, dtype=bool)) + summary = ArtifactRef( + scope="assay", assay="RNA", kind="feature_summary", artifact_id="b" * 64 + ) + original_load = run.store.load_artifact + run.store.load_artifact = lambda ref: ( + {"normed_n": np.repeat(25, 100)} if ref == summary else original_load(ref) + ) + run.store.filter_cells = filter_cells + run.store.inspect_artifact = lambda ref: SimpleNamespace( + inputs={"feature_summary": summary.to_dict()} + ) + + def select_hvgs(cells: Any, **kwargs: Any) -> ArtifactRef: + calls.append({"cells": cells, **kwargs}) + return core_features + + run.store.select_hvgs = select_hvgs + with pytest.raises(ValueError, match="approved technical column"): + run.batch_ranking(eligible, 40, "donor") + assert not calls + indices = run.batch_ranking(eligible, 40, "capture") + assert {call["cells"] for call in calls} == {"a", "b"} + assert all(call["blacklist"] == "" and call["top_n"] == 100 for call in calls) + assert len(indices) > 0 and 0 not in indices and 99 not in indices + labels[:] = "a" + with pytest.raises(ValueError, match="two groups"): + run.batch_ranking(eligible, 40, "capture") diff --git a/tests/test_agent_global_repairs.py b/tests/test_agent_global_repairs.py new file mode 100644 index 00000000..787d5804 --- /dev/null +++ b/tests/test_agent_global_repairs.py @@ -0,0 +1,237 @@ +"""Global full-cohort repair admissions survive retries and context revisions.""" + +import hashlib +from copy import deepcopy +from typing import Any + +import pytest + +from scarf.agent import record_io +from scarf.agent.orchestrator import journal, rna_tuning +from scarf.agent.orchestrator.budget import CandidateBudget, CandidateBudgetExceeded +from scarf.agent.orchestrator.models import AutomatedWorkflowConfig +from scarf.agent.types import ArtifactReferenceModel +from tests.agent_journal_store import memory_journal +from tests.test_agent_required_comparisons import panel_run # noqa: F401 + + +def repair_inputs() -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]: + return ( + ArtifactReferenceModel( + scope="datastore", kind="cell_selection", artifactId="a" * 64 + ).model_dump(mode="json"), + { + "features": "fixed", + "parameters": { + "candidateId": "baseline", + "dimensions": 21, + "neighborsK": 11, + "leidenResolution": 1.0, + }, + }, + {"parameter": "leidenResolution", "value": 1.5}, + ) + + +def save_review( + store: Any, prefix: str, run_id: str, key: str, *, harmony: bool = False +) -> None: + cells, setting, experiment = repair_inputs() + if harmony: + experiment = {"parameter": "useHarmony", "value": True} + journal.save_checkpoint( + store, + prefix, + run_id, + key, + { + "comparisonCoverage": {"phase": "validation"}, + "candidates": [{"candidateId": "baseline", "cellSelection": cells}], + "settings": {"baseline": setting}, + "experiments": {"target": experiment}, + }, + { + "action": { + "action": "experiment", + "selectedCandidateId": "baseline", + "experimentId": "target", + } + }, + ) + + +def test_repair_reservation_is_global_idempotent_and_does_not_charge_primary_work() -> ( + None +): + store, prefix, record = memory_journal() + original = {"study": "original"} + budget = CandidateBudget( + store, prefix, record.workflowRunId, record.config, original + ) + cells, setting, experiment = repair_inputs() + budget.admit_repair(cells, setting, experiment) + before = journal._list_keys(store.zw, prefix) + assert len(budget.repairs) == 1 + assert budget.summary()["scopes"]["full"]["reserved"] == { + "graphs": 0, + "partitions": 0, + } + resumed = CandidateBudget( + store, + prefix, + record.workflowRunId, + record.config, + {"study": "revised"}, + previous_provenances=(original,), + ) + renamed = deepcopy(setting) + renamed["parameters"]["candidateId"] = "replayed-label" + resumed.admit_repair(cells, renamed, experiment) + assert journal._list_keys(store.zw, prefix) == before + with pytest.raises(CandidateBudgetExceeded, match="repair has been used"): + resumed.admit_repair(cells, setting, {"parameter": "dimensions", "value": 30}) + assert len(resumed.repairs) == 1 + + +@pytest.mark.parametrize("revised", [False, True]) +def test_old_committed_repair_is_counted_without_rewriting_it(revised: bool) -> None: + store, prefix, record = memory_journal() + original = {"study": "original"} + digest = hashlib.sha256(record_io.canonical_json_bytes(original)).hexdigest() + key = ( + f"parameter_tuning/evidence_revisions/{digest}/full/review0" + if revised + else "parameter_tuning/full/review0" + ) + save_review(store, prefix, record.workflowRunId, key) + before = journal._list_keys(store.zw, prefix) + saved = journal.read_checkpoint(store, prefix, record.workflowRunId, key) + budget = CandidateBudget( + store, + prefix, + record.workflowRunId, + record.config, + {"study": "new"}, + previous_provenances=(original,), + ) + assert len(budget.repairs) == 1 + budget.admit_repair(*repair_inputs()) + assert journal._list_keys(store.zw, prefix) == before + assert journal.read_checkpoint(store, prefix, record.workflowRunId, key) == saved + with pytest.raises(CandidateBudgetExceeded): + cells, setting, _ = repair_inputs() + budget.admit_repair(cells, setting, {"parameter": "dimensions", "value": 30}) + + +def test_harmony_comparison_does_not_consume_a_targeted_repair() -> None: + store, prefix, record = memory_journal() + save_review( + store, + prefix, + record.workflowRunId, + "parameter_tuning/full/review0", + harmony=True, + ) + budget = CandidateBudget(store, prefix, record.workflowRunId, record.config, {}) + assert not budget.repairs + budget.admit_repair(*repair_inputs()) + assert len(budget.repairs) == 1 + + +def test_unadmitted_proposal_does_not_inflate_explicit_repair_accounting() -> None: + store, prefix, record = memory_journal() + budget = CandidateBudget(store, prefix, record.workflowRunId, record.config, {}) + cells, setting, _ = repair_inputs() + budget.admit_repair(cells, setting, {"parameter": "dimensions", "value": 30}) + save_review(store, prefix, record.workflowRunId, "parameter_tuning/full/review0") + restored = CandidateBudget(store, prefix, record.workflowRunId, record.config, {}) + assert restored.repairs == budget.repairs + + +def test_disabled_repair_limit_rejects_before_any_primary_admission() -> None: + store, prefix, record = memory_journal() + budget = CandidateBudget( + store, + prefix, + record.workflowRunId, + AutomatedWorkflowConfig(maxFullRepairs=0), + {}, + ) + with pytest.raises(CandidateBudgetExceeded): + budget.admit_repair(*repair_inputs()) + assert not budget.repairs + assert not budget.admissions["full"] + + +def test_full_recovery_executes_requested_resolution_and_preserves_baseline_panel( + request: pytest.FixtureRequest, + monkeypatch: Any, +) -> None: + run = request.getfixturevalue("panel_run") + run.recovery_scope = "sample1" + monkeypatch.setattr( + rna_tuning, + "screening_coverage", + lambda *args: ({"screeningCells": 100, "populationCells": 100}, []), + ) + monkeypatch.setattr( + run, + "experiments", + lambda selected: {"target": {"parameter": "leidenResolution", "value": 1.5}}, + ) + calls = [] + + def review(scope: str, index: int, selected: Any, coverage: Any) -> Any: + calls.append(selected.parameters.leidenResolution) + return rna_tuning.TuningAction( + action="experiment" if index == 0 else "accept", + selectedCandidateId=selected.candidateId, + experimentId="target" if index == 0 else None, + rationale="The observed population split needs a finer partition.", + plainLanguageSummary="Check the supported split.", + correctionNeed="notApplicable", + evidenceIds=[f"candidate:{selected.candidateId}"], + quantitativeFindings=["The supplied fixture has completed graph evidence."], + qualitativeFindings=["A supported population may contain a finer split."], + comparisonConclusions=[], + objectivePreservation="Retain the frozen cohort and gene selection.", + concern="The supported split requires resolution sensitivity evidence.", + expectedImprovement="Resolve the split without changing the graph.", + ) + + monkeypatch.setattr(run, "review", review) + status, selected = run.assess_scope("full", run.cells, run.baseline()) + assert status == "accept" + assert selected.parameters.leidenResolution == 1.5 + assert calls == [1.0, 1.5] + assert len(run.resolution_candidates["full"]) == 4 + assert run.budget.summary()["scopes"]["full"]["completed"] == { + "graphs": 1, + "partitions": 5, + } + assert len(run.budget.repairs) == run.full_repairs == 1 + assert run.full_repair["selectedCandidateId"] == selected.candidateId + + +def test_targeted_recovery_plans_do_not_collide_for_same_graph_different_question( + request: pytest.FixtureRequest, +) -> None: + run = request.getfixturevalue("panel_run") + run.recovery_scope = "sample1" + setting = run.baseline() + run._resolution_panel("full", run.cells, setting) + counts = run.budget.summary() + run.last_action = rna_tuning.TuningAction( + action="enlarge", + selectedCandidateId="observed", + rationale="The larger sample left donor support uncertain.", + plainLanguageSummary="Validate donor support with all cells.", + correctionNeed="notApplicable", + evidenceIds=["candidate:observed"], + quantitativeFindings=["Coverage was insufficient in the observed sample."], + qualitativeFindings=["Small populations require more donor support evidence."], + comparisonConclusions=[], + objectivePreservation="Keep rare populations eligible for assessment.", + ) + run._resolution_panel("full", run.cells, setting) + assert run.budget.summary() == counts diff --git a/tests/test_agent_harmony_reference.py b/tests/test_agent_harmony_reference.py new file mode 100644 index 00000000..b7446d66 --- /dev/null +++ b/tests/test_agent_harmony_reference.py @@ -0,0 +1,213 @@ +"""Matched numerical correction preserves biological programs and donor support.""" + +import json +from pathlib import Path + +import numpy as np +import pytest + +from scarf.agent.ingest import ingest +from scarf.agent.parameter_tuning.contracts import ( + ParameterCandidate, + ParameterTuningDependencies, +) +from scarf.agent.parameter_tuning.diagnostics import augment_cluster_evaluations +from scarf.agent.parameter_tuning.execution import execute_parameter_candidate +from scarf.agent.parameter_tuning.hvg import core_hvg_evidence +from scarf.agent.parameter_tuning.selection import harmony_acceptance_gate +from scarf.agent.tools import core_artifact_reference +from scarf.datastore.datastore import DataStore +from scarf.metrics.association import coefficient_estimability +from tests.test_agent_ingest import _write_h5ad + + +@pytest.mark.slow +@pytest.mark.parametrize( + ("batch_effect", "expected_benefit"), [(2.0, False), (0.5, True)] +) +def test_real_harmony_comparison_measures_batch_and_biological_preservation( + tmp_path: Path, + batch_effect: float, + expected_benefit: bool, +) -> None: + """A crossed technical effect is evaluated, with missing doublets still blocking.""" + rng = np.random.default_rng(4444) + n_cells, rare_start = 1200, 1152 + rows = np.arange(n_cells) + donor = rows % 6 + batch = (rows // 6) % 2 + labels = np.where(rows >= rare_start, "rare", np.where(rows < 576, "A", "B")) + design_rows = np.unique( + np.column_stack([donor.astype(str), batch.astype(str), labels]), axis=0 + ) + design = coefficient_estimability( + design_rows[:, 2], + coefficientKind="categorical", + technicals={"batch": design_rows[:, 1]}, + technicalKinds={"batch": "categorical"}, + ) + assert design["coefficientEstimable"] + confounded = coefficient_estimability( + design_rows[:, 2], + coefficientKind="categorical", + technicals={"batch": design_rows[:, 2]}, + technicalKinds={"batch": "categorical"}, + ) + assert not confounded["coefficientEstimable"] + counts = rng.poisson(0.2, (n_cells, 90)).astype(np.uint16) + for mask, columns in ( + (labels == "A", slice(0, 12)), + (labels == "B", slice(12, 24)), + (labels == "rare", slice(24, 36)), + ): + counts[mask, columns] += rng.poisson(6.0, (int(mask.sum()), 12)).astype( + np.uint16 + ) + for value, columns in ((0, slice(36, 48)), (1, slice(48, 60))): + mask = batch == value + counts[mask, columns] += rng.poisson( + batch_effect, (int(mask.sum()), 12) + ).astype(np.uint16) + names = ( + [f"A_{i}" for i in range(12)] + + [f"B_{i}" for i in range(12)] + + [f"RARE_{i}" for i in range(12)] + + [f"TECHNICAL_{i}" for i in range(24)] + + [f"BACKGROUND_{i}" for i in range(30)] + ) + source, target = tmp_path / "crossed.h5ad", tmp_path / "crossed.zarr" + _write_h5ad( + source, + counts, + feature_types=[b"Gene Expression"] * 90, + feature_names=[name.encode() for name in names], + ) + result = ingest(path=source, zarrPath=target, directions={"matrixKey": "X"}) + assert result.status == "done", result.notes + store = DataStore( + str(target), + default_assay="RNA", + min_features_per_cell=-1, + mito_pattern="", + ribo_pattern="", + nthreads=1, + ) + for column, values in ( + ("donor", donor.astype(str)), + ("batch", batch.astype(str)), + ("population", labels), + ): + store.cells.insert(column, values, overwrite=True) + cells = store.snapshot_cell_selection("I") + genes = core_hvg_evidence(store, assay="RNA", cells=cells)["scarfDefault"] + marker_genes = store.select_all_features(from_assay="RNA") + normalized = store.run_normalization(cells, features=genes) + candidates = { + name: ParameterCandidate( + candidateId=name, + dimensions=21, + neighborsK=21, + leidenResolution=0.5, + useHarmony=corrected, + ) + for name, corrected in (("native", False), ("harmony", True)) + } + deps = ParameterTuningDependencies( + store=store, + normalized=normalized, + cellSelection=cells, + fromAssay="RNA", + candidates=candidates, + batchColumns=("batch",), + preservationColumns=("population",), + columnKinds={"batch": "categorical", "population": "categorical"}, + harmonyAuthorized=True, + maxCandidates=2, + minClusterCells=10, + ) + measured = [execute_parameter_candidate(deps, name) for name in candidates] + assert all(row.status == "done" and row.eligible for row in measured), measured + native, corrected = augment_cluster_evaluations( + store, + measured, + marker_assay="RNA", + marker_features=marker_genes, + independent_unit_columns=("donor",), + technical_columns=("batch",), + ) + improvement = ( + corrected.metrics.batchMixing["batch"] - native.metrics.batchMixing["batch"] + ) + assert (improvement > 0.05) == expected_benefit, improvement + for metric, baseline in native.metrics.biologicalPreservation["population"].items(): + assert ( + corrected.metrics.biologicalPreservation["population"][metric] + >= baseline - 0.05 + ) + for row in (native, corrected): + clusters = np.asarray( + store.load_artifact(core_artifact_reference(row.artifacts["clusters"]))[ + "values" + ][:] + ) + rare_labels, sizes = np.unique(clusters[rare_start:], return_counts=True) + population = rare_labels[np.argmax(sizes)] + members = clusters == population + assert (members & (rows >= rare_start)).sum() / members.sum() >= 0.9 + assert len(np.unique(donor[members])) == 6 + assert ( + sum( + name.startswith("RARE_") + for name in row.metrics.topMarkerGenes[str(population)] + ) + >= 3 + ) + accepted, reasons = harmony_acceptance_gate( + native, + corrected, + batch_columns=("batch",), + protected_columns=("population",), + independent_unit_columns=("donor",), + require_doublet_evidence=True, + ) + assert not accepted + assert "Matched doublet-concentration comparison is missing." in reasons + other_checks, other_reasons = harmony_acceptance_gate( + native, + corrected, + batch_columns=("batch",), + protected_columns=("population",), + independent_unit_columns=("donor",), + ) + assert other_checks == expected_benefit, other_reasons + if not expected_benefit: + assert ( + "Harmony did not improve an approved batch metric beyond tolerance." + in reasons + ) + (tmp_path / "harmony_reference.json").write_text( + json.dumps( + { + "cells": n_cells, + "rareCells": n_cells - rare_start, + "nuisanceCountRate": batch_effect, + "batchMixingImprovement": improvement, + "batchMixing": { + row.candidateId: row.metrics.batchMixing + for row in (native, corrected) + }, + "preservation": { + row.candidateId: row.metrics.biologicalPreservation + for row in (native, corrected) + }, + "crossUnitSupport": { + row.candidateId: row.metrics.crossUnitSupport + for row in (native, corrected) + }, + "unresolvedAcceptance": reasons, + "primaryEvaluations": len(measured), + "limitation": "Synthetic measured correction, not model decision agreement or full workflow acceptance.", + }, + sort_keys=True, + ) + ) diff --git a/tests/test_agent_harmony_required.py b/tests/test_agent_harmony_required.py new file mode 100644 index 00000000..89c3f9e3 --- /dev/null +++ b/tests/test_agent_harmony_required.py @@ -0,0 +1,279 @@ +"""Safe correction designs require an executed matched Harmony comparison.""" + +from typing import Any + +import pytest + +from scarf.agent.orchestrator import rna_tuning +from scarf.agent.orchestrator.budget import CandidateBudgetExceeded +from scarf.agent.parameter_tuning.comparisons import ( + setting_changes, + validate_comparison_review, +) +from scarf.agent.parameter_tuning.contracts import ParameterCandidateEvaluation +from tests.agent_comparison_examples import observed_action +from tests.test_agent_required_comparisons import panel_run # noqa: F401 + + +def _set_design(run: rna_tuning.RnaTuningRun, *, safe: bool = True) -> None: + run.study = run.study.model_copy( + update={ + "correctionLicense": "safe" if safe else "unsafeConfounded", + "technicalBatchColumns": ["capture"], + } + ) + + +def _action( + selected: ParameterCandidateEvaluation, + *, + combining: bool = False, + choices: dict[str, str] | None = None, + safe: bool = True, +) -> rna_tuning.TuningAction: + return rna_tuning.TuningAction( + action="combine" if combining else "accept", + selectedCandidateId=selected.candidateId, + correctionNeed="notNeeded" if safe else "notApplicable", + combinedSettings=choices, + evidenceIds=[f"candidate:{selected.candidateId}"], + quantitativeFindings=["Compare the observed matched representations."], + qualitativeFindings=["Retain the reference population marker program."], + comparisonConclusions=[], + plainLanguageSummary="Prefer the native representation after comparison.", + objectivePreservation="Retain the reference population.", + rationale="The execution test nominates native; correction must still run.", + ) + + +def _assert_matched_pairs( + run: rna_tuning.RnaTuningRun, scope: str, *, partitions: int +) -> None: + corrected = [row for row in run.evaluations[scope] if row.parameters.useHarmony] + assert len(corrected) == partitions + for harmony in corrected: + setting = run.settings[harmony.candidateId] + native = [ + row + for row in run.evaluations[scope] + if not row.parameters.useHarmony + and row.parameters.model_dump(exclude={"candidateId", "useHarmony"}) + == harmony.parameters.model_dump(exclude={"candidateId", "useHarmony"}) + and run.settings[row.candidateId].features == setting.features + ] + assert len(native) == 1 + assert native[0].cellSelection == harmony.cellSelection + assert native[0].status == harmony.status == "done" + assert run.settings[native[0].candidateId].model_dump( + exclude={"parameters"} + ) == (setting.model_dump(exclude={"parameters"})) + + +@pytest.mark.parametrize("safe", [False, True]) +def test_combined_native_preference_still_executes_harmony_for_safe_design( + request: pytest.FixtureRequest, + monkeypatch: pytest.MonkeyPatch, + safe: bool, +) -> None: + run = request.getfixturevalue("panel_run") + _set_design(run, safe=safe) + reviews = [] + + def review(scope: str, index: int, selected: Any, coverage: Any) -> Any: + reviews.append(index) + if index == 0: + assert len(run.evaluations[scope]) == 12 + assert not any(row.parameters.useHarmony for row in run.evaluations[scope]) + return _action( + selected, + combining=True, + safe=safe, + choices={ + name: selected.candidateId + for name in ( + "hvgCountCandidateId", + "hvgRankingCandidateId", + "featurePolicyCandidateId", + "pcaCandidateId", + "neighborsCandidateId", + ) + }, + ) + _assert_matched_pairs(run, scope, partitions=4 if safe else 0) + native = next( + row + for row in run.evaluations[scope] + if not row.parameters.useHarmony + and row.parameters.dimensions == 21 + and row.parameters.neighborsK == 11 + and row.parameters.leidenResolution == 1.0 + ) + return _action(native, safe=safe) + + monkeypatch.setattr(run, "review", review) + status, selected = run.assess_scope("sample0", run.cells, None) + assert status == "accept" and selected is not None + assert not selected.parameters.useHarmony + assert reviews == [0, 1] + assert run.budget.summary()["scopes"]["sample0"]["completed"] == { + "graphs": 10 if safe else 9, + "partitions": 16 if safe else 12, + } + + +@pytest.mark.parametrize("safe", [False, True]) +@pytest.mark.parametrize("recovery", [False, True]) +def test_native_full_validation_and_recovery_admit_required_counterpart( + request: pytest.FixtureRequest, + monkeypatch: pytest.MonkeyPatch, + safe: bool, + recovery: bool, +) -> None: + run = request.getfixturevalue("panel_run") + _set_design(run, safe=safe) + if recovery: + run.recovery_scope = "sample1" + else: + run.discovery_scope = "sample0" + initial = run.baseline() + monkeypatch.setattr( + run, + "review", + lambda scope, index, selected, coverage: _action(selected, safe=safe), + ) + status, selected = run.assess_scope("full", run.cells, initial) + assert status == "accept" and selected is not None + assert not selected.parameters.useHarmony + assert selected.parameters.model_dump(exclude={"candidateId"}) == ( + initial.parameters.model_dump(exclude={"candidateId"}) + ) + _assert_matched_pairs(run, "full", partitions=(4 if recovery else 1) if safe else 0) + counts = { + "graphs": 2 if safe else 1, + "partitions": (4 if recovery else 1) * (2 if safe else 1), + } + assert run.budget.summary()["scopes"]["full"] == { + "reserved": counts, + "completed": counts, + } + if recovery: + coverage = run.comparison_coverage("full", run.cells) + for row in coverage["comparisons"]: + assert set( + setting_changes( + coverage["candidateSettings"][row["baselineCandidateId"]], + coverage["candidateSettings"][row["alternativeCandidateId"]], + ) + ) == {"partition"} + + +@pytest.mark.parametrize( + ("recovery", "limit", "value"), + [ + (False, "maxFullGraphs", 1), + (False, "maxFullPartitions", 1), + (True, "maxFullGraphs", 1), + (True, "maxFullPartitions", 7), + ], +) +def test_full_matched_plan_rejects_insufficient_capacity_before_execution( + request: pytest.FixtureRequest, + monkeypatch: pytest.MonkeyPatch, + recovery: bool, + limit: str, + value: int, +) -> None: + run = request.getfixturevalue("panel_run") + _set_design(run) + setattr(run.request.config, limit, value) + if recovery: + run.recovery_scope = "sample1" + monkeypatch.setattr( + run, "execute", lambda *args: pytest.fail("The whole pair must fit first") + ) + monkeypatch.setattr( + run, "review", lambda *args: pytest.fail("No incomplete pair may be reviewed") + ) + with pytest.raises(CandidateBudgetExceeded): + run.assess_scope("full", run.cells, run.baseline()) + assert run.evaluations["full"] == [] + assert run.budget.summary()["scopes"]["full"] == { + "reserved": {"graphs": 0, "partitions": 0}, + "completed": {"graphs": 0, "partitions": 0}, + } + + +def test_safe_combined_plan_admits_both_modes_before_new_native_partitions( + request: pytest.FixtureRequest, + monkeypatch: pytest.MonkeyPatch, +) -> None: + run = request.getfixturevalue("panel_run") + _set_design(run) + run.request.config.maxScreeningEvaluations = 18 + + def review(scope: str, index: int, selected: Any, coverage: Any) -> Any: + assert index == 0 + choices = { + name: selected.candidateId + for name in ( + "hvgCountCandidateId", + "hvgRankingCandidateId", + "featurePolicyCandidateId", + "pcaCandidateId", + "neighborsCandidateId", + ) + } + choices["pcaCandidateId"] = next( + row.candidateId + for row in run.evaluations[scope] + if row.parameters.dimensions == 10 + ) + return _action(selected, combining=True, choices=choices) + + monkeypatch.setattr(run, "review", review) + with pytest.raises(CandidateBudgetExceeded): + run.assess_scope("sample0", run.cells, None) + assert len(run.evaluations["sample0"]) == 12 + assert not any(row.parameters.useHarmony for row in run.evaluations["sample0"]) + assert run.budget.summary()["scopes"]["sample0"] == { + "reserved": {"graphs": 9, "partitions": 12}, + "completed": {"graphs": 9, "partitions": 12}, + } + + +def test_native_recovery_choice_validates_against_matched_resolution_panel( + request: pytest.FixtureRequest, + monkeypatch: pytest.MonkeyPatch, +) -> None: + run = request.getfixturevalue("panel_run") + _set_design(run) + sample = rna_tuning.artifact_model_to_ref( + run.handoff.cellSelection.model_copy(update={"artifactId": "e" * 64}) + ) + run.scope_sizes["sample1"] = 100 + baseline = run._resolution_panel("sample1", sample, run.baseline()) + run._sensitivity_panel("sample1", sample, baseline) + run.combined_candidates["sample1"] = baseline.candidateId + run.recovery_scope = "sample1" + + def review(scope: str, index: int, selected: Any, coverage: Any) -> Any: + assert scope == "full" and index == 0 + assert not selected.parameters.useHarmony + selected.metrics.topMarkerGenes = {"0": ["MS4A1"], "1": ["CD3D"]} + selected.metrics.nClusters = 2 + comparison = run.comparison_coverage(scope, run.cells) + action = observed_action( + { + "comparisonCoverage": comparison, + "currentCandidateId": selected.candidateId, + } + ) + action["correctionNeed"] = "notNeeded" + validate_comparison_review(comparison, action) + return rna_tuning.TuningAction.model_validate(action) + + monkeypatch.setattr(run, "review", review) + status, selected = run.assess_scope("full", run.cells, run.baseline()) + assert status == "accept" and selected is not None + assert not selected.parameters.useHarmony + _assert_matched_pairs(run, "full", partitions=4) diff --git a/tests/test_agent_hvg_boundaries.py b/tests/test_agent_hvg_boundaries.py new file mode 100644 index 00000000..6978c497 --- /dev/null +++ b/tests/test_agent_hvg_boundaries.py @@ -0,0 +1,211 @@ +"""Feature ranking respects frozen eligibility and measured group support.""" + +from dataclasses import replace +from types import SimpleNamespace +from typing import Any + +import numpy as np +import pytest + +from scarf.agent.parameter_tuning.hvg import ( + HvgGroupVariability, + aggregate_hvg_rankings, + effective_hvg_candidate_counts, + rank_core_hvgs, +) +from scarf.storage.refs import ArtifactRef + + +def _ranking_store() -> tuple[ + Any, ArtifactRef, ArtifactRef, dict[str, np.ndarray], list[np.ndarray] +]: + eligible = ArtifactRef("assay", "feature_selection", "a" * 64, "RNA") + statistics = ArtifactRef("assay", "feature_selection", "b" * 64, "RNA") + data = { + "mask": np.asarray([True, False, True, True, True]), + "variance": np.asarray([1.0, 100, 2, 2, 0]), + } + saved = [] + + def load(ref: ArtifactRef) -> dict[str, np.ndarray]: + return ( + {"values": data["mask"]} + if ref == eligible + else {"corrected_variance": data["variance"]} + ) + + def save( + *, from_assay: str, mask: np.ndarray, invalidate_cache: bool + ) -> ArtifactRef: + assert from_assay == "RNA" and not invalidate_cache + saved.append(mask.copy()) + return eligible + + return ( + SimpleNamespace(load_artifact=load, set_feature_selection=save), + eligible, + statistics, + data, + saved, + ) + + +def test_ranked_feature_intervention_preserves_eligibility_and_tie_order() -> None: + store, eligible, statistics, _, saved = _ranking_store() + rank_core_hvgs(store, eligible=eligible, statistics=statistics, top_n=3) + np.testing.assert_array_equal(saved[-1], [True, False, True, True, False]) + rank_core_hvgs( + store, + eligible=eligible, + statistics=statistics, + top_n=3, + ranking=np.asarray([4, 3, 2, 1, 0]), + ) + np.testing.assert_array_equal(saved[-1], [False, False, True, True, True]) + assert len(saved) == 2 + + +@pytest.mark.parametrize( + "damage", + [ + "unaligned", + "nonfinite", + "duplicate", + "matrix", + "negativeIndex", + "largeIndex", + "incompleteUniverse", + "tooFewRequested", + "booleanCount", + "tooFewEligible", + ], +) +def test_ranked_feature_intervention_rejects_incomplete_evidence_before_persistence( + damage: str, +) -> None: + store, eligible, statistics, data, saved = _ranking_store() + ranking = None + top_n = 3 + if damage == "unaligned": + data["variance"] = data["variance"][:-1] + elif damage == "nonfinite": + data["variance"][0] = np.nan + elif damage == "duplicate": + ranking = np.asarray([0, 0, 2, 3, 4]) + elif damage == "matrix": + ranking = np.asarray([[0, 2, 3, 4]]) + elif damage == "negativeIndex": + ranking = np.asarray([-1, 0, 2, 3, 4]) + elif damage == "largeIndex": + ranking = np.asarray([0, 2, 3, 4, 5]) + elif damage == "incompleteUniverse": + ranking = np.asarray([0, 2, 3]) + elif damage == "tooFewRequested": + top_n = 2 + elif damage == "booleanCount": + top_n = True + else: + data["mask"][:] = [True, False, True, False, False] + with pytest.raises(ValueError): + rank_core_hvgs( + store, + eligible=eligible, + statistics=statistics, + top_n=top_n, + ranking=ranking, + ) + assert saved == [] + + +@pytest.mark.parametrize( + "count,targets", + [(True, (1,)), (0, (1,)), (10, "123"), (10, (True,)), (10, (0,)), (10, ())], +) +def test_hvg_search_requires_valid_registered_counts(count: Any, targets: Any) -> None: + with pytest.raises((TypeError, ValueError)): + effective_hvg_candidate_counts(count, targets) + + +def test_one_supported_group_is_explicit_global_evidence_and_masks_remain_nested() -> ( + None +): + def unavailable_groups() -> Any: + raise AssertionError( + "An unsupported batch ranking must not consume group summaries" + ) + yield + + ranking = aggregate_hvg_rankings( + np.asarray([3.0, 9, 2, 1]), + np.asarray([True, False, True, True]), + unavailable_groups(), + valid_group_count=1, + candidate_targets=(2, 3, 5), + ) + assert ranking.ranking_mode == "global" + assert ranking.eligible_feature_count == 3 + assert ranking.candidate_counts == (2, 3) + np.testing.assert_array_equal(ranking.candidate_mask(2), [True, False, True, False]) + assert np.all(ranking.candidate_mask(2) <= ranking.candidate_mask(3)) + with pytest.raises(ValueError, match="registered counts"): + ranking.candidate_mask(1) + + +@pytest.mark.parametrize( + "damage", + [ + "globalShape", + "globalNonfinite", + "globalNegative", + "groupCountType", + "negativeGroups", + "excessGroups", + "missingGroups", + "emptyGroupId", + "cellCountType", + "emptyGroup", + "groupShape", + "groupNonfinite", + "groupNegative", + "undetectedGroup", + ], +) +def test_batch_aware_ranking_cannot_claim_unavailable_group_evidence( + damage: str, +) -> None: + corrected = np.asarray([3.0, 2, 1]) + eligible = np.ones(3, dtype=bool) + first = HvgGroupVariability("batch_a", 100, corrected.copy(), eligible.copy()) + second = replace(first, group_id="batch_b") + groups = [first, second] + declared: Any = 2 + if damage == "globalShape": + corrected = corrected[:2] + elif damage == "globalNonfinite": + corrected[0] = np.nan + elif damage == "globalNegative": + corrected[0] = -1 + elif damage == "groupCountType": + declared = True + elif damage == "negativeGroups": + declared = -1 + elif damage == "excessGroups": + groups.append(replace(first, group_id="batch_c")) + elif damage == "missingGroups": + groups.pop() + elif damage == "emptyGroupId": + groups[0] = replace(first, group_id="") + elif damage == "cellCountType": + groups[0] = replace(first, cell_count=True) + elif damage == "emptyGroup": + groups[0] = replace(first, cell_count=0) + elif damage == "groupShape": + groups[0] = replace(first, detected_features=np.ones(2, dtype=bool)) + elif damage == "groupNonfinite": + groups[0] = replace(first, corrected_variance=np.asarray([np.nan, 2, 1])) + elif damage == "groupNegative": + groups[0] = replace(first, corrected_variance=np.asarray([-1.0, 2, 1])) + else: + groups[0] = replace(first, detected_features=np.zeros(3, dtype=bool)) + with pytest.raises((TypeError, ValueError)): + aggregate_hvg_rankings(corrected, eligible, groups, valid_group_count=declared) diff --git a/tests/test_agent_interpretation_boundaries.py b/tests/test_agent_interpretation_boundaries.py new file mode 100644 index 00000000..aca0f1dc --- /dev/null +++ b/tests/test_agent_interpretation_boundaries.py @@ -0,0 +1,179 @@ +"""Standalone interpretation rejects stale artifacts before any model call.""" + +from types import SimpleNamespace +from typing import Any + +import pytest +from pydantic_ai import ModelRetry + +from scarf.agent.biological_interpretation.contracts import ( + BiologicalInterpretationReport, +) +from scarf.agent.biological_interpretation.validation import ( + _prepare_biological_interpretation_dependencies, + validate_biological_interpretation_report, +) +from scarf.storage.refs import ArtifactRef +from tests.test_agent_biological_interpretation import ( + FakeStore, + artifact_model, + context, +) + + +@pytest.mark.parametrize( + ("change", "reason"), + [ + ({"cluster": None}, "exact cluster artifact"), + ({"graph_assay": "Other"}, "different assay"), + ({"max_clusters": 0}, "max_clusters"), + ({"max_markers": 0}, "max_markers"), + ({"marker_min_score": 0}, "marker_min_score"), + ({"marker_min_fraction": 2}, "marker_min_fraction"), + ({"allow_marker_search": True, "marker": None}, "marker_features is required"), + ( + { + "marker": ArtifactRef( + scope="assay", + assay="RNA", + kind="feature_selection", + artifact_id="b" * 64, + ) + }, + "marker_table", + ), + ( + { + "marker_features": ArtifactRef( + scope="assay", + assay="RNA", + kind="marker_table", + artifact_id="b" * 64, + ) + }, + "feature_selection", + ), + ( + { + "cluster": ArtifactRef( + scope="assay", + assay="RNA", + kind="marker_table", + artifact_id="b" * 64, + ) + }, + "cluster_labels or cluster_cut", + ), + ( + { + "marker_features": ArtifactRef( + scope="assay", + assay="Other", + kind="feature_selection", + artifact_id="b" * 64, + ) + }, + "different assay", + ), + ], +) +def test_interpretation_input_contract_rejects_unsupported_work( + change: dict[str, Any], + reason: str, +) -> None: + store = FakeStore() + arguments = dict( + cluster=store.cluster, + from_assay=None, + graph_assay=None, + marker_assay_type=None, + sample_column=None, + condition_column=None, + tuning_handoff=None, + experimental_handoff=None, + marker=store.marker, + marker_features=None, + allow_marker_search=False, + max_clusters=8, + max_markers=10, + marker_min_score=0.1, + marker_min_fraction=0.1, + ) + with pytest.raises((TypeError, ValueError), match=reason): + _prepare_biological_interpretation_dependencies(store, **(arguments | change)) + assert store.marker_calls == 0 + + +@pytest.mark.parametrize( + ("artifact", "fields", "reason"), + [ + ("cluster", {"exists": False}, "cluster artifact does not exist"), + ("cluster", {"complete": False}, "cluster artifact is incomplete"), + ("cluster", {"inputs": {}}, "no cell-selection input"), + ("marker", {"exists": False}, "marker artifact does not exist"), + ("marker", {"complete": False}, "marker artifact is incomplete"), + ("marker", {"inputs": {}}, "exact cluster artifact"), + ], +) +def test_interpretation_artifact_status_is_validated_before_model_execution( + monkeypatch: Any, + artifact: str, + fields: dict[str, Any], + reason: str, +) -> None: + store = FakeStore() + inspect = store.inspect_artifact + damaged_ref = getattr(store, artifact) + + def damaged(ref: ArtifactRef) -> Any: + status = inspect(ref) + if ref != damaged_ref: + return status + return SimpleNamespace( + **( + { + "exists": status.exists, + "complete": status.complete, + "inputs": status.inputs, + } + | fields + ) + ) + + monkeypatch.setattr(store, "inspect_artifact", damaged) + with pytest.raises(ValueError, match=reason): + _prepare_biological_interpretation_dependencies( + store, + cluster=store.cluster, + from_assay=None, + graph_assay=None, + marker_assay_type=None, + sample_column=None, + condition_column=None, + tuning_handoff=None, + experimental_handoff=None, + marker=store.marker, + marker_features=None, + allow_marker_search=False, + max_clusters=8, + max_markers=10, + marker_min_score=0.1, + marker_min_fraction=0.1, + ) + assert store.marker_calls == 0 + + +@pytest.mark.parametrize("field", ["clusterArtifact", "markerArtifact"]) +def test_interpretation_model_cannot_replace_exact_artifact_bindings( + field: str, +) -> None: + store = FakeStore() + deps = context(store, marker=store.marker).deps + deps.clusterValues = ["0"] + reference = store.cluster if field == "clusterArtifact" else store.marker + changed = artifact_model(reference).model_copy(update={"artifactId": "f" * 64}) + report = BiologicalInterpretationReport.model_validate( + {"status": "done", field: changed} + ) + with pytest.raises(ModelRetry, match=f"{field} does not match"): + validate_biological_interpretation_report(report, deps) diff --git a/tests/test_agent_journal_recovery_boundaries.py b/tests/test_agent_journal_recovery_boundaries.py new file mode 100644 index 00000000..d3564bb1 --- /dev/null +++ b/tests/test_agent_journal_recovery_boundaries.py @@ -0,0 +1,366 @@ +"""Recovery requires the saved scientific identity, not merely a usable store.""" + +import hashlib +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest +import zarr + +from scarf.agent import record_io +from scarf.agent.decisions.kernel import DecisionSelection, DecisionSpec +from scarf.agent.orchestrator import AgentOrchestrator, journal +from scarf.agent.orchestrator import main as orchestrator_main +from scarf.agent.orchestrator.models import ( + AutomatedWorkflowRequest, + WorkflowNeedsInput, + WorkflowQuestion, + WorkflowStageAttempt, +) +from scarf.agent.types import AgentDataModel +from tests.agent_journal_store import memory_journal +from tests.test_agent_decision_kernel import _decision_spec + + +class ScientificEvidence(AgentDataModel): + conclusion: str = "Supported on the measured cohort" + + +class OtherEvidence(AgentDataModel): + conclusion: str = "A different scientific checkpoint" + + +@pytest.mark.parametrize( + ("payload", "reason"), + [ + ( + {"inputs": {}, "outputs": {}, "oldHistoryVersion": 1}, + "Unsupported RNA checkpoint contract", + ), + ({"inputs": [], "outputs": {}}, "inputs and outputs must be mappings"), + ({"inputs": {}, "outputs": []}, "inputs and outputs must be mappings"), + ], +) +def test_read_checkpoint_rejects_unsupported_history_without_rewriting_it( + payload: dict[str, Any], reason: str +) -> None: + store, prefix, record = memory_journal() + data = dict(payload) + if set(data) == {"inputs", "outputs"}: + data["contentSha256"] = hashlib.sha256( + record_io.canonical_json_bytes(data) + ).hexdigest() + key = journal._checkpoint_key(prefix, record.workflowRunId, "qc/evidence") + raw = record_io.canonical_json_bytes(data) + journal._write_key_once(store.zw, key, raw) + with pytest.raises(ValueError, match=reason): + journal.read_checkpoint(store, prefix, record.workflowRunId, "qc/evidence") + assert record_io.read_key(store.zw, key) == raw + + +def test_stage_recovery_rejects_wrong_report_type_checksum_and_ambiguous_ownership() -> ( + None +): + store, prefix, record = memory_journal() + started = journal._start_attempt( + store.zw, prefix, record.workflowRunId, "ingest", record, [] + ) + saved, reference = journal._save_stage_report( + store, started, ScientificEvidence(), expected_type=ScientificEvidence + ) + assert journal._recover_persisted_stage_report( + store, started, expected_type=ScientificEvidence + ) == (saved, reference) + with pytest.raises(ValueError, match="different scientific result type"): + journal._recover_persisted_stage_report( + store, started, expected_type=OtherEvidence + ) + with pytest.raises(ValueError, match="exact reference"): + journal.read_stage_evidence( + store, reference.model_copy(update={"contentSha256": "f" * 64}) + ) + duplicate = started.model_copy(update={"reportReferences": [reference, reference]}) + with pytest.raises(ValueError, match="exactly one evidence report"): + journal.load_stage_report(store, duplicate, ScientificEvidence) + assert journal.read_stage_evidence(store, reference) == saved.model_dump( + mode="json" + ) + + +@pytest.mark.parametrize( + ("answer", "reason"), + [ + ("native", "must contain decisionId"), + ( + { + "decisionId": "correction", + "optionId": "native", + "rationale": "Confounded", + "useHarmony": False, + }, + "contain exactly", + ), + ( + { + "decisionId": "some_other_decision", + "optionId": "native", + "rationale": "Confounded", + }, + "does not match decision", + ), + ( + {"decisionId": "correction", "optionId": "native", "rationale": " "}, + "non-empty rationale", + ), + ( + { + "decisionId": "correction", + "optionId": "invented", + "rationale": "Better mixing", + }, + "persisted option", + ), + ], +) +def test_resume_answer_is_bound_to_the_exact_saved_question_and_options( + answer: Any, reason: str +) -> None: + paused = WorkflowStageAttempt( + status="needsInput", + stage="experimental_context", + needsInput=WorkflowNeedsInput( + questions=[ + WorkflowQuestion( + questionId="correctionChoice", + decisionId="correction", + question="The design confounds batch with protected biology. Which offered action is justified?", + options=["native", "clarify"], + ) + ] + ), + ) + assert ( + journal._resume_answer_errors( + paused, + { + "correctionChoice": { + "decisionId": "correction", + "optionId": "native", + "rationale": "The design cannot separate batch from condition.", + } + }, + ) + == [] + ) + errors = journal._resume_answer_errors(paused, {"correctionChoice": answer}) + assert any(reason in error for error in errors) + + +@pytest.mark.parametrize( + "field", ["workflowRunId", "requestSha256", "configSha256", "contentSha256"] +) +def test_request_recovery_does_not_trust_a_corrupted_identity( + monkeypatch: pytest.MonkeyPatch, field: str +) -> None: + store, prefix, record = memory_journal() + altered = record.model_copy( + update={field: "wrong-workflow" if field == "workflowRunId" else "f" * 64} + ) + monkeypatch.setattr(journal, "_read_model", lambda *_args: altered) + with pytest.raises(ValueError, match="identity or checksum"): + journal.read_request(store.zw, prefix, record.workflowRunId) + + +@pytest.mark.parametrize( + ("change", "reason"), + [ + ({"zarrPath": None}, "no store path"), + ({"zarrPath": "other-study.zarr"}, "does not match the saved request"), + ({"workspace": "other_workspace"}, "does not match the saved request"), + ({"primaryAssay": ""}, "no selected RNA assay"), + ], +) +def test_result_store_is_validated_before_opening_datastore( + monkeypatch: pytest.MonkeyPatch, change: dict[str, Any], reason: str +) -> None: + store, _prefix, record = memory_journal() + altered = record.model_copy( + update={"request": record.request.model_copy(update=change)} + ) + calls: list[str] = [] + monkeypatch.setattr(journal.zarr, "open_group", lambda *_args, **_kwargs: store.zw) + monkeypatch.setattr(journal, "read_request", lambda *_args: altered) + monkeypatch.setattr( + journal, "DataStore", lambda *_args, **_kwargs: calls.append("open") + ) + with pytest.raises(ValueError, match=reason): + journal.open_analysis_store("analysis.zarr", record.workflowRunId) + assert calls == [] + + +@pytest.mark.parametrize("entry", ["artifact_array", "group"]) +def test_result_and_beginner_cannot_treat_an_array_as_a_workspace( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, entry: str +) -> None: + destination = tmp_path / "analysis.zarr" + root = zarr.open_group(str(destination), mode="w") + root.create_array("artifact_array", shape=(2,), dtype="i4") + root.create_group("group") + # The array is valid Zarr data, but cannot own the selected analysis history. + if entry == "artifact_array": + with pytest.raises(ValueError, match="workspace is not a group"): + journal.open_analysis_store(destination, "workflow", workspace=entry) + result = AgentOrchestrator("test-model").run( + AutomatedWorkflowRequest( + sourcePath=str(destination), + workspace=entry, + studyContext="RNA cells", + studyObjective="Characterize populations", + ) + ) + assert result.status == "failed" + assert "workspace is not a group" in " ".join(result.notes) + else: + # A real group proceeds as far as exact history lookup, not numerical work. + monkeypatch.setattr( + journal, + "read_request", + lambda *_args: (_ for _ in ()).throw(ValueError("Exact history absent")), + ) + with pytest.raises(ValueError, match="Exact history absent"): + journal.open_analysis_store(destination, "workflow", workspace=entry) + + +@pytest.mark.parametrize( + ("update", "reason"), + [ + ({"baselineOptionId": "invented"}, "baselineOptionId must reference"), + ( + {"metricPreferredOptionId": "invented"}, + "metricPreferredOptionId must reference", + ), + ({"metricPreferredOptionId": None}, "requires metricPreferredOptionId"), + ({"allowedSources": []}, "must not be empty"), + ({"allowedSources": ["agent", "agent"]}, "must not contain duplicates"), + ], +) +def test_closed_decision_spec_rejects_unregistered_authority( + update: dict[str, Any], reason: str +) -> None: + payload = _decision_spec().model_dump(mode="json") | update + with pytest.raises(ValueError, match=reason): + DecisionSpec.model_validate(payload) + + +@pytest.mark.parametrize( + ("update", "reason"), + [ + ( + {"overrideEvidenceIds": ["uncited"], "overrideOfOptionId": "baseline"}, + "must be included in evidenceIds", + ), + ({"overrideEvidenceIds": ["markers"]}, "require overrideOfOptionId"), + ({"overrideOfOptionId": "chosen"}, "must differ from selectedOptionId"), + ({"selectedOptionId": "invented option with spaces"}, "stable identifier"), + ], +) +def test_model_override_cannot_invent_a_comparator_or_uncited_support( + update: dict[str, Any], reason: str +) -> None: + payload = { + "selectedOptionId": "chosen", + "evidenceIds": ["markers"], + "rationale": "Marker programs support the selected populations.", + } | update + with pytest.raises(ValueError, match=reason): + DecisionSelection.model_validate(payload) + + +@pytest.mark.parametrize( + ("batch_columns", "reason"), + [ + ("library", "list of exact observation-column names"), + ([" "], "list of exact observation-column names"), + (["donor"], "must include the CELLxGENE"), + ], +) +def test_manifest_declared_batch_cannot_be_overridden_before_context_enrichment( + monkeypatch: pytest.MonkeyPatch, batch_columns: Any, reason: str +) -> None: + runner = AgentOrchestrator("test-model") + monkeypatch.setattr(runner, "_reuse_or_resume", lambda _request: None) + monkeypatch.setattr( + orchestrator_main, + "inspect_h5ad_manifest", + lambda *_args, **_kwargs: SimpleNamespace(declaredBatchColumns=["library"]), + ) + result = runner.run( + AutomatedWorkflowRequest( + sourcePath="study.h5ad", + studyContext="Two library preparations", + studyObjective="Compare populations", + experimentalDirections={"batchColumns": batch_columns}, + ) + ) + assert result.status == "failed" + assert result.currentStage == "ingest" + assert reason in " ".join(result.notes) + assert result.workflowRunId is None + + +@pytest.mark.parametrize( + ("manifest_status", "input_policy", "expected"), + [ + ("needsInput", "unattended", "abstained"), + ("needsInput", "pause", "needsInput"), + ("abstained", "unattended", "abstained"), + ], +) +def test_ambiguous_counts_never_reach_expensive_analysis( + monkeypatch: pytest.MonkeyPatch, + manifest_status: str, + input_policy: str, + expected: str, +) -> None: + from scarf.agent.orchestrator import AutomatedWorkflowConfig + + runner = AgentOrchestrator( + "test-model", config=AutomatedWorkflowConfig(inputPolicy=input_policy) + ) + monkeypatch.setattr(runner, "_reuse_or_resume", lambda _request: None) + manifest = SimpleNamespace( + declaredBatchColumns=["library"], + decision=SimpleNamespace( + status=manifest_status, + summary="The count matrix is ambiguous", + options=["X", "raw/X"], + evidenceIds=["counts:X", "counts:raw"], + ), + priorFiltering=SimpleNamespace( + limitations=["Only published cells are available"] + ), + ) + seen: list[Any] = [] + + def inspect(*args: Any, **kwargs: Any) -> Any: + seen.append((args, kwargs)) + return manifest + + monkeypatch.setattr(orchestrator_main, "inspect_h5ad_manifest", inspect) + result = runner.run( + AutomatedWorkflowRequest( + sourcePath="study.h5ad", + studyContext="RNA libraries", + studyObjective="Assess populations", + ) + ) + assert result.status == expected + assert result.currentStage == "ingest" + assert result.limitations == ["Only published cells are available"] + assert len(seen) == 1 + assert result.workflowRunId is None + if expected == "needsInput": + assert result.needsInput.questions[0].options == ["X", "raw/X"] + else: + assert "count" in " ".join(result.notes) diff --git a/tests/test_agent_journal_usage.py b/tests/test_agent_journal_usage.py new file mode 100644 index 00000000..b33ed88a --- /dev/null +++ b/tests/test_agent_journal_usage.py @@ -0,0 +1,181 @@ +"""Saved invocation usage survives failure and is counted once in derived views.""" + +from typing import Any + +import pytest + +from scarf.agent.orchestrator import AgentOrchestrator, journal +from scarf.agent.orchestrator.models import AutomatedWorkflowRequest, WorkflowIdentity +from scarf.agent.types import AgentDataModel, AgentRunInfo, AgentUsageInfo +from tests.agent_journal_store import memory_journal + + +class MeasuredReport(AgentDataModel): + runInfo: AgentRunInfo + + +def test_snapshot_counts_failed_and_completed_invocations_without_report_duplicates() -> ( + None +): + store, prefix, record = memory_journal("analysis") + inputs = {"selection": "cells", "metadata": {"age": "continuous"}} + save = journal.model_attempt_callback( + store, prefix, record.workflowRunId, "ingest/review", inputs + ) + complete = AgentRunInfo( + runId="complete", + agentName="test", + status="done", + usage=AgentUsageInfo( + requests=2, + inputTokens=20, + outputTokens=4, + totalTokens=24, + availability="reported", + ), + ) + failed = AgentRunInfo( + runId="failed", + agentName="test", + status="failed", + usage=AgentUsageInfo( + requests=1, + inputTokens=7, + outputTokens=1, + totalTokens=8, + availability="partial", + ), + error="RuntimeError: Provider outage after a measured response", + ) + unavailable = AgentRunInfo( + runId="no-response", + agentName="test", + status="failed", + usage=AgentUsageInfo(availability="unavailable"), + ) + for info in (failed, complete, unavailable, complete): + save(info) + started = journal._start_attempt( + store.zw, prefix, record.workflowRunId, "ingest", record, [], inputs=inputs + ) + _, reference = journal._save_stage_report( + store, started, MeasuredReport(runInfo=complete), expected_type=MeasuredReport + ) + outcome = journal._complete_attempt( + started, + status="done", + report_references=[reference], + outputs={"runInfo": complete.model_dump(mode="json")}, + ) + journal._save_outcome(store.zw, prefix, outcome) + snapshot = journal.analysis_snapshot(store, record.workflowRunId) + assert len(snapshot["modelAttempts"]) == 4 + usage = snapshot["modelUsage"] + assert usage["invocations"] == 3 + assert usage["failedInvocations"] == 2 + assert usage["requests"] == 3 + assert usage["inputTokens"] == 27 + assert usage["totalTokens"] == 32 + assert usage["availability"] == "partial" + assert usage["partialUsageInvocations"] == 1 + assert usage["unavailableUsageInvocations"] == 1 + assert snapshot == journal.analysis_snapshot(store, record.workflowRunId) + + +def test_usage_without_reported_availability_is_not_assumed_complete() -> None: + legacy = AgentRunInfo( + modelName="legacy-model", + usage=AgentUsageInfo(inputTokens=17, totalTokens=17), + ).model_dump(mode="json") + views = [{"runInfo": legacy}, {"report": {"runInfo": legacy}}, {"runInfo": {}}] + usage = journal._model_usage([], views) + assert usage["invocations"] == 1 + assert usage["inputTokens"] == 17 + assert usage["availability"] == "partial" + assert usage["unspecifiedUsageInvocations"] == 1 + assert journal._model_usage([], [])["availability"] == "unavailable" + + +def test_uncaught_stage_failure_preserves_cause_and_invocation_usage( + monkeypatch: Any, +) -> None: + store, prefix, record = memory_journal() + started = journal._start_attempt( + store.zw, prefix, record.workflowRunId, "ingest", record, [] + ) + info = AgentRunInfo( + runId="failed-call", + status="failed", + usage=AgentUsageInfo(inputTokens=9, totalTokens=9, availability="partial"), + ) + error = RuntimeError("The bounded decision could not be completed") + error.__cause__ = ValueError("Required marker comparison is missing") + error.agent_run_info = info + + def fail(*_args: Any, **_kwargs: Any) -> None: + raise error + + runner = AgentOrchestrator("test-model") + monkeypatch.setattr(runner, "_execute_stages", fail) + result = runner._continue( + store, WorkflowIdentity(record.workflowRunId, None), record, answers={} + ) + assert result.status == "failed" + assert result.currentStage == "ingest" + assert "Required marker comparison is missing" in " ".join(result.notes) + assert result.workflowRunId == record.workflowRunId + outcome = journal._stage_outcomes(store.zw, prefix, record.workflowRunId, "ingest")[ + 0 + ] + assert outcome.attemptId == started.attemptId + assert outcome.outputs["runInfo"] == info.model_dump(mode="json") + snapshot = journal.analysis_snapshot(store, record.workflowRunId) + assert snapshot["modelUsage"]["inputTokens"] == 9 + + +@pytest.mark.parametrize("persistence_fails", [False, True]) +def test_structured_failure_preserves_root_cause_before_persistence( + monkeypatch: Any, persistence_fails: bool +) -> None: + runner = AgentOrchestrator("test-model") + request = AutomatedWorkflowRequest( + sourcePath="missing.h5ad", + studyContext="Human cells", + studyObjective="Assess populations", + ) + error = RuntimeError("Could not open source") + error.__cause__ = OSError("Exact missing file") + if persistence_fails: + error.add_note("Saving failure also failed") + + def fail(_request: Any) -> None: + raise error + + monkeypatch.setattr(runner, "_run", fail) + result = runner.run(request) + assert result.status == "failed" + assert "OSError: Exact missing file" in result.notes[0] + if persistence_fails: + assert "Saving failure also failed" in result.notes[0] + + +def test_report_shows_saved_failed_usage_without_model_or_numerical_calls() -> None: + from scarf.agent.report.artifacts import scientific_summary + from scarf.agent.report.rendering import render_analysis_document + from tests.test_agent_report import display_payload, snapshot + + state = snapshot() + state["modelUsage"] = { + "invocations": 4, + "failedInvocations": 1, + "requests": 8, + "validationRetries": 2, + "inputTokens": 1234, + "outputTokens": 56, + "availability": "partial", + } + rendered = render_analysis_document(scientific_summary(state) | display_payload()) + assert "4 invocations, 1 failed" in rendered + assert "1,234 input and 56 output" in rendered + assert "missing usage is not zero" in rendered + assert "2 validation corrections" in rendered diff --git a/tests/test_agent_notebook_interrupt.py b/tests/test_agent_notebook_interrupt.py new file mode 100644 index 00000000..c3eb0dfb --- /dev/null +++ b/tests/test_agent_notebook_interrupt.py @@ -0,0 +1,215 @@ +"""Notebook interrupts cancel real worker tasks without waiting for model output.""" + +import asyncio +from concurrent.futures import ThreadPoolExecutor +from threading import Event +from typing import Any + +import pytest +from pydantic_ai.messages import ModelResponse, ToolCallPart +from pydantic_ai.models.function import FunctionModel +from pydantic_ai.usage import RequestUsage + +from scarf.agent.config import agent_exec +from scarf.agent.types import AgentDataModel + + +class Choice(AgentDataModel): + value: int + + +@pytest.mark.parametrize("callback_fails", [False, True]) +def test_notebook_interrupt_cancels_pending_request_and_keeps_partial_usage( + monkeypatch: Any, + callback_fails: bool, +) -> None: + waiting = Event() + cancelled = Event() + calls = {"requests": 0, "measurements": 0, "completedResponses": 0} + attempts = [] + original_interrupt = KeyboardInterrupt("Notebook interrupted") + + async def measure() -> int: + calls["measurements"] += 1 + return 2 + + async def respond(_messages: Any, _info: Any) -> ModelResponse: + calls["requests"] += 1 + if calls["requests"] == 1: + calls["completedResponses"] += 1 + return ModelResponse( + parts=[ToolCallPart("measure", {})], + usage=RequestUsage(input_tokens=11, output_tokens=2), + ) + waiting.set() + try: + await asyncio.Event().wait() + raise AssertionError("A pending request must not produce a completion") + except asyncio.CancelledError: + cancelled.set() + raise + + def save_attempt(info: Any) -> None: + attempts.append(info) + if callback_fails: + raise RuntimeError("Saving cancellation evidence failed") + + class InterruptingPool(ThreadPoolExecutor): + def submit(self, fn: Any, *args: Any, **kwargs: Any) -> Any: + future = super().submit(fn, *args, **kwargs) + original_result = future.result + first = True + + def result(timeout: float | None = None) -> Any: + nonlocal first + if first: + first = False + assert waiting.wait(10), "The simulated provider did not start" + raise original_interrupt + return original_result(timeout=timeout) + + future.result = result + return future + + monkeypatch.setattr(agent_exec, "ThreadPoolExecutor", InterruptingPool) + + async def notebook() -> BaseException: + with pytest.raises(KeyboardInterrupt) as caught: + agent_exec.run_agent_sync( + model=FunctionModel(respond), + output_type=Choice, + system_prompt="Interpret a measured count.", + user_prompt="Measure once, then decide.", + tools=[measure], + on_attempt=save_attempt, + ) + return caught.value + + interrupted = asyncio.run(notebook()) + assert interrupted is original_interrupt + assert cancelled.is_set() + assert calls == {"requests": 2, "measurements": 1, "completedResponses": 1} + assert len(attempts) == 1 + assert interrupted.agent_run_info is attempts[0] + assert attempts[0].status == "failed" + assert attempts[0].usage.availability == "partial" + assert attempts[0].usage.inputTokens == 11 + assert attempts[0].usage.requests == 1 + assert attempts[0].errorType == "CancelledError" + if callback_fails: + assert "Saving cancellation evidence failed" in agent_exec.describe_agent_error( + interrupted + ) + + +def test_notebook_interrupt_before_worker_ready_never_starts_a_provider_request( + monkeypatch: Any, +) -> None: + ready = Event() + release = Event() + attempts = [] + requests = [] + original_interrupt = KeyboardInterrupt("Interrupted before worker startup") + + async def respond(_messages: Any, _info: Any) -> ModelResponse: + requests.append("unexpected") + raise AssertionError("The interrupted invocation must not contact the provider") + + class StartingPool(ThreadPoolExecutor): + def submit(self, fn: Any, *args: Any, **kwargs: Any) -> Any: + def start() -> Any: + ready.set() + assert release.wait(10), "Worker startup was not released for cleanup" + return fn(*args, **kwargs) + + future = super().submit(start) + original_result = future.result + first = True + + def result(timeout: float | None = None) -> Any: + nonlocal first + if first: + first = False + assert ready.wait(10), "The worker thread did not start" + raise original_interrupt + release.set() + return original_result(timeout=timeout) + + future.result = result + return future + + monkeypatch.setattr(agent_exec, "ThreadPoolExecutor", StartingPool) + + async def notebook() -> BaseException: + with pytest.raises(KeyboardInterrupt) as caught: + agent_exec.run_agent_sync( + model=FunctionModel(respond), + output_type=Choice, + system_prompt="Interpret evidence.", + user_prompt="Assess the observed count.", + on_attempt=attempts.append, + ) + return caught.value + + interrupted = asyncio.run(notebook()) + assert interrupted is original_interrupt + assert requests == [] + assert len(attempts) == 1 + assert interrupted.agent_run_info is attempts[0] + assert attempts[0].status == "failed" + assert attempts[0].errorType == "CancelledError" + assert attempts[0].usage.availability == "unavailable" + assert "before model execution" in attempts[0].error + + +def test_notebook_interrupt_racing_with_completed_worker_preserves_actual_usage( + monkeypatch: Any, +) -> None: + attempts = [] + original_interrupt = KeyboardInterrupt("Interrupted after completion") + + async def respond(_messages: Any, info: Any) -> ModelResponse: + return ModelResponse( + parts=[ToolCallPart(info.output_tools[0].name, {"value": 1})], + usage=RequestUsage(input_tokens=7, output_tokens=2), + ) + + class CompletedPool(ThreadPoolExecutor): + def submit(self, fn: Any, *args: Any, **kwargs: Any) -> Any: + future = super().submit(fn, *args, **kwargs) + original_result = future.result + first = True + + def result(timeout: float | None = None) -> Any: + nonlocal first + completed = original_result(timeout=timeout) + if first: + first = False + raise original_interrupt + return completed + + future.result = result + return future + + monkeypatch.setattr(agent_exec, "ThreadPoolExecutor", CompletedPool) + + async def notebook() -> BaseException: + with pytest.raises(KeyboardInterrupt) as caught: + agent_exec.run_agent_sync( + model=FunctionModel(respond), + output_type=Choice, + system_prompt="Interpret evidence.", + user_prompt="Use the measured count.", + on_attempt=attempts.append, + ) + return caught.value + + interrupted = asyncio.run(notebook()) + assert interrupted is original_interrupt + assert len(attempts) == 1 + assert attempts[0].status == "done" + assert attempts[0].usage.inputTokens == 7 + assert interrupted.agent_run_info is attempts[0] + assert "completed before cancellation" in agent_exec.describe_agent_error( + interrupted + ) diff --git a/tests/test_agent_orchestrator.py b/tests/test_agent_orchestrator.py index f175c06b..6cc7393d 100644 --- a/tests/test_agent_orchestrator.py +++ b/tests/test_agent_orchestrator.py @@ -28,7 +28,6 @@ from scarf.agent.experimental_context import ( BatchCorrectionPlan, CellQcPlan, - CovariateEvidence, ExperimentalContextDecision, ) from scarf.agent.parameter_tuning import ParameterTuningReport @@ -99,6 +98,10 @@ def tool_result( content = part.content if isinstance(content, model_type): return content + if model_type is dict: + return ( + json.loads(content) if isinstance(content, str) else content + ) if isinstance(content, str): return model_type.model_validate_json(content) return model_type.model_validate(content) @@ -184,14 +187,14 @@ async def reply( context_evidence = tool_result( messages, "analyze_experimental_design", - CovariateEvidence, + dict, ) profile = next( value - for value in context_evidence.qcProfiles - if value.registeredProfile is not None + for value in context_evidence["qcProfiles"] + if value["registeredProfile"] is not None ) - evidence_id = profile.evidenceId + evidence_id = profile["evidenceId"] decision = ExperimentalContextDecision( batchCorrection=BatchCorrectionPlan( action="skip", diff --git a/tests/test_agent_parameter_tuning.py b/tests/test_agent_parameter_tuning.py index 622bd957..10c3fa40 100644 --- a/tests/test_agent_parameter_tuning.py +++ b/tests/test_agent_parameter_tuning.py @@ -1846,7 +1846,7 @@ def unavailable_structured_output(**kwargs: Any) -> None: primary_assay="RNA", ) - assert calls == ["parameter_batch_search_planning", "parameter_tuning_batch"] + assert calls == ["parameter_batch_search_planning"] assert result.status == "needsInput" assert result.recommendedByAssay == {} assert result.assayReports["RNA"].confidence == "low" @@ -1855,7 +1855,7 @@ def unavailable_structured_output(**kwargs: Any) -> None: assert result.assayReports["RNA"].needsInput.options == ["baseline", "pca_15"] assert result.searchPlan is not None assert result.searchPlan.status == "complete" - assert result.runInfo.agentName == "parameter_tuning_batch_needs_input" + assert result.runInfo.agentName == "parameter_batch_search_planning_needs_input" def test_single_tuning_pauses_after_structured_output_exhaustion( @@ -1884,7 +1884,7 @@ def unavailable_structured_output(**_kwargs: Any) -> None: assert result.confidence == "low" assert result.needsInput is not None assert result.needsInput.options == ["baseline", "pca_15"] - assert result.runInfo.agentName == "parameter_tuning_needs_input" + assert result.runInfo.agentName == "parameter_search_planning_needs_input" def test_pending_parameter_report_does_not_select_without_successful_baseline() -> None: diff --git a/tests/test_agent_plot_boundaries.py b/tests/test_agent_plot_boundaries.py new file mode 100644 index 00000000..878340ba --- /dev/null +++ b/tests/test_agent_plot_boundaries.py @@ -0,0 +1,139 @@ +"""Final maps fail visibly on invalid artifacts and retain dense population legends.""" + +from typing import Any + +import matplotlib.pyplot as plt +import numpy as np +import pytest + +from scarf.agent import _plots +from scarf.storage.refs import ArtifactRef +from tests.test_agent_analysis_plots import display_store + + +@pytest.mark.parametrize("limit", [False, 0, 50_001, 1.5]) +def test_invalid_display_limit_fails_before_artifact_reads( + monkeypatch: pytest.MonkeyPatch, limit: Any +) -> None: + store, refs, arrays = display_store(monkeypatch, n=12) + with pytest.raises(ValueError, match="max_points"): + _plots.plot_final_umap(store, **refs, max_points=limit, show=False) + assert not any(array.read_sizes for array in arrays.values()) + + +@pytest.mark.parametrize( + "damage", + [ + "incomplete", + "missingSelection", + "wrongAssay", + "wrongKind", + "nonNumeric", + "empty", + "graphSelection", + ], +) +def test_final_map_validates_all_artifact_boundaries( + monkeypatch: pytest.MonkeyPatch, damage: str +) -> None: + store, refs, arrays = display_store(monkeypatch, n=0 if damage == "empty" else 12) + inspect = store.inspect_artifact + + def changed(ref: ArtifactRef) -> Any: + status = inspect(ref) + if damage == "incomplete": + status.complete = False + elif damage == "missingSelection": + status.inputs.pop("cell_selection") + return status + + store.inspect_artifact = changed + if damage == "wrongAssay": + refs["umap"] = ArtifactRef("assay", "embedding", "4" * 64, "RNA") + elif damage == "wrongKind": + refs["umap"] = refs["graph"] + elif damage == "nonNumeric": + arrays["umap"].dtype = np.dtype("U8") + elif damage == "graphSelection": + monkeypatch.setattr( + _plots, + "graph_cell_selection", + lambda *_: ArtifactRef("datastore", "cell_selection", "f" * 64), + ) + with pytest.raises((ValueError, TypeError)): + _plots.plot_final_umap(store, **refs, show=False) + assert not any(array.read_sizes for array in arrays.values()) + + +def test_many_clusters_are_annotated_and_plot_failure_closes_figure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + store, refs, arrays = display_store(monkeypatch, n=82) + arrays["clusters"] = np.repeat(np.arange(41), 2) + plot = _plots.plot_final_umap(store, **refs, show=False) + try: + assert len(plot.axes["clusters"].texts) == 41 + assert len(plot.scales[0].palette) == 41 + assert plot.tables["cluster_counts"]["cells"].sum() == 82 + finally: + plot.close() + before = set(plt.get_fignums()) + from matplotlib.axes import Axes + + def fail(*args: Any, **kwargs: Any) -> Any: + raise RuntimeError("rendering interrupted") + + monkeypatch.setattr(Axes, "scatter", fail) + with pytest.raises(RuntimeError, match="rendering interrupted"): + _plots.plot_final_umap(store, **refs, show=False) + assert set(plt.get_fignums()) == before + + +def test_display_cannot_hide_clusters_to_fit_an_impossible_limit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + store, refs, arrays = display_store(monkeypatch, n=12) + arrays["clusters"] = np.arange(12) + with pytest.raises(RuntimeError, match="more clusters"): + _plots.plot_final_umap(store, **refs, max_points=10, show=False) + assert not arrays["umap"].read_sizes + + +def test_nonfinite_umap_fails_without_leaking_a_figure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + store, refs, arrays = display_store(monkeypatch, n=12) + coordinates = np.zeros((12, 2)) + coordinates[3, 1] = np.nan + arrays["umap"] = coordinates + before = set(plt.get_fignums()) + with pytest.raises(ValueError, match="finite"): + _plots.plot_final_umap(store, **refs, show=False) + assert set(plt.get_fignums()) == before + + +def test_mid_sized_population_palette_and_default_display_use_existing_plot_result( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from scarf.plotting import PlotResult + + store, refs, arrays = display_store(monkeypatch, n=30) + arrays["clusters"] = np.repeat(np.arange(15), 2) + shown = [] + monkeypatch.setattr(PlotResult, "show", lambda self: shown.append(self)) + plot = _plots.plot_final_umap(store, **refs) + try: + assert shown == [plot] + assert len(plot.scales[0].palette) == 15 + assert len(plot.axes["clusters"].get_legend().get_texts()) == 15 + finally: + plot.close() + + +def test_cluster_count_summary_rejects_matrix_valued_labels( + monkeypatch: pytest.MonkeyPatch, +) -> None: + store, refs, arrays = display_store(monkeypatch, n=12) + arrays["clusters"] = np.ones((12, 2), dtype=int) + with pytest.raises(ValueError, match="one-dimensional"): + _plots.cluster_counts(store, refs["clusters"]) diff --git a/tests/test_agent_provider_contracts.py b/tests/test_agent_provider_contracts.py new file mode 100644 index 00000000..50c3e9d9 --- /dev/null +++ b/tests/test_agent_provider_contracts.py @@ -0,0 +1,84 @@ +"""Provider schemas expose choices while exact evidence remains programmatic.""" + +from typing import Any + +import pytest + +from scarf.agent.biological_interpretation.contracts import ( + BiologicalInterpretationReport, +) +from scarf.agent.data_enrichment.contracts import ( + DataEnrichmentContext, + DataEnrichmentReport, + FeatureSelectionPolicy, + StudyContextSummary, +) +from scarf.agent.data_enrichment.validation import _ground_study_context_summary +from scarf.agent.parameter_tuning.contracts import ( + FinalGraphSelection, + ParameterTuningReport, +) +from tests.agent_examples import example + + +@pytest.mark.parametrize( + ("model", "derived", "authored"), + [ + ( + DataEnrichmentReport, + {"inspections", "evidenceIds", "runInfo", "toolCalls"}, + {"policies", "status"}, + ), + ( + FeatureSelectionPolicy, + {"assayType", "graphEligible", "organismName", "exactTagFeatures"}, + {"assay", "species", "excludeFamilies", "rationale"}, + ), + ( + ParameterTuningReport, + {"evaluations", "selectedArtifacts", "runInfo", "finalSelection"}, + {"recommendedCandidateId", "rationale", "comparisons", "assayReports"}, + ), + ( + FinalGraphSelection, + {"graphMethod", "nativeAssay", "integrationId", "runInfo"}, + {"selectedOptionId", "rationale", "comparisons"}, + ), + ( + BiologicalInterpretationReport, + {"clusterArtifact", "markerArtifact", "runInfo"}, + {"clusterInterpretations", "treatmentObservations", "status"}, + ), + ], +) +def test_derived_fields_are_not_requested_from_models( + model: Any, derived: set[str], authored: set[str] +) -> None: + schema = model.model_json_schema() + if "$ref" in schema: + schema = schema["$defs"][schema["$ref"].rsplit("/", 1)[-1]] + properties = set(schema["properties"]) + assert not properties.intersection(derived) + assert authored <= properties + payload = example(model).model_dump(mode="json") + assert derived <= payload.keys() + assert model.model_validate(payload).model_dump(mode="json") == payload + + +def test_bounded_model_excerpts_preserve_long_caller_context_and_references() -> None: + references = [ + (f"Experiment {index}: " + "method " * 40).strip() for index in range(14) + ] + context = DataEnrichmentContext( + studyContext="Human lung study with repeated donors and objective-specific observations.", + studyObjective="Compare the complete design, including all supplied experiments.", + experimentalDetails=references, + ) + summary = _ground_study_context_summary( + context, + StudyContextSummary(analysisIntentReferences=["Compare the complete design"]), + ) + assert summary.studyContext == context.studyContext + assert summary.studyObjective == context.studyObjective + assert summary.experimentalReferences == references + assert _ground_study_context_summary(context, summary) == summary diff --git a/tests/test_agent_provider_edges.py b/tests/test_agent_provider_edges.py index 06329a1c..b22d32dd 100644 --- a/tests/test_agent_provider_edges.py +++ b/tests/test_agent_provider_edges.py @@ -167,8 +167,8 @@ def test_biological_interpretation_cache_and_fallback_branches() -> None: error=provider_error, model_name="test-model", ) - assert needs_markers.status == "needsInput" - assert needs_markers.needsInput is not None + assert needs_markers.status == "failed" + assert needs_markers.needsInput is None assert needs_markers.evidenceIds == ["composition:clusters"] diff --git a/tests/test_agent_qc_decision_evidence.py b/tests/test_agent_qc_decision_evidence.py index c3f015df..dc8a410b 100644 --- a/tests/test_agent_qc_decision_evidence.py +++ b/tests/test_agent_qc_decision_evidence.py @@ -1,6 +1,7 @@ """QC choices distinguish reference grouping, measured retention and biology.""" from types import SimpleNamespace +from copy import deepcopy import pytest @@ -86,6 +87,7 @@ def test_qc_grouping_compares_the_same_cutoff_method( def resolve(_store, _request, definition, bundle, _answers, **kwargs): seen["definition"] = definition seen["bundle"] = bundle + seen["qcEvidence"] = kwargs["qc_evidence"] return SimpleNamespace( compiled=SimpleNamespace( executorPayload=QcGroupingExecutorPayload(groupingMode="global") @@ -114,3 +116,88 @@ def resolve(_store, _request, definition, bundle, _answers, **kwargs): ) assert "need not be independent biological units or healthy references" in design assert "different cutoff methods cannot isolate" in design + assert len(seen["qcEvidence"]["policies"]) == len(profiles) + + +def test_qc_handoff_deduplicates_without_losing_any_policy_measurements() -> None: + policies = [profile("globalMad5", 90), profile("captureMad5", 95)] + for item in policies: + item.resolvedBounds = [ + {"group": "a", "metric": "RNA_percentMito", "upper": 7.125} + ] + item.activeCellsByCapture = {"a": 100} + item.parameters = { + "resolvedBounds": deepcopy(item.resolvedBounds), + "captureSizes": {"a": 100}, + "captureComparisons": [ + { + "capture": "a", + "mitoQuantiles": [1.0, 3.1, 7.125], + "missingFraction": 0.05, + } + ], + } + item.retainedCellsByCombination = { + 'joint:["sex","condition"]': { + "F/treated": 0, + "M/control": item.retainedCells, + } + } + item.unsafeRetentionGroups = ["The protected joint group F/treated is absent"] + item.notes = ["A supported reference pool is unavailable"] + item.captureFailureEvidence = [ + CaptureFailureEvidence( + capture="a", + activeCells=100, + retainedCells=item.retainedCells, + retainedFraction=item.retainedCells / 100, + conditionAndUnitSafety=[ + { + "column": "age", + "kind": "continuous", + "missingFraction": 0.2, + "status": "unsupported", + "quantilesBeforeExclusion": [20, 50, 80], + } + ], + ) + ] + originals = [item.model_dump(mode="json") for item in policies] + payload = PreprocessingStagesMixin._qc_decision_evidence(policies) + shared = payload["sharedMeasurements"] + assert ( + payload["policies"][0]["captureFailureEvidence"][0]["conditionAndUnitSafetyRef"] + == payload["policies"][1]["captureFailureEvidence"][0][ + "conditionAndUnitSafetyRef" + ] + ) + assert ( + payload["policies"][0]["parameters"]["captureComparisonsRef"] + == payload["policies"][1]["parameters"]["captureComparisonsRef"] + ) + for saved, compact in zip(originals, payload["policies"], strict=True): + restored = deepcopy(compact) + for name in ("metricSources", "sourceConcordance"): + restored[name] = shared[restored.pop(name + "Ref")] + parameters = restored["parameters"] + parameters["captureComparisons"] = shared[ + parameters.pop("captureComparisonsRef") + ] + parameters["resolvedBounds"] = deepcopy(restored["resolvedBounds"]) + parameters["captureSizes"] = deepcopy(restored["activeCellsByCapture"]) + for capture in restored["captureFailureEvidence"]: + capture["conditionAndUnitSafety"] = shared[ + capture.pop("conditionAndUnitSafetyRef") + ] + assert restored == saved + assert [item.model_dump(mode="json") for item in policies] == originals + + +def test_qc_handoff_preserves_disagreeing_duplicate_thresholds_for_investigation() -> ( + None +): + selected = profile("globalMad5", 80) + selected.resolvedBounds = {"RNA_percentMito": [0.0, 7.0]} + selected.parameters["resolvedBounds"] = {"RNA_percentMito": [0.0, 10.0]} + payload = PreprocessingStagesMixin._qc_decision_evidence([selected])["policies"][0] + assert payload["resolvedBounds"] != payload["parameters"]["resolvedBounds"] diff --git a/tests/test_agent_qc_policy_execution.py b/tests/test_agent_qc_policy_execution.py new file mode 100644 index 00000000..03d0f617 --- /dev/null +++ b/tests/test_agent_qc_policy_execution.py @@ -0,0 +1,209 @@ +"""Offered QC alternatives execute their exact frozen cohort and diagnostic flags.""" + +from copy import deepcopy + +import numpy as np +import pytest + +from scarf.agent.cell_quality.execution import execute_registered_cell_qc +from scarf.agent.cell_quality.profiles import project_registered_qc_profile +from scarf.storage.artifacts import artifact_group +from scarf.storage.selections import read_stored_selection_mask +from tests.test_registered_qc_profiles import ( + _memory_qc_store, + _profile_parameters, + _quality_values, +) + + +@pytest.mark.parametrize( + "policy", + [ + "retainWithFlags", + "globalMad5", + "captureMad5", + "captureMad3Sensitivity", + "pooledReferenceMad5", + ], +) +def test_qc_policy_projection_matches_execution_on_a_preselected_cohort( + policy: str, +) -> None: + values = _quality_values() + captures = np.asarray(["a"] * 21 + ["b"] * 21) + store, _ = _memory_qc_store({**values, "capture": captures}) + store.cells._get_array("I")[0] = False + source = store.snapshot_cell_selection("I") + active = store.cells.fetch_all("I") + grouped = policy in {"captureMad5", "captureMad3Sensitivity", "pooledReferenceMad5"} + references = ("a", "b") if policy == "pooledReferenceMad5" else () + projection = project_registered_qc_profile( + policy, + values_by_metric={name: value[active] for name, value in values.items()}, + active=np.ones(int(active.sum()), dtype=bool), + capture_labels=captures[active] if grouped else None, + grouping_proven=grouped, + pooled_reference_captures=references, + ) + parameters = _profile_parameters(projection) + parameters["pooledReferenceCaptures"] = list(references) + selected, flags = execute_registered_cell_qc( + store, + policy, + profile_parameters=parameters, + expected_active_cells=int(active.sum()), + expected_retained_cells=projection.retainedCells, + expected_flag_counts=projection.flagCounts, + attrs=list(values), + cell_selection=source, + sample_column="capture" if grouped else None, + ) + observed = read_stored_selection_mask( + store.zw, + selected, + kind="cell_selection", + scope="datastore", + assay=None, + table_path="cellData", + ) + expected = np.zeros(len(active), dtype=bool) + expected[active] = projection.keep + np.testing.assert_array_equal(observed, expected) + np.testing.assert_array_equal(store.cells.fetch_all("I"), active) + assert flags is not None + np.testing.assert_array_equal( + artifact_group(store.zw, flags)["values"][:], + np.column_stack([projection.flags[name] for name in sorted(projection.flags)]), + ) + if policy == "retainWithFlags": + np.testing.assert_array_equal(observed, active) + assert any(count > 0 for count in projection.flagCounts.values()) + for name, original in values.items(): + np.testing.assert_array_equal(store.cells.fetch_all(name), original) + + +@pytest.mark.parametrize("changed", ["captureSizes", "captureComparisons", "metric"]) +def test_capture_execution_rejects_stale_evidence_before_saving_a_selection( + changed: str, +) -> None: + values = _quality_values() + captures = np.asarray(["a"] * 21 + ["b"] * 21) + store, source = _memory_qc_store({**values, "capture": captures}) + projection = project_registered_qc_profile( + "captureMad5", + values_by_metric=values, + active=np.ones(42, dtype=bool), + capture_labels=captures, + grouping_proven=True, + ) + parameters = deepcopy(_profile_parameters(projection)) + if changed == "captureSizes": + parameters["captureSizes"]["a"] -= 1 + elif changed == "captureComparisons": + parameters["captureComparisons"][0]["retainedCells"] = -1 + else: + store.cells._get_array("RNA_nCounts")[0] = 0 + with pytest.raises(ValueError, match="do not match"): + execute_registered_cell_qc( + store, + "captureMad5", + profile_parameters=parameters, + expected_active_cells=42, + expected_retained_cells=projection.retainedCells, + expected_flag_counts=projection.flagCounts, + attrs=list(values), + cell_selection=source, + sample_column="capture", + ) + np.testing.assert_array_equal(store.cells.fetch_all("I"), np.ones(42, dtype=bool)) + + +@pytest.mark.parametrize( + "invalid", ["empty", "matrixSelection", "matrixMetric", "unaligned", "nonfinite"] +) +def test_qc_projection_rejects_unmeasurable_or_misaligned_selected_cells( + invalid: str, +) -> None: + active = np.ones(4, dtype=bool) + counts = np.asarray([10.0, 11, 12, 13]) + if invalid == "empty": + active[:] = False + elif invalid == "matrixSelection": + active = active.reshape(2, 2) + elif invalid == "matrixMetric": + counts = counts.reshape(2, 2) + elif invalid == "unaligned": + counts = counts[:-1] + else: + counts[0] = np.nan + with pytest.raises(ValueError): + project_registered_qc_profile( + "globalMad5", values_by_metric={"RNA_nCounts": counts}, active=active + ) + + +def test_qc_ignores_unselected_missing_values_and_diagnostic_only_covariates() -> None: + active = np.ones(40, dtype=bool) + active[0] = False + counts = np.arange(40, dtype=float) + 100 + counts[0] = np.nan + reference = project_registered_qc_profile( + "globalMad5", values_by_metric={"RNA_nCounts": counts}, active=active + ) + measured = project_registered_qc_profile( + "globalMad5", + values_by_metric={"RNA_nCounts": counts, "age": np.arange(40) ** 4}, + active=active, + ) + np.testing.assert_array_equal(measured.keep, reference.keep) + assert measured.thresholds == reference.thresholds + assert not measured.keep[0] + + +@pytest.mark.parametrize("references", [("a", "a"), ("a", "missing"), ("a", "b")]) +def test_invalid_reference_pool_is_unavailable_without_changing_global_evidence( + references: tuple[str, ...], +) -> None: + from scarf.agent.cell_quality.profiles import offered_registered_qc_profiles + + active = np.ones(30, dtype=bool) + captures = np.asarray(["a"] * 5 + ["b"] * 5 + ["c"] * 20) + values = {"RNA_nCounts": np.arange(30, dtype=float) + 100} + with pytest.raises( + ValueError, match="reference captures|reference captures do not" + ): + project_registered_qc_profile( + "pooledReferenceMad5", + values_by_metric=values, + active=active, + capture_labels=captures, + grouping_proven=True, + pooled_reference_captures=references, + ) + offered = offered_registered_qc_profiles( + values_by_metric=values, + active=active, + capture_labels=captures, + grouping_proven=True, + pooled_reference_captures=references, + ) + assert {profile.profile for profile in offered} == {"retainWithFlags", "globalMad5"} + global_profile = next( + profile for profile in offered if profile.profile == "globalMad5" + ) + reference = project_registered_qc_profile( + "globalMad5", values_by_metric=values, active=active + ) + np.testing.assert_array_equal(global_profile.keep, reference.keep) + + +def test_capture_provenance_cannot_merge_distinct_typed_labels() -> None: + captures = np.asarray([1] * 20 + ["1"] * 20, dtype=object) + with pytest.raises(ValueError, match="consistent label type"): + project_registered_qc_profile( + "captureMad5", + values_by_metric={"RNA_nCounts": np.arange(40, dtype=float) + 100}, + active=np.ones(40, dtype=bool), + capture_labels=captures, + grouping_proven=True, + ) diff --git a/tests/test_agent_qc_source_boundaries.py b/tests/test_agent_qc_source_boundaries.py new file mode 100644 index 00000000..7b412033 --- /dev/null +++ b/tests/test_agent_qc_source_boundaries.py @@ -0,0 +1,177 @@ +"""QC policies cannot acquire invented metric, capture, or replication support.""" + +from types import SimpleNamespace +from typing import Any + +import numpy as np +import pytest + +from scarf.agent.experimental_context import qc_evidence as q +from scarf.agent.experimental_context.contracts import ( + CaptureProposal, + NamedArtifactSource, +) +from scarf.agent.tools import artifact_reference +from scarf.storage.refs import ArtifactRef +from tests.test_agent_context_computation_reuse import _capture_context + + +@pytest.mark.parametrize( + "directions", + [ + {"physicalCaptureColumn": 0}, + { + "physicalCaptureColumn": "capture", + "cellQc": {"physicalCaptureColumn": "donor"}, + }, + {"physicalCaptureColumn": "absent"}, + {"physicalCaptureColumn": "capture", "pooledReferenceCaptures": ["d1", "d1"]}, + {"physicalCaptureColumn": "capture", "pooledReferenceCaptures": [1, 2]}, + {"pooledReferenceCaptures": ["d1", "d2"]}, + ], +) +def test_unproven_or_conflicting_capture_scope_cannot_create_qc_policies( + directions: dict[str, Any], +) -> None: + deps, characterized = _capture_context() + deps.directions = directions + with pytest.raises(ValueError): + q._offered_qc_profiles(deps, characterized) + assert deps.qcDesignData is None + + +def test_reference_proposal_cannot_replace_explicit_caller_references() -> None: + deps, _ = _capture_context() + deps.directions["pooledReferenceCaptures"] = ["d1", "d2"] + deps.captureProposal = CaptureProposal( + column="capture", + provenanceQuote="capture is a physical capture", + referenceCaptures=["d2", "d3"], + referenceProvenanceQuote="d2 and d3 are reference captures", + ) + with pytest.raises(ValueError, match="conflicts"): + q._directed_pooled_reference_captures(deps) + + +def test_nonnumeric_counts_remain_missing_for_every_capture() -> None: + deps, characterized = _capture_context() + deps.store.cells._values["RNA_nCounts"] = np.asarray(["unavailable"] * 24) + profiles = q._offered_qc_profiles(deps, characterized) + assert profiles + for profile in profiles: + source = next( + item for item in profile.metricSources if item.metricName == "RNA_nCounts" + ) + assert not source.usableForFiltering + assert source.missingCellsByCapture == {"d1": 8, "d2": 8, "d3": 8} + assert "RNA_nCounts" not in profile.attributes + if isinstance(profile.resolvedBounds, dict): + assert "RNA_nCounts" not in profile.resolvedBounds + else: + assert all( + bound.get("metric") != "RNA_nCounts" for bound in profile.resolvedBounds + ) + + +@pytest.mark.parametrize( + "damage", ["metadataShape", "artifactShape", "duplicateMetric"] +) +def test_competing_qc_sources_require_exact_axes_and_unique_execution_names( + monkeypatch: pytest.MonkeyPatch, damage: str +) -> None: + deps, _ = _capture_context() + if damage == "metadataShape": + monkeypatch.setattr(q, "_active_cell_count", lambda _: 23) + else: + source = NamedArtifactSource( + name="externalCounts", + artifact=artifact_reference( + ArtifactRef("assay", "quality_metric", "a" * 64, "RNA") + ), + ) + deps.qualityMetricArtifacts = ( + [source] if damage == "artifactShape" else [source, source] + ) + monkeypatch.setattr( + q, + "_resolved_artifact_values", + lambda *_args, **_kwargs: np.ones(23 if damage == "artifactShape" else 24), + ) + monkeypatch.setattr( + q, + "inspect_artifact", + lambda *_: SimpleNamespace(operation="externalQuality", inputs={}), + ) + with pytest.raises(ValueError, match="align|not unique"): + q._qc_metric_sources(deps, ("RNA", "RNA")) + + +def test_qc_artifact_provenance_keeps_valid_refs_without_promoting_malformed_records() -> ( + None +): + valid = ArtifactRef("assay", "feature_selection", "a" * 64, "RNA") + malformed = { + "scope": "assay", + "kind": "feature_selection", + "artifact_id": "missing-fields", + } + refs = q._artifact_input_references( + {"untrusted": malformed, "valid": valid.to_dict(), "duplicate": [valid]} + ) + assert refs == [artifact_reference(valid)] + + +@pytest.mark.parametrize("column", ["condition", "donor"]) +def test_capture_safety_rejects_misaligned_condition_or_independent_unit_values( + column: str, +) -> None: + deps, characterized = _capture_context() + deps.qcDesignData = q._QcDesignData(deps.cells) + deps.qcDesignData.values[column] = np.asarray(["value"] * 23) + with pytest.raises(ValueError, match="align"): + q._capture_design_safety(deps, characterized, deps.cells.fetch("capture"), "d1") + with pytest.raises(ValueError, match="align"): + q._design_retention( + deps, characterized, np.ones(24, dtype=bool), np.ones(24, dtype=bool) + ) + + +def test_capture_exclusion_preserves_margins_but_cannot_claim_complete_repeated_donor_pairs() -> ( + None +): + deps, characterized = _capture_context() + deps.store.cells._values["condition"][16:] = "case" + rows, conditions, independent = q._capture_design_safety( + deps, characterized, deps.cells.fetch("capture"), "d1" + ) + assert conditions and not independent + assert rows[0]["completePairsAfterExclusion"] == 1 + assert rows[0]["incompletePairsAfterExclusion"] == 1 + characterized.coefficients[0]["scope"] = "withinCell" + rows, conditions, independent = q._capture_design_safety( + deps, characterized, deps.cells.fetch("capture"), "d1" + ) + assert rows == [] and not conditions and not independent + + +def test_core_default_failure_is_reported_without_claiming_that_policy_executed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + deps, characterized = _capture_context() + original = q.project_auto_filter_profile + + def reject(action: str, **kwargs: Any) -> Any: + if action == "globalGaussian": + raise ValueError("core projection unavailable") + return original(action, **kwargs) + + monkeypatch.setattr(q, "project_auto_filter_profile", reject) + profiles = q._offered_qc_profiles(deps, characterized) + assert profiles and not any( + profile.action == "globalGaussian" for profile in profiles + ) + assert any( + "core projection unavailable" in note + for profile in profiles + for note in profile.notes + ) diff --git a/tests/test_agent_report.py b/tests/test_agent_report.py index 82c19bf0..9db48915 100644 --- a/tests/test_agent_report.py +++ b/tests/test_agent_report.py @@ -368,8 +368,9 @@ def test_screening_that_uses_all_cells_is_not_labeled_as_a_sample() -> None: @pytest.mark.parametrize("mode", ["visual", "structured"]) @pytest.mark.parametrize("damage", [None, "digest", "scope", "action", "genes", "mode"]) +@pytest.mark.parametrize("revised", [False, True]) def test_review_view_requires_exact_checkpoint_bindings( - monkeypatch: pytest.MonkeyPatch, damage: str | None, mode: str + monkeypatch: pytest.MonkeyPatch, damage: str | None, mode: str, revised: bool ) -> None: import hashlib @@ -419,7 +420,11 @@ def test_review_view_requires_exact_checkpoint_bindings( entry = { "scope": "full", "review": action, - "checkpointKey": "parameter_tuning/full/review0", + "checkpointKey": ( + f"parameter_tuning/evidence_revisions/{'a' * 64}/full/review0" + if revised + else "parameter_tuning/full/review0" + ), "checkpointSha256": digest, "imageHashes": payload["inputs"]["imageHashes"], "evidenceMode": mode, diff --git a/tests/test_agent_required_comparisons.py b/tests/test_agent_required_comparisons.py index 45d35b7c..a836c8ae 100644 --- a/tests/test_agent_required_comparisons.py +++ b/tests/test_agent_required_comparisons.py @@ -203,6 +203,11 @@ def save( return deepcopy(outputs) monkeypatch.setattr(journal, "load_checkpoint", load) + monkeypatch.setattr( + journal, + "read_checkpoint", + lambda store, prefix, workflow, key: deepcopy(saved.get(key)), + ) monkeypatch.setattr(journal, "save_checkpoint", save) handoff = example(PreprocessedAssayHandoff) handoff.nCells = 100 diff --git a/tests/test_agent_rna_adaptive.py b/tests/test_agent_rna_adaptive.py index 8f695e63..ff457d64 100644 --- a/tests/test_agent_rna_adaptive.py +++ b/tests/test_agent_rna_adaptive.py @@ -176,6 +176,7 @@ def test_summary_counts_unique_diagnostic_evidence_and_labels_reuse( } run.history = [] run.full_repairs = 0 + run.diagnostic_counts = {} messages = [] monkeypatch.setattr(rna_tuning.logger, "info", messages.append) summary = run.summary() @@ -316,7 +317,8 @@ def propose_native(**kwargs: Any) -> Any: else "required correction unresolved", ): run.review("sample0", 0, evaluation, {}) - assert not any("review0" in key for key in checkpoints) + assert "parameter_tuning/sample0/review0" not in checkpoints + assert "parameter_tuning/sample0/review0/evidence/visual" in checkpoints @pytest.mark.slow @@ -409,7 +411,16 @@ def no_recomputation(*args: Any, **kwargs: Any) -> Any: monkeypatch.setattr(rna_tuning, "partition_comparison_evidence", no_recomputation) resumed, resumed_history = runner().run() assert resumed == first - assert resumed_history == history + assert { + key: value + for key, value in resumed_history.items() + if key != "diagnosticOperations" + } == {key: value for key, value in history.items() if key != "diagnosticOperations"} + measured = history["diagnosticOperations"]["operations"] + assert measured["core.primaryNormalization"]["completed"] > 0 + restored = resumed_history["diagnosticOperations"]["operations"] + assert restored["diagnostic.primaryCandidateEvidence"]["restored"] > 0 + assert all(row["attempted"] == 0 for row in restored.values()) assert len(model_calls) == expected_calls @@ -533,7 +544,7 @@ def test_feature_experiments_preserve_aliases_and_exact_protected_genes( ) -def test_inadequate_screens_fall_back_to_full_baseline_once( +def test_inadequate_screens_preserve_evidence_without_unaffordable_full_panel( checkpoints: dict[str, Any], monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -571,7 +582,6 @@ def assess(scope: str, cells: Any, initial: Any) -> Any: monkeypatch.setattr(run, "assess_scope", assess) report, summary = run.run() assert report.status == "needsInput" - assert sampled == [50_000, 100_000] - assert [scope for scope, _, _ in assessed] == ["sample0", "sample1", "full"] - assert assessed[-1] == ("full", run.cells, None) + assert sampled == [20_000, 100_000] + assert [scope for scope, _, _ in assessed] == ["sample0", "sample1"] assert summary["budget"]["scopes"]["full"]["reserved"]["partitions"] == 0 diff --git a/tests/test_agent_rna_assessment_integrity.py b/tests/test_agent_rna_assessment_integrity.py index 31214799..1273b0b9 100644 --- a/tests/test_agent_rna_assessment_integrity.py +++ b/tests/test_agent_rna_assessment_integrity.py @@ -368,3 +368,67 @@ def request_harmony(**kwargs: Any) -> Any: run.review("full", 0, selected, {}) saved = request.getfixturevalue("memory_checkpoints") assert "parameter_tuning/full/review0" not in saved + + +@pytest.mark.parametrize( + "comparison", + ["missing", "failed", "cells", "features", "parameters", "batch", "matched"], +) +def test_safe_not_needed_requires_an_exact_completed_harmony_comparison( + monkeypatch: pytest.MonkeyPatch, + request: pytest.FixtureRequest, + comparison: str, +) -> None: + run, selected = _run(monkeypatch) + run.study.correctionLicense = "safe" + run.study.technicalBatchColumns = ["batch"] + run.batch_columns = ["batch"] + selected.metrics.batchMixing = {"batch": 0.8} + if comparison != "missing": + corrected = selected.model_copy(deep=True) + corrected.parameters.candidateId = "matched_harmony" + corrected.candidateId = "matched_harmony" + corrected.parameters.useHarmony = True + corrected.harmonyBatchColumns = ["batch"] + corrected.metrics.batchMixing = {"batch": 0.7} + setting = run.settings[selected.candidateId].model_copy(deep=True) + setting.parameters = corrected.parameters + if comparison == "failed": + corrected.status = "failed" + elif comparison == "cells": + corrected.cellSelection.artifactId = "a" * 64 + elif comparison == "features": + setting.features.artifactId = "b" * 64 + elif comparison == "parameters": + corrected.parameters.neighborsK += 1 + elif comparison == "batch": + corrected.harmonyBatchColumns = ["another_batch"] + run.evaluations["full"].append(corrected) + run.settings[corrected.candidateId] = setting + + def prefer_native(**kwargs: Any) -> Any: + action = assess(**{**kwargs, "output_validator": lambda value: value}).output + action.correctionNeed = "notNeeded" + action.quantitativeFindings = [ + "Native batch mixing is 0.8; corrected mixing is 0.7." + ] + action.rationale = ( + "The observed Harmony comparison worsens batch mixing; retain native." + ) + return SimpleNamespace(output=kwargs["output_validator"](action)) + + monkeypatch.setattr(rna_tuning, "run_agent_sync", prefer_native) + if comparison == "matched": + passed, _ = run.harmony_gate("full", corrected) + assert not passed + action = run.review("full", 0, selected, {}) + assert action.action == "accept" + assert action.selectedCandidateId == selected.candidateId + assert action.correctionNeed == "notNeeded" + else: + with pytest.raises( + ValueError, match="requires a completed, matched Harmony experiment" + ): + run.review("full", 0, selected, {}) + saved = request.getfixturevalue("memory_checkpoints") + assert "parameter_tuning/full/review0" not in saved diff --git a/tests/test_agent_rna_evidence_mode.py b/tests/test_agent_rna_evidence_mode.py index aca2f2b8..878aa0c9 100644 --- a/tests/test_agent_rna_evidence_mode.py +++ b/tests/test_agent_rna_evidence_mode.py @@ -333,7 +333,11 @@ def interrupt(**kwargs: Any) -> Any: monkeypatch.setattr(rna_tuning, "run_agent_sync", interrupt) with pytest.raises(RuntimeError, match="Interrupted during structured"): run.review("full", 0, selected, {}) - assert set(saved) == {"parameter_tuning/structured_evidence"} + assert set(saved) == { + "parameter_tuning/structured_evidence", + "parameter_tuning/full/review0/evidence/visual", + "parameter_tuning/full/review0/evidence/structured", + } resumed, selected = make_run(monkeypatch, object()) monkeypatch.setattr( tuning, @@ -365,7 +369,7 @@ def fail(**kwargs: Any) -> Any: with pytest.raises(type(error)) as caught: run.review("full", 0, selected, {}) assert caught.value is error - assert saved == {} + assert set(saved) == {"parameter_tuning/full/review0/evidence/visual"} def test_committed_visual_review_replays_without_images_or_neighbor_diagnostics( @@ -747,7 +751,15 @@ def defer(**kwargs: Any) -> Any: assert resumed_report == first_report assert resumed_report.status == "needsInput" assert resumed_summary["budget"] == first_summary["budget"] - assert json.dumps(saved, sort_keys=True) == before + original = json.loads(before) + assert {key: saved[key] for key in original} == original + appended = {key: value for key, value in saved.items() if key not in original} + assert len(appended) == 2 + assert all("/diagnostic_attempts/" in key for key in appended) + counts = resumed_summary["diagnosticOperations"]["operations"] + assert all(row["attempted"] == 0 for row in counts.values()) + assert counts["diagnostic.primaryCandidateEvidence"]["restored"] == len(settings) + assert counts["diagnostic.reviewEvidence"]["restored"] == 1 def test_model_failure_is_not_reported_as_a_scientific_question( diff --git a/tests/test_agent_runtime_boundaries.py b/tests/test_agent_runtime_boundaries.py new file mode 100644 index 00000000..2bb2a190 --- /dev/null +++ b/tests/test_agent_runtime_boundaries.py @@ -0,0 +1,146 @@ +"""Provider-independent checks on bounded images, repair feedback, and failures.""" + +from typing import Any + +import pytest +from pydantic_ai import ModelRetry, UnexpectedModelBehavior +from pydantic_ai.exceptions import ModelHTTPError +from pydantic_ai.messages import BinaryContent, ModelResponse, ToolCallPart +from pydantic_ai.models.function import FunctionModel +from pydantic_ai.usage import RequestUsage + +from scarf.agent.config import agent_exec +from scarf.agent.decisions.selection import decide +from scarf.agent.types import EvidenceItem +from tests.test_agent_attempt_audit import CountDecision + + +@pytest.mark.parametrize( + ("prompt", "images", "reason"), + [ + ("", [("a", b"a", "image/png")], "non-empty string"), + ("Review", [], "at least one"), + ("Review", [(str(i), b"a", "image/png") for i in range(9)], "maximum"), + ("Review", [(" a", b"a", "image/png")], "trimmed"), + ("Review", [("a", b"a", "image/png")] * 2, "unique"), + ("Review", [("a", b"a", "image/gif")], "unsupported media"), + ("Review", [("a", b"", "image/png")], "non-empty bytes"), + ("Review", [("a", b"abcde", "image/png")], "exceeds 4 bytes"), + ("Review", [(str(i), b"abcd", "image/png") for i in range(3)], "total byte"), + ], +) +def test_visual_prompt_rejects_unbounded_or_ambiguous_evidence( + monkeypatch: Any, prompt: str, images: list[tuple[str, bytes, Any]], reason: str +) -> None: + monkeypatch.setattr(agent_exec, "_MAX_VISUAL_EVIDENCE_ITEM_BYTES", 4) + monkeypatch.setattr(agent_exec, "_MAX_VISUAL_EVIDENCE_TOTAL_BYTES", 8) + with pytest.raises(ValueError, match=reason): + agent_exec.build_visual_evidence_prompt( + prompt, [agent_exec.ImageEvidence(*value) for value in images] + ) + + +def test_visual_prompt_preserves_exact_supplied_image_bytes() -> None: + image = agent_exec.ImageEvidence("observed-loadings", b"observed") + prompt = agent_exec.build_visual_evidence_prompt("Assess these loadings.", [image]) + assert prompt[0] == "Assess these loadings." + assert isinstance(prompt[1], BinaryContent) + assert prompt[1].data == image.data + assert prompt[1].identifier == image.identifier + + +@pytest.mark.parametrize("unsupported", [True, False]) +def test_image_rejection_keeps_failed_usage_and_other_provider_failures_are_not_hidden( + unsupported: bool, +) -> None: + original = ModelHTTPError( + 400 if unsupported else 401, + "test-model", + {"message": "Image input is not supported" if unsupported else "Unauthorized"}, + ) + + async def respond(_messages: Any, _info: Any) -> ModelResponse: + raise original + + attempts = [] + with pytest.raises( + agent_exec.ImageInputUnsupportedError if unsupported else ModelHTTPError + ) as caught: + agent_exec.run_agent_sync( + model=FunctionModel(respond), + output_type=CountDecision, + system_prompt="Assess only measured evidence.", + user_prompt=agent_exec.build_visual_evidence_prompt( + "Review the loading plot.", + [agent_exec.ImageEvidence("loadings", b"image")], + ), + on_attempt=attempts.append, + ) + assert attempts == [caught.value.agent_run_info] + assert attempts[0].usage.availability == "unavailable" + assert attempts[0].status == "failed" + assert "ModelHTTPError" in attempts[0].error + if unsupported: + assert caught.value.__cause__ is original + else: + assert caught.value is original + + +def test_explicit_async_model_retry_is_audited_once_before_repair() -> None: + calls = 0 + + async def respond(_messages: Any, info: Any) -> ModelResponse: + nonlocal calls + calls += 1 + return ModelResponse( + parts=[ToolCallPart(info.output_tools[0].name, {"value": calls})], + usage=RequestUsage(input_tokens=3, output_tokens=1), + ) + + async def validate(output: CountDecision) -> CountDecision: + if output.value != 2: + raise ModelRetry("The completed comparison requires count 2") + return output + + result = agent_exec.run_agent_sync( + model=FunctionModel(respond), + output_type=CountDecision, + system_prompt="Use measured evidence.", + user_prompt="Interpret the count.", + output_validator=validate, + ) + assert result.output.value == 2 + assert len(result.runInfo.validationRetries) == 1 + assert result.runInfo.validationRetries[0].response == {"value": 1} + assert result.runInfo.usage.requests == 2 + + +def test_generic_decision_does_not_reclassify_unrelated_provider_failure() -> None: + original = UnexpectedModelBehavior("Provider returned an empty response") + + async def respond(_messages: Any, _info: Any) -> ModelResponse: + raise original + + with pytest.raises(UnexpectedModelBehavior) as caught: + decide( + model=FunctionModel(respond), + question="Select measured evidence.", + evidence=[ + EvidenceItem(id="measured", label="Measured", summary="Observed") + ], + ) + assert caught.value is original + assert caught.value.agent_run_info.status == "failed" + + +def test_error_detail_handles_implicit_context_cycles_and_bounded_notes() -> None: + cause = ValueError("Exact failed measurement") + error = RuntimeError("Could not finish interpretation") + error.__context__ = cause + cause.__context__ = error + error.add_note("The failed evidence remains saved") + detail = agent_exec.describe_agent_error(error) + assert detail.count("Could not finish") == 1 + assert "Exact failed measurement" in detail + assert "evidence remains saved" in detail + assert len(agent_exec.describe_agent_error(error, limit=20)) == 20 diff --git a/tests/test_agent_sampling_recovery.py b/tests/test_agent_sampling_recovery.py new file mode 100644 index 00000000..5c33376c --- /dev/null +++ b/tests/test_agent_sampling_recovery.py @@ -0,0 +1,466 @@ +"""Bounded sampling and recovery preserve exact scientific work.""" + +from copy import deepcopy +from types import SimpleNamespace +from typing import Any + +import pytest + +from scarf.agent.config import AgentRunConfig +from scarf.agent.config.agent_exec import run_agent_sync +from scarf.agent.orchestrator import rna_tuning +from scarf.agent.orchestrator.main import AgentOrchestrator +from scarf.agent.orchestrator.models import AutomatedWorkflowConfig +from scarf.agent.parameter_tuning.comparisons import ( + bind_comparison_measurements, + comparison_advantages, + validate_comparison_review, +) +from tests.agent_comparison_examples import comparison_review +from tests.test_agent_rna_adaptive import checkpoints as memory_checkpoints # noqa: F401 +from tests.test_agent_rna_evidence_mode import assess, make_run + + +@pytest.mark.parametrize( + ("population", "expected"), + [ + (1, (1,)), + (8000, (8000,)), + (10_000, (10_000,)), + (62_721, (10_000, 62_721)), + (100_001, (10_001, 100_000)), + (621_200, (62_120, 100_000)), + (1_000_000, (100_000,)), + (10_000_000, (100_000,)), + ], +) +def test_automatic_sampling_bounds(population: int, expected: tuple[int, ...]) -> None: + assert rna_tuning.screening_sizes(population, AutomatedWorkflowConfig()) == expected + + +def test_fixed_saved_policy_and_explicit_resume_overrides() -> None: + original = AutomatedWorkflowConfig(screeningCells=50_000) + wire = original.model_dump_json() + restored = AutomatedWorkflowConfig.model_validate_json(wire) + assert restored.model_dump_json() == wire + assert rna_tuning.screening_sizes(62_721, restored) == (50_000, 62_721) + assert rna_tuning.screening_sizes(621_200, restored) == (50_000, 100_000) + AgentOrchestrator(object())._validate_resume_config(restored) + with pytest.raises(ValueError, match="execution settings differ"): + AgentOrchestrator( + object(), config=AutomatedWorkflowConfig(screeningCells=10_000) + )._validate_resume_config(restored) + with pytest.raises(ValueError, match="non-empty"): + rna_tuning.screening_sizes(0, restored) + + +def test_inventory_exposes_small_advantages_and_exact_values() -> None: + review = comparison_review() + coverage = review["comparisonCoverage"] + baseline = coverage["candidateSettings"]["baseline"]["metrics"] + baseline["macroF1"] = 0.8 + alternative = coverage["candidateSettings"]["genes-two"]["metrics"] + alternative["seedStability"] = baseline["seedStability"] + 1e-10 + alternative["macroF1"] = 0.999 + rows = [ + row + for row in comparison_advantages(coverage) + if row["preferredCandidateId"] == "baseline" + and row["alternativeCandidateId"] == "genes-two" + ] + assert {row["metric"] for row in rows} == {"seedStability", "macroF1"} + assert ( + next(row for row in rows if row["metric"] == "seedStability")["difference"] > 0 + ) + with pytest.raises(ValueError) as failure: + validate_comparison_review(coverage, review) + for required in ("hvgCount", "baseline", "genes-two", "seedStability", "macroF1"): + assert required in str(failure.value) + + +def test_model_repairs_multiple_missing_tradeoffs_without_transcribing_values() -> None: + from pydantic_ai.messages import ModelResponse, RetryPromptPart, ToolCallPart + from pydantic_ai.models.function import FunctionModel + + review = comparison_review() + coverage = review["comparisonCoverage"] + coverage["candidateSettings"]["baseline"]["metrics"]["macroF1"] = 0.8 + coverage["candidateSettings"]["genes-two"]["metrics"].update( + seedStability=0.99, macroF1=0.999 + ) + action = { + key: value + for key, value in review.items() + if key in rna_tuning.TuningAction.model_fields + } + output_type = rna_tuning._assessment_output_type( + list(coverage["candidateSettings"]), [], scope="full" + ) + schema = output_type.model_json_schema() + tradeoff_schema = schema["$defs"]["ObservedTradeoffInterpretation"]["properties"] + assert "preferredValue" not in tradeoff_schema + assert "alternativeValue" not in tradeoff_schema + calls = [] + + def provider(messages: Any, info: Any) -> Any: + proposed = deepcopy(action) + calls.append(messages) + if len(calls) == 2: + feedback = [ + part.content + for message in messages + for part in message.parts + if isinstance(part, RetryPromptPart) + ] + assert "genes-two" in str(feedback) and "macroF1" in str(feedback) + conclusion = next( + row + for row in proposed["comparisonConclusions"] + if row["axis"] == "hvgCount" + ) + conclusion["tradeoffs"] = [ + { + "alternativeCandidateId": "genes-two", + "metric": metric, + "interpretation": "This observed advantage must be weighed against the retained population marker programs.", + } + for metric in ("seedStability", "macroF1") + ] + return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, proposed)]) + + def validate(proposed: Any) -> Any: + result = rna_tuning.TuningAction.model_validate( + bind_comparison_measurements(coverage, proposed.model_dump(mode="json")) + ) + validate_comparison_review(coverage, result.model_dump(mode="json")) + return result + + result = run_agent_sync( + model=FunctionModel(provider), + output_type=output_type, + system_prompt="Explain the supplied comparisons.", + user_prompt="Saved evidence", + config=AgentRunConfig(retries=2), + output_validator=validate, + ) + assert len(calls) == 2 + rows = next( + row.tradeoffs + for row in result.output.comparisonConclusions + if row.axis == "hvgCount" + ) + assert {row.metric: row.alternativeValue for row in rows} == { + "seedStability": 0.99, + "macroF1": 0.999, + } + + +@pytest.mark.usefixtures("memory_checkpoints") +@pytest.mark.parametrize("visual", [False, True]) +def test_uncommitted_review_reuses_prepared_evidence( + monkeypatch: pytest.MonkeyPatch, visual: bool +) -> None: + from scarf.agent.orchestrator import tuning + + model = object() if visual else SimpleNamespace(supports_image_input=False) + run, selected = make_run(monkeypatch, model) + calls = [] + + def fail(**kwargs: Any) -> Any: + calls.append(kwargs["user_prompt"]) + raise RuntimeError("Interrupted during provider call") + + monkeypatch.setattr(rna_tuning, "run_agent_sync", fail) + with pytest.raises(RuntimeError, match="Interrupted"): + run.review("full", 0, selected, {}) + resumed, selected = make_run(monkeypatch, model) + + def forbidden(*args: Any, **kwargs: Any) -> Any: + pytest.fail( + "Committed evidence must precede diagnostic, feature and image work" + ) + + monkeypatch.setattr(resumed, "feature_evidence", forbidden) + monkeypatch.setattr(tuning, "_analysis_visual_content", forbidden) + monkeypatch.setattr(rna_tuning, "population_support_evidence", forbidden) + monkeypatch.setattr(rna_tuning, "partition_comparison_evidence", forbidden) + monkeypatch.setattr(rna_tuning, "_neighbor_overlap", forbidden) + + def recovered(**kwargs: Any) -> Any: + if visual: + assert kwargs["user_prompt"][0] == calls[0][0] + else: + assert kwargs["user_prompt"] == calls[0] + return assess(**kwargs) + + monkeypatch.setattr(rna_tuning, "run_agent_sync", recovered) + assert resumed.review("full", 0, selected, {}).action == "accept" + + +def test_binding_rejects_invented_values_and_unknown_advantages() -> None: + review = comparison_review() + coverage = review["comparisonCoverage"] + coverage["candidateSettings"]["genes-two"]["metrics"]["seedStability"] = 0.99 + conclusion = next( + row for row in review["comparisonConclusions"] if row["axis"] == "hvgCount" + ) + conclusion["tradeoffs"] = [ + { + "alternativeCandidateId": "genes-two", + "metric": "seedStability", + "interpretation": "Observed tradeoff", + "alternativeValue": 1.0, + } + ] + with pytest.raises(ValueError, match="exact preferred"): + bind_comparison_measurements(coverage, review) + conclusion["tradeoffs"][0]["alternativeCandidateId"] = "invented" + with pytest.raises(ValueError, match="observed advantage"): + bind_comparison_measurements(coverage, review) + + +@pytest.mark.usefixtures("memory_checkpoints") +@pytest.mark.parametrize("joint_revision", [False, True]) +def test_context_revision_preserves_primary_artifacts_and_admissions( + monkeypatch: pytest.MonkeyPatch, joint_revision: bool +) -> None: + from scarf.agent.orchestrator.budget import CandidateBudget, candidate_identity + + run, old_evaluation = make_run(monkeypatch, object()) + original = {"studyContract": run.study.model_dump(mode="json")} + run.provenance = original + run.budget = CandidateBudget( + run.store, run.prefix, run.workflow.workflowRunId, run.request.config, original + ) + setting = run.baseline() + admission = run.budget.admit("sample0", run.execution_inputs(run.cells, setting)) + identifier = ( + "rna_" + candidate_identity(run.execution_inputs(run.cells, setting))[:24] + ) + old_evaluation.candidateId = identifier + old_evaluation.parameters = setting.parameters.model_copy( + update={"candidateId": identifier} + ) + old_evaluation.artifacts["normalized"] = run.handoff.normalized + run.budget.complete( + admission, {"evaluation": old_evaluation.model_dump(mode="json")} + ) + revised = run.study.model_copy( + update={"protectedCombinations": [["sex", "condition"]]} + if joint_revision + else { + "studyContext": run.study.studyContext + + " Additional design evidence is now measured." + } + ) + provenance = {"studyContract": revised.model_dump(mode="json")} + resumed = rna_tuning.RnaTuningRun( + run.owner, + run.store, + run.workflow, + run.request, + run.plan, + run.handoff, + revised, + {}, + provenance, + previous_provenances=[original], + ) + counter = [] + + def forbidden(*args: Any, **kwargs: Any) -> Any: + pytest.fail( + "A context revision must not recompute the primary graph, stability or doublets" + ) + + def refresh(deps: Any, evaluation: Any) -> Any: + counter.append(deps.protectedCombinations) + updated = evaluation.model_copy(deep=True) + updated.metrics.biologicalPreservation['joint:["sex","condition"]'] = { + "clisi": 0.8 + } + return updated + + monkeypatch.setattr( + rna_tuning, + "prepare_parameter_tuning_dependencies", + lambda *a, **kw: (SimpleNamespace(), []), + ) + monkeypatch.setattr(rna_tuning, "refresh_candidate_design_evidence", refresh) + monkeypatch.setattr(rna_tuning, "execute_parameter_candidate", forbidden) + monkeypatch.setattr(rna_tuning, "score_advisory_doublets", forbidden) + monkeypatch.setattr(rna_tuning, "augment_pca_evaluations", forbidden) + monkeypatch.setattr(rna_tuning, "augment_cluster_evaluations", forbidden) + result = resumed.execute("sample0", resumed.cells, setting) + assert ( + result.model_dump(mode="json")["artifacts"] + == old_evaluation.model_dump(mode="json")["artifacts"] + ) + assert len(counter) == int(joint_revision) + assert resumed.budget.summary()["scopes"]["sample0"]["reserved"]["partitions"] == 1 + assert resumed.budget.completed(admission)[ + "evaluation" + ] == old_evaluation.model_dump(mode="json") + assert resumed.execute("sample0", resumed.cells, setting) == result + assert len(counter) == int(joint_revision) + + +from tests.test_agent_required_comparisons import panel_run # noqa: E402, F401 + + +@pytest.mark.parametrize("harmony", [False, True]) +def test_targeted_recovery_admits_complete_resolution_panel_and_controls( + request: pytest.FixtureRequest, harmony: bool +) -> None: + from scarf.agent.orchestrator.budget import CandidateBudgetExceeded + + run = request.getfixturevalue("panel_run") + run.recovery_scope = "sample1" + run.study = run.study.model_copy(update={"correctionLicense": "safe"}) + setting = run.baseline() + setting.parameters.useHarmony = harmony + run._resolution_panel("full", run.cells, setting) + counts = run.budget.summary()["scopes"]["full"]["completed"] + assert counts == {"graphs": 2 if harmony else 1, "partitions": 8 if harmony else 4} + repair = setting.model_copy( + update={"parameters": setting.parameters.model_copy(update={"dimensions": 30})} + ) + if harmony: + with pytest.raises(CandidateBudgetExceeded): + run._resolution_panel("full", run.cells, repair) + assert run.budget.summary()["scopes"]["full"]["completed"] == counts + else: + run._resolution_panel("full", run.cells, repair) + assert run.budget.summary()["scopes"]["full"]["completed"] == { + "graphs": 2, + "partitions": 8, + } + with pytest.raises(CandidateBudgetExceeded): + run._resolution_panel( + "full", + run.cells, + setting.model_copy( + update={ + "parameters": setting.parameters.model_copy( + update={"dimensions": 10} + ) + } + ), + ) + + +@pytest.mark.usefixtures("memory_checkpoints") +def test_revised_feature_preparation_reuses_exact_inputs_but_not_other_ranking_columns( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from scarf.agent.orchestrator import journal + + run, _ = make_run(monkeypatch, object()) + run.study.technicalBatchColumns = ["old_batch"] + run.batch_columns = ["old_batch"] + original = {"studyContract": run.study.model_dump(mode="json")} + baseline = run.baseline() + experiment = {"parameter": "hvgRanking", "value": "batchAware"} + key, inputs, saved = run._setting_checkpoint( + "sample0", "sensitivity/hvgRanking", baseline, experiment + ) + assert saved is None + journal.save_checkpoint( + run.store, + run.prefix, + run.workflow.workflowRunId, + key, + inputs, + {"setting": baseline.model_dump(mode="json")}, + ) + run.previous_provenances = (original,) + run.evidence_revision = "a" * 64 + assert ( + run._setting_checkpoint( + "sample0", "sensitivity/hvgRanking", baseline, experiment + )[2] + is not None + ) + run.batch_columns = ["new_batch"] + revised_key, revised_inputs, saved = run._setting_checkpoint( + "sample0", "sensitivity/hvgRanking", baseline, experiment + ) + assert saved is None and revised_key != key + assert revised_inputs["rankingColumns"] == ["new_batch"] + assert journal.load_checkpoint( + run.store, run.prefix, run.workflow.workflowRunId, key, inputs + )["setting"] == baseline.model_dump(mode="json") + different = {"parameter": "excludeFeature", "value": "MT-CO1"} + assert ( + run._setting_checkpoint( + "sample0", "sensitivity/hvgRanking", baseline, different + )[2] + is None + ) + + +@pytest.mark.usefixtures("memory_checkpoints") +@pytest.mark.parametrize( + ("failure", "reason"), + [ + ("unknownCandidate", "observed candidate"), + ("missingCandidateCitation", "selected numerical evidence"), + ("unknownExperiment", "Unknown experiment ID"), + ("safeNotApplicable", "observed necessity assessment"), + ("confoundedNotNeeded", "confounds the batch columns"), + ("uncertain", "further evidence or deferral"), + ("ineligible", "failed required full-cell checks"), + ("missingUnits", "independent-unit support"), + ("unsupportedProtection", "protection is unsupported"), + ("neededNative", "required correction unresolved"), + ("indeterminate", "authorization remains indeterminate"), + ("harmonyRejected", "Harmony acceptance failed"), + ], +) +def test_assessment_cannot_hide_unresolved_design_or_scientific_failures( + monkeypatch: pytest.MonkeyPatch, failure: str, reason: str +) -> None: + run, selected = make_run(monkeypatch, SimpleNamespace(supports_image_input=False)) + if failure == "safeNotApplicable": + run.study.correctionLicense = "safe" + elif failure == "confoundedNotNeeded": + run.study.correctionLicense = "unsafeConfounded" + elif failure == "ineligible": + selected.eligible = False + elif failure == "missingUnits": + run.study.independentUnitColumns = ["donor"] + selected.metrics.crossUnitSupport = None + elif failure == "unsupportedProtection": + run.study.unsupportedProtection = ["condition/sex"] + elif failure == "indeterminate": + run.study.correctionLicense = "indeterminate" + elif failure == "harmonyRejected": + monkeypatch.setattr( + run, + "harmony_gate", + lambda *args: (False, ["Matched doublet agreement is missing"]), + ) + + def invalid(**kwargs: Any) -> Any: + action = assess(**{**kwargs, "output_validator": lambda action: action}).output + if failure == "unknownCandidate": + action.selectedCandidateId = "unobserved" + elif failure == "missingCandidateCitation": + action.evidenceIds = ["featureEvidence"] + elif failure == "unknownExperiment": + action.action = "experiment" + action.experimentId = "nearly-the-offered-id" + action.concern = "A supported population may merge" + action.expectedImprovement = "Test membership at another neighborhood size" + elif failure == "confoundedNotNeeded": + action.correctionNeed = "notNeeded" + elif failure == "uncertain": + action.correctionNeed = "uncertain" + elif failure in {"unsupportedProtection", "neededNative"}: + action.correctionNeed = "needed" + return kwargs["output_validator"](action) + + monkeypatch.setattr(rna_tuning, "run_agent_sync", invalid) + with pytest.raises(ValueError, match=reason): + run.review("full", 0, selected, {}) + assert not any("review" in row for row in run.history) diff --git a/tests/test_agent_screening_reference.py b/tests/test_agent_screening_reference.py new file mode 100644 index 00000000..0ef0f3c1 --- /dev/null +++ b/tests/test_agent_screening_reference.py @@ -0,0 +1,210 @@ +"""Numerical screening transfer with rare cells and repeated, unequal captures.""" + +import json +from pathlib import Path + +import numpy as np +import pytest +from sklearn.metrics import adjusted_rand_score + +from scarf.agent.ingest import ingest +from scarf.agent.orchestrator.rna_tuning import ( + screening_coverage, + screening_sizes, + uniform_screening_selection, +) +from scarf.agent.orchestrator.models import AutomatedWorkflowConfig +from scarf.agent.parameter_tuning.hvg import core_hvg_evidence +from scarf.datastore.datastore import DataStore +from scarf.storage.selections import read_stored_selection_indices +from tests.test_agent_ingest import _write_h5ad + + +@pytest.mark.slow +@pytest.mark.parametrize( + ("n_cells", "rare_cells", "automatic_policy"), + [(1800, 24, False), (12_000, 42, True)], + ids=["scaled-coverage-failure-regression", "bounded-default-policy-reference"], +) +def test_rare_population_transfer_requires_enlargement_beyond_metadata_coverage( + tmp_path: Path, + n_cells: int, + rare_cells: int, + automatic_policy: bool, +) -> None: + """Measure transfer and distinguish scaled coverage checks from the real policy.""" + rng = np.random.default_rng(4444) + rare_start = n_cells - rare_cells + split = rare_start // 2 + counts = rng.poisson(0.2, (n_cells, 90)).astype(np.uint16) + counts[:split, :12] += rng.poisson(6.0, (split, 12)).astype(np.uint16) + counts[split:rare_start, 12:24] += rng.poisson( + 6.0, (rare_start - split, 12) + ).astype(np.uint16) + counts[rare_start:, 24:36] += rng.poisson(6.0, (rare_cells, 12)).astype(np.uint16) + names = ( + [f"COMMON_A_{i}" for i in range(12)] + + [f"COMMON_B_{i}" for i in range(12)] + + [f"RARE_{i}" for i in range(12)] + + [f"BACKGROUND_{i}" for i in range(54)] + ) + source, target = tmp_path / "reference.h5ad", tmp_path / "reference.zarr" + _write_h5ad( + source, + counts, + feature_types=[b"Gene Expression"] * 90, + feature_names=[name.encode() for name in names], + ) + outcome = ingest(path=source, zarrPath=target, directions={"matrixKey": "X"}) + assert outcome.status == "done", outcome.notes + store = DataStore( + str(target), + default_assay="RNA", + min_features_per_cell=-1, + mito_pattern="", + ribo_pattern="", + nthreads=1, + ) + rows = np.arange(n_cells) + donors = rows % 6 + captures = np.asarray( + [ + f"donor{donor}:capture{int((row // 6) % 4 != 0)}" + for row, donor in zip(rows, donors, strict=True) + ] + ) + store.cells.insert("donor", donors.astype(str), overwrite=True) + store.cells.insert("capture", captures, overwrite=True) + store.cells.insert( + "known_population", + np.where(rows >= rare_start, "rare", "common"), + overwrite=True, + ) + full = store.snapshot_cell_selection("I") + if automatic_policy: + sizes = screening_sizes(n_cells, AutomatedWorkflowConfig()) + assert sizes == (10_000, 12_000) + sample_size = sizes[0] + assert rare_cells / n_cells < 0.01 + else: + # This scaled regression deliberately explores unsupported populations; + # it is not a test of the production minimum of 10,000 screening cells. + sample_size = 1500 + initial = uniform_screening_selection(store, full, size=180, seed=4444) + medium = uniform_screening_selection(store, full, size=700, seed=4444) + _, initial_concerns = screening_coverage( + store, full, initial, ["donor", "capture", "known_population"] + ) + assert initial_concerns + _, observed_group_concerns = screening_coverage( + store, full, medium, ["donor", "capture"] + ) + assert not observed_group_concerns + _, population_concerns = screening_coverage( + store, full, medium, ["known_population"] + ) + assert population_concerns + screened = uniform_screening_selection(store, full, size=sample_size, seed=4444) + coverage, concerns = screening_coverage( + store, full, screened, ["donor", "capture", "known_population"] + ) + assert not concerns + rare = next( + row for row in coverage["groups"]["known_population"] if row["value"] == "rare" + ) + assert rare["populationCells"] == rare_cells and rare["screeningCells"] >= 20 + genes = core_hvg_evidence(store, assay="RNA", cells=full)["scarfDefault"] + marker_genes = store.select_all_features(from_assay="RNA") + observed = {} + graph_artifacts = set() + marker_artifacts = set() + for scope, selection in (("sample", screened), ("full", full)): + normalized = store.run_normalization(selection, features=genes) + pca = store.run_pca(normalized, dims=21) + ann = store.build_ann_index( + pca, ann_metric="l2", ann_parallel=False, rand_state=4444 + ) + neighbors = store.query_neighbors(ann, coordinates=pca, k=11) + graph = store.build_connectivity_map( + neighbors, local_connectivity=1.0, bandwidth=1.5 + ) + graph_artifacts.add(graph) + clusters = store.run_leiden_clustering( + graph, + resolution=0.5, + backend="igraph", + symmetric_graph=False, + graph_upper_only=False, + random_seed=4444, + ) + indices = read_stored_selection_indices( + store.zw, + selection, + kind="cell_selection", + scope="datastore", + assay=None, + table_path="cellData", + ) + labels = np.asarray(store.load_artifact(clusters)["values"][:]) + labels_rare, sizes = np.unique( + labels[indices >= rare_start], return_counts=True + ) + rare_label = labels_rare[np.argmax(sizes)] + rare_cluster = labels == rare_label + recall = float( + (rare_cluster & (indices >= rare_start)).sum() + / (indices >= rare_start).sum() + ) + purity = float( + (rare_cluster & (indices >= rare_start)).sum() / rare_cluster.sum() + ) + assert recall >= 0.9 and purity >= 0.9 + assert len(np.unique(donors[indices[rare_cluster]])) == 6 + markers = store.run_marker_search( + clusters, features=marker_genes, from_assay="RNA" + ) + marker_artifacts.add(markers) + table = store.get_markers(marker=markers) + rare_markers = set( + table.loc[table.group_id.astype(str) == str(rare_label)] + .sort_values("score", ascending=False) + .head(12)["feature_name"] + ) + assert {"RARE_0", "RARE_1", "RARE_2"}.issubset(rare_markers) + assert len({name for name in rare_markers if name.startswith("RARE_")}) >= 10 + observed[scope] = { + "indices": indices, + "labels": labels, + "recall": recall, + "purity": purity, + "rareMarkers": rare_markers, + } + agreement = adjusted_rand_score( + observed["full"]["labels"][observed["sample"]["indices"]], + observed["sample"]["labels"], + ) + assert agreement > 0.9 + np.testing.assert_array_equal( + store.cells.fetch_all("I"), np.ones(n_cells, dtype=bool) + ) + (tmp_path / "screening_reference.json").write_text( + json.dumps( + { + "fullCells": n_cells, + "rareFullCells": rare_cells, + "screeningCells": sample_size, + "automaticPolicy": automatic_policy, + "sharedCellAdjustedRandIndex": agreement, + "rareRecall": { + scope: value["recall"] for scope, value in observed.items() + }, + "rarePurity": { + scope: value["purity"] for scope, value in observed.items() + }, + "primaryGraphArtifacts": len(graph_artifacts), + "markerArtifacts": len(marker_artifacts), + "limitation": "Synthetic numerical transfer, not LLM decision agreement or an atlas runtime benchmark.", + }, + sort_keys=True, + ) + ) diff --git a/tests/test_agent_selection_boundaries.py b/tests/test_agent_selection_boundaries.py new file mode 100644 index 00000000..934299ca --- /dev/null +++ b/tests/test_agent_selection_boundaries.py @@ -0,0 +1,543 @@ +"""Selection cannot erase failed preservation, alternative evidence, or lineage.""" + +from types import SimpleNamespace +from typing import Any + +import pytest +from pydantic_ai import UnexpectedModelBehavior + +from scarf.agent.parameter_tuning import agent, selection +from scarf.agent.parameter_tuning.contracts import ( + CandidateComparison, + ParameterCandidate, + ParameterCandidateEvaluation, + ParameterMetrics, + ParameterSearchPlan, + ParameterTuningAssayInput, + ParameterTuningReport, +) +from scarf.agent.tools import artifact_reference +from scarf.agent.types import AgentRunInfo, AgentUsageInfo +from tests.test_agent_parameter_tuning import ( + _FakeStore, + _artifact, + _cell_selection, + _dependencies, +) + + +def _evaluation( + name: str = "baseline", **parameters: Any +) -> ParameterCandidateEvaluation: + return ParameterCandidateEvaluation( + candidateId=name, + parameters=ParameterCandidate(candidateId=name, **parameters), + status="done", + eligible=True, + cellSelection=artifact_reference(_cell_selection()), + clusterColumn=f"clusters_{name}", + artifacts={ + "clusters": artifact_reference(_artifact("cluster_labels", 7)).model_dump() + }, + metrics=ParameterMetrics( + seedStability=0.8, + markerCoherence=0.8, + markerSpecificityMedian=0.8, + clusterConnectivity=0.8, + membershipStrengthMean=0.8, + crossUnitSupport=0.8, + batchMixing={"library": 0.2}, + biologicalPreservation={ + "condition": {"clisi": 0.8, "graphConnectivity": 0.8} + }, + doubletHighScoreConcentration=0.1, + ), + evidenceIds=[ + f"candidate:{name}:{metric}" + for metric in ( + "seedStability", + "markers", + "crossUnitSupport", + "protected:condition", + "doublet", + "batchMixing", + "geometry", + ) + ], + ) + + +@pytest.mark.parametrize( + ("change", "reason"), + [ + ({"status": "failed"}, "not an eligible execution"), + ( + {"parameters": ParameterCandidate(candidateId="corrected")}, + "correction modes", + ), + ( + { + "parameters": ParameterCandidate( + candidateId="corrected", useHarmony=True, dimensions=10 + ) + }, + "parameters are not matched", + ), + ( + {"cellSelection": artifact_reference(_cell_selection(99))}, + "different cell selections", + ), + ({"metrics": {"batchMixing": {}}}, "Batch comparison is missing"), + ({"metrics": {"batchMixing": {"library": 0.1}}}, "materially worsened"), + ( + {"metrics": {"biologicalPreservation": {}}}, + "Protected comparison is missing", + ), + ( + {"metrics": {"biologicalPreservation": {"condition": {"clisi": 0.8}}}}, + "Required protected metrics", + ), + ( + { + "metrics": { + "biologicalPreservation": { + "condition": { + "clisi": 0.8, + "graphConnectivity": 0.8, + "extra": 0.9, + } + } + } + }, + "Protected metrics do not align", + ), + ( + { + "metrics": { + "biologicalPreservation": { + "condition": {"clisi": 0.6, "graphConnectivity": 0.8} + } + } + }, + "degraded protected evidence", + ), + ( + {"metrics": {"crossUnitSupport": None}}, + "Cross-unit support comparison is missing", + ), + ({"metrics": {"crossUnitSupport": 0.6}}, "degraded cross-unit support"), + ( + {"metrics": {"markerCoherence": None}}, + "Marker-coherence comparison is missing", + ), + ({"metrics": {"markerCoherence": 0.6}}, "degraded marker coherence"), + ( + {"metrics": {"markerSpecificityMedian": None}}, + "Matched marker specificity comparison is missing", + ), + ({"metrics": {"clusterConnectivity": 0.6}}, "degraded cluster connectivity"), + ( + {"metrics": {"doubletHighScoreConcentration": 0.3}}, + "increased doublet concentration", + ), + ( + {"metrics": {"doubletHighScoreConcentration": None}}, + "Matched doublet-concentration comparison is missing", + ), + ], +) +def test_harmony_batch_gain_cannot_hide_missing_or_contradictory_evidence( + change: dict[str, Any], reason: str +) -> None: + native = _evaluation() + corrected = _evaluation("corrected", useHarmony=True) + corrected.metrics.batchMixing = {"library": 0.7} + assert selection.harmony_acceptance_gate( + native, + corrected, + batch_columns=["library", "library"], + protected_columns=["condition", "condition"], + independent_unit_columns=["donor"], + require_doublet_evidence=True, + ) == (True, []) + updates = dict(change) + if "metrics" in updates: + updates["metrics"] = corrected.metrics.model_copy(update=updates["metrics"]) + accepted, reasons = selection.harmony_acceptance_gate( + native, + corrected.model_copy(update=updates), + batch_columns=["library", "library"], + protected_columns=["condition", "condition"], + independent_unit_columns=["donor"], + require_doublet_evidence=True, + ) + assert not accepted + assert any(reason in item for item in reasons) + assert len(reasons) == len(set(reasons)) + + +def test_harmony_requires_approved_batch_evidence_and_valid_tolerance() -> None: + native, corrected = _evaluation(), _evaluation("corrected", useHarmony=True) + for tolerance in (-0.1, float("inf"), float("nan")): + with pytest.raises(ValueError, match="tolerance"): + selection.harmony_acceptance_gate( + native, + corrected, + batch_columns=["library"], + protected_columns=[], + tolerance=tolerance, + ) + accepted, reasons = selection.harmony_acceptance_gate( + native.model_copy(update={"eligible": False}), + corrected, + batch_columns=[], + protected_columns=[], + ) + assert not accepted + assert any("matched native candidate" in reason for reason in reasons) + assert "No approved batch metric was supplied." in reasons + + +def test_pareto_override_requires_independent_scientific_evidence() -> None: + native = _evaluation() + alternative = _evaluation("neighbors_21", neighborsK=21) + alternative.metrics.seedStability = 0.95 + alternative.metrics.markerCoherence = 0.95 + native.metrics.technicalAssociation = {"depth": 0.2} + alternative.metrics.technicalAssociation = {"depth": 0.2} + deps = _dependencies( + _FakeStore(), candidates=[native.parameters, alternative.parameters] + ) + deps.evaluations = {item.candidateId: item for item in (native, alternative)} + deps.executionOrder = list(deps.evaluations) + geometric = ["candidate:baseline:geometry", "candidate:neighbors_21:geometry"] + report = ParameterTuningReport( + status="done", + recommendedCandidateId="baseline", + evidenceIds=[geometric[0]], + comparisons=[ + CandidateComparison( + candidateId="neighbors_21", + summary="The wider graph has better measured stability and marker coverage.", + evidenceIds=geometric, + ) + ], + ) + with pytest.raises(ValueError, match="two independent non-geometric"): + selection.validate_parameter_tuning_report(report, deps) + report.comparisons[0].evidenceIds.extend(native.evidenceIds) + grounded = selection.validate_parameter_tuning_report(report, deps) + assert grounded.evaluations[0].metrics.dominatedByCandidateIds == ["neighbors_21"] + assert grounded.evaluations[1].metrics.dominatesCandidateIds == ["baseline"] + assert selection.parameter_evidence_classes(native.evidenceIds) == { + "resamplingStability", + "markerCoherence", + "crossUnitSupport", + "protectedVariablePreservation", + "qualityControl", + "technical", + "geometric", + } + assert selection.annotate_candidate_dominance(grounded.evaluations) == tuple( + grounded.evaluations + ) + + +def test_pareto_does_not_treat_incomparable_or_missing_metrics_as_superiority() -> None: + baseline, alternative = _evaluation(), _evaluation("alternative", dimensions=10) + alternative.metrics.seedStability = 0.95 + alternative.metrics.markerCoherence = 0.6 + assert not selection.annotate_candidate_dominance([baseline, alternative])[ + 0 + ].metrics.dominatedByCandidateIds + alternative.metrics = ParameterMetrics(seedStability=0.95) + baseline.metrics = ParameterMetrics(seedStability=0.8) + assert not selection.annotate_candidate_dominance([baseline, alternative])[ + 0 + ].metrics.dominatedByCandidateIds + alternative.parameters.useHarmony = True + result = selection.annotate_candidate_dominance([baseline, alternative]) + assert all(item.metrics.paretoOptimal is None for item in result) + with pytest.raises(ValueError, match="tolerance"): + selection.annotate_candidate_dominance(result, tolerance=float("nan")) + + +@pytest.mark.parametrize("batch", [False, True]) +def test_selection_exhaustion_preserves_completed_evidence_and_failed_usage( + monkeypatch: pytest.MonkeyPatch, batch: bool +) -> None: + calls: list[str] = [] + info = AgentRunInfo( + agentName="failed-selection", + status="failed", + runId="attempt", + usage=AgentUsageInfo(inputTokens=34, outputTokens=5, availability="partial"), + ) + + def fail(**kwargs: Any) -> None: + calls.append(kwargs["name"]) + error = UnexpectedModelBehavior("Essential comparison remained unresolved") + error.agent_run_info = info + raise error + + monkeypatch.setattr(agent, "run_agent_sync", fail) + store = _FakeStore() + candidates = [ + ParameterCandidate(candidateId="baseline"), + ParameterCandidate(candidateId="pca10", dimensions=10), + ] + if batch: + result = agent.tune_parameters_batch( + store, + model=object(), + assays=[ + ParameterTuningAssayInput( + normalized=store.normalized, + candidates=candidates, + maxCandidates=2, + maxRefinedCandidates=0, + ) + ], + ) + assert calls == ["parameter_tuning_batch"] + else: + result = agent.tune_parameters( + store, + model=object(), + normalized=store.normalized, + candidates=candidates, + max_candidates=2, + max_refined_candidates=0, + ) + assert calls == ["parameter_tuning"] + assert result.status == "needsInput" + assert result.recommendedCandidateId is None + assert result.runInfo == info + assert len(result.evaluations) == 2 + assert all( + item.status == "done" and item.artifacts["clusters"] + for item in result.evaluations + ) + assert result.needsInput is not None + assert set(result.needsInput.options) == {"baseline", "pca10"} + assert sum(name == "run_pca" for name, _, _ in store.calls) == 2 + + +def test_committed_refinement_executes_only_the_nominated_branch() -> None: + store = _FakeStore() + candidates = [ + ParameterCandidate(candidateId="baseline", dimensions=21), + ParameterCandidate(candidateId="pca10", dimensions=10), + ] + deps = _dependencies(store, candidates=candidates, max_candidates=3) + initial = [ + agent.execute_parameter_candidate(deps, item.candidateId) for item in candidates + ] + before = len(store.calls) + plan = ParameterSearchPlan( + status="refine", + candidates=[ParameterCandidate(candidateId="pca15", dimensions=15)], + basedOnCandidateIds=[item.candidateId for item in initial], + objectives=["Resolve the dimension tradeoff"], + rationale="The initial results bracket this dimension.", + evidenceIds=[item.evidenceIds[0] for item in initial], + stoppingCriteria=["Assess this single intermediate value"], + ) + validated, evaluations = agent.execute_parameter_search_plan( + deps, + plan, + initial_candidate_ids=[item.candidateId for item in initial], + max_refined_candidates=1, + ) + assert validated == plan + assert [item.candidateId for item in evaluations] == ["pca15"] + assert [ + kwargs["dims"] for name, _, kwargs in store.calls[before:] if name == "run_pca" + ] == [15] + + +@pytest.mark.parametrize( + ("change", "reason"), + [ + ({"exists": False}, "unavailable or incomplete"), + ({"complete": False}, "unavailable or incomplete"), + ({"inputs": {}}, "no cell-selection input"), + ( + {"inputs": {"cell_selection": _cell_selection(9).to_dict()}}, + "candidate does not match normalized artifact lineage", + ), + ], +) +def test_promoting_selected_artifacts_checks_exact_normalization_lineage( + change: dict[str, Any], reason: str +) -> None: + candidate = _evaluation() + report = ParameterTuningReport( + status="done", + fromAssay="RNA", + cellSelection=candidate.cellSelection, + recommendedCandidateId=candidate.candidateId, + evaluations=[candidate], + ) + inspections: list[Any] = [] + + def inspect(ref: Any) -> Any: + inspections.append(ref) + return SimpleNamespace( + **( + { + "exists": True, + "complete": True, + "inputs": {"cell_selection": _cell_selection().to_dict()}, + } + | change + ) + ) + + store = SimpleNamespace(inspect_artifact=inspect) + with pytest.raises(ValueError, match=reason): + selection.promote_parameter_candidate( + store, report=report, normalized=_artifact("normalized", 1) + ) + assert inspections == [_artifact("normalized", 1)] + + +@pytest.mark.parametrize( + ("report_change", "candidate_change", "normalized", "limit", "reason"), + [ + ( + {"status": "needsInput"}, + {}, + _artifact("normalized", 1), + 64, + "completed native tuning recommendation", + ), + ( + {"recommendedCandidateId": "absent"}, + {}, + _artifact("normalized", 1), + 64, + "not an eligible execution", + ), + ( + {}, + {"artifacts": {}}, + _artifact("normalized", 1), + 64, + "exact cluster artifact", + ), + ( + {}, + {}, + _artifact("normalized", 1, "ADT"), + 64, + "exact normalized assay artifact", + ), + ( + {"cellSelection": artifact_reference(_cell_selection(99))}, + {}, + _artifact("normalized", 1), + 64, + "report does not match normalized artifact lineage", + ), + ({}, {}, _artifact("normalized", 1), 1, "at least two"), + ], +) +def test_native_promotion_rejects_an_incomplete_or_mismatched_recommendation( + report_change: dict[str, Any], + candidate_change: dict[str, Any], + normalized: Any, + limit: int, + reason: str, +) -> None: + candidate = _evaluation().model_copy(update=candidate_change) + report = ParameterTuningReport( + status="done", + fromAssay="RNA", + cellSelection=artifact_reference(_cell_selection()), + recommendedCandidateId="baseline", + evaluations=[candidate], + ).model_copy(update=report_change) + store = _FakeStore() + with pytest.raises(ValueError, match=reason): + selection.promote_parameter_candidate( + store, report=report, normalized=normalized, identity_feature_limit=limit + ) + assert not any( + name in {"run_pca", "run_leiden_clustering"} for name, _, _ in store.calls + ) + + +@pytest.mark.parametrize( + ("report_change", "candidate_change", "kwargs", "reason"), + [ + ({"status": "needsInput"}, {}, {}, "must be done"), + ({}, {}, {"marker_assay": ""}, "must be non-empty"), + ({"cellSelection": None}, {}, {}, "exact cell selection"), + ({}, {}, {"marker_assay": "ADT"}, "Unknown marker assay"), + ({}, {}, {"native_assay": "ADT"}, "lacks a native tuning recommendation"), + ({}, {"artifacts": {}}, {}, "lacks exact clusters"), + ( + {}, + {"cellSelection": artifact_reference(_cell_selection(99))}, + {}, + "different cell selection", + ), + ( + {}, + {}, + {"native_assay": "RNA", "recommended_integration_id": "unexecuted"}, + "either an integrated graph", + ), + ({}, {}, {"recommended_integration_id": "unexecuted"}, "was not evaluated"), + ], +) +def test_finalization_cannot_publish_an_unexecuted_or_unmatched_native_branch( + report_change: dict[str, Any], + candidate_change: dict[str, Any], + kwargs: dict[str, Any], + reason: str, +) -> None: + candidate = _evaluation().model_copy(update=candidate_change) + report = ParameterTuningReport( + status="done", + fromAssay="RNA", + cellSelection=artifact_reference(_cell_selection()), + recommendedCandidateId="baseline", + evaluations=[candidate], + ).model_copy(update=report_change) + with pytest.raises(ValueError, match=reason): + selection.finalize_parameter_tuning_selection( + report, **({"marker_assay": "RNA"} | kwargs) + ) + + +@pytest.mark.parametrize( + ("case", "reason"), + [ + ("empty", "at least one tuning input"), + ("zero_budget", "at least one"), + ("wrong_kind", "assay-scoped normalized"), + ("duplicate", "names must be unique"), + ("unknown_primary", "Unknown primary assay"), + ], +) +def test_standalone_batched_inputs_fail_before_any_candidate_execution( + case: str, reason: str +) -> None: + store = _FakeStore() + assays = [ParameterTuningAssayInput(normalized=store.normalized)] + kwargs: dict[str, Any] = {} + if case == "empty": + assays = [] + elif case == "zero_budget": + kwargs["max_total_candidates"] = 0 + elif case == "wrong_kind": + assays[0].normalized = _artifact("reduction", 1) + elif case == "duplicate": + assays.append(assays[0]) + else: + kwargs["primary_assay"] = "ADT" + with pytest.raises((TypeError, ValueError), match=reason): + agent.tune_parameters_batch(store, model=object(), assays=assays, **kwargs) + assert store.calls == [] diff --git a/tests/test_agent_teaching_model.py b/tests/test_agent_teaching_model.py index 01b52acb..62d3b4ce 100644 --- a/tests/test_agent_teaching_model.py +++ b/tests/test_agent_teaching_model.py @@ -7,6 +7,7 @@ from pydantic_ai import Agent +from scarf.agent.experimental_context.contracts import ExperimentalContextDecision from scarf.agent.orchestrator.rna_tuning import _assessment_output_type from scarf.agent.parameter_tuning.comparisons import validate_comparison_review from tests.agent_comparison_examples import comparison_review @@ -101,3 +102,27 @@ def test_teaching_provider_nominates_combines_and_assesses_actual_evidence() -> assert "6 selected genes" in result.concern assert state["requests"] == 3 assert len(state["assessments"][-1]["alternatives"]) == 4 + + async def inspect_cell_covariates() -> dict: + return {"characterization": {}, "qcProfiles": []} + + async def analyze_experimental_design( + column_domains: dict, + coefficients_of_interest: list, + units_of_inference: dict, + batch_columns: list, + ) -> dict: + return { + "qcProfiles": [{"action": "skip", "evidenceId": "qc:retention"}], + "captureDesignSafety": {}, + "requestedComparisons": [], + } + + context_model, _ = namespace["_scripted_workflow_model"]() + context = Agent( + context_model, + output_type=ExperimentalContextDecision, + tools=[inspect_cell_covariates, analyze_experimental_design], + ).run_sync("Assess the available context") + assert context.output.batchCorrection.action == "skip" + assert context.output.evidenceIds == ["qc:retention"] diff --git a/tests/test_agent_tuning_reuse.py b/tests/test_agent_tuning_reuse.py index 6d31dae7..1ec890d0 100644 --- a/tests/test_agent_tuning_reuse.py +++ b/tests/test_agent_tuning_reuse.py @@ -247,3 +247,39 @@ def test_restore_doublets_keeps_frozen_artifacts_and_summaries() -> None: assert restored.cell_selections == (_cell_selection(),) assert restored.capture_values == ("captureA",) assert restored.score_quantiles == {"p95": 0.4} + + +def test_revised_design_metrics_reuse_primary_analysis_and_drop_stale_findings() -> ( + None +): + store = _FakeStore() + candidate = ParameterCandidate(candidateId="baseline") + deps = ParameterTuningDependencies( + store=store, + normalized=store.normalized, + normalizedShape=store.normalized_shape, + cellSelection=store.cell_selection, + fromAssay="RNA", + candidates={"baseline": candidate}, + batchColumns=("batch",), + preservationColumns=("condition",), + ) + observed = execution.execute_parameter_candidate(deps, "baseline") + assert observed.status == "done" + observed.metrics.biologicalPreservation["old"] = {"clisi": 1.0} + observed.evidenceIds.append("candidate:baseline:clisi:old") + observed.warnings.append("cLISI for 'old' unavailable: missing column") + before = Counter(name for name, _args, _kwargs in store.calls) + deps.preservationColumns = ("age",) + deps.columnKinds = {"age": "continuous"} + revised = execution.refresh_candidate_design_evidence(deps, observed) + after = Counter(name for name, _args, _kwargs in store.calls) + assert revised.artifacts == observed.artifacts + assert revised.metrics.biologicalPreservation == {} + assert not any(":clisi:old" in item for item in revised.evidenceIds) + assert not any("'old'" in item for item in revised.warnings) + assert any( + "continuous column 'age' is unsupported" in item for item in revised.warnings + ) + assert "old" in observed.metrics.biologicalPreservation + assert after - before == Counter({"metric_proportional_batch_mixing": 1}) diff --git a/tests/test_agent_visual_adjudication.py b/tests/test_agent_visual_adjudication.py new file mode 100644 index 00000000..a3c610e0 --- /dev/null +++ b/tests/test_agent_visual_adjudication.py @@ -0,0 +1,209 @@ +"""Model-facing plots use matched artifacts and bounded selected-cell reads.""" + +from types import SimpleNamespace + +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +import pytest + +from scarf.agent.orchestrator import tuning +from scarf.agent.parameter_tuning.contracts import ( + ArtifactRecord, + ParameterCandidate, + ParameterCandidateEvaluation, + ParameterMetrics, +) +from scarf.agent.types import ArtifactReferenceModel + + +class _Array: + def __init__(self, values): + self.values = np.asarray(values) + self.shape = self.values.shape + self.dtype = self.values.dtype + self.rows = [] + + def get_orthogonal_selection(self, selection): + self.rows.append(np.asarray(selection[0]).copy()) + assert len(selection[0]) <= 5000 + return self.values[selection] + + +def _visual_fixture(monkeypatch, *, batch=True, doublet=False, qc_size=20): + arrays = {} + selections = {} + + def ref(kind, index, scope="assay"): + return ArtifactRecord( + kind=kind, + scope=scope, + assay="RNA" if scope == "assay" else None, + artifactId=f"{index:064x}", + ) + + cells = ref("cell_selection", 1, "datastore") + full = ref("cell_selection", 2, "datastore") + selections[cells.artifactId] = np.arange(5, 15) + selections[full.artifactId] = np.arange(20) + coordinates = ref("reduction", 3) + harmony = ref("reduction", 4) + clusters = ref("cluster_labels", 5) + features = ref("feature_selection", 6) + marker = ref("marker_table", 7) + qc = ref("quality_metric", 8) + for item, field, value in ( + (coordinates, "data", np.arange(20).reshape(10, 2)), + (harmony, "data", np.arange(20).reshape(10, 2) * 0.8), + (clusters, "values", np.arange(10) % 2), + (qc, "values", np.linspace(0.1, 1.1, qc_size)), + ): + arrays[item.artifactId] = {field: _Array(value)} + genes = ["MT-CO1", "MRPL1", "RPL1", "CCN1", "HLA-A", "H2-A", "HIST1", "XIST"] + native = ParameterCandidateEvaluation( + candidateId="native", + status="done", + eligible=True, + parameters=ParameterCandidate(candidateId="native"), + cellSelection=ArtifactReferenceModel.model_validate(cells.model_dump()), + artifacts={ + "pca": coordinates, + "clusters": clusters, + "graphFeatures": features, + "markerTable": marker, + }, + metrics=ParameterMetrics( + batchMixing={"batch": 0.5} if batch else {}, + topMarkerGenes={"0": genes, "1": ["MS4A1"]}, + ), + ) + corrected = native.model_copy( + deep=True, + update={ + "candidateId": "harmony", + "parameters": native.parameters.model_copy(update={"useHarmony": True}), + }, + ) + corrected.artifacts["harmony"] = harmony + if doublet: + score = ref("doublet_score", 9) + arrays[score.artifactId] = {"values": _Array(np.linspace(0.01, 0.9, 10))} + native.artifacts["doubletScore:all"] = score + store = SimpleNamespace( + zw=object(), + cells=SimpleNamespace(columns=["batch"] if batch else []), + load_artifact=lambda item: arrays[item.artifact_id], + inspect_artifact=lambda item: SimpleNamespace( + inputs={ + "cell_selection": { + "type": "artifact", + "scope": full.scope, + "kind": full.kind, + "artifact_id": full.artifactId, + } + } + ), + get_markers=lambda *a, **k: pd.DataFrame( + { + "group_id": ["0"] * len(genes), + "feature_name": genes, + "score": np.linspace(0.3, 0.9, len(genes)), + } + ), + ) + monkeypatch.setattr(tuning, "as_zarr_array", lambda array, **kwargs: array) + monkeypatch.setattr( + tuning, + "read_stored_selection_indices", + lambda group, item, **kwargs: selections[item.artifact_id], + ) + monkeypatch.setattr( + tuning, + "read_metadata_rows_chunkwise", + lambda metadata, name, rows: np.asarray(["batch-a", "batch-b"] * 10)[rows], + ) + return store, native, corrected, qc, arrays, selections + + +@pytest.mark.parametrize( + "batch,doublet,qc_size", [(True, True, 20), (False, False, 10)] +) +def test_native_harmony_visuals_preserve_matching_and_exact_qc_projection( + monkeypatch, batch, doublet, qc_size +): + store, native, corrected, qc, arrays, _ = _visual_fixture( + monkeypatch, batch=batch, doublet=doublet, qc_size=qc_size + ) + captured = [] + savefig = plt.Figure.savefig + + def save(figure, *args, **kwargs): + captured.extend( + text.get_text() for axis in figure.axes for text in axis.get_xticklabels() + ) + return savefig(figure, *args, **kwargs) + + monkeypatch.setattr(plt.Figure, "savefig", save) + outputs = tuning._analysis_visual_content( + store, + native, + [native, corrected], + qc_artifact_metrics=[("Exact mitochondrial fraction", qc)], + ) + assert {item.identifier for item in outputs} == { + "analysis-overview", + "native-harmony-comparison", + "marker-score-heatmap", + "qc-doublet-diagnostics", + } + assert all(item.data.startswith(b"\x89PNG") for item in outputs) + assert "MT-CO1 [mitochondrial]" in captured + assert "XIST [sex-linked]" in captured + expected = np.arange(5, 15) if qc_size == 20 else np.arange(10) + assert np.array_equal(arrays[qc.artifactId]["values"].rows[-1], expected) + + +@pytest.mark.parametrize( + "damage,error", + [ + ("missingCoordinates", "lacks visualizable"), + ("oneCoordinate", "two dimensions"), + ("clusterLength", "do not align"), + ("doubletSelection", "lacks its exact cell selection"), + ("qcSelection", "lacks its cell selection"), + ("qcCoverage", "does not cover selected cells"), + ("missingHarmony", "lacks visual artifacts"), + ], +) +def test_visual_evidence_rejects_unmatched_or_missing_artifact_inputs( + monkeypatch, damage, error +): + store, native, corrected, qc, arrays, selections = _visual_fixture( + monkeypatch, doublet=True + ) + if damage == "missingCoordinates": + del native.artifacts["pca"] + elif damage == "oneCoordinate": + arrays[native.artifacts["pca"].artifactId]["data"] = _Array(np.ones((10, 1))) + elif damage == "clusterLength": + arrays[native.artifacts["clusters"].artifactId]["values"] = _Array(np.arange(9)) + elif damage == "doubletSelection": + arrays[native.artifacts["doubletScore:all"].artifactId]["values"] = _Array( + np.arange(9) + ) + elif damage == "qcSelection": + store.inspect_artifact = lambda item: SimpleNamespace(inputs={}) + elif damage == "qcCoverage": + selections[f"{2:064x}"] = np.arange(20, 40) + elif damage == "missingHarmony": + del corrected.artifacts["harmony"] + try: + with pytest.raises(ValueError, match=error): + tuning._analysis_visual_content( + store, + native, + [native, corrected], + qc_artifact_metrics=[("mitochondrial", qc)], + ) + finally: + plt.close("all") diff --git a/tests/test_registered_qc_profiles.py b/tests/test_registered_qc_profiles.py index ceac64c0..7cd228b6 100644 --- a/tests/test_registered_qc_profiles.py +++ b/tests/test_registered_qc_profiles.py @@ -1226,7 +1226,12 @@ def test_audited_core_qc_policy_executes_exact_projected_selection( ) orchestrator = AgentOrchestrator(object()) - def resolve(_store, _request, definition, bundle, _answers): + def resolve(_store, _request, definition, bundle, _answers, *, qc_evidence): + assert ( + qc_evidence["policies"][0]["resolvedBounds"] + == profile.model_dump(mode="json")["resolvedBounds"] + ) + assert qc_evidence["policies"][0]["retainedCells"] == projection.retainedCells option = definition.executor_option(f"cellQuality:{policy}") assert option.payload.lowerCountMad is None assert option.payload.upperMitoMad is None From 71fc73c030b928144ff8c4ca7b88763dce4b4216 Mon Sep 17 00:00:00 2001 From: gautam8387 Date: Tue, 8 Sep 2026 23:43:49 +0200 Subject: [PATCH 17/21] doublet fix --- scarf/agent/config/agent_exec.py | 157 ++++++- scarf/agent/experimental_context/agent.py | 75 +++- scarf/agent/experimental_context/contracts.py | 3 +- .../experimental_context/requirements.py | 82 +++- scarf/agent/experimental_context/tools.py | 139 +++++- .../agent/experimental_context/validation.py | 20 + scarf/agent/orchestrator/api.py | 9 + scarf/agent/orchestrator/context.py | 35 +- scarf/agent/orchestrator/models.py | 7 + scarf/agent/orchestrator/rna_tuning.py | 63 ++- scarf/agent/types.py | 12 + tests/test_agent_attempt_audit.py | 5 +- tests/test_agent_beginner.py | 110 +++++ tests/test_agent_context_capture_repair.py | 247 +++++++++++ tests/test_agent_context_failure_repair.py | 40 ++ tests/test_agent_context_resume_boundaries.py | 51 ++- tests/test_agent_design_comparisons.py | 2 +- .../test_agent_finalization_doublet_policy.py | 217 ++++++++++ tests/test_agent_joint_design_recovery.py | 211 +++++++++ tests/test_agent_notebook_interrupt.py | 2 +- tests/test_agent_orchestrator_stages.py | 60 ++- tests/test_agent_rate_limit_recovery.py | 399 ++++++++++++++++++ tests/test_agent_rna_adaptive.py | 33 +- tests/test_agent_rna_evidence_mode.py | 90 +++- 24 files changed, 1973 insertions(+), 96 deletions(-) create mode 100644 tests/test_agent_context_capture_repair.py create mode 100644 tests/test_agent_context_failure_repair.py create mode 100644 tests/test_agent_finalization_doublet_policy.py create mode 100644 tests/test_agent_joint_design_recovery.py create mode 100644 tests/test_agent_rate_limit_recovery.py diff --git a/scarf/agent/config/agent_exec.py b/scarf/agent/config/agent_exec.py index 1906a888..93f41ed6 100644 --- a/scarf/agent/config/agent_exec.py +++ b/scarf/agent/config/agent_exec.py @@ -2,12 +2,13 @@ import asyncio import json +import math import sys import time import uuid from collections.abc import Callable, Sequence from concurrent.futures import ThreadPoolExecutor -from dataclasses import dataclass +from dataclasses import dataclass, field from inspect import isawaitable, iscoroutinefunction from threading import Event, Lock from typing import TYPE_CHECKING, Any, Literal @@ -16,6 +17,7 @@ from .._deps import require_pydantic_ai from ..types import ( AgentExecutionResult, + AgentProviderFailure, AgentRunInfo, AgentUsageInfo, AgentValidationRetry, @@ -34,6 +36,8 @@ _MAX_VISUAL_EVIDENCE_ITEMS = 8 _MAX_VISUAL_EVIDENCE_ITEM_BYTES = 4 * 1024 * 1024 _MAX_VISUAL_EVIDENCE_TOTAL_BYTES = 16 * 1024 * 1024 +_RATE_LIMIT_DELAYS = (15.0, 30.0, 60.0) +_RATE_LIMIT_WAIT_LIMIT = 120.0 __all__ = [ "AgentUserPrompt", @@ -60,6 +64,115 @@ class ImageInputUnsupportedError(RuntimeError): """The configured model or provider rejected image input.""" +@dataclass +class _ProviderRequestAudit: + requests: int = 0 + waited: float = 0.0 + failures: list[AgentProviderFailure] = field(default_factory=list) + response_requests: list[int] = field(default_factory=list) + + +def _rate_limit_delay(error: Exception, retry: int) -> float | None: + """Retry temporary throttling, preserving provider timing and hard quotas.""" + from pydantic_ai.exceptions import ModelHTTPError + + if not isinstance(error, ModelHTTPError) or error.status_code != 429: + return None + detail = describe_agent_error(error).casefold() + if any( + marker in detail + for marker in ( + "insufficient_quota", + "insufficient quota", + "exceeded your current quota", + "insufficient credit", + "insufficient balance", + "billing", + "payment required", + "spending limit", + "daily limit", + "monthly limit", + ) + ): + return None + if retry >= len(_RATE_LIMIT_DELAYS): + return None + current: BaseException | None = error + seen: set[int] = set() + while current is not None and id(current) not in seen and len(seen) < 8: + seen.add(id(current)) + headers = getattr(current, "headers", None) or getattr( + getattr(current, "response", None), "headers", None + ) + if headers: + parsed = ModelHTTPError(429, error.model_name, headers=headers).retry_after + if parsed is not None and math.isfinite(parsed): + return parsed + current = current.__cause__ or ( + current.__context__ if not current.__suppress_context__ else None + ) + return _RATE_LIMIT_DELAYS[retry] + + +def _rate_limited_model( + model: Any, + *, + config: AgentRunConfig, + audit: _ProviderRequestAudit, + name: str | None, +) -> Any: + """Retry only a failed request; completed agent tools are never replayed.""" + from pydantic_ai.exceptions import UsageLimitExceeded + from pydantic_ai.models.wrapper import WrapperModel + + class RateLimitedModel(WrapperModel): + async def request( + self, messages: Any, model_settings: Any, model_request_parameters: Any + ) -> Any: + retry = 0 + while True: + if audit.requests >= config.requestLimit: + raise UsageLimitExceeded( + "The model request limit includes failed provider requests; " + f"all {config.requestLimit} available requests were used" + ) + audit.requests += 1 + try: + response = await self.wrapped.request( + messages, model_settings, model_request_parameters + ) + audit.response_requests.append(audit.requests) + return response + except Exception as exc: + delay = _rate_limit_delay(exc, retry) + if delay is not None and ( + audit.requests >= config.requestLimit + or audit.waited + delay > _RATE_LIMIT_WAIT_LIMIT + ): + delay = None + audit.failures.append( + AgentProviderFailure( + requestIndex=audit.requests, + statusCode=getattr(exc, "status_code", None), + error=describe_agent_error(exc), + retryDelaySeconds=delay, + ) + ) + if delay is None: + raise + logger.warning( + f"Agent {name or 'unnamed'}: provider rate limit; retrying " + f"the same request in {delay:g}s " + f"({retry + 1}/{len(_RATE_LIMIT_DELAYS)} retries). " + "Completed tools and scientific evidence are preserved." + ) + audit.waited += delay + await asyncio.sleep(delay) + retry += 1 + + return RateLimitedModel(model) + + def describe_agent_error(error: BaseException, *, limit: int = 8000) -> str: """Describe the concrete cause chain without traceback or response payloads.""" parts: list[str] = [] @@ -294,12 +407,20 @@ def _build_agent( output_validator: Callable[[Any], Any] | None, normalize_sync_function_model: bool, validation_failures: list[AgentValidationRetry] | None = None, + provider_audit: _ProviderRequestAudit | None = None, ) -> Any: require_pydantic_ai() from pydantic_ai import Agent, RunContext + normalized_model = ( + _normalize_model(model) if normalize_sync_function_model else model + ) + if provider_audit is not None: + normalized_model = _rate_limited_model( + normalized_model, config=config, audit=provider_audit, name=name + ) agent = Agent( - _normalize_model(model) if normalize_sync_function_model else model, + normalized_model, output_type=output_type, system_prompt=system_prompt, deps_type=deps_type or object, @@ -330,7 +451,11 @@ async def validate_output(context: RunContext[Any], output: Any) -> Any: validation_failures.append( AgentValidationRetry( source="output", - requestIndex=context.usage.requests, + requestIndex=( + provider_audit.requests + if provider_audit is not None + else context.usage.requests + ), message=str(exc), response=submitted, ) @@ -345,7 +470,11 @@ async def validate_output(context: RunContext[Any], output: Any) -> Any: validation_failures.append( AgentValidationRetry( source="output", - requestIndex=context.usage.requests, + requestIndex=( + provider_audit.requests + if provider_audit is not None + else context.usage.requests + ), message=str(exc), response=submitted, ) @@ -369,17 +498,21 @@ def _run_info( tools: Sequence[Callable[..., Any] | Any], validation_failures: Sequence[AgentValidationRetry], error: BaseException | None = None, + provider_audit: _ProviderRequestAudit | None = None, ) -> AgentRunInfo: from pydantic import ValidationError from pydantic_ai.messages import ModelResponse, RetryPromptPart, ToolCallPart calls = _tool_calls(messages, allowed_names=_tool_names(tools)) reported_usage = _usage_info(usage, tool_calls=len(calls)) + if provider_audit is not None: + reported_usage.requests = provider_audit.requests responses = [message for message in messages if isinstance(message, ModelResponse)] measured = [message for message in responses if message.usage.has_values()] reported_usage.availability = ( "reported" if measured + and not (provider_audit is not None and provider_audit.failures) and len(measured) == len(responses) == reported_usage.requests and not ( error is not None @@ -398,9 +531,16 @@ def _run_info( pending: dict[str, Any] = {} tool_names = _tool_names(tools) request_index = 0 + response_index = 0 for message in messages: if isinstance(message, ModelResponse): - request_index += 1 + request_index = ( + provider_audit.response_requests[response_index] + if provider_audit is not None + and response_index < len(provider_audit.response_requests) + else request_index + 1 + ) + response_index += 1 for part in getattr(message, "parts", ()): if isinstance(part, ToolCallPart): pending[part.tool_call_id] = part.args @@ -435,7 +575,7 @@ def _run_info( retries.append( AgentValidationRetry( source="tool" if rejected.tool_name in tool_names else "schema", - requestIndex=len(responses), + requestIndex=request_index, message=str(error.__cause__), response=rejected.args, ) @@ -456,6 +596,7 @@ def _run_info( toolCalls=calls, status="failed" if error is not None else "done", validationRetries=sorted(retries, key=lambda retry: retry.requestIndex), + providerFailures=provider_audit.failures if provider_audit is not None else [], errorType=type(error).__name__ if error is not None else None, error=error_detail, ) @@ -486,6 +627,7 @@ async def _execute_agent( usage_limits = get_usage_limits(run_config) usage = RunUsage() failures: list[AgentValidationRetry] = [] + provider_audit = _ProviderRequestAudit() started = time.monotonic() logger.debug( f"Starting agent {agent_name}: model={_model_name(model)}, " @@ -511,6 +653,7 @@ async def _execute_agent( output_validator=output_validator, normalize_sync_function_model=normalize_sync_function_model, validation_failures=failures, + provider_audit=provider_audit, ) async with agent: try: @@ -539,6 +682,7 @@ async def _execute_agent( tools=tools, validation_failures=failures, error=exc, + provider_audit=provider_audit, ) setattr(exc, "agent_run_info", info) logger.error( @@ -561,6 +705,7 @@ async def _execute_agent( started=started, tools=tools, validation_failures=failures, + provider_audit=provider_audit, ) if on_attempt is not None: try: diff --git a/scarf/agent/experimental_context/agent.py b/scarf/agent/experimental_context/agent.py index 7d9acb10..fa6f3981 100644 --- a/scarf/agent/experimental_context/agent.py +++ b/scarf/agent/experimental_context/agent.py @@ -31,6 +31,7 @@ from .tools import ( _prepare_experimental_context_tool, analyze_experimental_design, + capture_repair_inputs, contrast_plans_from_characterization, inspect_cell_covariates, inspect_context_evidence, @@ -88,9 +89,12 @@ def __init__( observationUnit and independentUnit are separate unit fields and do not count toward that limit. Never repeat response among explanatory or conditioning columns. Never append unit identifiers to explanatory - columns just to identify replication. A joint explanation within - strata is unsupported; separate simpler comparisons do not establish - that joint conditional finding. If a tool rejects a proposal, correct + columns just to identify replication. Two explanatory columns plus a + conditioning column are unsupported. One response, one explanatory + column and one conditioning column are supported for descriptive + design coverage; association support depends on the measured units. + Separate simpler comparisons do not establish an unsupported joint + conditional finding. If a tool rejects a proposal, correct the named fields while preserving its scientific question or record the unsupported requirement explicitly. You may make one follow-up call with at most four new or revised comparisons after @@ -114,7 +118,11 @@ def __init__( evidenceRequirements and evidenceCoverage enforce this distinction. For repeated donors and incomplete pairing, inspect descriptiveDesign: it retains observation counts, distinct donors, group support and paired - coverage without collapsing a donor to its first condition. Unsupported + coverage without collapsing a donor to its first condition. A computed + descriptiveDesign answers a designCoverage question even if the separate + association method is unsupported. Preserve that association limitation + in the rationale; do not ask the user to resolve an optional association + before continuing descriptive population discovery. Unsupported association methods do not establish non-identifiability. Only measured rank and estimability for the exact tested design support that claim. Use the single follow-up round to resolve missing design evidence. @@ -124,13 +132,21 @@ def __init__( testing are unsupported. You may call score_current_representation at most once when an exact supplied graph can add evidence. Pass batch_columns as a JSON array, including a - singleton. Capture proposals must name an exact observed column and - quote the study statement identifying it as a physical capture. An - optional reference pool also needs an exact quote identifying the + singleton. When the study explicitly identifies physical capture, pass + capture_proposal in analyze_experimental_design, preferably in its first + call. This existing tool validates capture provenance; no separate + capture tool or additional user confirmation is required for an explicit + supported study statement. Capture proposals must name an exact observed + column and quote the study statement identifying it as a physical capture. + An optional reference pool also needs an exact quote identifying the observed reference captures. Sample uniqueness is not capture proof. Leave unresolved capture provenance explicit. Validated tools own capture identities and protected combinations; do not copy them into the final decision. Nominate any new combination through a design tool. + If capture was omitted from two completed design rounds, the same tool + permits only a capture provenance repair with identical domains, + coefficients, units and batch columns, and no proposals. This does not + authorize another design comparison round. The tools return bounded cell-QC profiles projected against the exact shared cell selection. Do not choose a profile or return cellQc. @@ -429,6 +445,51 @@ def run( ) ) ) + repair_inputs = capture_repair_inputs(deps) + if repair_inputs is not None: + user_prompt += ( + "\nCapture provenance can be repaired with these exact saved " + "analyze_experimental_design inputs. Add only capture_proposal " + "with an exact supporting study quote: " + + json.dumps(repair_inputs, sort_keys=True) + ) + if previous_context is not None and previous_context.status == "needsInput": + from .requirements import objective_evidence + + requirements, coverage = ( + objective_evidence( + study_context=study_context, + study_objective=study_objective, + experimental_result=previous_context.model_copy( + update={ + "characterization": deps.characterization, + "batchSafety": list(deps.batchSafety.values()), + } + ), + ) + if study_objective + else ([], []) + ) + user_prompt += ( + "\nCurrent objective requirements and their measured support: " + + json.dumps( + { + "evidenceRequirements": [ + item.model_dump(mode="json") for item in requirements + ], + "evidenceCoverage": [ + item.model_dump(mode="json") for item in coverage + ], + } + ) + + "\nThe previous interpretation stopped with these unresolved questions: " + + json.dumps(previous_context.decision.needsInput) + + "\nReassess each against the original study text and committed evidence. " + "Use the existing design tool for an omitted capture proposal. " + "Distinguish computed descriptive support from unavailable association " + "methods. Keep genuinely essential unresolved questions; do not copy " + "a previous blocker when supplied provenance or measured evidence answers it." + ) try: execution = run_agent_sync( model=self.model, diff --git a/scarf/agent/experimental_context/contracts.py b/scarf/agent/experimental_context/contracts.py index 2937aa88..11d88ef5 100644 --- a/scarf/agent/experimental_context/contracts.py +++ b/scarf/agent/experimental_context/contracts.py @@ -50,7 +50,8 @@ class CovariateProposal(AgentDataModel): Use one response with either one or two explanatory columns and no conditioning, or one response with one explanatory column and one categorical conditioning column. Observation and independent units are separate and do not count toward - this limit. A joint comparison within strata is unsupported. + this limit. Two explanatory columns plus conditioning are unsupported; one + explanatory column within a categorical stratum is an offered comparison. """ response: str = Field( diff --git a/scarf/agent/experimental_context/requirements.py b/scarf/agent/experimental_context/requirements.py index 028bb418..7173b8fb 100644 --- a/scarf/agent/experimental_context/requirements.py +++ b/scarf/agent/experimental_context/requirements.py @@ -62,6 +62,53 @@ def requested_design_questions( return output +def _measured_joint_design(comparison: Any) -> bool: + """Recognize joint descriptive counts without claiming an association.""" + proposal = comparison.proposal + columns = { + proposal.response, + *proposal.explanatoryColumns, + *([proposal.conditionedOn] if proposal.conditionedOn else []), + } + descriptive = comparison.evidence.get("descriptiveDesign", {}) + rows = descriptive.get("jointGroupSupport", []) + if ( + len(columns) != 3 + or descriptive.get("status") != "computed" + or not isinstance(rows, list) + or not rows + or any( + comparison.evidence.get("columnKinds", {}).get(name) != "categorical" + for name in columns + ) + ): + return False + for row in rows: + if ( + not isinstance(row, dict) + or set(row.get("groups", {})) != columns + or type(row.get("observationUnits")) is not int + or type(row.get("independentUnits")) is not int + or not 0 < row["independentUnits"] <= row["observationUnits"] + ): + return False + return bool( + sum(row["observationUnits"] for row in rows) + == descriptive.get("observationUnits") + ) + + +def requested_design_purpose( + quote: str, +) -> Literal["effectEstimation", "association", "designCoverage"]: + """Keep model guidance and validation on the same requested evidence kind.""" + if re.search(r"\bestimat\w*.*\beffect", quote, re.I): + return "effectEstimation" + if re.search(r"\bassociat\w*", quote, re.I): + return "association" + return "designCoverage" + + def objective_evidence( *, study_context: str, study_objective: str, experimental_result: Any ) -> tuple[list[DesignEvidenceRequirement], list[DesignEvidenceCoverage]]: @@ -248,16 +295,10 @@ def objective_evidence( for quote, names, conditional in requested_design_questions( study_context, study_objective, list(records) ): - purpose: Literal["effectEstimation", "association", "designCoverage"] = ( - "effectEstimation" - if re.search(r"\bestimat\w*.*\beffect", quote, re.I) - else "association" - if re.search(r"\bassociat\w*", quote, re.I) - else "designCoverage" - ) + purpose = requested_design_purpose(quote) matches = [ - item - for item in characterization.comparisons + index + for index, item in enumerate(characterization.comparisons, start=1) if set(names).issubset( { item.proposal.response, @@ -265,15 +306,28 @@ def objective_evidence( item.proposal.conditionedOn, } ) + and item.proposal.purpose == purpose and ( - item.proposal.conditionedOn is not None - if conditional - else len(item.proposal.explanatoryColumns) == 2 + ( + item.proposal.conditionedOn is not None + if conditional + else len(item.proposal.explanatoryColumns) == 2 + ) + and item.proposal.essential + or purpose == "designCoverage" + and coverage[index].status == "computed" + and _measured_joint_design(item) ) - and item.proposal.purpose == purpose - and item.proposal.essential ] if matches: + for index in matches: + # A model's optional label cannot erase an explicit requirement. + # Reuse its measured question rather than adding a duplicate that + # would consume another slot in the bounded study contract. + if not requirements[index].essential: + requirements[index] = requirements[index].model_copy( + update={"essential": True} + ) continue identifier = "requestedDesign:" + hashlib.sha256(quote.encode()).hexdigest() requirements.append( diff --git a/scarf/agent/experimental_context/tools.py b/scarf/agent/experimental_context/tools.py index 738f0933..0fb12f82 100644 --- a/scarf/agent/experimental_context/tools.py +++ b/scarf/agent/experimental_context/tools.py @@ -46,7 +46,11 @@ _hto_identity_columns, _offered_qc_profiles, ) -from .requirements import objective_evidence, requested_design_questions +from .requirements import ( + objective_evidence, + requested_design_purpose, + requested_design_questions, +) try: from pydantic_ai import ModelRetry, RunContext @@ -229,6 +233,32 @@ async def inspect_context_evidence( raise ModelRetry("Choose an exact record from the saved context summary") +def capture_repair_inputs( + deps: ExperimentalContextDependencies, +) -> dict[str, Any] | None: + """Offer the exact assessed design for one missing-provenance repair.""" + saved_directions = deps.characterizationInputs.get("directions") + tested_batch_sets = { + tuple(sorted(item.batchColumns)) for item in deps.batchSafety.values() + } + if ( + deps.designRounds < len(DESIGN_ROUND_LIMITS) + or deps.captureProposal is not None + or not isinstance(saved_directions, dict) + or len(tested_batch_sets) > 1 + ): + return None + return { + "column_domains": deepcopy(saved_directions.get("columnDomains", {})), + "coefficients_of_interest": list( + saved_directions.get("coefficientsOfInterest", []) + ), + "units_of_inference": deepcopy(saved_directions.get("unitsOfInference", {})), + "batch_columns": list(next(iter(tested_batch_sets), ())), + "proposals": [], + } + + def model_evidence_tool(function: Any) -> Any: """Keep complete tool results in state and send a deduplicated model view.""" @@ -237,8 +267,26 @@ async def invoke(*args: Any, **kwargs: Any) -> dict[str, Any]: result = await function(*args, **kwargs) payload = compact_context_evidence(result) context = args[0] if args else kwargs["ctx"] + repair_inputs = capture_repair_inputs(context.deps) + payload["captureRepairAvailable"] = repair_inputs is not None + if payload["captureRepairAvailable"]: + payload["captureRepairInstructions"] = ( + "analyze_experimental_design can validate capture_proposal using an " + "exact supplied study quote. Keep the assessed domains, coefficients, " + "units and batch columns unchanged and pass proposals=[]. This only " + "adds capture provenance and dependent QC evidence; it does not " + "authorize another covariate comparison round." + ) + payload["captureRepairInputs"] = repair_inputs payload["requestedComparisons"] = [ - {"question": quote, "columns": columns, "conditional": conditional} + { + "question": quote, + "columns": columns, + "conditional": conditional, + "purpose": requested_design_purpose(quote), + "essential": True, + "completionEvidence": "evidenceRequirements and evidenceCoverage", + } for quote, columns, conditional in requested_design_questions( context.deps.studyContext, context.deps.studyObjective, @@ -267,7 +315,7 @@ def restore_context_evidence(deps: ExperimentalContextDependencies) -> bool: if deps.checkpointRead is None: return False restored = False - for key in ("inspection", "design1", "design2"): + for key in ("inspection", "design1", "design2", "capture"): saved = deps.checkpointRead(key) if saved is None: continue @@ -332,6 +380,7 @@ def _prepare_experimental_context_tool( if ( "inspect_cell_covariates" not in completed_calls or ctx.deps.designRounds >= len(DESIGN_ROUND_LIMITS) + and capture_repair_inputs(ctx.deps) is None ): return None return tool_definition @@ -698,9 +747,15 @@ async def analyze_experimental_design( two explanatory columns without conditioning, or one explanatory column with one categorical conditioning column. Observation and independent units do not count toward the three-column limit. - Joint explanations within strata are unsupported. Do not discard a - scientific question or a unit identity just to fit this schema. + Two explanatory columns together with conditioning are unsupported. + One explanatory column within one categorical stratum is supported + subject to observed unit support; descriptive joint counts can remain + available when the association method is unsupported. Do not discard + a scientific question or a unit identity just to fit this schema. capture_proposal: Exact capture and baseline identities supported by study prose. + After two comparison rounds, only this provenance repair is permitted: + keep the assessed design unchanged and pass no proposals. The tool + validates the supplied quote and refreshes dependent QC evidence. """ logger.info( "Experimental Context design analysis started: " @@ -709,9 +764,17 @@ async def analyze_experimental_design( f"inferenceUnits={len(units_of_inference)}, " f"batchColumns={len(batch_columns)}" ) - if ctx.deps.designRounds >= len(DESIGN_ROUND_LIMITS): - raise ModelRetry("Design comparison permits at most two evidence rounds") - if len(proposals or ()) > DESIGN_ROUND_LIMITS[ctx.deps.designRounds]: + capture_repair = ctx.deps.designRounds >= len(DESIGN_ROUND_LIMITS) + if capture_repair and (capture_proposal is None or proposals): + raise ModelRetry( + "Design comparison permits at most two evidence rounds. Only capture " + "provenance can now be repaired: supply capture_proposal, proposals=[], " + "and the exact unchanged assessed design." + ) + if ( + not capture_repair + and len(proposals or ()) > DESIGN_ROUND_LIMITS[ctx.deps.designRounds] + ): raise ModelRetry( "Design comparison permits eight initial and four follow-up proposals" ) @@ -801,7 +864,36 @@ async def analyze_experimental_design( f"Batch column {batch_column!r} must be categorical for Harmony" ) - characterization = characterize_context(ctx.deps, directions) + if capture_repair: + characterization = ctx.deps.characterization + if ( + characterization is None + or directions != ctx.deps.characterizationInputs.get("directions") + ): + raise ModelRetry( + "Capture provenance repair requires the exact saved domains, " + "coefficients and inference units; it cannot change the design." + ) + tested_batch_sets = { + tuple(sorted(item.batchColumns)) for item in ctx.deps.batchSafety.values() + } + if tested_batch_sets != {tuple(canonical_batch_columns)} and ( + tested_batch_sets or canonical_batch_columns + ): + raise ModelRetry( + "Capture provenance repair requires one unambiguous, unchanged " + "assessed batch-column set. Alternative designs cannot be combined." + ) + if ctx.deps.captureProposal is not None: + raise ModelRetry( + "Capture provenance is already committed; reuse its saved evidence." + ) + logger.info( + "Experimental Context capture provenance: validating the supplied study " + "statement and refreshing dependent QC; reusing measured study design" + ) + else: + characterization = characterize_context(ctx.deps, directions) if characterization.status == "failed": rejection = "; ".join(characterization.notes).strip() logger.warning( @@ -818,14 +910,21 @@ async def analyze_experimental_design( # the evidence without rescanning metadata or accepting an unsafe choice. ctx.deps.characterization = characterization try: - evaluate_proposals(ctx.deps, characterization, proposals or ()) + if not capture_repair: + evaluate_proposals(ctx.deps, characterization, proposals or ()) if capture_proposal is not None: accept_capture_proposal(ctx.deps, characterization, capture_proposal) except ValueError as exc: raise ModelRetry(str(exc)) from exc if not ctx.deps.htoIdentityColumns: ctx.deps.htoIdentityColumns = _hto_identity_columns(ctx.deps) - qc_profiles = _offered_qc_profiles(ctx.deps, characterization) + try: + qc_profiles = _offered_qc_profiles(ctx.deps, characterization) + except Exception: + if capture_repair: + ctx.deps.captureProposal = None + characterization.captureProvenance = None + raise contrast_plans = contrast_plans_from_characterization(characterization) ctx.deps.contrastPlans = {plan.coefficient: plan for plan in contrast_plans} evidence_ids = characterization_evidence(characterization) @@ -878,17 +977,23 @@ async def analyze_experimental_design( f"Batch column {batch_column!r} must be categorical for Harmony" ) - batch_safety = _batch_safety_evidence( - ctx.deps, - characterization, - coefficients=directed_coefficients, - batch_columns=canonical_batch_columns, + batch_safety = ( + list(ctx.deps.batchSafety.values()) + if capture_repair + else _batch_safety_evidence( + ctx.deps, + characterization, + coefficients=directed_coefficients, + batch_columns=canonical_batch_columns, + ) ) evidence_ids.update(item.evidenceId for item in batch_safety) ctx.deps.evidenceIds.update(evidence_ids) ctx.deps.toolCalls.append("analyze_experimental_design") - persist_context_evidence(ctx.deps, f"design{ctx.deps.designRounds}") + persist_context_evidence( + ctx.deps, "capture" if capture_repair else f"design{ctx.deps.designRounds}" + ) safety_counts = { status: sum(item.status == status for item in batch_safety) for status in ("safe", "unsafe", "notComputed") diff --git a/scarf/agent/experimental_context/validation.py b/scarf/agent/experimental_context/validation.py index 1661a6e2..3945b062 100644 --- a/scarf/agent/experimental_context/validation.py +++ b/scarf/agent/experimental_context/validation.py @@ -413,6 +413,26 @@ def validate_experimental_context( ) unanswered = unmet_objective_requirements(requirements, coverage) if unanswered: + pending_comparisons = [ + item.question + for item in requirements + if item.requirementId.startswith("requestedDesign:") + and any( + row.requirementId == item.requirementId + and row.status == "unsupported" + for row in coverage + ) + ] + if pending_comparisons and deps.designRounds < 2: + raise ModelRetry( + "Explicit study comparisons have not been measured: " + + "; ".join(pending_comparisons) + + ". Use the remaining analyze_experimental_design round for " + "these questions before finalizing. Match their requested purpose: " + "descriptive crossing/replication is designCoverage, not an " + "association or effect estimate. Preserve unsupported methods " + "as limitations; do not substitute marginal comparisons." + ) validated = validated.model_copy( update={ "needsInput": list( diff --git a/scarf/agent/orchestrator/api.py b/scarf/agent/orchestrator/api.py index fd3fb006..85e12fb5 100644 --- a/scarf/agent/orchestrator/api.py +++ b/scarf/agent/orchestrator/api.py @@ -20,6 +20,7 @@ def analyze_rna( study_objective: str, assay: str | None = None, zarr_path: str | Path | None = None, + score_doublets: bool = False, ) -> AutomatedWorkflowResult: """Choose and explain settings for one RNA assay, then execute them. @@ -32,6 +33,13 @@ def analyze_rna( Work is bounded by the advanced orchestrator's screening and full-cohort limits. Use that interface for explicit workspaces, execution limits, and resumable pauses. An identical repeated call reuses or resumes exact work. + + ``score_doublets=False`` skips advisory doublet scoring when Harmony is + unavailable or prohibited. Harmony-eligible runs retain the matched doublet + diagnostics required by the correction acceptance gate. Scoring does not + remove cells. Changing this option requires a new workflow destination. + If `score_doublets=True`, the workflow will score doublets and save the scores + to the Zarr store. This may consume a lot of additional time. """ if model is None or isinstance(model, str) and not model.strip(): raise ValueError( @@ -48,6 +56,7 @@ def analyze_rna( ) config = AutomatedWorkflowConfig( inputPolicy="unattended", + scoreDoublets=score_doublets, ) result = AgentOrchestrator(model, config=config).run(request) if result.status != "completed": diff --git a/scarf/agent/orchestrator/context.py b/scarf/agent/orchestrator/context.py index df958a48..e60dc757 100644 --- a/scarf/agent/orchestrator/context.py +++ b/scarf/agent/orchestrator/context.py @@ -762,13 +762,21 @@ def find_held_out_references(value: Any) -> None: failed_report = journal.load_stage_report( store, failed, ExperimentalContextResult ) - if cast(ExperimentalContextResult, failed_report).status == "failed": - # A failed model report is evidence of an attempt, not a decision - # to replay. Keep it immutable and address the retry separately. + if cast(ExperimentalContextResult, failed_report).status in { + "failed", + "needsInput", + }: + # An unresolved unattended answer is also an unsuccessful attempt. + # Keep it immutable while reusing its measured design evidence. # A committed successful retry still has a stable recovery key. retry_inputs["retryAfterFailedReport"] = failed.reportReferences[ 0 ].model_dump(mode="json") + if ( + cast(ExperimentalContextResult, failed_report).status + == "needsInput" + ): + prior_context = cast(ExperimentalContextResult, failed_report) logger.info("Retrying experimental context after the previous failure") started = journal._start_attempt( store.zw, @@ -939,13 +947,25 @@ def find_held_out_references(value: Any) -> None: canonical_json_bytes(evidence_inputs) ).hexdigest() ) + result_key = "result" + if "retryAfterFailedReport" in retry_inputs: + result_key += ( + "/" + + hashlib.sha256( + canonical_json_bytes( + retry_inputs["retryAfterFailedReport"] + ) + ).hexdigest() + ) def read_evidence(key: str) -> dict[str, Any] | None: return journal.load_checkpoint( store, prefix, workflow.workflowRunId, - evidence_key + "/" + key, + evidence_key + + "/" + + (result_key if key == "result" else key), evidence_inputs, ) @@ -954,7 +974,9 @@ def write_evidence(key: str, output: dict[str, Any]) -> None: store, prefix, workflow.workflowRunId, - evidence_key + "/" + key, + evidence_key + + "/" + + (result_key if key == "result" else key), evidence_inputs, output, ) @@ -1014,7 +1036,8 @@ def write_evidence(key: str, output: dict[str, Any]) -> None: artifacts=context_artifacts, error=( "The unattended Experimental Context stage returned an " - "unresolved decision" + "unresolved decision: " + + "; ".join(report.decision.needsInput or report.notes) ), notes=report.notes, ) diff --git a/scarf/agent/orchestrator/models.py b/scarf/agent/orchestrator/models.py index 3daa84df..4d15196f 100644 --- a/scarf/agent/orchestrator/models.py +++ b/scarf/agent/orchestrator/models.py @@ -289,6 +289,13 @@ class AutomatedWorkflowConfig(AgentDataModel): default="pause", exclude_if=lambda value: value == "pause", ) + # Older requests always scored doublets. Keep their serialized defaults exact. + scoreDoublets: bool = Field( + default=False, + strict=True, + exclude_if=lambda value: value is True, + description="Score advisory doublets; this run is not eligible for label-based benchmark scoring.", + ) screeningCells: int | None = Field( default=None, ge=20, diff --git a/scarf/agent/orchestrator/rna_tuning.py b/scarf/agent/orchestrator/rna_tuning.py index 5d36a948..29699823 100644 --- a/scarf/agent/orchestrator/rna_tuning.py +++ b/scarf/agent/orchestrator/rna_tuning.py @@ -668,26 +668,41 @@ def _augment_evaluation( ), None, ) - doublets = ( - restore_advisory_doublets( - evaluation, capture_column=self.study.physicalCaptureColumn + doublets = None + if self.request.config.scoreDoublets or ( + self.study.correctionLicense == "safe" and self.batch_columns + ): + doublets = ( + restore_advisory_doublets( + evaluation, capture_column=self.study.physicalCaptureColumn + ) + if preserve_doublets and "doubletNativeGraph" in evaluation.artifacts + else score_advisory_doublets( + self.store, + native or evaluation, + [ + item + for item in [*self.evaluations[scope], evaluation] + if item.artifacts.get("graphFeatures") + == evaluation.artifacts["graphFeatures"] + and item.cellSelection == evaluation.cellSelection + ], + assay=self.handoff.assay, + feature_selection=features, + capture_column=self.study.physicalCaptureColumn, + ) ) - if preserve_doublets and "doubletNativeGraph" in evaluation.artifacts - else score_advisory_doublets( - self.store, - native or evaluation, - [ - item - for item in [*self.evaluations[scope], evaluation] - if item.artifacts.get("graphFeatures") - == evaluation.artifacts["graphFeatures"] - and item.cellSelection == evaluation.cellSelection - ], - assay=self.handoff.assay, - feature_selection=features, - capture_column=self.study.physicalCaptureColumn, + else: + limitation = ( + f"Advisory doublet scoring was not run for assay {self.handoff.assay!r} " + "because score_doublets=False. Doublet contamination was not assessed." ) - ) + evaluation = evaluation.model_copy( + update={ + "warnings": list(dict.fromkeys([*evaluation.warnings, limitation])) + } + ) + logger.info(limitation) evaluation = augment_cluster_evaluations( self.store, [evaluation], @@ -2512,6 +2527,14 @@ def validate(action: TuningAction, *, replay: bool = False) -> TuningAction: if mode == "visual" else "Visual inspection is unavailable: no images were supplied. Do not claim to have seen, inspected or compared plots or images, and do not cite image IDs. Use qualitativeFindings to interpret the reported marker identities, PCA loading genes, feature families and structured diagnostic tables. State limitations and defer if the supplied evidence cannot resolve an essential question." ) + serialized_evidence = json.dumps( + evidence, sort_keys=True, separators=(",", ":") + ) + logger.debug( + f"Analysis assessment payload: {len(serialized_evidence.encode('utf-8')):,} " + f"text bytes, {len(candidates)} candidates, {len(images)} images; " + "all saved scientific evidence retained" + ) try: result = run_agent_sync( model=self.owner.model, @@ -2523,10 +2546,10 @@ def validate(action: TuningAction, *, replay: bool = False) -> TuningAction: ), system_prompt=prompt, user_prompt=build_visual_evidence_prompt( - json.dumps(evidence, sort_keys=True), images + serialized_evidence, images ) if mode == "visual" - else json.dumps(evidence, sort_keys=True), + else serialized_evidence, config=self.request.config.agentRunConfig, name=f"rna_{scope}_assessment", output_validator=validate, diff --git a/scarf/agent/types.py b/scarf/agent/types.py index 29a1c67a..fa878719 100644 --- a/scarf/agent/types.py +++ b/scarf/agent/types.py @@ -132,6 +132,15 @@ class AgentValidationRetry(AgentDataModel): response: dict[str, Any] | str | None = None +class AgentProviderFailure(AgentDataModel): + """One failed model request, distinct from rejected scientific output.""" + + requestIndex: int + statusCode: int | None = None + error: str + retryDelaySeconds: float | None = None + + class AgentRunInfo(AgentDataModel): agentName: str = "" modelName: str = "" @@ -145,6 +154,9 @@ class AgentRunInfo(AgentDataModel): validationRetries: list[AgentValidationRetry] = Field( default_factory=list, exclude_if=lambda value: not value ) + providerFailures: list[AgentProviderFailure] = Field( + default_factory=list, exclude_if=lambda value: not value + ) errorType: str | None = Field(default=None, exclude_if=lambda value: value is None) error: str | None = Field(default=None, exclude_if=lambda value: value is None) diff --git a/tests/test_agent_attempt_audit.py b/tests/test_agent_attempt_audit.py index 2de00a65..348e3e1f 100644 --- a/tests/test_agent_attempt_audit.py +++ b/tests/test_agent_attempt_audit.py @@ -201,9 +201,12 @@ async def respond(_messages: Any, _info: Any) -> ModelResponse: info = caught.value.agent_run_info assert operations == 1 assert requests == 2 - assert info.usage.requests == 1 # The SDK reported only the completed response. + assert info.usage.requests == 2 # Include the failed provider request. assert info.usage.inputTokens == 11 assert info.usage.availability == "partial" + assert len(info.providerFailures) == 1 + assert info.providerFailures[0].requestIndex == 2 + assert info.providerFailures[0].retryDelaySeconds is None assert [call.toolName for call in info.toolCalls] == ["measure"] diff --git a/tests/test_agent_beginner.py b/tests/test_agent_beginner.py index db99b7b0..a443927f 100644 --- a/tests/test_agent_beginner.py +++ b/tests/test_agent_beginner.py @@ -2,6 +2,7 @@ from tests.agent_examples import example +import hashlib from pathlib import Path from types import SimpleNamespace from typing import Any @@ -12,6 +13,7 @@ from scarf.agent import AnalysisError, AutomatedWorkflowResult, analyze_rna from scarf.agent.orchestrator import api, journal +from scarf.agent.orchestrator.main import AgentOrchestrator from scarf.agent.orchestrator.models import ( AutomatedWorkflowConfig, AutomatedWorkflowRequest, @@ -20,6 +22,7 @@ artifact_model_to_ref, ) from scarf.agent.types import ArtifactReferenceModel +from scarf.agent.record_io import canonical_json_bytes def _completed_result(root: Path) -> AutomatedWorkflowResult: @@ -71,6 +74,8 @@ def run(self, request: AutomatedWorkflowRequest) -> AutomatedWorkflowResult: assert called["model"] is model config = called["config"] assert config.inputPolicy == "unattended" + assert config.scoreDoublets is True + assert "scoreDoublets" not in config.model_dump(mode="json") assert config.screeningCells is None assert config.maxScreeningCells == 100_000 assert config.maxScreeningEvaluations == 24 @@ -86,6 +91,111 @@ def run(self, request: AutomatedWorkflowRequest) -> AutomatedWorkflowResult: assert request.ingestDirections == {} +@pytest.mark.parametrize("enabled", [True, False]) +def test_beginner_can_control_advisory_doublet_scoring(monkeypatch, enabled): + observed = [] + outcome = _completed_result(Path("study.zarr")) + + def orchestrator(model, *, config): + observed.append(config) + return SimpleNamespace(run=lambda request: outcome) + + monkeypatch.setattr(api, "AgentOrchestrator", orchestrator) + assert ( + analyze_rna( + "study.h5ad", + model=object(), + study_context="Human blood from independent physical captures.", + study_objective="Discover stable populations.", + score_doublets=enabled, + ) + is outcome + ) + assert observed[0].scoreDoublets is enabled + if not enabled: + assert observed[0].model_dump(mode="json")["scoreDoublets"] is False + assert "scoreDoublets" in observed[0].model_fields_set + + +@pytest.mark.parametrize("value", [None, 0, 1, "false", "true", [], {}]) +def test_doublet_scoring_flag_requires_a_boolean_before_pipeline_work( + monkeypatch, value +): + def forbidden(*args, **kwargs): + pytest.fail("Invalid scoring policy must not start an orchestrator") + + monkeypatch.setattr(api, "AgentOrchestrator", forbidden) + with pytest.raises(ValueError, match="scoreDoublets"): + analyze_rna( + "study.h5ad", + model=object(), + study_context="Human blood.", + study_objective="Discover stable populations.", + score_doublets=value, + ) + with pytest.raises(ValueError, match="scoreDoublets"): + AutomatedWorkflowConfig(scoreDoublets=value) + + +def test_legacy_doublet_policy_preserves_saved_request_bytes_and_checksums(): + legacy_config = AutomatedWorkflowConfig(screeningCells=50_000).model_dump( + mode="json", exclude={"scoreDoublets"} + ) + restored = AutomatedWorkflowConfig.model_validate(legacy_config) + assert restored.scoreDoublets is True + assert restored.model_dump(mode="json") == legacy_config + assert "scoreDoublets" not in AutomatedWorkflowConfig().model_dump(mode="json") + assert "scoreDoublets" not in AutomatedWorkflowConfig( + scoreDoublets=True + ).model_dump(mode="json") + request = example(AutomatedWorkflowRequest).model_dump(mode="json") + saved = { + "recordType": "automatedWorkflowRequest", + "inputIdentity": {"source": "study.h5ad"}, + "modelIdentity": "test-model", + "workflowRunId": "legacy-workflow", + "createdAtNs": 1, + "request": request, + "config": legacy_config, + "requestSha256": hashlib.sha256(canonical_json_bytes(request)).hexdigest(), + "configSha256": hashlib.sha256(canonical_json_bytes(legacy_config)).hexdigest(), + } + saved["contentSha256"] = hashlib.sha256(canonical_json_bytes(saved)).hexdigest() + original = canonical_json_bytes(saved) + loaded = OrchestrationRequestRecord.model_validate_json(original) + assert loaded.config.scoreDoublets is True + assert canonical_json_bytes(loaded.model_dump(mode="json")) == original + assert journal._record_checksum(loaded) == saved["contentSha256"] + assert ( + hashlib.sha256( + canonical_json_bytes(loaded.config.model_dump(mode="json")) + ).hexdigest() + == saved["configSha256"] + ) + + +def test_doublet_policy_cannot_be_changed_while_resuming_saved_work(): + legacy = AutomatedWorkflowConfig.model_validate({"screeningCells": 50_000}) + AgentOrchestrator(object())._validate_resume_config(legacy) + AgentOrchestrator( + object(), config=AutomatedWorkflowConfig(scoreDoublets=True) + )._validate_resume_config(legacy) + with pytest.raises(ValueError, match="execution settings differ"): + AgentOrchestrator( + object(), config=AutomatedWorkflowConfig(scoreDoublets=False) + )._validate_resume_config(legacy) + + disabled = AutomatedWorkflowConfig(scoreDoublets=False) + AgentOrchestrator(object())._validate_resume_config(disabled) + AgentOrchestrator( + object(), config=AutomatedWorkflowConfig(scoreDoublets=False) + )._validate_resume_config(disabled) + with pytest.raises(ValueError, match="execution settings differ"): + AgentOrchestrator( + object(), config=AutomatedWorkflowConfig(scoreDoublets=True) + )._validate_resume_config(disabled) + + @pytest.mark.parametrize("status", ["failed", "abstained", "needsInput"]) def test_beginner_failure_raises_with_resumable_result( monkeypatch: pytest.MonkeyPatch, status: str diff --git a/tests/test_agent_context_capture_repair.py b/tests/test_agent_context_capture_repair.py new file mode 100644 index 00000000..3c7fca14 --- /dev/null +++ b/tests/test_agent_context_capture_repair.py @@ -0,0 +1,247 @@ +"""Capture provenance repair reuses measured designs without reopening comparisons.""" + +import asyncio +from copy import deepcopy +from types import SimpleNamespace + +import pytest +from pydantic_ai import ModelRetry +from pydantic_ai.tools import ToolDefinition + +from scarf.agent.experimental_context import tools +from scarf.agent.experimental_context.contracts import ( + CaptureProposal, + CellQcProfileEvidence, + CovariateComparison, + InferenceUnit, +) +from scarf.agent.types import BatchSafetyEvidence +from tests.test_agent_design_comparisons import _deps, _design, _proposal + + +def _repair_context(monkeypatch): + cells, characterization = _design() + characterization.columns.append( + {"name": "batch", "kind": "categorical", "domain": "technical"} + ) + deps = _deps(cells) + deps.studyContext = "sample identifies the physical capture." + deps.characterization = characterization + deps.designRounds = 2 + deps.toolCalls = [ + "inspect_cell_covariates", + "analyze_experimental_design", + "analyze_experimental_design", + ] + measured = CovariateComparison( + proposal=_proposal(), status="computed", evidenceId="measured-comparison" + ) + deps.comparisons = [measured] + characterization.comparisons = [measured] + deps.batchSafety = { + "measured-safety": BatchSafetyEvidence( + coefficient="response", + batchColumns=["batch"], + status="unsafe", + evidenceId="measured-safety", + ) + } + arguments = { + "column_domains": {"sample": "design", "batch": "technical"}, + "coefficients_of_interest": [], + "units_of_inference": {}, + "batch_columns": ["batch"], + "proposals": [], + "capture_proposal": CaptureProposal( + column="sample", provenanceQuote=deps.studyContext + ), + } + deps.characterizationInputs = { + "directions": { + "columnDomains": dict(arguments["column_domains"]), + "coefficientsOfInterest": [], + "unitsOfInference": {}, + }, + "metadata": {"sample": "unchanged"}, + } + saved = {} + + def write(key, value): + assert key not in saved, "Committed measurements must remain immutable" + saved[key] = deepcopy(value) + + deps.checkpointWrite = write + deps.checkpointRead = saved.get + tools.persist_context_evidence(deps, "design2") + qc_calls = [] + + def qc(current, design): + qc_calls.append(current.captureProposal) + assert design is current.characterization + profile = CellQcProfileEvidence( + profileId="capture-supported", + action="sampleMad", + sampleColumn="sample", + attributes=["counts"], + ) + current.qcProfiles = {profile.profileId: profile} + return [profile] + + monkeypatch.setattr(tools, "_offered_qc_profiles", qc) + for name in ( + "characterize_context", + "evaluate_proposals", + "_batch_safety_evidence", + ): + monkeypatch.setattr( + tools, + name, + lambda *a, **k: pytest.fail("Capture repair must reuse measured design"), + ) + return SimpleNamespace(deps=deps), arguments, saved, qc_calls + + +def test_capture_repair_preserves_design_and_immutable_checkpoints(monkeypatch): + context, arguments, saved, qc_calls = _repair_context(monkeypatch) + before = deepcopy(saved["design2"]) + result = asyncio.run(tools.analyze_experimental_design(context, **arguments)) + assert context.deps.designRounds == 2 + assert len(context.deps.comparisons) == 1 + assert result.characterization.captureProvenance == arguments["capture_proposal"] + assert result.batchSafety[0].status == "unsafe" + assert result.batchSafety[0].batchColumns == ["batch"] + assert len(qc_calls) == 1 + assert set(saved) == {"design2", "capture"} + assert saved["design2"] == before + assert ( + saved["capture"]["characterizationInputs"] == before["characterizationInputs"] + ) + restored = _deps(context.deps.cells) + restored.checkpointRead = saved.get + assert tools.restore_context_evidence(restored) + assert restored.captureProposal == arguments["capture_proposal"] + assert restored.designRounds == 2 + assert restored.comparisons == context.deps.comparisons + assert list(restored.qcProfiles) == ["capture-supported"] + assert len(qc_calls) == 1 + definition = ToolDefinition(name="analyze_experimental_design") + assert ( + tools._prepare_experimental_context_tool( + SimpleNamespace(deps=restored), definition + ) + is None + ) + + +@pytest.mark.parametrize( + "change,reason", + [ + ({"capture_proposal": None}, "two evidence rounds"), + ({"proposals": [_proposal()]}, "two evidence rounds"), + ({"column_domains": {"sample": "technical"}}, "exact saved domains"), + ({"coefficients_of_interest": ["response"]}, "exact saved domains"), + ( + { + "units_of_inference": { + "response": InferenceUnit(observationUnit="sample") + } + }, + "exact saved domains", + ), + ({"batch_columns": []}, "unchanged assessed batch"), + ( + { + "capture_proposal": CaptureProposal( + column="sample", provenanceQuote="sample is an inferred capture" + ) + }, + "exact study quote", + ), + ], +) +def test_capture_repair_rejects_new_work_or_unverified_provenance( + monkeypatch, change, reason +): + context, arguments, saved, qc_calls = _repair_context(monkeypatch) + arguments.update(change) + with pytest.raises(ModelRetry, match=reason): + asyncio.run(tools.analyze_experimental_design(context, **arguments)) + assert context.deps.designRounds == 2 + assert context.deps.captureProposal is None + assert set(saved) == {"design2"} + assert not qc_calls + + +def test_capture_repair_rejects_ambiguous_historical_batch_designs(monkeypatch): + context, arguments, saved, qc_calls = _repair_context(monkeypatch) + context.deps.batchSafety["alternative"] = BatchSafetyEvidence( + coefficient="response", batchColumns=["other"], status="safe" + ) + with pytest.raises(ModelRetry, match="Alternative designs cannot be combined"): + asyncio.run(tools.analyze_experimental_design(context, **arguments)) + assert not qc_calls + assert set(saved) == {"design2"} + + +def test_failed_capture_qc_refresh_does_not_authorize_incomplete_evidence(monkeypatch): + context, arguments, saved, _ = _repair_context(monkeypatch) + + def failed(*args): + raise RuntimeError("Capture quality measurements unavailable") + + monkeypatch.setattr(tools, "_offered_qc_profiles", failed) + with pytest.raises(RuntimeError, match="quality measurements unavailable"): + asyncio.run(tools.analyze_experimental_design(context, **arguments)) + assert context.deps.captureProposal is None + assert context.deps.characterization.captureProvenance is None + assert context.deps.designRounds == 2 + assert set(saved) == {"design2"} + + +def test_model_summary_exposes_provenance_repair_after_exhausted_design_rounds( + monkeypatch, +): + context, arguments, _, _ = _repair_context(monkeypatch) + definition = ToolDefinition(name="analyze_experimental_design") + assert tools._prepare_experimental_context_tool(context, definition) is definition + from scarf.agent.experimental_context.contracts import CovariateEvidence + + async def measured(ctx): + return CovariateEvidence(characterization=ctx.deps.characterization) + + before = asyncio.run(tools.model_evidence_tool(measured)(context)) + assert before["captureRepairAvailable"] is True + assert "proposals=[]" in before["captureRepairInstructions"] + assert before["captureRepairInputs"] == { + key: value for key, value in arguments.items() if key != "capture_proposal" + } + asyncio.run(tools.analyze_experimental_design(context, **arguments)) + after = asyncio.run(tools.model_evidence_tool(measured)(context)) + assert after["captureRepairAvailable"] is False + assert "captureRepairInstructions" not in after + + +@pytest.mark.parametrize( + "quote,purpose", + [ + ("Assess combined covariates.", "designCoverage"), + ("Assess joint association of covariates.", "association"), + ("Estimate joint effects of covariates.", "effectEstimation"), + ], +) +def test_requested_question_summary_preserves_its_required_purpose( + monkeypatch, quote, purpose +): + from scarf.agent.experimental_context.contracts import CovariateEvidence + + context, _, _, _ = _repair_context(monkeypatch) + context.deps.studyObjective = quote + + async def measured(ctx): + return CovariateEvidence(characterization=ctx.deps.characterization) + + payload = asyncio.run(tools.model_evidence_tool(measured)(context)) + question = payload["requestedComparisons"][0] + assert question["purpose"] == purpose + assert question["essential"] is True + assert question["completionEvidence"] == "evidenceRequirements and evidenceCoverage" diff --git a/tests/test_agent_context_failure_repair.py b/tests/test_agent_context_failure_repair.py new file mode 100644 index 00000000..5f0ac21c --- /dev/null +++ b/tests/test_agent_context_failure_repair.py @@ -0,0 +1,40 @@ +"""Unmeasured study questions are repaired before the design allowance closes.""" + +import asyncio + +import pytest +from pydantic_ai import ModelRetry + +from scarf.agent.experimental_context import tools, validation +from tests.test_agent_experimental_context import _Store, _context, _design_decision + + +def test_pending_joint_question_requests_followup_then_remains_unresolved(monkeypatch): + context = _context(_Store()) + context.deps.studyContext = "Assess individual and combined covariates." + context.deps.studyObjective = "Describe disease populations." + decision = _design_decision() + asyncio.run(tools.inspect_cell_covariates(context)) + arguments = { + "column_domains": decision.columnDomains, + "coefficients_of_interest": decision.coefficientsOfInterest, + "units_of_inference": decision.unitsOfInference, + "batch_columns": decision.batchCorrection.batchColumns, + } + asyncio.run(tools.analyze_experimental_design(context, **arguments)) + monkeypatch.setattr( + tools, + "characterize_covariates", + lambda *a, **k: pytest.fail( + "Output repair must reuse measured characterization" + ), + ) + with pytest.raises(ModelRetry, match="remaining analyze_experimental_design round"): + validation.validate_experimental_context(decision, context.deps) + assert context.deps.designRounds == 1 + # Repeating marginal evidence cannot answer the pending combined question. + asyncio.run(tools.analyze_experimental_design(context, **arguments)) + result = validation.validate_experimental_context(decision, context.deps) + assert context.deps.designRounds == 2 + assert any("combined covariates" in question for question in result.needsInput) + assert result.batchCorrection.action == "unsafe" diff --git a/tests/test_agent_context_resume_boundaries.py b/tests/test_agent_context_resume_boundaries.py index ecc9acc7..2c17e8b9 100644 --- a/tests/test_agent_context_resume_boundaries.py +++ b/tests/test_agent_context_resume_boundaries.py @@ -1,6 +1,7 @@ """Committed context decisions and bounded details retain exact scientific inputs.""" import asyncio +import json from types import SimpleNamespace import pytest @@ -61,12 +62,19 @@ def test_committed_context_result_replays_without_a_model_and_rejects_other_sele @pytest.mark.parametrize("rounds", [1, 2]) +@pytest.mark.parametrize("previous_status", ["done", "needsInput"]) +@pytest.mark.parametrize( + "objective", + ["", "Discover populations while preserving condition."], + ids=["without-objective", "with-objective"], +) def test_explicit_context_revision_preserves_rounds_and_complete_study_text( - monkeypatch, rounds + monkeypatch, rounds, previous_status, objective ): store = _Store() previous = ExperimentalContextResult.get_blank().model_copy( update={ + "status": previous_status, "cellSelection": ArtifactReferenceModel.from_artifact_ref( store.cell_selection ), @@ -83,10 +91,19 @@ def test_explicit_context_revision_preserves_rounds_and_complete_study_text( ), } ) + blocker = "Confirm whether the supplied batch identifies physical capture." + if previous_status == "needsInput": + previous.decision.needsInput = [blocker] + saved_previous = previous.model_dump(mode="json") text = "Study provenance. " * 150 + "Assess condition and batch jointly." monkeypatch.setattr( context_agent, "_derive_missing_percentage_artifacts", lambda *a, **k: [] ) + monkeypatch.setattr( + context_agent, + "characterize_covariates", + lambda *a, **k: pytest.fail("A revision must reuse measured characterization"), + ) seen = [] def inspect(**kwargs): @@ -96,6 +113,35 @@ def inspect(**kwargs): assert text in kwargs["user_prompt"] assert "Committed evidence already measured" in kwargs["user_prompt"] assert "Explicit requested questions" in kwargs["user_prompt"] + assert ( + f"Study objective: {objective or 'not provided'}" in kwargs["user_prompt"] + ) + if previous_status == "needsInput": + prompt = kwargs["user_prompt"] + assert blocker in prompt + assert "Keep genuinely essential unresolved questions" in prompt + evidence_json = prompt.split( + "Current objective requirements and their measured support: ", 1 + )[1].split("\nThe previous interpretation stopped", 1)[0] + evidence = json.loads(evidence_json) + if objective: + joint = next( + item + for item in evidence["evidenceRequirements"] + if item["question"] == "Assess condition and batch jointly" + ) + assert joint["columns"] == ["batch", "condition"] + assert joint["essential"] is True + measured = { + item["requirementId"]: item for item in evidence["evidenceCoverage"] + } + assert measured[joint["requirementId"]]["status"] == "unsupported" + assert "marginal comparisons do not answer" in " ".join( + measured[joint["requirementId"]]["reasons"] + ) + assert measured["studyDesign"]["status"] == "computed" + else: + assert evidence == {"evidenceRequirements": [], "evidenceCoverage": []} raise UnexpectedModelBehavior("No additional provider attempt is available") monkeypatch.setattr(context_agent, "run_agent_sync", inspect) @@ -103,11 +149,12 @@ def inspect(**kwargs): store, cell_selection=store.cell_selection, study_context=text, + study_objective=objective, previous_context=previous, ) assert result.status == "failed" assert seen == [rounds] - assert previous.runInfo.toolCalls[0].toolName == "analyze_experimental_design" + assert previous.model_dump(mode="json") == saved_previous def test_saved_details_return_complete_inventory_and_policy_without_computation(): diff --git a/tests/test_agent_design_comparisons.py b/tests/test_agent_design_comparisons.py index 85d4475e..ca70993a 100644 --- a/tests/test_agent_design_comparisons.py +++ b/tests/test_agent_design_comparisons.py @@ -426,7 +426,7 @@ async def reply(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: proposal_schema = schema["$defs"]["CovariateProposal"] properties = proposal_schema["properties"] assert ( - "joint comparison within strata is unsupported" + "Two explanatory columns plus conditioning are unsupported" in proposal_schema["description"] ) assert ( diff --git a/tests/test_agent_finalization_doublet_policy.py b/tests/test_agent_finalization_doublet_policy.py new file mode 100644 index 00000000..7c5f49f3 --- /dev/null +++ b/tests/test_agent_finalization_doublet_policy.py @@ -0,0 +1,217 @@ +"""Finalization distinguishes disabled scoring from missing required evidence.""" + +from types import SimpleNamespace + +from scarf.agent.orchestrator import finalization +from scarf.agent.orchestrator.models import ( + AssayPreprocessingPlan, + AutomatedPreprocessingPlan, + AutomatedWorkflowConfig, + AutomatedWorkflowRequest, + OrchestrationRequestRecord, + PreprocessedAssayHandoff, + StageEvidenceReference, + WorkflowIdentity, + WorkflowStageAttempt, + artifact_model_to_ref, +) +from scarf.agent.parameter_tuning.contracts import ( + ArtifactRecord, + ParameterCandidate, + ParameterCandidateEvaluation, + ParameterMetrics, + ParameterTuningReport, +) + +from tests.agent_examples import example + + +def _fixture(monkeypatch, *, scored=False, warning=None): + sequence = iter(range(1, 30)) + + def reference(kind, *, assay="RNA"): + return ArtifactRecord( + kind=kind, + assay=assay, + scope="datastore" if assay is None else "assay", + artifactId=f"{next(sequence):064x}", + ) + + cells = reference("cell_selection", assay=None) + normalized = reference("normalized") + marker_features = reference("feature_selection") + artifacts = { + "pca": reference("reduction"), + "connectivityMap": reference("connectivity_map"), + "clusters": reference("cluster_labels"), + "markerTable": reference("marker_table"), + } + selection = reference("cell_selection", assay=None) + score = reference("doublet_score") + if scored: + artifacts.update({"doubletScore:0": score, "doubletCellSelection:0": selection}) + selected = ParameterCandidateEvaluation( + candidateId="selected", + parameters=ParameterCandidate(candidateId="selected"), + status="done", + eligible=True, + cellSelection=cells, + artifacts=artifacts, + metrics=ParameterMetrics(nClusters=3, seedStability=0.95, markerCoherence=1), + warnings=[] if warning is None else [warning], + ) + report = ParameterTuningReport( + status="done", + fromAssay="RNA", + cellSelection=cells, + evaluations=[selected], + recommendedCandidateId=selected.candidateId, + finalClusterArtifact=artifacts["clusters"], + ) + statuses = {} + + def bind(ref, **inputs): + statuses[artifact_model_to_ref(ref)] = SimpleNamespace( + exists=True, + complete=True, + inputs={ + name: artifact_model_to_ref(value).to_dict() + for name, value in inputs.items() + }, + ) + + bind(normalized, cell_selection=cells) + bind( + artifacts["clusters"], cell_selection=cells, graph=artifacts["connectivityMap"] + ) + bind(artifacts["markerTable"], cell_selection=cells, clusters=artifacts["clusters"]) + bind(score, cell_selection=selection) + layouts = [] + + def initialize(ref, **kwargs): + layouts.append(("initialize", ref)) + return artifact_model_to_ref(reference("embedding_initialization")) + + def umap(ref, initialization, **kwargs): + layouts.append(("umap", ref)) + return artifact_model_to_ref(reference("embedding")) + + store = SimpleNamespace( + zw=None, + summary=lambda: SimpleNamespace( + assays=[SimpleNamespace(name="RNA", assay_type="RNA")] + ), + inspect_artifact=statuses.__getitem__, + load_artifact=lambda ref: {}, + build_embedding_initialization=initialize, + run_umap=umap, + ) + monkeypatch.setattr( + finalization, + "graph_cell_selection", + lambda root, graph: artifact_model_to_ref(cells), + ) + monkeypatch.setattr( + finalization.journal, "_ensure_orchestration_store", lambda store: "agents" + ) + monkeypatch.setattr( + finalization.journal, "_validated_done_outcome", lambda *args: None + ) + monkeypatch.setattr( + finalization.journal, + "_start_attempt", + lambda *args, **kwargs: WorkflowStageAttempt(stage="analysis_finalization"), + ) + monkeypatch.setattr(finalization.journal, "_save_outcome", lambda *args: None) + monkeypatch.setattr( + finalization.journal, + "read_stage_evidence", + lambda *args: report.model_dump(mode="json"), + ) + record = OrchestrationRequestRecord( + inputIdentity={}, + modelIdentity="test", + request=example(AutomatedWorkflowRequest).model_copy( + update={ + "primaryAssay": "RNA", + "markerAssay": "RNA", + "analysisAssays": ["RNA"], + } + ), + config=AutomatedWorkflowConfig(), + ) + plan = AutomatedPreprocessingPlan( + primaryAssay="RNA", + markerAssay="RNA", + cellSelection=cells, + assays=[ + AssayPreprocessingPlan(assay="RNA", assayType="RNA", graphEligible=True) + ], + ) + handoff = PreprocessedAssayHandoff( + assay="RNA", + assayType="RNA", + cellSelection=cells, + normalized=normalized, + markerFeatures=marker_features, + nCells=30, + ) + + def run(): + return finalization.FinalizationStagesMixin().analysis_finalization_stage( + store, + WorkflowIdentity(workflowRunId="test-workflow"), + record, + [], + plan, + [handoff], + report, + StageEvidenceReference( + workflowRunId="test-workflow", + stage="parameter_tuning", + key="report", + contentSha256="0" * 64, + ), + ) + + return SimpleNamespace( + run=run, + selected=selected, + score=score, + selection=selection, + layouts=layouts, + artifacts=artifacts, + ) + + +def test_explicitly_disabled_doublets_finalize_with_visible_limitation(monkeypatch): + warning = ( + "Advisory doublet scoring was not run for assay 'RNA' because " + "score_doublets=False. Doublet contamination was not assessed." + ) + fixture = _fixture(monkeypatch, warning=warning) + outcome, final = fixture.run() + assert outcome.status == "done", outcome.error + assert final.doubletScores == final.doubletScoreSelections == [] + assert warning in final.limitations + assert warning in outcome.notes + assert [name for name, ref in fixture.layouts] == ["initialize", "umap"] + assert final.markers.artifactId == fixture.artifacts["markerTable"].artifactId + assert fixture.selected.metrics.seedStability == 0.95 + + +def test_missing_doublets_without_a_recorded_limitation_prevent_completion(monkeypatch): + fixture = _fixture(monkeypatch) + outcome, final = fixture.run() + assert outcome.status == "failed" + assert "lacks advisory doublet scores" in outcome.error + assert final.umap is None + + +def test_existing_scores_retain_exact_capture_selection(monkeypatch): + fixture = _fixture(monkeypatch, scored=True) + outcome, final = fixture.run() + assert outcome.status == "done", outcome.error + assert final.doubletScores[0].artifactId == fixture.score.artifactId + assert final.doubletScoreSelections[0].artifactId == fixture.selection.artifactId + assert final.doubletScoreSelections[0] != final.cellSelection diff --git a/tests/test_agent_joint_design_recovery.py b/tests/test_agent_joint_design_recovery.py new file mode 100644 index 00000000..0509baf0 --- /dev/null +++ b/tests/test_agent_joint_design_recovery.py @@ -0,0 +1,211 @@ +"""Saved descriptive joint measurements answer design questions, not effects.""" + +from copy import deepcopy +from types import SimpleNamespace + +import pandas as pd +import pytest + +from scarf.agent.experimental_context.comparisons import compare_covariates +from scarf.agent.experimental_context.contracts import CovariateProposal +from scarf.agent.experimental_context.requirements import ( + objective_evidence, + unmet_objective_requirements, +) +from tests.test_agent_objective_requirements import _repeated_design + +CONTEXT = "The observations contain repeated donors and incomplete pairing." +REQUEST = "Assess individual and combined covariates, replication, and confounding" + + +def _joint_result(*, essential=False): + result = _repeated_design() + observations = pd.DataFrame( + { + "sample": ["a0", "b0", "a1", "b2", "a3", "b3"], + "donor": ["d0", "d0", "d1", "d2", "d3", "d3"], + "tissue": ["A", "B", "A", "B", "A", "B"], + "condition": ["yes", "yes", "no", "no", "yes", "yes"], + "sex": ["female", "female", "female", "male", "female", "female"], + } + ) + cells = observations.loc[observations.index.repeat(3)].reset_index(drop=True) + result.characterization.columns.append( + {"name": "sex", "kind": "categorical", "domain": "biological"} + ) + proposal = CovariateProposal( + response="tissue", + explanatoryColumns=["condition"], + conditionedOn="sex", + observationUnit="sample", + independentUnit="donor", + rationale="Assess joint tissue and condition support within sex groups.", + purpose="designCoverage", + essential=essential, + objectiveQuote=CONTEXT, + ) + result.characterization.comparisons = [ + compare_covariates( + SimpleNamespace( + columns=list(cells.columns), fetch=lambda name: cells[name].to_numpy() + ), + result.characterization, + proposal, + selection_identity={"cells": "fixed"}, + ) + ] + return result + + +def _evidence(result, request=REQUEST): + return objective_evidence( + study_context=f"{CONTEXT} {request}", + study_objective="Describe supported populations", + experimental_result=result, + ) + + +@pytest.mark.parametrize("essential", [False, True]) +def test_joint_counts_recover_descriptive_requirement_without_erasing_limitations( + essential, +): + result = _joint_result(essential=essential) + comparison = result.characterization.comparisons[0] + original = comparison.model_dump(mode="json") + assert comparison.status == "unsupported" + assert comparison.evidence["descriptiveDesign"]["observationUnits"] == 6 + assert comparison.evidence["descriptiveDesign"]["independentUnits"] == 4 + assert comparison.evidence["missingCells"] == 0 + assert ( + comparison.evidence["descriptiveDesign"]["pairedCoverage"]["tissue"][ + "completePairs" + ] + == 2 + ) + + requirements, coverage = _evidence(result) + + assert len(requirements) == 2 + assert requirements[1].essential + assert requirements[1].kind == "designCoverage" + assert coverage[1].status == "computed" + assert coverage[1].evidenceIds == [comparison.evidenceId] + assert "withinIndependentUnitComparisonsAreUnsupported" in coverage[1].reasons + assert any( + "association method remains unsupported" in x for x in coverage[1].reasons + ) + assert not unmet_objective_requirements(requirements, coverage) + assert comparison.model_dump(mode="json") == original + + +@pytest.mark.parametrize( + "damage", + [ + "missingTable", + "emptyTable", + "notComputed", + "missingColumn", + "missingGroup", + "invalidUnitCount", + "fractionalUnitCount", + "changedKind", + "changedDomain", + "missingKinds", + ], +) +def test_joint_descriptive_recovery_requires_exact_completed_measurements(damage): + result = _joint_result() + comparison = result.characterization.comparisons[0] + table = comparison.evidence["descriptiveDesign"] + if damage == "missingTable": + table.pop("jointGroupSupport") + elif damage == "emptyTable": + table["jointGroupSupport"] = [] + elif damage == "notComputed": + table["status"] = "unsupported" + elif damage == "missingColumn": + for row in table["jointGroupSupport"]: + row["groups"].pop("sex") + elif damage == "missingGroup": + table["jointGroupSupport"].pop() + elif damage == "invalidUnitCount": + table["jointGroupSupport"][0]["independentUnits"] = 100 + elif damage == "fractionalUnitCount": + table["jointGroupSupport"][0]["independentUnits"] = 1.5 + elif damage in {"changedKind", "changedDomain"}: + field, value = ( + ("kind", "continuous") if damage == "changedKind" else ("domain", "ignore") + ) + result.characterization.columns[-1][field] = value + else: + comparison.evidence.pop("columnKinds") + + requirements, coverage = _evidence(result) + + pending = [ + r for r in requirements if r.requirementId.startswith("requestedDesign:") + ] + assert len(pending) == 1 + assert pending[0].essential + assert unmet_objective_requirements(requirements, coverage) + + +def test_marginal_counts_cannot_replace_a_joint_design(): + requirements, coverage = _evidence(_repeated_design()) + assert any(r.requirementId.startswith("requestedDesign:") for r in requirements) + assert unmet_objective_requirements(requirements, coverage) + + +@pytest.mark.parametrize( + "question, purpose", + [ + ( + "Assess joint associations of tissue and condition within sex", + "designCoverage", + ), + ("Assess joint associations of tissue and condition within sex", "association"), + ( + "Estimate combined effects of tissue and condition within sex", + "designCoverage", + ), + ( + "Estimate combined effects of tissue and condition within sex", + "effectEstimation", + ), + ], +) +def test_descriptive_recovery_does_not_answer_an_association_or_effect( + question, purpose +): + result = _joint_result(essential=True) + result.characterization.comparisons[0].proposal.purpose = purpose + requirements, coverage = _evidence(result, question) + assert unmet_objective_requirements(requirements, coverage) + + +def test_joint_descriptive_recovery_does_not_ignore_a_requested_column(): + result = _joint_result() + result.characterization.columns.append( + {"name": "age", "kind": "continuous", "domain": "biological"} + ) + requirements, coverage = _evidence( + result, "Assess tissue and age jointly within sex groups" + ) + assert any(r.requirementId.startswith("requestedDesign:") for r in requirements) + assert unmet_objective_requirements(requirements, coverage) + + +def test_reusing_joint_measurements_does_not_duplicate_bounded_requirements(): + result = _joint_result() + comparison = result.characterization.comparisons[0] + result.characterization.comparisons = [] + quotes = [f"Optional observation {i}" for i in range(12)] + for index, quote in enumerate(quotes): + item = deepcopy(comparison) + item.proposal.objectiveQuote = quote + item.evidenceId = f"measured:{index}" + result.characterization.comparisons.append(item) + requirements, coverage = _evidence(result, ". ".join([*quotes, REQUEST])) + assert len(requirements) == len(coverage) == 13 + assert all(r.essential for r in requirements) + assert not unmet_objective_requirements(requirements, coverage) diff --git a/tests/test_agent_notebook_interrupt.py b/tests/test_agent_notebook_interrupt.py index c3eb0dfb..3174bd81 100644 --- a/tests/test_agent_notebook_interrupt.py +++ b/tests/test_agent_notebook_interrupt.py @@ -94,7 +94,7 @@ async def notebook() -> BaseException: assert attempts[0].status == "failed" assert attempts[0].usage.availability == "partial" assert attempts[0].usage.inputTokens == 11 - assert attempts[0].usage.requests == 1 + assert attempts[0].usage.requests == 2 assert attempts[0].errorType == "CancelledError" if callback_fails: assert "Saving cancellation evidence failed" in agent_exec.describe_agent_error( diff --git a/tests/test_agent_orchestrator_stages.py b/tests/test_agent_orchestrator_stages.py index 3e5b36c2..45750d0d 100644 --- a/tests/test_agent_orchestrator_stages.py +++ b/tests/test_agent_orchestrator_stages.py @@ -26,9 +26,14 @@ from scarf.agent.experimental_context import ( CellQcPlan, CellQcProfileEvidence, + ExperimentalContextDependencies, ExperimentalContextResult, NamedArtifactSource, ) +from scarf.agent.experimental_context.tools import ( + persist_context_evidence, + restore_context_evidence, +) from scarf.agent.orchestrator import ( AgentOrchestrator, AutomatedWorkflowConfig, @@ -417,10 +422,12 @@ def run(self, *_args: Any, **_kwargs: Any) -> ExperimentalContextResult: @pytest.mark.parametrize("after_commit", ["none", "interrupt", "exception"]) +@pytest.mark.parametrize("report_status", ["failed", "needsInput"]) def test_failed_context_retries_without_overwriting_or_replaying_failure( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, after_commit: str, + report_status: str, ) -> None: path = create_store(tmp_path / "retry-context.zarr") store = DataStore(str(path), default_assay="RNA", min_features_per_cell=0) @@ -455,10 +462,14 @@ def test_failed_context_retries_without_overwriting_or_replaying_failure( } ) report.decision.batchCorrection.action = "unsafe" + unresolved_question = "Assess the joint tissue and treatment contrast by donor." failed_report = report.model_copy( - update={"status": "failed", "notes": ["Invalid design proposal"]}, deep=True + update={"status": report_status, "notes": ["Invalid design proposal"]}, + deep=True, ) failed_report.decision.batchCorrection.action = "needsInput" + failed_report.decision.needsInput = [unresolved_question] + evidence_readers = [] class RecoveringAgent: calls = 0 @@ -466,9 +477,38 @@ class RecoveringAgent: def __init__(self, *_args: Any, **_kwargs: Any) -> None: pass - def run(self, *_args: Any, **_kwargs: Any) -> ExperimentalContextResult: + def run(self, *_args: Any, **kwargs: Any) -> ExperimentalContextResult: type(self).calls += 1 - return failed_report if self.calls <= 2 else report + read = kwargs["checkpoint_read"] + evidence_readers.append(read) + # Every new interpretation has its own result, while the complete + # measured design and its consumed allowance remain shared. + assert read("result") is None + deps = ExperimentalContextDependencies( + checkpointRead=read, + checkpointWrite=kwargs["checkpoint_write"], + ) + if self.calls == 1: + assert not restore_context_evidence(deps) + deps.characterization = report.characterization + deps.toolCalls = ["inspect_cell_covariates"] + persist_context_evidence(deps, "inspection") + deps.designRounds = 1 + deps.toolCalls.append("analyze_experimental_design") + deps.evidenceIds.add("saved-design-evidence") + persist_context_evidence(deps, "design1") + else: + assert restore_context_evidence(deps) + assert deps.characterization == report.characterization + assert deps.designRounds == 1 + assert deps.evidenceIds == {"saved-design-evidence"} + assert deps.toolCalls.count("analyze_experimental_design") == 1 + current = failed_report if self.calls <= 2 else report + kwargs["checkpoint_write"]( + "result", {"report": current.model_dump(mode="json")} + ) + assert read("result")["report"]["status"] == current.status + return current monkeypatch.setattr(context_module, "ExperimentalContextAgent", RecoveringAgent) orchestrator = AgentOrchestrator(object()) @@ -481,6 +521,9 @@ def execute(): first, _ = execute() second, _ = execute() assert first.status == second.status == "failed" + if report_status == "needsInput": + assert unresolved_question in first.error + assert unresolved_question in second.error assert RecoveringAgent.calls == 2 assert first.reportReferences != second.reportReferences assert second.inputs["retryAfterFailedReport"] == first.reportReferences[ @@ -520,6 +563,15 @@ def interrupt(_group, _prefix, outcome): assert resumed_report.status == "done" assert resumed_report.decision.batchCorrection.action == "unsafe" assert RecoveringAgent.calls == 3 + assert [read("result")["report"]["status"] for read in evidence_readers] == [ + report_status, + report_status, + "done", + ] + for key in ("inspection", "design1"): + assert all( + read(key) == evidence_readers[0](key) for read in evidence_readers[1:] + ) if after_commit != "none": assert "recover_persisted_experimental_context_report" in resumed.actions assert ( @@ -528,7 +580,7 @@ def interrupt(_group, _prefix, outcome): ) assert ( journal_module.read_stage_evidence(store, second.reportReferences[0])["status"] - == "failed" + == report_status ) diff --git a/tests/test_agent_rate_limit_recovery.py b/tests/test_agent_rate_limit_recovery.py new file mode 100644 index 00000000..e561b2cf --- /dev/null +++ b/tests/test_agent_rate_limit_recovery.py @@ -0,0 +1,399 @@ +"""Transport throttling replays one request without repeating scientific tools.""" + +import asyncio +from datetime import datetime, timedelta, timezone +from email.utils import format_datetime + +import httpx +import pytest +from pydantic_ai.exceptions import ModelHTTPError, UsageLimitExceeded +from pydantic_ai.messages import ModelResponse, ToolCallPart +from pydantic_ai.models.function import FunctionModel +from pydantic_ai.usage import RequestUsage + +from scarf.agent.config import AgentRunConfig, agent_exec +from scarf.agent.types import AgentDataModel, AgentRunInfo + + +class Choice(AgentDataModel): + value: int + + +@pytest.fixture +def recorded_waits(monkeypatch): + waits = [] + original_sleep = asyncio.sleep + + async def sleep(delay): + waits.append(delay) + await original_sleep(0) + + monkeypatch.setattr(agent_exec.asyncio, "sleep", sleep) + return waits + + +def _response(info, value=2): + return ModelResponse( + parts=[ToolCallPart(info.output_tools[0].name, {"value": value})], + usage=RequestUsage(input_tokens=11, output_tokens=3), + ) + + +@pytest.mark.parametrize("host", ["sync", "async", "notebook"]) +def test_rate_limit_retries_same_request_without_reexecuting_tools( + recorded_waits, host +): + calls = [] + tools = [] + attempts = [] + + async def measure(): + tools.append("measured") + return {"measured": 2} + + async def respond(messages, info): + calls.append(messages) + if len(calls) == 1: + return ModelResponse( + parts=[ToolCallPart("measure", {}, tool_call_id="measurement")], + usage=RequestUsage(input_tokens=7, output_tokens=2), + ) + if len(calls) in {2, 3}: + raise ModelHTTPError(429, "test", "Too Many Requests") + return _response(info) + + kwargs = dict( + model=FunctionModel(respond), + output_type=Choice, + system_prompt="Use the completed measurement.", + user_prompt="Measure once and select.", + tools=[measure], + on_attempt=attempts.append, + ) + if host == "sync": + result = agent_exec.run_agent_sync(**kwargs) + elif host == "async": + result = asyncio.run(agent_exec.run_agent_async(**kwargs)) + else: + + async def notebook(): + return agent_exec.run_agent_sync(**kwargs) + + result = asyncio.run(notebook()) + + assert result.output.value == 2 + assert len(calls) == 4 + assert calls[1] is calls[2] is calls[3] + assert tools == ["measured"] + assert recorded_waits == [15, 30] + assert attempts == [result.runInfo] + assert result.runInfo.usage.requests == 4 + assert result.runInfo.usage.toolCalls == 1 + assert result.runInfo.usage.inputTokens == 18 + assert result.runInfo.usage.outputTokens == 5 + assert result.runInfo.usage.availability == "partial" + assert [item.requestIndex for item in result.runInfo.providerFailures] == [2, 3] + assert all(item.statusCode == 429 for item in result.runInfo.providerFailures) + assert result.runInfo.validationRetries == [] + + +@pytest.mark.parametrize( + "status, body", + [ + (429, {"error": {"code": "insufficient_quota"}}), + (429, "You exceeded your current quota"), + (429, "Insufficient credits"), + (429, "Billing limit reached"), + (429, "Daily limit reached"), + (401, "Unauthorized"), + (400, "This model does not support multimodal inputs"), + (503, "Service unavailable"), + ], +) +def test_nontransient_provider_errors_are_recorded_without_retry( + recorded_waits, status, body +): + error = ModelHTTPError(status, "test", body) + requests = [] + + async def fail(messages, info): + requests.append(messages) + raise error + + with pytest.raises(ModelHTTPError) as caught: + agent_exec.run_agent_sync( + model=FunctionModel(fail), + output_type=Choice, + system_prompt="Use evidence.", + user_prompt="Assess evidence.", + ) + assert caught.value is error + assert len(requests) == 1 + assert recorded_waits == [] + saved = error.agent_run_info + assert saved.usage.requests == 1 + assert saved.usage.availability == "unavailable" + assert len(saved.providerFailures) == 1 + assert saved.providerFailures[0].retryDelaySeconds is None + assert saved.validationRetries == [] + + +@pytest.mark.parametrize( + "source", ["direct", "cause", "date", "past", "zero", "invalid"] +) +def test_retry_after_headers_are_preserved(recorded_waits, source): + calls = [] + wait = ( + format_datetime( + datetime.now(timezone.utc) + + timedelta(seconds=40 if source == "date" else -40) + ) + if source in {"date", "past"} + else "0" + if source == "zero" + else "invalid" + if source == "invalid" + else "7" + ) + error = ModelHTTPError( + 429, + "test", + "Too Many Requests", + headers={"Retry-After": wait} if source != "cause" else None, + ) + if source == "cause": + response = httpx.Response(429, headers={"Retry-After": wait}) + original = RuntimeError("Throttled request") + original.response = response + error.__cause__ = original + + async def respond(messages, info): + calls.append(messages) + if len(calls) == 1: + raise error + return _response(info) + + result = agent_exec.run_agent_sync( + model=FunctionModel(respond), + output_type=Choice, + system_prompt="Use evidence.", + user_prompt="Assess evidence.", + ) + assert len(calls) == 2 + if source == "date": + assert 30 < recorded_waits[0] <= 40 + elif source in {"past", "zero"}: + assert recorded_waits == [0] + else: + assert recorded_waits == [15 if source == "invalid" else 7] + assert result.runInfo.providerFailures[0].retryDelaySeconds == recorded_waits[0] + + +@pytest.mark.parametrize( + "header, request_limit, expected_calls, expected_waits", + [ + (None, 10, 4, [15, 30, 60]), + ("80", 10, 2, [80]), + ("121", 10, 1, []), + (None, 2, 2, [15]), + ], +) +def test_retries_respect_attempt_wait_and_request_limits( + recorded_waits, header, request_limit, expected_calls, expected_waits +): + calls = [] + error = ModelHTTPError( + 429, + "test", + "Too Many Requests", + headers={"Retry-After": header} if header else None, + ) + + async def fail(messages, info): + calls.append(messages) + raise error + + with pytest.raises(ModelHTTPError) as caught: + agent_exec.run_agent_sync( + model=FunctionModel(fail), + output_type=Choice, + system_prompt="Use evidence.", + user_prompt="Assess evidence.", + config=AgentRunConfig(requestLimit=request_limit), + ) + assert caught.value is error + assert len(calls) == expected_calls + assert recorded_waits == expected_waits + info = error.agent_run_info + assert info.usage.requests == expected_calls + assert len(info.providerFailures) == expected_calls + assert info.providerFailures[-1].retryDelaySeconds is None + assert info.usage.availability == "unavailable" + + +def test_rate_limit_wait_cancels_without_another_request(monkeypatch): + entered_wait = asyncio.Event() + attempts = [] + requests = [] + + async def wait(delay): + entered_wait.set() + await asyncio.Future() + + async def fail(messages, info): + requests.append(messages) + raise ModelHTTPError(429, "test", "Too Many Requests") + + monkeypatch.setattr(agent_exec.asyncio, "sleep", wait) + + async def interrupt(): + task = asyncio.create_task( + agent_exec.run_agent_async( + model=FunctionModel(fail), + output_type=Choice, + system_prompt="Use evidence.", + user_prompt="Assess evidence.", + on_attempt=attempts.append, + ) + ) + await entered_wait.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + asyncio.run(interrupt()) + assert len(requests) == len(attempts) == 1 + assert attempts[0].status == "failed" + assert attempts[0].usage.requests == 1 + assert attempts[0].usage.availability == "unavailable" + assert attempts[0].providerFailures[0].statusCode == 429 + + +def test_transport_retries_do_not_replace_semantic_repair(recorded_waits): + calls = [] + + async def respond(messages, info): + calls.append(messages) + if len(calls) == 1: + raise ModelHTTPError(429, "test", "Too Many Requests") + return _response(info, 1 if len(calls) == 2 else 2) + + def validate(choice): + if choice.value != 2: + raise ValueError("Saved measurement requires value 2") + return choice + + result = agent_exec.run_agent_sync( + model=FunctionModel(respond), + output_type=Choice, + system_prompt="Use evidence.", + user_prompt="Assess evidence.", + output_validator=validate, + ) + assert result.output.value == 2 + assert result.runInfo.usage.requests == 3 + assert len(result.runInfo.validationRetries) == 1 + assert result.runInfo.validationRetries[0].requestIndex == 2 + assert len(result.runInfo.providerFailures) == 1 + assert result.runInfo.providerFailures[0].requestIndex == 1 + assert recorded_waits == [15] + + +def test_failed_requests_consume_allowance_before_the_next_agent_step(recorded_waits): + calls = [] + operations = [] + + async def measure(): + operations.append("measured") + return 2 + + async def respond(messages, info): + calls.append(messages) + if len(calls) == 1: + raise ModelHTTPError(429, "test", "Too Many Requests") + return ModelResponse( + parts=[ToolCallPart("measure", {})], + usage=RequestUsage(input_tokens=11, output_tokens=3), + ) + + with pytest.raises( + UsageLimitExceeded, match="includes failed provider requests" + ) as caught: + agent_exec.run_agent_sync( + model=FunctionModel(respond), + output_type=Choice, + system_prompt="Use evidence.", + user_prompt="Measure once, then decide.", + tools=[measure], + config=AgentRunConfig(requestLimit=2), + ) + assert len(calls) == 2 + assert operations == ["measured"] + assert caught.value.agent_run_info.usage.requests == 2 + assert len(caught.value.agent_run_info.providerFailures) == 1 + + +def test_total_wait_allowance_is_shared_across_agent_steps(recorded_waits): + calls = [] + operations = [] + + async def measure(): + operations.append("measured") + return 2 + + async def respond(messages, info): + calls.append(messages) + if len(calls) != 2: + raise ModelHTTPError( + 429, "test", "Too Many Requests", headers={"Retry-After": "80"} + ) + return ModelResponse( + parts=[ToolCallPart("measure", {})], + usage=RequestUsage(input_tokens=11, output_tokens=3), + ) + + with pytest.raises(ModelHTTPError) as caught: + agent_exec.run_agent_sync( + model=FunctionModel(respond), + output_type=Choice, + system_prompt="Use evidence.", + user_prompt="Measure once, then decide.", + tools=[measure], + ) + assert len(calls) == 3 + assert operations == ["measured"] + assert recorded_waits == [80] + assert caught.value.agent_run_info.usage.requests == 3 + assert [ + row.retryDelaySeconds for row in caught.value.agent_run_info.providerFailures + ] == [80, None] + + +def test_old_run_serialization_does_not_gain_empty_provider_records(): + saved = AgentRunInfo().model_dump(mode="json") + assert "providerFailures" not in saved + assert AgentRunInfo.model_validate(saved).model_dump(mode="json") == saved + + +def test_schema_retry_uses_actual_request_ordinal_after_transient_quota(recorded_waits): + calls = [] + + async def respond(messages, info): + calls.append(messages) + if len(calls) == 1: + raise ModelHTTPError(429, "test", "Per-minute quota exceeded") + return _response(info, "invalid" if len(calls) == 2 else 2) + + result = agent_exec.run_agent_sync( + model=FunctionModel(respond), + output_type=Choice, + system_prompt="Use evidence.", + user_prompt="Assess evidence.", + ) + assert result.output.value == 2 + assert result.runInfo.usage.requests == 3 + assert len(result.runInfo.validationRetries) == 1 + assert result.runInfo.validationRetries[0].source == "schema" + assert result.runInfo.validationRetries[0].requestIndex == 2 + assert result.runInfo.providerFailures[0].requestIndex == 1 + assert recorded_waits == [15] diff --git a/tests/test_agent_rna_adaptive.py b/tests/test_agent_rna_adaptive.py index ff457d64..94329e99 100644 --- a/tests/test_agent_rna_adaptive.py +++ b/tests/test_agent_rna_adaptive.py @@ -424,9 +424,22 @@ def no_recomputation(*args: Any, **kwargs: Any) -> Any: assert len(model_calls) == expected_calls +@pytest.mark.parametrize( + "enabled,license,batch_columns,expected_scores", + [ + (True, "notApplicable", [], 1), + (False, "notApplicable", [], 0), + (False, "unsafeConfounded", ["batch"], 0), + (False, "safe", ["batch"], 1), + ], +) def test_failed_execution_retries_and_doublets_bind_exact_feature_mask( checkpoints: dict[str, Any], monkeypatch: pytest.MonkeyPatch, + enabled: bool, + license: str, + batch_columns: list[str], + expected_scores: int, ) -> None: handoff = example(PreprocessedAssayHandoff) handoff.graphFeatureCandidates = {"eligibleDefault": handoff.graphFeatures} @@ -436,10 +449,15 @@ def test_failed_execution_retries_and_doublets_bind_exact_feature_mask( SimpleNamespace(model=object()), store, SimpleNamespace(workflowRunId="retry"), - SimpleNamespace(config=AutomatedWorkflowConfig()), + SimpleNamespace(config=AutomatedWorkflowConfig(scoreDoublets=enabled)), example(AutomatedPreprocessingPlan), handoff, - StudyContract.get_blank(), + StudyContract.get_blank().model_copy( + update={ + "correctionLicense": license, + "technicalBatchColumns": batch_columns, + } + ), {}, {}, ) @@ -503,7 +521,16 @@ def doublets(store: Any, selected: Any, candidates: Any, **kwargs: Any) -> Any: assert completed.status == "done" assert run.budget.summary()["scopes"]["full"]["reserved"]["partitions"] == 1 assert run.budget.summary()["scopes"]["full"]["completed"]["partitions"] == 1 - assert len(scored) == 1 + assert len(scored) == expected_scores + assert any("score_doublets=False" in warning for warning in completed.warnings) == ( + expected_scores == 0 + ) + if not expected_scores: + assert completed.metrics.doubletHighScoreConcentration is None + assert any( + "contamination was not assessed" in warning + for warning in completed.warnings + ) @pytest.mark.parametrize( diff --git a/tests/test_agent_rna_evidence_mode.py b/tests/test_agent_rna_evidence_mode.py index 878aa0c9..8d350f63 100644 --- a/tests/test_agent_rna_evidence_mode.py +++ b/tests/test_agent_rna_evidence_mode.py @@ -319,8 +319,18 @@ def rejecting_assessment(**kwargs: Any) -> Any: assert all(row["evidenceMode"] == "structured" for row in run.history) +@pytest.mark.parametrize( + "failure", + [ + RuntimeError("Interrupted during structured assessment"), + ModelHTTPError(429, "test-model", "Too Many Requests"), + ], + ids=["interrupted", "rate-limited"], +) def test_interrupted_structured_retry_does_not_probe_images_on_resume( - monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest + monkeypatch: pytest.MonkeyPatch, + request: pytest.FixtureRequest, + failure: Exception, ) -> None: saved = request.getfixturevalue("memory_checkpoints") run, selected = make_run(monkeypatch, object()) @@ -328,25 +338,56 @@ def test_interrupted_structured_retry_does_not_probe_images_on_resume( def interrupt(**kwargs: Any) -> Any: if not isinstance(kwargs["user_prompt"], str): raise ImageInputUnsupportedError("Image input is not supported") - raise RuntimeError("Interrupted during structured assessment") + raise failure monkeypatch.setattr(rna_tuning, "run_agent_sync", interrupt) - with pytest.raises(RuntimeError, match="Interrupted during structured"): + with pytest.raises(type(failure)) as caught: run.review("full", 0, selected, {}) + assert caught.value is failure assert set(saved) == { "parameter_tuning/structured_evidence", "parameter_tuning/full/review0/evidence/visual", "parameter_tuning/full/review0/evidence/structured", } + original = deepcopy(saved) resumed, selected = make_run(monkeypatch, object()) monkeypatch.setattr( tuning, "_analysis_visual_content", lambda *a, **kw: pytest.fail("Resume must preserve the rejected capability"), ) - monkeypatch.setattr(rna_tuning, "run_agent_sync", assess) + for name in ( + "partition_comparison_evidence", + "population_support_evidence", + "_neighbor_overlap", + ): + monkeypatch.setattr( + rna_tuning, + name, + lambda *a, **kw: pytest.fail( + "Output recovery must reuse saved diagnostics" + ), + ) + monkeypatch.setattr( + resumed, + "feature_evidence", + lambda *a, **kw: pytest.fail("Output recovery must reuse saved gene evidence"), + ) + + def recover(**kwargs): + evidence = original["parameter_tuning/full/review0/evidence/structured"][ + "outputs" + ]["evidence"] + assert json.loads(kwargs["user_prompt"]) == evidence + assert kwargs["user_prompt"] == json.dumps( + evidence, sort_keys=True, separators=(",", ":") + ) + return assess(**kwargs) + + monkeypatch.setattr(rna_tuning, "run_agent_sync", recover) assert resumed.review("full", 0, selected, {}).action == "accept" assert resumed.history[-1]["evidenceMode"] == "structured" + assert {key: saved[key] for key in original} == original @pytest.mark.parametrize( @@ -639,8 +680,13 @@ async def reply(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: assert requests == 2 +@pytest.mark.parametrize( + "provider_failure", [False, True], ids=["deferred", "rate-limited"] +) def test_saved_scientific_defer_replays_completed_candidates_without_new_work( - monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest + monkeypatch: pytest.MonkeyPatch, + request: pytest.FixtureRequest, + provider_failure: bool, ) -> None: saved = request.getfixturevalue("memory_checkpoints") run, prototype = make_run(monkeypatch, object()) @@ -730,9 +776,19 @@ def defer(**kwargs: Any) -> Any: ) return SimpleNamespace(output=kwargs["output_validator"](action)) - monkeypatch.setattr(rna_tuning, "run_agent_sync", defer) - first_report, first_summary = run.run() - assert first_report.status == "needsInput" + def unavailable(**kwargs): + raise ModelHTTPError(429, "test-model", "Too Many Requests") + + monkeypatch.setattr( + rna_tuning, "run_agent_sync", unavailable if provider_failure else defer + ) + if provider_failure: + with pytest.raises(ModelHTTPError, match="429"): + run.run() + else: + first_report, _ = run.run() + assert first_report.status == "needsInput" + first_budget = run.budget.summary() before = json.dumps(saved, sort_keys=True) resumed, _ = make_run(monkeypatch, object()) @@ -745,17 +801,25 @@ def defer(**kwargs: Any) -> Any: "comparison_coverage", rna_tuning.RnaTuningRun.comparison_coverage.__get__(resumed), ) - monkeypatch.setattr(rna_tuning, "run_agent_sync", unexpected) + monkeypatch.setattr( + rna_tuning, "run_agent_sync", defer if provider_failure else unexpected + ) monkeypatch.setattr(tuning, "_analysis_visual_content", unexpected) resumed_report, resumed_summary = resumed.run() - assert resumed_report == first_report + if not provider_failure: + assert resumed_report == first_report assert resumed_report.status == "needsInput" - assert resumed_summary["budget"] == first_summary["budget"] + assert resumed_summary["budget"] == first_budget original = json.loads(before) assert {key: saved[key] for key in original} == original appended = {key: value for key, value in saved.items() if key not in original} - assert len(appended) == 2 - assert all("/diagnostic_attempts/" in key for key in appended) + assert len(appended) == (3 if provider_failure else 2) + assert sum("/diagnostic_attempts/" in key for key in appended) == 2 + if provider_failure: + assert ( + saved["parameter_tuning/sample0/review0"]["outputs"]["action"]["action"] + == "defer" + ) counts = resumed_summary["diagnosticOperations"]["operations"] assert all(row["attempted"] == 0 for row in counts.values()) assert counts["diagnostic.primaryCandidateEvidence"]["restored"] == len(settings) From 216900f356d3d30636757a2b98f384c7ec7a6e18 Mon Sep 17 00:00:00 2001 From: gautam8387 Date: Tue, 8 Sep 2026 23:44:03 +0200 Subject: [PATCH 18/21] readthedocs fix --- .readthedocs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.readthedocs.yml b/.readthedocs.yml index bc5daf61..894d98cd 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -16,6 +16,7 @@ python: - method: uv command: sync extras: + - agent - docs - extra From f0df118fdbf9cc6385afeadace189ab6e1239a8a Mon Sep 17 00:00:00 2001 From: gautam8387 Date: Wed, 9 Sep 2026 00:37:10 +0200 Subject: [PATCH 19/21] tool call limit fixed --- .gitignore | 1 + scarf/agent/experimental_context/agent.py | 9 +- scarf/agent/experimental_context/tools.py | 10 +- scarf/agent/orchestrator/api.py | 4 +- scarf/agent/orchestrator/models.py | 4 +- scarf/agent/orchestrator/rna_tuning.py | 14 +- scarf/agent/parameter_tuning/comparisons.py | 20 +- .../test_agent_assessment_contract_repair.py | 219 ++++++++++++++++++ tests/test_agent_beginner.py | 4 +- tests/test_agent_context_tool_budget.py | 171 ++++++++++++++ tests/test_agent_required_comparisons.py | 2 +- tests/test_agent_sampling_recovery.py | 2 + 12 files changed, 444 insertions(+), 16 deletions(-) create mode 100644 tests/test_agent_assessment_contract_repair.py create mode 100644 tests/test_agent_context_tool_budget.py diff --git a/.gitignore b/.gitignore index 30523597..cecf5c63 100644 --- a/.gitignore +++ b/.gitignore @@ -77,3 +77,4 @@ modal_scarf/ notebook/ PR/ scarf/agent/*.md +.cache/ diff --git a/scarf/agent/experimental_context/agent.py b/scarf/agent/experimental_context/agent.py index fa6f3981..85429418 100644 --- a/scarf/agent/experimental_context/agent.py +++ b/scarf/agent/experimental_context/agent.py @@ -65,8 +65,8 @@ def __init__( ) -> None: self.model = model self.config = (config or AgentRunConfig()).with_limits( - request_limit=9, - tool_call_limit=6, + request_limit=10, + tool_call_limit=10, output_token_limit=32768, timeout_seconds=600.0, ) @@ -153,7 +153,10 @@ def __init__( Summaries retain adverse findings, missingness, protected group loss, replication and correction constraints. Use inspect_context_evidence to inspect one exact saved policy/capture or design record when its - details are needed. Omitted donor examples and detailed thresholds + details are needed. Copy the supplied section and record_id from + confounding details; do not construct an evidence ID for this lookup. + Reuse returned details instead of requesting the same record again. + Omitted donor examples and detailed thresholds remain available; do not interpret their omission as passing evidence. A later audited checkpoint compares the Scarf default with eligible alternatives and selects one exact policy. Never author diff --git a/scarf/agent/experimental_context/tools.py b/scarf/agent/experimental_context/tools.py index 0fb12f82..af9caf2a 100644 --- a/scarf/agent/experimental_context/tools.py +++ b/scarf/agent/experimental_context/tools.py @@ -1,6 +1,7 @@ """Read-only Pydantic AI tools for experimental context.""" import hashlib +import json import math from copy import deepcopy from functools import wraps @@ -72,7 +73,7 @@ def compact_context_evidence(evidence: CovariateEvidence) -> dict[str, Any]: if name in source and report[name] == source[name]: report.pop(name) report["coefficientDetails"] = f"coefficient:{report.get('coefficient')}" - report["details"] = f"confounding:{index}" + report["details"] = {"section": "confounding", "record_id": str(index)} for plan in payload["contrastPlans"]: source = coefficients.get(plan["coefficient"], {}) for name in ("replication", "estimability", "pairedCoverage"): @@ -221,6 +222,13 @@ async def inspect_context_evidence( elif section == "confounding": if record_id.isdecimal() and int(record_id) < len(characterization.confounding): return deepcopy(characterization.confounding[int(record_id)]) + raise ModelRetry( + "For section='confounding', record_id must be a zero-based index " + "string, not an evidence ID. Available record_id values: " + + json.dumps( + [str(index) for index in range(len(characterization.confounding))] + ) + ) else: records = ( characterization.columns diff --git a/scarf/agent/orchestrator/api.py b/scarf/agent/orchestrator/api.py index 85e12fb5..d61f240c 100644 --- a/scarf/agent/orchestrator/api.py +++ b/scarf/agent/orchestrator/api.py @@ -38,8 +38,8 @@ def analyze_rna( unavailable or prohibited. Harmony-eligible runs retain the matched doublet diagnostics required by the correction acceptance gate. Scoring does not remove cells. Changing this option requires a new workflow destination. - If `score_doublets=True`, the workflow will score doublets and save the scores - to the Zarr store. This may consume a lot of additional time. + Advisory scoring defaults to disabled for new beginner calls. Pass + ``score_doublets=True`` explicitly to resume a run that enabled scoring. """ if model is None or isinstance(model, str) and not model.strip(): raise ValueError( diff --git a/scarf/agent/orchestrator/models.py b/scarf/agent/orchestrator/models.py index 4d15196f..a9f75642 100644 --- a/scarf/agent/orchestrator/models.py +++ b/scarf/agent/orchestrator/models.py @@ -291,10 +291,10 @@ class AutomatedWorkflowConfig(AgentDataModel): ) # Older requests always scored doublets. Keep their serialized defaults exact. scoreDoublets: bool = Field( - default=False, + default=True, strict=True, exclude_if=lambda value: value is True, - description="Score advisory doublets; this run is not eligible for label-based benchmark scoring.", + description="Score advisory doublets; Harmony-eligible runs always retain required doublet diagnostics. The beginner API defaults to false; absent saved fields retain enabled scoring.", ) screeningCells: int | None = Field( default=None, diff --git a/scarf/agent/orchestrator/rna_tuning.py b/scarf/agent/orchestrator/rna_tuning.py index 29699823..0be642ed 100644 --- a/scarf/agent/orchestrator/rna_tuning.py +++ b/scarf/agent/orchestrator/rna_tuning.py @@ -204,7 +204,19 @@ def _assessment_output_type( conclusion_type = create_model( "ObservedComparisonInterpretation", __base__=ComparisonConclusion, - tradeoffs=(cast(Any, list)[tradeoff_type], Field(default_factory=list)), + tradeoffs=( + cast(Any, list)[tradeoff_type], + Field( + description=( + "Required array: for each comparisonAdvantages row matching this " + "axis and preferredCandidateId, supply alternativeCandidateId, " + "metric and your interpretation. Explanations in quantitativeReason " + "or biologicalReason do not replace these entries. For combine or " + "accept, use [] only when this preference has no measured " + "alternative advantages. Scarf attaches the measured values." + ) + ), + ), ) actions = tuple( action diff --git a/scarf/agent/parameter_tuning/comparisons.py b/scarf/agent/parameter_tuning/comparisons.py index 8d91e3b2..b2abd321 100644 --- a/scarf/agent/parameter_tuning/comparisons.py +++ b/scarf/agent/parameter_tuning/comparisons.py @@ -461,13 +461,17 @@ def validate_comparison_review( (row.alternativeCandidateId, row.metric): row for row in conclusion.tradeoffs } - prefix = f"{axis}, preferred {conclusion.preferredCandidateId}" + prefix = ( + f"comparisonConclusions[axis={axis!r}, " + f"preferredCandidateId={conclusion.preferredCandidateId!r}].tradeoffs" + ) if len(supplied) != len(conclusion.tradeoffs): tradeoff_errors.append(f"{prefix}: duplicate tradeoff entries") for key in sorted(required_tradeoffs.keys() - supplied.keys()): left, right = required_tradeoffs[key] tradeoff_errors.append( - f"{prefix}: explain alternative {key[0]} on {key[1]} " + f"{prefix}: add alternativeCandidateId={key[0]!r}, metric={key[1]!r}, " + "and an interpretation " f"(preferred={left!r}, alternative={right!r})" ) for key, row in supplied.items(): @@ -482,8 +486,16 @@ def validate_comparison_review( ) if tradeoff_errors: raise ValueError( - "Explain each observed alternative's better stability, marker or " - "separability measurement: " + "; ".join(tradeoff_errors) + "Repair the comparisonConclusions entries' tradeoffs arrays. Each " + "missing entry must contain alternativeCandidateId, metric and " + "interpretation explaining the measured alternative advantage and " + "its tradeoff with the objective. Expanding quantitativeReason, " + "biologicalReason or the overall rationale does not fill these arrays. " + "Do not leave them empty when advantages are listed. Scarf supplies " + "preferredValue and alternativeValue; do not transcribe them. " + "A larger listed value is an advantage on that metric, even if " + "you prefer another candidate for other reasons. Required repairs: " + + "; ".join(tradeoff_errors) ) if action["action"] == "combine": if coverage["phase"] != "sensitivity": diff --git a/tests/test_agent_assessment_contract_repair.py b/tests/test_agent_assessment_contract_repair.py new file mode 100644 index 00000000..9be038fc --- /dev/null +++ b/tests/test_agent_assessment_contract_repair.py @@ -0,0 +1,219 @@ +"""Structured tradeoff repair keeps scientific counterevidence explicit.""" + +from copy import deepcopy +import json +from typing import Any + +from pydantic import ValidationError +from pydantic_ai.exceptions import UnexpectedModelBehavior +from pydantic_ai.messages import ModelResponse, RetryPromptPart, ToolCallPart +from pydantic_ai.models.function import FunctionModel +import pytest + +from scarf.agent.config import AgentRunConfig +from scarf.agent.config.agent_exec import run_agent_sync +from scarf.agent.orchestrator import rna_tuning +from scarf.agent.parameter_tuning.comparisons import ( + bind_comparison_measurements, + comparison_advantages, + validate_comparison_review, +) +from tests.agent_comparison_examples import comparison_review, observed_action + + +def _competing_review() -> tuple[dict[str, Any], dict[str, Any]]: + review = comparison_review() + coverage = review["comparisonCoverage"] + settings = coverage["candidateSettings"] + for candidate in settings.values(): + candidate["metrics"]["macroF1"] = 0.8 + for identifier in ( + "resolution-half", + "genes-two", + "dimensions-thirty", + "neighbors-forty-one", + ): + settings[identifier]["metrics"].update(seedStability=0.97, macroF1=0.8 + 1e-10) + review["currentCandidateId"] = review["selectedCandidateId"] + action = observed_action(review) + for conclusion in action["comparisonConclusions"]: + explanations = [ + f"{row['alternativeCandidateId']} improves {row['metric']} from " + f"{row['preferredValue']!r} to {row['alternativeValue']!r}; " + "the reference marker program still motivates this preference." + for row in conclusion["tradeoffs"] + ] + conclusion["quantitativeReason"] = " ".join(explanations) or ( + "No measured advantage requires explanation on this axis." + ) + conclusion["biologicalReason"] = ( + "Retain the reference marker program while acknowledging every " + "alternative's measured improvements in the quantitative explanation." + ) + conclusion["tradeoffs"] = [] + return coverage, action + + +def test_provider_requires_explicit_tradeoff_field_without_numeric_authorship() -> None: + coverage, action = _competing_review() + output_type = rna_tuning._assessment_output_type( + list(coverage["candidateSettings"]), [], scope="full" + ) + schema = output_type.model_json_schema() + conclusion_schema = schema["$defs"]["ObservedComparisonInterpretation"] + assert "tradeoffs" in conclusion_schema["required"] + assert ( + "comparisonAdvantages" + in conclusion_schema["properties"]["tradeoffs"]["description"] + ) + tradeoff_fields = schema["$defs"]["ObservedTradeoffInterpretation"]["properties"] + assert set(tradeoff_fields) == { + "alternativeCandidateId", + "metric", + "interpretation", + } + del action["comparisonConclusions"][0]["tradeoffs"] + with pytest.raises(ValidationError, match="tradeoffs"): + output_type.model_validate(action) + # The provider contract does not rewrite the existing persisted action schema. + saved = rna_tuning.TuningAction.model_validate(action) + assert saved.comparisonConclusions[0].tradeoffs == [] + + +def test_detailed_prose_cannot_replace_structured_tradeoff_explanations() -> None: + coverage, action = _competing_review() + original = deepcopy(action) + with pytest.raises(ValueError) as failure: + validate_comparison_review(coverage, action) + feedback = str(failure.value) + for axis in ("partition", "hvgCount", "pca", "neighbors"): + assert axis in feedback + for required in ( + "comparisonConclusions", + "tradeoffs", + "alternativeCandidateId", + "metric", + "interpretation", + "quantitativeReason", + "biologicalReason", + "resolution-half", + "genes-two", + "dimensions-thirty", + "neighbors-forty-one", + "0.8000000001", + ): + assert required in feedback + assert action == original + + +@pytest.mark.parametrize("repair", [True, False]) +def test_model_repairs_exact_fields_or_fails_without_accepting_prose( + repair: bool, +) -> None: + coverage, action = _competing_review() + inventory = comparison_advantages(coverage) + offered = json.dumps({"comparisonAdvantages": inventory}, sort_keys=True) + output_type = rna_tuning._assessment_output_type( + list(coverage["candidateSettings"]), [], scope="full" + ) + responses = [] + attempts = [] + + def provider(messages: Any, info: Any) -> ModelResponse: + proposed = deepcopy(action) + if responses: + feedback = " ".join( + str(part.content) + for message in messages + for part in message.parts + if isinstance(part, RetryPromptPart) + ) + for name in ( + "comparisonConclusions", + "tradeoffs", + "alternativeCandidateId", + "metric", + "interpretation", + ): + assert name in feedback + if repair: + for conclusion in proposed["comparisonConclusions"]: + conclusion["tradeoffs"] = [ + { + "alternativeCandidateId": row["alternativeCandidateId"], + "metric": row["metric"], + "interpretation": ( + f"The observed {row['metric']} advantage must be " + "weighed against preserving the reference marker " + "program, rather than asserting numerical superiority." + ), + } + for row in inventory + if row["axis"] == conclusion["axis"] + and row["preferredCandidateId"] + == conclusion["preferredCandidateId"] + ] + responses.append(proposed) + return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, proposed)]) + + def validate(proposed: Any) -> rna_tuning.TuningAction: + bound = bind_comparison_measurements(coverage, proposed.model_dump(mode="json")) + validate_comparison_review(coverage, bound) + return rna_tuning.TuningAction.model_validate(bound) + + def run(): + return run_agent_sync( + model=FunctionModel(provider), + output_type=output_type, + system_prompt="Adjudicate the supplied measured tradeoffs.", + user_prompt=offered, + config=AgentRunConfig(retries=1), + output_validator=validate, + on_attempt=attempts.append, + ) + + if repair: + result = run() + completed = result.output.model_dump(mode="json") + validate_comparison_review(coverage, completed) + measured = { + ( + row["axis"], + row["preferredCandidateId"], + row["alternativeCandidateId"], + row["metric"], + ): row + for row in inventory + } + checked = set() + for conclusion in completed["comparisonConclusions"]: + for tradeoff in conclusion["tradeoffs"]: + expected = measured[ + ( + conclusion["axis"], + conclusion["preferredCandidateId"], + tradeoff["alternativeCandidateId"], + tradeoff["metric"], + ) + ] + assert tradeoff["preferredValue"] == expected["preferredValue"] + assert tradeoff["alternativeValue"] == expected["alternativeValue"] + checked.add(conclusion["axis"]) + assert checked == {"partition", "hvgCount", "pca", "neighbors"} + assert attempts[0].status == "done" + assert len(attempts[0].validationRetries) == 1 + else: + with pytest.raises(UnexpectedModelBehavior, match="maximum output retries"): + run() + assert attempts[0].status == "failed" + assert len(attempts[0].validationRetries) == 2 + assert len(responses) == 2 + assert len(attempts) == 1 + assert attempts[0].usage.requests == 2 + assert json.dumps({"comparisonAdvantages": inventory}, sort_keys=True) == offered + assert all( + "preferredValue" not in tradeoff and "alternativeValue" not in tradeoff + for response in responses + for conclusion in response["comparisonConclusions"] + for tradeoff in conclusion["tradeoffs"] + ) diff --git a/tests/test_agent_beginner.py b/tests/test_agent_beginner.py index a443927f..02744368 100644 --- a/tests/test_agent_beginner.py +++ b/tests/test_agent_beginner.py @@ -74,8 +74,8 @@ def run(self, request: AutomatedWorkflowRequest) -> AutomatedWorkflowResult: assert called["model"] is model config = called["config"] assert config.inputPolicy == "unattended" - assert config.scoreDoublets is True - assert "scoreDoublets" not in config.model_dump(mode="json") + assert config.scoreDoublets is False + assert config.model_dump(mode="json")["scoreDoublets"] is False assert config.screeningCells is None assert config.maxScreeningCells == 100_000 assert config.maxScreeningEvaluations == 24 diff --git a/tests/test_agent_context_tool_budget.py b/tests/test_agent_context_tool_budget.py new file mode 100644 index 00000000..f9214ffb --- /dev/null +++ b/tests/test_agent_context_tool_budget.py @@ -0,0 +1,171 @@ +"""Context evidence reads fit the bounded run without expanding scientific work.""" + +import asyncio +from copy import deepcopy +from types import SimpleNamespace + +import pytest +from pydantic_ai import ModelRetry, UsageLimitExceeded +from pydantic_ai.messages import ModelResponse, ToolCallPart +from pydantic_ai.models.function import FunctionModel + +from scarf.agent.config import AgentRunConfig +from scarf.agent.experimental_context import ExperimentalContextAgent, tools +from scarf.agent.experimental_context.contracts import ( + CovariateCharacterization, + CovariateEvidence, + CovariateProposal, + ExperimentalContextDependencies, +) +from tests.test_agent_experimental_context import _Store, _design_decision + + +@pytest.mark.parametrize("tool_limit", [None, 6]) +def test_context_can_read_required_saved_details_with_bounded_usage( + monkeypatch, tool_limit +): + store = _Store() + decision = _design_decision() + calls = [] + snapshots = {} + attempts = [] + characterize = tools.characterize_covariates + + def measured(*args, **kwargs): + calls.append(kwargs["directions"]) + return characterize(*args, **kwargs) + + monkeypatch.setattr(tools, "characterize_covariates", measured) + actions = [ + ("inspect_cell_covariates", {}), + ( + "analyze_experimental_design", + { + "column_domains": decision.columnDomains, + "coefficients_of_interest": decision.coefficientsOfInterest, + "units_of_inference": { + name: unit.model_dump() + for name, unit in decision.unitsOfInference.items() + }, + "batch_columns": decision.batchCorrection.batchColumns, + }, + ), + # Reproduce the notebook's malformed lookup and bounded tool repair. + ( + "inspect_context_evidence", + {"section": "confounding", "record_id": "confounding:disease:batch"}, + ), + ( + "inspect_context_evidence", + {"section": "confounding", "record_id": "0"}, + ), + ( + "inspect_context_evidence", + {"section": "coefficient", "record_id": "disease"}, + ), + *[ + ("inspect_context_evidence", {"section": "column", "record_id": name}) + for name in ("batch", "sample", "donor") + ], + ] + requests = 0 + measured_before_details = None + + async def reply(_messages, info): + nonlocal requests, measured_before_details + index = requests + requests += 1 + if index == 2: + measured_before_details = len(calls) + assert measured_before_details > 0 + if index >= 2: + assert len(calls) == measured_before_details + if index < len(actions): + name, args = actions[index] + else: + name, args = info.output_tools[0].name, decision.model_dump() + return ModelResponse(parts=[ToolCallPart(tool_name=name, args=args)]) + + def checkpoint_write(key, value): + snapshots[key] = deepcopy(value) + + agent = ExperimentalContextAgent( + FunctionModel(reply), + config=AgentRunConfig(toolCallLimit=tool_limit) if tool_limit else None, + ) + kwargs = { + "study_context": "Case-control study with samples nested in donors.", + "cell_selection": store.cell_selection, + "checkpoint_write": checkpoint_write, + "on_attempt": attempts.append, + } + if tool_limit is not None: + with pytest.raises(UsageLimitExceeded, match="tool_calls_limit of 6"): + agent.run(store, **kwargs) + assert attempts[-1].status == "failed" + assert "result" not in snapshots + else: + result = agent.run(store, **kwargs) + assert result.status == "done" + assert result.decision.batchCorrection.action == "unsafe" + assert len(result.runInfo.toolCalls) == 8 + assert len(result.runInfo.validationRetries) == 1 + assert result.runInfo.usage.requests == 9 + assert len(calls) == measured_before_details + assert requests <= 10 + assert snapshots["design1"]["state"]["designRounds"] == 1 + assert "design2" not in snapshots + + # More room to inspect evidence does not permit a fifth follow-up proposal. + deps = ExperimentalContextDependencies.model_validate(snapshots["design1"]["state"]) + saved = deps.model_dump(mode="json") + with pytest.raises(ModelRetry, match="eight initial and four follow-up"): + asyncio.run( + tools.analyze_experimental_design( + SimpleNamespace(deps=deps), + column_domains=decision.columnDomains, + coefficients_of_interest=decision.coefficientsOfInterest, + units_of_inference=decision.unitsOfInference, + batch_columns=decision.batchCorrection.batchColumns, + proposals=[ + CovariateProposal( + response="disease", + explanatoryColumns=["batch"], + observationUnit="sample", + rationale="Check batch confounding across observation units.", + ) + ] + * 5, + ) + ) + assert deps.model_dump(mode="json") == saved + + +def test_context_summary_offers_exact_confounding_lookup_without_mutation(): + characterization = CovariateCharacterization( + status="done", + confounding=[ + {"coefficient": "disease", "reason": "Disease aliases the batch."}, + {"coefficient": "sex", "reason": "Sex is incompletely crossed."}, + ], + ) + evidence = CovariateEvidence(characterization=characterization) + original = evidence.model_dump(mode="json") + view = tools.compact_context_evidence(evidence) + ctx = SimpleNamespace( + deps=ExperimentalContextDependencies(characterization=characterization) + ) + for index, row in enumerate(view["characterization"]["confounding"]): + assert row["details"] == {"section": "confounding", "record_id": str(index)} + detail = asyncio.run(tools.inspect_context_evidence(ctx, **row["details"])) + assert detail == characterization.confounding[index] + detail["reason"] = "Returned copies cannot rewrite committed evidence." + with pytest.raises(ModelRetry) as exc_info: + asyncio.run( + tools.inspect_context_evidence( + ctx, "confounding", "confounding:disease:batch" + ) + ) + assert "record_id" in str(exc_info.value) + assert '["0", "1"]' in str(exc_info.value) + assert evidence.model_dump(mode="json") == original diff --git a/tests/test_agent_required_comparisons.py b/tests/test_agent_required_comparisons.py index a836c8ae..acf1f4bd 100644 --- a/tests/test_agent_required_comparisons.py +++ b/tests/test_agent_required_comparisons.py @@ -90,7 +90,7 @@ def test_better_alternative_requires_exact_observed_tradeoff() -> None: review = comparison_review() coverage = review["comparisonCoverage"] coverage["candidateSettings"]["genes-two"]["metrics"]["seedStability"] = 0.99 - with pytest.raises(ValueError, match="better stability"): + with pytest.raises(ValueError, match="tradeoffs.*seedStability"): validate_comparison_review(coverage, review) conclusion = next( row for row in review["comparisonConclusions"] if row["axis"] == "hvgCount" diff --git a/tests/test_agent_sampling_recovery.py b/tests/test_agent_sampling_recovery.py index 5c33376c..03a1605c 100644 --- a/tests/test_agent_sampling_recovery.py +++ b/tests/test_agent_sampling_recovery.py @@ -93,6 +93,8 @@ def test_model_repairs_multiple_missing_tradeoffs_without_transcribing_values() for key, value in review.items() if key in rna_tuning.TuningAction.model_fields } + for conclusion in action["comparisonConclusions"]: + conclusion.setdefault("tradeoffs", []) output_type = rna_tuning._assessment_output_type( list(coverage["candidateSettings"]), [], scope="full" ) From 5e70f8feb352af5d22291c9b07b3246dfebe2b6a Mon Sep 17 00:00:00 2001 From: gautam8387 Date: Wed, 9 Sep 2026 02:54:12 +0200 Subject: [PATCH 20/21] final agent; report --- scarf/agent/experimental_context/agent.py | 2 +- .../experimental_context/characterization.py | 4 +- .../agent/experimental_context/qc_evidence.py | 2 +- scarf/agent/experimental_context/tools.py | 9 +- .../agent/experimental_context/validation.py | 2 +- scarf/agent/orchestrator/api.py | 2 +- scarf/agent/orchestrator/context.py | 10 +- scarf/agent/orchestrator/models.py | 2 +- scarf/agent/orchestrator/preprocessing.py | 3 +- scarf/agent/orchestrator/rna_tuning.py | 39 ++-- scarf/agent/orchestrator/tuning.py | 3 +- scarf/agent/parameter_tuning/comparisons.py | 116 +++++----- scarf/agent/parameter_tuning/execution.py | 2 +- scarf/agent/report/artifacts.py | 7 +- scarf/agent/report/rendering.py | 41 ++-- .../test_agent_assessment_contract_repair.py | 208 +++++++++++++++++- tests/test_agent_report.py | 122 +++++++++- tests/test_agent_rna_assessment_integrity.py | 90 ++++++++ 18 files changed, 540 insertions(+), 124 deletions(-) diff --git a/scarf/agent/experimental_context/agent.py b/scarf/agent/experimental_context/agent.py index 85429418..b4af3b86 100644 --- a/scarf/agent/experimental_context/agent.py +++ b/scarf/agent/experimental_context/agent.py @@ -32,10 +32,10 @@ _prepare_experimental_context_tool, analyze_experimental_design, capture_repair_inputs, + compact_context_evidence, contrast_plans_from_characterization, inspect_cell_covariates, inspect_context_evidence, - compact_context_evidence, model_evidence_tool, restore_context_evidence, score_current_representation, diff --git a/scarf/agent/experimental_context/characterization.py b/scarf/agent/experimental_context/characterization.py index 3b3b0014..682b705b 100644 --- a/scarf/agent/experimental_context/characterization.py +++ b/scarf/agent/experimental_context/characterization.py @@ -1,7 +1,7 @@ """Characterize cell covariates and study-design confounding.""" -import re import hashlib +import re from collections.abc import Iterator, Mapping, Sequence from dataclasses import dataclass, field from typing import Any, Literal, cast @@ -23,8 +23,8 @@ ) from ...metadata.selection import resolve_cell_aligned_artifact from ...metrics.association import directional_mapping, report_confounding -from ...storage.refs import ArtifactRef from ...storage.artifacts import fingerprint_array +from ...storage.refs import ArtifactRef from ...storage.selections import read_stored_selection_indices from ..decisions.selection import DecisionValidationError, decide from ..tools import artifact_reference diff --git a/scarf/agent/experimental_context/qc_evidence.py b/scarf/agent/experimental_context/qc_evidence.py index 782475ad..cc321c6d 100644 --- a/scarf/agent/experimental_context/qc_evidence.py +++ b/scarf/agent/experimental_context/qc_evidence.py @@ -3,8 +3,8 @@ import json import math import re -from dataclasses import dataclass, field from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field from typing import Any, Literal, cast import numpy as np diff --git a/scarf/agent/experimental_context/tools.py b/scarf/agent/experimental_context/tools.py index af9caf2a..4f868f5c 100644 --- a/scarf/agent/experimental_context/tools.py +++ b/scarf/agent/experimental_context/tools.py @@ -3,22 +3,23 @@ import hashlib import json import math +from collections.abc import Sequence from copy import deepcopy from functools import wraps -from collections.abc import Sequence from types import SimpleNamespace from typing import Any, Literal + import numpy as np from ...metadata.queries import reduce_observation_units from ...metrics.association import coefficient_estimability -from ...storage.refs import ArtifactRef from ...storage.artifacts import fingerprint_array +from ...storage.refs import ArtifactRef from ...utils.logging import logger from .._deps import AGENT_INSTALL_HINT +from ..record_io import canonical_json_bytes from ..tools import artifact_reference, core_artifact_reference from ..types import BatchSafetyEvidence, BatchSafetyStatus -from ..record_io import canonical_json_bytes from .characterization import characterize_covariates from .comparisons import ( DESIGN_ROUND_LIMITS, @@ -26,6 +27,7 @@ evaluate_proposals, ) from .contracts import ( + BatchCorrectionPlan, CaptureProposal, ColumnDomain, ContrastPlan, @@ -34,7 +36,6 @@ CovariateCharacterization, CovariateEvidence, CovariateProposal, - BatchCorrectionPlan, ExperimentalContextDecision, ExperimentalContextDependencies, InferenceUnit, diff --git a/scarf/agent/experimental_context/validation.py b/scarf/agent/experimental_context/validation.py index 3945b062..ee882fc6 100644 --- a/scarf/agent/experimental_context/validation.py +++ b/scarf/agent/experimental_context/validation.py @@ -20,12 +20,12 @@ from .qc_evidence import ( _offered_qc_profiles, ) -from .tools import characterize_context, contrast_plans_from_characterization from .requirements import ( active_batch_safety, objective_evidence, unmet_objective_requirements, ) +from .tools import characterize_context, contrast_plans_from_characterization try: from pydantic import ValidationError diff --git a/scarf/agent/orchestrator/api.py b/scarf/agent/orchestrator/api.py index d61f240c..3b6c18a2 100644 --- a/scarf/agent/orchestrator/api.py +++ b/scarf/agent/orchestrator/api.py @@ -5,10 +5,10 @@ from .main import AgentOrchestrator from .models import ( + AnalysisError, AutomatedWorkflowConfig, AutomatedWorkflowRequest, AutomatedWorkflowResult, - AnalysisError, ) diff --git a/scarf/agent/orchestrator/context.py b/scarf/agent/orchestrator/context.py index e60dc757..c20d7b16 100644 --- a/scarf/agent/orchestrator/context.py +++ b/scarf/agent/orchestrator/context.py @@ -1,7 +1,7 @@ """Ingest, RNA enrichment, quality metrics, and experimental-context stages.""" -from collections.abc import Mapping, Sequence import hashlib +from collections.abc import Mapping, Sequence from typing import Any, cast from ...datastore.datastore import DataStore @@ -16,22 +16,22 @@ ExperimentalContextResult, NamedArtifactSource, ) +from ..experimental_context.requirements import objective_evidence from ..experimental_context.study import ( StudyContract, build_study_contract, validate_objective_evidence, ) -from ..experimental_context.requirements import objective_evidence from ..ingest import IngestResult from ..ingest.manifest import DatasetManifest, is_author_label_column -from ..types import AgentRunInfo, ArtifactReferenceModel from ..record_io import canonical_json_bytes +from ..types import AgentRunInfo, ArtifactReferenceModel from . import journal from .models import ( - WorkflowIdentity, - StageEvidenceReference, OrchestrationRequestRecord, OrchestrationResumeRecord, + StageEvidenceReference, + WorkflowIdentity, WorkflowNeedsInput, WorkflowQuestion, WorkflowStageAttempt, diff --git a/scarf/agent/orchestrator/models.py b/scarf/agent/orchestrator/models.py index a9f75642..9ef34336 100644 --- a/scarf/agent/orchestrator/models.py +++ b/scarf/agent/orchestrator/models.py @@ -2,8 +2,8 @@ import re from collections.abc import Mapping -from pathlib import Path from dataclasses import dataclass +from pathlib import Path from typing import TYPE_CHECKING, Any, Literal from pydantic import Field, field_validator, model_validator diff --git a/scarf/agent/orchestrator/preprocessing.py b/scarf/agent/orchestrator/preprocessing.py index 2054ccd6..fd8181c8 100644 --- a/scarf/agent/orchestrator/preprocessing.py +++ b/scarf/agent/orchestrator/preprocessing.py @@ -39,7 +39,6 @@ from ..parameter_tuning.execution import ( candidate_metric_cache, ) -from .models import WorkflowIdentity from ..types import ArtifactReferenceModel from . import journal from .decisions import DecisionStagesMixin @@ -49,6 +48,7 @@ OrchestrationRequestRecord, OrchestrationResumeRecord, PreprocessedAssayHandoff, + WorkflowIdentity, WorkflowNeedsInput, WorkflowQuestion, WorkflowStageAttempt, @@ -56,7 +56,6 @@ WorkflowStageName, artifact_model_to_ref, ) - from .rna import ( selected_store_rna_assay, validate_rna_context, diff --git a/scarf/agent/orchestrator/rna_tuning.py b/scarf/agent/orchestrator/rna_tuning.py index 0be642ed..7fdf0094 100644 --- a/scarf/agent/orchestrator/rna_tuning.py +++ b/scarf/agent/orchestrator/rna_tuning.py @@ -1,7 +1,7 @@ """Objective-led RNA experiments on frozen screening and full-cohort cells.""" -import hashlib import base64 +import hashlib import json import re from collections.abc import Mapping, Sequence @@ -25,19 +25,12 @@ build_visual_evidence_prompt, run_agent_sync, ) +from ..experimental_context.contracts import CovariateComparison from ..experimental_context.study import ( StudyContract, unsupported_comparison_limitations, ) -from ..experimental_context.contracts import CovariateComparison from ..parameter_tuning.agent import prepare_parameter_tuning_dependencies -from ..parameter_tuning.contracts import ( - ArtifactRecord, - ParameterCandidate, - ParameterCandidateEvaluation, - ParameterTuningNeedsInput, - ParameterTuningReport, -) from ..parameter_tuning.comparisons import ( CombinedSettings, ComparisonConclusion, @@ -45,10 +38,17 @@ PopulationConcern, bind_comparison_measurements, comparison_advantages, - setting_changes, partition_comparison_evidence, + setting_changes, validate_comparison_review, ) +from ..parameter_tuning.contracts import ( + ArtifactRecord, + ParameterCandidate, + ParameterCandidateEvaluation, + ParameterTuningNeedsInput, + ParameterTuningReport, +) from ..parameter_tuning.diagnostics import ( SCARF_DEFAULT_DIAGNOSTIC_FAMILIES, _family_mask, @@ -84,7 +84,6 @@ artifact_model_to_ref, ) - _STRUCTURED_VISUAL_LIMITATION = ( "The model assessed structured marker, PCA loading and diagnostic evidence; " "visual inspection was unavailable because the configured model does not accept images." @@ -213,7 +212,9 @@ def _assessment_output_type( "metric and your interpretation. Explanations in quantitativeReason " "or biologicalReason do not replace these entries. For combine or " "accept, use [] only when this preference has no measured " - "alternative advantages. Scarf attaches the measured values." + "alternative advantages. Include only matching inventory rows: " + "ties and measurements favoring your preference belong in " + "quantitativeReason, not tradeoffs. Scarf attaches the measured values." ) ), ), @@ -2503,6 +2504,9 @@ def validate(action: TuningAction, *, replay: bool = False) -> TuningAction: "Scarf will execute it and compare four resolutions on its exact graph before acceptance. Do not request already covered settings as experiments. " "comparisonConclusions must include quantitativeReason, biologicalReason and a short plainLanguageSummary for each axis. Explain how split or merged marker programs serve the stated objective, not just larger clusters or a single numerical maximum. " "comparisonAdvantages enumerates the exact measured advantages for each possible preference on every axis. For each row belonging to your preference, include a tradeoffs entry naming alternativeCandidateId, metric and interpretation. Scarf attaches the exact saved values; do not transcribe numbers into tradeoff fields. Address every listed advantage, including small differences; these require explanation, not automatic winner selection. " + "tradeoffs contains only counterevidence: an alternative's measurement is strictly better than your preference. Copy only inventory rows matching the exact axis and preferredCandidateId; general comparisons favoring your preference and ties belong in quantitativeReason. " + "If validation requests repair, fix every listed error in the same response using permittedTradeoffs for your current preferences. Preserve unaffected choices and explanations. Explicitly explain any changed preference, update its combinedSettings field when applicable, and use comparisonAdvantages for that new preference. " + "Every numerical explanation must agree with the saved measurements, including direction and candidate identity. Unsupported biological interpretations must remain hypotheses or limitations; do not call protected sex-linked or cell-cycle programs artifacts merely because they appear in PCA loadings. " "For each selected cluster with empty topMarkerGenes, populationConcerns must name candidateId, clusterId, cited evidenceIds and explain whether it is a nonEssentialLimitation or unresolvedEssential. An unresolved essential population blocks acceptance; do not invent marker support. " "The action plainLanguageSummary should state the selected settings, what evidence changed the choice, and any unresolved population interpretations without workflow jargon. " "QC/capture retention, batch associations per PC and protected biological structure. " @@ -2562,7 +2566,16 @@ def validate(action: TuningAction, *, replay: bool = False) -> TuningAction: ) if mode == "visual" else serialized_evidence, - config=self.request.config.agentRunConfig, + config=self.request.config.agentRunConfig.model_copy( + update={ + "retries": min( + self.request.config.agentRunConfig.retries, 2 + ), + "requestLimit": min( + self.request.config.agentRunConfig.requestLimit, 3 + ), + } + ), name=f"rna_{scope}_assessment", output_validator=validate, on_attempt=journal.model_attempt_callback( diff --git a/scarf/agent/orchestrator/tuning.py b/scarf/agent/orchestrator/tuning.py index c1cc1ed8..47176363 100644 --- a/scarf/agent/orchestrator/tuning.py +++ b/scarf/agent/orchestrator/tuning.py @@ -24,7 +24,6 @@ _metadata_column_fingerprint, candidate_metric_cache, ) -from .models import StageEvidenceReference, WorkflowIdentity from ..types import ArtifactReferenceModel from . import journal from .decisions import DecisionStagesMixin @@ -33,6 +32,8 @@ OrchestrationRequestRecord, OrchestrationResumeRecord, PreprocessedAssayHandoff, + StageEvidenceReference, + WorkflowIdentity, WorkflowNeedsInput, WorkflowQuestion, WorkflowStageAttempt, diff --git a/scarf/agent/parameter_tuning/comparisons.py b/scarf/agent/parameter_tuning/comparisons.py index b2abd321..e283861e 100644 --- a/scarf/agent/parameter_tuning/comparisons.py +++ b/scarf/agent/parameter_tuning/comparisons.py @@ -1,17 +1,17 @@ """Exact RNA sensitivity comparisons and their review requirements.""" -from collections.abc import Mapping +import json from collections import Counter +from collections.abc import Mapping from typing import Any, Literal, get_args -from pydantic import Field import numpy as np +from pydantic import Field -from ..types import AgentDataModel from ...storage.refs import ArtifactRef +from ..types import AgentDataModel from .contracts import ParameterCandidateEvaluation - type ComparisonAxis = Literal[ "hvgCount", "hvgRanking", "featurePolicy", "pca", "neighbors", "partition" ] @@ -100,20 +100,39 @@ def bind_comparison_measurements( for row in comparison_advantages(coverage) } conclusions = [] + errors = [] + permitted = {} for conclusion in action.get("comparisonConclusions", []): + preference = (conclusion["axis"], conclusion["preferredCandidateId"]) + required = {key: row for key, row in inventory.items() if key[:2] == preference} + permitted.update(required) + prefix = ( + f"comparisonConclusions[axis={preference[0]!r}, " + f"preferredCandidateId={preference[1]!r}].tradeoffs" + ) tradeoffs = [] + supplied = set() for interpretation in conclusion.get("tradeoffs", []): key = ( - conclusion["axis"], - conclusion["preferredCandidateId"], + *preference, interpretation["alternativeCandidateId"], interpretation["metric"], ) + if key in supplied: + errors.append(f"{prefix}: duplicate tradeoff entry {key[2:]!r}") + supplied.add(key) measured = inventory.get(key) if measured is None: - raise ValueError( - f"Tradeoff does not identify an observed advantage: {key!r}" + settings = coverage["candidateSettings"] + left = settings.get(preference[1], {}).get("metrics", {}).get(key[3]) + right = settings.get(key[2], {}).get("metrics", {}).get(key[3]) + errors.append( + f"{prefix}: {key[2:]!r} does not identify an observed advantage " + f"(preferred={left!r}, alternative={right!r}). " + "Only permittedTradeoffs rows belong here; comparisons favoring " + "the preference and ties belong in quantitativeReason." ) + continue values = { field: measured[field] for field in ("preferredValue", "alternativeValue") @@ -122,11 +141,36 @@ def bind_comparison_measurements( interpretation.get(field) is not None and interpretation[field] != value for field, value in values.items() ): - raise ValueError( - f"Tradeoff must use exact preferred and alternative measurements for {key!r}: {values!r}" + errors.append( + f"{prefix}: use exact preferred and alternative measurements " + f"for {key[2:]!r}: {values!r}" ) tradeoffs.append({**interpretation, **values}) + if action.get("action") in {"accept", "combine"}: + for key in sorted(required.keys() - supplied): + errors.append( + f"{prefix}: add alternativeCandidateId={key[2]!r}, " + f"metric={key[3]!r} and an interpretation of its measured advantage" + ) conclusions.append({**conclusion, "tradeoffs": tradeoffs}) + if errors: + raise ValueError( + "Repair all comparisonConclusions tradeoffs errors together. " + "permittedTradeoffs contains the exact allowed rows for your current " + "axis and preferredCandidateId choices. For accept or combine, explain " + "every matching row using alternativeCandidateId, metric and interpretation. " + "Do not include other comparisons, worse alternatives or ties in tradeoffs; " + "put general comparisons in quantitativeReason. Expanding quantitativeReason " + "or biologicalReason does not replace required entries. Scarf supplies " + "preferredValue and alternativeValue; do not transcribe them. " + "Preserve unaffected choices and explanations. If you reconsider a " + "preference, update its conclusion and combinedSettings consistently and " + "use comparisonAdvantages for the new preference. Required repairs: " + + json.dumps( + {"errors": errors, "permittedTradeoffs": list(permitted.values())}, + sort_keys=True, + ) + ) return {**action, "comparisonConclusions": conclusions} @@ -434,8 +478,6 @@ def validate_comparison_review( raise ValueError( "Conclude every required comparison axis before combining or accepting" ) - inventory = comparison_advantages(coverage) - tradeoff_errors: list[str] = [] for axis, ids in axis_candidates.items(): conclusion = by_axis[axis] if not ids.issubset(conclusion.candidateIds) or not set( @@ -448,55 +490,7 @@ def validate_comparison_review( raise ValueError( "A comparison preference must name its observed candidate" ) - required_tradeoffs = { - (row["alternativeCandidateId"], row["metric"]): ( - row["preferredValue"], - row["alternativeValue"], - ) - for row in inventory - if row["axis"] == axis - and row["preferredCandidateId"] == conclusion.preferredCandidateId - } - supplied = { - (row.alternativeCandidateId, row.metric): row - for row in conclusion.tradeoffs - } - prefix = ( - f"comparisonConclusions[axis={axis!r}, " - f"preferredCandidateId={conclusion.preferredCandidateId!r}].tradeoffs" - ) - if len(supplied) != len(conclusion.tradeoffs): - tradeoff_errors.append(f"{prefix}: duplicate tradeoff entries") - for key in sorted(required_tradeoffs.keys() - supplied.keys()): - left, right = required_tradeoffs[key] - tradeoff_errors.append( - f"{prefix}: add alternativeCandidateId={key[0]!r}, metric={key[1]!r}, " - "and an interpretation " - f"(preferred={left!r}, alternative={right!r})" - ) - for key, row in supplied.items(): - if ( - key not in required_tradeoffs - or (row.preferredValue, row.alternativeValue) - != required_tradeoffs[key] - ): - tradeoff_errors.append( - f"{prefix}: use exact preferred and alternative measurements " - f"for {key!r}; expected {required_tradeoffs.get(key)!r}" - ) - if tradeoff_errors: - raise ValueError( - "Repair the comparisonConclusions entries' tradeoffs arrays. Each " - "missing entry must contain alternativeCandidateId, metric and " - "interpretation explaining the measured alternative advantage and " - "its tradeoff with the objective. Expanding quantitativeReason, " - "biologicalReason or the overall rationale does not fill these arrays. " - "Do not leave them empty when advantages are listed. Scarf supplies " - "preferredValue and alternativeValue; do not transcribe them. " - "A larger listed value is an advantage on that metric, even if " - "you prefer another candidate for other reasons. Required repairs: " - + "; ".join(tradeoff_errors) - ) + bind_comparison_measurements(coverage, action) if action["action"] == "combine": if coverage["phase"] != "sensitivity": raise ValueError( diff --git a/scarf/agent/parameter_tuning/execution.py b/scarf/agent/parameter_tuning/execution.py index 64a245d8..7340a9c9 100644 --- a/scarf/agent/parameter_tuning/execution.py +++ b/scarf/agent/parameter_tuning/execution.py @@ -7,8 +7,8 @@ import numpy as np -from ...metrics import graph_connectivity from ...metadata.rows import iter_metadata_column_blocks, metadata_missing_mask +from ...metrics import graph_connectivity from ...storage.refs import ArtifactRef from ...storage.types import as_zarr_array from ...utils.logging import logger diff --git a/scarf/agent/report/artifacts.py b/scarf/agent/report/artifacts.py index b71628dc..4467f5a1 100644 --- a/scarf/agent/report/artifacts.py +++ b/scarf/agent/report/artifacts.py @@ -151,7 +151,12 @@ def scientific_summary(snapshot: Mapping[str, Any]) -> dict[str, Any]: ) if population: for key in ("cellSelection", "clusters"): - if artifact_ref(population.get(key)) != artifact_ref(final.get(key)): + reference = population.get(key) + if not isinstance(reference, Mapping): + raise ValueError( + f"Reported population support lacks its {key} reference" + ) + if ArtifactRef.from_dict(reference) != artifact_ref(final.get(key)): raise ValueError( "Reported population support differs from the final analysis" ) diff --git a/scarf/agent/report/rendering.py b/scarf/agent/report/rendering.py index 6f01b167..558024f6 100644 --- a/scarf/agent/report/rendering.py +++ b/scarf/agent/report/rendering.py @@ -7,25 +7,30 @@ from .contracts import label, mapping, mappings, scalar, texts - _STYLES = """ -:root{color-scheme:light;font:16px/1.6 system-ui,sans-serif;color:#223137;background:#f4f6f5} -*{box-sizing:border-box}body{margin:0}main{max-width:1060px;margin:auto;padding:36px 28px 64px} -header{border-bottom:2px solid #237e6a;padding-bottom:22px}h1,h2,h3{line-height:1.25;color:#164c40} -h1{font-size:2.2rem;margin:8px 0}h2{font-size:1.4rem;margin-top:36px}h3{font-size:1.05rem} -p{max-width:90ch}a{color:#17644f}small,.muted{color:#586763}.numbers{font-size:1.25rem;font-weight:600} -.fraction{white-space:nowrap}progress{width:86px;height:12px;accent-color:#237e6a}.notice{background:#fff6df;border-left:4px solid #bd8b22;padding:12px 18px;margin:20px 0}.notice h2{margin-top:0} +:root{color-scheme:light;font-family:Inter,sans-serif;font-size:16px;font-weight:300;line-height:1.2;color:#000000;background:#ffffff;letter-spacing:-.04em} +*{box-sizing:border-box}body{margin:0;background:#ffffff}main{max-width:1120px;margin:auto;padding:48px 32px 72px} +header{border-bottom:1px solid #000000;padding-bottom:32px}h1,h2,h3{line-height:1.2;color:#000000;font-weight:400} +h1{font-size:3.5rem;letter-spacing:0;margin:12px 0 20px}h2{font-size:2rem;letter-spacing:-.04em;margin:56px 0 20px}h3{font-size:1.25rem;letter-spacing:-.04em;margin:32px 0 12px} +p{max-width:82ch}header h1+p{font-size:1.5rem;font-weight:300;margin:0 0 24px}a{color:#0077fc} +small{display:block;color:#b4b4b4;font-size:.75rem;font-weight:400;letter-spacing:-.04em;text-transform:uppercase}.muted{color:#b4b4b4} +.header-links{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:baseline;gap:8px 24px;margin-bottom:40px;font-size:.9rem}.header-links a{font-weight:400} +.numbers{display:inline-block;margin:0 0 20px;padding:10px 18px;border-radius:999px;background:#0077fc;color:#ffffff;font-size:1rem;font-weight:400} +.fraction{white-space:nowrap}progress{width:86px;height:8px;border:0;border-radius:999px;overflow:hidden;accent-color:#0077fc;background:#b4b4b4} +progress::-webkit-progress-bar{background:#b4b4b4;border-radius:999px}progress::-webkit-progress-value{background:#0077fc;border-radius:999px}progress::-moz-progress-bar{background:#0077fc;border-radius:999px} +section{margin-top:64px}section>h2:first-child{margin-top:0}.limitations::before{content:"";display:block;width:72px;height:8px;margin-bottom:24px;border-radius:999px;background:#0077fc} .population-overview{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.3fr);gap:20px;align-items:start}.population-overview figure{position:sticky;top:20px}.population-overview>div{min-width:0} -figure{margin:24px 0;background:white;padding:12px;border-radius:8px}figure img{width:100%;height:auto} -figcaption{font-size:.9rem;text-align:center}.decision{border-top:1px solid #ccd7d1;padding:14px 0} -.decision h3{margin:0}.decision p{margin:8px 0}details{margin:12px 0}summary{cursor:pointer;color:#17644f} +figure{margin:24px 0;background:#ffffff;padding:0}figure img{width:100%;height:auto} +figcaption{color:#b4b4b4;font-size:.9rem;text-align:center}.decision{border-top:1px solid #b4b4b4;padding:14px 0} +.decision h3{margin:0}.decision p{margin:8px 0}details{margin:20px 0}summary{display:inline-block;cursor:pointer;padding:10px 18px;border-radius:999px;box-shadow:inset 0 0 0 2px #0077fc;color:#0077fc;font-weight:400} +details[open] summary{background:#0077fc;color:#ffffff;box-shadow:none}summary:focus-visible{outline:2px solid #000000;outline-offset:3px} .table-wrap{overflow-x:auto}table{border-collapse:collapse;width:100%;font-size:.92rem;margin:14px 0} -th,td{text-align:left;vertical-align:top;padding:9px 12px;border-bottom:1px solid #d9e0dc} -th{background:#e9efeb}td p{margin:0}li{margin:6px 0} -footer{margin-top:36px;border-top:1px solid #ccd7d1;padding-top:18px;font-size:.85rem} +th,td{text-align:left;vertical-align:top;padding:10px 12px;border-bottom:1px solid #b4b4b4} +th,strong{font-weight:400}th{background:#ffffff}td p{margin:0}li{margin:8px 0} +footer{margin-top:64px;border-top:1px solid #000000;padding-top:20px;color:#b4b4b4;font-size:.85rem} @media(max-width:800px){.population-overview{display:block}.population-overview figure{position:static}} -@media(max-width:600px){main{padding:20px 14px}h1{font-size:1.7rem}th,td{padding:7px}} -@media print{body{background:white}main{padding:0}details{break-inside:avoid}} +@media(max-width:600px){main{padding:32px 16px 48px}h1{font-size:2.5rem}h2{font-size:1.75rem}header h1+p{font-size:1.25rem}th,td{padding:8px}} +@media print{body{background:#ffffff}main{padding:0}details{break-inside:avoid}} """ @@ -472,13 +477,13 @@ def render_analysis_document(payload: Mapping[str, Any]) -> str: ) return f""" -Scarf analysis summary
    -
    Scarf analysis

    Analysis summary

    {_escape(objective)}

    +Scarf analysis summary
    +
    Scarf analysis

    Analysis summary

    {_escape(objective)}

    {total:,} cells · {len(counts):,} clusters · {_escape(assay)}

    {qc_text}

    {_escape(outcome)}

    -{'" if limitations else ""}

    Populations and markers

    {map_markup}
    {_population_table(payload)}
    {_qc_section(payload)}{_design_section(payload)}{_comparison_sections(payload)}
    Selected methods and evidence{methods}{mode_note}{usage_note}

    Repeat and subsample agreement use adjusted Rand index. Marker coverage is the fraction of clusters with qualifying markers. These describe the selected analysis; they are not probabilities of biological correctness.

    {"
    Unavailable displays" + display_notes + "
    " if display_notes else ""} +{'

    Limits of this analysis

    ' + limitations + "
    " if limitations else ""}
    Generated locally by Scarf. All numerical evidence is read from the saved analysis; report generation makes no analysis or model calls.
    """ diff --git a/tests/test_agent_assessment_contract_repair.py b/tests/test_agent_assessment_contract_repair.py index 9be038fc..a78f2307 100644 --- a/tests/test_agent_assessment_contract_repair.py +++ b/tests/test_agent_assessment_contract_repair.py @@ -167,7 +167,7 @@ def run(): output_type=output_type, system_prompt="Adjudicate the supplied measured tradeoffs.", user_prompt=offered, - config=AgentRunConfig(retries=1), + config=AgentRunConfig(retries=2), output_validator=validate, on_attempt=attempts.append, ) @@ -206,10 +206,10 @@ def run(): with pytest.raises(UnexpectedModelBehavior, match="maximum output retries"): run() assert attempts[0].status == "failed" - assert len(attempts[0].validationRetries) == 2 - assert len(responses) == 2 + assert len(attempts[0].validationRetries) == 3 + assert len(responses) == (2 if repair else 3) assert len(attempts) == 1 - assert attempts[0].usage.requests == 2 + assert attempts[0].usage.requests == len(responses) assert json.dumps({"comparisonAdvantages": inventory}, sort_keys=True) == offered assert all( "preferredValue" not in tradeoff and "alternativeValue" not in tradeoff @@ -217,3 +217,203 @@ def run(): for conclusion in response["comparisonConclusions"] for tradeoff in conclusion["tradeoffs"] ) + + +def _permitted_feedback(feedback: str) -> list[dict[str, Any]]: + decoder = json.JSONDecoder() + for start, char in enumerate(feedback): + if char != "{": + continue + try: + value, _ = decoder.raw_decode(feedback[start:]) + except json.JSONDecodeError: + continue + if isinstance(value, dict) and "permittedTradeoffs" in value: + return value["permittedTradeoffs"] + pytest.fail("Repair feedback did not supply exact permittedTradeoffs as JSON") + + +def _apply_permitted(action: dict[str, Any], permitted: list[dict[str, Any]]) -> None: + for conclusion in action["comparisonConclusions"]: + conclusion["tradeoffs"] = [ + { + "alternativeCandidateId": row["alternativeCandidateId"], + "metric": row["metric"], + "interpretation": ( + "This measured advantage is counterevidence to the preference; " + "the recorded marker program motivates retaining the reference " + "while acknowledging the alternative's quantitative improvement." + ), + } + for row in permitted + if row["axis"] == conclusion["axis"] + and row["preferredCandidateId"] == conclusion["preferredCandidateId"] + ] + + +def test_one_retry_repairs_all_axes_without_pruning_or_rewriting_evidence() -> None: + coverage, action = _competing_review() + settings = coverage["candidateSettings"] + settings["resolution-high"]["metrics"]["subsampleStability"] = 0.7 + settings["genes-four"]["metrics"]["seedStability"] = 0.8 + inventory = comparison_advantages(coverage) + _apply_permitted(action, inventory) + by_axis = {row["axis"]: row for row in action["comparisonConclusions"]} + for axis, alternative, metric in ( + ("partition", "resolution-high", "subsampleStability"), + ("partition", "resolution-half", "markerCoherence"), + ("hvgCount", "genes-four", "seedStability"), + ("pca", "dimensions-thirty", "markerCoherence"), + ): + by_axis[axis]["tradeoffs"].append( + { + "alternativeCandidateId": alternative, + "metric": metric, + "interpretation": ( + "The alternative is tied or worse on this metric, which belongs " + "in the general quantitative explanation instead of counterevidence." + ), + } + ) + by_axis["partition"]["tradeoffs"].append( + deepcopy(by_axis["partition"]["tradeoffs"][0]) + ) + by_axis["neighbors"]["tradeoffs"] = [ + row for row in by_axis["neighbors"]["tradeoffs"] if row["metric"] != "macroF1" + ] + frozen_coverage, frozen_action = deepcopy(coverage), deepcopy(action) + responses: list[dict[str, Any]] = [] + attempts = [] + output_type = rna_tuning._assessment_output_type(list(settings), [], scope="full") + + def provider(messages: Any, info: Any) -> ModelResponse: + proposed = deepcopy(action) + if responses: + feedback = next( + str(part.content) + for message in reversed(messages) + for part in message.parts + if isinstance(part, RetryPromptPart) + ) + for identifier in ( + "resolution-high", + "resolution-half", + "genes-four", + "dimensions-thirty", + "neighbors-forty-one", + "duplicate", + "macroF1", + "markerCoherence", + "subsampleStability", + ): + assert identifier in feedback + permitted = _permitted_feedback(feedback) + preferences = { + (row["axis"], row["preferredCandidateId"]) + for row in proposed["comparisonConclusions"] + } + assert {json.dumps(row, sort_keys=True) for row in permitted} == { + json.dumps(row, sort_keys=True) + for row in inventory + if (row["axis"], row["preferredCandidateId"]) in preferences + } + _apply_permitted(proposed, permitted) + responses.append(deepcopy(proposed)) + return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, proposed)]) + + def validate(proposed: Any) -> rna_tuning.TuningAction: + bound = bind_comparison_measurements(coverage, proposed.model_dump(mode="json")) + validate_comparison_review(coverage, bound) + return rna_tuning.TuningAction.model_validate(bound) + + result = run_agent_sync( + model=FunctionModel(provider), + output_type=output_type, + system_prompt="Explain only the observed advantages using exact feedback.", + user_prompt=json.dumps({"comparisonAdvantages": inventory}), + config=AgentRunConfig(retries=2), + output_validator=validate, + on_attempt=attempts.append, + ) + completed = result.output.model_dump(mode="json") + validate_comparison_review(coverage, completed) + assert len(responses) == 2 + assert attempts[0].usage.requests == 2 + assert len(attempts[0].validationRetries) == 1 + for conclusion in completed["comparisonConclusions"]: + assert ( + conclusion["preferredCandidateId"] + == by_axis[conclusion["axis"]]["preferredCandidateId"] + ) + assert ( + conclusion["quantitativeReason"] + == by_axis[conclusion["axis"]]["quantitativeReason"] + ) + for tradeoff in conclusion["tradeoffs"]: + measured = next( + row + for row in inventory + if row["axis"] == conclusion["axis"] + and row["preferredCandidateId"] == conclusion["preferredCandidateId"] + and row["alternativeCandidateId"] == tradeoff["alternativeCandidateId"] + and row["metric"] == tradeoff["metric"] + ) + assert tradeoff["preferredValue"] == measured["preferredValue"] + assert tradeoff["alternativeValue"] == measured["alternativeValue"] + assert action == frozen_action + assert coverage == frozen_coverage + assert all( + "preferredValue" not in row and "alternativeValue" not in row + for response in responses + for conclusion in response["comparisonConclusions"] + for row in conclusion["tradeoffs"] + ) + + +def test_repair_rejects_unknown_and_fabricated_measurements_together() -> None: + coverage, action = _competing_review() + _apply_permitted(action, comparison_advantages(coverage)) + partition = next( + row for row in action["comparisonConclusions"] if row["axis"] == "partition" + ) + partition["tradeoffs"][0]["alternativeValue"] = 100.0 + partition["tradeoffs"].append( + { + "alternativeCandidateId": "unmeasured-candidate", + "metric": "seedStability", + "interpretation": "This candidate does not exist in the supplied evidence.", + } + ) + frozen = deepcopy(action) + with pytest.raises(ValueError) as failure: + bind_comparison_measurements(coverage, action) + feedback = str(failure.value) + assert "unmeasured-candidate" in feedback + assert "exact" in feedback + assert "resolution-half" in feedback + assert _permitted_feedback(feedback) + assert action == frozen + + +def test_changed_preference_recalculates_counterevidence_and_allows_no_advantages() -> ( + None +): + coverage, action = _competing_review() + _apply_permitted(action, comparison_advantages(coverage)) + partition = next( + row for row in action["comparisonConclusions"] if row["axis"] == "partition" + ) + partition["preferredCandidateId"] = "resolution-half" + action["selectedCandidateId"] = "resolution-half" + # Old obligations cannot be reused after a preference changes. + with pytest.raises(ValueError) as failure: + bind_comparison_measurements(coverage, action) + permitted = _permitted_feedback(str(failure.value)) + assert not any(row["axis"] == "partition" for row in permitted) + assert all(row["preferredCandidateId"] != "candidate-two" for row in permitted) + _apply_permitted(action, permitted) + assert partition["tradeoffs"] == [] + bound = bind_comparison_measurements(coverage, action) + validate_comparison_review(coverage, bound) + # General quantitative evidence remains available even without counterevidence. + assert bound["comparisonConclusions"][0]["quantitativeReason"] diff --git a/tests/test_agent_report.py b/tests/test_agent_report.py index 9db48915..217343e0 100644 --- a/tests/test_agent_report.py +++ b/tests/test_agent_report.py @@ -6,6 +6,7 @@ from types import SimpleNamespace from typing import Any +import numpy as np import pandas as pd import pytest @@ -18,6 +19,7 @@ ) from scarf.agent.report.rendering import render_analysis_document from scarf.agent.types import ArtifactReferenceModel +from scarf.storage.refs import ArtifactRef from tests.test_agent_analysis_plots import display_store @@ -139,12 +141,36 @@ def test_one_page_shows_recorded_choices_evidence_and_qualitative_findings( mode == "structured" ) assert ( - document.index("Limits of this analysis") - < document.index("final_umap.png") + document.index("final_umap.png") < document.index("Cell quality") + < document.index("Selected methods and evidence") + < document.index("Limits of this analysis") + < document.index("