mrm-trace 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. mrm_trace/__init__.py +7 -0
  2. mrm_trace/analyser/__init__.py +53 -0
  3. mrm_trace/analyser/iai.py +67 -0
  4. mrm_trace/analyser/locality.py +83 -0
  5. mrm_trace/analyser/read_freq.py +40 -0
  6. mrm_trace/analyser/retention.py +83 -0
  7. mrm_trace/analyser/suitability.py +63 -0
  8. mrm_trace/analyser/working_set.py +49 -0
  9. mrm_trace/analyser/write_once.py +50 -0
  10. mrm_trace/api.py +52 -0
  11. mrm_trace/cli.py +140 -0
  12. mrm_trace/collector/__init__.py +42 -0
  13. mrm_trace/collector/artifact_manager.py +67 -0
  14. mrm_trace/collector/base.py +46 -0
  15. mrm_trace/collector/memray_runner.py +83 -0
  16. mrm_trace/collector/perf_runner.py +164 -0
  17. mrm_trace/collector/process_monitor.py +115 -0
  18. mrm_trace/config/__init__.py +4 -0
  19. mrm_trace/config/loader.py +28 -0
  20. mrm_trace/config/schema.py +220 -0
  21. mrm_trace/config/validators.py +143 -0
  22. mrm_trace/engines/__init__.py +29 -0
  23. mrm_trace/engines/base.py +140 -0
  24. mrm_trace/engines/llamacpp.py +200 -0
  25. mrm_trace/engines/vllm.py +176 -0
  26. mrm_trace/labeller/__init__.py +15 -0
  27. mrm_trace/labeller/address_tracker.py +111 -0
  28. mrm_trace/labeller/kv_lifecycle.py +80 -0
  29. mrm_trace/labeller/labeller.py +228 -0
  30. mrm_trace/labeller/symbol_rules.py +106 -0
  31. mrm_trace/orchestration/__init__.py +0 -0
  32. mrm_trace/parser/__init__.py +13 -0
  33. mrm_trace/parser/memray_parser.py +93 -0
  34. mrm_trace/parser/normalizer.py +61 -0
  35. mrm_trace/parser/perf_script_parser.py +102 -0
  36. mrm_trace/parser/schema.py +33 -0
  37. mrm_trace/parser/writer.py +54 -0
  38. mrm_trace/reporter/__init__.py +24 -0
  39. mrm_trace/reporter/figures.py +138 -0
  40. mrm_trace/reporter/manifest.py +59 -0
  41. mrm_trace/reporter/metrics_csv.py +87 -0
  42. mrm_trace/reporter/parquet_export.py +52 -0
  43. mrm_trace/reporter/run_exporter.py +123 -0
  44. mrm_trace/schema_version.py +127 -0
  45. mrm_trace/telemetry/__init__.py +21 -0
  46. mrm_trace/telemetry/baseline.py +68 -0
  47. mrm_trace/telemetry/observer_effect.py +66 -0
  48. mrm_trace/telemetry/thermal.py +77 -0
  49. mrm_trace/telemetry/validity.py +128 -0
  50. mrm_trace/utils/__init__.py +0 -0
  51. mrm_trace/utils/files.py +18 -0
  52. mrm_trace/utils/ids.py +17 -0
  53. mrm_trace/utils/logging.py +48 -0
  54. mrm_trace-0.1.0.dist-info/METADATA +234 -0
  55. mrm_trace-0.1.0.dist-info/RECORD +58 -0
  56. mrm_trace-0.1.0.dist-info/WHEEL +5 -0
  57. mrm_trace-0.1.0.dist-info/entry_points.txt +2 -0
  58. mrm_trace-0.1.0.dist-info/top_level.txt +1 -0
