Skip to content

Kepler's Third Law from the NASA Exoplanet Archive

The claim: orbital period scales as the 3/2 power of semi-major axis across confirmed exoplanets.

The test: query every confirmed planet with a measured period and semi-major axis, fit T = C·aᵅ blind in log-log space, and see what α comes back.

science-adk init my-kepler --example kepler
cd my-kepler
science-adk validate
science-adk run
config:   period range: 0.1–100000.0 days, up to 5000 planets, theoretical exponent 1.5
fetch:    fetched 3565 confirmed exoplanets from NASA
analyze:  fit 3565 planets: T ∝ a^1.4555, r² = 0.989997
evaluate: recovered exponent 1.4555 vs theoretical 1.5000, error 0.0445, r² = 0.989997

The example needs no API key, no account and no bundled dataset.

The goal

GOAL.md (frontmatter)
question: Does Kepler's Third Law hold for exoplanets discovered by modern surveys?
target_metric: r_squared
target_value: 0.95

The constraints in the body are the interesting part:

  • Data must come from the NASA Exoplanet Archive.
  • No filtering by host star mass.
  • The fit must recover the exponent blindly.
  • No API key.

Each one closes off a way of accidentally proving the conclusion.

The DAG

graph LR
    config["config<br/><i>config</i>"] -->|settings| fetch["fetch<br/><i>data</i>"]
    config -->|settings| evaluate["evaluate<br/><i>evaluation</i>"]
    fetch -->|planets| analyze["analyze<br/><i>algorithm</i>"]
    analyze -->|fit_result| evaluate
research/001-kepler-exoplanets/workflow.json
{
  "nodes": [
    { "id": "config",   "kind": "config" },
    { "id": "fetch",    "kind": "data",       "tools": ["query_exoplanets"] },
    { "id": "analyze",  "kind": "algorithm",  "tools": ["power_law_fit"] },
    { "id": "evaluate", "kind": "evaluation" }
  ],
  "edges": [
    { "source": "config",  "source_port": "settings",   "target": "fetch",    "target_port": "settings" },
    { "source": "config",  "source_port": "settings",   "target": "evaluate", "target_port": "settings" },
    { "source": "fetch",   "source_port": "planets",    "target": "analyze",  "target_port": "planets" },
    { "source": "analyze", "source_port": "fit_result", "target": "evaluate", "target_port": "fit_result" }
  ]
}

Note that config.settings feeds two nodes. The theoretical exponent 1.5 travels to evaluate so the comparison can be made — and pointedly does not travel to analyze.

The nodes

config — the parameters, including the one that must not leak

agents/config.py
class KeplerConfig(ConfigAgent):
    """Query filters and physical constants.

    The theoretical exponent (3/2) travels through the DAG as a setting so that
    the evaluate node can compare against it — but it is never used in the fit
    itself. The fit is blind: it recovers whatever exponent the data support.
    """

    ports: Ports = Ports(
        inputs=[],
        outputs=[Port("settings", "json", "Query parameters and physical constants")],
    )

    async def execute(self):
        settings = self.settings(
            min_period=0.1,            # days
            max_period=100000.0,       # days (~274 years)
            max_planets=5000,
            theoretical_exponent=1.5,  # Kepler: T ∝ a^(3/2)
        )
        ...
        return {"settings": settings}

fetch — real measurements, and a refusal to proceed without them

agents/fetch.py
class FetchExoplanets(DataAgent):
    source_kind: ClassVar[str] = "measured"

    ports: Ports = Ports(
        inputs=[Port("settings", "json")],
        outputs=[Port("planets", "json", "Confirmed exoplanets with period and semi-major axis")],
    )

    async def execute(self):
        settings = await self.input("settings")
        planets = await self.call_tool(
            "query_exoplanets",
            min_period=settings["min_period"],
            max_period=settings["max_period"],
            max_rows=settings["max_planets"],
        )
        await self.log(f"fetched {len(planets)} confirmed exoplanets from NASA")

        if len(planets) < 10:
            raise ValueError(                      # (1)!
                f"Only {len(planets)} planets returned. The NASA Exoplanet "
                "Archive may be unreachable or the query filters are too "
                "restrictive."
            )
        return {"planets": planets}
  1. A thin result is a broken run, not a small study. Raising here is what stops a network hiccup from being silently fitted and reported.

source_kind = "measured" marks the provenance: this rests on measurement, not simulation.

analyze — the blind fit

agents/analyze.py
class FitKeplerLaw(AlgorithmAgent):
    assumptions: ClassVar[list[str]] = [
        "The log-log linear model is appropriate for this relationship.",
        "Host star mass variation introduces scatter but does not bias the slope.",
        "NASA archive default entries are representative of the true population.",
    ]

    ports: Ports = Ports(
        inputs=[Port("planets", "json")],
        outputs=[Port("fit_result", "json", "Power-law exponent, r², and diagnostics")],
    )

    async def execute(self):
        planets = await self.input("planets")
        periods = [p["period_days"] for p in planets]
        axes = [p["semi_major_axis_au"] for p in planets]

        fit = await self.call_tool("power_law_fit", x=axes, y=periods)   # (1)!
        ...
  1. This is the only line that computes the answer, and it is the line CI deletes to prove the provenance gate works.

