strathmark 2.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.
Files changed (52) hide show
  1. strathmark/__init__.py +335 -0
  2. strathmark/analytics.py +267 -0
  3. strathmark/api.py +1789 -0
  4. strathmark/auth.py +457 -0
  5. strathmark/calculator.py +1288 -0
  6. strathmark/config.py +761 -0
  7. strathmark/consumer_contract.py +105 -0
  8. strathmark/contracts/shadow_consumer_v1.openapi.json +1 -0
  9. strathmark/contracts/shadow_consumer_v1.openapi.sha256 +1 -0
  10. strathmark/db.py +2139 -0
  11. strathmark/decay.py +355 -0
  12. strathmark/drift.py +476 -0
  13. strathmark/fairness.py +674 -0
  14. strathmark/fallback.py +500 -0
  15. strathmark/features.py +386 -0
  16. strathmark/identity.py +29 -0
  17. strathmark/ledger.py +2950 -0
  18. strathmark/llm.py +364 -0
  19. strathmark/llm_roles.py +244 -0
  20. strathmark/loader.py +181 -0
  21. strathmark/mark_optimizer.py +359 -0
  22. strathmark/migrations/20260504_001_add_source_tracking.sql +100 -0
  23. strathmark/migrations/20260504_002_ml_state_tables.sql +206 -0
  24. strathmark/migrations/20260504_003_rls_reframe.sql +249 -0
  25. strathmark/migrations/20260508_004_atomic_model_swap_and_residual_dedup.sql +88 -0
  26. strathmark/migrations/20260811_005_prediction_v2.sql +810 -0
  27. strathmark/migrations/20260813_006_prediction_hash_algorithm.down.sql +638 -0
  28. strathmark/migrations/20260813_006_prediction_hash_algorithm.sql +649 -0
  29. strathmark/migrations/20260813_007_shadow_mirror_contract.down.sql +35 -0
  30. strathmark/migrations/20260813_007_shadow_mirror_contract.sql +1688 -0
  31. strathmark/migrations/README.md +188 -0
  32. strathmark/migrations/prerequisites/prediction_rpc_owner.sql +48 -0
  33. strathmark/mirror_contract.py +6 -0
  34. strathmark/mnemex.py +348 -0
  35. strathmark/models/prediction_v2_core.json +1 -0
  36. strathmark/prediction_v2.py +1458 -0
  37. strathmark/predictor.py +2868 -0
  38. strathmark/provenance.py +24 -0
  39. strathmark/residual.py +752 -0
  40. strathmark/shadow.py +498 -0
  41. strathmark/sqlite_utils.py +25 -0
  42. strathmark/store.py +1744 -0
  43. strathmark/sync.py +492 -0
  44. strathmark/utils.py +185 -0
  45. strathmark/validation.py +417 -0
  46. strathmark/variance.py +770 -0
  47. strathmark/visualization.py +129 -0
  48. strathmark/wood.py +672 -0
  49. strathmark-2.0.0.dist-info/METADATA +243 -0
  50. strathmark-2.0.0.dist-info/RECORD +52 -0
  51. strathmark-2.0.0.dist-info/WHEEL +4 -0
  52. strathmark-2.0.0.dist-info/licenses/LICENSE +203 -0
