codebook-lab 1.0.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.
codebook_lab/ollama.py ADDED
@@ -0,0 +1,117 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+ import shutil
6
+ import subprocess
7
+ import time
8
+ from urllib import error as urllib_error
9
+ from urllib import request as urllib_request
10
+
11
+
12
+ def get_ollama_base_url() -> str:
13
+ """Return the Ollama base URL used for connectivity checks.
14
+
15
+ Returns:
16
+ Base URL string, defaulting to ``http://127.0.0.1:11434`` when
17
+ ``OLLAMA_HOST`` is not set.
18
+ """
19
+ base_url = os.environ.get("OLLAMA_HOST", "http://127.0.0.1:11434").strip()
20
+ if "://" not in base_url:
21
+ base_url = f"http://{base_url}"
22
+ return base_url.rstrip("/")
23
+
24
+
25
+ def _can_auto_start_local_ollama(base_url: str) -> bool:
26
+ """Return whether CodeBook Lab can reasonably auto-start Ollama locally."""
27
+ return base_url in {
28
+ "http://127.0.0.1:11434",
29
+ "http://localhost:11434",
30
+ "http://0.0.0.0:11434",
31
+ }
32
+
33
+
34
+ def ensure_ollama_available(
35
+ timeout: float = 2.0,
36
+ start_if_needed: bool = False,
37
+ startup_timeout: float = 10.0,
38
+ ) -> str:
39
+ """Check that the Ollama server is reachable, optionally starting it locally.
40
+
41
+ Args:
42
+ timeout: Timeout in seconds for each connectivity probe.
43
+ start_if_needed: If ``True``, try to start ``ollama serve`` when the
44
+ default local server is not reachable.
45
+ startup_timeout: Maximum seconds to wait after auto-starting the server.
46
+
47
+ Returns:
48
+ Base URL string for the reachable Ollama server.
49
+
50
+ Raises:
51
+ RuntimeError: If Ollama is not reachable and cannot be started.
52
+ """
53
+ base_url = get_ollama_base_url()
54
+ tags_url = f"{base_url}/api/tags"
55
+
56
+ def _probe() -> bool:
57
+ try:
58
+ with urllib_request.urlopen(tags_url, timeout=timeout) as response:
59
+ return response.status < 400
60
+ except urllib_error.URLError:
61
+ return False
62
+
63
+ if _probe():
64
+ return base_url
65
+
66
+ if not start_if_needed:
67
+ raise RuntimeError(
68
+ "Ollama is not reachable. Start the local server with `ollama serve` "
69
+ f"and make sure it is available at {base_url}."
70
+ )
71
+
72
+ if not _can_auto_start_local_ollama(base_url):
73
+ raise RuntimeError(
74
+ "Ollama is not reachable, and CodeBook Lab only auto-starts local "
75
+ f"servers on the default host. Current host: {base_url}"
76
+ )
77
+
78
+ ollama_executable = shutil.which("ollama")
79
+ if ollama_executable is None:
80
+ raise RuntimeError(
81
+ "Ollama is not installed or not on PATH. Install Ollama first, then "
82
+ "either start it manually with `ollama serve` or rerun the script."
83
+ )
84
+
85
+ subprocess.Popen(
86
+ [ollama_executable, "serve"],
87
+ stdout=subprocess.DEVNULL,
88
+ stderr=subprocess.DEVNULL,
89
+ stdin=subprocess.DEVNULL,
90
+ start_new_session=True,
91
+ cwd=Path.cwd(),
92
+ )
93
+
94
+ deadline = time.time() + startup_timeout
95
+ while time.time() < deadline:
96
+ if _probe():
97
+ return base_url
98
+ time.sleep(0.25)
99
+
100
+ raise RuntimeError(
101
+ "Tried to auto-start Ollama, but it still was not reachable after "
102
+ f"{startup_timeout:.1f} seconds at {base_url}."
103
+ )
104
+
105
+
106
+ def ensure_ollama_model(model: str) -> None:
107
+ """Pull an Ollama model so it is available locally before a run.
108
+
109
+ Args:
110
+ model: Ollama model identifier such as ``"gemma3:270m"``.
111
+ """
112
+ ollama_executable = shutil.which("ollama")
113
+ if ollama_executable is None:
114
+ raise RuntimeError(
115
+ "Ollama is not installed or not on PATH, so the model cannot be pulled."
116
+ )
117
+ subprocess.run([ollama_executable, "pull", model], check=True)
@@ -0,0 +1,146 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Callable
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class PromptContext:
9
+ """Structured prompt-building context passed to prompt wrapper functions.
10
+
11
+ Attributes:
12
+ section_name: Codebook section name for the current annotation.
13
+ section_instruction: Optional section-level instructions from the codebook.
14
+ annotation_name: Human-readable annotation label inside the section.
15
+ tooltip: Optional annotation guidance or tooltip text.
16
+ annotation_type: One of ``"dropdown"``, ``"checkbox"``, ``"likert"``, or ``"textbox"``.
17
+ options: Dropdown options when ``annotation_type`` is ``"dropdown"``, otherwise ``None``.
18
+ min_value: Minimum Likert value when applicable, otherwise ``None``.
19
+ max_value: Maximum Likert value when applicable, otherwise ``None``.
20
+ example: Example block extracted from the codebook, if present.
21
+ text: Raw source text being annotated.
22
+ use_examples: Whether examples should be included in the rendered prompt.
23
+ response_instructions: Type-specific response instructions generated by CodeBook Lab.
24
+ core_prompt: Prompt body assembled before the outer wrapper is applied.
25
+ """
26
+
27
+ section_name: str
28
+ section_instruction: str
29
+ annotation_name: str
30
+ tooltip: str
31
+ annotation_type: str
32
+ options: list[str] | None
33
+ min_value: int | None
34
+ max_value: int | None
35
+ example: str
36
+ text: str
37
+ use_examples: bool
38
+ response_instructions: str
39
+ core_prompt: str
40
+
41
+
42
+ PromptWrapper = Callable[[PromptContext], str]
43
+ PromptType = str | PromptWrapper
44
+
45
+
46
+ def _standard_wrapper(context: PromptContext) -> str:
47
+ """Render the default prompt wrapper for a ``PromptContext``."""
48
+ return f'{context.core_prompt}\n\n---\n\nText: \n"{context.text}"\n\nResponse: \n'
49
+
50
+
51
+ def _persona_wrapper(context: PromptContext) -> str:
52
+ """Render the built-in persona prompt wrapper for a ``PromptContext``."""
53
+ prefix = (
54
+ "You are an expert political scientist and data annotator with extensive "
55
+ "experience in analyzing political discourse and parliamentary debates.\n\n"
56
+ "Task: Annotate the following text using the criteria below. Your annotation "
57
+ "should be precise, consistent, and based solely on the text content.\n\n"
58
+ )
59
+ suffix = f'\n\n---\n\nText: \n"{context.text}"\n\nResponse: \n'
60
+ return f"{prefix}{context.core_prompt}{suffix}"
61
+
62
+
63
+ def _cot_wrapper(context: PromptContext) -> str:
64
+ """Render the built-in chain-of-thought prompt wrapper for a ``PromptContext``."""
65
+ suffix = "\n\nI'll think through this step by step:\n\n"
66
+ suffix += "1. First, I'll identify key parts of the text relevant to this dimension\n"
67
+ suffix += "2. Next, I'll analyze how these elements relate to the annotation criteria\n"
68
+ suffix += "3. Then, I'll consider my assessment carefully\n"
69
+ suffix += "4. Finally, I'll make my selection based on this analysis\n"
70
+ suffix += f'\n\n---\n\nText: \n"{context.text}"\n\n'
71
+ suffix += "Step-by-step analysis:\n\n[Think through your reasoning here]\n\n"
72
+ suffix += "Response: \n"
73
+ return f"{context.core_prompt}{suffix}"
74
+
75
+
76
+ _PROMPT_WRAPPERS: dict[str, PromptWrapper] = {
77
+ "standard": _standard_wrapper,
78
+ "persona": _persona_wrapper,
79
+ "CoT": _cot_wrapper,
80
+ }
81
+
82
+
83
+ def register_prompt_wrapper(name: str, wrapper: PromptWrapper, overwrite: bool = False) -> None:
84
+ """Register a prompt wrapper for use in Python and CLI experiment configs.
85
+
86
+ Args:
87
+ name: String key users will pass as ``prompt_type`` such as ``"concise"``.
88
+ wrapper: Callable accepting a :class:`PromptContext` and returning a full prompt string.
89
+ overwrite: Set to ``True`` to replace an existing wrapper with the same name.
90
+ """
91
+ if not name:
92
+ raise ValueError("Prompt wrapper name must be a non-empty string.")
93
+ if not overwrite and name in _PROMPT_WRAPPERS:
94
+ raise ValueError(f"Prompt wrapper '{name}' is already registered.")
95
+ _PROMPT_WRAPPERS[name] = wrapper
96
+
97
+
98
+ def get_prompt_wrapper(name: str) -> PromptWrapper:
99
+ """Return a registered prompt wrapper by name.
100
+
101
+ Args:
102
+ name: Prompt wrapper key, for example ``"standard"`` or a custom name.
103
+
104
+ Returns:
105
+ The callable prompt wrapper registered under ``name``.
106
+ """
107
+ try:
108
+ return _PROMPT_WRAPPERS[name]
109
+ except KeyError as exc:
110
+ available = ", ".join(sorted(_PROMPT_WRAPPERS))
111
+ raise ValueError(
112
+ f"Unknown prompt wrapper '{name}'. Available wrappers: {available}."
113
+ ) from exc
114
+
115
+
116
+ def list_prompt_wrappers() -> list[str]:
117
+ """Return the sorted names of all registered prompt wrappers."""
118
+ return sorted(_PROMPT_WRAPPERS)
119
+
120
+
121
+ def get_prompt_type_name(prompt_type: PromptType) -> str:
122
+ """Return a stable display name for a prompt type or callable wrapper.
123
+
124
+ Args:
125
+ prompt_type: Either a registered wrapper name or a callable wrapper.
126
+
127
+ Returns:
128
+ A string name suitable for config files and experiment metadata.
129
+ """
130
+ if isinstance(prompt_type, str):
131
+ return prompt_type
132
+ return getattr(prompt_type, "__name__", "custom_prompt_wrapper")
133
+
134
+
135
+ def render_prompt(prompt_type: PromptType, context: PromptContext) -> str:
136
+ """Render a prompt using a registered wrapper name or a direct callable.
137
+
138
+ Args:
139
+ prompt_type: Registered wrapper name or callable accepting ``PromptContext``.
140
+ context: Structured prompt inputs for the current annotation.
141
+
142
+ Returns:
143
+ A full prompt string ready to send to the model.
144
+ """
145
+ wrapper = get_prompt_wrapper(prompt_type) if isinstance(prompt_type, str) else prompt_type
146
+ return wrapper(context)
codebook_lab/py.typed ADDED
File without changes
@@ -0,0 +1 @@
1
+ """Bundled example tasks distributed with CodeBook Lab."""
@@ -0,0 +1,42 @@
1
+ {
2
+ "header_column": "title",
3
+ "text_column": "text",
4
+ "section_1": {
5
+ "section_name": "Policy Sentiment",
6
+ "section_instruction": "Read the short text and assess whether it expresses an evaluative stance toward a public policy, proposal, or political decision.",
7
+ "annotations": {
8
+ "annotation_1": {
9
+ "name": "Explicit evaluation",
10
+ "type": "checkbox",
11
+ "tooltip": "Tick this box if the text clearly expresses approval, disapproval, praise, criticism, support, concern, or another evaluative stance toward the policy or decision. Leave it unticked if the text is mainly descriptive or procedural and does not clearly express a stance.",
12
+ "example": "Text: \n\"The mayor called the clean transport plan a practical step that will cut congestion and improve air quality.\"\n\nResponse: \n{\"response\": true}\n\n---\n\nText: \n\"The committee will hear witnesses on Tuesday and vote on amendments on Thursday.\"\n\nResponse: \n{\"response\": false}\n\n---\n\nText: \n\"The new childcare package is overdue and will make everyday life easier for working parents.\"\n\nResponse: \n{\"response\": true}"
13
+ },
14
+ "annotation_2": {
15
+ "name": "Direction",
16
+ "type": "dropdown",
17
+ "tooltip": "If the text expresses a stance, identify whether the overall direction is positive, negative, or mixed. Use 'no clear sentiment' only when the text does not convey an evaluative stance.",
18
+ "example": "Text: \n\"The childcare reform is overdue and will give working parents real support.\"\n\nResponse: \n{\"response\": \"positive\"}\n\n---\n\nText: \n\"The housing package may help first-time buyers, but its financing assumptions are unrealistic.\"\n\nResponse: \n{\"response\": \"mixed\"}\n\n---\n\nText: \n\"The proposal is wasteful and risks undermining public trust.\"\n\nResponse: \n{\"response\": \"negative\"}\n\n---\n\nText: \n\"Officials released the updated timetable for debate and implementation.\"\n\nResponse: \n{\"response\": \"no clear sentiment\"}",
19
+ "options": [
20
+ "positive",
21
+ "negative",
22
+ "mixed",
23
+ "no clear sentiment"
24
+ ]
25
+ },
26
+ "annotation_3": {
27
+ "name": "Intensity",
28
+ "type": "likert",
29
+ "tooltip": "Rate the overall sentiment on a 5-point scale where 1 = strongly negative, 2 = somewhat negative, 3 = mixed, neutral, or no clear sentiment, 4 = somewhat positive, and 5 = strongly positive.",
30
+ "example": "Text: \n\"The subsidies are wasteful and will damage market confidence.\"\n\nResponse: \n{\"response\": 1}\n\n---\n\nText: \n\"The tax credit is helpful, though it does not go far enough to solve the problem.\"\n\nResponse: \n{\"response\": 4}\n\n---\n\nText: \n\"The reform has clear advantages, but its implementation risks remain significant.\"\n\nResponse: \n{\"response\": 3}\n\n---\n\nText: \n\"The briefing note lists the agencies involved in implementation.\"\n\nResponse: \n{\"response\": 3}",
31
+ "min_value": 1,
32
+ "max_value": 5
33
+ },
34
+ "annotation_4": {
35
+ "name": "Evidence",
36
+ "type": "textbox",
37
+ "tooltip": "Provide a short phrase or sentence from the text that best supports your sentiment judgement. Keep the response brief.",
38
+ "example": "Text: \n\"The regional rail package is a sensible investment that will reconnect towns that have been neglected for decades.\"\n\nResponse: \n{\"response\": \"sensible investment that will reconnect towns\"}\n\n---\n\nText: \n\"The housing proposal may help some renters, but the funding model is fragile and unfair to local councils.\"\n\nResponse: \n{\"response\": \"funding model is fragile and unfair to local councils\"}"
39
+ }
40
+ }
41
+ }
42
+ }
@@ -0,0 +1,21 @@
1
+ doc_id,title,topic,speaker_type,text,Policy Sentiment_Explicit evaluation,Policy Sentiment_Direction,Policy Sentiment_Intensity,Policy Sentiment_Evidence
2
+ ps_001,"Carbon Border Reform Receives Broad Support",climate,legislator,"The carbon border reform is a sensible compromise that protects domestic industry while finally aligning trade rules with climate goals.",1,positive,5,"sensible compromise"
3
+ ps_002,"Housing Plan Faces Cost Criticism",housing,policy analyst,"The housing plan has an admirable goal, but its financing assumptions are unrealistic and the burden on municipalities is far too high.",1,negative,2,"financing assumptions are unrealistic"
4
+ ps_003,"Committee Schedule Released",legislative process,journalist,"The committee will hear witnesses on Tuesday, debate amendments on Wednesday, and hold a final vote on Thursday.",0,no clear sentiment,3,"hear witnesses on Tuesday, debate amendments on Wednesday"
5
+ ps_004,"Minimum Wage Proposal Wins Qualified Praise",labour,union representative,"The proposal is a step in the right direction for low-paid workers, although the exemptions remain too broad to celebrate it fully.",1,mixed,3,"a step in the right direction"
6
+ ps_005,"Mayor Welcomes Rail Subsidy",transport,mayor,"This rail subsidy is exactly the kind of long-term investment our region needs, and it will make commuting more affordable for thousands of residents.",1,positive,5,"exactly the kind of long-term investment our region needs"
7
+ ps_006,"Opposition MP Attacks Surveillance Bill",civil liberties,opposition MP,"The surveillance bill is intrusive, poorly drafted, and a dangerous expansion of executive power.",1,negative,1,"dangerous expansion of executive power"
8
+ ps_007,"Analyst Notes Pension Reform Tradeoffs",welfare,policy analyst,"The pension reform may improve long-run fiscal sustainability, but it also asks future retirees to accept lower benefits.",1,mixed,3,"may improve long-run fiscal sustainability, but it also asks future retirees to accept lower benefits"
9
+ ps_008,"Union Backs Sick Leave Expansion",public health,union representative,"Expanding sick leave is a welcome and overdue reform that gives workers basic security when they fall ill.",1,positive,5,"welcome and overdue reform"
10
+ ps_009,"Manufacturers Criticise Plastic Ban",environment,business association,"The plastic ban moves too quickly, raises compliance costs, and gives firms too little time to adapt.",1,negative,2,"raises compliance costs"
11
+ ps_010,"Turnout Briefing Note Released",elections,research institute,"The briefing note reports turnout rates by district and summarizes which age groups participated at the highest levels.",0,no clear sentiment,3,"reports turnout rates by district"
12
+ ps_011,"Editorial Supports Flood Resilience Fund",climate adaptation,editorial board,"The new flood resilience fund is a constructive response to repeated climate shocks, even if implementation will need close oversight.",1,positive,4,"constructive response to repeated climate shocks"
13
+ ps_012,"Voucher Pilot Draws Mixed Community Response",education,civil society group,"Some parents welcome the voucher pilot as a source of choice, but others worry it will deepen inequality between schools.",1,mixed,3,"welcome the voucher pilot as a source of choice, but others worry it will deepen inequality"
14
+ ps_013,"Treasury Defends Energy Rebate",cost of living,government minister,"The energy rebate is a fair and timely measure that will shield vulnerable households from another difficult winter.",1,positive,5,"fair and timely measure"
15
+ ps_014,"Think Tank Warns About Farm Subsidy Design",agriculture,think tank,"The subsidy may keep some farms afloat, but its design rewards inefficiency and ignores long-term environmental costs.",1,mixed,3,"rewards inefficiency and ignores long-term environmental costs"
16
+ ps_015,"Election Commission Publishes Guidance",elections,election commission,"The commission published updated guidance on postal voting procedures and reporting deadlines for local administrators.",0,no clear sentiment,3,"published updated guidance on postal voting procedures"
17
+ ps_016,"Mayor Questions Congestion Charge Delay",transport,mayor,"Delaying the congestion charge is a mistake that weakens the city's climate commitments and prolongs traffic problems.",1,negative,1,"is a mistake"
18
+ ps_017,"NGO Gives Cautious Support to Water Plan",environment,civil society group,"The water plan is a meaningful improvement, although its enforcement provisions remain weaker than campaigners had hoped.",1,mixed,3,"meaningful improvement"
19
+ ps_018,"Party Spokesperson Praises Apprenticeship Scheme",education,party spokesperson,"The apprenticeship scheme is exactly the kind of practical reform that links education policy to good jobs.",1,positive,5,"exactly the kind of practical reform"
20
+ ps_019,"Analyst Describes Budget Rollout",fiscal policy,policy analyst,"The finance ministry will publish the budget tables on Monday and present departmental spending plans later in the week.",0,no clear sentiment,3,"will publish the budget tables on Monday"
21
+ ps_020,"Business Group Splits on Remote Work Tax Break",labour,business association,"Some employers see the tax break as a sensible incentive, while others argue it is poorly targeted and unlikely to change behaviour.",1,mixed,3,"sensible incentive, while others argue it is poorly targeted"
codebook_lab/types.py ADDED
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ if TYPE_CHECKING:
8
+ import pandas as pd
9
+
10
+
11
+ @dataclass
12
+ class AnnotationRunResult:
13
+ """Result returned by ``run_annotation``.
14
+
15
+ Attributes:
16
+ model: Ollama model name used for the run, such as ``"gemma3:270m"``.
17
+ output_path: Filesystem path to the generated annotation CSV.
18
+ experiment_directory: Directory containing run metadata and outputs.
19
+ config: Serializable experiment configuration written to ``config.json``.
20
+ char_counts: Prompt and response character counts collected during the run.
21
+ timing_data: Inference timing summary, including total and average seconds.
22
+ emissions: CodeCarbon estimate in kilograms of CO2 equivalent, or ``None``.
23
+ dataframe: Pandas DataFrame containing the annotated rows written to disk.
24
+ """
25
+
26
+ model: str
27
+ output_path: Path
28
+ experiment_directory: Path
29
+ config: dict[str, Any]
30
+ char_counts: dict[str, Any]
31
+ timing_data: dict[str, Any]
32
+ emissions: float | None
33
+ dataframe: pd.DataFrame
34
+
35
+
36
+ @dataclass
37
+ class MetricsRunResult:
38
+ """Result returned by ``run_metrics``.
39
+
40
+ Attributes:
41
+ output_csv: Filesystem path to the aggregate metrics CSV that was updated.
42
+ report_file: Filesystem path to the per-column classification report text file.
43
+ columns_to_compare: Annotation columns included in the evaluation.
44
+ metrics_by_column: Nested dictionary of computed metrics keyed by column name.
45
+ reports: Human-readable report text keyed by annotation column name.
46
+ total_inference_time: Total model inference time in seconds, if available.
47
+ avg_inference_time: Mean inference time per annotation request in seconds, if available.
48
+ input_chars: Total prompt characters sent to the model, if available.
49
+ output_chars: Total response characters returned by the model, if available.
50
+ energy_consumed: Energy consumption in kilowatt-hours, if available.
51
+ emissions: Emissions estimate in kilograms of CO2 equivalent, if available.
52
+ cpu_model: CPU metadata recorded by CodeCarbon, if available.
53
+ gpu_model: GPU metadata recorded by CodeCarbon, if available.
54
+ summary_text: Plain-text summary of the main evaluation metrics.
55
+ """
56
+
57
+ output_csv: Path
58
+ report_file: Path
59
+ columns_to_compare: list[str]
60
+ metrics_by_column: dict[str, dict[str, Any]]
61
+ reports: dict[str, str]
62
+ total_inference_time: float | None
63
+ avg_inference_time: float | None
64
+ input_chars: int | None
65
+ output_chars: int | None
66
+ energy_consumed: float | None
67
+ emissions: float | None
68
+ cpu_model: str | None
69
+ gpu_model: str | None
70
+ summary_text: str
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class ExperimentSpec:
75
+ """Declarative specification for one experiment run in a sweep.
76
+
77
+ Attributes:
78
+ task: Task folder name under ``tasks/``, for example ``"policy-sentiment"``.
79
+ model: Ollama model identifier, such as ``"gemma3:270m"``.
80
+ use_examples: Whether to include worked examples from the codebook.
81
+ prompt_type: Registered prompt wrapper name, for example ``"standard"``.
82
+ temperature: Optional sampling temperature as ``None``, string, or float.
83
+ top_p: Optional nucleus-sampling value as ``None``, string, or float.
84
+ process_textbox: Whether textbox annotations should be generated and scored.
85
+ country_iso_code: Three-letter ISO 3166-1 alpha-3 code for CodeCarbon.
86
+ """
87
+
88
+ task: str
89
+ model: str
90
+ use_examples: bool = False
91
+ prompt_type: str = "standard"
92
+ temperature: float | None = None
93
+ top_p: float | None = None
94
+ process_textbox: bool = False
95
+ country_iso_code: str = "USA"
96
+
97
+
98
+ @dataclass
99
+ class ExperimentRunResult:
100
+ """Combined result returned by ``run_experiment``.
101
+
102
+ Attributes:
103
+ spec: The experiment specification that was executed.
104
+ experiment_directory: Directory containing this run's outputs.
105
+ model_id: Stable model/config identifier used in the metrics log.
106
+ label: Task label written to the metrics CSV.
107
+ annotation: Result object returned by ``run_annotation``.
108
+ metrics: Result object returned by ``run_metrics``.
109
+ """
110
+
111
+ spec: ExperimentSpec
112
+ experiment_directory: Path
113
+ model_id: str
114
+ label: str
115
+ annotation: AnnotationRunResult
116
+ metrics: MetricsRunResult