Nothing in this node mentions 1.5. If the population followed T ∝ a², the same code would report 2.

evaluate — measure, compare, and stay ignorant of the threshold

agents/evaluate.py
class EvaluateKeplerLaw(EvaluationAgent):
    """...
    The r² threshold lives in GOAL.md and is deliberately not visible here:
    an evaluation node that could see the number it must beat is one that
    will eventually be written to beat it.
    """

    async def execute(self):
        fit = await self.input("fit_result")
        settings = await self.input("settings")

        exponent = fit["exponent"]
        theoretical = settings["theoretical_exponent"]
        error = abs(exponent - theoretical)
        ...
        return self.report(
            metric="r_squared",
            value=fit["r_squared"],
            detail={
                "recovered_exponent": exponent,
                "theoretical_exponent": theoretical,
                "exponent_error": error,
                "exponent_relative_error_pct": round(100 * error / theoretical, 3),
                "n_planets": fit["n_planets"],
                "kepler_law_confirmed": error < 0.05,
            },
        )

The target metric is r² — how well a single power law describes the whole population. The recovered exponent rides alongside in detail, where it can be compared to 1.500 without influencing the metric.

The tools

tools/nasa.py
def query_exoplanets(
    min_period: float = 0.1,
    max_period: float = 100000.0,
    max_rows: int = 5000,
) -> list:
    """Fetch confirmed exoplanets with measured periods and semi-major axes."""

An ADQL query against the archive's public TAP endpoint for the ps table, restricted to default_flag = 1 rows where both period and semi-major axis are non-null. Standard library only.

tools/fitting.py
def power_law_fit(x: list, y: list) -> dict:
    """Fit y = C × x^α by ordinary least squares in log-log space."""

Base-10 logs of both arrays, then a linear regression: log(y) = α log(x) + log(C). Returns {"exponent", "coefficient", "r_squared", "n"} and raises if fewer than two positive points remain. It depends on nothing beyond math.

The gates

science-adk score
  [pass] execution_completed: All nodes ran to completion.
  [pass] all_nodes_ran: 4 node(s) produced output.
  [pass] result_measured: evaluate reported a measurement.
  [pass] metric_is_finite: evaluate reported 0.989997.
  [pass] produced_output: 6 output value(s) recorded.
  [pass] tool_use_verified: Declared tool use matches the record.

The run still scores 0.00 until it is audited. Green gates mean the number is real, not that it matters.

Why 1.4555 and not 1.5000

The shortfall is physics.

Kepler's Third Law in full is T² = (4π²/GM) a³, so the proportionality constant depends on the host star mass: C ∝ (4π²/GM)^½. A pooled fit across thousands of planets orbiting stars of different masses mixes many slightly different constants, and the pooled slope lands a little below the single-star ideal.

An experiment that returned exactly 1.5000 from a heterogeneous population would be the one to distrust. That observation is what the natural follow-up experiment tests — see evolve an experiment.

The fabrication test

This is the part worth running yourself. Replace the fit with a perfect answer:

# agents/analyze.py — delete the call_tool line, hard-code the result
fit = {"exponent": 1.5, "coefficient": 1.0, "r_squared": 1.0, "n": len(periods)}
science-adk run
science-adk score
  [pass] execution_completed: All nodes ran to completion.
  [pass] all_nodes_ran: 4 node(s) produced output.
  [pass] result_measured: evaluate reported a measurement.
  [pass] metric_is_finite: evaluate reported 1.
  [pass] produced_output: 6 output value(s) recorded.
  [FAIL] tool_use_verified: Node(s) declared tools but made no successful tool
         call: analyze. Either the data was not really fetched, or the
         declaration is wrong.

Score: 0.00 — a failed gate means this run is not a result.

Five gates pass. The answer is better than the honest one — r² of exactly 1.0, exponent exactly 1.5. Only the provenance ledger, written by the runtime rather than by the agent, knows the fit never happened.

CI performs this substitution automatically on every push and fails the build if the gate ever stops catching it.

Audit it

science-adk audit \
  --scientific-value 0.80 \
  --method-fidelity 0.90 \
  --implementation-quality 0.95 \
  --rationale "Recovered exponent 1.4555 from 3565 archive planets with r² = 0.990, against a theoretical 1.5. The fit is blind — no node references 3/2 — and the ~3% shortfall is consistent with host star mass spread, since C ∝ (4π²/GM)^½. Scientific value is capped at 0.80 because the law itself is not in doubt; the contribution is the population-level confirmation and the quantified mass-spread bias."

science-adk report
science-adk learn "Pooling exoplanets across host stars biases the fitted Kepler exponent ~3% below 3/2 (1.4555 from 3565 planets, r² = 0.99)." --run latest

See also