strathmark/__init__.py ADDED
@@ -0,0 +1,335 @@
1
+ """
2
+ strathmark — Woodchopping Handicap Engine
3
+ ==========================================
4
+
5
+ A Python package that exposes the STRATHMARK handicap calculation
6
+ engine for use in external applications (tournament software, scoring apps, etc.).
7
+
8
+ Quick start
9
+ -----------
10
+ from strathmark import HandicapCalculator, CompetitorRecord, WoodProfile, ResultStore
11
+ from strathmark.predictor import HistoricalResult
12
+
13
+ store = ResultStore() # opens ~/.strathmark/results.db
14
+ calc = HandicapCalculator()
15
+
16
+ competitor = CompetitorRecord(
17
+ name="Alice Smith",
18
+ history=store.get_competitor_history("Alice Smith", "SB"),
19
+ )
20
+ wood = WoodProfile(species="Pine", diameter_mm=300, quality=5)
21
+ marks = calc.calculate([competitor], wood, "SB")
22
+
23
+ Python import API (for STRATHEX and other Python projects):
24
+ from strathmark import HandicapCalculator
25
+ from strathmark.fairness import simulate_and_assess_handicaps
26
+ from strathmark.variance import run_monte_carlo_simulation
27
+
28
+ HTTP REST API (for web/mobile/non-Python projects):
29
+ uvicorn strathmark.api:app --port 8000
30
+ POST http://localhost:8000/calculate
31
+
32
+ Design Rules (invariants enforced in all submodules)
33
+ -----------------------------------------------------
34
+ - Mark floor: 3 seconds (never lower, under any circumstances)
35
+ - Mark ceiling: system-wide 183 seconds (180s time limit + 3s minimum mark)
36
+ event configs may enforce a lower ceiling
37
+ - Variance: absolute +-3 seconds ONLY — proportional variance is forbidden
38
+ - Prediction engine: manual override, otherwise the validated V2 posterior
39
+ with an explicit deterministic rollback fallback
40
+ - Time-decay: exponential decay, 2-year half-life (730 days)
41
+ - Output: plain text only — no emojis, no ANSI color codes
42
+ - Style: lean and simple, no unnecessary complexity
43
+
44
+ Downstream integration:
45
+ Pin an immutable reviewed release or commit. STRATHEX uses direct Python for its
46
+ offline default and may select the REST /calculate transport explicitly.
47
+ """
48
+
49
+ from strathmark.auth import (
50
+ ACTOR_ATTESTATION_SCHEMA_VERSION,
51
+ SHADOW_ATTESTATION_AUDIENCE,
52
+ VerifiedActorAttestation,
53
+ shadow_auth_configuration_status,
54
+ sign_actor_attestation,
55
+ )
56
+ from strathmark.calculator import HandicapCalculator, process_competition_day
57
+ from strathmark.consumer_contract import (
58
+ EXPECTED_SHADOW_CONSUMER_PATHS,
59
+ SHADOW_CONSUMER_CONTRACT_VERSION,
60
+ ShadowConsumerContractIntegrityError,
61
+ load_shadow_consumer_contract,
62
+ shadow_consumer_contract_bytes,
63
+ shadow_consumer_contract_digest,
64
+ )
65
+ from strathmark.db import (
66
+ format_proam_results,
67
+ get_active_model_version,
68
+ get_competitor_bias,
69
+ mirror_prediction_ledger,
70
+ pull_competitors,
71
+ pull_results,
72
+ push_competitors,
73
+ push_results,
74
+ push_results_dicts,
75
+ record_calibration,
76
+ record_prediction,
77
+ record_prediction_residuals,
78
+ register_competitor,
79
+ register_model_version,
80
+ set_active_model,
81
+ settle_prediction,
82
+ store_features,
83
+ )
84
+ from strathmark.drift import DriftReport, evaluate_drift, is_drifting
85
+ from strathmark.fairness import (
86
+ get_ai_assessment_of_handicaps,
87
+ get_championship_race_analysis,
88
+ simulate_and_assess_handicaps,
89
+ )
90
+ from strathmark.features import (
91
+ ExclusionDiagnostics,
92
+ PriorEvidence,
93
+ build_prior_evidence,
94
+ normalize_prediction_as_of,
95
+ resolve_species_properties,
96
+ )
97
+ from strathmark.ledger import (
98
+ LEGACY_SETTLEMENT_REASON_CODES,
99
+ MAX_NUMERIC_RAW_TIME_SECONDS,
100
+ NUMERIC_OUTCOME_REASON_CODES,
101
+ LedgerConflictError,
102
+ LedgerMonitoringStatus,
103
+ LedgerPrediction,
104
+ LedgerWriteResult,
105
+ NumericOutcomeRevisionResult,
106
+ NumericSettlementRevision,
107
+ NumericSettlementRevisionResult,
108
+ PredictionLedger,
109
+ SettlementConflictError,
110
+ SettlementResult,
111
+ )
112
+ from strathmark.llm import call_ollama, check_ollama_connection
113
+ from strathmark.loader import load_results_for_competitor, load_woodchopping_xlsx
114
+ from strathmark.mark_optimizer import (
115
+ MarkOptimizationResult,
116
+ legacy_rounded_gap_marks,
117
+ optimize_joint_marks,
118
+ )
119
+ from strathmark.mnemex import (
120
+ is_mnemex_configured,
121
+ pull_canonical_competitors,
122
+ pull_canonical_results,
123
+ register_competitor_in_mnemex,
124
+ )
125
+ from strathmark.prediction_v2 import (
126
+ ChronologicalCalibrator,
127
+ ForecastInterval,
128
+ PredictionV2Model,
129
+ PredictionV2Request,
130
+ PredictiveDistribution,
131
+ )
132
+ from strathmark.predictor import (
133
+ CompetitorRecord,
134
+ FilePredictionProvider,
135
+ HistoricalResult,
136
+ PredictionBundle,
137
+ PredictionContext,
138
+ PredictionEngineProvider,
139
+ PredictionInterval,
140
+ PredictionResult,
141
+ StaticPredictionProvider,
142
+ WoodProfile,
143
+ get_all_predictions,
144
+ get_best_prediction,
145
+ get_prediction_provider,
146
+ predict_baseline,
147
+ select_best_prediction,
148
+ )
149
+ from strathmark.shadow import (
150
+ ACTIVE_INPUT_SCHEMA_VERSION,
151
+ IDENTITY_SCHEMA_VERSION,
152
+ OBSERVATION_SCHEMA_VERSION,
153
+ RECEIPT_CORE_SCHEMA_VERSION,
154
+ REQUEST_PROJECTION_SCHEMA_VERSION,
155
+ SHADOW_TARGET_SINGLE_ELAPSED,
156
+ ShadowCalculationResult,
157
+ ShadowFieldRequest,
158
+ ShadowLiveStatus,
159
+ ShadowPredictionService,
160
+ ShadowReceipt,
161
+ )
162
+ from strathmark.store import (
163
+ DEFAULT_MAX_SNAPSHOT_AGE_DAYS,
164
+ EVIDENCE_ACTIVATION_SCHEMA_VERSION,
165
+ EVIDENCE_HISTORY_ROW_SCHEMA_VERSION,
166
+ EVIDENCE_SNAPSHOT_SCHEMA_VERSION,
167
+ EVIDENCE_SNAPSHOT_SOURCE_SCHEMA_VERSION,
168
+ MAX_CAPTURE_CLOCK_SKEW_SECONDS,
169
+ EvidenceSnapshotConflictError,
170
+ EvidenceSnapshotIntegrityError,
171
+ EvidenceSnapshotPayload,
172
+ EvidenceSnapshotSelection,
173
+ EvidenceSnapshotSource,
174
+ EvidenceSnapshotStatus,
175
+ ResultStore,
176
+ canonical_evidence_source_digest,
177
+ )
178
+ from strathmark.sync import (
179
+ SyncResult,
180
+ manual_force_sync,
181
+ nightly_batch,
182
+ strathex_finalization,
183
+ )
184
+ from strathmark.utils import score_prediction_accuracy
185
+ from strathmark.variance import (
186
+ audit_mark_sheet,
187
+ estimate_competitor_std_dev,
188
+ quick_fairness_check,
189
+ run_monte_carlo_simulation,
190
+ )
191
+ from strathmark.visualization import (
192
+ generate_simulation_summary,
193
+ visualize_simulation_results,
194
+ )
195
+
196
+ __all__ = [
197
+ # Core calculation
198
+ "HandicapCalculator",
199
+ "process_competition_day",
200
+ # Data types
201
+ "CompetitorRecord",
202
+ "WoodProfile",
203
+ "HistoricalResult",
204
+ "PredictionResult",
205
+ "PredictionContext",
206
+ "PredictionInterval",
207
+ "PredictionBundle",
208
+ "PredictionEngineProvider",
209
+ "StaticPredictionProvider",
210
+ "FilePredictionProvider",
211
+ "PriorEvidence",
212
+ "ExclusionDiagnostics",
213
+ # Prediction API
214
+ "get_best_prediction",
215
+ "get_prediction_provider",
216
+ "get_all_predictions",
217
+ "select_best_prediction",
218
+ "predict_baseline",
219
+ "build_prior_evidence",
220
+ "normalize_prediction_as_of",
221
+ "resolve_species_properties",
222
+ "PredictionV2Model",
223
+ "PredictionV2Request",
224
+ "PredictiveDistribution",
225
+ "ForecastInterval",
226
+ "ChronologicalCalibrator",
227
+ "SHADOW_CONSUMER_CONTRACT_VERSION",
228
+ "EXPECTED_SHADOW_CONSUMER_PATHS",
229
+ "ShadowConsumerContractIntegrityError",
230
+ "load_shadow_consumer_contract",
231
+ "shadow_consumer_contract_bytes",
232
+ "shadow_consumer_contract_digest",
233
+ "MarkOptimizationResult",
234
+ "legacy_rounded_gap_marks",
235
+ "optimize_joint_marks",
236
+ # Persistence
237
+ "ResultStore",
238
+ "EvidenceSnapshotPayload",
239
+ "EvidenceSnapshotSelection",
240
+ "EvidenceSnapshotSource",
241
+ "EvidenceSnapshotStatus",
242
+ "EvidenceSnapshotConflictError",
243
+ "EvidenceSnapshotIntegrityError",
244
+ "canonical_evidence_source_digest",
245
+ "EVIDENCE_SNAPSHOT_SOURCE_SCHEMA_VERSION",
246
+ "EVIDENCE_SNAPSHOT_SCHEMA_VERSION",
247
+ "EVIDENCE_HISTORY_ROW_SCHEMA_VERSION",
248
+ "EVIDENCE_ACTIVATION_SCHEMA_VERSION",
249
+ "DEFAULT_MAX_SNAPSHOT_AGE_DAYS",
250
+ "MAX_CAPTURE_CLOCK_SKEW_SECONDS",
251
+ "PredictionLedger",
252
+ "LedgerPrediction",
253
+ "LedgerWriteResult",
254
+ "LedgerConflictError",
255
+ "LEGACY_SETTLEMENT_REASON_CODES",
256
+ "MAX_NUMERIC_RAW_TIME_SECONDS",
257
+ "NUMERIC_OUTCOME_REASON_CODES",
258
+ "LedgerMonitoringStatus",
259
+ "NumericSettlementRevision",
260
+ "NumericSettlementRevisionResult",
261
+ "NumericOutcomeRevisionResult",
262
+ "SettlementResult",
263
+ "SettlementConflictError",
264
+ "ShadowFieldRequest",
265
+ "ShadowLiveStatus",
266
+ "ShadowReceipt",
267
+ "ShadowCalculationResult",
268
+ "ShadowPredictionService",
269
+ "RECEIPT_CORE_SCHEMA_VERSION",
270
+ "REQUEST_PROJECTION_SCHEMA_VERSION",
271
+ "ACTIVE_INPUT_SCHEMA_VERSION",
272
+ "IDENTITY_SCHEMA_VERSION",
273
+ "OBSERVATION_SCHEMA_VERSION",
274
+ "SHADOW_TARGET_SINGLE_ELAPSED",
275
+ "ACTOR_ATTESTATION_SCHEMA_VERSION",
276
+ "SHADOW_ATTESTATION_AUDIENCE",
277
+ "VerifiedActorAttestation",
278
+ "shadow_auth_configuration_status",
279
+ "sign_actor_attestation",
280
+ # Simulation
281
+ "run_monte_carlo_simulation",
282
+ "estimate_competitor_std_dev",
283
+ "audit_mark_sheet",
284
+ "quick_fairness_check",
285
+ # Visualization
286
+ "generate_simulation_summary",
287
+ "visualize_simulation_results",
288
+ # Fairness
289
+ "get_ai_assessment_of_handicaps",
290
+ "get_championship_race_analysis",
291
+ "simulate_and_assess_handicaps",
292
+ # LLM
293
+ "call_ollama",
294
+ "check_ollama_connection",
295
+ # Data loading
296
+ "load_woodchopping_xlsx",
297
+ "load_results_for_competitor",
298
+ # Database (Supabase)
299
+ "push_results",
300
+ "push_results_dicts",
301
+ "pull_results",
302
+ "push_competitors",
303
+ "pull_competitors",
304
+ "register_competitor",
305
+ "format_proam_results",
306
+ "record_prediction_residuals",
307
+ "get_competitor_bias",
308
+ "mirror_prediction_ledger",
309
+ # ML state (carve-out from controlled-write rule; STRATHMARK-internal)
310
+ "register_model_version",
311
+ "set_active_model",
312
+ "get_active_model_version",
313
+ "record_calibration",
314
+ "store_features",
315
+ "record_prediction",
316
+ "settle_prediction",
317
+ # MNEMEX (canonical archive client)
318
+ "is_mnemex_configured",
319
+ "pull_canonical_results",
320
+ "pull_canonical_competitors",
321
+ "register_competitor_in_mnemex",
322
+ # Sync (MNEMEX -> STRATHMARK Supabase)
323
+ "SyncResult",
324
+ "nightly_batch",
325
+ "strathex_finalization",
326
+ "manual_force_sync",
327
+ # Drift detection
328
+ "DriftReport",
329
+ "evaluate_drift",
330
+ "is_drifting",
331
+ # Scoring / accuracy
332
+ "score_prediction_accuracy",
333
+ ]
334
+
335
+ __version__ = "2.0.0"
@@ -0,0 +1,267 @@
1
+ """
2
+ Analytics
3
+ ==========
4
+
5
+ Backtesting, competitor profiling, and performance history analysis.
6
+
7
+ Public functions:
8
+ backtest_predictions() -- compare predicted vs actual times
9
+ profile_competitor() -- summarise a single competitor's history
10
+ summarise_performance_history() -- tournament history summary for a field
11
+
12
+ Source references (STRATHEX):
13
+ woodchopping/analytics/prediction_accuracy.py
14
+ woodchopping/analytics/competitor_profiling.py
15
+ woodchopping/analytics/performance_history.py
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from datetime import date
21
+ from typing import Any, Dict, List, Optional
22
+
23
+ import numpy as np
24
+ import pandas as pd
25
+
26
+ from strathmark.predictor import (
27
+ CompetitorRecord,
28
+ WoodProfile,
29
+ get_best_prediction,
30
+ )
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Backtesting
34
+ # ---------------------------------------------------------------------------
35
+
36
+
37
+ def backtest_predictions(
38
+ competitors: List[CompetitorRecord],
39
+ wood: WoodProfile,
40
+ event_code: str,
41
+ actuals: Dict[str, float],
42
+ ml_model=None,
43
+ results_df=None,
44
+ wood_df=None,
45
+ ) -> Dict[str, Any]:
46
+ """
47
+ Compare predicted times against actual race times.
48
+
49
+ Uses the full prediction cascade (get_best_prediction) when ml_model or
50
+ results_df are provided, otherwise falls back to baseline only.
51
+
52
+ Args:
53
+ competitors: List of CompetitorRecord objects.
54
+ wood: Wood profile used in the event.
55
+ event_code: 'SB' or 'UH'.
56
+ actuals: Dict mapping competitor name to actual cutting time (seconds).
57
+ ml_model: Optional trained MLModel for ML cascade level.
58
+ results_df: Optional historical results DataFrame.
59
+ wood_df: Optional wood properties DataFrame.
60
+
61
+ Returns:
62
+ Dict with keys:
63
+ 'results' -- List of per-competitor dicts
64
+ 'mae' -- Mean Absolute Error (seconds)
65
+ 'rmse' -- Root Mean Squared Error (seconds)
66
+ 'bias' -- Mean signed error (positive = predictions too high)
67
+ 'within_3s_pct' -- % of predictions within 3 seconds of actual
68
+ """
69
+ results = []
70
+ errors = []
71
+
72
+ for record in competitors:
73
+ if record.name not in actuals:
74
+ continue
75
+ actual = actuals[record.name]
76
+ pred_result = get_best_prediction(
77
+ record,
78
+ wood,
79
+ event_code,
80
+ wood_data_df=wood_df,
81
+ results_df=results_df,
82
+ ml_model=ml_model,
83
+ )
84
+ if pred_result is None:
85
+ continue
86
+ predicted = pred_result.value
87
+ error = predicted - actual
88
+ abs_error = abs(error)
89
+ errors.append(error)
90
+ results.append(
91
+ {
92
+ "name": record.name,
93
+ "predicted": predicted,
94
+ "actual": actual,
95
+ "error": error,
96
+ "abs_error": abs_error,
97
+ "confidence": pred_result.confidence,
98
+ }
99
+ )
100
+
101
+ if not errors:
102
+ return {
103
+ "results": results,
104
+ "mae": None,
105
+ "rmse": None,
106
+ "bias": None,
107
+ "within_3s_pct": None,
108
+ }
109
+
110
+ errors_arr = np.array(errors)
111
+ within_3s = sum(1 for e in errors if abs(e) <= 3.0)
112
+ return {
113
+ "results": results,
114
+ "mae": float(np.mean(np.abs(errors_arr))),
115
+ "rmse": float(np.sqrt(np.mean(errors_arr**2))),
116
+ "bias": float(np.mean(errors_arr)),
117
+ "within_3s_pct": within_3s / len(errors) * 100,
118
+ }
119
+
120
+
121
+ def rolling_origin_analysis(
122
+ evidence: pd.DataFrame,
123
+ *,
124
+ target_start: date | None = None,
125
+ target_end_exclusive: date | None = None,
126
+ min_training_rows: int = 30,
127
+ minimum_global_rows: int = 100,
128
+ minimum_cohort_rows: int = 30,
129
+ ) -> Dict[str, Any]:
130
+ """Return an honest prior-only V2 rolling-origin accuracy summary.
131
+
132
+ ``claim_eligible`` is explicit so a small sample cannot accidentally be
133
+ presented as a validated accuracy or subgroup claim.
134
+ """
135
+
136
+ from strathmark.validation import chronological_backtest
137
+
138
+ report = chronological_backtest(
139
+ evidence,
140
+ target_start=target_start,
141
+ target_end_exclusive=target_end_exclusive,
142
+ min_training_rows=min_training_rows,
143
+ )
144
+ predictions = report.predictions
145
+ metrics: Dict[str, Any] = dict(report.metrics)
146
+ count = int(metrics.get("count", 0))
147
+ metrics["claim_eligible"] = count >= minimum_global_rows
148
+ metrics["sample_label"] = (
149
+ "global_claim_eligible" if metrics["claim_eligible"] else "insufficient_global_sample"
150
+ )
151
+ if not predictions.empty and {"core_lower", "core_upper"}.issubset(predictions.columns):
152
+ actual = predictions["actual_time"].to_numpy(dtype=float)
153
+ lower = predictions["core_lower"].to_numpy(dtype=float)
154
+ upper = predictions["core_upper"].to_numpy(dtype=float)
155
+ metrics["coverage_90"] = float(np.mean((lower <= actual) & (actual <= upper)))
156
+ metrics["mean_interval_width"] = float(np.mean(upper - lower))
157
+
158
+ cohorts: Dict[str, Dict[str, Any]] = {}
159
+ for name, values in report.cohort_metrics.items():
160
+ cohort = dict(values)
161
+ cohort_count = int(cohort.get("count", 0))
162
+ cohort["claim_eligible"] = cohort_count >= minimum_cohort_rows
163
+ cohort["sample_label"] = (
164
+ "cohort_claim_eligible" if cohort["claim_eligible"] else "insufficient_cohort_sample"
165
+ )
166
+ cohorts[name] = cohort
167
+ return {
168
+ "metrics": metrics,
169
+ "cohorts": cohorts,
170
+ "predictions": predictions,
171
+ }
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # Competitor profiling
176
+ # ---------------------------------------------------------------------------
177
+
178
+
179
+ def profile_competitor(
180
+ record: CompetitorRecord,
181
+ event_code: Optional[str] = None,
182
+ ) -> Dict[str, Any]:
183
+ """
184
+ Summarise a competitor's historical performance.
185
+
186
+ Args:
187
+ record: Competitor record with history populated.
188
+ event_code: Optional filter ('SB' or 'UH'). None analyses all events.
189
+
190
+ Returns:
191
+ Dict with:
192
+ 'name', 'division', 'total_results', 'events_contested',
193
+ 'mean_time', 'std_dev', 'best_time', 'worst_time',
194
+ 'most_recent_date', 'activity_level'
195
+ """
196
+ history = record.history
197
+ if event_code is not None:
198
+ history = [r for r in history if r.event_code.upper() == event_code.upper()]
199
+
200
+ if not history:
201
+ return {
202
+ "name": record.name,
203
+ "division": record.division,
204
+ "total_results": 0,
205
+ "events_contested": [],
206
+ "mean_time": None,
207
+ "std_dev": None,
208
+ "best_time": None,
209
+ "worst_time": None,
210
+ "most_recent_date": None,
211
+ "activity_level": "inactive",
212
+ }
213
+
214
+ times = [r.time_seconds for r in history]
215
+ events = sorted({r.event_code for r in history})
216
+ dates = [r.result_date for r in history if r.result_date is not None]
217
+ most_recent = max(dates) if dates else None
218
+
219
+ today = date.today()
220
+ recent_count = sum(1 for d in dates if d is not None and (today - d).days <= 730)
221
+ if recent_count >= 5:
222
+ activity = "active"
223
+ elif recent_count >= 2:
224
+ activity = "moderate"
225
+ else:
226
+ activity = "inactive"
227
+
228
+ return {
229
+ "name": record.name,
230
+ "division": record.division,
231
+ "total_results": len(times),
232
+ "events_contested": events,
233
+ "mean_time": float(np.mean(times)),
234
+ "std_dev": float(np.std(times, ddof=1)) if len(times) >= 2 else None,
235
+ "best_time": float(min(times)),
236
+ "worst_time": float(max(times)),
237
+ "most_recent_date": most_recent,
238
+ "activity_level": activity,
239
+ }
240
+
241
+
242
+ # ---------------------------------------------------------------------------
243
+ # Performance history summary
244
+ # ---------------------------------------------------------------------------
245
+
246
+
247
+ def summarise_performance_history(
248
+ competitors: List[CompetitorRecord],
249
+ event_code: Optional[str] = None,
250
+ ) -> List[Dict[str, Any]]:
251
+ """
252
+ Return a ranked performance summary for a field of competitors.
253
+
254
+ Sorted by mean_time ascending (fastest first).
255
+
256
+ Args:
257
+ competitors: List of CompetitorRecord objects.
258
+ event_code: Optional filter.
259
+
260
+ Returns:
261
+ List of profile dicts (see profile_competitor()), sorted fastest first.
262
+ """
263
+ profiles = [profile_competitor(c, event_code) for c in competitors]
264
+ with_data = [p for p in profiles if p["mean_time"] is not None]
265
+ without_data = [p for p in profiles if p["mean_time"] is None]
266
+ with_data.sort(key=lambda p: p["mean_time"])
267
+ return with_data + without_data