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

Revert Explorer after production acceptance failure

Browse files

Restore the exact pre-Unified-3000 Space tree from 62cba3adc5a2a33589e69e436169491f9ec3016e after event detail failed to render in production browser acceptance. The rollback tag pre-unified3000-explorer-20260814 and canary branch remain unchanged.

README.md CHANGED
@@ -9,54 +9,24 @@ license: cc-by-nc-4.0
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.
 
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.
 
 
app.py CHANGED
@@ -4,9 +4,6 @@ import json
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,22 +11,11 @@ import streamlit as st
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,37 +26,25 @@ from h2epr_explorer.navigation import (
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,71 +83,34 @@ div[data-testid="stMetric"] {
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,131 +118,113 @@ if not filtered_rows:
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
  )
 
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
 
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
  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
  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
  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
  )
src/h2epr_explorer/constants.py CHANGED
@@ -1,172 +1,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
- )
 
 
 
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
+ ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/h2epr_explorer/data_loader.py CHANGED
@@ -1,100 +1,36 @@
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,388 +40,87 @@ 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
- )
 
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
  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)
 
 
 
 
src/h2epr_explorer/filters.py CHANGED
@@ -1,57 +1,29 @@
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,21 +61,22 @@ def filter_catalog(
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
 
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
  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
src/h2epr_explorer/navigation.py CHANGED
@@ -1,35 +1,13 @@
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,42 +16,8 @@ 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,18 +30,16 @@ def resolve_selected_event_index(rows: list[dict[str, Any]], requested_event_id:
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
 
 
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
  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
  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
 
src/h2epr_explorer/render_gantt.py CHANGED
@@ -1,30 +1,14 @@
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,62 +16,40 @@ def _stage_order(row: dict[str, Any], fallback_index: int = 0) -> int:
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,18 +58,12 @@ def prepare_gantt_rows(stage_rows: list[dict[str, Any]]) -> list[dict[str, Any]]
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,8 +74,6 @@ def build_timeline_figure(stage_rows: list[dict[str, Any]], event_id: str):
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",
 
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
 
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
  "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
 
75
  frame = pd.DataFrame(prepared)
76
  if set(frame["axis_mode"]) == {"calendar"}:
 
 
77
  fig = px.timeline(
78
  frame,
79
  x_start="display_start",