wandler67 commited on
Commit
a84c961
·
1 Parent(s): 62cba3a

Repair Explorer for Unified-3000 (E1.1/E2)

Browse files

Dataset revision: 1d01f3649ace0301ac3bbe9ee875eea660347a29
Local cumulative diff SHA-256: 35ffcb4f6551646fa5af2f70592c0c65045424b048d30b59d59de3be3fd6a271
Space payload diff SHA-256: 148c00a691ab9208cd70949e7bdaccfb3a70de92e5ccf8bfa3e7ec78935b80ba

README.md CHANGED
@@ -9,24 +9,54 @@ license: cc-by-nc-4.0
9
 
10
  # H²EPR-Bench Explorer
11
 
12
- H²EPR-Bench Explorer is the interactive browsing layer for `AgenticFinLab/H2EPR-Bench`. It is separate from the canonical dataset repository: the dataset repo remains the release package, while this Docker Space runs a Streamlit app for search, event detail, stage inspection, public FinalCascade JSON browsing, and Gantt-style timelines.
 
 
 
 
13
 
14
- **Release boundary:** public records are intended for browsing, reuse, and presentation. Official scoring uses the [manual-gated Gold companion](https://huggingface.co/datasets/AgenticFinLab/H2EPR-Bench-Gold). Public FinalCascade and Gantt views are supplementary inspection assets, not official scoring references.
 
15
 
16
- ## Data Sources
 
 
 
17
 
18
- | Source | Role |
 
 
 
 
19
  |---|---|
20
- | `AgenticFinLab/H2EPR-Bench` | Public event catalog, stage table, public sanitized FinalCascade, and Gantt artifact paths. |
21
- | `AgenticFinLab/H2EPR-Bench-Gold` | Manual-gated Gold companion for official scoring references. Linked for users who need scoring access; not loaded by this app. |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
- ## Local Development
24
 
25
- The app can run from a local staged dataset package before upload:
 
26
 
27
  ```bash
28
- export H2EPR_EXPLORER_LOCAL_DATASET_DIR=../../build/hf_dataset_repo_staging/eventmycelium-v1_1000-public
29
  streamlit run app.py
30
  ```
31
 
32
- Without `H2EPR_EXPLORER_LOCAL_DATASET_DIR`, the app downloads public files from the Hugging Face dataset repo.
 
 
 
9
 
10
  # H²EPR-Bench Explorer
11
 
12
+ H²EPR-Bench Explorer is the interactive browsing layer for the Unified-3000
13
+ release of `AgenticFinLab/H2EPR-Bench`. The existing Docker Space runs a
14
+ Streamlit interface for searching all 3,000 catalog events, inspecting current
15
+ Draft EPG summaries and stage timelines, and previewing or downloading the
16
+ 2,876 public per-event Draft EPG files.
17
 
18
+ The other 124 catalog events remain fully browsable and show a neutral local
19
+ empty state because no public Draft EPG is available for them in this release.
20
 
21
+ **Release boundary:** public Draft EPGs are sanitized FinMycelium construction
22
+ artifacts. Official benchmark scoring uses expert-adjudicated reference EPGs in
23
+ the [manual-gated companion](https://huggingface.co/datasets/AgenticFinLab/H2EPR-Bench-Gold).
24
+ The Explorer does not load reference EPGs or frozen evidence packages.
25
 
26
+ ## Public data contract
27
+
28
+ The Explorer loads five Parquet tables from one immutable dataset revision:
29
+
30
+ | Table | Role |
31
  |---|---|
32
+ | `event_catalog.parquet` | Stable event discovery metadata |
33
+ | `event_instances.parquet` | Public access-state fields |
34
+ | `finalcascade_summary.parquet` | Draft graph counts and event-level temporal summary |
35
+ | `draft_availability.parquet` | Per-event Draft EPG availability and integrity metadata |
36
+ | `event_stages.parquet` | Ordered stage rows for available drafts |
37
+
38
+ Selected Draft EPGs are loaded directly from
39
+ `draft_events/<H2EPR-ID>/draft_epg.json`. The path is derived only after the
40
+ canonical event ID and availability state have been validated. The availability
41
+ table's `draft_asset` value identifies the consolidated release asset and is
42
+ not used as the selected-event path.
43
+
44
+ Default dataset revision:
45
+
46
+ ```text
47
+ 1d01f3649ace0301ac3bbe9ee875eea660347a29
48
+ ```
49
 
50
+ ## Local development
51
 
52
+ The app can use the frozen local Unified-3000 release candidate without any
53
+ network access:
54
 
55
  ```bash
56
+ export H2EPR_EXPLORER_LOCAL_DATASET_DIR=../../build/hf_unified3000_inplace_upgrade_rc_v3_dataset_card/H2EPR-Bench
57
  streamlit run app.py
58
  ```
59
 
60
+ Without `H2EPR_EXPLORER_LOCAL_DATASET_DIR`, all public table and selected-event
61
+ downloads use the pinned revision above. The application exposes no runtime
62
+ revision override, so one process cannot mix dataset revisions.
app.py CHANGED
@@ -4,6 +4,9 @@ import json
4
  from pathlib import Path
5
  import sys
6
 
 
 
 
7
  APP_ROOT = Path(__file__).resolve().parent
8
  sys.path.insert(0, str(APP_ROOT / "src"))
9
 
@@ -11,11 +14,22 @@ import streamlit as st
11
 
12
  from h2epr_explorer.constants import (
13
  CATALOG_COLUMNS,
 
14
  GOLD_COMPANION_REPO,
 
15
  PUBLIC_DATASET_REPO,
 
16
  RELEASE_BOUNDARY_NOTICE,
17
  )
18
- from h2epr_explorer.data_loader import load_catalog, load_event_graph, load_finalcascade_summary, load_stages
 
 
 
 
 
 
 
 
19
  from h2epr_explorer.filters import event_description, event_display_label, event_name, filter_catalog
20
  from h2epr_explorer.navigation import (
21
  build_event_links,
@@ -26,25 +40,37 @@ from h2epr_explorer.navigation import (
26
  from h2epr_explorer.render_gantt import build_timeline_figure
27
 
28
 
29
- def _as_records(frame):
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  return frame.to_dict(orient="records")
31
 
32
 
33
- def _select_columns(frame, columns):
34
- present = [column for column in columns if column in frame.columns]
35
- return frame[present] if present else frame
36
 
37
 
38
- def _sort_stage_frame(frame):
39
- sort_columns = [column for column in ("stage_index", "stage_order", "stage_id") if column in frame.columns]
40
- return frame.sort_values(sort_columns) if sort_columns else frame
 
41
 
42
 
43
- def _safe_int(value, default=0):
44
- try:
45
- return int(value)
46
- except (TypeError, ValueError):
47
- return default
48
 
49
 
50
  st.set_page_config(page_title="H2EPR-Bench Explorer", layout="wide")
@@ -83,34 +109,71 @@ div[data-testid="stMetric"] {
83
  st.markdown('<div class="h2epr-kicker">H²EPR-Bench · public release explorer</div>', unsafe_allow_html=True)
84
  st.markdown('<div class="h2epr-title">Event-process graph browser</div>', unsafe_allow_html=True)
85
  st.markdown(
86
- '<div class="h2epr-subtitle">Browse public event metadata, stage rows, FinalCascade summaries, and Gantt-style timelines for the H²EPR-Bench release.</div>',
87
  unsafe_allow_html=True,
88
  )
89
  st.info(RELEASE_BOUNDARY_NOTICE)
90
 
91
- catalog = load_catalog()
92
- stages = load_stages()
93
- summary = load_finalcascade_summary()
 
 
 
 
 
94
 
 
95
  catalog_rows = _as_records(catalog)
 
96
 
97
  with st.sidebar:
98
  st.header("Filter events")
99
- query = st.text_input("Search", placeholder="event name, ID, category, keyword")
100
- domains = st.multiselect("Domain", sorted(catalog["domain"].dropna().unique().tolist()))
101
- categories = st.multiselect("Category", sorted(catalog["event_category"].dropna().unique().tolist()))
102
- min_source_count = st.slider("Minimum sources", 0, int(catalog["source_count"].max()), 0)
103
- min_stage_count = st.slider("Minimum stages", 0, int(catalog["stage_count"].max()), 0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  st.divider()
105
- st.link_button("Dataset repository", f"https://huggingface.co/datasets/{PUBLIC_DATASET_REPO}", use_container_width=True)
106
- st.link_button("Request Gold access", f"https://huggingface.co/datasets/{GOLD_COMPANION_REPO}", use_container_width=True)
 
 
 
 
 
 
 
 
107
 
108
  filtered_rows = filter_catalog(
109
  catalog_rows,
110
  query=query,
111
  domains=domains,
112
  categories=categories,
113
- min_source_count=min_source_count,
114
  min_stage_count=min_stage_count,
115
  )
116
 
@@ -118,113 +181,131 @@ if not filtered_rows:
118
  st.warning("No event matches the current filters.")
119
  st.stop()
120
 
121
- event_labels = {row["event_id"]: event_display_label(row) for row in catalog_rows}
122
- requested_event_id = query_param_event_id(st.query_params)
123
- selected_index = resolve_selected_event_index(filtered_rows, requested_event_id)
 
124
 
 
 
125
  selected_event = st.selectbox(
126
  "Selected event",
127
  [row["event_id"] for row in filtered_rows],
128
  index=selected_index,
129
  format_func=lambda event_id: event_labels.get(event_id, event_id),
130
  )
131
- try:
132
  st.query_params["event_id"] = selected_event
133
- except Exception:
134
- pass
135
 
136
- event_row = catalog[catalog["event_id"] == selected_event].iloc[0]
137
  event_record = event_row.to_dict()
138
- event_stages = _sort_stage_frame(stages[stages["event_id"] == selected_event])
139
- summary_row = summary[summary["event_id"] == selected_event]
140
- event_links = build_event_links(selected_event, str(event_record.get("gantt_html_path") or ""))
 
141
 
142
  st.caption(filter_summary_text(len(filtered_rows), len(catalog_rows)))
143
 
144
- tabs = st.tabs(["Catalog", "Event detail", "Timeline", "Stages", "FinalCascade JSON", "Access and boundary"])
 
 
145
 
146
  with tabs[0]:
147
  st.subheader("Event catalog")
148
- st.dataframe(_select_columns(catalog[catalog["event_id"].isin([row["event_id"] for row in filtered_rows])], CATALOG_COLUMNS), use_container_width=True, height=520)
 
 
149
 
150
  with tabs[1]:
151
  st.subheader(event_name(event_record))
152
  st.write(event_description(event_record))
153
- c1, c2, c3, c4, c5 = st.columns(5)
154
- c1.metric("Sources", _safe_int(event_row.get("source_count", 0)))
155
- c2.metric("Stages", _safe_int(event_row.get("stage_count", 0)))
156
- c3.metric("Episodes", _safe_int(event_row.get("episode_count", 0)))
157
- c4.metric("Participants", _safe_int(event_row.get("participant_count", 0)))
158
- c5.metric("Relations", _safe_int(event_row.get("relation_count", 0)))
 
159
 
160
  st.markdown("#### Event profile")
161
- profile_columns = [
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  "event_id",
163
- "display_name",
164
- "domain",
165
- "event_category",
166
- "event_scope_label",
167
- "keywords",
 
168
  "event_boundary_time_status",
169
- "temporal_anchor_summary",
170
- "gold_reference_access_level",
171
- "finalcascade_access_level",
172
  ]
173
- st.dataframe(_select_columns(catalog[catalog["event_id"] == selected_event], profile_columns), use_container_width=True)
174
-
175
- link_cols = st.columns(4)
176
- link_cols[0].link_button("Open dataset", event_links["public_dataset"], use_container_width=True)
177
- link_cols[1].link_button("Gold access", event_links["gold_request"], use_container_width=True)
178
- link_cols[2].link_button("FinalCascade file", event_links["finalcascade_jsonl"], use_container_width=True)
179
- if "gantt_html" in event_links:
180
- link_cols[3].link_button("Gantt artifact", event_links["gantt_html"], use_container_width=True)
181
-
182
- if not summary_row.empty:
183
- st.markdown("#### Public FinalCascade summary")
184
- summary_columns = [
185
- "event_id",
186
- "stage_count",
187
- "episode_count",
188
- "participant_count",
189
- "transaction_count",
190
- "relation_count",
191
- "event_boundary_time_status",
192
- "known_action_time_anchor_count",
193
- "not_gold_warning",
194
- ]
195
- st.dataframe(_select_columns(summary_row, summary_columns), use_container_width=True)
196
 
197
  with tabs[2]:
198
- figure = build_timeline_figure(_as_records(event_stages), selected_event)
199
  if figure is None:
200
- st.warning("No public stage rows are available for this event.")
201
  else:
202
- st.plotly_chart(figure, use_container_width=True)
203
- if "gantt_html_path" in event_row and event_row.get("gantt_html_path"):
204
- st.markdown(f"Gantt HTML artifact path: `{event_row.get('gantt_html_path')}`")
205
 
206
  with tabs[3]:
207
- st.dataframe(event_stages, use_container_width=True, height=520)
 
 
 
208
 
209
  with tabs[4]:
210
- graph = load_event_graph(selected_event)
211
- st.download_button(
212
- "Download selected public FinalCascade JSON",
213
- data=json.dumps(graph, ensure_ascii=False, indent=2),
214
- file_name=f"{selected_event}_finalcascade_public.json",
215
- mime="application/json",
216
- )
217
- st.json(graph, expanded=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
 
219
  with tabs[5]:
220
  st.markdown(
221
  f"""
222
  ### Release boundary
223
 
224
- - Public dataset repo: [`{PUBLIC_DATASET_REPO}`](https://huggingface.co/datasets/{PUBLIC_DATASET_REPO})
225
- - Manual-gated Gold companion: [`{GOLD_COMPANION_REPO}`](https://huggingface.co/datasets/{GOLD_COMPANION_REPO})
226
- - This Explorer loads public event metadata, public stages, public sanitized FinalCascade records, and public visualization paths.
227
- - It does not load gated Gold references.
228
- - Public FinalCascade and Gantt views are supplementary inspection assets, not official scoring references.
229
  """
230
  )
 
4
  from pathlib import Path
5
  import sys
6
 
7
+ import pandas as pd
8
+
9
+
10
  APP_ROOT = Path(__file__).resolve().parent
11
  sys.path.insert(0, str(APP_ROOT / "src"))
12
 
 
14
 
15
  from h2epr_explorer.constants import (
16
  CATALOG_COLUMNS,
17
+ DRAFT_UNAVAILABLE_MESSAGE,
18
  GOLD_COMPANION_REPO,
19
+ PROFILE_COLUMNS,
20
  PUBLIC_DATASET_REPO,
21
+ PUBLIC_DATASET_REVISION,
22
  RELEASE_BOUNDARY_NOTICE,
23
  )
24
+ from h2epr_explorer.data_loader import (
25
+ DatasetTransportError,
26
+ DraftAssetMissing,
27
+ DraftIntegrityError,
28
+ DraftUnavailable,
29
+ ReleaseContractError,
30
+ load_event_graph,
31
+ load_release,
32
+ )
33
  from h2epr_explorer.filters import event_description, event_display_label, event_name, filter_catalog
34
  from h2epr_explorer.navigation import (
35
  build_event_links,
 
40
  from h2epr_explorer.render_gantt import build_timeline_figure
41
 
42
 
43
+ FILTER_SEARCH_KEY = "h2epr_filter_search"
44
+ FILTER_DOMAIN_KEY = "h2epr_filter_domains"
45
+ FILTER_CATEGORY_KEY = "h2epr_filter_categories"
46
+ FILTER_MIN_STAGE_KEY = "h2epr_filter_min_stage_count"
47
+ FILTER_RESET_KEY = "h2epr_filter_reset"
48
+ FILTER_DEFAULTS = {
49
+ FILTER_SEARCH_KEY: "",
50
+ FILTER_DOMAIN_KEY: (),
51
+ FILTER_CATEGORY_KEY: (),
52
+ FILTER_MIN_STAGE_KEY: 0,
53
+ }
54
+
55
+
56
+ def _as_records(frame: pd.DataFrame) -> list[dict]:
57
  return frame.to_dict(orient="records")
58
 
59
 
60
+ def _select_columns(frame: pd.DataFrame, columns: tuple[str, ...] | list[str]) -> pd.DataFrame:
61
+ return frame.loc[:, list(columns)]
 
62
 
63
 
64
+ def _metric_value(value) -> str:
65
+ if value is None or bool(pd.isna(value)):
66
+ return "—"
67
+ return f"{int(value):,}"
68
 
69
 
70
+ def _reset_filters(state=None) -> None:
71
+ target = st.session_state if state is None else state
72
+ for key, value in FILTER_DEFAULTS.items():
73
+ target[key] = list(value) if isinstance(value, tuple) else value
 
74
 
75
 
76
  st.set_page_config(page_title="H2EPR-Bench Explorer", layout="wide")
 
109
  st.markdown('<div class="h2epr-kicker">H²EPR-Bench · public release explorer</div>', unsafe_allow_html=True)
110
  st.markdown('<div class="h2epr-title">Event-process graph browser</div>', unsafe_allow_html=True)
111
  st.markdown(
112
+ '<div class="h2epr-subtitle">Browse 3,000 public event records, Draft EPG summaries, and stage timelines from one pinned Unified-3000 release.</div>',
113
  unsafe_allow_html=True,
114
  )
115
  st.info(RELEASE_BOUNDARY_NOTICE)
116
 
117
+ try:
118
+ release = load_release()
119
+ except DatasetTransportError as exc:
120
+ st.error(f"The pinned public dataset could not be retrieved. Please retry. Details: {exc}")
121
+ st.stop()
122
+ except ReleaseContractError as exc:
123
+ st.error(f"The public release failed Explorer contract validation: {exc}")
124
+ st.stop()
125
 
126
+ catalog = release.events
127
  catalog_rows = _as_records(catalog)
128
+ query_resolution = query_param_event_id(st.query_params)
129
 
130
  with st.sidebar:
131
  st.header("Filter events")
132
+ query = st.text_input(
133
+ "Search",
134
+ placeholder="event name, ID, category, keyword",
135
+ key=FILTER_SEARCH_KEY,
136
+ )
137
+ domains = st.multiselect(
138
+ "Domain",
139
+ sorted(catalog["domain"].dropna().unique().tolist()),
140
+ key=FILTER_DOMAIN_KEY,
141
+ )
142
+ categories = st.multiselect(
143
+ "Category",
144
+ sorted(catalog["category"].dropna().unique().tolist()),
145
+ key=FILTER_CATEGORY_KEY,
146
+ )
147
+ max_stage_count = int(catalog["stage_count"].dropna().max())
148
+ min_stage_count = st.slider(
149
+ "Minimum stages",
150
+ 0,
151
+ max_stage_count,
152
+ key=FILTER_MIN_STAGE_KEY,
153
+ )
154
+ st.button(
155
+ "Reset filters",
156
+ key=FILTER_RESET_KEY,
157
+ on_click=_reset_filters,
158
+ width="stretch",
159
+ )
160
  st.divider()
161
+ st.link_button(
162
+ "Dataset repository",
163
+ f"https://huggingface.co/datasets/{PUBLIC_DATASET_REPO}/tree/{PUBLIC_DATASET_REVISION}",
164
+ width="stretch",
165
+ )
166
+ st.link_button(
167
+ "Reference EPG access",
168
+ f"https://huggingface.co/datasets/{GOLD_COMPANION_REPO}",
169
+ width="stretch",
170
+ )
171
 
172
  filtered_rows = filter_catalog(
173
  catalog_rows,
174
  query=query,
175
  domains=domains,
176
  categories=categories,
 
177
  min_stage_count=min_stage_count,
178
  )
179
 
 
181
  st.warning("No event matches the current filters.")
182
  st.stop()
183
 
184
+ if query_resolution.used_legacy_mapping:
185
+ st.caption(f"Historical event link resolved to canonical ID {query_resolution.canonical_id}.")
186
+ elif query_resolution.unresolved:
187
+ st.warning("The requested event link is malformed or outside this release; showing a valid event instead.")
188
 
189
+ event_labels = {row["event_id"]: event_display_label(row) for row in catalog_rows}
190
+ selected_index = resolve_selected_event_index(filtered_rows, query_resolution.canonical_id)
191
  selected_event = st.selectbox(
192
  "Selected event",
193
  [row["event_id"] for row in filtered_rows],
194
  index=selected_index,
195
  format_func=lambda event_id: event_labels.get(event_id, event_id),
196
  )
197
+ if st.query_params.get("event_id") != selected_event:
198
  st.query_params["event_id"] = selected_event
 
 
199
 
200
+ event_row = release.event_row(selected_event)
201
  event_record = event_row.to_dict()
202
+ event_stages = release.stage_frame(selected_event)
203
+ stage_records = _as_records(event_stages)
204
+ draft_available = event_record["draft_status"] == "draft_available"
205
+ event_links = build_event_links(selected_event, draft_available=draft_available)
206
 
207
  st.caption(filter_summary_text(len(filtered_rows), len(catalog_rows)))
208
 
209
+ tabs = st.tabs(
210
+ ["Catalog", "Event detail", "Timeline", "Stages", "Draft EPG JSON", "Access and boundary"]
211
+ )
212
 
213
  with tabs[0]:
214
  st.subheader("Event catalog")
215
+ filtered_ids = [row["event_id"] for row in filtered_rows]
216
+ table = catalog.loc[catalog["event_id"].isin(filtered_ids)]
217
+ st.dataframe(_select_columns(table, CATALOG_COLUMNS), width="stretch", height=520)
218
 
219
  with tabs[1]:
220
  st.subheader(event_name(event_record))
221
  st.write(event_description(event_record))
222
+ metric_columns = st.columns(5)
223
+ for column, label, widget in zip(
224
+ ("stage_count", "episode_count", "participant_count", "action_count", "relation_count"),
225
+ ("Stages", "Episodes", "Participants", "Actions", "Relations"),
226
+ metric_columns,
227
+ ):
228
+ widget.metric(label, _metric_value(event_record.get(column)))
229
 
230
  st.markdown("#### Event profile")
231
+ selected_frame = catalog.loc[catalog["event_id"].eq(selected_event)]
232
+ st.dataframe(
233
+ _select_columns(selected_frame, PROFILE_COLUMNS),
234
+ width="stretch",
235
+ )
236
+
237
+ link_columns = st.columns(3 if draft_available else 2)
238
+ link_columns[0].link_button("Open dataset", event_links["public_dataset"], width="stretch")
239
+ link_columns[1].link_button(
240
+ "Reference EPG access", event_links["reference_access"], width="stretch"
241
+ )
242
+ if draft_available:
243
+ link_columns[2].link_button(
244
+ "Open Draft EPG file", event_links["draft_epg"], width="stretch"
245
+ )
246
+
247
+ st.markdown("#### Draft EPG summary")
248
+ summary_columns = [
249
  "event_id",
250
+ "stage_count",
251
+ "episode_count",
252
+ "participant_count",
253
+ "action_count",
254
+ "transaction_count",
255
+ "relation_count",
256
  "event_boundary_time_status",
257
+ "known_action_time_anchor_count",
 
 
258
  ]
259
+ st.dataframe(selected_frame.loc[:, summary_columns], width="stretch")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
 
261
  with tabs[2]:
262
+ figure = build_timeline_figure(stage_records, selected_event)
263
  if figure is None:
264
+ st.info(DRAFT_UNAVAILABLE_MESSAGE)
265
  else:
266
+ st.plotly_chart(figure, width="stretch")
 
 
267
 
268
  with tabs[3]:
269
+ if event_stages.empty:
270
+ st.info(DRAFT_UNAVAILABLE_MESSAGE)
271
+ else:
272
+ st.dataframe(event_stages, width="stretch", height=520)
273
 
274
  with tabs[4]:
275
+ if not draft_available:
276
+ st.info(DRAFT_UNAVAILABLE_MESSAGE)
277
+ else:
278
+ try:
279
+ graph_result = load_event_graph(selected_event, release=release)
280
+ except DatasetTransportError as exc:
281
+ st.error(f"The selected Draft EPG could not be retrieved. Please retry. Details: {exc}")
282
+ except DraftAssetMissing as exc:
283
+ st.error(f"The selected event is marked available but its Draft EPG file is missing: {exc}")
284
+ except DraftIntegrityError as exc:
285
+ st.error(f"The selected Draft EPG failed integrity validation and was not shown: {exc}")
286
+ else:
287
+ if isinstance(graph_result, DraftUnavailable):
288
+ st.info(graph_result.message)
289
+ else:
290
+ graph_json = json.dumps(graph_result, ensure_ascii=False, indent=2)
291
+ st.download_button(
292
+ "Download selected Draft EPG JSON",
293
+ data=graph_json,
294
+ file_name=f"{selected_event}_draft_epg.json",
295
+ mime="application/json",
296
+ )
297
+ st.link_button("Open exact public file", event_links["draft_epg"])
298
+ st.json(graph_result, expanded=False)
299
 
300
  with tabs[5]:
301
  st.markdown(
302
  f"""
303
  ### Release boundary
304
 
305
+ - Public dataset: [`{PUBLIC_DATASET_REPO}`](https://huggingface.co/datasets/{PUBLIC_DATASET_REPO}/tree/{PUBLIC_DATASET_REVISION}) at `{PUBLIC_DATASET_REVISION}`.
306
+ - Manual-gated reference EPG companion: [`{GOLD_COMPANION_REPO}`](https://huggingface.co/datasets/{GOLD_COMPANION_REPO}).
307
+ - Public Draft EPGs are sanitized FinMycelium construction artifacts, not scoring references.
308
+ - Official benchmark scoring uses expert-adjudicated reference EPGs in the gated companion.
309
+ - This Explorer loads neither reference EPGs nor frozen evidence packages.
310
  """
311
  )
src/h2epr_explorer/constants.py CHANGED
@@ -1,30 +1,172 @@
 
 
1
  PUBLIC_DATASET_REPO = "AgenticFinLab/H2EPR-Bench"
2
  GOLD_COMPANION_REPO = "AgenticFinLab/H2EPR-Bench-Gold"
 
 
 
3
  LOCAL_DATASET_ENV = "H2EPR_EXPLORER_LOCAL_DATASET_DIR"
4
 
 
 
 
 
5
  CATALOG_PARQUET = "data/viewer_mirrors/event_catalog.parquet"
6
- CATALOG_JSONL = "data/event_catalog.jsonl"
7
- STAGES_PARQUET = "data/viewer_mirrors/event_stages.parquet"
8
- STAGES_JSONL = "data/event_stages.jsonl"
9
- FINALCASCADE_JSONL = "data/finmycelium_finalcascade_public.jsonl"
10
  FINALCASCADE_SUMMARY_PARQUET = "data/viewer_mirrors/finalcascade_summary.parquet"
 
 
 
11
 
12
- RELEASE_BOUNDARY_NOTICE = (
13
- "Official scoring uses the manual-gated Gold companion repository. "
14
- "Public FinalCascade and Gantt views are supplementary inspection assets, "
15
- "not official scoring references."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  )
17
 
18
- CATALOG_COLUMNS = [
 
19
  "event_id",
 
20
  "display_name",
 
21
  "domain",
22
- "event_category",
23
- "event_descriptor_en",
24
  "keywords",
25
- "source_count",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  "stage_count",
 
 
 
 
 
 
 
27
  "event_boundary_time_status",
28
  "known_action_time_anchor_count",
29
- "gantt_html_path",
30
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
  PUBLIC_DATASET_REPO = "AgenticFinLab/H2EPR-Bench"
4
  GOLD_COMPANION_REPO = "AgenticFinLab/H2EPR-Bench-Gold"
5
+
6
+ DEFAULT_PUBLIC_DATASET_REVISION = "1d01f3649ace0301ac3bbe9ee875eea660347a29"
7
+ PUBLIC_DATASET_REVISION = DEFAULT_PUBLIC_DATASET_REVISION
8
  LOCAL_DATASET_ENV = "H2EPR_EXPLORER_LOCAL_DATASET_DIR"
9
 
10
+ EVENT_ID_PATTERN = r"^H2EPR-[0-9]{4}$"
11
+ EVENT_ID_MIN = 1
12
+ EVENT_ID_MAX = 3000
13
+
14
  CATALOG_PARQUET = "data/viewer_mirrors/event_catalog.parquet"
15
+ EVENT_INSTANCES_PARQUET = "data/viewer_mirrors/event_instances.parquet"
 
 
 
16
  FINALCASCADE_SUMMARY_PARQUET = "data/viewer_mirrors/finalcascade_summary.parquet"
17
+ DRAFT_AVAILABILITY_PARQUET = "data/viewer_mirrors/draft_availability.parquet"
18
+ STAGES_PARQUET = "data/viewer_mirrors/event_stages.parquet"
19
+ DRAFT_EPG_PATH_TEMPLATE = "draft_events/{event_id}/draft_epg.json"
20
 
21
+ EXPECTED_EVENT_COUNT = 3000
22
+ EXPECTED_AVAILABLE_DRAFT_COUNT = 2876
23
+ EXPECTED_UNAVAILABLE_DRAFT_COUNT = 124
24
+ EXPECTED_STAGE_ROW_COUNT = 8500
25
+
26
+ CATALOG_SCHEMA = (
27
+ "public_event_id",
28
+ "event_id",
29
+ "title",
30
+ "display_name",
31
+ "event_descriptor",
32
+ "domain",
33
+ "category",
34
+ "keywords",
35
+ "release_split",
36
+ "version",
37
+ "schema_version",
38
+ "draft_status",
39
+ "has_gold_reference",
40
  )
41
 
42
+ EVENT_INSTANCES_SCHEMA = (
43
+ "public_event_id",
44
  "event_id",
45
+ "title",
46
  "display_name",
47
+ "event_descriptor",
48
  "domain",
49
+ "category",
 
50
  "keywords",
51
+ "release_split",
52
+ "version",
53
+ "schema_version",
54
+ "has_finalcascade",
55
+ "draft_status",
56
+ "has_gold_reference",
57
+ "finalcascade_access_level",
58
+ "gold_reference_access_level",
59
+ "evidence_context_access_level",
60
+ )
61
+
62
+ FINALCASCADE_SUMMARY_SCHEMA = (
63
+ "public_event_id",
64
+ "event_id",
65
+ "title",
66
+ "domain",
67
+ "category",
68
+ "draft_status",
69
  "stage_count",
70
+ "episode_count",
71
+ "participant_count",
72
+ "action_count",
73
+ "transaction_count",
74
+ "relation_count",
75
+ "event_start_time",
76
+ "event_end_time",
77
  "event_boundary_time_status",
78
  "known_action_time_anchor_count",
79
+ "known_action_time_anchors",
80
+ "relative_order_available",
81
+ )
82
+
83
+ DRAFT_AVAILABILITY_SCHEMA = (
84
+ "public_event_id",
85
+ "draft_status",
86
+ "draft_source_kind",
87
+ "draft_schema",
88
+ "draft_asset",
89
+ "draft_record_index",
90
+ "draft_sha256",
91
+ "source_payload_sha256",
92
+ "has_reference_epg",
93
+ )
94
+
95
+ STAGES_SCHEMA = (
96
+ "public_event_id",
97
+ "event_id",
98
+ "stage_id",
99
+ "stage_index",
100
+ "stage_title",
101
+ "stage_start_time",
102
+ "stage_end_time",
103
+ "stage_boundary_time_status",
104
+ "episode_count",
105
+ "participant_count",
106
+ "action_count",
107
+ "transaction_count",
108
+ "relation_count",
109
+ "known_action_time_anchor_count",
110
+ "known_action_time_anchors",
111
+ "relative_order_available",
112
+ "release_split",
113
+ "version",
114
+ "schema_version",
115
+ )
116
+
117
+ GRAPH_COUNT_COLUMNS = (
118
+ "stage_count",
119
+ "episode_count",
120
+ "participant_count",
121
+ "action_count",
122
+ "transaction_count",
123
+ "relation_count",
124
+ )
125
+
126
+ ARROW_INT64_COLUMNS = frozenset(
127
+ {
128
+ *GRAPH_COUNT_COLUMNS,
129
+ "stage_index",
130
+ "known_action_time_anchor_count",
131
+ "draft_record_index",
132
+ }
133
+ )
134
+
135
+ ARROW_BOOL_COLUMNS = frozenset(
136
+ {
137
+ "has_gold_reference",
138
+ "has_finalcascade",
139
+ "relative_order_available",
140
+ "has_reference_epg",
141
+ }
142
+ )
143
+
144
+ CATALOG_COLUMNS = (
145
+ "event_id",
146
+ "display_name",
147
+ "domain",
148
+ "category",
149
+ "event_descriptor",
150
+ "keywords",
151
+ "stage_count",
152
+ )
153
+
154
+ PROFILE_COLUMNS = (
155
+ "event_id",
156
+ "display_name",
157
+ "domain",
158
+ "category",
159
+ "keywords",
160
+ "event_boundary_time_status",
161
+ "known_action_time_anchors",
162
+ "gold_reference_access_level",
163
+ "finalcascade_access_level",
164
+ )
165
+
166
+ DRAFT_UNAVAILABLE_MESSAGE = "No public Draft EPG is available for this event in this release."
167
+
168
+ RELEASE_BOUNDARY_NOTICE = (
169
+ "Public Draft EPGs are FinMycelium construction artifacts. Official benchmark "
170
+ "scoring uses reference EPGs in the manual-gated companion repository; this "
171
+ "Explorer loads neither reference EPGs nor frozen evidence packages."
172
+ )
src/h2epr_explorer/data_loader.py CHANGED
@@ -1,36 +1,100 @@
1
  from __future__ import annotations
2
 
 
 
 
3
  import json
4
  import os
5
- from functools import lru_cache
6
  from pathlib import Path
 
7
  from typing import Any
8
 
 
 
 
 
 
9
  from .constants import (
10
- CATALOG_JSONL,
 
11
  CATALOG_PARQUET,
12
- FINALCASCADE_JSONL,
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  FINALCASCADE_SUMMARY_PARQUET,
 
 
14
  LOCAL_DATASET_ENV,
15
  PUBLIC_DATASET_REPO,
16
- STAGES_JSONL,
17
  STAGES_PARQUET,
 
18
  )
19
 
20
 
21
- class SimpleTable:
22
- """Small fallback table used when pandas is unavailable in local checks."""
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
- def __init__(self, rows: list[dict[str, Any]]):
25
- self._rows = rows
26
 
27
- def __len__(self) -> int:
28
- return len(self._rows)
29
 
30
- def to_dict(self, orient: str = "records") -> list[dict[str, Any]]:
31
- if orient != "records":
32
- raise ValueError("SimpleTable only supports orient='records'")
33
- return list(self._rows)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
 
35
 
36
  def _as_local_root(local_dataset_dir: Path | str | None = None) -> Path | None:
@@ -40,87 +104,388 @@ def _as_local_root(local_dataset_dir: Path | str | None = None) -> Path | None:
40
  return Path(value).expanduser().resolve()
41
 
42
 
 
 
 
 
 
 
 
 
 
43
  def resolve_dataset_file(filename: str, local_dataset_dir: Path | str | None = None) -> Path:
44
  local_root = _as_local_root(local_dataset_dir)
45
  if local_root is not None:
46
  path = (local_root / filename).resolve()
47
  if not path.is_relative_to(local_root):
48
  raise ValueError(f"Refusing to read outside local dataset root: {filename}")
49
- if not path.exists():
50
  raise FileNotFoundError(path)
51
  return path
52
 
53
- from huggingface_hub import hf_hub_download
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
 
55
- return Path(
56
- hf_hub_download(
57
- repo_id=PUBLIC_DATASET_REPO,
58
- repo_type="dataset",
59
- filename=filename,
 
 
 
 
 
60
  )
 
61
  )
 
 
 
 
 
62
 
63
 
64
- def load_jsonl_rows(filename: str, local_dataset_dir: Path | str | None = None) -> list[dict[str, Any]]:
65
- path = resolve_dataset_file(filename, local_dataset_dir=local_dataset_dir)
66
- rows: list[dict[str, Any]] = []
67
- with path.open("r", encoding="utf-8") as handle:
68
- for line in handle:
69
- line = line.strip()
70
- if line:
71
- rows.append(json.loads(line))
72
- return rows
73
 
74
 
75
- def read_event_graph_from_jsonl(
76
- event_id: str, local_dataset_dir: Path | str | None = None
77
- ) -> dict[str, Any]:
78
- path = resolve_dataset_file(FINALCASCADE_JSONL, local_dataset_dir=local_dataset_dir)
79
- with path.open("r", encoding="utf-8") as handle:
80
- for line in handle:
81
- if not line.strip():
82
- continue
83
- row = json.loads(line)
84
- if row.get("event_id") == event_id:
85
- return row
86
- raise KeyError(f"Event graph not found: {event_id}")
87
-
88
-
89
- def _read_table(filename: str, fallback_jsonl: str, local_dataset_dir: Path | str | None = None):
90
- pd = None
91
- try:
92
- import pandas as pandas_module
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
 
94
- pd = pandas_module
95
- except ImportError:
96
- pass
97
  try:
98
- path = resolve_dataset_file(filename, local_dataset_dir=local_dataset_dir)
99
- if pd is None:
100
- raise ImportError("pandas is unavailable")
101
- return pd.read_parquet(path)
102
- except (FileNotFoundError, ImportError, ValueError):
103
- rows = load_jsonl_rows(fallback_jsonl, local_dataset_dir=local_dataset_dir)
104
- if pd is None:
105
- return SimpleTable(rows)
106
- return pd.DataFrame(rows)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
 
109
- @lru_cache(maxsize=1)
110
- def load_catalog():
111
- return _read_table(CATALOG_PARQUET, CATALOG_JSONL)
112
 
 
 
 
 
 
 
 
 
113
 
114
- @lru_cache(maxsize=1)
115
- def load_stages():
116
- return _read_table(STAGES_PARQUET, STAGES_JSONL)
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
 
119
- @lru_cache(maxsize=1)
120
- def load_finalcascade_summary():
121
- return _read_table(FINALCASCADE_SUMMARY_PARQUET, CATALOG_JSONL)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
 
123
 
124
- @lru_cache(maxsize=128)
125
- def load_event_graph(event_id: str) -> dict[str, Any]:
126
- return read_event_graph_from_jsonl(event_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
+ from dataclasses import dataclass
4
+ from functools import lru_cache
5
+ import hashlib
6
  import json
7
  import os
 
8
  from pathlib import Path
9
+ import re
10
  from typing import Any
11
 
12
+ from huggingface_hub import hf_hub_download
13
+ from huggingface_hub.errors import EntryNotFoundError
14
+ import pandas as pd
15
+ import pyarrow.parquet as parquet
16
+
17
  from .constants import (
18
+ ARROW_BOOL_COLUMNS,
19
+ ARROW_INT64_COLUMNS,
20
  CATALOG_PARQUET,
21
+ CATALOG_SCHEMA,
22
+ DRAFT_AVAILABILITY_PARQUET,
23
+ DRAFT_AVAILABILITY_SCHEMA,
24
+ DRAFT_EPG_PATH_TEMPLATE,
25
+ DRAFT_UNAVAILABLE_MESSAGE,
26
+ EVENT_ID_MAX,
27
+ EVENT_ID_MIN,
28
+ EVENT_ID_PATTERN,
29
+ EVENT_INSTANCES_PARQUET,
30
+ EVENT_INSTANCES_SCHEMA,
31
+ EXPECTED_AVAILABLE_DRAFT_COUNT,
32
+ EXPECTED_EVENT_COUNT,
33
+ EXPECTED_STAGE_ROW_COUNT,
34
+ EXPECTED_UNAVAILABLE_DRAFT_COUNT,
35
  FINALCASCADE_SUMMARY_PARQUET,
36
+ FINALCASCADE_SUMMARY_SCHEMA,
37
+ GRAPH_COUNT_COLUMNS,
38
  LOCAL_DATASET_ENV,
39
  PUBLIC_DATASET_REPO,
40
+ PUBLIC_DATASET_REVISION,
41
  STAGES_PARQUET,
42
+ STAGES_SCHEMA,
43
  )
44
 
45
 
46
+ class ExplorerDataError(RuntimeError):
47
+ """Base error for public Explorer data access."""
48
+
49
+
50
+ class DatasetTransportError(ExplorerDataError):
51
+ """A pinned public dataset asset could not be retrieved."""
52
+
53
+
54
+ class ReleaseContractError(ExplorerDataError):
55
+ """The loaded files do not form the expected Unified-3000 release."""
56
+
57
+
58
+ class DraftAssetMissing(ExplorerDataError):
59
+ """An available event is missing its required direct Draft EPG file."""
60
+
61
 
62
+ class DraftIntegrityError(ExplorerDataError):
63
+ """A direct Draft EPG failed identity or digest validation."""
64
 
 
 
65
 
66
+ class InvalidEventId(ValueError):
67
+ """An identifier is not a canonical Unified-3000 event ID."""
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class DraftUnavailable:
72
+ event_id: str
73
+ message: str = DRAFT_UNAVAILABLE_MESSAGE
74
+
75
+
76
+ @dataclass
77
+ class ExplorerRelease:
78
+ events: pd.DataFrame
79
+ stages: pd.DataFrame
80
+ stages_by_event: dict[str, pd.DataFrame]
81
+ revision: str = PUBLIC_DATASET_REVISION
82
+
83
+ def event_row(self, event_id: str) -> pd.Series:
84
+ validate_event_id(event_id)
85
+ rows = self.events.loc[self.events["event_id"].eq(event_id)]
86
+ if rows.empty:
87
+ raise InvalidEventId(f"Unknown Unified-3000 event ID: {event_id}")
88
+ if len(rows) != 1:
89
+ raise ReleaseContractError(f"Duplicate event identity in joined view: {event_id}")
90
+ return rows.iloc[0]
91
+
92
+ def stage_frame(self, event_id: str) -> pd.DataFrame:
93
+ self.event_row(event_id)
94
+ frame = self.stages_by_event.get(event_id)
95
+ if frame is None:
96
+ return self.stages.iloc[0:0].copy()
97
+ return frame.copy()
98
 
99
 
100
  def _as_local_root(local_dataset_dir: Path | str | None = None) -> Path | None:
 
104
  return Path(value).expanduser().resolve()
105
 
106
 
107
+ def validate_event_id(event_id: str) -> str:
108
+ if not isinstance(event_id, str) or not re.fullmatch(EVENT_ID_PATTERN, event_id):
109
+ raise InvalidEventId(f"Invalid Unified-3000 event ID: {event_id!r}")
110
+ number = int(event_id.rsplit("-", 1)[1])
111
+ if not EVENT_ID_MIN <= number <= EVENT_ID_MAX:
112
+ raise InvalidEventId(f"Unified-3000 event ID is out of range: {event_id}")
113
+ return event_id
114
+
115
+
116
  def resolve_dataset_file(filename: str, local_dataset_dir: Path | str | None = None) -> Path:
117
  local_root = _as_local_root(local_dataset_dir)
118
  if local_root is not None:
119
  path = (local_root / filename).resolve()
120
  if not path.is_relative_to(local_root):
121
  raise ValueError(f"Refusing to read outside local dataset root: {filename}")
122
+ if not path.is_file():
123
  raise FileNotFoundError(path)
124
  return path
125
 
126
+ try:
127
+ return Path(
128
+ hf_hub_download(
129
+ repo_id=PUBLIC_DATASET_REPO,
130
+ repo_type="dataset",
131
+ filename=filename,
132
+ revision=PUBLIC_DATASET_REVISION,
133
+ )
134
+ )
135
+ except EntryNotFoundError as exc:
136
+ raise FileNotFoundError(filename) from exc
137
+ except Exception as exc:
138
+ raise DatasetTransportError(
139
+ f"Unable to retrieve {filename!r} from pinned dataset revision "
140
+ f"{PUBLIC_DATASET_REVISION}."
141
+ ) from exc
142
+
143
+
144
+ def _read_required_parquet(
145
+ filename: str,
146
+ expected_schema: tuple[str, ...],
147
+ local_dataset_dir: Path | str | None = None,
148
+ ) -> pd.DataFrame:
149
+ try:
150
+ path = resolve_dataset_file(filename, local_dataset_dir=local_dataset_dir)
151
+ arrow_schema = parquet.read_schema(path)
152
+ frame = pd.read_parquet(path)
153
+ except DatasetTransportError:
154
+ raise
155
+ except Exception as exc:
156
+ raise ReleaseContractError(f"Unable to read required Parquet table: {filename}") from exc
157
 
158
+ observed = tuple(frame.columns)
159
+ observed_arrow = tuple((field.name, str(field.type)) for field in arrow_schema)
160
+ expected_arrow = tuple(
161
+ (
162
+ column,
163
+ "int64"
164
+ if column in ARROW_INT64_COLUMNS
165
+ else "bool"
166
+ if column in ARROW_BOOL_COLUMNS
167
+ else "string",
168
  )
169
+ for column in expected_schema
170
  )
171
+ if observed != expected_schema or observed_arrow != expected_arrow:
172
+ raise ReleaseContractError(
173
+ f"Schema mismatch for {filename}: expected {expected_arrow}, observed {observed_arrow}"
174
+ )
175
+ return frame
176
 
177
 
178
+ def _require_row_count(frame: pd.DataFrame, expected: int, table_name: str) -> None:
179
+ if len(frame) != expected:
180
+ raise ReleaseContractError(
181
+ f"{table_name} row count mismatch: expected {expected}, observed {len(frame)}"
182
+ )
 
 
 
 
183
 
184
 
185
+ def _require_unique(frame: pd.DataFrame, columns: list[str], table_name: str) -> None:
186
+ if frame.duplicated(columns).any():
187
+ raise ReleaseContractError(f"{table_name} has duplicate identity rows for {columns}")
188
+
189
+
190
+ def _require_equal_ids(frame: pd.DataFrame, table_name: str) -> None:
191
+ if not frame["event_id"].equals(frame["public_event_id"]):
192
+ raise ReleaseContractError(f"{table_name} event_id/public_event_id mismatch")
193
+
194
+
195
+ def _require_semantic_equality(
196
+ catalog: pd.DataFrame,
197
+ other: pd.DataFrame,
198
+ columns: tuple[str, ...],
199
+ other_name: str,
200
+ ) -> None:
201
+ left = catalog.set_index("event_id").sort_index()
202
+ right = other.set_index("event_id").sort_index()
203
+ for column in columns:
204
+ if not left[column].equals(right[column]):
205
+ raise ReleaseContractError(f"{column} disagrees between catalog and {other_name}")
206
+
207
+
208
+ def build_explorer_view(
209
+ catalog: pd.DataFrame,
210
+ instances: pd.DataFrame,
211
+ summary: pd.DataFrame,
212
+ availability: pd.DataFrame,
213
+ ) -> pd.DataFrame:
214
+ """Build the validated one-row-per-event Unified-3000 Explorer view."""
215
+
216
+ for frame, schema, name in (
217
+ (catalog, CATALOG_SCHEMA, "event_catalog"),
218
+ (instances, EVENT_INSTANCES_SCHEMA, "event_instances"),
219
+ (summary, FINALCASCADE_SUMMARY_SCHEMA, "finalcascade_summary"),
220
+ (availability, DRAFT_AVAILABILITY_SCHEMA, "draft_availability"),
221
+ ):
222
+ if tuple(frame.columns) != schema:
223
+ raise ReleaseContractError(f"Schema mismatch for {name}")
224
+ _require_row_count(frame, EXPECTED_EVENT_COUNT, name)
225
+
226
+ expected_ids = {f"H2EPR-{index:04d}" for index in range(EVENT_ID_MIN, EVENT_ID_MAX + 1)}
227
+ _require_unique(catalog, ["public_event_id", "event_id"], "event_catalog")
228
+ _require_unique(instances, ["public_event_id", "event_id"], "event_instances")
229
+ _require_unique(summary, ["public_event_id", "event_id"], "finalcascade_summary")
230
+ _require_unique(availability, ["public_event_id"], "draft_availability")
231
+ _require_equal_ids(catalog, "event_catalog")
232
+ _require_equal_ids(instances, "event_instances")
233
+ _require_equal_ids(summary, "finalcascade_summary")
234
+ if set(catalog["event_id"]) != expected_ids:
235
+ raise ReleaseContractError("event_catalog does not contain the exact Unified-3000 ID set")
236
+ if set(instances["event_id"]) != expected_ids or set(summary["event_id"]) != expected_ids:
237
+ raise ReleaseContractError("instance/summary event identity does not match the catalog")
238
+ if set(availability["public_event_id"]) != expected_ids:
239
+ raise ReleaseContractError("availability identity does not match the catalog")
240
+
241
+ _require_semantic_equality(
242
+ catalog, instances, ("domain", "category", "draft_status"), "event_instances"
243
+ )
244
+ _require_semantic_equality(
245
+ catalog, summary, ("domain", "category", "draft_status"), "finalcascade_summary"
246
+ )
247
+ catalog_status = catalog.set_index("public_event_id")["draft_status"].sort_index()
248
+ availability_status = availability.set_index("public_event_id")["draft_status"].sort_index()
249
+ if not catalog_status.equals(availability_status):
250
+ raise ReleaseContractError("draft_status disagrees between catalog and availability")
251
+
252
+ status_counts = availability["draft_status"].value_counts(dropna=False).to_dict()
253
+ expected_status_counts = {
254
+ "draft_available": EXPECTED_AVAILABLE_DRAFT_COUNT,
255
+ "draft_unavailable": EXPECTED_UNAVAILABLE_DRAFT_COUNT,
256
+ }
257
+ if status_counts != expected_status_counts:
258
+ raise ReleaseContractError(
259
+ f"Draft availability mismatch: expected {expected_status_counts}, observed {status_counts}"
260
+ )
261
+
262
+ access_fields = [
263
+ "public_event_id",
264
+ "event_id",
265
+ "has_finalcascade",
266
+ "finalcascade_access_level",
267
+ "gold_reference_access_level",
268
+ "evidence_context_access_level",
269
+ ]
270
+ summary_fields = [
271
+ "event_id",
272
+ *GRAPH_COUNT_COLUMNS,
273
+ "event_start_time",
274
+ "event_end_time",
275
+ "event_boundary_time_status",
276
+ "known_action_time_anchor_count",
277
+ "known_action_time_anchors",
278
+ "relative_order_available",
279
+ ]
280
+ availability_fields = [
281
+ "public_event_id",
282
+ "draft_source_kind",
283
+ "draft_schema",
284
+ "draft_asset",
285
+ "draft_record_index",
286
+ "draft_sha256",
287
+ "source_payload_sha256",
288
+ "has_reference_epg",
289
+ ]
290
 
 
 
 
291
  try:
292
+ joined = catalog.merge(
293
+ instances[access_fields],
294
+ on=["public_event_id", "event_id"],
295
+ how="left",
296
+ validate="one_to_one",
297
+ )
298
+ joined = joined.merge(
299
+ summary[summary_fields], on="event_id", how="left", validate="one_to_one"
300
+ )
301
+ joined = joined.merge(
302
+ availability[availability_fields],
303
+ on="public_event_id",
304
+ how="left",
305
+ validate="one_to_one",
306
+ )
307
+ except Exception as exc:
308
+ raise ReleaseContractError("Unified-3000 Explorer join multiplicity failure") from exc
309
+
310
+ _require_row_count(joined, EXPECTED_EVENT_COUNT, "joined Explorer view")
311
+ _require_unique(joined, ["public_event_id", "event_id"], "joined Explorer view")
312
+ if joined[access_fields[2:] + availability_fields[1:]].isna().all(axis=1).any():
313
+ raise ReleaseContractError("Joined Explorer view contains unmatched access/availability rows")
314
+
315
+ available = joined["draft_status"].eq("draft_available")
316
+ unavailable = joined["draft_status"].eq("draft_unavailable")
317
+ if joined.loc[available, list(GRAPH_COUNT_COLUMNS)].isna().any().any():
318
+ raise ReleaseContractError("Available drafts have null graph counts")
319
+ if not joined.loc[unavailable, list(GRAPH_COUNT_COLUMNS)].isna().all().all():
320
+ raise ReleaseContractError("Unavailable drafts contain observed graph counts")
321
+ if not joined.loc[available, "has_finalcascade"].eq(True).all():
322
+ raise ReleaseContractError("Available draft rows disagree with has_finalcascade")
323
+ if not joined.loc[unavailable, "has_finalcascade"].eq(False).all():
324
+ raise ReleaseContractError("Unavailable draft rows disagree with has_finalcascade")
325
+ return joined.sort_values("event_id", kind="stable").reset_index(drop=True)
326
+
327
+
328
+ def _validate_stages(stages: pd.DataFrame, events: pd.DataFrame) -> None:
329
+ if tuple(stages.columns) != STAGES_SCHEMA:
330
+ raise ReleaseContractError("Schema mismatch for event_stages")
331
+ _require_row_count(stages, EXPECTED_STAGE_ROW_COUNT, "event_stages")
332
+ _require_equal_ids(stages, "event_stages")
333
+ if stages[["event_id", "stage_id"]].duplicated().any():
334
+ raise ReleaseContractError("event_stages contains duplicate stage identity")
335
+ if stages[["event_id", "stage_index"]].duplicated().any():
336
+ raise ReleaseContractError("event_stages contains duplicate stage_index")
337
+ invalid_stage_index = stages["stage_index"].map(
338
+ lambda value: pd.isna(value) or int(value) != value or int(value) <= 0
339
+ )
340
+ if invalid_stage_index.any():
341
+ raise ReleaseContractError("event_stages stage_index values must be positive integers")
342
+ if not stages["event_id"].map(
343
+ lambda value: isinstance(value, str) and bool(re.fullmatch(EVENT_ID_PATTERN, value))
344
+ ).all():
345
+ raise ReleaseContractError("event_stages contains a malformed event ID")
346
+ available_ids = set(events.loc[events["draft_status"].eq("draft_available"), "event_id"])
347
+ if set(stages["event_id"]) != available_ids:
348
+ raise ReleaseContractError("Stage coverage does not equal the draft-available event set")
349
+
350
+ available_summary = events.loc[
351
+ events["draft_status"].eq("draft_available"),
352
+ ["event_id", *GRAPH_COUNT_COLUMNS],
353
+ ].set_index("event_id")
354
+ for event_id in sorted(available_ids):
355
+ event_stages = stages.loc[stages["event_id"].eq(event_id)]
356
+ expected_stage_count = available_summary.at[event_id, "stage_count"]
357
+ if (
358
+ pd.isna(expected_stage_count)
359
+ or int(expected_stage_count) != expected_stage_count
360
+ or int(expected_stage_count) <= 0
361
+ or len(event_stages) != int(expected_stage_count)
362
+ ):
363
+ raise ReleaseContractError(f"stage_count closure mismatch for {event_id}")
364
+
365
+ observed_indices = sorted(int(value) for value in event_stages["stage_index"])
366
+ expected_indices = list(range(1, int(expected_stage_count) + 1))
367
+ if observed_indices != expected_indices:
368
+ raise ReleaseContractError(f"Non-contiguous stage_index values for {event_id}")
369
+
370
+ for column in GRAPH_COUNT_COLUMNS[1:]:
371
+ expected_count = available_summary.at[event_id, column]
372
+ observed_count = event_stages[column].sum(min_count=1)
373
+ if pd.isna(expected_count) or pd.isna(observed_count) or observed_count != expected_count:
374
+ raise ReleaseContractError(f"{column} closure mismatch for {event_id}")
375
+
376
 
377
+ @lru_cache(maxsize=8)
378
+ def _load_release_cached(local_root_value: str | None) -> ExplorerRelease:
379
+ local_root = Path(local_root_value) if local_root_value else None
380
+ catalog = _read_required_parquet(CATALOG_PARQUET, CATALOG_SCHEMA, local_root)
381
+ instances = _read_required_parquet(
382
+ EVENT_INSTANCES_PARQUET, EVENT_INSTANCES_SCHEMA, local_root
383
+ )
384
+ summary = _read_required_parquet(
385
+ FINALCASCADE_SUMMARY_PARQUET, FINALCASCADE_SUMMARY_SCHEMA, local_root
386
+ )
387
+ availability = _read_required_parquet(
388
+ DRAFT_AVAILABILITY_PARQUET, DRAFT_AVAILABILITY_SCHEMA, local_root
389
+ )
390
+ stages = _read_required_parquet(STAGES_PARQUET, STAGES_SCHEMA, local_root)
391
+ events = build_explorer_view(catalog, instances, summary, availability)
392
+ _validate_stages(stages, events)
393
+ stages = stages.sort_values(["event_id", "stage_index", "stage_id"], kind="stable")
394
+ stages_by_event = {
395
+ event_id: group.reset_index(drop=True)
396
+ for event_id, group in stages.groupby("event_id", sort=False)
397
+ }
398
+ return ExplorerRelease(
399
+ events=events,
400
+ stages=stages.reset_index(drop=True),
401
+ stages_by_event=stages_by_event,
402
+ )
403
+
404
+
405
+ def load_release(local_dataset_dir: Path | str | None = None) -> ExplorerRelease:
406
+ local_root = _as_local_root(local_dataset_dir)
407
+ return _load_release_cached(str(local_root) if local_root is not None else None)
408
+
409
+
410
+ def clear_caches() -> None:
411
+ _load_release_cached.cache_clear()
412
+ _load_event_graph_cached.cache_clear()
413
 
 
 
 
414
 
415
+ def _canonical_graph_sha256(payload: dict[str, Any]) -> str:
416
+ canonical = json.dumps(
417
+ payload,
418
+ ensure_ascii=False,
419
+ sort_keys=True,
420
+ separators=(",", ":"),
421
+ ).encode("utf-8")
422
+ return hashlib.sha256(canonical).hexdigest()
423
 
 
 
 
424
 
425
+ def _validate_graph(payload: Any, event_id: str, event_row: pd.Series) -> dict[str, Any]:
426
+ if not isinstance(payload, dict):
427
+ raise DraftIntegrityError(f"Draft EPG for {event_id} is not a JSON object")
428
+ if payload.get("event_id") != event_id or payload.get("public_event_id") != event_id:
429
+ raise DraftIntegrityError(f"Draft EPG identity mismatch for {event_id}")
430
+ nested_event = payload.get("event")
431
+ if not isinstance(nested_event, dict):
432
+ raise DraftIntegrityError(f"Nested Draft EPG event must be an object for {event_id}")
433
+ if nested_event.get("event_id") != event_id:
434
+ raise DraftIntegrityError(f"Nested Draft EPG identity mismatch for {event_id}")
435
+ if payload.get("source_payload_sha256") != event_row.get("source_payload_sha256"):
436
+ raise DraftIntegrityError(f"Draft EPG source payload digest mismatch for {event_id}")
437
+ if _canonical_graph_sha256(payload) != event_row.get("draft_sha256"):
438
+ raise DraftIntegrityError(f"Draft EPG canonical digest mismatch for {event_id}")
439
+ return payload
440
 
441
+
442
+ @lru_cache(maxsize=256)
443
+ def _load_event_graph_cached(
444
+ event_id: str,
445
+ local_root_value: str | None,
446
+ expected_source_sha256: str,
447
+ expected_draft_sha256: str,
448
+ ) -> dict[str, Any]:
449
+ filename = DRAFT_EPG_PATH_TEMPLATE.format(event_id=event_id)
450
+ local_root = Path(local_root_value) if local_root_value else None
451
+ try:
452
+ path = resolve_dataset_file(filename, local_dataset_dir=local_root)
453
+ except FileNotFoundError as exc:
454
+ raise DraftAssetMissing(f"Available Draft EPG file is missing: {filename}") from exc
455
+ try:
456
+ with path.open("r", encoding="utf-8") as handle:
457
+ payload = json.load(handle)
458
+ except Exception as exc:
459
+ raise DraftIntegrityError(f"Unable to parse Draft EPG JSON for {event_id}") from exc
460
+ expected = pd.Series(
461
+ {
462
+ "source_payload_sha256": expected_source_sha256,
463
+ "draft_sha256": expected_draft_sha256,
464
+ }
465
+ )
466
+ return _validate_graph(payload, event_id, expected)
467
 
468
 
469
+ def load_event_graph(
470
+ event_id: str,
471
+ *,
472
+ release: ExplorerRelease | None = None,
473
+ local_dataset_dir: Path | str | None = None,
474
+ ) -> dict[str, Any] | DraftUnavailable:
475
+ """Load one direct public Draft EPG after catalog and availability checks."""
476
+
477
+ validate_event_id(event_id)
478
+ selected_release = release or load_release(local_dataset_dir=local_dataset_dir)
479
+ event_row = selected_release.event_row(event_id)
480
+ if event_row["draft_status"] == "draft_unavailable":
481
+ return DraftUnavailable(event_id)
482
+ if event_row["draft_status"] != "draft_available":
483
+ raise ReleaseContractError(f"Unknown draft_status for {event_id}")
484
+
485
+ local_root = _as_local_root(local_dataset_dir)
486
+ return _load_event_graph_cached(
487
+ event_id,
488
+ str(local_root) if local_root is not None else None,
489
+ str(event_row["source_payload_sha256"]),
490
+ str(event_row["draft_sha256"]),
491
+ )
src/h2epr_explorer/filters.py CHANGED
@@ -1,29 +1,57 @@
1
  from __future__ import annotations
2
 
 
 
3
  from typing import Any, Iterable
4
 
 
5
 
6
- NAME_FIELDS = ("display_name", "event_name_en", "event_name", "event_name_zh")
7
- DESCRIPTION_FIELDS = ("short_description", "event_descriptor_en", "event_description_en", "event_description_zh")
 
8
  SEARCH_FIELDS = (
9
  "event_id",
 
 
10
  "display_name",
11
- "event_name_en",
12
- "event_name",
13
- "event_name_zh",
14
- "short_description",
15
- "event_descriptor_en",
16
- "event_description_zh",
17
  "domain",
18
- "event_category",
19
  "keywords",
20
  )
21
 
22
 
23
  def _text_value(value: Any) -> str:
24
- if isinstance(value, list):
25
  return " ".join(str(item) for item in value)
26
- return str(value or "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
 
29
  def event_name(row: dict[str, Any]) -> str:
@@ -61,22 +89,21 @@ def filter_catalog(
61
  query: str = "",
62
  domains: list[str] | None = None,
63
  categories: list[str] | None = None,
64
- min_source_count: int = 0,
65
  min_stage_count: int = 0,
66
  ) -> list[dict[str, Any]]:
67
  domain_set = set(domains or [])
68
  category_set = set(categories or [])
69
  filtered: list[dict[str, Any]] = []
70
  for row in rows:
 
71
  if domain_set and row.get("domain") not in domain_set:
72
  continue
73
- if category_set and row.get("event_category") not in category_set:
74
- continue
75
- if int(row.get("source_count") or 0) < min_source_count:
76
  continue
77
- if int(row.get("stage_count") or 0) < min_stage_count:
 
78
  continue
79
- if not _contains_query(row, query):
80
  continue
81
  filtered.append(row)
82
  return filtered
 
1
  from __future__ import annotations
2
 
3
+ import math
4
+ import re
5
  from typing import Any, Iterable
6
 
7
+ from .constants import EVENT_ID_MAX, EVENT_ID_MIN, EVENT_ID_PATTERN
8
 
9
+
10
+ NAME_FIELDS = ("display_name", "title")
11
+ DESCRIPTION_FIELDS = ("event_descriptor",)
12
  SEARCH_FIELDS = (
13
  "event_id",
14
+ "public_event_id",
15
+ "title",
16
  "display_name",
17
+ "event_descriptor",
 
 
 
 
 
18
  "domain",
19
+ "category",
20
  "keywords",
21
  )
22
 
23
 
24
  def _text_value(value: Any) -> str:
25
+ if isinstance(value, (list, tuple, set)):
26
  return " ".join(str(item) for item in value)
27
+ if value is None:
28
+ return ""
29
+ try:
30
+ if value != value:
31
+ return ""
32
+ except (TypeError, ValueError):
33
+ pass
34
+ return str(value)
35
+
36
+
37
+ def _optional_int(value: Any) -> int | None:
38
+ if value is None:
39
+ return None
40
+ try:
41
+ if isinstance(value, float) and math.isnan(value):
42
+ return None
43
+ return int(value)
44
+ except (TypeError, ValueError):
45
+ return None
46
+
47
+
48
+ def _require_current_event_id(row: dict[str, Any]) -> None:
49
+ event_id = _text_value(row.get("event_id")).strip()
50
+ if not re.fullmatch(EVENT_ID_PATTERN, event_id):
51
+ raise ValueError(f"Catalog filter received a non-canonical event ID: {event_id!r}")
52
+ number = int(event_id.rsplit("-", 1)[1])
53
+ if not EVENT_ID_MIN <= number <= EVENT_ID_MAX:
54
+ raise ValueError(f"Catalog filter received an out-of-range event ID: {event_id}")
55
 
56
 
57
  def event_name(row: dict[str, Any]) -> str:
 
89
  query: str = "",
90
  domains: list[str] | None = None,
91
  categories: list[str] | None = None,
 
92
  min_stage_count: int = 0,
93
  ) -> list[dict[str, Any]]:
94
  domain_set = set(domains or [])
95
  category_set = set(categories or [])
96
  filtered: list[dict[str, Any]] = []
97
  for row in rows:
98
+ _require_current_event_id(row)
99
  if domain_set and row.get("domain") not in domain_set:
100
  continue
101
+ if category_set and row.get("category") not in category_set:
 
 
102
  continue
103
+ stage_count = _optional_int(row.get("stage_count"))
104
+ if min_stage_count > 0 and (stage_count is None or stage_count < min_stage_count):
105
  continue
106
+ if not _contains_query(row, query.strip()):
107
  continue
108
  filtered.append(row)
109
  return filtered
src/h2epr_explorer/navigation.py CHANGED
@@ -1,13 +1,35 @@
1
  from __future__ import annotations
2
 
 
 
3
  from typing import Any, Mapping
4
 
5
- from .constants import GOLD_COMPANION_REPO, PUBLIC_DATASET_REPO
 
 
 
 
 
 
 
 
6
 
7
 
8
  SPACE_URL = "https://huggingface.co/spaces/AgenticFinLab/H2EPR-Bench-Explorer"
9
  PUBLIC_DATASET_URL = f"https://huggingface.co/datasets/{PUBLIC_DATASET_REPO}"
10
  GOLD_COMPANION_URL = f"https://huggingface.co/datasets/{GOLD_COMPANION_REPO}"
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
 
13
  def _first_value(value: Any) -> str:
@@ -16,8 +38,42 @@ def _first_value(value: Any) -> str:
16
  return str(value or "").strip()
17
 
18
 
19
- def query_param_event_id(query_params: Mapping[str, Any]) -> str:
20
- return _first_value(query_params.get("event_id"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
 
23
  def resolve_selected_event_index(rows: list[dict[str, Any]], requested_event_id: str = "") -> int:
@@ -30,16 +86,18 @@ def resolve_selected_event_index(rows: list[dict[str, Any]], requested_event_id:
30
  return 0
31
 
32
 
33
- def build_event_links(event_id: str, gantt_html_path: str = "") -> dict[str, str]:
34
- event_id = event_id.strip()
 
 
35
  links = {
36
- "explorer": f"{SPACE_URL}?event_id={event_id}",
37
- "public_dataset": PUBLIC_DATASET_URL,
38
- "gold_request": GOLD_COMPANION_URL,
39
- "finalcascade_jsonl": f"{PUBLIC_DATASET_URL}/blob/main/data/finmycelium_finalcascade_public.jsonl",
40
  }
41
- if gantt_html_path:
42
- links["gantt_html"] = f"{PUBLIC_DATASET_URL}/blob/main/{gantt_html_path.lstrip('/')}"
 
43
  return links
44
 
45
 
 
1
  from __future__ import annotations
2
 
3
+ from dataclasses import dataclass
4
+ import re
5
  from typing import Any, Mapping
6
 
7
+ from .constants import (
8
+ DRAFT_EPG_PATH_TEMPLATE,
9
+ EVENT_ID_MAX,
10
+ EVENT_ID_MIN,
11
+ EVENT_ID_PATTERN,
12
+ GOLD_COMPANION_REPO,
13
+ PUBLIC_DATASET_REPO,
14
+ PUBLIC_DATASET_REVISION,
15
+ )
16
 
17
 
18
  SPACE_URL = "https://huggingface.co/spaces/AgenticFinLab/H2EPR-Bench-Explorer"
19
  PUBLIC_DATASET_URL = f"https://huggingface.co/datasets/{PUBLIC_DATASET_REPO}"
20
  GOLD_COMPANION_URL = f"https://huggingface.co/datasets/{GOLD_COMPANION_REPO}"
21
+ LEGACY_EVENT_ID_PATTERN = r"^P1000-([0-9]{4})$"
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class QueryEventResolution:
26
+ raw_value: str
27
+ canonical_id: str
28
+ used_legacy_mapping: bool = False
29
+
30
+ @property
31
+ def unresolved(self) -> bool:
32
+ return bool(self.raw_value) and not self.canonical_id
33
 
34
 
35
  def _first_value(value: Any) -> str:
 
38
  return str(value or "").strip()
39
 
40
 
41
+ def _canonical_id(value: str) -> str:
42
+ if not re.fullmatch(EVENT_ID_PATTERN, value):
43
+ return ""
44
+ number = int(value.rsplit("-", 1)[1])
45
+ return value if EVENT_ID_MIN <= number <= EVENT_ID_MAX else ""
46
+
47
+
48
+ def normalize_query_event_id(value: Any) -> QueryEventResolution:
49
+ """Normalize only inbound navigation IDs; all returned IDs are canonical."""
50
+
51
+ raw_value = _first_value(value)
52
+ canonical = _canonical_id(raw_value)
53
+ if canonical:
54
+ return QueryEventResolution(raw_value, canonical)
55
+
56
+ match = re.fullmatch(LEGACY_EVENT_ID_PATTERN, raw_value)
57
+ if not match:
58
+ return QueryEventResolution(raw_value, "")
59
+ legacy_number = int(match.group(1))
60
+ if not 1 <= legacy_number <= 1000:
61
+ return QueryEventResolution(raw_value, "")
62
+ if legacy_number <= 359:
63
+ canonical_number = legacy_number
64
+ elif legacy_number == 360:
65
+ canonical_number = 87
66
+ else:
67
+ canonical_number = legacy_number - 1
68
+ return QueryEventResolution(
69
+ raw_value,
70
+ f"H2EPR-{canonical_number:04d}",
71
+ used_legacy_mapping=True,
72
+ )
73
+
74
+
75
+ def query_param_event_id(query_params: Mapping[str, Any]) -> QueryEventResolution:
76
+ return normalize_query_event_id(query_params.get("event_id"))
77
 
78
 
79
  def resolve_selected_event_index(rows: list[dict[str, Any]], requested_event_id: str = "") -> int:
 
86
  return 0
87
 
88
 
89
+ def build_event_links(event_id: str, *, draft_available: bool = False) -> dict[str, str]:
90
+ canonical = _canonical_id(event_id.strip())
91
+ if not canonical:
92
+ raise ValueError(f"Cannot build current-release links for event ID: {event_id!r}")
93
  links = {
94
+ "explorer": f"{SPACE_URL}?event_id={canonical}",
95
+ "public_dataset": f"{PUBLIC_DATASET_URL}/tree/{PUBLIC_DATASET_REVISION}",
96
+ "reference_access": GOLD_COMPANION_URL,
 
97
  }
98
+ if draft_available:
99
+ draft_path = DRAFT_EPG_PATH_TEMPLATE.format(event_id=canonical)
100
+ links["draft_epg"] = f"{PUBLIC_DATASET_URL}/blob/{PUBLIC_DATASET_REVISION}/{draft_path}"
101
  return links
102
 
103
 
src/h2epr_explorer/render_gantt.py CHANGED
@@ -1,14 +1,30 @@
1
  from __future__ import annotations
2
 
 
 
3
  from typing import Any
4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
  def _is_known_time(value: Any) -> bool:
7
- return bool(value) and str(value).strip().lower() not in {"unknown", "none", "nan", "nat"}
 
8
 
9
 
10
  def _stage_order(row: dict[str, Any], fallback_index: int = 0) -> int:
11
- value = row.get("stage_index", row.get("stage_order", fallback_index))
12
  try:
13
  return int(value)
14
  except (TypeError, ValueError):
@@ -16,40 +32,62 @@ def _stage_order(row: dict[str, Any], fallback_index: int = 0) -> int:
16
 
17
 
18
  def _stage_label(row: dict[str, Any]) -> str:
19
- for field in ("stage_title", "stage_label_public", "stage_label", "stage_id"):
20
- value = str(row.get(field) or "").strip()
21
  if value:
22
  return value
23
  return "Unnamed stage"
24
 
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  def prepare_gantt_rows(stage_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
 
 
27
  ordered = sorted(
28
  stage_rows,
29
  key=lambda row: (_stage_order(row), str(row.get("stage_id", ""))),
30
  )
31
- calendar_axis = all(
32
- _is_known_time(row.get("stage_start_time")) and _is_known_time(row.get("stage_end_time"))
 
33
  for row in ordered
34
  )
35
  prepared: list[dict[str, Any]] = []
36
  for fallback_index, row in enumerate(ordered, start=1):
37
- start = row.get("stage_start_time")
38
- end = row.get("stage_end_time")
39
  stage_order = _stage_order(row, fallback_index)
40
  if calendar_axis:
41
- display_start = start
42
- display_end = end
43
  axis_mode = "calendar"
44
  else:
45
  display_start = stage_order
46
  display_end = display_start + 0.85
47
  axis_mode = "relative_order"
48
 
49
- time_note = row.get("temporal_anchor_summary") or ""
50
- if not time_note and int(row.get("known_action_time_anchor_count") or 0) > 0:
51
- time_note = "Action-level time anchors available"
52
-
53
  prepared.append(
54
  {
55
  **row,
@@ -58,12 +96,18 @@ def prepare_gantt_rows(stage_rows: list[dict[str, Any]]) -> list[dict[str, Any]]
58
  "display_start": display_start,
59
  "display_end": display_end,
60
  "axis_mode": axis_mode,
61
- "time_note": time_note,
62
  }
63
  )
64
  return prepared
65
 
66
 
 
 
 
 
 
 
67
  def build_timeline_figure(stage_rows: list[dict[str, Any]], event_id: str):
68
  import pandas as pd
69
  import plotly.express as px
@@ -74,6 +118,8 @@ def build_timeline_figure(stage_rows: list[dict[str, Any]], event_id: str):
74
 
75
  frame = pd.DataFrame(prepared)
76
  if set(frame["axis_mode"]) == {"calendar"}:
 
 
77
  fig = px.timeline(
78
  frame,
79
  x_start="display_start",
 
1
  from __future__ import annotations
2
 
3
+ import json
4
+ import re
5
  from typing import Any
6
 
7
+ from .constants import EVENT_ID_MAX, EVENT_ID_MIN, EVENT_ID_PATTERN
8
+
9
+
10
+ def _text_value(value: Any) -> str:
11
+ if value is None:
12
+ return ""
13
+ try:
14
+ if value != value:
15
+ return ""
16
+ except (TypeError, ValueError):
17
+ pass
18
+ return str(value).strip()
19
+
20
 
21
  def _is_known_time(value: Any) -> bool:
22
+ text = _text_value(value).lower()
23
+ return bool(text) and text not in {"unknown", "none", "nan", "nat"}
24
 
25
 
26
  def _stage_order(row: dict[str, Any], fallback_index: int = 0) -> int:
27
+ value = row.get("stage_index", fallback_index)
28
  try:
29
  return int(value)
30
  except (TypeError, ValueError):
 
32
 
33
 
34
  def _stage_label(row: dict[str, Any]) -> str:
35
+ for field in ("stage_title", "stage_id"):
36
+ value = _text_value(row.get(field))
37
  if value:
38
  return value
39
  return "Unnamed stage"
40
 
41
 
42
+ def _time_note(row: dict[str, Any]) -> str:
43
+ anchors = row.get("known_action_time_anchors")
44
+ if isinstance(anchors, (list, tuple)):
45
+ return "; ".join(_text_value(anchor) for anchor in anchors if _text_value(anchor))
46
+ text = _text_value(anchors)
47
+ if not text:
48
+ return ""
49
+ try:
50
+ parsed = json.loads(text)
51
+ except (TypeError, ValueError, json.JSONDecodeError):
52
+ return text
53
+ if isinstance(parsed, list):
54
+ return "; ".join(_text_value(anchor) for anchor in parsed if _text_value(anchor))
55
+ return text
56
+
57
+
58
+ def _require_current_event_id(row: dict[str, Any]) -> None:
59
+ event_id = _text_value(row.get("event_id"))
60
+ if not re.fullmatch(EVENT_ID_PATTERN, event_id):
61
+ raise ValueError(f"Timeline received a non-canonical event ID: {event_id!r}")
62
+ number = int(event_id.rsplit("-", 1)[1])
63
+ if not EVENT_ID_MIN <= number <= EVENT_ID_MAX:
64
+ raise ValueError(f"Timeline received an out-of-range event ID: {event_id}")
65
+
66
+
67
  def prepare_gantt_rows(stage_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
68
+ for row in stage_rows:
69
+ _require_current_event_id(row)
70
  ordered = sorted(
71
  stage_rows,
72
  key=lambda row: (_stage_order(row), str(row.get("stage_id", ""))),
73
  )
74
+ calendar_axis = bool(ordered) and all(
75
+ _is_known_time(row.get("stage_start_time"))
76
+ and _is_known_time(row.get("stage_end_time"))
77
  for row in ordered
78
  )
79
  prepared: list[dict[str, Any]] = []
80
  for fallback_index, row in enumerate(ordered, start=1):
 
 
81
  stage_order = _stage_order(row, fallback_index)
82
  if calendar_axis:
83
+ display_start = row.get("stage_start_time")
84
+ display_end = row.get("stage_end_time")
85
  axis_mode = "calendar"
86
  else:
87
  display_start = stage_order
88
  display_end = display_start + 0.85
89
  axis_mode = "relative_order"
90
 
 
 
 
 
91
  prepared.append(
92
  {
93
  **row,
 
96
  "display_start": display_start,
97
  "display_end": display_end,
98
  "axis_mode": axis_mode,
99
+ "time_note": _time_note(row),
100
  }
101
  )
102
  return prepared
103
 
104
 
105
+ def _calendar_datetime_values(values: list[Any]) -> list[Any]:
106
+ import pandas as pd
107
+
108
+ return [pd.to_datetime(value, errors="raise") for value in values]
109
+
110
+
111
  def build_timeline_figure(stage_rows: list[dict[str, Any]], event_id: str):
112
  import pandas as pd
113
  import plotly.express as px
 
118
 
119
  frame = pd.DataFrame(prepared)
120
  if set(frame["axis_mode"]) == {"calendar"}:
121
+ frame["display_start"] = _calendar_datetime_values(frame["display_start"].tolist())
122
+ frame["display_end"] = _calendar_datetime_values(frame["display_end"].tolist())
123
  fig = px.timeline(
124
  frame,
125
  x_start="display_start",