Skip to content

Workspace

The on-disk research project.

workspace

The on-disk research project: layout, discovery, reads, and writes.

A research project is structured as plain, durable files on disk: - science.toml: Project configuration and tool definitions. - GOAL.md: Single source of truth for the overarching research question. - LEARNINGS.md: Append-only empirical memory repository. - CAMPAIGN.md: Aggregated leaderboard and lineage overview. - tools/: Local Python tools. - research/: Experiment directories containing hypotheses, workflows, and runs.

ProjectError

Bases: RuntimeError

Raised when the project workspace is missing, invalid, or corrupted.

ToolProvider dataclass

ToolProvider(name, kind, path='', command='', url='', token_env='', enabled=True)

One declared tool provider.

Attributes:

Name Type Description
name str

Tool provider name.

kind str

Provider type ('local', 'stdio', 'http').

path str

Path to local tools directory.

command str

Command to start MCP stdio server.

url str

URL for remote MCP server.

token_env str

Environment variable containing authorization token.

enabled bool

Whether this provider is active.

from_dict classmethod

from_dict(name, data)

Constructs a ToolProvider from dictionary data.

Source code in python/src/science_adk/workspace.py
@classmethod
def from_dict(cls, name: str, data: dict[str, Any]) -> ToolProvider:
    """Constructs a ToolProvider from dictionary data."""
    if data.get("path"):
        kind = "local"
    elif data.get("command"):
        kind = "stdio"
    elif data.get("url"):
        kind = "http"
    else:
        raise ProjectError(
            f"Tool provider [tools.{name}] must define one of: "
            "path (local directory), command (MCP stdio), url (MCP http)."
        )
    return cls(
        name=name,
        kind=kind,
        path=data.get("path", ""),
        command=data.get("command", ""),
        url=data.get("url", ""),
        token_env=data.get("token_env", ""),
        enabled=bool(data.get("enabled", True)),
    )

Config dataclass

Config(project='research', providers=None, max_optimize_iterations=5, node_timeout_s=900, raw=None)

Configuration loaded from science.toml.

Attributes:

Name Type Description
project str

Name of the research project.

providers list[ToolProvider]

List of configured ToolProviders.

max_optimize_iterations int

Maximum optimization loop iterations.

node_timeout_s int

Execution timeout in seconds per node.

raw dict[str, Any]

Complete unparsed configuration dictionary.

load classmethod

load(path)

Loads and parses science.toml from the given path.

Source code in python/src/science_adk/workspace.py
@classmethod
def load(cls, path: Path) -> Config:
    """Loads and parses science.toml from the given path."""
    if not path.exists():
        return cls()
    if tomllib is None:  # pragma: no cover
        raise ProjectError(
            "Reading science.toml needs Python 3.11+ (or install tomli)."
        )
    data = tomllib.loads(path.read_text(encoding="utf-8"))
    run = data.get("run", {})
    return cls(
        project=data.get("project", {}).get("name", "research"),
        providers=[
            ToolProvider.from_dict(name, cfg)
            for name, cfg in (data.get("tools") or {}).items()
        ],
        max_optimize_iterations=int(run.get("max_optimize_iterations", 5)),
        node_timeout_s=int(run.get("node_timeout_s", 900)),
        raw=data,
    )

Project

Project(root)

Manages files and directories in a Science ADK research workspace.

Source code in python/src/science_adk/workspace.py
def __init__(self, root: Path | str):
    self.root = Path(root).resolve()

exists property

exists

Returns True if science.toml exists in this project root.

config_path property

config_path

Path to science.toml.

goal_path property

goal_path

Path to GOAL.md.

learnings_path property

learnings_path

Path to LEARNINGS.md.

campaign_path property

campaign_path

Path to CAMPAIGN.md.

research_dir property

research_dir

Path to research/ directory.

tools_dir property

tools_dir

Path to tools/ directory.

find classmethod

find(start=None)

Discovers the root Project by walking up parent directories.

Parameters:

Name Type Description Default
start Path | str | None

Directory to start searching from (defaults to cwd).

None

Returns:

Type Description
Project

Discovered Project instance.

Raises:

Type Description
ProjectError

If no science.toml is found.

Source code in python/src/science_adk/workspace.py
@classmethod
def find(cls, start: Path | str | None = None) -> Project:
    """Discovers the root Project by walking up parent directories.

    Args:
      start: Directory to start searching from (defaults to cwd).

    Returns:
      Discovered Project instance.

    Raises:
      ProjectError: If no science.toml is found.
    """
    current = Path(start or Path.cwd()).resolve()
    for candidate in [current, *current.parents]:
        if (candidate / CONFIG_FILE).exists():
            return cls(candidate)
    raise ProjectError(
        f"No {CONFIG_FILE} found in {current} or any parent directory. "
        "Run `science-adk init` to create a research project here."
    )

