coordinator-node 0.1.2__tar.gz → 0.1.3__tar.gz

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 (125) hide show
  1. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/PKG-INFO +1 -1
  2. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/node/Dockerfile +1 -1
  3. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/node/docker-compose.yml +5 -5
  4. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/db/repositories.py +1 -0
  5. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/services/score.py +8 -1
  6. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/workers/report_worker.py +88 -0
  7. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/pyproject.toml +1 -1
  8. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/tests/test_checkpoint_worker.py +92 -0
  9. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/tests/test_node_template_predict_service.py +66 -2
  10. coordinator_node-0.1.3/tests/test_node_template_repositories.py +86 -0
  11. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/uv.lock +1 -1
  12. coordinator_node-0.1.2/tests/test_node_template_repositories.py +0 -39
  13. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/.dev.env +0 -0
  14. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/.dockerignore +0 -0
  15. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/.gitignore +0 -0
  16. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/.local.env +0 -0
  17. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/.production.env +0 -0
  18. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/.python-version +0 -0
  19. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/Dockerfile +0 -0
  20. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/Makefile +0 -0
  21. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/README.md +0 -0
  22. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/SKILL.md +0 -0
  23. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/Makefile +0 -0
  24. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/README.md +0 -0
  25. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/SKILL.md +0 -0
  26. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/challenge/README.md +0 -0
  27. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/challenge/SKILL.md +0 -0
  28. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/challenge/pyproject.toml +0 -0
  29. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/challenge/starter_challenge/__init__.py +0 -0
  30. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/challenge/starter_challenge/examples/README.md +0 -0
  31. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/challenge/starter_challenge/examples/__init__.py +0 -0
  32. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/challenge/starter_challenge/examples/mean_reversion_tracker.py +0 -0
  33. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/challenge/starter_challenge/examples/trend_following_tracker.py +0 -0
  34. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/challenge/starter_challenge/examples/volatility_regime_tracker.py +0 -0
  35. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/challenge/starter_challenge/schemas/README.md +0 -0
  36. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/challenge/starter_challenge/scoring.py +0 -0
  37. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/challenge/starter_challenge/tracker.py +0 -0
  38. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/node/.local.env +0 -0
  39. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/node/.local.env.example +0 -0
  40. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/node/.production.env.example +0 -0
  41. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/node/Makefile +0 -0
  42. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/node/README.md +0 -0
  43. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/node/RUNBOOK.md +0 -0
  44. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/node/SKILL.md +0 -0
  45. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/node/config/README.md +0 -0
  46. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/node/config/callables.env +0 -0
  47. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/node/config/scheduled_prediction_configs.json +0 -0
  48. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/node/deployment/README.md +0 -0
  49. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/node/deployment/model-orchestrator-local/config/docker-entrypoint.sh +0 -0
  50. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/node/deployment/model-orchestrator-local/config/models.dev.yml +0 -0
  51. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/node/deployment/model-orchestrator-local/config/orchestrator.dev.yml +0 -0
  52. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/node/deployment/model-orchestrator-local/config/starter-submission/main.py +0 -0
  53. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/node/deployment/model-orchestrator-local/config/starter-submission/requirements.txt +0 -0
  54. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/node/deployment/model-orchestrator-local/config/starter-submission/tracker.py +0 -0
  55. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/node/deployment/report-ui/config/global-settings.json +0 -0
  56. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/node/deployment/report-ui/config/leaderboard-columns.json +0 -0
  57. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/node/deployment/report-ui/config/metrics-widgets.json +0 -0
  58. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/node/extensions/README.md +0 -0
  59. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/node/plugins/README.md +0 -0
  60. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/node/pyproject.toml +0 -0
  61. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/node/runtime_definitions/__init__.py +0 -0
  62. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/node/runtime_definitions/contracts.py +0 -0
  63. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/node/scripts/backfill.py +0 -0
  64. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/node/scripts/capture_runtime_logs.py +0 -0
  65. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/node/scripts/check_models.py +0 -0
  66. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/base/node/scripts/verify_e2e.py +0 -0
  67. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/clean-data.sh +0 -0
  68. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/__init__.py +0 -0
  69. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/config/__init__.py +0 -0
  70. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/config/extensions.py +0 -0
  71. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/config/runtime.py +0 -0
  72. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/contracts.py +0 -0
  73. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/db/__init__.py +0 -0
  74. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/db/feed_records.py +0 -0
  75. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/db/init_db.py +0 -0
  76. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/db/pg_notify.py +0 -0
  77. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/db/session.py +0 -0
  78. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/db/tables/__init__.py +0 -0
  79. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/db/tables/feed.py +0 -0
  80. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/db/tables/models.py +0 -0
  81. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/db/tables/pipeline.py +0 -0
  82. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/entities/__init__.py +0 -0
  83. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/entities/feed_record.py +0 -0
  84. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/entities/model.py +0 -0
  85. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/entities/prediction.py +0 -0
  86. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/extensions/__init__.py +0 -0
  87. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/extensions/callable_resolver.py +0 -0
  88. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/extensions/default_callables.py +0 -0
  89. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/feeds/__init__.py +0 -0
  90. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/feeds/base.py +0 -0
  91. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/feeds/contracts.py +0 -0
  92. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/feeds/providers/__init__.py +0 -0
  93. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/feeds/providers/binance.py +0 -0
  94. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/feeds/providers/pyth.py +0 -0
  95. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/feeds/registry.py +0 -0
  96. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/schemas/__init__.py +0 -0
  97. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/schemas/payload_contracts.py +0 -0
  98. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/services/__init__.py +0 -0
  99. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/services/backfill.py +0 -0
  100. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/services/feed_data.py +0 -0
  101. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/services/feed_reader.py +0 -0
  102. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/services/predict.py +0 -0
  103. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/services/realtime_predict.py +0 -0
  104. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/workers/__init__.py +0 -0
  105. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/workers/checkpoint_worker.py +0 -0
  106. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/workers/feed_data_worker.py +0 -0
  107. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/workers/predict_worker.py +0 -0
  108. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/coordinator_node/workers/score_worker.py +0 -0
  109. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/docker-compose.yml +0 -0
  110. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/docs/plans/2026-02-10-coordinator-restructure-design.md +0 -0
  111. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/docs/plans/2026-02-11-contract-based-architecture.md +0 -0
  112. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/docs/plans/2026-02-11-scoring-snapshots-checkpoints.md +0 -0
  113. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/docs/plans/2026-02-11-thin-node-cli-implementation-plan.md +0 -0
  114. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/docs/plans/2026-02-11-thin-node-cli-onboarding-design.md +0 -0
  115. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/mkdocs.yml +0 -0
  116. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/packs/README.md +0 -0
  117. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/scripts/verify_e2e.py +0 -0
  118. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/tests/test_backfill.py +0 -0
  119. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/tests/test_callable_resolver.py +0 -0
  120. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/tests/test_coordinator_core_schema.py +0 -0
  121. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/tests/test_coordinator_runtime_data_feeds.py +0 -0
  122. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/tests/test_core_entities.py +0 -0
  123. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/tests/test_node_template_report_worker.py +0 -0
  124. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/tests/test_node_template_score_service.py +0 -0
  125. {coordinator_node-0.1.2 → coordinator_node-0.1.3}/tests/test_prediction_lifecycle.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coordinator-node
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Runtime engine for Crunch coordinator nodes
5
5
  Project-URL: Homepage, https://github.com/crunchdao/coordinator-node-starter
