File size: 15,523 Bytes
2025f05
 
1e1dffa
2025f05
 
 
de08e9d
2025f05
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1e1dffa
de08e9d
 
 
 
 
 
35ff794
1e1dffa
38ac4d6
b10ddba
1e1dffa
de08e9d
35ff794
de08e9d
2025f05
 
 
4ebcd24
2025f05
 
 
4ebcd24
 
 
35ff794
 
 
1e1dffa
38ac4d6
 
f1448ba
 
e0d6ce5
1e1dffa
 
35ff794
 
 
b10ddba
 
 
1e1dffa
 
5066a90
1e1dffa
e0d6ce5
 
35ff794
 
 
 
 
 
 
1e1dffa
 
38ac4d6
 
b10ddba
 
f1448ba
 
 
 
 
 
38ac4d6
de08e9d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2025f05
de08e9d
 
 
 
2025f05
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35ff794
2025f05
 
 
1e1dffa
 
de08e9d
 
 
1e1dffa
e0d6ce5
2025f05
 
 
 
 
 
 
 
 
 
 
 
 
35ff794
de08e9d
 
2025f05
 
 
 
1e1dffa
 
2025f05
 
 
1e1dffa
2025f05
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
de08e9d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2025f05
 
 
 
 
 
de08e9d
2025f05
 
de08e9d
2025f05
 
 
de08e9d
 
2025f05
 
 
de08e9d
2025f05
 
 
 
1e1dffa
2025f05
 
 
 
 
de08e9d
2025f05
 
35ff794
 
 
de08e9d
 
 
 
 
 
 
 
 
35ff794
de08e9d
 
 
 
35ff794
2025f05
de08e9d
2025f05
 
 
 
 
 
 
 
 
 
 
 
 
 
de08e9d
 
2025f05
de08e9d
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
"""Render the complete HF card from the canonical GitHub README.

Preserve remote metadata, the featured film and frozen neural weights. Default
mode only prepares artifacts on D; --publish explicitly updates the HF card,
matching existing inference wrapper/docs and this small reproduction utility.
No forecasts are rerun, no media are regenerated and no neural modules change.
--docs-only limits uploads to an explicit documentation/figure allowlist.
"""
from __future__ import annotations