experiment_dir

experiment_dir(experiment_id)

Returns the directory for a specific experiment ID.

Source code in python/src/science_adk/workspace.py
def experiment_dir(self, experiment_id: str) -> Path:
    """Returns the directory for a specific experiment ID."""
    return self.research_dir / experiment_id

runs_dir

runs_dir(experiment_id)

Returns the runs/ directory for a specific experiment ID.

Source code in python/src/science_adk/workspace.py
def runs_dir(self, experiment_id: str) -> Path:
    """Returns the runs/ directory for a specific experiment ID."""
    return self.experiment_dir(experiment_id) / "runs"

run_dir

run_dir(experiment_id, run_id)

Returns the specific run directory.

Source code in python/src/science_adk/workspace.py
def run_dir(self, experiment_id: str, run_id: str) -> Path:
    """Returns the specific run directory."""
    return self.runs_dir(experiment_id) / run_id

rel

rel(path)

Formats path relative to the project root for display.

Source code in python/src/science_adk/workspace.py
def rel(self, path: Path) -> str:
    """Formats path relative to the project root for display."""
    try:
        return str(path.relative_to(self.root))
    except ValueError:
        return str(path)

config

config()

Loads and returns the Project configuration.

Source code in python/src/science_adk/workspace.py
def config(self) -> Config:
    """Loads and returns the Project configuration."""
    return Config.load(self.config_path)

read_goal

read_goal()

Reads and parses GOAL.md.

Source code in python/src/science_adk/workspace.py
def read_goal(self) -> Goal:
    """Reads and parses GOAL.md."""
    if not self.goal_path.exists():
        return Goal()
    meta, body = parse_frontmatter(self.goal_path.read_text(encoding="utf-8"))
    constraints = [
        line.lstrip("-* ").strip()
        for line in _section(body, "Constraints").splitlines()
        if line.strip().startswith(("-", "*"))
    ]
    return Goal(
        question=_text(meta.get("question")) or _section(body, "Question"),
        background=_section(body, "Background"),
        target_metric=_text(meta.get("target_metric")),
        target_value=meta.get("target_value"),
        constraints=constraints,
    )

write_goal

write_goal(goal)

Serializes and writes Goal data to GOAL.md.

Source code in python/src/science_adk/workspace.py
def write_goal(self, goal: Goal) -> Path:
    """Serializes and writes Goal data to GOAL.md."""
    meta = render_frontmatter(
        {
            "question": goal.question,
            "target_metric": goal.target_metric,
            "target_value": goal.target_value,
        }
    )
    parts = [
        meta,
        "",
        "# Research Goal",
        "",
        "## Question",
        "",
        goal.question or "_Not set._",
    ]
    if goal.background:
        parts += ["", "## Background", "", goal.background]
    if goal.constraints:
        parts += ["", "## Constraints", ""]
        parts += [f"- {c}" for c in goal.constraints]
    write_text(self.goal_path, "\n".join(parts))
    return self.goal_path

experiment_ids

experiment_ids()

Returns sorted list of experiment directory IDs.

Source code in python/src/science_adk/workspace.py
def experiment_ids(self) -> list[str]:
    """Returns sorted list of experiment directory IDs."""
    if not self.research_dir.exists():
        return []
    return sorted(
        d.name
        for d in self.research_dir.iterdir()
        if d.is_dir() and (d / "HYPOTHESIS.md").exists()
    )

experiments

experiments()

Returns all Experiment objects in the project.

Source code in python/src/science_adk/workspace.py
def experiments(self) -> list[Experiment]:
    """Returns all Experiment objects in the project."""
    return [self.read_experiment(eid) for eid in self.experiment_ids()]

next_experiment_id

next_experiment_id(hypothesis)

Generates a sequential experiment ID slug.

Source code in python/src/science_adk/workspace.py
def next_experiment_id(self, hypothesis: str) -> str:
    """Generates a sequential experiment ID slug."""
    existing = self.experiment_ids()
    number = 1
    for eid in existing:
        head = eid.split("-", 1)[0]
        if head.isdigit():
            number = max(number, int(head) + 1)
    return f"{number:03d}-{slugify(hypothesis, 40)}"

read_experiment

read_experiment(experiment_id)