6
6
  Project-URL: Repository, https://github.com/crunchdao/coordinator-node-starter
@@ -8,7 +8,7 @@ WORKDIR /app
8
8
 
9
9
  # Coordinator engine (from PyPI)
10
10
  # Bump this to force Docker to re-pull the latest package version
11
- ARG COORDINATOR_NODE_VERSION=0.1.2
11
+ ARG COORDINATOR_NODE_VERSION=0.1.3
12
12
  RUN pip install --no-cache-dir "coordinator-node>=${COORDINATOR_NODE_VERSION}"
13
13
 
14
14
  # Challenge package
@@ -62,7 +62,7 @@ services:
62
62
  FEED_BACKFILL_MINUTES: ${FEED_BACKFILL_MINUTES:-180}
63
63
  FEED_RECORD_TTL_DAYS: ${FEED_RECORD_TTL_DAYS:-90}
64
64
  FEED_RETENTION_CHECK_SECONDS: ${FEED_RETENTION_CHECK_SECONDS:-3600}
65
- PYTHONPATH: /app/challenge
65
+ PYTHONPATH: /app:/app/challenge
66
66
  volumes:
67
67
  - ../challenge:/app/challenge
68
68
  depends_on:
@@ -93,7 +93,7 @@ services:
93
93
  FEED_SUBJECTS: ${FEED_SUBJECTS:-BTC}