import argparse
import hashlib
import html
import json
import re
import subprocess
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
REPO = 'euler314/typhoon-predict'
MEDIA_REVISION = '87a6e366b42bb4cc0edc95d2c50c55fca21a2c93'
CACHE = '/Volumes/D/typhoon_predict/.cache/huggingface-intensity'
HF = 'https://huggingface.co/' + REPO
FILMS = ('mangkhut',)
REPORT_FIGURES = tuple('docs/report_figures/' + name for name in (
    'day_1_to_5.png',
    'fung_wong_pressure_comparison.png', 'fung_wong_track_pressure.png', 'fung_wong_observed_r34.png',
    'mangkhut_pressure_comparison.png', 'mangkhut_track_pressure.png', 'mangkhut_observed_r34.png',
    'jebi_pressure_comparison.png', 'jebi_track_pressure.png', 'jebi_observed_r34.png',
))
CURRENT_FIGURES = frozenset((
    'evaluation/released_daily/model_1_2_benchmark.png',
    'evaluation/released_daily/model_1_2_after_2024_benchmark.png',
    'evaluation/released_daily/model_1_2_before_2024_benchmark.png',
    'docs/trackformer_1_2_architecture.svg',
    *REPORT_FIGURES,
))
LEGACY_SYNC_FILES = (
    'models/trackformer_1_2_field/README.md',
    'models/trackformer_1_2_field/WIND_ESTIMATION.md',
    'models/trackformer_1_2_field/predict.py',
    'models/trackformer_1_2_field/plot_pressure.py',
    'models/trackformer_1_2_field/wind_estimation.py',
    'release_tools/sync_public_model_cards.py',
    'release_tools/test_public_model_cards.py',
    'release_tools/test_pressure_field_export.py',
    'release_tools/verify_pressure_field_export.py',
    'RELEASE_NOTES_TRACKFORMER_1_2.md',
    'release_tools/build_release_benchmark.py',
    'release_tools/plot_release_pressure_benchmark.py',
    'release_tools/plot_daily_storm_final.py',
    'release_tools/plot_model_announcement.py',
    'release_tools/build_deepmind_period_comparison.py',
    'release_tools/test_deepmind_period_comparison.py',
    'release_tools/import_deepmind_release_results.py',
    'release_tools/test_deepmind_release_results.py',
    'release_tools/build_mangkhut_gif.py',
    'release_tools/deepmind_daily_benchmark.py',
    'release_tools/test_deepmind_daily_benchmark.py',
    'docs/daily_storm_benchmark.md',
    'docs/intensity_benchmark.md',
    'docs/trackformer_1_2_evaluation.md',
    'docs/trackformer_1_2_training_data.md',
    'evaluation/training_data/trackformer_1_2_provenance.json',
    'release_tools/audit_training_data.py',
    'docs/deepmind_daily_benchmark.md',
    'docs/showcase_archive.md',
    'evaluation/README.md',
    'docs/trackformer_1_2_architecture.svg',
    'docs/trackformer_1_2_mangkhut.gif',
    'docs/trackformer_1_2_mangkhut.gif.json',
    'evaluation/daily_storm_final.json',
    'evaluation/released_daily/released_daily_benchmark.json',
    'evaluation/released_daily/released_daily_verification.json',
    'evaluation/intensity/intensity_final.json',
    'evaluation/intensity/verification.json',
    'evaluation/trackformer_1_2_vs_1_1_270_storms_bars.png',
    'evaluation/released_daily/pressure_comparison.png',
    'evaluation/released_daily/model_1_2_benchmark.png',
    'evaluation/released_daily/model_1_2_benchmark.json',
    'evaluation/released_daily/model_1_2_after_2024_benchmark.png',
    'evaluation/released_daily/model_1_2_after_2024_benchmark.json',
    'evaluation/released_daily/model_1_2_before_2024_benchmark.png',
    'evaluation/released_daily/model_1_2_before_2024_benchmark.json',
    'evaluation/deepmind_daily/benchmark.json',
    'evaluation/deepmind_daily/verification.json',
    'evaluation/deepmind_daily/publication_audit.json',
    'evaluation/deepmind_daily/protocol.json',
    'evaluation/deepmind_daily/case-manifest.json',
    'evaluation/deepmind_daily/canary.json',
    'evaluation/deepmind_daily/period_comparison.json',
    'docs/research_report_zh.md',
    'docs/report_figures/day_1_to_5.json',
    'docs/report_figures/provenance.json',
    *REPORT_FIGURES,
)
LEGACY_DOC_SYNC_FILES = (
    'models/trackformer_1_2_field/README.md',
    'models/trackformer_1_2_field/WIND_ESTIMATION.md',
    'RELEASE_NOTES_TRACKFORMER_1_2.md',
    'docs/daily_storm_benchmark.md', 'docs/intensity_benchmark.md',
    'docs/trackformer_1_2_evaluation.md', 'docs/trackformer_1_2_training_data.md',
    'docs/deepmind_daily_benchmark.md', 'docs/showcase_archive.md',
    'evaluation/README.md', 'docs/research_report_zh.md',
    'docs/report_figures/day_1_to_5.json', 'docs/report_figures/provenance.json',
    'docs/trackformer_1_2_architecture.svg', 'docs/trackformer_1_2_mangkhut.gif',
    *REPORT_FIGURES,
)


_DOC_INDEX = json.loads((ROOT / 'docs/archive/documentation-manifest.json').read_text())
_RETIRED_DOCS = frozenset(_DOC_INDEX['removed_paths'])
_COMPACT_DOCS = (
    'docs/REFERENCE.md',
    'docs/TrackFormer_1.2_Training_and_Reproducibility.pdf',
    'docs/archive/documentation-before-consolidation-20261030.zip',
    'docs/archive/documentation-manifest.json',
)
SYNC_FILES = tuple(dict.fromkeys(name for name in (*LEGACY_SYNC_FILES, *_COMPACT_DOCS)
                                if name not in _RETIRED_DOCS))
DOC_SYNC_FILES = tuple(dict.fromkeys(name for name in (*LEGACY_DOC_SYNC_FILES, *_COMPACT_DOCS)
                                    if name not in _RETIRED_DOCS))


def sha(path):
    with Path(path).open('rb') as stream:
        return hashlib.file_digest(stream, 'sha256').hexdigest()


def media_url(stem, poster=False):
    path = ('evaluation/trackformer_1_2_' + stem + '_video_poster.png'
            if poster else 'docs/trackformer_1_2_' + stem + '.mp4')
    return HF + '/resolve/' + MEDIA_REVISION + '/' + path