Reads and parses an experiment's HYPOTHESIS.md file.

Source code in python/src/science_adk/workspace.py
def read_experiment(self, experiment_id: str) -> Experiment:
    """Reads and parses an experiment's HYPOTHESIS.md file."""
    path = self.experiment_dir(experiment_id) / "HYPOTHESIS.md"
    if not path.exists():
        raise ProjectError(
            f"Unknown experiment {experiment_id!r} (expected {self.rel(path)})."
        )
    meta, body = parse_frontmatter(path.read_text(encoding="utf-8"))
    return Experiment(
        id=experiment_id,
        hypothesis=(
            str(meta.get("hypothesis", "")) or _section(body, "Hypothesis")
        ),
        rationale=_section(body, "Rationale"),
        parent=str(meta.get("parent", "") or ""),
        generation=int(meta.get("generation", 0) or 0),
        status=str(meta.get("status", "draft")),
        best_run=str(meta.get("best_run", "") or ""),
        best_score=meta.get("best_score"),
        created_at=str(meta.get("created_at", utcnow())),
    )

write_experiment

write_experiment(experiment)

Writes an Experiment model to HYPOTHESIS.md.

Source code in python/src/science_adk/workspace.py
def write_experiment(self, experiment: Experiment) -> Path:
    """Writes an Experiment model to HYPOTHESIS.md."""
    path = self.experiment_dir(experiment.id) / "HYPOTHESIS.md"
    meta = render_frontmatter(
        {
            "hypothesis": experiment.hypothesis,
            "parent": experiment.parent,
            "generation": experiment.generation,
            "status": experiment.status,
            "best_run": experiment.best_run,
            "best_score": experiment.best_score,
            "created_at": experiment.created_at,
        }
    )
    parts = [
        meta,
        "",
        f"# {experiment.id}",
        "",
        "## Hypothesis",
        "",
        experiment.hypothesis or "_Not set._",
    ]
    if experiment.rationale:
        parts += ["", "## Rationale", "", experiment.rationale]
    if experiment.parent:
        parts += [
            "",
            "## Lineage",
            "",
            f"Descends from `{experiment.parent}` (generation"
            f" {experiment.generation}).",
        ]
    write_text(path, "\n".join(parts))
    return path

delete_experiment

delete_experiment(experiment_id)

Deletes an experiment directory from disk.

Source code in python/src/science_adk/workspace.py
def delete_experiment(self, experiment_id: str) -> None:
    """Deletes an experiment directory from disk."""
    shutil.rmtree(self.experiment_dir(experiment_id), ignore_errors=True)

workflow_path

workflow_path(experiment_id)

Returns path to workflow.json for an experiment.

Source code in python/src/science_adk/workspace.py
def workflow_path(self, experiment_id: str) -> Path:
    """Returns path to workflow.json for an experiment."""
    return self.experiment_dir(experiment_id) / "workflow.json"

read_workflow

read_workflow(experiment_id)

Reads workflow.json for an experiment.

Source code in python/src/science_adk/workspace.py
def read_workflow(self, experiment_id: str) -> Workflow:
    """Reads workflow.json for an experiment."""
    return Workflow.from_dict(read_json(self.workflow_path(experiment_id)))

write_workflow

write_workflow(experiment_id, workflow)

Writes workflow.json for an experiment.

Source code in python/src/science_adk/workspace.py
def write_workflow(self, experiment_id: str, workflow: Workflow) -> Path:
    """Writes workflow.json for an experiment."""
    path = self.workflow_path(experiment_id)
    write_json(path, workflow.to_dict())
    return path

agent_path

agent_path(experiment_id, module)

Returns the path to an agent Python source file.

Source code in python/src/science_adk/workspace.py
def agent_path(self, experiment_id: str, module: str) -> Path:
    """Returns the path to an agent Python source file."""
    return self.experiment_dir(experiment_id) / module

new_run_id

new_run_id(experiment_id)

Allocates a unique chronological run ID.

Source code in python/src/science_adk/workspace.py
def new_run_id(self, experiment_id: str) -> str:
    """Allocates a unique chronological run ID."""
    stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
    run_id, suffix = stamp, 1
    while self.run_dir(experiment_id, run_id).exists():
        suffix += 1
        run_id = f"{stamp}-{suffix}"
    return run_id

run_ids

run_ids(experiment_id)

Returns sorted list of run IDs for an experiment.