94
94
  FEED_KIND: ${FEED_KIND:-tick}
95
95
  FEED_GRANULARITY: ${FEED_GRANULARITY:-1s}
96
- PYTHONPATH: /app/challenge
96
+ PYTHONPATH: /app:/app/challenge
97
97
  volumes:
98
98
  - ../challenge:/app/challenge
99
99
  depends_on:
@@ -124,7 +124,7 @@ services:
124
124
  FEED_KIND: ${FEED_KIND:-tick}
125
125
  FEED_GRANULARITY: ${FEED_GRANULARITY:-1s}
126
126
  SCORING_FUNCTION: ${SCORING_FUNCTION}
127
- PYTHONPATH: /app/challenge
127
+ PYTHONPATH: /app:/app/challenge
128
128
  volumes:
129
129
  - ../challenge:/app/challenge
130
130
  depends_on:
@@ -148,7 +148,7 @@ services:
148
148
  POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-starter}
149
149
  POSTGRES_DB: ${POSTGRES_DB:-starter}
150
150
  CHECKPOINT_INTERVAL_SECONDS: ${CHECKPOINT_INTERVAL_SECONDS:-604800}
151
- PYTHONPATH: /app/challenge
151
+ PYTHONPATH: /app:/app/challenge
152
152
  volumes:
153
153
  - ../challenge:/app/challenge
154
154
  depends_on:
@@ -171,7 +171,7 @@ services:
171
171
  POSTGRES_USER: ${POSTGRES_USER:-starter}
172
172
  POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-starter}
173
173
  POSTGRES_DB: ${POSTGRES_DB:-starter}
174
- PYTHONPATH: /app/challenge
174
+ PYTHONPATH: /app:/app/challenge
175
175
  volumes:
176
176
  - ../challenge:/app/challenge
177
177
  depends_on:
@@ -168,6 +168,7 @@ class DBPredictionRepository:
168
168
  existing.meta_jsonb = row.meta_jsonb
169
169
  existing.scope_key = row.scope_key
170
170
  existing.scope_jsonb = row.scope_jsonb
171
+ existing.resolvable_at = row.resolvable_at
171
172
  self._session.commit()
172
173
 
173
174
  def save_all(self, predictions: Iterable[PredictionRecord]) -> None:
@@ -224,7 +224,7 @@ class ScoreService:
224
224
  metrics: dict[str, float] = {}
225
225
  for window_name, window in aggregation.windows.items():
226
226
  cutoff = now - timedelta(hours=window.hours)
227
- window_snaps = [s for s in model_snapshots if s.period_end >= cutoff]
227
+ window_snaps = [s for s in model_snapshots if self._ensure_utc(s.period_end) >= cutoff]
228
228
  if window_snaps:
229
229
  vals = [float(s.result_summary.get(aggregation.ranking_key, 0)) for s in window_snaps]
230
230
  metrics[window_name] = sum(vals) / len(vals)
@@ -270,6 +270,13 @@ class ScoreService:
270
270
 
271
271
  _rank_leaderboard = _rank
272
272
 
273
+ @staticmethod
274
+ def _ensure_utc(dt: datetime) -> datetime:
275
+ """Ensure a datetime is timezone-aware (assume UTC if naive)."""
276
+ if dt.tzinfo is None:
277
+ return dt.replace(tzinfo=timezone.utc)
278
+ return dt
279
+
273
280
  def _rollback_repositories(self) -> None:
274
281
  for name, repo in [("input", self.input_repository),
275
282
  ("prediction", self.prediction_repository),
@@ -667,6 +667,94 @@ def get_checkpoint_emission_cli_format(
667
667
  }
668
668
 
669
669
 
670
+ @app.get("/reports/checkpoints/{checkpoint_id}/prizes")
671
+ def get_checkpoint_prizes(
672
+ checkpoint_id: str,
673
+ checkpoint_repo: Annotated[DBCheckpointRepository, Depends(get_checkpoint_repository)],
674
+ total_prize: Annotated[int, Query(description="Total prize pool to distribute (in token lowest denomination)")] = 0,
675
+ ) -> list[dict[str, Any]]:
676
+ """Return checkpoint emission as Prize[] JSON for the coordinator webapp.
677
+
678
+ The webapp's CreateCheckpoint UI expects:
679
+ [{prizeId, timestamp, model, prize}]
680
+ where `model` is a model ID and `prize` is an absolute token amount.
681
+
682
+ This endpoint converts the node's frac64 percentage-based emission into
683
+ the webapp format by distributing `total_prize` proportionally.
684
+ """
685
+ checkpoints = checkpoint_repo.find()
686
+ checkpoint = next((c for c in checkpoints if c.id == checkpoint_id), None)
687
+ if checkpoint is None:
688
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Checkpoint not found")
689
+ if not checkpoint.entries:
690
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No emission data in checkpoint")
691
+
692
+ emission = checkpoint.entries[0]
693
+ ranking = checkpoint.meta.get("ranking", [])
694
+ frac64_multiplier = 1_000_000_000
695
+ timestamp = int(checkpoint.period_end.timestamp())
696
+
697
+ prizes: list[dict[str, Any]] = []
698
+ for reward in emission.get("cruncher_rewards", []):
699
+ idx = reward["cruncher_index"]
700
+ pct = reward["reward_pct"] / frac64_multiplier
701
+ model_id = ranking[idx]["model_id"] if idx < len(ranking) else str(idx)
702
+
703
+ prize_amount = int(round(total_prize * pct))
704
+ prizes.append({
705
+ "prizeId": f"{checkpoint_id}-{model_id}",
706
+ "timestamp": timestamp,
707
+ "model": model_id,
708
+ "prize": prize_amount,
709
+ })
710
+
711
+ return prizes
712
+
713
+
714
+ @app.get("/reports/checkpoints/latest/prizes")
715
+ def get_latest_checkpoint_prizes(
716
+ checkpoint_repo: Annotated[DBCheckpointRepository, Depends(get_checkpoint_repository)],
717
+ total_prize: Annotated[int, Query(description="Total prize pool to distribute (in token lowest denomination)")] = 0,
718
+ ) -> dict[str, Any]:
719
+ """Return the latest checkpoint's prizes in webapp format.
720
+
721
+ Convenience wrapper that finds the latest checkpoint and returns its prizes.
722
+ """
723
+ checkpoint = checkpoint_repo.get_latest()
724
+ if checkpoint is None:
725
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No checkpoints found")
726
+ if not checkpoint.entries:
727
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No emission data in checkpoint")
728
+
729
+ emission = checkpoint.entries[0]
730
+ ranking = checkpoint.meta.get("ranking", [])
731
+ frac64_multiplier = 1_000_000_000
732
+ timestamp = int(checkpoint.period_end.timestamp())
733
+
734
+ prizes: list[dict[str, Any]] = []
735
+ for reward in emission.get("cruncher_rewards", []):
736
+ idx = reward["cruncher_index"]
737
+ pct = reward["reward_pct"] / frac64_multiplier
738
+ model_id = ranking[idx]["model_id"] if idx < len(ranking) else str(idx)
739
+
740
+ prize_amount = int(round(total_prize * pct))
741
+ prizes.append({
742
+ "prizeId": f"{checkpoint.id}-{model_id}",
743
+ "timestamp": timestamp,
744
+ "model": model_id,
745
+ "prize": prize_amount,
746
+ })
747
+
748
+ return {
749
+ "checkpoint_id": checkpoint.id,
750
+ "status": checkpoint.status,
751
+ "period_start": checkpoint.period_start.isoformat(),
752
+ "period_end": checkpoint.period_end.isoformat(),
753
+ "total_prize": total_prize,
754
+ "prizes": prizes,
755
+ }
756
+
757
+
670
758
  @app.get("/reports/emissions/latest")
671
759
  def get_latest_emission(
672
760
  checkpoint_repo: Annotated[DBCheckpointRepository, Depends(get_checkpoint_repository)],
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "coordinator-node"
7
- version = "0.1.2"
7
+ version = "0.1.3"
8
8
  description = "Runtime engine for Crunch coordinator nodes"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -397,5 +397,97 @@ class TestEmissionEndpoints(unittest.TestCase):
397
397
  self.assertEqual(result["emission"]["crunch"], "crunch_abc")
398
398
 
399
399
 
400
+ # ── Prizes endpoint tests ──
401
+
402
+
403
+ class TestPrizesEndpoints(unittest.TestCase):
404
+ def _make_checkpoint(self) -> CheckpointRecord:
405
+ return CheckpointRecord(
406
+ id="CKP_001",
407
+ period_start=now - timedelta(days=7),
408
+ period_end=now,
409
+ status=CheckpointStatus.PENDING,
410
+ entries=[{
411
+ "crunch": "crunch_abc",
412
+ "cruncher_rewards": [
413
+ {"cruncher_index": 0, "reward_pct": 600_000_000},
414
+ {"cruncher_index": 1, "reward_pct": 400_000_000},
415
+ ],
416
+ "compute_provider_rewards": [],
417
+ "data_provider_rewards": [],
418
+ }],
419
+ meta={"ranking": [
420
+ {"model_id": "m1", "rank": 1},
421
+ {"model_id": "m2", "rank": 2},
422
+ ]},
423
+ created_at=now,
424
+ )
425
+
426
+ def test_get_checkpoint_prizes_format(self):
427
+ from coordinator_node.workers.report_worker import get_checkpoint_prizes
428
+
429
+ repo = MemCheckpointRepository([self._make_checkpoint()])
430
+ result = get_checkpoint_prizes("CKP_001", repo, total_prize=1_000_000)
431
+
432
+ self.assertEqual(len(result), 2)
433
+ # First model gets 60%
434
+ self.assertEqual(result[0]["model"], "m1")
435
+ self.assertEqual(result[0]["prize"], 600_000)
436
+ self.assertEqual(result[0]["prizeId"], "CKP_001-m1")
437
+ self.assertIn("timestamp", result[0])
438
+ # Second model gets 40%
439
+ self.assertEqual(result[1]["model"], "m2")
440
+ self.assertEqual(result[1]["prize"], 400_000)
441
+
442
+ def test_get_checkpoint_prizes_zero_total(self):
443
+ from coordinator_node.workers.report_worker import get_checkpoint_prizes
444
+
445
+ repo = MemCheckpointRepository([self._make_checkpoint()])
446
+ result = get_checkpoint_prizes("CKP_001", repo, total_prize=0)
447
+
448
+ self.assertEqual(len(result), 2)
449
+ self.assertEqual(result[0]["prize"], 0)
450
+ self.assertEqual(result[1]["prize"], 0)
451
+
452
+ def test_get_checkpoint_prizes_not_found(self):
453
+ from coordinator_node.workers.report_worker import get_checkpoint_prizes
454
+ from fastapi import HTTPException
455
+
456
+ repo = MemCheckpointRepository()
457
+ with self.assertRaises(HTTPException):
458
+ get_checkpoint_prizes("CKP_NONEXISTENT", repo, total_prize=1000)
459
+
460
+ def test_get_checkpoint_prizes_sums_to_total(self):
461
+ from coordinator_node.workers.report_worker import get_checkpoint_prizes
462
+
463
+ repo = MemCheckpointRepository([self._make_checkpoint()])
464
+ total = 999_999
465
+ result = get_checkpoint_prizes("CKP_001", repo, total_prize=total)
466
+
467
+ actual_sum = sum(p["prize"] for p in result)
468
+ # May differ by ±1 due to rounding, but should be close
469
+ self.assertAlmostEqual(actual_sum, total, delta=len(result))
470
+
471
+ def test_get_latest_checkpoint_prizes(self):
472
+ from coordinator_node.workers.report_worker import get_latest_checkpoint_prizes
473
+
474
+ repo = MemCheckpointRepository([self._make_checkpoint()])
475
+ result = get_latest_checkpoint_prizes(repo, total_prize=1_000_000)
476
+
477
+ self.assertEqual(result["checkpoint_id"], "CKP_001")
478
+ self.assertEqual(result["total_prize"], 1_000_000)
479
+ self.assertEqual(len(result["prizes"]), 2)
480
+ self.assertEqual(result["prizes"][0]["model"], "m1")
481
+ self.assertEqual(result["prizes"][0]["prize"], 600_000)
482
+
483
+ def test_get_latest_checkpoint_prizes_not_found(self):
484
+ from coordinator_node.workers.report_worker import get_latest_checkpoint_prizes
485
+ from fastapi import HTTPException
486
+
487
+ repo = MemCheckpointRepository()
488
+ with self.assertRaises(HTTPException):
489
+ get_latest_checkpoint_prizes(repo, total_prize=1000)
490
+
491
+
400
492
  if __name__ == "__main__":
401
493
  unittest.main()
@@ -2,7 +2,7 @@ import unittest
2
2
  from datetime import datetime, timezone
3
3
 
4
4
  from coordinator_node.entities.model import Model
5
- from coordinator_node.entities.prediction import PredictionRecord
5
+ from coordinator_node.entities.prediction import InputRecord, PredictionRecord
6
6
  from coordinator_node.contracts import CrunchContract
7
7
  from coordinator_node.services.realtime_predict import RealtimePredictService
8
8
 
@@ -111,16 +111,33 @@ class InMemoryPredictionRepository:
111
111
  ]
112
112
 
113
113
 
114
+ class InMemoryInputRepository:
115
+ def __init__(self):
116
+ self.records: list[InputRecord] = []
117
+
118
+ def save(self, record: InputRecord):
119
+ for i, r in enumerate(self.records):
120
+ if r.id == record.id:
121
+ self.records[i] = record
122
+ return
123
+ self.records.append(record)
124
+
125
+ def find(self, **kwargs):
126
+ return list(self.records)
127
+
128
+
114
129
  class NoConfigPredictionRepository(InMemoryPredictionRepository):
115
130
  def fetch_active_configs(self):
116
131
  return []
117
132
 
118
133
 
119
- def _make_service(feed_reader=None, prediction_repo=None, runner=None, contract=None):
134
+ def _make_service(feed_reader=None, prediction_repo=None, input_repo=None,
135
+ runner=None, contract=None):
120
136
  return RealtimePredictService(
121
137
  checkpoint_interval_seconds=60,
122
138
  feed_reader=feed_reader or FakeFeedReader(),
123
139
  contract=contract or CrunchContract(),
140
+ input_repository=input_repo,
124
141
  model_repository=InMemoryModelRepository(),
125
142
  prediction_repository=prediction_repo or InMemoryPredictionRepository(),
126
143
  runner=runner or FakeRunner(),
@@ -191,5 +208,52 @@ class TestRealtimePredictService(unittest.IsolatedAsyncioTestCase):
191
208
  self.assertTrue(any("INFERENCE_OUTPUT_VALIDATION_ERROR" in line for line in logs.output))
192
209
 
193
210
 
211
+ async def test_run_once_sets_input_scope_with_feed_dimensions(self):
212
+ """Regression: input scope must include source/subject/kind/granularity
213
+ so the score worker can query matching feed records for ground truth."""
214
+ input_repo = InMemoryInputRepository()
215
+ pred_repo = InMemoryPredictionRepository()
216
+ feed_reader = FakeFeedReader({"symbol": "BTC"})
217
+ feed_reader.source = "binance"
218
+ feed_reader.subject = "BTC"
219
+ feed_reader.kind = "candle"
220
+ feed_reader.granularity = "1m"
221
+
222
+ service = _make_service(
223
+ feed_reader=feed_reader,
224
+ prediction_repo=pred_repo,
225
+ input_repo=input_repo,
226
+ )
227
+
228
+ await service.run_once(raw_input={"symbol": "BTC"}, now=datetime.now(timezone.utc))
229
+
230
+ self.assertEqual(len(input_repo.records), 1)
231
+ inp = input_repo.records[0]
232
+ # Feed dimensions must be in scope for score worker to query feed records
233
+ self.assertEqual(inp.scope.get("source"), "binance")
234
+ self.assertEqual(inp.scope.get("kind"), "candle")
235
+ self.assertEqual(inp.scope.get("granularity"), "1m")
236
+ # subject comes from config scope_template, which may override feed_reader
237
+ self.assertIn("subject", inp.scope)
238
+
239
+ async def test_run_once_sets_input_resolvable_at(self):
240
+ """Regression: input resolvable_at must be set so the score worker
241
+ can find inputs that are ready for ground truth resolution."""
242
+ input_repo = InMemoryInputRepository()
243
+ pred_repo = InMemoryPredictionRepository()
244
+ service = _make_service(
245
+ prediction_repo=pred_repo,
246
+ input_repo=input_repo,
247
+ )
248
+
249
+ now = datetime.now(timezone.utc)
250
+ await service.run_once(raw_input={"symbol": "BTC"}, now=now)
251
+
252
+ self.assertEqual(len(input_repo.records), 1)
253
+ inp = input_repo.records[0]
254
+ self.assertIsNotNone(inp.resolvable_at)
255
+ self.assertGreater(inp.resolvable_at, now)
256
+
257
+
194
258
  if __name__ == "__main__":
195
259
  unittest.main()
@@ -0,0 +1,86 @@
1
+ import inspect
2
+ import unittest
3
+
4
+ from coordinator_node.db.repositories import (
5
+ DBInputRepository,
6
+ DBLeaderboardRepository,
7
+ DBModelRepository,
8
+ DBPredictionRepository,
9
+ DBScoreRepository,
10
+ )
11
+
12
+
13
+ class TestRepositoryAPIs(unittest.TestCase):
14
+ def test_model_repository_has_required_methods(self):
15
+ self.assertTrue(callable(getattr(DBModelRepository, "fetch_all", None)))
16
+ self.assertTrue(callable(getattr(DBModelRepository, "save", None)))
17
+
18
+ def test_input_repository_has_required_methods(self):
19
+ self.assertTrue(callable(getattr(DBInputRepository, "save", None)))
20
+ self.assertTrue(callable(getattr(DBInputRepository, "find", None)))
21
+
22
+ def test_prediction_repository_has_required_methods(self):
23
+ self.assertTrue(callable(getattr(DBPredictionRepository, "save", None)))
24
+ self.assertTrue(callable(getattr(DBPredictionRepository, "save_all", None)))
25
+ self.assertTrue(callable(getattr(DBPredictionRepository, "find", None)))
26
+
27
+ def test_score_repository_has_required_methods(self):
28
+ self.assertTrue(callable(getattr(DBScoreRepository, "save", None)))
29
+ self.assertTrue(callable(getattr(DBScoreRepository, "find", None)))
30
+
31
+ def test_prediction_repository_has_query_scores_method(self):
32
+ self.assertTrue(callable(getattr(DBPredictionRepository, "query_scores", None)))
33
+
34
+ def test_leaderboard_repository_has_required_methods(self):
35
+ self.assertTrue(callable(getattr(DBLeaderboardRepository, "save", None)))
36
+ self.assertTrue(callable(getattr(DBLeaderboardRepository, "get_latest", None)))
37
+
38
+ def test_input_repository_save_updates_scope_and_resolvable_at(self):
39
+ """Regression: DBInputRepository.save() must update scope_jsonb and
40
+ resolvable_at on existing records, not just status/actuals/meta."""
41
+ source = inspect.getsource(DBInputRepository.save)
42
+ self.assertIn("scope_jsonb", source,
43
+ "save() must update scope_jsonb on existing records")
44
+ self.assertIn("resolvable_at", source,
45
+ "save() must update resolvable_at on existing records")
46
+
47
+ def test_prediction_repository_save_updates_resolvable_at(self):
48
+ """Regression: DBPredictionRepository.save() must update resolvable_at
49
+ on existing records."""
50
+ source = inspect.getsource(DBPredictionRepository.save)
51
+ self.assertIn("existing.resolvable_at", source,
52
+ "save() must update resolvable_at on existing records")
53
+
54
+ def test_all_repository_save_methods_update_all_constructor_fields(self):
55
+ """Regression: every save() method must update all fields it constructs,
56
+ except the primary key (id). Catches field omission bugs like the
57
+ scope_jsonb/resolvable_at issue."""
58
+ import re
59
+ from coordinator_node.db.repositories import (
60
+ DBCheckpointRepository, DBSnapshotRepository,
61
+ )
62
+ repos = [
63
+ ("DBModelRepository", DBModelRepository),
64
+ ("DBInputRepository", DBInputRepository),
65
+ ("DBPredictionRepository", DBPredictionRepository),
66
+ ("DBScoreRepository", DBScoreRepository),
67
+ ("DBSnapshotRepository", DBSnapshotRepository),
68
+ ("DBCheckpointRepository", DBCheckpointRepository),
69
+ ]
70
+ for name, cls in repos:
71
+ source = inspect.getsource(cls.save)
72
+ # Fields assigned via row.X in constructor
73
+ row_fields = set(re.findall(r'(\w+)=row\.(\w+)', source))
74
+ constructor_fields = {f[0] for f in row_fields if f[0] != 'id'}
75
+ # Fields updated via existing.X = row.X
76
+ existing_fields = set(re.findall(r'existing\.(\w+)\s*=\s*row\.(\w+)', source))
77
+ update_fields = {f[0] for f in existing_fields}
78
+
79
+ missing = constructor_fields - update_fields
80
+ self.assertEqual(missing, set(),
81
+ f"{name}.save() creates fields {sorted(missing)} "
82
+ f"but doesn't update them on existing records")
83
+
84
+
85
+ if __name__ == "__main__":
86
+ unittest.main()
@@ -304,7 +304,7 @@ wheels = [
304
304
 
305
305
  [[package]]
306
306
  name = "coordinator-node"
307
- version = "0.1.1"
307
+ version = "0.1.2"
308
308
  source = { editable = "." }
309
309
  dependencies = [
310
310
  { name = "fastapi" },
@@ -1,39 +0,0 @@
1
- import unittest
2
-
3
- from coordinator_node.db.repositories import (
4
- DBInputRepository,
5
- DBLeaderboardRepository,
6
- DBModelRepository,
7
- DBPredictionRepository,
8
- DBScoreRepository,
9
- )
10
-
11
-
12
- class TestRepositoryAPIs(unittest.TestCase):
13
- def test_model_repository_has_required_methods(self):
14
- self.assertTrue(callable(getattr(DBModelRepository, "fetch_all", None)))
15
- self.assertTrue(callable(getattr(DBModelRepository, "save", None)))
16
-
17
- def test_input_repository_has_required_methods(self):
18
- self.assertTrue(callable(getattr(DBInputRepository, "save", None)))
19
- self.assertTrue(callable(getattr(DBInputRepository, "find", None)))
20
-
21
- def test_prediction_repository_has_required_methods(self):
22
- self.assertTrue(callable(getattr(DBPredictionRepository, "save", None)))
23
- self.assertTrue(callable(getattr(DBPredictionRepository, "save_all", None)))
24
- self.assertTrue(callable(getattr(DBPredictionRepository, "find", None)))
25
-
26
- def test_score_repository_has_required_methods(self):
27
- self.assertTrue(callable(getattr(DBScoreRepository, "save", None)))
28
- self.assertTrue(callable(getattr(DBScoreRepository, "find", None)))
29
-
30
- def test_prediction_repository_has_query_scores_method(self):
31
- self.assertTrue(callable(getattr(DBPredictionRepository, "query_scores", None)))
32
-
33
- def test_leaderboard_repository_has_required_methods(self):
34
- self.assertTrue(callable(getattr(DBLeaderboardRepository, "save", None)))
35
- self.assertTrue(callable(getattr(DBLeaderboardRepository, "get_latest", None)))
36
-
37
-
38
- if __name__ == "__main__":
39
- unittest.main()