kairopsis 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.
kairopsis/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """Relative-value idea generation powered by metapyle."""
2
+
3
+
4
+ def main() -> None:
5
+ """Launch the installed application without runtime dependency resolution."""
6
+ import logging
7
+ from logging.handlers import RotatingFileHandler
8
+
9
+ import uvicorn
10
+
11
+ from .app import create_app
12
+ from .config import load_settings
13
+
14
+ settings = load_settings()
15
+ for path in (settings.config_dir, settings.cache_dir, settings.log_dir):
16
+ path.mkdir(parents=True, exist_ok=True)
17
+ logging.getLogger("kairopsis").addHandler(RotatingFileHandler(
18
+ settings.log_dir / "kairopsis.log", maxBytes=1_000_000, backupCount=2, encoding="utf-8"
19
+ ))
20
+ uvicorn.run(create_app(settings), host=settings.host, port=settings.port)
kairopsis/analytics.py ADDED
@@ -0,0 +1,160 @@
1
+ """Exact-date metrics, input risk normalization and intercept OLS."""
2
+ from bisect import bisect_right
3
+ from datetime import date, datetime
4
+ from math import fsum, isfinite
5
+ from typing import cast
6
+ from .frequencies import expected_session, horizon_target, previous_session, shift_years
7
+ from .risk import transform_inputs
8
+ from .models import AdjustmentEstimate, DataRequest, DataResponse, Evaluation, Fit, MetricPoint, ResolvedDefinition
9
+
10
+
11
+ def condition_rules(definition: ResolvedDefinition, unit: str, percentile: float | None, change: float | None, eligible: bool) -> tuple[tuple[str, ...], tuple[str, ...]]:
12
+ reasons, keys = [], []
13
+ settings = definition.settings
14
+ if eligible and percentile is not None and (percentile >= settings.upper_percentile or percentile <= 100 - settings.upper_percentile):
15
+ reasons.append(f"Historical standing {percentile:.1f}th percentile / {settings.history_years}y")
16
+ keys.append("upper" if percentile >= settings.upper_percentile else "lower")
17
+ if eligible and change is not None and settings.move_threshold is not None and abs(change) >= settings.move_threshold:
18
+ reasons.append(f"{change:+.2f} {unit} / {settings.horizon} meets configured {settings.move_threshold:g} {unit} rule")
19
+ keys.append("move")
20
+ return tuple(reasons), tuple(keys)
21
+
22
+
23
+ def regression(points: list[MetricPoint], current: date, definition: ResolvedDefinition, years: int) -> Fit | None:
24
+ start = shift_years(current, -years)
25
+ sample = [p for p in points if p.eligible and start <= p.date < current]
26
+ if len(sample) < definition.settings.minimum_fit or (sample[0].date - start).days > 7 or (previous_session(current) - sample[-1].date).days > 7:
27
+ return None
28
+ xs = [cast(float, (p.transformed_inputs or p.inputs)[1]) for p in sample]
29
+ ys = [cast(float, (p.transformed_inputs or p.inputs)[0]) for p in sample]
30
+ xm, ym = fsum(xs) / len(xs), fsum(ys) / len(ys)
31
+ xx = fsum((x - xm) ** 2 for x in xs)
32
+ if not xx:
33
+ return None
34
+ slope = fsum((x - xm) * (y - ym) for x, y in zip(xs, ys)) / xx
35
+ intercept = ym - slope * xm
36
+ yy = fsum((y - ym) ** 2 for y in ys)
37
+ sse = fsum((y - intercept - slope * x) ** 2 for x, y in zip(xs, ys))
38
+ if not all(isfinite(v) for v in (slope, intercept, sse, yy)):
39
+ return None
40
+ return Fit(slope=slope, intercept=intercept, r_squared=1-sse/yy if yy else None,
41
+ sample_count=len(sample), start=sample[0].date, end=sample[-1].date, x_min=min(xs), x_max=max(xs))
42
+
43
+
44
+ def evaluate(definition: ResolvedDefinition, data: DataResponse, request: DataRequest, now: datetime) -> Evaluation:
45
+ results = {s.binding.id: s for s in data.series}
46
+ bindings = (*definition.inputs, *definition.references)
47
+ if data.failures or any(b.id not in results for b in bindings):
48
+ raise ValueError("Retrieval incomplete: " + "; ".join(f.message for f in data.failures))
49
+ all_rows = {}
50
+ for binding in bindings:
51
+ result = results[binding.id]
52
+ if result.binding != binding:
53
+ raise ValueError("Provider returned a different resolved binding")
54
+ dates = [p.date for p in result.observations]
55
+ if len(dates) != len(set(dates)):
56
+ raise ValueError("Duplicate daily observations")
57
+ all_rows[binding.id] = {p.date: p for p in result.observations if request.start <= p.date <= request.end}
58
+ rows = [all_rows[b.id] for b in definition.inputs]
59
+ days = sorted(set().union(*(r.keys() for r in rows)))
60
+ transformed: dict[date, tuple[float | None, ...]] = {}
61
+ scales: dict[date, tuple[float | None, ...]] = {}
62
+ starts: dict[date, tuple[date | None, ...]] = {}
63
+ input_units = tuple(b.unit for b in definition.inputs)
64
+ estimates: tuple[AdjustmentEstimate, ...] = ()
65
+ if definition.settings.measure != "level":
66
+ transformed, scales, starts, input_units, estimates = transform_inputs(definition, all_rows, days)
67
+ method = definition.calculation
68
+ if method == "difference" and input_units[0] != input_units[1]:
69
+ raise ValueError("Difference requires matching units; no implicit conversion")
70
+ unit = input_units[0]
71
+ if method == "ratio":
72
+ unit = "×" if unit == input_units[1] else f"{unit}/{input_units[1]}"
73
+ points = []
74
+ limits = []
75
+ for day in days:
76
+ inputs = tuple(r[day].value if day in r else None for r in rows)
77
+ observed = tuple(r[day].observed_on if day in r else None for r in rows)
78
+ measured = transformed.get(day, inputs)
79
+ usable = all(v is not None for v in measured)
80
+ eligible = usable and all(d == day for d in observed) and day.weekday() < 5
81
+ value = None
82
+ if usable:
83
+ left = cast(float, measured[0])
84
+ if method in ("level", "regression"):
85
+ value = left
86
+ elif method == "difference":
87
+ value = left - cast(float, measured[1])
88
+ elif measured[1]:
89
+ value = left / cast(float, measured[1])
90
+ if value is not None and not isfinite(value):
91
+ value = None
92
+ points.append(MetricPoint(date=day, value=value, inputs=inputs, observed_on=observed, eligible=eligible and value is not None,
93
+ transformed_inputs=transformed.get(day, ()), risk_scales=scales.get(day, ()), period_start=starts.get(day, ())))
94
+ fit = None
95
+ sensitivity = []
96
+ current_day = points[-1].date if points else None
97
+ if method == "regression" and current_day:
98
+ fit = regression(points, current_day, definition, definition.settings.fit_years)
99
+ if not fit:
100
+ limits.append("Fit unavailable: insufficient full-window history or constant explanatory input")
101
+ else:
102
+ limits.append("Residual history uses the current fit retrospectively; association is descriptive")
103
+ x = (points[-1].transformed_inputs or points[-1].inputs)[1]
104
+ if x is not None and not fit.x_min <= x <= fit.x_max:
105
+ limits.append("Current explanatory input is outside fit range (extrapolation)")
106
+ selected = (points[-1].transformed_inputs or points[-1].inputs)[0]
107
+ for years in (1, 5):
108
+ alternative = regression(points, current_day, definition, years)
109
+ if not alternative:
110
+ sensitivity.append(f"{years}y comparison unavailable: insufficient fit history")
111
+ elif selected is not None and x is not None:
112
+ a = selected - fit.intercept - fit.slope * x
113
+ b = selected - alternative.intercept - alternative.slope * x
114
+ if a * b < 0:
115
+ sensitivity.append(f"Residual direction changes under {years}y fit")
116
+ points = [p.model_copy(update={"value": (cast(float, (p.transformed_inputs or p.inputs)[0]) - fit.intercept - fit.slope * cast(float, (p.transformed_inputs or p.inputs)[1]))
117
+ if fit and all(v is not None for v in (p.transformed_inputs or p.inputs)) else None,
118
+ "eligible": p.eligible and fit is not None}) for p in points]
119
+ current = points[-1] if points else None
120
+ for binding, estimate in zip(definition.inputs, estimates):
121
+ if estimate.limitation:
122
+ limits.append(f"{binding.name}: {estimate.limitation}")
123
+ percentile = change = None
124
+ change_start = None
125
+ eligible = bool(current and current.eligible and current.date == expected_session(request.end))
126
+ if not current or current.value is None:
127
+ limits.append("Current calculation unavailable (missing input or zero denominator)")
128
+ elif not current.eligible:
129
+ limits.append("Missing, carried, unknown or mismatched native observations; excluded from findings")
130
+ if current and current.date < expected_session(request.end):
131
+ limits.append(f"Stale observations: latest row {current.date}; requested as of {request.end}")
132
+ if data.outcome not in ("synthetic", "fresh"):
133
+ eligible = False
134
+ limits.append("Upstream retrieval freshness unverified; excluded from findings")
135
+ if current and current.value is not None:
136
+ value = current.value
137
+ history = [p.value for p in points if p.eligible and p.value is not None and shift_years(current.date, -definition.settings.history_years) <= p.date < current.date]
138
+ if len(history) >= definition.settings.minimum_history:
139
+ percentile = 100 * (sum(v < value for v in history) + .5 * sum(v == value for v in history)) / len(history)
140
+ else:
141
+ eligible = False
142
+ limits.append(f"Insufficient reference history: {len(history)}; require {definition.settings.minimum_history}")
143
+ target = horizon_target(current.date, definition.settings.horizon)
144
+ valid = [p for p in points if p.eligible and p.value is not None]
145
+ idx = bisect_right([p.date for p in valid], target) - 1
146
+ if definition.settings.measure != "level" and current.eligible:
147
+ change = value
148
+ change_start = min((d for d in current.period_start if d is not None), default=None)
149
+ elif idx >= 0 and (target - valid[idx].date).days <= (0 if definition.settings.horizon == "day" else 3) and current.eligible:
150
+ change = value - cast(float, valid[idx].value)
151
+ change_start = valid[idx].date
152
+ else:
153
+ limits.append(f"{definition.settings.horizon.title()} change unavailable: missing eligible baseline")
154
+ reasons, keys = condition_rules(definition, unit, percentile, change, eligible)
155
+ return Evaluation(definition=definition, request=request, data=data, evaluated_at=now, points=tuple(points), unit=unit,
156
+ input_units=input_units, adjustment_estimates=estimates,
157
+ current=current.value if current else None, observation_date=current.date if current else None,
158
+ input_dates=current.observed_on if current else (), percentile=percentile, change=change, change_start=change_start,
159
+ eligible=eligible, limitations=tuple(limits), fit=fit, sensitivity=tuple(sensitivity), reasons=reasons, conditions=reasons, condition_keys=keys,
160
+ finding="condition" if reasons else "quiet" if eligible else "unavailable")
kairopsis/app.py ADDED
@@ -0,0 +1,235 @@
1
+ """One supported three-destination application; all evidence passes real persistence."""
2
+ import base64
3
+ import hashlib
4
+ from collections.abc import Callable
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Literal
8
+
9
+ from fastapi import FastAPI, HTTPException, Request
10
+ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
11
+ from fastapi.staticfiles import StaticFiles
12
+ from fastapi.templating import Jinja2Templates
13
+ from pydantic import BaseModel, Field
14
+
15
+ from .catalogue import Catalogue
16
+ from .config import Settings
17
+ from .fixtures import MockMarketData
18
+ from .metapyle_adapter import MetapyleMarketData
19
+ from .models import AnalysisDefinition, DisplaySettings, SeriesBinding
20
+ from .repository import Repository
21
+ from .service import MarketData, Research
22
+
23
+
24
+ class CaptureRequest(BaseModel):
25
+ evaluation_id: str
26
+ display: DisplaySettings = Field(default_factory=DisplaySettings)
27
+ image: str = Field(max_length=16_000_000)
28
+ title: str = Field(default="", max_length=200)
29
+ thought: str = Field(default="", max_length=50000)
30
+ idea_id: str | None = None
31
+ note: str = Field(default="", max_length=50000)
32
+ mode: Literal["saved", "latest", "investigation"] = "investigation"
33
+
34
+ def png(self) -> bytes:
35
+ try:
36
+ return base64.b64decode(self.image.removeprefix("data:image/png;base64,"), validate=True)
37
+ except ValueError:
38
+ raise ValueError("Invalid PNG encoding") from None
39
+
40
+
41
+ class IdeaEdit(BaseModel):
42
+ title: str | None = Field(default=None, min_length=1, max_length=200)
43
+ thought: str | None = Field(default=None, max_length=50000)
44
+ shortlisted: bool | None = None
45
+ archived: bool | None = None
46
+
47
+
48
+ class NoteEdit(BaseModel):
49
+ text: str = Field(max_length=50000)
50
+
51
+
52
+ def create_app(settings: Settings | None = None, market_data: MarketData | None = None,
53
+ clock: Callable[[], datetime] | None = None) -> FastAPI:
54
+ settings = settings or Settings()
55
+ clock = clock or (lambda: datetime.now(timezone.utc))
56
+ repository = Repository(settings.workspace, settings.mode)
57
+ catalogue = Catalogue(repository)
58
+ provider = market_data or (MockMarketData(clock) if settings.mode == "mock" else MetapyleMarketData(settings, clock))
59
+ research = Research(repository, catalogue, provider, clock, settings.timezone)
60
+ app = FastAPI(title="Kairopsis", docs_url=None, redoc_url=None)
61
+ app.state.repository, app.state.research = repository, research
62
+ app.state.settings = settings
63
+ package = Path(__file__).resolve().parent
64
+ templates = Jinja2Templates(directory=package / "templates")
65
+ app.mount("/static", StaticFiles(directory=package / "static"), name="static")
66
+
67
+ def asset_url(name: str) -> str:
68
+ version = hashlib.sha256((package / "static" / name).read_bytes()).hexdigest()[:16]
69
+ return f"/static/{name}?v={version}"
70
+
71
+ templates.env.globals["asset_url"] = asset_url
72
+
73
+ @app.exception_handler(FileNotFoundError)
74
+ async def missing(request: Request, error: FileNotFoundError):
75
+ return JSONResponse(status_code=404, content={"detail": "Record not found; existing evidence preserved"})
76
+
77
+ @app.exception_handler(ValueError)
78
+ async def invalid(request: Request, error: ValueError):
79
+ return JSONResponse(status_code=422, content={"detail": str(error)})
80
+
81
+ @app.exception_handler(OSError)
82
+ async def write_failed(request: Request, error: OSError):
83
+ return JSONResponse(status_code=503, content={"detail": "Evidence could not be read/saved. Check writable workspace and free disk space; retry. Export is incomplete."})
84
+
85
+ def page(request, name):
86
+ research.first_open()
87
+ return templates.TemplateResponse(request=request, name="app.html", context={"page": name, "mode": settings.mode})
88
+
89
+ @app.get("/", response_class=HTMLResponse)
90
+ def root():
91
+ return RedirectResponse("/analyses")
92
+
93
+ @app.get("/analyses", response_class=HTMLResponse)
94
+ def analyses_page(request: Request):
95
+ return page(request, "analyses")
96
+
97
+ @app.get("/analyses/{key}", response_class=HTMLResponse)
98
+ def chart_page(request: Request, key: str):
99
+ catalogue.resolve(key)
100
+ return page(request, "chart")
101
+
102
+ @app.get("/ideas", response_class=HTMLResponse)
103
+ def ideas_page(request: Request):
104
+ return page(request, "ideas")
105
+
106
+ @app.get("/ideas/{key}", response_class=HTMLResponse)
107
+ def idea_page(request: Request, key: str):
108
+ repository.get_idea(key)
109
+ return page(request, "idea")
110
+
111
+ @app.get("/library", response_class=HTMLResponse)
112
+ def library_page(request: Request):
113
+ return page(request, "library")
114
+
115
+ @app.get("/api/analyses")
116
+ def rows(scope: Literal["all", "flagged"] = "flagged", q: str = "", kind: Literal["all", "standalone", "pair"] = "all"):
117
+ research.first_open()
118
+ return research.rows(scope, q, kind)
119
+
120
+ @app.post("/api/refresh")
121
+ def refresh():
122
+ return research.refresh()
123
+
124
+ @app.get("/api/library")
125
+ def library():
126
+ return catalogue.records()
127
+
128
+ @app.post("/api/library/series")
129
+ def save_series(record: SeriesBinding):
130
+ return catalogue.save("series", record)
131
+
132
+ @app.post("/api/library/analyses")
133
+ def save_analysis(record: AnalysisDefinition):
134
+ return catalogue.save("analyses", record)
135
+
136
+ @app.delete("/api/library/{kind}/{key}")
137
+ def delete_catalogue(kind: Literal["series", "analyses"], key: str):
138
+ catalogue.delete(kind, key)
139
+ return {"ok": True}
140
+
141
+ @app.post("/api/analyses/{key}/preview")
142
+ def preview(key: str, options: dict):
143
+ return research.preview(key, options)
144
+
145
+ @app.get("/api/evaluations/{key}")
146
+ def evaluation(key: str):
147
+ return repository.get_evaluation(key)
148
+
149
+ @app.post("/api/ideas")
150
+ def add_chart(body: CaptureRequest):
151
+ evaluation = repository.get_evaluation(body.evaluation_id)
152
+ return repository.add_chart(evaluation, body.display, body.png(), clock(), body.title, body.thought, body.idea_id, body.note)
153
+
154
+ @app.get("/api/ideas")
155
+ def ideas(q: str = "", scope: Literal["active", "shortlist", "archived"] = "active"):
156
+ found = []
157
+ for idea in repository.list_ideas():
158
+ text = " ".join([idea.title, idea.thought, *[n.text for n in idea.notes], *[c.note for c in idea.charts]])
159
+ if q.casefold() not in text.casefold():
160
+ continue
161
+ if idea.archived != (scope == "archived") or (scope == "shortlist" and not idea.shortlisted):
162
+ continue
163
+ found.append({"idea": idea.model_dump(mode="json"), "changes": research.idea_changes(idea)})
164
+ return found
165
+
166
+ @app.get("/api/ideas/{key}")
167
+ def idea(key: str):
168
+ result = repository.get_idea(key)
169
+ return {"idea": result.model_dump(mode="json"), "snapshots": {c.id: repository.get_snapshot(key, c.snapshot_id).model_dump(mode="json") for c in result.charts},
170
+ "changes": research.idea_changes(result)}
171
+
172
+ @app.patch("/api/ideas/{key}")
173
+ def edit_idea(key: str, body: IdeaEdit):
174
+ return repository.update_idea(key, body.model_dump(exclude_none=True), clock())
175
+
176
+ @app.post("/api/ideas/{key}/notes")
177
+ def add_note(key: str, body: NoteEdit):
178
+ now = clock()
179
+ return repository.modify_idea(key, lambda idea: idea.add_note(body.text, now), now)
180
+
181
+ @app.patch("/api/ideas/{key}/notes/{note_id}")
182
+ def edit_note(key: str, note_id: str, body: NoteEdit):
183
+ now = clock()
184
+ return repository.modify_idea(key, lambda idea: idea.change_note(note_id, body.text, now), now)
185
+
186
+ @app.delete("/api/ideas/{key}/notes/{note_id}")
187
+ def delete_note(key: str, note_id: str):
188
+ now = clock()
189
+ return repository.modify_idea(key, lambda idea: idea.change_note(note_id, None, now), now)
190
+
191
+ @app.patch("/api/ideas/{key}/charts/{entry_id}")
192
+ def chart_note(key: str, entry_id: str, body: NoteEdit):
193
+ now = clock()
194
+ return repository.modify_idea(key, lambda idea: idea.annotate_chart(entry_id, body.text, now), now)
195
+
196
+ @app.delete("/api/ideas/{key}/charts/{entry_id}")
197
+ def remove_chart(key: str, entry_id: str):
198
+ return repository.modify_idea(key, lambda idea: idea.remove_chart(entry_id), clock())
199
+
200
+ def chart_snapshot(key, entry_id):
201
+ result = repository.get_idea(key)
202
+ entry = next((c for c in result.charts if c.id == entry_id), None)
203
+ if not entry:
204
+ raise FileNotFoundError()
205
+ return repository.get_snapshot(key, entry.snapshot_id)
206
+
207
+ @app.post("/api/ideas/{key}/charts/{entry_id}/latest")
208
+ def latest(key: str, entry_id: str):
209
+ saved = chart_snapshot(key, entry_id)
210
+ try:
211
+ return {"evaluation": research.evaluate(saved.evaluation.definition)}
212
+ except (ValueError, OSError) as error:
213
+ return {"unavailable": str(error)}
214
+
215
+ @app.get("/api/ideas/{key}/snapshots/{snapshot_id}/image")
216
+ def image(key: str, snapshot_id: str):
217
+ return Response(repository.snapshot_image(key, snapshot_id), media_type="image/png")
218
+
219
+ @app.post("/api/ideas/{key}/charts/{entry_id}/export")
220
+ def export(key: str, entry_id: str, body: CaptureRequest):
221
+ saved = chart_snapshot(key, entry_id)
222
+ evaluation = saved.evaluation if body.mode == "saved" else repository.get_evaluation(body.evaluation_id)
223
+ if body.mode == "saved" and body.evaluation_id != saved.evaluation.id:
224
+ raise ValueError("Export must match displayed saved evidence")
225
+ if (body.mode == "saved" and evaluation.id != saved.evaluation.id) or (body.mode == "latest" and evaluation.definition != saved.evaluation.definition) or body.mode == "investigation":
226
+ raise ValueError("Export must match displayed saved evidence or its preserved Latest definition")
227
+ if (body.display.years, body.display.view) != (saved.display.years, saved.display.view):
228
+ raise ValueError("Export must preserve displayed settings")
229
+ png = body.png()
230
+ with repository.transaction():
231
+ snapshot = repository.capture(key, evaluation, body.display, png, body.mode, clock())
232
+ return Response(png, media_type="image/png", headers={"X-Snapshot-ID": snapshot.id,
233
+ "Content-Disposition": 'attachment; filename="kairopsis-chart.png"'})
234
+
235
+ return app
kairopsis/catalogue.py ADDED
@@ -0,0 +1,93 @@
1
+ """Small active catalogue. Binding changes advance affected resolved definitions."""
2
+ from .fixtures import analysis_fixture, series_fixture
3
+ from .models import AnalysisDefinition, AnalysisSettings, ResolvedDefinition, SeriesBinding
4
+ from .repository import Repository
5
+ from .risk import adjusted_unit
6
+ from .metapyle_catalogue import publish_catalogue
7
+ from .repository import atomic_bytes
8
+
9
+
10
+ class Catalogue:
11
+ def __init__(self, repository: Repository):
12
+ self.repository = repository
13
+ with repository.transaction():
14
+ if repository.read_json("catalogue") is None:
15
+ repository.write_json("catalogue", {
16
+ "series": [s.model_dump(mode="json") for s in series_fixture()] if repository.mode == "mock" else [],
17
+ "analyses": [a.model_dump(mode="json") for a in analysis_fixture()] if repository.mode == "mock" else []})
18
+ self._publish(self.records())
19
+
20
+ @property
21
+ def metapyle_path(self):
22
+ return self.repository.root / "metapyle.yaml"
23
+
24
+ def _publish(self, records: dict) -> None:
25
+ publish_catalogue(self.metapyle_path, tuple(SeriesBinding.model_validate(s) for s in records["series"]))
26
+
27
+ def _commit(self, records: dict) -> None:
28
+ # JSON publishes metadata/analyses together. YAML is a recoverable
29
+ # projection, restored on a failed commit and rebuilt at startup.
30
+ previous = self.metapyle_path.read_bytes()
31
+ self._publish(records)
32
+ try:
33
+ self.repository.write_json("catalogue", records)
34
+ except OSError:
35
+ atomic_bytes(self.metapyle_path, previous)
36
+ raise
37
+
38
+ def records(self) -> dict:
39
+ return self.repository.read_json("catalogue")
40
+
41
+ def resolve(self, key: str, settings: AnalysisSettings | None = None) -> ResolvedDefinition:
42
+ records = self.records()
43
+ analysis = next((AnalysisDefinition.model_validate(a) for a in records["analyses"] if a["id"] == key), None)
44
+ if not analysis:
45
+ raise FileNotFoundError("Analysis not found")
46
+ series = {s["id"]: SeriesBinding.model_validate(s) for s in records["series"]}
47
+ if settings is not None:
48
+ analysis = AnalysisDefinition.model_validate({**analysis.model_dump(), "settings": settings})
49
+ reference_ids = risk_references(analysis)
50
+ if any(i not in series for i in reference_ids):
51
+ raise ValueError("Choose an existing risk reference series")
52
+ return ResolvedDefinition(id=analysis.id, revision=analysis.revision, name=analysis.name,
53
+ calculation=analysis.calculation, settings=analysis.settings, inputs=tuple(series[i] for i in analysis.series_ids),
54
+ references=tuple(series[i] for i in sorted(reference_ids - set(analysis.series_ids))))
55
+
56
+ def save(self, kind: str, record: SeriesBinding | AnalysisDefinition) -> dict:
57
+ with self.repository.transaction():
58
+ records = self.records()
59
+ old = next((r for r in records[kind] if r["id"] == record.id), None)
60
+ value = record.model_dump(mode="json")
61
+ value["revision"] = old["revision"] + 1 if old else 1
62
+ if kind == "analyses":
63
+ definition = AnalysisDefinition.model_validate(value)
64
+ series = {s["id"]: s for s in records["series"]}
65
+ if any(i not in series for i in definition.series_ids):
66
+ raise ValueError("Choose existing input series")
67
+ if any(i not in series for i in risk_references(definition)):
68
+ raise ValueError("Choose an existing risk reference series")
69
+ units = [adjusted_unit(series[i]["unit"], series[r.reference_id or i]["unit"] if r.method != "none" else series[i]["unit"],
70
+ r.method, definition.settings.measure) for i, r in zip(definition.series_ids, definition.settings.adjustments(len(definition.series_ids)))]
71
+ if definition.calculation == "difference" and len(set(units)) != 1:
72
+ raise ValueError("Difference requires matching units")
73
+ elif old:
74
+ records["analyses"] = [{**a, "revision": a["revision"] + 1} if record.id in set(a["series_ids"]) | risk_references(AnalysisDefinition.model_validate(a)) else a for a in records["analyses"]]
75
+ records[kind] = [value if r["id"] == record.id else r for r in records[kind]] if old else [*records[kind], value]
76
+ self._commit(records)
77
+ return value
78
+
79
+ def delete(self, kind: str, key: str) -> None:
80
+ with self.repository.transaction():
81
+ records = self.records()
82
+ if not any(r["id"] == key for r in records[kind]):
83
+ raise FileNotFoundError("Catalogue entry not found")
84
+ dependencies = [a["name"] for a in records["analyses"] if key in set(a["series_ids"]) | risk_references(AnalysisDefinition.model_validate(a))] if kind == "series" else []
85
+ if dependencies:
86
+ raise ValueError("Used by: " + ", ".join(dependencies))
87
+ records[kind] = [r for r in records[kind] if r["id"] != key]
88
+ self._commit(records)
89
+
90
+
91
+ def risk_references(analysis: AnalysisDefinition) -> set[str]:
92
+ return {r.reference_id for r in analysis.settings.adjustments(len(analysis.series_ids))
93
+ if r.method != "none" and r.reference_id is not None}
kairopsis/config.py ADDED
@@ -0,0 +1,74 @@
1
+ """User-writable paths and launch settings; never infer paths from cwd."""
2
+
3
+ import argparse
4
+ import os
5
+ import tomllib
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Literal
9
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
10
+
11
+ from platformdirs import user_cache_path, user_config_path, user_data_path, user_log_path
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Settings:
16
+ workspace: Path = field(default_factory=lambda: user_data_path("Kairopsis", appauthor=False) / "mock")
17
+ config_dir: Path = field(default_factory=lambda: user_config_path("Kairopsis", appauthor=False))
18
+ cache_dir: Path = field(default_factory=lambda: user_cache_path("Kairopsis", appauthor=False))
19
+ log_dir: Path = field(default_factory=lambda: user_log_path("Kairopsis", appauthor=False))
20
+ mode: Literal["mock", "live"] = "mock"
21
+ host: str = "127.0.0.1"
22
+ port: int = 8765
23
+ timezone: str = "Europe/London"
24
+
25
+ def __post_init__(self) -> None:
26
+ package = Path(__file__).resolve().parent
27
+ for name in ("workspace", "config_dir", "cache_dir", "log_dir"):
28
+ path = Path(getattr(self, name)).expanduser()
29
+ if not path.is_absolute():
30
+ raise ValueError(f"{name} must be an absolute path, independent of the working directory")
31
+ path = path.resolve()
32
+ if path == package or package in path.parents:
33
+ raise ValueError(f"{name} must be outside the installed package")
34
+ object.__setattr__(self, name, path)
35
+ if self.mode not in ("mock", "live"):
36
+ raise ValueError("mode must be mock or live")
37
+ if not 1 <= self.port <= 65535:
38
+ raise ValueError("port must be between 1 and 65535")
39
+ try:
40
+ ZoneInfo(self.timezone)
41
+ except (ZoneInfoNotFoundError, ValueError):
42
+ raise ValueError("timezone must be an available IANA timezone, for example Europe/London") from None
43
+
44
+
45
+ def load_settings(argv: list[str] | None = None) -> Settings:
46
+ parser = argparse.ArgumentParser(description="Local Kairopsis research dashboard")
47
+ parser.add_argument("--config", type=Path, help="Absolute path to an optional TOML configuration")
48
+ parser.add_argument("--mode", choices=("mock", "live"))
49
+ parser.add_argument("--host")
50
+ parser.add_argument("--port", type=int)
51
+ parser.add_argument("--timezone")
52
+ for name in ("workspace", "config-dir", "cache-dir", "log-dir"):
53
+ parser.add_argument(f"--{name}", type=Path)
54
+ args = vars(parser.parse_args(argv))
55
+ config = args.pop("config") or os.environ.get("KAIROPSIS_CONFIG")
56
+ values: dict = {}
57
+ if config:
58
+ config_path = Path(config).expanduser()
59
+ if not config_path.is_absolute():
60
+ parser.error("--config must be an absolute path")
61
+ with config_path.open("rb") as stream:
62
+ values = tomllib.load(stream).get("kairopsis", {})
63
+ for name in args:
64
+ environment = os.environ.get(f"KAIROPSIS_{name.upper()}")
65
+ if environment is not None:
66
+ values[name] = int(environment) if name == "port" else environment
67
+ if args[name] is not None:
68
+ values[name] = args[name]
69
+ if "workspace" not in values:
70
+ values["workspace"] = user_data_path("Kairopsis", appauthor=False) / values.get("mode", "mock")
71
+ try:
72
+ return Settings(**values)
73
+ except (TypeError, ValueError) as error:
74
+ parser.error(str(error))