Source code in python/src/science_adk/workspace.py
def run_ids(self, experiment_id: str) -> list[str]:
    """Returns sorted list of run IDs for an experiment."""
    runs = self.runs_dir(experiment_id)
    if not runs.exists():
        return []
    return sorted(d.name for d in runs.iterdir() if d.is_dir())

latest_run_id

latest_run_id(experiment_id)

Returns the most recent run ID for an experiment.

Source code in python/src/science_adk/workspace.py
def latest_run_id(self, experiment_id: str) -> str:
    """Returns the most recent run ID for an experiment."""
    ids = self.run_ids(experiment_id)
    if not ids:
        raise ProjectError(
            f"Experiment {experiment_id!r} has no runs yet. Run"
            " `science-adk run` first."
        )
    return ids[-1]

read_trace

read_trace(experiment_id, run_id)

Reads trace.json for a specific run.

Source code in python/src/science_adk/workspace.py
def read_trace(self, experiment_id: str, run_id: str) -> Trace:
    """Reads trace.json for a specific run."""
    return Trace.from_dict(
        read_json(self.run_dir(experiment_id, run_id) / "trace.json")
    )

write_trace

write_trace(experiment_id, trace)

Writes trace.json for a specific run.

Source code in python/src/science_adk/workspace.py
def write_trace(self, experiment_id: str, trace: Trace) -> Path:
    """Writes trace.json for a specific run."""
    path = self.run_dir(experiment_id, trace.run_id) / "trace.json"
    write_json(path, trace.to_dict())
    return path

score_path

score_path(experiment_id, run_id)

Returns path to score.json for a run.

Source code in python/src/science_adk/workspace.py
def score_path(self, experiment_id: str, run_id: str) -> Path:
    """Returns path to score.json for a run."""
    return self.run_dir(experiment_id, run_id) / "score.json"

read_score

read_score(experiment_id, run_id)

Reads score.json for a run.

Source code in python/src/science_adk/workspace.py
def read_score(self, experiment_id: str, run_id: str) -> Score:
    """Reads score.json for a run."""
    return Score.from_dict(read_json(self.score_path(experiment_id, run_id)))

write_score

write_score(experiment_id, score)

Writes score.json for a run.

Source code in python/src/science_adk/workspace.py
def write_score(self, experiment_id: str, score: Score) -> Path:
    """Writes score.json for a run."""
    path = self.score_path(experiment_id, score.run_id)
    write_json(path, score.to_dict())
    return path

data_dir

data_dir(experiment_id, run_id)

Returns the data/ directory path for a run.

Source code in python/src/science_adk/workspace.py
def data_dir(self, experiment_id: str, run_id: str) -> Path:
    """Returns the data/ directory path for a run."""
    return self.run_dir(experiment_id, run_id) / "data"

iter_runs

iter_runs(experiment_id)

Yields (run_id, trace) tuples for an experiment.

Source code in python/src/science_adk/workspace.py
def iter_runs(self, experiment_id: str) -> Iterator[tuple[str, Trace]]:
    """Yields (run_id, trace) tuples for an experiment."""
    for run_id in self.run_ids(experiment_id):
        try:
            yield run_id, self.read_trace(experiment_id, run_id)
        except ProjectError:
            continue

best_score

best_score(experiment_id)

Finds the highest-scoring audited run for an experiment.

Source code in python/src/science_adk/workspace.py
def best_score(self, experiment_id: str) -> tuple[str, Score] | None:
    """Finds the highest-scoring audited run for an experiment."""
    best: tuple[str, Score] | None = None
    for run_id in self.run_ids(experiment_id):
        path = self.score_path(experiment_id, run_id)
        if not path.exists():
            continue
        score = Score.from_dict(read_json(path))
        if best is None or score.value > best[1].value:
            best = (run_id, score)
    return best

append_learning

append_learning(learning)

Appends an empirical insight to LEARNINGS.md.

Source code in python/src/science_adk/workspace.py
def append_learning(self, learning: Learning) -> Path:
    """Appends an empirical insight to LEARNINGS.md."""
    path = self.learnings_path
    if not path.exists():
        write_text(
            path,
            "# Learnings\n\n"
            "Empirical insights from this project. Append-only: each entry "
            "cites the run that produced it.\n",
        )
    cite = " ".join(
        part
        for part in (
            f"`{learning.experiment}`" if learning.experiment else "",
            f"run `{learning.run_id}`" if learning.run_id else "",
        )
        if part
    )
    entry = [f"\n## {learning.created_at}", ""]
    entry.append(learning.insight)
    if learning.evidence:
        entry += ["", f"**Evidence:** {learning.evidence}"]
    if cite:
        entry += ["", f"**Source:** {cite}"]
    with path.open("a", encoding="utf-8") as handle:
        handle.write("\n".join(entry) + "\n")
    return path

