"""Helpers for consuming canonical experiment exports."""
from __future__ import annotations
import csv
import json
from collections import defaultdict
from collections.abc import Mapping, Sequence
from pathlib import Path
from typing import Any, cast
from ._comparison import ComparisonResult
from .sequence import MarkovChainResult, fit_markov_chain_from_table
from .stats import (
ConditionComparisonReport,
RegressionResult,
build_condition_metric_table,
compare_condition_pairs,
fit_regression,
)
from .table import UnifiedTableValidationReport, coerce_unified_table, validate_unified_table
_ANALYSIS_ARTIFACT_FILES = (
"manifest.json",
"conditions.csv",
"runs.csv",
"events.csv",
"evaluations.csv",
)
_CONDITION_CONTEXT_EXCLUDE_COLUMNS = frozenset(
{
"study_id",
"condition_id",
"admissible",
"constraint_messages",
"assignment_meta_json",
}
)
_RUN_CONTEXT_EXCLUDE_COLUMNS = frozenset({"trace_path", "manifest_path"})
[docs]
def load_experiment_artifacts(path: str | Path) -> dict[str, Any]:
"""Load the canonical analysis-facing experiment artifacts.
Args:
path: Study output directory or the canonical ``events.csv`` path inside it.
Returns:
Mapping keyed by canonical artifact filename.
Raises:
ValueError: If ``path`` does not resolve to a canonical artifact directory.
"""
return _load_artifact_rows(path, artifact_names=_ANALYSIS_ARTIFACT_FILES)
[docs]
def validate_experiment_events(path: str | Path) -> UnifiedTableValidationReport:
"""Validate canonical ``events.csv`` output from design-research-experiments.
Args:
path: Study output directory or the canonical ``events.csv`` path inside it.
Returns:
Unified-table validation report for the exported event rows.
Raises:
ValueError: If ``path`` does not resolve to a canonical ``events.csv`` artifact.
"""
events_path = _resolve_events_path(path)
rows = _read_csv(events_path)
return validate_unified_table(coerce_unified_table(rows))
[docs]
def build_condition_metric_table_from_artifacts(
path: str | Path,
*,
metric: str,
condition_column: str = "condition",
run_id_column: str = "run_id",
condition_id_column: str = "condition_id",
evaluation_metric_column: str = "metric_name",
evaluation_value_column: str = "metric_value",
) -> list[dict[str, Any]]:
"""Build a condition metric table directly from canonical experiment artifacts."""
artifacts = _load_artifact_rows(
path,
artifact_names=("conditions.csv", "runs.csv", "evaluations.csv"),
)
return build_condition_metric_table(
artifacts["runs.csv"],
metric=metric,
condition_column=condition_column,
evaluations=artifacts["evaluations.csv"],
conditions=artifacts["conditions.csv"],
run_id_column=run_id_column,
condition_id_column=condition_id_column,
evaluation_metric_column=evaluation_metric_column,
evaluation_value_column=evaluation_value_column,
)
[docs]
def compare_condition_pairs_from_artifacts(
path: str | Path,
*,
metric: str,
condition_column: str = "condition",
condition_pairs: Sequence[tuple[str, str]] | None = None,
alternative: str = "two-sided",
alpha: float = 0.05,
exact_threshold: int = 250_000,
n_permutations: int = 20_000,
seed: int = 0,
) -> ConditionComparisonReport:
"""Compare condition pairs directly from canonical experiment artifacts."""
rows = build_condition_metric_table_from_artifacts(
path,
metric=metric,
condition_column=condition_column,
)
return compare_condition_pairs(
rows,
condition_pairs=condition_pairs,
alternative=alternative,
alpha=alpha,
exact_threshold=exact_threshold,
n_permutations=n_permutations,
seed=seed,
)
[docs]
def build_event_table_from_artifacts(
path: str | Path,
*,
condition_columns: Sequence[str] | None = None,
run_columns: Sequence[str] | None = None,
run_id_column: str = "run_id",
session_column: str = "session_id",
condition_id_column: str = "condition_id",
) -> list[dict[str, Any]]:
"""Return event rows enriched with run and condition context from artifacts."""
artifacts = _load_artifact_rows(
path,
artifact_names=("conditions.csv", "runs.csv", "events.csv"),
)
conditions = _rows(artifacts["conditions.csv"], table_name="conditions.csv")
runs = _rows(artifacts["runs.csv"], table_name="runs.csv")
events = _rows(artifacts["events.csv"], table_name="events.csv")
condition_lookup = _unique_row_map(
conditions,
key_column=condition_id_column,
table_name="conditions.csv",
)
run_lookup = _unique_row_map(runs, key_column=run_id_column, table_name="runs.csv")
resolved_condition_columns = _resolve_context_columns(
conditions,
requested_columns=condition_columns,
exclude_columns=_CONDITION_CONTEXT_EXCLUDE_COLUMNS,
table_name="conditions.csv",
)
resolved_run_columns = _resolve_context_columns(
runs,
requested_columns=run_columns,
exclude_columns=_RUN_CONTEXT_EXCLUDE_COLUMNS,
table_name="runs.csv",
)
enriched: list[dict[str, Any]] = []
for index, event in enumerate(events):
row = dict(event)
run_row = _resolve_event_run(
row,
run_lookup,
row_index=index,
run_id_column=run_id_column,
session_column=session_column,
)
condition_row = _resolve_condition(
row,
run_row,
condition_lookup,
row_index=index,
condition_id_column=condition_id_column,
)
_merge_context_columns(row, run_row, resolved_run_columns, source="run")
_merge_context_columns(
row,
condition_row,
resolved_condition_columns,
source="condition",
)
enriched.append(row)
return enriched
[docs]
def fit_markov_chains_from_artifacts(
path: str | Path,
*,
condition_column: str,
order: int = 1,
smoothing: float = 1.0,
event_column: str = "event_type",
session_column: str = "run_id",
actor_column: str = "actor_id",
include_actor_in_token: bool = False,
) -> dict[str, MarkovChainResult]:
"""Fit one Markov chain per condition directly from experiment artifacts."""
events = build_event_table_from_artifacts(path)
grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
for index, row in enumerate(events):
condition = row.get(condition_column)
if _is_blank(condition):
raise ValueError(
f"events row {index} is missing condition column {condition_column!r}."
)
grouped[str(condition)].append(row)
if len(grouped) < 2:
raise ValueError("At least two condition groups are required.")
results: dict[str, MarkovChainResult] = {}
for condition, rows in sorted(grouped.items()):
result = fit_markov_chain_from_table(
rows,
order=order,
smoothing=smoothing,
event_column=event_column,
session_column=session_column,
actor_column=actor_column,
include_actor_in_token=include_actor_in_token,
)
result.config.update(
{
"source": "experiment_artifacts",
"condition_column": condition_column,
"condition": condition,
}
)
results[condition] = result
return results
[docs]
def compare_markov_chains_from_artifacts(
path: str | Path,
*,
condition_column: str,
left_condition: str,
right_condition: str,
order: int = 1,
smoothing: float = 1.0,
event_column: str = "event_type",
session_column: str = "run_id",
actor_column: str = "actor_id",
include_actor_in_token: bool = False,
) -> ComparisonResult:
"""Compare two condition-specific Markov chains from experiment artifacts."""
chains = fit_markov_chains_from_artifacts(
path,
condition_column=condition_column,
order=order,
smoothing=smoothing,
event_column=event_column,
session_column=session_column,
actor_column=actor_column,
include_actor_in_token=include_actor_in_token,
)
try:
return chains[str(left_condition)] - chains[str(right_condition)]
except KeyError as exc:
available = ", ".join(sorted(chains))
raise ValueError(
f"Unknown condition {exc.args[0]!r}. Available conditions: {available}."
) from exc
[docs]
def build_run_metric_table_from_artifacts(
path: str | Path,
*,
metrics: str | Sequence[str],
condition_columns: Sequence[str] | None = None,
run_columns: Sequence[str] | None = None,
run_id_column: str = "run_id",
condition_id_column: str = "condition_id",
evaluation_metric_column: str = "metric_name",
evaluation_value_column: str = "metric_value",
) -> list[dict[str, Any]]:
"""Return one run-level row with requested metrics and experiment context."""
artifacts = _load_artifact_rows(
path,
artifact_names=("conditions.csv", "runs.csv", "evaluations.csv"),
)
return _build_run_metric_table(
artifacts,
metrics=_as_name_list(metrics, name="metrics"),
condition_columns=condition_columns,
run_columns=run_columns,
run_id_column=run_id_column,
condition_id_column=condition_id_column,
evaluation_metric_column=evaluation_metric_column,
evaluation_value_column=evaluation_value_column,
)
[docs]
def fit_regression_from_artifacts(
path: str | Path,
*,
outcome: str,
predictors: Sequence[str],
categorical_predictors: Sequence[str] = (),
condition_columns: Sequence[str] | None = None,
run_columns: Sequence[str] | None = None,
add_intercept: bool = True,
drop_first: bool = True,
) -> RegressionResult:
"""Fit ordinary least squares regression from canonical experiment artifacts."""
if not predictors:
raise ValueError("predictors must contain at least one column.")
artifacts = _load_artifact_rows(
path,
artifact_names=("conditions.csv", "runs.csv", "evaluations.csv"),
)
context_columns = _artifact_context_columns(
artifacts,
condition_columns=condition_columns,
run_columns=run_columns,
)
metric_names = _artifact_metric_names(artifacts)
metric_columns = [outcome]
for predictor in predictors:
if predictor not in context_columns and predictor in metric_names:
metric_columns.append(predictor)
rows = _build_run_metric_table(
artifacts,
metrics=_dedupe(metric_columns),
condition_columns=condition_columns,
run_columns=run_columns,
)
matrix, response, feature_names = _encode_regression_rows(
rows,
outcome=outcome,
predictors=predictors,
categorical_predictors=categorical_predictors,
drop_first=drop_first,
)
result = fit_regression(
matrix,
response,
feature_names=feature_names,
add_intercept=add_intercept,
)
result.config.update(
{
"source": "experiment_artifacts",
"outcome": outcome,
"predictors": list(predictors),
"categorical_predictors": list(categorical_predictors),
"drop_first": bool(drop_first),
}
)
return result
def _resolve_output_dir(path: str | Path) -> Path:
"""Resolve one study output directory from a directory or events path."""
candidate = Path(path).expanduser()
if candidate.is_dir():
return candidate
if candidate.is_file() and candidate.name == "events.csv":
return candidate.parent
raise ValueError(
"Expected a study output directory or the canonical 'events.csv' artifact path."
)
def _resolve_events_path(path: str | Path) -> Path:
"""Resolve the canonical ``events.csv`` path from a directory or file input."""
candidate = Path(path).expanduser()
events_path = candidate / "events.csv" if candidate.is_dir() else candidate
if not events_path.is_file() or events_path.name != "events.csv":
raise ValueError(
"Expected a study output directory or the canonical 'events.csv' artifact path."
)
return events_path
def _load_artifact_rows(
path: str | Path,
*,
artifact_names: Sequence[str],
) -> dict[str, Any]:
"""Load the requested canonical artifacts from one export directory."""
output_dir = _resolve_output_dir(path)
_require_artifacts(output_dir, artifact_names)
rows: dict[str, Any] = {}
for artifact_name in artifact_names:
artifact_path = output_dir / artifact_name
rows[artifact_name] = (
_read_json(artifact_path)
if artifact_path.suffix.lower() == ".json"
else _read_csv(artifact_path)
)
return rows
def _require_artifacts(output_dir: Path, artifact_names: Sequence[str]) -> None:
"""Raise a clear error when a canonical export file is missing."""
missing = [name for name in artifact_names if not (output_dir / name).exists()]
if missing:
raise ValueError("Missing canonical experiment artifacts: " + ", ".join(missing) + ".")
def _read_csv(path: Path) -> list[dict[str, Any]]:
"""Read one CSV artifact into row dictionaries."""
with path.open("r", encoding="utf-8", newline="") as handle:
return list(csv.DictReader(handle))
def _read_json(path: Path) -> dict[str, Any]:
"""Read one JSON artifact into a dictionary."""
payload = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError(f"Expected JSON object payload in '{path.name}'.")
return payload
def _rows(data: Any, *, table_name: str) -> list[dict[str, Any]]:
try:
rows = coerce_unified_table(data, config=None)
except ValueError as exc:
raise ValueError(f"Failed to coerce {table_name}: {exc}") from exc
return rows
def _is_blank(value: Any) -> bool:
return value is None or (isinstance(value, str) and value.strip() == "")
def _as_name_list(names: str | Sequence[str], *, name: str) -> list[str]:
if isinstance(names, str):
if not names.strip():
raise ValueError(f"{name} must not contain blank column names.")
return [names]
resolved = [str(item) for item in names]
if not resolved or any(not item.strip() for item in resolved):
raise ValueError(f"{name} must not be empty or contain blank column names.")
return resolved
def _dedupe(names: Sequence[str]) -> list[str]:
seen: set[str] = set()
deduped: list[str] = []
for name in names:
if name not in seen:
deduped.append(name)
seen.add(name)
return deduped
def _stable_columns(rows: Sequence[Mapping[str, Any]]) -> list[str]:
seen: set[str] = set()
columns: list[str] = []
for row in rows:
for column in row:
if column not in seen:
columns.append(str(column))
seen.add(str(column))
return columns
def _resolve_context_columns(
rows: Sequence[Mapping[str, Any]],
*,
requested_columns: Sequence[str] | None,
exclude_columns: frozenset[str],
table_name: str,
) -> list[str]:
available = _stable_columns(rows)
if requested_columns is None:
return [column for column in available if column not in exclude_columns]
resolved = _as_name_list(tuple(requested_columns), name="requested_columns")
missing = [column for column in resolved if column not in available]
if missing:
raise ValueError(f"{table_name} is missing requested columns: {', '.join(missing)}.")
return resolved
def _unique_row_map(
rows: Sequence[Mapping[str, Any]],
*,
key_column: str,
table_name: str,
) -> dict[str, dict[str, Any]]:
resolved: dict[str, dict[str, Any]] = {}
for index, row in enumerate(rows):
raw_key = row.get(key_column)
if _is_blank(raw_key):
raise ValueError(f"{table_name} row {index} is missing {key_column!r}.")
key = str(raw_key)
if key in resolved:
raise ValueError(f"{table_name} contains duplicate {key_column!r} value {key!r}.")
resolved[key] = dict(row)
return resolved
def _resolve_event_run(
event: Mapping[str, Any],
run_lookup: Mapping[str, dict[str, Any]],
*,
row_index: int,
run_id_column: str,
session_column: str,
) -> dict[str, Any]:
raw_run_id = event.get(run_id_column)
if _is_blank(raw_run_id):
raw_run_id = event.get(session_column)
if _is_blank(raw_run_id):
raise ValueError(
f"events.csv row {row_index} is missing {run_id_column!r} and {session_column!r}."
)
run_id = str(raw_run_id)
try:
return run_lookup[run_id]
except KeyError as exc:
raise ValueError(
f"events.csv row {row_index} references unknown run_id {run_id!r}."
) from exc
def _resolve_condition(
event: Mapping[str, Any],
run_row: Mapping[str, Any],
condition_lookup: Mapping[str, dict[str, Any]],
*,
row_index: int,
condition_id_column: str,
) -> dict[str, Any]:
raw_condition_id = event.get(condition_id_column)
if _is_blank(raw_condition_id):
raw_condition_id = run_row.get(condition_id_column)
if _is_blank(raw_condition_id):
raise ValueError(f"events.csv row {row_index} has no {condition_id_column!r} context.")
condition_id = str(raw_condition_id)
try:
return condition_lookup[condition_id]
except KeyError as exc:
raise ValueError(
f"events.csv row {row_index} references unknown condition_id {condition_id!r}."
) from exc
def _merge_context_columns(
target: dict[str, Any],
source_row: Mapping[str, Any],
columns: Sequence[str],
*,
source: str,
) -> None:
for column in columns:
if column not in source_row:
continue
value = source_row[column]
if column not in target or _is_blank(target[column]):
target[column] = value
elif not _is_blank(value) and str(target[column]) != str(value):
target[f"{source}_{column}"] = value
def _artifact_context_columns(
artifacts: Mapping[str, Any],
*,
condition_columns: Sequence[str] | None,
run_columns: Sequence[str] | None,
) -> set[str]:
conditions = _rows(artifacts["conditions.csv"], table_name="conditions.csv")
runs = _rows(artifacts["runs.csv"], table_name="runs.csv")
return set(
_resolve_context_columns(
conditions,
requested_columns=condition_columns,
exclude_columns=_CONDITION_CONTEXT_EXCLUDE_COLUMNS,
table_name="conditions.csv",
)
) | set(
_resolve_context_columns(
runs,
requested_columns=run_columns,
exclude_columns=_RUN_CONTEXT_EXCLUDE_COLUMNS,
table_name="runs.csv",
)
)
def _artifact_metric_names(artifacts: Mapping[str, Any]) -> set[str]:
runs = _rows(artifacts["runs.csv"], table_name="runs.csv")
evaluations = _rows(artifacts["evaluations.csv"], table_name="evaluations.csv")
names = set(_stable_columns(runs))
for row in evaluations:
metric_name = row.get("metric_name")
if not _is_blank(metric_name):
names.add(str(metric_name))
return names
def _build_run_metric_table(
artifacts: Mapping[str, Any],
*,
metrics: Sequence[str],
condition_columns: Sequence[str] | None,
run_columns: Sequence[str] | None,
run_id_column: str = "run_id",
condition_id_column: str = "condition_id",
evaluation_metric_column: str = "metric_name",
evaluation_value_column: str = "metric_value",
) -> list[dict[str, Any]]:
conditions = _rows(artifacts["conditions.csv"], table_name="conditions.csv")
runs = _rows(artifacts["runs.csv"], table_name="runs.csv")
evaluations = _rows(artifacts["evaluations.csv"], table_name="evaluations.csv")
condition_lookup = _unique_row_map(
conditions,
key_column=condition_id_column,
table_name="conditions.csv",
)
evaluation_lookup = _collect_rows_by_run_id(
evaluations,
run_id_column=run_id_column,
table_name="evaluations.csv",
)
resolved_condition_columns = _resolve_context_columns(
conditions,
requested_columns=condition_columns,
exclude_columns=_CONDITION_CONTEXT_EXCLUDE_COLUMNS,
table_name="conditions.csv",
)
resolved_run_columns = _resolve_context_columns(
runs,
requested_columns=run_columns,
exclude_columns=_RUN_CONTEXT_EXCLUDE_COLUMNS,
table_name="runs.csv",
)
rows: list[dict[str, Any]] = []
for index, run_row in enumerate(runs):
raw_run_id = run_row.get(run_id_column)
if _is_blank(raw_run_id):
raise ValueError(f"runs.csv row {index} is missing {run_id_column!r}.")
run_id = str(raw_run_id)
raw_condition_id = run_row.get(condition_id_column)
if _is_blank(raw_condition_id):
raise ValueError(f"runs.csv row {index} is missing {condition_id_column!r}.")
condition_id = str(raw_condition_id)
condition_row = condition_lookup.get(condition_id)
if condition_row is None:
raise ValueError(
f"runs.csv row {index} references unknown condition_id {condition_id!r}."
)
row: dict[str, Any] = {}
_merge_context_columns(row, run_row, resolved_run_columns, source="run")
_merge_context_columns(row, condition_row, resolved_condition_columns, source="condition")
row.setdefault(run_id_column, run_id)
row.setdefault(condition_id_column, condition_id)
for metric in metrics:
row[metric] = _run_metric_value(
run_row,
evaluation_lookup.get(run_id, []),
metric=metric,
run_id=run_id,
evaluation_metric_column=evaluation_metric_column,
evaluation_value_column=evaluation_value_column,
)
rows.append(row)
return rows
def _collect_rows_by_run_id(
rows: Sequence[Mapping[str, Any]],
*,
run_id_column: str,
table_name: str,
) -> dict[str, list[dict[str, Any]]]:
grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
for index, row in enumerate(rows):
raw_run_id = row.get(run_id_column)
if _is_blank(raw_run_id):
raise ValueError(f"{table_name} row {index} is missing {run_id_column!r}.")
grouped[str(raw_run_id)].append(dict(row))
return dict(grouped)
def _run_metric_value(
run_row: Mapping[str, Any],
evaluation_rows: Sequence[Mapping[str, Any]],
*,
metric: str,
run_id: str,
evaluation_metric_column: str,
evaluation_value_column: str,
) -> float:
if metric in run_row and not _is_blank(run_row[metric]):
return float(run_row[metric])
matching = [
row
for row in evaluation_rows
if str(row.get(evaluation_metric_column, "")) == metric
and (_is_blank(row.get("aggregation_level")) or str(row.get("aggregation_level")) == "run")
]
if not matching:
raise ValueError(f"No metric {metric!r} was found for run_id {run_id!r}.")
if len(matching) > 1:
raise ValueError(f"Multiple metric rows matched {metric!r} for run_id {run_id!r}.")
value = matching[0].get(evaluation_value_column)
if _is_blank(value):
raise ValueError(f"Metric {metric!r} for run_id {run_id!r} is missing a value.")
return float(cast(float | int | str, value))
def _encode_regression_rows(
rows: Sequence[Mapping[str, Any]],
*,
outcome: str,
predictors: Sequence[str],
categorical_predictors: Sequence[str],
drop_first: bool,
) -> tuple[list[list[float]], list[float], list[str]]:
categorical = set(categorical_predictors)
unknown_categorical = sorted(categorical.difference(predictors))
if unknown_categorical:
raise ValueError(
"categorical_predictors must be a subset of predictors. Unknown: "
+ ", ".join(unknown_categorical)
)
levels_by_predictor: dict[str, list[str]] = {}
for predictor in categorical:
levels = sorted(
{
str(row[predictor])
for index, row in enumerate(rows)
if _require_value(row, predictor, row_index=index) is not None
}
)
if not levels:
raise ValueError(f"Categorical predictor {predictor!r} has no observed levels.")
levels_by_predictor[predictor] = levels[1:] if drop_first else levels
feature_names: list[str] = []
for predictor in predictors:
if predictor in categorical:
feature_names.extend(
f"{predictor}[{level}]" for level in levels_by_predictor[predictor]
)
else:
feature_names.append(predictor)
matrix: list[list[float]] = []
response: list[float] = []
for row_index, row in enumerate(rows):
response.append(float(_require_value(row, outcome, row_index=row_index)))
features: list[float] = []
for predictor in predictors:
value = _require_value(row, predictor, row_index=row_index)
if predictor in categorical:
encoded_levels = levels_by_predictor[predictor]
features.extend(1.0 if str(value) == level else 0.0 for level in encoded_levels)
else:
try:
features.append(float(value))
except (TypeError, ValueError) as exc:
raise ValueError(
f"Predictor {predictor!r} must be numeric or listed in "
"categorical_predictors."
) from exc
matrix.append(features)
return matrix, response, feature_names
def _require_value(row: Mapping[str, Any], column: str, *, row_index: int) -> Any:
if column not in row or _is_blank(row[column]):
raise ValueError(f"analysis row {row_index} is missing {column!r}.")
return row[column]
__all__ = [
"build_condition_metric_table_from_artifacts",
"build_event_table_from_artifacts",
"build_run_metric_table_from_artifacts",
"compare_condition_pairs_from_artifacts",
"compare_markov_chains_from_artifacts",
"fit_markov_chains_from_artifacts",
"fit_regression_from_artifacts",
"load_experiment_artifacts",
"validate_experiment_events",
]