def player(stem, title):
    return ('<video controls preload="metadata" width="100%" '
            'aria-label="' + html.escape(title, quote=True) + '" '
            'poster="' + media_url(stem, True) + '" '
            'src="' + media_url(stem) + '"></video>')


def render_card(original, github, figure_revision='main'):
    metadata = re.match(r'\A---\n.*?\n---\n', original, re.S)
    if not metadata:
        raise ValueError('Preserve existing HF YAML metadata; missing header')
    if github.count('## See the forecast: Mangkhut (2018)\n') != 1:
        raise ValueError('Missing or ambiguous featured-film section')
    # The canonical GitHub README also carries valid YAML now. Retain exactly
    # the existing remote header rather than nesting a second YAML block.
    card = re.sub(r'\A---\n.*?\n---\n', '', github, count=1, flags=re.S).lstrip()
    for stem, alt in (('mangkhut', 'Trackformer 1.2 Mangkhut pressure and route forecast'),):
        old = '[![' + alt + '](docs/trackformer_1_2_' + stem + '.gif)](' + media_url(stem) + ')'
        if card.count(old) != 1:
            raise ValueError('Missing or ambiguous primary preview: ' + stem)
        card = card.replace(old, player(stem, alt), 1)
    # Relative GitHub links need HF-specific URLs. Keep evolving documentation
    # on main; pin unchanged scientific image bytes to the verified asset commit.
    def link(match):
        image, label, url = match.groups()
        if url.startswith(('https:', 'http:', '#', 'mailto:')):
            return match.group(0)
        path, _, fragment = url.partition('#')
        resolved = (ROOT / path).resolve()
        if not resolved.is_relative_to(ROOT) or not resolved.exists():
            raise ValueError('Invalid or missing relative public link: ' + path)
        revision = figure_revision if path in CURRENT_FIGURES else MEDIA_REVISION
        kind = ('resolve/' + revision if image or path in CURRENT_FIGURES
                else ('tree/main' if resolved.is_dir() else 'blob/main'))
        target = HF + '/' + kind + '/' + path + ('#' + fragment if fragment else '')
        return image + '[' + label + '](' + target + ')'
    card = re.sub(r'(!?)\[([^\[\]\n]*)\]\(([^)\s]+)\)', link, card)
    card = metadata.group(0) + '\n' + card
    if card.count('<video ') != 1:
        raise ValueError('Expected exactly one featured native player')
    for stem in FILMS:
        if card.count('src="' + media_url(stem) + '"') != 1:
            raise ValueError('Missing, repeated or stale player: ' + stem)
    if re.search(r'/resolve/main/docs/trackformer_1_2_[^"\s)]+\.mp4', card):
        raise ValueError('Unversioned movie source')
    return card


def frozen_identity(api, revision):
    manifest = json.loads((ROOT / 'models/trackformer_1_2_field/manifest.json').read_text())
    names = ['models/trackformer_1_2_field/weights.pt'] + [
        'models/trackformer_1_2_field/' + name for name in manifest['source_module_sha256']]
    entries = {i.path: i for i in api.get_paths_info(REPO, names, revision=revision)}
    weight = entries.get(names[0])
    actual = getattr(getattr(weight, 'lfs', None), 'sha256', None)
    if actual != manifest['inference_weights_sha256']:
        raise ValueError('Released weights are absent or differ from frozen identity')
    for name, expected in manifest['source_module_sha256'].items():
        path = ROOT / 'models/trackformer_1_2_field' / name
        data = path.read_bytes()
        entry = entries.get('models/trackformer_1_2_field/' + name)
        if sha(path) != expected or entry is None or entry.blob_id != hashlib.sha1(b'blob ' + str(len(data)).encode() + b'\0' + data).hexdigest():
            raise ValueError('Frozen neural source differs: ' + name)
    return actual


def git_hf_token():
    """Use the existing credential helper for this exact HF host, never log it."""
    import os
    result = subprocess.run(['git', 'credential', 'fill'], cwd=ROOT,
        input='protocol=https\nhost=huggingface.co\n\n',
        text=True, capture_output=True, env={**os.environ, 'GIT_TERMINAL_PROMPT': '0'})
    fields = dict(line.split('=', 1) for line in result.stdout.splitlines() if '=' in line)
    if result.returncode or not fields.get('password'):
        raise ValueError('No existing Hugging Face git credential; authenticate before publishing')
    return fields['password']