read_learnings

read_learnings()

Parses LEARNINGS.md into Learning objects.

Source code in python/src/science_adk/workspace.py
def read_learnings(self) -> list[Learning]:
    """Parses LEARNINGS.md into Learning objects."""
    if not self.learnings_path.exists():
        return []
    text = self.learnings_path.read_text(encoding="utf-8")
    out: list[Learning] = []
    for block in re.split(r"^##\s+", text, flags=re.MULTILINE)[1:]:
        lines = block.strip().splitlines()
        if not lines:
            continue
        created = lines[0].strip()
        body = "\n".join(lines[1:]).strip()
        insight = body.split("\n**")[0].strip()
        evidence = ""
        match = re.search(r"\*\*Evidence:\*\*\s*(.+)", body)
        if match:
            evidence = match.group(1).strip()
        out.append(Learning(insight=insight, evidence=evidence, created_at=created))
    return out

read_json

read_json(path)

Reads a JSON file from disk with descriptive error handling.

Parameters:

Name Type Description Default
path Path

Path to the JSON file.

required

Returns:

Type Description
dict[str, Any]

Parsed dictionary data.

Raises:

Type Description
ProjectError

If the file is missing or contains invalid JSON.

Source code in python/src/science_adk/workspace.py
def read_json(path: Path) -> dict[str, Any]:
    """Reads a JSON file from disk with descriptive error handling.

    Args:
      path: Path to the JSON file.

    Returns:
      Parsed dictionary data.

    Raises:
      ProjectError: If the file is missing or contains invalid JSON.
    """
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except FileNotFoundError:
        raise ProjectError(f"Missing file: {path}") from None
    except json.JSONDecodeError as exc:
        raise ProjectError(f"Invalid JSON in {path}: {exc}") from None

write_json

write_json(path, data)

Writes JSON data atomically to disk with pretty printing.

Parameters:

Name Type Description Default
path Path

Target destination path.

required
data Any

Data to serialize.

required
Source code in python/src/science_adk/workspace.py
def write_json(path: Path, data: Any) -> None:
    """Writes JSON data atomically to disk with pretty printing.

    Args:
      path: Target destination path.
      data: Data to serialize.
    """
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_suffix(path.suffix + ".tmp")
    tmp.write_text(json.dumps(data, indent=2, default=str) + "\n", encoding="utf-8")
    os.replace(tmp, path)

write_text

write_text(path, text)

Writes text content to disk ensuring a trailing newline.

Parameters:

Name Type Description Default
path Path

Target destination path.

required
text str

String content to write.

required
Source code in python/src/science_adk/workspace.py
def write_text(path: Path, text: str) -> None:
    """Writes text content to disk ensuring a trailing newline.

    Args:
      path: Target destination path.
      text: String content to write.
    """
    path.parent.mkdir(parents=True, exist_ok=True)
    if not text.endswith("\n"):
        text += "\n"
    path.write_text(text, encoding="utf-8")

parse_frontmatter

parse_frontmatter(text)

Parses frontmatter metadata and body from a Markdown document.

Parameters:

Name Type Description Default
text str

The raw document text.

required

Returns:

Type Description
tuple[dict[str, Any], str]

A tuple containing (metadata_dict, body_string).

Source code in python/src/science_adk/workspace.py
def parse_frontmatter(text: str) -> tuple[dict[str, Any], str]:
    """Parses frontmatter metadata and body from a Markdown document.

    Args:
      text: The raw document text.

    Returns:
      A tuple containing (metadata_dict, body_string).
    """
    match = _FRONTMATTER_RE.match(text)
    if not match:
        return {}, text
    raw, body = match.groups()
    meta: dict[str, Any] = {}
    for line in raw.splitlines():
        line = line.strip()
        if not line or line.startswith("#") or ":" not in line:
            continue
        key, _, value = line.partition(":")
        meta[key.strip()] = _coerce(value.strip())
    return meta, body

render_frontmatter

render_frontmatter(meta)

Renders dictionary metadata into Markdown frontmatter.

Source code in python/src/science_adk/workspace.py
def render_frontmatter(meta: dict[str, Any]) -> str:
    """Renders dictionary metadata into Markdown frontmatter."""
    lines = ["---"]
    for key, value in meta.items():
        if value is None or value == "":
            continue
        lines.append(f"{key}: {value}")
    lines.append("---")
    return "\n".join(lines)