mrm_trace/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """mrm-trace: LLM inference memory trace platform for MRM research."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from mrm_trace.api import Experiment
6
+
7
+ __all__ = ["Experiment"]
@@ -0,0 +1,53 @@
1
+ """Analysis subsystem for mrm-trace — Phase 6."""
2
+
3
+ from mrm_trace.analyser.iai import compute_iai
4
+ from mrm_trace.analyser.locality import compute_locality
5
+ from mrm_trace.analyser.read_freq import compute_read_freq
6
+ from mrm_trace.analyser.retention import compute_retention
7
+ from mrm_trace.analyser.suitability import classify_suitability
8
+ from mrm_trace.analyser.working_set import compute_working_set
9
+ from mrm_trace.analyser.write_once import compute_write_once
10
+
11
+ __all__ = [
12
+ "compute_retention",
13
+ "compute_write_once",
14
+ "compute_read_freq",
15
+ "compute_working_set",
16
+ "compute_locality",
17
+ "compute_iai",
18
+ "classify_suitability",
19
+ "compute_all",
20
+ ]
21
+
22
+
23
+ def compute_all(trace) -> dict:
24
+ """
25
+ Run all analysis modules on a trace DataFrame.
26
+
27
+ Returns a dict with keys:
28
+ retention_per_region, retention_summary,
29
+ write_once, read_freq,
30
+ working_set_per_region, working_set_summary,
31
+ locality_per_region, locality_summary,
32
+ iai, suitability
33
+ """
34
+ ret_per_region, ret_summary = compute_retention(trace)
35
+ wo = compute_write_once(trace)
36
+ rf = compute_read_freq(trace)
37
+ ws_per_region, ws_summary = compute_working_set(trace)
38
+ loc_per_region, loc_summary = compute_locality(trace)
39
+ iai = compute_iai(trace)
40
+ suit = classify_suitability(ret_summary, wo, rf)
41
+
42
+ return {
43
+ "retention_per_region": ret_per_region,
44
+ "retention_summary": ret_summary,
45
+ "write_once": wo,
46
+ "read_freq": rf,
47
+ "working_set_per_region": ws_per_region,
48
+ "working_set_summary": ws_summary,
49
+ "locality_per_region": loc_per_region,
50
+ "locality_summary": loc_summary,
51
+ "iai": iai,
52
+ "suitability": suit,
53
+ }
@@ -0,0 +1,67 @@
1
+ """
2
+ Inter-access interval (IAI) analysis — time between consecutive accesses.
3
+
4
+ Groups by exact address (not address_page) to correctly handle sub-page KV
5
+ blocks at 256-byte stride where multiple blocks share a 4K page.
6
+ """
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+
11
+
12
+ def compute_iai(trace: pd.DataFrame) -> pd.DataFrame:
13
+ """
14
+ Compute inter-access interval distribution per region_type.
15
+
16
+ IAI = time gap between consecutive accesses to the *same exact address*,
17
+ sorted by timestamp_ns within each address group.
18
+
19
+ Returns
20
+ -------
21
+ DataFrame
22
+ Columns: region_type, n_intervals, iai_p50_ns, iai_p90_ns,
23
+ iai_p99_ns, iai_mean_ns
24
+ One row per region_type (regions with < 2 accesses to any address
25
+ contribute 0 intervals and are excluded from the summary if they
26
+ have no intervals at all).
27
+ """
28
+ trace_sorted = trace.sort_values(["address", "timestamp_ns"])
29
+ all_intervals = []
30
+
31
+ for (address, region_type), grp in trace_sorted.groupby(
32
+ ["address", "region_type"], sort=False
33
+ ):
34
+ ts = grp["timestamp_ns"].to_numpy(dtype=np.int64)
35
+ if len(ts) < 2:
36
+ continue
37
+ intervals = np.diff(ts)
38
+ intervals = intervals[intervals > 0] # ignore same-timestamp duplicates
39
+ if len(intervals) == 0:
40
+ continue
41
+ all_intervals.append(
42
+ pd.DataFrame({"region_type": region_type, "iai_ns": intervals})
43
+ )
44
+
45
+ if not all_intervals:
46
+ return pd.DataFrame(
47
+ columns=["region_type", "n_intervals", "iai_p50_ns",
48
+ "iai_p90_ns", "iai_p99_ns", "iai_mean_ns"]
49
+ )
50
+
51
+ combined = pd.concat(all_intervals, ignore_index=True)
52
+
53
+ rows = []
54
+ for rtype, grp in combined.groupby("region_type"):
55
+ s = grp["iai_ns"]
56
+ rows.append(
57
+ {
58
+ "region_type": rtype,
59
+ "n_intervals": len(s),
60
+ "iai_p50_ns": int(s.quantile(0.50)),
61
+ "iai_p90_ns": int(s.quantile(0.90)),
62
+ "iai_p99_ns": int(s.quantile(0.99)),
63
+ "iai_mean_ns": float(s.mean()),
64
+ }
65
+ )
66
+
67
+ return pd.DataFrame(rows)
@@ -0,0 +1,83 @@
1
+ """
2
+ Access locality analysis — stride distribution and same-page access fraction.
3
+
4
+ Locality measures spatial regularity: small strides and high same-page fraction
5
+ indicate cache-friendly, predictable access patterns.
6
+ """
7
+
8
+ from typing import Tuple
9
+
10
+ import numpy as np
11
+ import pandas as pd
12
+
13
+
14
+ def compute_locality(trace: pd.DataFrame) -> Tuple[pd.DataFrame, pd.DataFrame]:
15
+ """
16
+ Compute locality statistics per region.
17
+
18
+ Stride = |address[i+1] - address[i]| within consecutive accesses to same region.
19
+ Same-page fraction = fraction of consecutive accesses that stay on the same page.
20
+
21
+ Returns
22
+ -------
23
+ per_region : DataFrame
24
+ Columns: region_id, region_type, n_accesses, mean_stride_bytes,
25
+ median_stride_bytes, same_page_fraction
26
+ type_summary : DataFrame
27
+ Columns: region_type, mean_stride_bytes, median_stride_bytes,
28
+ same_page_fraction
29
+ """
30
+ rows = []
31
+ trace_sorted = trace.sort_values(["region_id", "timestamp_ns"])
32
+
33
+ for region_id, grp in trace_sorted.groupby("region_id"):
34
+ region_type = grp["region_type"].iloc[0]
35
+ n = len(grp)
36
+
37
+ if n < 2:
38
+ rows.append(
39
+ {
40
+ "region_id": region_id,
41
+ "region_type": region_type,
42
+ "n_accesses": n,
43
+ "mean_stride_bytes": 0.0,
44
+ "median_stride_bytes": 0.0,
45
+ "same_page_fraction": 0.0,
46
+ }
47
+ )
48
+ continue
49
+
50
+ addrs = grp["address"].to_numpy()
51
+ pages = grp["address_page"].to_numpy()
52
+ strides = np.abs(np.diff(addrs.astype(np.int64)))
53
+ same_page = np.sum(pages[1:] == pages[:-1])
54
+
55
+ rows.append(
56
+ {
57
+ "region_id": region_id,
58
+ "region_type": region_type,
59
+ "n_accesses": n,
60
+ "mean_stride_bytes": float(strides.mean()),
61
+ "median_stride_bytes": float(np.median(strides)),
62
+ "same_page_fraction": float(same_page / len(strides)),
63
+ }
64
+ )
65
+
66
+ per_region = pd.DataFrame(rows)
67
+ if per_region.empty:
68
+ return per_region, pd.DataFrame(
69
+ columns=["region_type", "mean_stride_bytes", "median_stride_bytes",
70
+ "same_page_fraction"]
71
+ )
72
+
73
+ type_summary = (
74
+ per_region.groupby("region_type")
75
+ .agg(
76
+ mean_stride_bytes=("mean_stride_bytes", "mean"),
77
+ median_stride_bytes=("median_stride_bytes", "mean"),
78
+ same_page_fraction=("same_page_fraction", "mean"),
79
+ )
80
+ .reset_index()
81
+ )
82
+
83
+ return per_region, type_summary
@@ -0,0 +1,40 @@
1
+ """
2
+ Read frequency analysis — total accesses, read fraction, reads-per-write.
3
+ """
4
+
5
+ import pandas as pd
6
+
7
+
8
+ def compute_read_freq(trace: pd.DataFrame) -> pd.DataFrame:
9
+ """
10
+ Compute read frequency statistics per region.
11
+
12
+ Returns
13
+ -------
14
+ DataFrame
15
+ Columns: region_id, region_type, total_reads, total_writes,
16
+ total_accesses, read_fraction, reads_per_write
17
+ """
18
+ region_types = trace.groupby("region_id")["region_type"].first()
19
+
20
+ counts = trace.groupby(["region_id", "op_type"]).size().unstack(fill_value=0)
21
+
22
+ # Ensure both columns exist even if all ops are one type
23
+ for col in ("load", "store"):
24
+ if col not in counts.columns:
25
+ counts[col] = 0
26
+
27
+ counts = counts.rename(columns={"load": "total_reads", "store": "total_writes"})
28
+ counts["total_accesses"] = counts["total_reads"] + counts["total_writes"]
29
+ counts["read_fraction"] = (counts["total_reads"] / counts["total_accesses"]).fillna(0.0)
30
+ counts["reads_per_write"] = (
31
+ counts["total_reads"] / counts["total_writes"].replace(0, float("nan"))
32
+ ).fillna(0.0)
33
+
34
+ result = counts.reset_index()
35
+ result["region_type"] = result["region_id"].map(region_types)
36
+
37
+ return result[
38
+ ["region_id", "region_type", "total_reads", "total_writes",
39
+ "total_accesses", "read_fraction", "reads_per_write"]
40
+ ]
@@ -0,0 +1,83 @@
1
+ """
2
+ Retention analysis — how long each region is live between first write and last read.
3
+
4
+ Per-region: first_write_ns, last_read_ns, retention_ns, retention_s
5
+ Summary: per region_type percentile stats (p50, p90, p99, mean)
6
+ """
7
+
8
+ from typing import Tuple
9
+
10
+ import pandas as pd
11
+
12
+
13
+ def compute_retention(trace: pd.DataFrame) -> Tuple[pd.DataFrame, pd.DataFrame]:
14
+ """
15
+ Compute retention duration for each region.
16
+
17
+ Returns
18
+ -------
19
+ per_region : DataFrame
20
+ Columns: region_id, region_type, first_write_ns, last_read_ns,
21
+ retention_ns, retention_s
22
+ summary : DataFrame
23
+ Columns: region_type, n_regions, retention_p50_s, retention_p90_s,
24
+ retention_p99_s, retention_mean_s
25
+ """
26
+ stores = trace[trace["op_type"] == "store"]
27
+ loads = trace[trace["op_type"] == "load"]
28
+
29
+ first_write = (
30
+ stores.groupby("region_id")["timestamp_ns"].min().rename("first_write_ns")
31
+ )
32
+
33
+ last_read = (
34
+ loads.groupby("region_id")["timestamp_ns"].max().rename("last_read_ns")
35
+ )
36
+
37
+ region_types = trace.groupby("region_id")["region_type"].first()
38
+
39
+ per_region = pd.concat([region_types, first_write, last_read], axis=1).reset_index()
40
+ per_region.columns = ["region_id", "region_type", "first_write_ns", "last_read_ns"]
41
+
42
+ # Regions that were never written — use first access as write time
43
+ no_write_mask = per_region["first_write_ns"].isna()
44
+ if no_write_mask.any():
45
+ fallback = (
46
+ trace.groupby("region_id")["timestamp_ns"].min().rename("first_write_ns")
47
+ )
48
+ per_region.loc[no_write_mask, "first_write_ns"] = per_region.loc[
49
+ no_write_mask, "region_id"
50
+ ].map(fallback)
51
+
52
+ # Regions with no reads — retention = 0
53
+ per_region["last_read_ns"] = per_region["last_read_ns"].fillna(
54
+ per_region["first_write_ns"]
55
+ )
56
+
57
+ per_region["retention_ns"] = (
58
+ per_region["last_read_ns"] - per_region["first_write_ns"]
59
+ ).clip(lower=0).astype("int64")
60
+ per_region["retention_s"] = per_region["retention_ns"] / 1e9
61
+
62
+ per_region = per_region.astype({"first_write_ns": "int64", "last_read_ns": "int64"})
63
+
64
+ # Summary per region_type
65
+ def _pct(g: pd.Series, q: float) -> float:
66
+ return float(g.quantile(q))
67
+
68
+ summary_rows = []
69
+ for rtype, grp in per_region.groupby("region_type"):
70
+ s = grp["retention_s"]
71
+ summary_rows.append(
72
+ {
73
+ "region_type": rtype,
74
+ "n_regions": len(grp),
75
+ "retention_p50_s": _pct(s, 0.50),
76
+ "retention_p90_s": _pct(s, 0.90),
77
+ "retention_p99_s": _pct(s, 0.99),
78
+ "retention_mean_s": float(s.mean()),
79
+ }
80
+ )
81
+ summary = pd.DataFrame(summary_rows)
82
+
83
+ return per_region, summary
@@ -0,0 +1,63 @@
1
+ """
2
+ MRM suitability classification.
3
+
4
+ Thresholds
5
+ ----------
6
+ high_mrm : write_once_ratio >= 0.8 AND retention_p99_s >= 10.0
7
+ medium_mrm : write_once_ratio >= 0.5 AND retention_p50_s >= 1.0
8
+ low_mrm : everything else
9
+ """
10
+
11
+ import pandas as pd
12
+
13
+
14
+ def classify_suitability(
15
+ retention_summary: pd.DataFrame,
16
+ write_once_per_region: pd.DataFrame,
17
+ read_freq_per_region: pd.DataFrame,
18
+ retention_threshold_high_s: float = 10.0,
19
+ retention_threshold_medium_s: float = 1.0,
20
+ write_once_threshold_high: float = 0.8,
21
+ write_once_threshold_medium: float = 0.5,
22
+ ) -> pd.DataFrame:
23
+ """
24
+ Classify each region_type as high_mrm, medium_mrm, or low_mrm.
25
+
26
+ Parameters
27
+ ----------
28
+ retention_summary : output of compute_retention()[1]
29
+ write_once_per_region : output of compute_write_once()
30
+ read_freq_per_region : output of compute_read_freq()
31
+
32
+ Returns
33
+ -------
34
+ DataFrame
35
+ Columns: region_type, write_once_ratio_mean, retention_p99_s,
36
+ retention_p50_s, suitability
37
+ """
38
+ wo_by_type = (
39
+ write_once_per_region.groupby("region_type")["write_once_ratio"]
40
+ .mean()
41
+ .rename("write_once_ratio_mean")
42
+ .reset_index()
43
+ )
44
+
45
+ merged = retention_summary.merge(wo_by_type, on="region_type", how="left")
46
+ merged["write_once_ratio_mean"] = merged["write_once_ratio_mean"].fillna(0.0)
47
+
48
+ def _classify(row: pd.Series) -> str:
49
+ wo = row["write_once_ratio_mean"]
50
+ p99 = row["retention_p99_s"]
51
+ p50 = row["retention_p50_s"]
52
+ if wo >= write_once_threshold_high and p99 >= retention_threshold_high_s:
53
+ return "high_mrm"
54
+ if wo >= write_once_threshold_medium and p50 >= retention_threshold_medium_s:
55
+ return "medium_mrm"
56
+ return "low_mrm"
57
+
58
+ merged["suitability"] = merged.apply(_classify, axis=1)
59
+
60
+ return merged[
61
+ ["region_type", "write_once_ratio_mean", "retention_p99_s",
62
+ "retention_p50_s", "suitability"]
63
+ ]
@@ -0,0 +1,49 @@
1
+ """
2
+ Working set size analysis — unique pages and bytes touched per region.
3
+ """
4
+
5
+ from typing import Tuple
6
+
7
+ import pandas as pd
8
+
9
+ _PAGE_BYTES = 4096
10
+
11
+
12
+ def compute_working_set(trace: pd.DataFrame) -> Tuple[pd.DataFrame, pd.DataFrame]:
13
+ """
14
+ Compute working set size per region and a summary per region_type.
15
+
16
+ Returns
17
+ -------
18
+ per_region : DataFrame
19
+ Columns: region_id, region_type, unique_pages, working_set_bytes
20
+ type_summary : DataFrame
21
+ Columns: region_type, n_regions, total_pages, total_bytes,
22
+ mean_pages_per_region, max_pages_per_region
23
+ """
24
+ region_types = trace.groupby("region_id")["region_type"].first()
25
+
26
+ per_region = (
27
+ trace.groupby("region_id")["address_page"]
28
+ .nunique()
29
+ .rename("unique_pages")
30
+ .reset_index()
31
+ )
32
+ per_region["region_type"] = per_region["region_id"].map(region_types)
33
+ per_region["working_set_bytes"] = per_region["unique_pages"] * _PAGE_BYTES
34
+
35
+ type_summary_rows = []
36
+ for rtype, grp in per_region.groupby("region_type"):
37
+ type_summary_rows.append(
38
+ {
39
+ "region_type": rtype,
40
+ "n_regions": len(grp),
41
+ "total_pages": int(grp["unique_pages"].sum()),
42
+ "total_bytes": int(grp["working_set_bytes"].sum()),
43
+ "mean_pages_per_region": float(grp["unique_pages"].mean()),
44
+ "max_pages_per_region": int(grp["unique_pages"].max()),
45
+ }
46
+ )
47
+ type_summary = pd.DataFrame(type_summary_rows)
48
+
49
+ return per_region, type_summary
@@ -0,0 +1,50 @@
1
+ """
2
+ Write-once analysis — what fraction of written addresses are written exactly once.
3
+
4
+ High write-once ratio indicates write-once memory behaviour ideal for MRM.
5
+ """
6
+
7
+ import pandas as pd
8
+
9
+
10
+ def compute_write_once(trace: pd.DataFrame) -> pd.DataFrame:
11
+ """
12
+ Compute write-once ratio per region.
13
+
14
+ An address is "write-once" if it appears in exactly one store access.
15
+
16
+ Returns
17
+ -------
18
+ DataFrame
19
+ Columns: region_id, region_type, n_written_addresses,
20
+ n_write_once_addresses, write_once_ratio
21
+ """
22
+ stores = trace[trace["op_type"] == "store"].copy()
23
+
24
+ if stores.empty:
25
+ return pd.DataFrame(
26
+ columns=[
27
+ "region_id", "region_type", "n_written_addresses",
28
+ "n_write_once_addresses", "write_once_ratio",
29
+ ]
30
+ )
31
+
32
+ write_counts = (
33
+ stores.groupby(["region_id", "address"]).size().reset_index(name="write_count")
34
+ )
35
+ region_types = trace.groupby("region_id")["region_type"].first().reset_index()
36
+
37
+ per_region = write_counts.groupby("region_id").agg(
38
+ n_written_addresses=("address", "count"),
39
+ n_write_once_addresses=("write_count", lambda x: (x == 1).sum()),
40
+ ).reset_index()
41
+
42
+ per_region = per_region.merge(region_types, on="region_id", how="left")
43
+ per_region["write_once_ratio"] = (
44
+ per_region["n_write_once_addresses"] / per_region["n_written_addresses"]
45
+ ).clip(0.0, 1.0)
46
+
47
+ return per_region[
48
+ ["region_id", "region_type", "n_written_addresses",
49
+ "n_write_once_addresses", "write_once_ratio"]
50
+ ]
mrm_trace/api.py ADDED
@@ -0,0 +1,52 @@
1
+ from pathlib import Path
2
+ from typing import List, Union
3
+
4
+ from mrm_trace.config.loader import load_experiment
5
+ from mrm_trace.config.schema import ExperimentConfig, RunConfig
6
+ from mrm_trace.config.validators import (
7
+ expand_sweep,
8
+ validate_config_semantics,
9
+ validate_environment,
10
+ validate_model_paths,
11
+ )
12
+
13
+
14
+ class Experiment:
15
+ """Python API for defining and executing mrm-trace experiments."""
16
+
17
+ def __init__(self, config: ExperimentConfig) -> None:
18
+ self._config = config
19
+
20
+ @classmethod
21
+ def from_yaml(cls, path: Union[str, Path]) -> "Experiment":
22
+ """Load an experiment from a YAML config file."""
23
+ return cls(load_experiment(path))
24
+
25
+ def validate(self) -> bool:
26
+ """Validate config and environment. Returns True if no errors."""
27
+ all_ok = True
28
+ for check_fn in (validate_config_semantics, validate_model_paths, validate_environment):
29
+ result = check_fn(self._config)
30
+ if not result.is_valid:
31
+ all_ok = False
32
+ return all_ok
33
+
34
+ def plan(self) -> List[RunConfig]:
35
+ """Materialise and return the full list of runs for this experiment."""
36
+ return expand_sweep(self._config)
37
+
38
+ def run(self) -> None:
39
+ """Execute all planned runs. Implemented in Phase 3."""
40
+ raise NotImplementedError("run() is available from Phase 3 onwards")
41
+
42
+ def analyse(self) -> None:
43
+ """Parse, label, and compute metrics. Implemented in Phase 4–6."""
44
+ raise NotImplementedError("analyse() is available from Phase 4 onwards")
45
+
46
+ def report(self, output_dir: Union[str, Path] = "reports/") -> None:
47
+ """Generate aggregated outputs and figures. Implemented in Phase 8."""
48
+ raise NotImplementedError("report() is available from Phase 8 onwards")
49
+
50
+ @property
51
+ def config(self) -> ExperimentConfig:
52
+ return self._config