def remote_matches(entry, path):
    if entry is None:
        return False
    lfs_sha = getattr(getattr(entry, 'lfs', None), 'sha256', None)
    if lfs_sha:
        return lfs_sha == sha(path)
    data = path.read_bytes()
    return entry.blob_id == hashlib.sha1(b'blob ' + str(len(data)).encode() + b'\0' + data).hexdigest()


def run(output, publish=False, docs_only=False, use_git_credential=False):
    # Lazy imports keep portable CI document tests independent of HF packages.
    from huggingface_hub import CommitOperationAdd, HfApi, hf_hub_download
    output = output.resolve()
    if not output.is_relative_to(Path('/Volumes/D')):
        raise ValueError('All generated documentation/cache artifacts must stay on D')
    output.mkdir(parents=True, exist_ok=True)
    api = HfApi(token=git_hf_token() if use_git_credential else None)
    parent = api.model_info(REPO).sha
    weights_before = frozen_identity(api, parent)
    original = Path(hf_hub_download(REPO, 'README.md', revision=parent, cache_dir=CACHE, token=api.token))
    github = (ROOT / 'README.md').read_text()
    card_path = output / 'README.md'
    card_path.write_text(render_card(original.read_text(), github))
    selected = DOC_SYNC_FILES if docs_only else SYNC_FILES
    files = {'README.md': card_path, **{name: ROOT / name for name in selected}}
    source_commit = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip()
    receipt = {
        'status': 'prepared', 'repo_id': REPO, 'parent_commit': parent,
        'sync_scope': 'documentation_only' if docs_only else 'full_existing_release_sync',
        'github_source_commit': source_commit,
        'github_readme_sha256': sha(ROOT / 'README.md'),
        'original_hf_readme_sha256': sha(original),
        'files': {name: sha(path) for name, path in files.items()},
        'media_revision': MEDIA_REVISION, 'native_video_players': 1,
        'inference_weights_sha256': weights_before,
        'weights_changed': False, 'neural_modules_changed': False,
        'forecast_arrays_changed': False, 'videos_changed': False,
    }
    if publish:
        dirty = subprocess.check_output(['git', 'status', '--porcelain', '--', 'README.md', *selected], cwd=ROOT, text=True)
        if dirty:
            raise ValueError('Commit canonical README and matching source files before publishing')
        # Publish scientific assets first, then pin the new figures to that
        # immutable commit in the card. Never pin new bytes to the old movie
        # revision or let a missing future image render as a stale figure.
        existing = {entry.path: entry for entry in api.get_paths_info(REPO, list(selected), revision=parent)}
        changed = [name for name in selected if not remote_matches(existing.get(name), ROOT/name)]
        figure_revision = parent
        if changed:
            assets = api.create_commit(repo_id=REPO, repo_type='model', parent_commit=parent,
                commit_message='Align 1.2 research documentation and report figures',
                operations=[CommitOperationAdd(path_in_repo=name, path_or_fileobj=str(ROOT/name)) for name in changed])
            figure_revision = assets.oid
        card_path.write_text(render_card(original.read_text(), github, figure_revision))
        receipt['files']['README.md'] = sha(card_path)
        receipt['figure_revision'] = figure_revision
        receipt['uploaded_asset_paths'] = changed
        committed = api.create_commit(repo_id=REPO, repo_type='model', parent_commit=figure_revision,
            commit_message='Match released 1.2 model card to the Chinese research report',
            operations=[CommitOperationAdd(path_in_repo='README.md', path_or_fileobj=str(card_path))])
        for name, expected in receipt['files'].items():
            saved = hf_hub_download(REPO, name, revision=committed.oid, cache_dir=CACHE, token=api.token)
            if sha(saved) != expected:
                raise ValueError('Published documentation/source differs: ' + name)
        if frozen_identity(api, committed.oid) != weights_before:
            raise ValueError('Frozen identity changed during documentation publication')
        receipt.update(status='published_verified', commit=committed.oid,
            commit_url=str(committed.commit_url), all_remote_files_sha256_verified=True)
    (output / 'publication.json').write_text(json.dumps(receipt, indent=2) + '\n')
    print(json.dumps({key: value for key, value in receipt.items() if key != 'files'}, indent=2))


if __name__ == '__main__':
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--output', type=Path, required=True)
    parser.add_argument('--publish', action='store_true')
    parser.add_argument('--docs-only', action='store_true', help='Upload only whitelisted Markdown and report assets')
    parser.add_argument('--use-git-credential', action='store_true', help='Use the existing HF credential helper without saving a token')
    args = parser.parse_args()
    run(args.output, args.publish, args.docs_only, args.use_git_credential)