From e88106bc94a92181bc7aef016891f1952b174d8d Mon Sep 17 00:00:00 2001 From: Jinxin <102596431+JinxinWonderWorld@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:45:36 +0800 Subject: [PATCH 1/4] Publish bilingual web documentation --- .github/workflows/docs.yml | 79 ++ docs/404.md | 7 + docs/_static/css/site.css | 1002 +++++++++++++++++ docs/_static/css/tokens.css | 178 +++ docs/_static/js/site.js | 211 ++++ .../components/language-switcher.html | 9 + docs/_templates/components/navbar-logo.html | 4 + docs/_templates/components/navbar-nav.html | 19 + docs/_templates/components/sidebar-nav.html | 35 + docs/_templates/sections/footer.html | 34 + docs/api.md | 17 +- docs/concepts.md | 44 + docs/conf.py | 75 ++ docs/index.md | 110 ++ docs/installation.md | 44 + docs/limitations.md | 34 + docs/locale/zh_CN/LC_MESSAGES/404.po | 25 + docs/locale/zh_CN/LC_MESSAGES/api.po | 169 +++ docs/locale/zh_CN/LC_MESSAGES/concepts.po | 109 ++ docs/locale/zh_CN/LC_MESSAGES/index.po | 131 +++ docs/locale/zh_CN/LC_MESSAGES/installation.po | 93 ++ docs/locale/zh_CN/LC_MESSAGES/limitations.po | 85 ++ docs/locale/zh_CN/LC_MESSAGES/quickstart.po | 133 +++ docs/locale/zh_CN/LC_MESSAGES/usage.po | 219 ++++ docs/quickstart.md | 55 + docs/usage.md | 21 +- pyproject.toml | 10 + scripts/build_docs.py | 71 ++ scripts/publish_docs.sh | 67 ++ tests/test_web_docs.py | 223 ++++ 30 files changed, 3296 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/docs.yml create mode 100644 docs/404.md create mode 100644 docs/_static/css/site.css create mode 100644 docs/_static/css/tokens.css create mode 100644 docs/_static/js/site.js create mode 100644 docs/_templates/components/language-switcher.html create mode 100644 docs/_templates/components/navbar-logo.html create mode 100644 docs/_templates/components/navbar-nav.html create mode 100644 docs/_templates/components/sidebar-nav.html create mode 100644 docs/_templates/sections/footer.html create mode 100644 docs/concepts.md create mode 100644 docs/conf.py create mode 100644 docs/index.md create mode 100644 docs/installation.md create mode 100644 docs/limitations.md create mode 100644 docs/locale/zh_CN/LC_MESSAGES/404.po create mode 100644 docs/locale/zh_CN/LC_MESSAGES/api.po create mode 100644 docs/locale/zh_CN/LC_MESSAGES/concepts.po create mode 100644 docs/locale/zh_CN/LC_MESSAGES/index.po create mode 100644 docs/locale/zh_CN/LC_MESSAGES/installation.po create mode 100644 docs/locale/zh_CN/LC_MESSAGES/limitations.po create mode 100644 docs/locale/zh_CN/LC_MESSAGES/quickstart.po create mode 100644 docs/locale/zh_CN/LC_MESSAGES/usage.po create mode 100644 docs/quickstart.md create mode 100644 scripts/build_docs.py create mode 100755 scripts/publish_docs.sh create mode 100644 tests/test_web_docs.py diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..da9562a --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,79 @@ +name: Docs + +on: + push: + branches: [main] + paths: + - ".github/workflows/docs.yml" + - "docs/**" + - "observable_library/**" + - "scripts/build_docs.py" + - "scripts/publish_docs.sh" + - "tests/test_web_docs.py" + - "pyproject.toml" + pull_request: + paths: + - ".github/workflows/docs.yml" + - "docs/**" + - "observable_library/**" + - "scripts/build_docs.py" + - "scripts/publish_docs.sh" + - "tests/test_web_docs.py" + - "pyproject.toml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: docs-${{ github.ref }} + cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }} + +jobs: + build: + name: Build bilingual documentation + runs-on: ubuntu-latest + timeout-minutes: 8 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + cache: pip + - name: Install documentation dependencies + run: python -m pip install -e ".[docs]" + - name: Build the exact Pages artifact + run: python scripts/build_docs.py --output-dir build/web-docs + - name: Run documentation policy tests + run: python -m pytest tests/test_web_docs.py -q + - name: Upload Pages artifact + uses: actions/upload-artifact@v7 + with: + name: web-docs-${{ github.sha }} + path: build/web-docs + include-hidden-files: true + if-no-files-found: error + retention-days: 7 + + deploy: + name: Publish gh-pages + needs: build + if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 5 + environment: docs-production + steps: + - uses: actions/checkout@v7 + - name: Download the artifact built by this run + uses: actions/download-artifact@v8 + with: + name: web-docs-${{ github.sha }} + path: build/web-docs + - name: Publish by normal fast-forward push + run: scripts/publish_docs.sh build/web-docs + env: + DOCS_DEPLOY_TOKEN: ${{ secrets.DOCS_DEPLOY_TOKEN }} + DOCS_TARGET_REPOSITORY: MetaCircleAI/Observable-Library + DOCS_TARGET_BRANCH: gh-pages + DOCS_GIT_NAME: Observable Library Docs Bot + DOCS_GIT_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com diff --git a/docs/404.md b/docs/404.md new file mode 100644 index 0000000..f5c1162 --- /dev/null +++ b/docs/404.md @@ -0,0 +1,7 @@ +--- +orphan: true +--- + +# Page not found + +The requested Observable Library 0.1.0 documentation page does not exist. Return to the [documentation home](index.md). diff --git a/docs/_static/css/site.css b/docs/_static/css/site.css new file mode 100644 index 0000000..b716c63 --- /dev/null +++ b/docs/_static/css/site.css @@ -0,0 +1,1002 @@ +* { + box-sizing: border-box; +} + +html { + scroll-padding-top: calc(var(--layout-navbar-height) + var(--space-md)); +} + +body { + background: var(--color-bg-base); + color: var(--color-text-primary); + font-family: var(--font-sans); + font-size: var(--text-body); + line-height: var(--line-height-body); +} + +::selection { + background: var(--color-accent-subtle); +} + +:focus-visible { + border-radius: var(--radius-sm); + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +a { + color: var(--color-accent); + text-underline-offset: 0.18em; +} + +a:hover { + color: var(--color-accent-hover); +} + +h1, +h2, +h3, +h4 { + color: var(--color-text-primary); + font-family: var(--font-sans); + letter-spacing: -0.015em; +} + +h1 { + font-size: var(--text-h1); +} + +h2 { + font-size: var(--text-h2); + margin-top: var(--space-3xl); +} + +h3 { + font-size: var(--text-h3); + margin-top: var(--space-xl); +} + +code, +kbd, +pre, +.sig, +.sig-name, +.docutils.literal { + font-family: var(--font-mono); +} + +code, +.docutils.literal:not(.xref) { + overflow-wrap: anywhere; +} + +#pst-header { + height: var(--layout-navbar-height); + min-height: var(--layout-navbar-height); + background: color-mix(in srgb, var(--color-bg-base) 94%, transparent); + border-bottom: 1px solid var(--color-border-subtle); + backdrop-filter: blur(12px); +} + +#pst-header .bd-header__inner { + height: var(--layout-navbar-height); + min-height: var(--layout-navbar-height); + max-width: var(--layout-max); + padding: 0 var(--space-lg); +} + +.docs-wordmark { + display: inline-flex; + align-items: center; + gap: var(--space-sm); + min-width: max-content; + color: var(--color-text-primary); + text-decoration: none; + height: auto !important; + padding: 0; +} + +.docs-wordmark:hover { + color: var(--color-text-primary); +} + +.docs-wordmark__name { + font-family: var(--font-mono); + font-size: var(--text-body-sm); + font-weight: 650; + letter-spacing: -0.02em; +} + +.docs-wordmark__version { + padding: var(--space-3xs) var(--space-xs); + color: var(--color-accent); + background: var(--color-accent-subtle); + border: 1px solid color-mix(in srgb, var(--color-accent) 55%, transparent); + border-radius: var(--radius-sm); + font-family: var(--font-mono); + font-size: 0.6875rem; + font-weight: 600; + line-height: 1.4; +} + +.bd-navbar-elements .nav-link { + border-radius: var(--radius-sm); + color: var(--color-text-secondary); + font-size: var(--text-body-sm); + font-weight: 500; +} + +.bd-navbar-elements .nav-link:hover, +.bd-navbar-elements .nav-link.active { + color: var(--color-text-primary); + background: var(--color-accent-subtle); +} + +#pst-header .drawer-header { + display: none !important; +} + +.search-button-field { + min-width: 10rem; + height: 2rem; + color: var(--color-text-secondary); + background: var(--color-bg-surface); + border-color: var(--color-border-default); + border-radius: var(--radius-md); + box-shadow: none; +} + +.search-button-field:hover { + color: var(--color-text-primary); + background: var(--color-bg-raised); + border-color: var(--color-border-strong); +} + +.docs-language-switch { + display: inline-flex; + align-items: center; + min-height: 2rem; + padding: var(--space-3xs); + background: var(--color-bg-surface); + border: 1px solid var(--color-border-default); + border-radius: var(--radius-md); + font-size: var(--text-caption); + line-height: 1; + white-space: nowrap; +} + +.docs-language-switch a, +.docs-language-switch span { + padding: var(--space-xs) var(--space-sm); + border-radius: var(--radius-sm); + color: var(--color-text-secondary); + text-decoration: none; +} + +.docs-language-switch [aria-current="page"] { + color: var(--color-text-primary); + background: var(--color-accent-subtle); + font-weight: 600; +} + +.theme-switch-button, +.pst-navbar-icon { + color: var(--color-text-secondary); +} + +.theme-switch-button:hover, +.pst-navbar-icon:hover { + color: var(--color-text-primary); +} + +.bd-container__inner, +.bd-page-width { + max-width: var(--layout-max); +} + +.bd-main .bd-content .bd-article-container { + max-width: var(--layout-content-max); +} + +.bd-main .bd-content .bd-article { + min-width: 0; +} + +.bd-main .bd-content .bd-article-container .bd-article :target > :is(h1, .h1, h2, .h2, h3, .h3, h4, .h4, h5, .h5) { + background-color: transparent; +} + +#pst-primary-sidebar { + width: var(--layout-sidebar-width); + border-right-color: var(--color-border-subtle); +} + +.bd-sidebar-secondary { + width: var(--layout-toc-width); +} + +.bd-links .active > a, +.bd-links a.current, +.bd-toc-nav .active > a { + color: var(--color-accent); + background: var(--color-accent-subtle); + border-left: 2px solid var(--color-accent); +} + +.highlight, +div[class*="highlight-"] { + max-width: 100%; + background: var(--color-bg-surface); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-md); + box-shadow: none; +} + +.highlight pre { + max-width: 100%; + margin: 0; + padding: var(--space-md); + overflow-x: auto; + color: var(--code-text); + font-size: var(--text-code); + line-height: var(--line-height-code); +} + +.highlight .c, +.highlight .c1, +.highlight .cm { + color: var(--code-comment) !important; +} + +.highlight .k, +.highlight .kn, +.highlight .kd, +.highlight .ow { + color: var(--code-keyword) !important; +} + +.highlight .s, +.highlight .s1, +.highlight .s2 { + color: var(--code-string) !important; +} + +.highlight .mi, +.highlight .mf { + color: var(--code-number) !important; +} + +.highlight .nf, +.highlight .fm { + color: var(--code-function) !important; +} + +.highlight .nc { + color: var(--code-class) !important; +} + +.copybtn { + color: var(--color-text-secondary); + background: var(--color-bg-raised); + border: 1px solid var(--color-border-default); + border-radius: var(--radius-sm); +} + +.sd-card { + height: 100%; + color: var(--color-text-secondary); + background: var(--color-bg-surface); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-lg); + box-shadow: none; + transition: border-color var(--transition-fast); +} + +.sd-card:hover { + border-color: var(--color-border-strong); + box-shadow: none; + transform: none; +} + +.sd-card-title { + color: var(--color-text-primary); + font-size: var(--text-body); + font-weight: 650; +} + +.admonition { + border: 0; + border-left: 3px solid var(--pst-color-info); + border-radius: var(--radius-sm); + box-shadow: none; +} + +.admonition.warning { + border-left-color: var(--color-warning); +} + +.admonition.danger, +.admonition.error { + border-left-color: var(--color-danger); +} + +.admonition.tip, +.admonition.hint, +.admonition.important { + border-left-color: var(--color-success); +} + +table.table { + display: block; + max-width: 100%; + overflow-x: auto; + border-color: var(--color-border-subtle); +} + +.hero-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.25fr); + gap: var(--space-2xl); + align-items: center; + margin: 0; + padding: 0 0 var(--space-2xl); +} + +.hero-grid.sd-mb-4 { + margin-bottom: 0 !important; +} + +body:has(.hero-grid) .bd-article { + padding-left: 0 !important; +} + +body:has(.hero-grid) #pst-primary-sidebar { + display: none !important; +} + +body:has(.hero-grid) .bd-main { + width: 100%; +} + +.hero-grid > .sd-row { + display: contents; +} + +.hero-grid .sd-col { + width: auto; + max-width: none; + min-width: 0; + padding: 0; +} + +.hero-copy > p:first-child { + margin: 0 0 var(--space-md); + color: var(--color-accent); + font-size: var(--text-caption); + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.hero-copy h1 { + margin: 0 0 var(--space-lg); + color: var(--color-text-primary); + font-size: var(--text-display); + font-weight: 700; + line-height: var(--line-height-display); + letter-spacing: -0.03em; +} + +.hero-copy h1 + p { + margin: 0 0 var(--space-xl); + color: var(--color-text-secondary); + font-size: var(--text-body-lg); + line-height: var(--line-height-body); +} + +.hero-copy > p:last-child { + display: flex; + gap: var(--space-md); + align-items: center; + margin: 0; +} + +.hero-copy > p:last-child a { + display: inline-flex; + align-items: center; + min-height: 2.5rem; + padding: var(--space-sm) var(--space-md); + border: 1px solid var(--color-border-default); + border-radius: var(--radius-md); + color: var(--color-text-primary); + font-size: var(--text-body-sm); + font-weight: 600; + text-decoration: none; +} + +.hero-copy > p:last-child a:first-child { + color: var(--color-text-inverse); + background: var(--color-accent); + border-color: var(--color-accent); +} + +.hero-copy > p:last-child a:first-child:hover { + background: var(--color-accent-hover); + border-color: var(--color-accent-hover); +} + +.hero-code-stage { + position: relative; + min-width: 0; + overflow: hidden; + background: var(--color-bg-surface); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-lg); + min-height: 450px; +} + +.hero-code-stage .hero-live-code, +.hero-code-stage .hero-live-code .highlight { + margin: 0; + border: 0; + border-radius: 0; +} + +.hero-code-stage .hero-live-code pre { + padding: var(--space-md) 0; + line-height: 1.9 !important; +} + +.live-code-head, +.live-code-foot, +.live-code-metrics { + border-color: var(--color-border-subtle); +} + +.live-code-head, +.live-code-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); + padding: var(--space-sm) var(--space-md); + color: var(--color-text-tertiary); + font-family: var(--font-mono); + font-size: var(--text-caption); +} + +.live-code-head { + border-bottom: 1px solid var(--color-border-subtle); +} + +.live-code-label { + display: inline-flex; + align-items: center; + gap: var(--space-sm); + color: var(--color-text-secondary); +} + +.live-code-dot { + width: 7px; + height: 7px; + background: var(--color-accent); + border-radius: 50%; +} + +.is-paused .live-code-dot { + background: var(--color-text-tertiary); +} + +.live-code-line { + position: relative; + display: block; + min-width: max-content; + padding-inline: calc(var(--space-md) + 3ch) var(--space-md); + white-space: pre; +} + +.live-code-line::before { + position: absolute; + left: var(--space-md); + width: 2ch; + color: var(--color-text-tertiary); + content: attr(data-line); + text-align: right; + user-select: none; +} + +.live-code-line.is-active { + background: var(--color-accent-subtle); +} + +.live-code-line.is-active::after { + position: absolute; + inset: 0 auto 0 0; + width: 2px; + background: var(--color-accent); + content: ""; +} + +.live-code-metrics { + position: relative; + display: grid; + gap: var(--space-sm); + padding: var(--space-xl) var(--space-md) var(--space-md); + border-top: 1px solid var(--color-border-subtle); +} + +.live-code-metrics::before { + position: absolute; + top: var(--space-sm); + right: var(--space-md); + padding: 1px var(--space-xs); + color: var(--color-text-tertiary); + border: 1px dashed var(--color-border-strong); + border-radius: 3px; + content: "illustrative"; + font-family: var(--font-mono); + font-size: 0.625rem; + letter-spacing: 0.05em; +} + +.live-code-metric { + display: grid; + grid-template-columns: 104px minmax(60px, 1fr) 64px; + gap: var(--space-md); + align-items: center; + min-width: 0; +} + +.live-code-metric__label, +.live-code-metric__value { + font-family: var(--font-mono); + font-size: var(--text-caption); +} + +.live-code-metric__label { + color: var(--color-text-secondary); +} + +.live-code-metric__value { + color: var(--color-accent); + font-variant-numeric: tabular-nums; + text-align: right; +} + +.live-code-metric canvas { + display: block; + width: 100%; + min-width: 0; + height: 34px; +} + +.live-code-foot { + border-top: 1px solid var(--color-border-subtle); + font-family: var(--font-sans); +} + +.live-code-control { + min-height: 2rem; + padding: var(--space-2xs) var(--space-md); + color: var(--color-text-secondary); + background: transparent; + border: 1px solid var(--color-border-default); + border-radius: var(--radius-md); + font-family: inherit; + font-size: var(--text-body-sm); + cursor: pointer; +} + +.live-code-control:hover { + color: var(--color-text-primary); + border-color: var(--color-border-strong); +} + +.workflow-strip { + margin: 0 0 var(--space-xl); + padding: var(--space-xl); + overflow-x: auto; + color: var(--color-text-secondary); + background: var(--color-bg-surface); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-lg); + font-family: var(--font-mono); + font-size: var(--text-body-sm); +} + +.workflow-strip > p { + display: none; +} + +.workflow-steps { + display: grid; + grid-template-columns: minmax(9rem, 1fr) auto minmax(9rem, 1fr) auto minmax(9rem, 1fr); + align-items: center; + min-width: 34rem; + max-width: 44rem; + margin-inline: auto; +} + +.workflow-node { + display: grid; + gap: var(--space-2xs); + justify-items: center; +} + +.workflow-node strong { + color: var(--color-text-primary); + font-weight: 500; +} + +.workflow-node span { + color: var(--color-text-tertiary); + font-size: var(--text-caption); +} + +.workflow-node.is-optional strong, +.workflow-arrow { + color: var(--color-text-tertiary); +} + +.workflow-arrow { + padding-inline: var(--space-md); +} + +.home-details-grid { + display: grid; + grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr); + gap: var(--space-lg); + align-items: start; + margin-bottom: var(--space-3xl); +} + +.home-details-grid > section { + min-width: 0; +} + +.home-details-grid h2 { + margin-top: 0; +} + +.docs-footer { + width: 100%; + max-width: var(--layout-max); + padding: var(--space-2xl) var(--space-lg) var(--space-lg); +} + +.docs-footer__grid { + display: grid; + grid-template-columns: minmax(15rem, 2fr) minmax(10rem, 1fr) minmax(10rem, 1fr); + gap: var(--space-2xl); +} + +.docs-footer__brand p { + max-width: 25rem; + margin-top: var(--space-md); + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.docs-footer nav { + display: grid; + align-content: start; + gap: var(--space-sm); +} + +.docs-footer nav a { + color: var(--color-text-secondary); + font-size: var(--text-body-sm); + text-decoration: none; +} + +.docs-footer nav a:hover { + color: var(--color-accent); +} + +.docs-footer__heading { + margin: 0 0 var(--space-sm); + color: var(--color-text-tertiary); + font-size: var(--text-caption); + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.docs-footer__meta { + display: flex; + justify-content: space-between; + gap: var(--space-md); + margin-top: var(--space-2xl); + padding-top: var(--space-lg); + color: var(--color-text-tertiary); + border-top: 1px solid var(--color-border-subtle); + font-size: var(--text-caption); +} + +body[data-default-mode] .bd-article > .hero-grid, +body[data-default-mode] .bd-article > .workflow-strip, +body[data-default-mode] .bd-article > .hero-grid ~ * { + max-width: 100%; +} + +html[lang^="zh"] .hero-copy h1 { + font-size: var(--text-display-zh); +} + +html[lang^="zh"] .bd-main .bd-content .bd-article-container { + max-width: 42rem; +} + +@media (min-width: 960px) { + body:has(.hero-grid) .bd-main .bd-content .bd-article-container { + max-width: var(--layout-max); + padding-inline: var(--space-lg); + } +} + +@media (min-width: 960px) and (max-width: 1199px) { + body:not(:has(.hero-grid)) .bd-content { + min-width: 0; + } + + body:not(:has(.hero-grid)) .bd-main .bd-content .bd-article-container { + flex: 1 1 auto; + width: calc(100% - var(--layout-toc-width)); + max-width: calc(100% - var(--layout-toc-width)); + } + + body:not(:has(.hero-grid)) .bd-sidebar-secondary { + position: sticky; + top: var(--layout-navbar-height); + right: auto; + bottom: auto; + left: auto; + display: flex; + flex: 0 0 var(--layout-toc-width); + height: calc(100vh - var(--layout-navbar-height)); + margin: 0; + visibility: visible; + } +} + +@media (max-width: 1279px) { + #pst-header .navbar-header-items__center { + display: none !important; + } + + #pst-header .navbar-header-items__end { + margin-left: auto; + } + + #pst-header .primary-toggle { + display: flex !important; + } + + .search-button-field { + min-width: 2rem; + } +} + +@media (min-width: 901px) and (max-width: 1279px) { + #pst-header .secondary-toggle { + display: none !important; + } +} + +@media (max-width: 900px) { + .hero-grid { + grid-template-columns: minmax(0, 1fr); + padding-block: var(--space-md) var(--space-xl); + } + + .home-details-grid { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (max-width: 767px) { + body { + font-size: 0.875rem; + } + + #pst-header .bd-header__inner { + padding-inline: var(--space-md); + } + + #pst-header .navbar-header-items__end .navbar-item:has(.docs-language-switch) { + display: none; + } + + .docs-wordmark__name { + max-width: 10rem; + overflow: hidden; + text-overflow: ellipsis; + } + + .hero-copy h1, + html[lang^="zh"] .hero-copy h1 { + font-size: 1.75rem; + } + + .hero-copy h1 + p { + font-size: 0.875rem; + line-height: 1.65; + } + + .hero-grid { + gap: var(--space-lg); + padding-top: 0; + } + + .workflow-strip { + overflow-x: visible; + } + + .workflow-steps { + grid-template-columns: minmax(0, 1fr); + min-width: 0; + } + + .workflow-arrow { + justify-self: center; + transform: rotate(90deg); + } + + .hero-copy { + margin-top: calc(-1 * var(--space-sm)); + } + + .hero-copy > p:last-child { + flex-direction: row; + align-items: center; + } + + .hero-copy > p:last-child a { + justify-content: center; + min-height: 44px; + } + + .hero-code-stage .hero-live-code pre { + font-size: 0.75rem; + } + + .hero-code-stage { + min-height: 0; + margin-top: 0; + } + + .live-code-metric { + grid-template-columns: 88px minmax(40px, 1fr) 54px; + gap: var(--space-sm); + } + + .live-code-foot > span { + display: none; + } + + .sd-card { + min-height: 44px; + } + + h1 { + font-size: 1.75rem; + } + + h2 { + font-size: 1.375rem; + } + + .docs-footer { + padding-inline: var(--space-md); + } + + .docs-footer__grid { + grid-template-columns: minmax(0, 1fr); + gap: var(--space-xl); + } + + .docs-footer__meta { + flex-direction: column; + } + + #pst-header .primary-toggle { + width: 36px; + margin-right: var(--space-sm); + padding-inline: var(--space-2xs); + } + + #pst-header .navbar-header-items__start { + margin: 0 auto 0 0; + } + + #pst-header .navbar-persistent--mobile { + display: flex; + gap: var(--space-sm); + margin-left: var(--space-sm); + } + + #pst-header .navbar-persistent--mobile .navbar-item, + #pst-header .navbar-persistent--mobile .search-button-field { + width: 32px; + min-width: 32px; + } + + #pst-primary-sidebar-modal[open] { + inset: 0 0 0 auto; + width: min(318px, 85vw); + max-width: min(318px, 85vw); + height: 100dvh; + margin: 0; + } + + #pst-primary-sidebar-modal nav, + #pst-primary-sidebar-modal .bd-navbar-elements { + width: 100%; + } + + #pst-primary-sidebar-modal .drawer-header { + position: relative; + display: flex; + align-items: center; + min-height: 56px; + margin-bottom: var(--space-md); + padding: 0 0 var(--space-md); + border-bottom: 1px solid var(--color-border-subtle); + } + + #pst-primary-sidebar-modal .drawer-header .docs-wordmark__name { + max-width: none; + } + + #pst-primary-sidebar-modal .drawer-header .docs-wordmark__version { + display: none; + } + + #pst-primary-sidebar-modal .drawer-close { + position: absolute; + top: 0; + right: 0; + display: inline-flex !important; + width: 36px; + height: 36px; + } + + #pst-primary-sidebar-modal .bd-navbar-elements .nav-link { + min-height: 44px; + padding: var(--space-sm) var(--space-md); + font-size: var(--text-body); + } + + #pst-primary-sidebar-modal .bd-docs-nav, + #pst-primary-sidebar-modal .docs-language-switch, + #pst-primary-sidebar-modal .navbar-icon-links { + display: none; + } +} + +@media (max-width: 400px) { + #pst-header .navbar-persistent--mobile:has(.theme-switch-container) { + display: none; + } + + #pst-header .navbar-header-items__start .docs-wordmark__version { + display: none; + } +} + +@media (max-width: 360px) { + .docs-wordmark__name { + max-width: 7.25rem; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +} diff --git a/docs/_static/css/tokens.css b/docs/_static/css/tokens.css new file mode 100644 index 0000000..0cac810 --- /dev/null +++ b/docs/_static/css/tokens.css @@ -0,0 +1,178 @@ +:root { + --font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", + "Noto Sans SC", "Source Han Sans SC", "PingFang SC", sans-serif; + --font-mono: "JetBrains Mono", "SFMono-Regular", Consolas, "Liberation Mono", + Menlo, monospace; + + --text-display: 2.5rem; + --text-display-zh: 2.375rem; + --text-h1: 2rem; + --text-h2: 1.5rem; + --text-h3: 1.25rem; + --text-body-lg: 1.125rem; + --text-body: 1rem; + --text-body-sm: 0.875rem; + --text-caption: 0.75rem; + --text-code: 0.875rem; + --line-height-display: 1.15; + --line-height-body: 1.7; + --line-height-code: 1.6; + + --space-3xs: 2px; + --space-2xs: 4px; + --space-xs: 6px; + --space-sm: 8px; + --space-md: 16px; + --space-lg: 24px; + --space-xl: 32px; + --space-2xl: 48px; + --space-3xl: 64px; + --space-4xl: 96px; + + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 8px; + --radius-xl: 16px; + + --layout-navbar-height: 56px; + --layout-sidebar-width: 260px; + --layout-toc-width: 220px; + --layout-content-max: 44rem; + --layout-max: 1400px; + + --transition-fast: 150ms ease; + --transition-base: 200ms ease; +} + +:root, +html[data-theme="dark"] { + --color-bg-base: #0d1117; + --color-bg-surface: #161b22; + --color-bg-raised: #1c2128; + --color-bg-overlay: #22272e; + --color-bg-inline-code: #2d333b; + --color-text-primary: #e6edf3; + --color-text-secondary: #9da7b3; + --color-text-tertiary: #7d8590; + --color-text-inverse: #0d1117; + --color-border-subtle: #21262d; + --color-border-default: #30363d; + --color-border-strong: #3d444d; + --color-accent: #39c5cf; + --color-accent-hover: #56d4dd; + --color-accent-active: #2aa8b2; + --color-accent-subtle: rgba(57, 197, 207, 0.12); + --color-info: #58a6ff; + --color-warning: #d29922; + --color-danger: #f85149; + --color-success: #3fb950; + --code-text: #e6edf3; + --code-comment: #8b949e; + --code-keyword: #ff7b72; + --code-string: #a5d6ff; + --code-number: #79c0ff; + --code-function: #d2a8ff; + --code-class: #ffa657; + color-scheme: dark; +} + +html[data-theme="light"] { + --color-bg-base: #ffffff; + --color-bg-surface: #f6f8fa; + --color-bg-raised: #ffffff; + --color-bg-overlay: #ffffff; + --color-bg-inline-code: #eff1f3; + --color-text-primary: #1f2328; + --color-text-secondary: #59636e; + --color-text-tertiary: #6e7781; + --color-text-inverse: #ffffff; + --color-border-subtle: #e8ebee; + --color-border-default: #d1d9e0; + --color-border-strong: #b6bfc8; + --color-accent: #0e7c86; + --color-accent-hover: #0a626b; + --color-accent-active: #084e55; + --color-accent-subtle: rgba(14, 124, 134, 0.1); + --color-info: #0969da; + --color-warning: #9a6700; + --color-danger: #cf222e; + --color-success: #1a7f37; + --code-text: #1f2328; + --code-comment: #59636e; + --code-keyword: #cf222e; + --code-string: #0a3069; + --code-number: #0550ae; + --code-function: #8250df; + --code-class: #953800; + color-scheme: light; +} + +html[data-theme="dark"], +html[data-theme="light"] { + --pst-font-family-base: var(--font-sans); + --pst-font-family-monospace: var(--font-mono); + --pst-color-background: var(--color-bg-base); + --pst-color-surface: var(--color-bg-surface); + --pst-color-on-surface: var(--color-bg-raised); + --pst-color-on-background: var(--color-bg-overlay); + --pst-color-text-base: var(--color-text-primary); + --pst-color-text-muted: var(--color-text-secondary); + --pst-color-border: var(--color-border-subtle); + --pst-color-primary: var(--color-accent); + --pst-color-primary-highlight: var(--color-accent-hover); + --pst-color-info: var(--color-info); + --pst-color-warning: var(--color-warning); + --pst-color-danger: var(--color-danger); + --pst-color-success: var(--color-success); +} + +@media (prefers-color-scheme: light) { + html[data-theme="auto"] { + --color-bg-base: #ffffff; + --color-bg-surface: #f6f8fa; + --color-bg-raised: #ffffff; + --color-bg-overlay: #ffffff; + --color-bg-inline-code: #eff1f3; + --color-text-primary: #1f2328; + --color-text-secondary: #59636e; + --color-text-tertiary: #6e7781; + --color-text-inverse: #ffffff; + --color-border-subtle: #e8ebee; + --color-border-default: #d1d9e0; + --color-border-strong: #b6bfc8; + --color-accent: #0e7c86; + --color-accent-hover: #0a626b; + --color-accent-active: #084e55; + --color-accent-subtle: rgba(14, 124, 134, 0.1); + --color-info: #0969da; + --color-warning: #9a6700; + --color-danger: #cf222e; + --color-success: #1a7f37; + --code-text: #1f2328; + --code-comment: #59636e; + --code-keyword: #cf222e; + --code-string: #0a3069; + --code-number: #0550ae; + --code-function: #8250df; + --code-class: #953800; + color-scheme: light; + } +} + +html[data-theme="auto"] { + --pst-font-family-base: var(--font-sans); + --pst-font-family-monospace: var(--font-mono); + --pst-color-background: var(--color-bg-base); + --pst-color-surface: var(--color-bg-surface); + --pst-color-on-surface: var(--color-bg-raised); + --pst-color-on-background: var(--color-bg-overlay); + --pst-color-text-base: var(--color-text-primary); + --pst-color-text-muted: var(--color-text-secondary); + --pst-color-border: var(--color-border-subtle); + --pst-color-primary: var(--color-accent); + --pst-color-primary-highlight: var(--color-accent-hover); + --pst-color-info: var(--color-info); + --pst-color-warning: var(--color-warning); + --pst-color-danger: var(--color-danger); + --pst-color-success: var(--color-success); +} diff --git a/docs/_static/js/site.js b/docs/_static/js/site.js new file mode 100644 index 0000000..c6a67d1 --- /dev/null +++ b/docs/_static/js/site.js @@ -0,0 +1,211 @@ +(() => { + "use strict"; + + function initializeLiveCode() { + document.addEventListener("click", (event) => { + const drawerClose = event.target.closest?.(".drawer-close"); + if (!drawerClose) return; + event.preventDefault(); + event.stopImmediatePropagation(); + drawerClose.closest("dialog")?.removeAttribute("open"); + }, true); + + const stage = document.querySelector(".hero-code-stage"); + const source = stage?.querySelector(".hero-live-code pre"); + if (!stage || !source) return; + + const pageTitle = document.querySelector(".bd-article > section > h1"); + const heroCopy = document.querySelector(".hero-copy"); + if (pageTitle && heroCopy) { + heroCopy.children[0].after(pageTitle); + const ctaRow = heroCopy.querySelector(":scope > p:last-child"); + for (const node of ctaRow?.childNodes || []) { + if (node.nodeType === Node.TEXT_NODE && node.textContent.includes("·")) { + node.remove(); + } + } + const primaryCta = ctaRow?.querySelector("a:first-child"); + primaryCta?.insertAdjacentHTML( + "beforeend", + '', + ); + } + stage.querySelector(":scope > ul")?.remove(); + + const workflow = document.querySelector(".workflow-strip"); + if (workflow) { + workflow.insertAdjacentHTML( + "beforeend", + `
+
generateobservables
+ +
Runtime.observe()values
+ +
ValueSinkoptional storage
+
`, + ); + } + + const installSection = document.querySelector("section#install"); + const coreSection = document.querySelector("section#core-data-flow"); + if (installSection && coreSection) { + const details = document.createElement("div"); + details.className = "home-details-grid"; + installSection.before(details); + details.append(installSection, coreSection); + } + + const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const isChinese = document.documentElement.lang.startsWith("zh"); + const labels = isChinese + ? { running: "运行中", paused: "已暂停", pause: "暂停", play: "播放", hint: "悬停可加速 · 空格键暂停/播放" } + : { running: "running", paused: "paused", pause: "Pause", play: "Play", hint: "Hover to speed up · Space pauses/plays" }; + const series = { + param_norm: [2.31, 2.36, 2.41, 2.38, 2.46, 2.52, 2.49, 2.57], + observed_values: [12, 14, 13, 17, 18, 21, 20, 24], + }; + + const header = document.createElement("div"); + header.className = "live-code-head"; + header.innerHTML = `train.py — ${labels.running}step 0`; + + const metrics = document.createElement("div"); + metrics.className = "live-code-metrics"; + for (const name of Object.keys(series)) { + const row = document.createElement("div"); + row.className = "live-code-metric"; + row.innerHTML = `${name}`; + metrics.append(row); + } + + const footer = document.createElement("div"); + footer.className = "live-code-foot"; + footer.innerHTML = `${labels.hint}`; + + const originalLines = source.innerHTML.replace(/\n$/, "").split("\n"); + source.innerHTML = ""; + const lines = originalLines.map((line, index) => { + const element = document.createElement("span"); + element.className = "live-code-line"; + element.dataset.line = String(index + 1); + element.innerHTML = line || " "; + source.append(element); + return element; + }); + + stage.prepend(header); + source.closest(".hero-live-code").after(metrics, footer); + + const stateLabel = header.querySelector("[data-live-state]"); + const stepLabel = header.querySelector("[data-live-step]"); + const toggle = footer.querySelector("[data-live-toggle]"); + const observeLine = Math.max(0, lines.findIndex((line) => line.textContent.includes("runtime.observe"))); + let activeLine = reducedMotion ? observeLine : 0; + let pointCount = reducedMotion ? series.param_norm.length : 1; + let step = reducedMotion ? 700 : 0; + let paused = reducedMotion; + let timer = 0; + let fast = false; + + function cssColor(name) { + return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + } + + function draw(canvas, values, count) { + const ratio = window.devicePixelRatio || 1; + const width = Math.max(80, canvas.clientWidth); + const height = Math.max(24, canvas.clientHeight); + canvas.width = Math.round(width * ratio); + canvas.height = Math.round(height * ratio); + const context = canvas.getContext("2d"); + context.scale(ratio, ratio); + context.clearRect(0, 0, width, height); + + const visible = values.slice(0, Math.max(1, count)); + const min = Math.min(...values); + const max = Math.max(...values); + const range = max - min || 1; + context.beginPath(); + visible.forEach((value, index) => { + const x = values.length === 1 ? width / 2 : (index / (values.length - 1)) * width; + const y = height - 4 - ((value - min) / range) * (height - 8); + if (index === 0) context.moveTo(x, y); + else context.lineTo(x, y); + }); + context.strokeStyle = cssColor("--color-accent"); + context.lineWidth = 1.5; + context.stroke(); + } + + function renderMetrics() { + for (const [name, values] of Object.entries(series)) { + const count = Math.min(pointCount, values.length); + const value = values[count - 1]; + const formatted = name === "param_norm" ? value.toFixed(2) : String(value); + metrics.querySelector(`[data-value="${name}"]`).textContent = formatted; + draw(metrics.querySelector(`[data-series="${name}"]`), values, count); + } + } + + function render() { + lines.forEach((line, index) => line.classList.toggle("is-active", index === activeLine)); + stepLabel.textContent = `step ${step}`; + stateLabel.textContent = paused ? labels.paused : labels.running; + stage.classList.toggle("is-paused", paused); + toggle.textContent = paused ? labels.play : labels.pause; + toggle.setAttribute("aria-pressed", String(paused)); + renderMetrics(); + } + + function schedule() { + window.clearTimeout(timer); + if (paused) return; + timer = window.setTimeout(advance, fast ? 220 : 700); + } + + function advance() { + activeLine = (activeLine + 1) % lines.length; + if (activeLine === observeLine) { + step += 100; + pointCount = Math.min(pointCount + 1, series.param_norm.length); + } + render(); + schedule(); + } + + function setPaused(next) { + paused = next; + render(); + schedule(); + } + + toggle.addEventListener("click", () => setPaused(!paused)); + stage.addEventListener("mouseenter", () => { + fast = true; + schedule(); + }); + stage.addEventListener("mouseleave", () => { + fast = false; + schedule(); + }); + document.addEventListener("keydown", (event) => { + const tag = event.target instanceof Element ? event.target.tagName : ""; + if (event.code !== "Space" || ["INPUT", "TEXTAREA", "SELECT", "BUTTON"].includes(tag)) return; + const rect = stage.getBoundingClientRect(); + if (rect.bottom < 0 || rect.top > window.innerHeight) return; + event.preventDefault(); + setPaused(!paused); + }); + window.addEventListener("resize", renderMetrics); + new MutationObserver(renderMetrics).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); + + render(); + schedule(); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initializeLiveCode, { once: true }); + } else { + initializeLiveCode(); + } +})(); diff --git a/docs/_templates/components/language-switcher.html b/docs/_templates/components/language-switcher.html new file mode 100644 index 0000000..08cb3a7 --- /dev/null +++ b/docs/_templates/components/language-switcher.html @@ -0,0 +1,9 @@ + diff --git a/docs/_templates/components/navbar-logo.html b/docs/_templates/components/navbar-logo.html new file mode 100644 index 0000000..30bb9c6 --- /dev/null +++ b/docs/_templates/components/navbar-logo.html @@ -0,0 +1,4 @@ + diff --git a/docs/_templates/components/navbar-nav.html b/docs/_templates/components/navbar-nav.html new file mode 100644 index 0000000..ddbc0d0 --- /dev/null +++ b/docs/_templates/components/navbar-nav.html @@ -0,0 +1,19 @@ + diff --git a/docs/_templates/components/sidebar-nav.html b/docs/_templates/components/sidebar-nav.html new file mode 100644 index 0000000..181baaf --- /dev/null +++ b/docs/_templates/components/sidebar-nav.html @@ -0,0 +1,35 @@ +{% set zh = language and language.startswith("zh") %} +{% set pages = [ + ("index", "概览" if zh else "Overview"), + ("installation", "安装" if zh else "Installation"), + ("quickstart", "快速入门" if zh else "Quickstart"), + ("concepts", "核心概念" if zh else "Core Concepts"), + ("usage", "操作指南" if zh else "How-to Guides"), + ("api", "API 参考" if zh else "API Reference"), + ("limitations", "当前限制" if zh else "Limitations") +] %} + diff --git a/docs/_templates/sections/footer.html b/docs/_templates/sections/footer.html new file mode 100644 index 0000000..09b113c --- /dev/null +++ b/docs/_templates/sections/footer.html @@ -0,0 +1,34 @@ + diff --git a/docs/api.md b/docs/api.md index ce59be1..a8b7a4e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,6 +1,7 @@ +(api-reference)= # API Reference -The names below are available from the top-level `observable_library` package. +The names below are available from the top-level `observable_library` 0.1.0 package. ## Core Types @@ -26,7 +27,7 @@ returns a list of observables. The default uses all 13 registered reductions: `abs_mean`, `nonzero_count`, `positive_fraction`, `negative_fraction`, and `numel`. -`generate()` scans every `model.named_parameters()` entry. It has no public source +M2 scans every `model.named_parameters()` entry. It has no public source allowlist and does not generate activation, gradient, or loss observables. Filter the returned list for a smaller Runtime pack, or use the advanced manual `Observable` API for another source. See the practical usage guide for examples. @@ -56,7 +57,7 @@ are captured. HookSource retains the latest captured value without per-step freshness validation, so run the corresponding forward/backward before each observation. Record a loss explicitly with `record_loss(loss, step)`. -The public contract supports only `selector='all'`. When Runtime reads a `TypedTensor`, it passes +M2 supports only `selector='all'`. When Runtime reads a `TypedTensor`, it passes the complete object in compute context as `typed_tensor` and forwards its `axes`, `stage`, and `provenance` to `ValueSink` metadata. @@ -85,7 +86,7 @@ source lookup. - `generate()` gives generated observables a small positive shape-aware `heuristic`, with a conservative `0.01` ms floor. It is scheduling input only; custom observables should provide explicit `budget_hint` values when a budget - matters. The heuristic is not a calibrated cost-accuracy estimate. + matters. M2 makes no `M3` cost-accuracy claim. `OfflineAnalyzer(observables, source, budget=None, sink=None)` exposes `analyze(step, **context)` and delegates to the same runtime compute path. @@ -101,7 +102,7 @@ The temporal functions operate on explicit numeric histories: - `rolling_std(values, window)` returns the population standard deviation of the latest window. -The `temporal` field on `ObservableSpec` is metadata included in its id. The +The `temporal` field on `ObservableSpec` is metadata included in its id. M2's `Runtime` does not apply a temporal function automatically. ## Storage And Query @@ -114,14 +115,14 @@ It is not a general analysis query engine. Observable has no separate display-name field. `ObservableSpec.id` is the 16-character storage and result key derived from the complete spec. Applications -may derive a readable label from source, transforms, and reduction, but the package does +may derive a readable label from source, transforms, and reduction, but M2 does not query by that label. ## Filter Foundation Subclass `Filter` and implement `apply(observables)`. A filter is also callable. -Compose filters with `&` for intersection and `|` for stable union. -Generation-stage template filters are not implemented. The package includes no built-in +Compose filters with `&` for intersection and `|` for stable union. M3 owns +template filters and their generation pipeline. M2 includes no built-in `BySource` or `ByReduction` filters; user filters operate on an already-generated observable list. diff --git a/docs/concepts.md b/docs/concepts.md new file mode 100644 index 0000000..c39bfbf --- /dev/null +++ b/docs/concepts.md @@ -0,0 +1,44 @@ +(concepts)= +# Concepts + +Observable Library separates **what to compute**, **where tensors come from**, and **when computation runs**. + +## Observable specs and identity + +`ObservableSpec` describes a source, selector, ordered transform chain, reduction, temporal metadata, frequency, and budget hint. Its `id` is a stable 16-character hash of the complete spec. Change an identity field and the id can change. + +`Observable` pairs a spec with a compute callable. `Pack` is the stable iterable container accepted by execution APIs and rejects duplicate ids when materialized by Runtime. + +## Generation + +`generate(model, reductions=..., transforms=...)` enumerates every `model.named_parameters()` entry. For each parameter and requested reduction it creates one observable. + +Generation in 0.1.0 does not discover activations, gradients, or loss values. Add those as hand-written observables and supply an appropriate source. + +Transforms run from left to right before the reduction. The registry validates transform and reduction names during generation; tensor compatibility is checked only when the operation executes. + +## Sources + +All sources implement the `TensorSource` protocol: + +- `HookSource` reads live parameters and captured activations, gradients, and explicitly recorded losses. +- `FileSource` reads arrays by key from an NPZ file. +- `CheckpointSource` reads `param.*` values from a Torch state dict. + +Source values are wrapped in `TypedTensor`, which can carry axes, stage, and provenance metadata. + +## Runtime and scheduling + +`Runtime.observe()` computes every eligible observable for a step and returns a dictionary keyed by spec id. Within one call, Runtime caches each source lookup so multiple reductions can share one tensor read. + +`frequency` controls step eligibility. `Budget(max_compute_ms=...)` limits the sum of declared `budget_hint["compute_ms"]` estimates. The scheduler does not measure wall-clock execution time or recalibrate estimates. + +`OfflineAnalyzer` delegates to the same runtime path through `analyze(step, **context)`. + +## Storage + +`ValueSink` is the storage contract. `LocalStorage` is one optional implementation using SQLite metadata and NPZ payload files. `query()` supports exact observable id and step readback only; it is not a general analytics query engine. + +## Selection and temporal functions + +Observable Library 0.1.0 supports only `selector="all"`. The `delta`, `ema`, `slope`, and `rolling_std` functions operate on explicit numeric histories. A spec's `temporal` field contributes to identity but Runtime does not apply a temporal function automatically. diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..e433055 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import os +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +project = "Observable Library" +author = "Jinxin" +copyright = "2026, Observable Library contributors" +version = "0.1.0" +release = version + +extensions = [ + "myst_parser", + "sphinx.ext.autodoc", + "sphinx.ext.autosummary", + "sphinx_design", + "sphinx_copybutton", +] + +source_suffix = {".md": "markdown", ".rst": "restructuredtext"} +root_doc = "index" +exclude_patterns = ["_build", "superpowers", "Thumbs.db", ".DS_Store"] +nitpicky = True + +language = os.environ.get("DOCS_LANGUAGE", "en") +locale_dirs = ["locale/"] +gettext_compact = False +gettext_additional_targets = ["literal-block"] + +myst_enable_extensions = ["colon_fence", "deflist", "fieldlist"] +myst_heading_anchors = 3 + +html_theme = "pydata_sphinx_theme" +html_title = f"{project} {version}" +html_baseurl = "https://metacircleai.github.io/Observable-Library/" +templates_path = ["_templates"] +html_static_path = ["_static"] +html_css_files = ["css/tokens.css", "css/site.css"] +html_js_files = ["js/site.js"] +html_sidebars = {"**": []} +html_context = {"default_mode": "auto"} +html_theme_options = { + "show_toc_level": 2, + "navigation_with_keys": True, + "navbar_align": "left", + "navbar_start": ["components/navbar-logo.html"], + "navbar_center": ["components/navbar-nav.html"], + "navbar_persistent": [ + "components/search-button-field.html", + "components/theme-switcher.html", + ], + "navbar_end": [ + "components/language-switcher.html", + "components/navbar-icon-links.html", + ], + "icon_links": [ + { + "name": "GitHub", + "url": "https://github.com/MetaCircleAI/Observable-Library", + "icon": "fa-brands fa-github", + "type": "fontawesome", + } + ], +} +html_last_updated_fmt = None +html_show_sourcelink = False +html_copy_source = False + +copybutton_prompt_text = r">>> |\.\.\. |\$ " +copybutton_prompt_is_regexp = True diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..842824e --- /dev/null +++ b/docs/index.md @@ -0,0 +1,110 @@ +--- +html_theme.sidebar_primary.remove: true +html_theme.sidebar_secondary.remove: true +--- + +(home)= +# Generate and compute training observables without changing your training loop. + +::::{grid} 1 1 2 2 +:gutter: 4 +:class-container: hero-grid + +:::{grid-item} +:class: hero-copy + +**PyTorch training observables · 0.1.0** + +Observable Library is a small Python package for defining, generating, and computing observables from a running PyTorch training loop. Return values directly or store them for exact id-and-step readback. + +{ref}`Get started ` · {ref}`API reference ` +::: + +:::{grid-item} +:class: hero-code-stage + +```{code-block} python +:class: hero-live-code + +import observable_library as ol + +# You choose the observables. Nothing is inferred automatically. +observables = ol.generate(model, reductions=["l2_norm"]) +runtime = ol.Runtime(observables, source=ol.HookSource(model)) + +if step % 100 == 0: + values = runtime.observe(step=step) +``` + +- `param_norm` · illustrative +- `observed_values` · illustrative +::: +:::: + +:::{container} workflow-strip +`generate` observables → `Runtime.observe()` values → optional `ValueSink` storage +::: + +## Install + +Requires Python 3.10–3.12 and `torch>=2.4.1`. The package is published on PyPI under Apache-2.0. + +```bash +python -m pip install observable-library +``` + +## Core data flow + +You explicitly select the reductions and the steps at which computation happens. Parameter generation does not change the training loop and does not automatically create activation, gradient, or loss observables. + +```python +observables = ol.generate(model, reductions=["l2_norm"]) +runtime = ol.Runtime(observables, source=ol.HookSource(model)) +values = runtime.observe(step=0) +``` + +## Where to go next + +::::{grid} 1 2 2 4 +:gutter: 3 + +:::{grid-item-card} Quickstart +:link: quickstart +:link-type: ref + +Go from installation to the first observed and stored value with a small CPU example. +::: + +:::{grid-item-card} Concepts +:link: concepts +:link-type: ref + +Understand specs, sources, packs, runtime scheduling, storage, and stable ids. +::: + +:::{grid-item-card} How-to Guides +:link: how-to-guides +:link-type: ref + +Use online hooks, custom observables, filters, transforms, reductions, and offline files. +::: + +:::{grid-item-card} API Reference +:link: api-reference +:link-type: ref + +Browse the complete top-level 0.1.0 public API and its current behavior. +::: +:::: + +```{toctree} +:hidden: +:maxdepth: 2 + +installation +quickstart +concepts +usage +api +limitations +``` diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..ac4f713 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,44 @@ +(installation)= +# Installation + +Observable Library 0.1.0 is published on PyPI. + +## Requirements + +- Python 3.10, 3.11, or 3.12 +- `numpy>=1.24` +- `torch>=2.4.1` + +Python 3.13 has an advisory CI lane but is not part of the required support policy for 0.1.0. + +## Install from PyPI + +```bash +python -m pip install observable-library +``` + +Check the installed version: + +```bash +python -c "import observable_library as ol; print(ol.__version__)" +``` + +The expected output is `0.1.0`. + +## Install from a checkout + +```bash +python -m pip install . +``` + +For development, install the test, formatting, lint, and typing tools: + +```bash +python -m pip install -e ".[dev]" +``` + +The package and its documentation are licensed under Apache-2.0. + +## Next step + +Continue to the {ref}`quickstart` for a complete CPU-only example with no dataset download. diff --git a/docs/limitations.md b/docs/limitations.md new file mode 100644 index 0000000..4fcc5f1 --- /dev/null +++ b/docs/limitations.md @@ -0,0 +1,34 @@ +(limitations)= +# Limitations + +Observable Library 0.1.0 is a deliberately small, headless package. These boundaries are part of the released behavior. + +## Generation is parameter-only + +`generate()` enumerates `model.named_parameters()`. It has no source allowlist and does not generate activation, gradient, or loss observables. Filter the returned list to reduce Runtime work or construct a custom `Observable` for another source. + +## Selection supports only all values + +The only supported selector is `selector="all"`. Slice, axis, and semantic selectors are not part of 0.1.0. + +## Hooks retain the latest value + +`HookSource.attach()` installs broad hooks on top-level child activations and trainable parameter gradients. It retains the latest captured tensor without checking that it belongs to the current step. Run the matching forward/backward before every observation and call `detach()` when finished. + +Loss is not discovered automatically. Call `record_loss(loss, step)` before observing a custom `source="loss"` spec. + +## Budgeting uses estimates + +The scheduler sums `budget_hint["compute_ms"]` estimates. It does not measure execution time, learn a cost model, or change a spec's frequency. A missing custom estimate counts as zero. + +## Temporal metadata does not execute + +The `temporal` field contributes to `ObservableSpec.id`, but Runtime does not automatically call `delta`, `ema`, `slope`, or `rolling_std`. Apply those functions to explicit histories. + +## Storage and query are intentionally narrow + +`LocalStorage` is a convenience `ValueSink`: SQLite metadata plus NumPy NPZ payloads. `query()` reads by exact observable id and step only. There is no query by source, reduction, tag, or display label. + +## No application surface + +Version 0.1.0 has no CLI, Web UI, notebook product, dataset downloader, or general analysis service. Later roadmap ideas are not current package capabilities. diff --git a/docs/locale/zh_CN/LC_MESSAGES/404.po b/docs/locale/zh_CN/LC_MESSAGES/404.po new file mode 100644 index 0000000..5937223 --- /dev/null +++ b/docs/locale/zh_CN/LC_MESSAGES/404.po @@ -0,0 +1,25 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2026, Observable Library contributors +# This file is distributed under the same license as the Observable Library package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: Observable Library 0.1.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-22 11:46+0800\n" +"PO-Revision-Date: 2026-07-22 12:00+0800\n" +"Last-Translator: Observable Library contributors\n" +"Language-Team: zh_CN\n" +"Language: zh_CN\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: ../../../docs/404.md:5 +msgid "Page not found" +msgstr "页面未找到" + +#: ../../../docs/404.md:7 +msgid "The requested Observable Library 0.1.0 documentation page does not exist. Return to the [documentation home](index.md)." +msgstr "请求的 Observable Library 0.1.0 文档页面不存在。请返回[文档首页](index.md)。" diff --git a/docs/locale/zh_CN/LC_MESSAGES/api.po b/docs/locale/zh_CN/LC_MESSAGES/api.po new file mode 100644 index 0000000..299f6bc --- /dev/null +++ b/docs/locale/zh_CN/LC_MESSAGES/api.po @@ -0,0 +1,169 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2026, Observable Library contributors +# This file is distributed under the same license as the Observable Library package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: Observable Library 0.1.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-22 11:46+0800\n" +"PO-Revision-Date: 2026-07-22 12:00+0800\n" +"Last-Translator: Observable Library contributors\n" +"Language-Team: zh_CN\n" +"Language: zh_CN\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: ../../../docs/api.md:2 +msgid "API Reference" +msgstr "API 参考" + +#: ../../../docs/api.md:4 +msgid "The names below are available from the top-level `observable_library` 0.1.0 package." +msgstr "以下名称可从顶层 `observable_library` 0.1.0 包导入。" + +#: ../../../docs/api.md:6 +msgid "Core Types" +msgstr "核心类型" + +#: ../../../docs/api.md:8 +msgid "`ObservableSpec(source, selector, transforms=[], reduction=\"\", temporal=None, frequency=1, budget_hint={})` describes one observable. Its stable `id` includes non-default temporal and scheduling fields." +msgstr "`ObservableSpec(source, selector, transforms=[], reduction=\"\", temporal=None, frequency=1, budget_hint={})` 描述一个可观测量。其稳定 `id` 包含非默认 temporal 和调度字段。" + +#: ../../../docs/api.md:12 +msgid "`Observable(spec, compute, tags=set())` pairs a spec with a callable that accepts the source tensor mapping and runtime context. Manual `Observable` construction is an advanced API; `Pack(observables)` is the stable iterable container passed to execution APIs." +msgstr "`Observable(spec, compute, tags=set())` 将 spec 与接受 source 张量映射和 runtime context 的 callable 组合起来。手工构造 `Observable` 属于高级 API;`Pack(observables)` 是传给执行 API 的稳定可迭代容器。" + +#: ../../../docs/api.md:17 +msgid "`TypedTensor(value, axes, stage=\"\", provenance={})` carries a tensor with axis, stage, and provenance metadata. `__version__` reports the installed package version." +msgstr "`TypedTensor(value, axes, stage=\"\", provenance={})` 携带张量以及 axis、stage 和 provenance 元数据。`__version__` 返回已安装包版本。" + +#: ../../../docs/api.md:21 +msgid "Generation And Registry" +msgstr "生成与 Registry" + +#: ../../../docs/api.md:23 +msgid "`generate` is the canonical API for normal instrumentation. `generate(model, reductions=..., transforms=...)` inspects model parameters and returns a list of observables. The default uses all 13 registered reductions: `sum`, `mean`, `l1_norm`, `l2_norm`, `max`, `min`, `std`, `variance`, `abs_mean`, `nonzero_count`, `positive_fraction`, `negative_fraction`, and `numel`." +msgstr "`generate` 是常规 instrumentation 的规范 API。`generate(model, reductions=..., transforms=...)` 检查模型参数并返回可观测量列表。默认使用全部 13 个已注册 reduction:`sum`、`mean`、`l1_norm`、`l2_norm`、`max`、`min`、`std`、`variance`、`abs_mean`、`nonzero_count`、`positive_fraction`、`negative_fraction` 和 `numel`。" + +#: ../../../docs/api.md:30 +msgid "M2 scans every `model.named_parameters()` entry. It has no public source allowlist and does not generate activation, gradient, or loss observables. Filter the returned list for a smaller Runtime pack, or use the advanced manual `Observable` API for another source. See the practical usage guide for examples." +msgstr "M2 扫描每个 `model.named_parameters()` 条目。它没有公共 source allowlist,也不生成 activation、gradient 或 loss 可观测量。过滤返回列表可得到更小的 Runtime pack;其他 source 请使用高级手工 `Observable` API。示例见操作指南。" + +#: ../../../docs/api.md:35 +msgid "Transforms execute in the supplied order before the reduction. Thus `transforms=[\"a\", \"b\"]` means `b(a(tensor))`; no subsets or permutations are generated. Registered names are resolved during generation. Tensor rank, shape, dtype, and device compatibility remain the transform author's responsibility and are checked only by the actual operation at runtime." +msgstr "Transform 按给定顺序在 reduction 前执行。因此 `transforms=[\"a\", \"b\"]` 表示 `b(a(tensor))`;不会生成子集或排列。注册名称在生成期间解析。张量 rank、shape、dtype 和 device 兼容性仍由 transform 作者负责,只在运行时由实际操作检查。" + +#: ../../../docs/api.md:41 +msgid "`register_transform(name)` and `register_reduction(name)` are decorators for callable extensions. `get_transform(name)` and `get_reduction(name)` retrieve registered callables." +msgstr "`register_transform(name)` 和 `register_reduction(name)` 是 callable 扩展装饰器。`get_transform(name)` 和 `get_reduction(name)` 获取已注册 callable。" + +#: ../../../docs/api.md:45 +msgid "Tensor Sources" +msgstr "张量 Sources" + +#: ../../../docs/api.md:47 +msgid "`TensorSource` is the protocol `get(source_id, step) -> TypedTensor`." +msgstr "`TensorSource` 是 protocol `get(source_id, step) -> TypedTensor`。" + +#: ../../../docs/api.md:49 +msgid "`HookSource(model)` supplies online parameters, activations, gradients, and recorded losses. Call `attach()` before the training work and `detach()` when finished. `FileSource(path)` reads tensors by key from an NPZ file. `CheckpointSource(path)` reads `param.*` sources from a Torch state dict." +msgstr "`HookSource(model)` 提供在线参数、activation、gradient 和已记录 loss。训练工作前调用 `attach()`,结束后调用 `detach()`。`FileSource(path)` 按 key 从 NPZ 文件读取张量。`CheckpointSource(path)` 从 Torch state dict 读取 `param.*` source。" + +#: ../../../docs/api.md:54 +msgid "Parameter reads are lazy and do not require `attach()`. Current hook attachment is broad: all top-level child activations and all trainable parameter gradients are captured. HookSource retains the latest captured value without per-step freshness validation, so run the corresponding forward/backward before each observation. Record a loss explicitly with `record_loss(loss, step)`." +msgstr "参数读取是惰性的,不需要 `attach()`。当前 hook attachment 范围较广:会捕获所有顶层子模块 activation 和所有可训练参数 gradient。HookSource 保留最近捕获的值,不验证每个 step 的新鲜度,因此每次观测前都要运行对应的 forward/backward。使用 `record_loss(loss, step)` 显式记录 loss。" + +#: ../../../docs/api.md:60 +msgid "M2 supports only `selector='all'`. When Runtime reads a `TypedTensor`, it passes the complete object in compute context as `typed_tensor` and forwards its `axes`, `stage`, and `provenance` to `ValueSink` metadata." +msgstr "M2 只支持 `selector='all'`。Runtime 读取 `TypedTensor` 时,会在 compute context 中以 `typed_tensor` 传入完整对象,并将其 `axes`、`stage` 和 `provenance` 转发到 `ValueSink` 元数据。" + +#: ../../../docs/api.md:64 +msgid "Execution And Budget" +msgstr "执行与 Budget" + +#: ../../../docs/api.md:66 +msgid "`Runtime(observables, tensors=None, source=None, budget=None, sink=None)` computes each eligible observable when `observe(step, **context)` is called. Pass either a tensor mapping or a `TensorSource`; a configured `ValueSink` receives each value, and the same values are returned as a dictionary." +msgstr "调用 `observe(step, **context)` 时,`Runtime(observables, tensors=None, source=None, budget=None, sink=None)` 计算每个符合条件的可观测量。传入张量映射或 `TensorSource`;配置的 `ValueSink` 会收到每个值,同一组值也以字典返回。" + +#: ../../../docs/api.md:71 +msgid "Generated and hand-written observables can be placed in the same list or `Pack`. Runtime materializes the iterable and rejects duplicate spec ids. Within one observation it caches each source id, so multiple reductions share one source lookup." +msgstr "生成和手写可观测量可以放入同一列表或 `Pack`。Runtime 将可迭代对象物化,并拒绝重复 spec id。一次观测内会缓存每个 source id,使多个 reduction 共享一次 source lookup。" + +#: ../../../docs/api.md:76 +msgid "`Budget(max_compute_ms=None)` limits the sum of estimated observable cost per `observe()` call. Scheduling uses these `ObservableSpec` fields:" +msgstr "`Budget(max_compute_ms=None)` 限制每次 `observe()` 调用的可观测量估算成本总和。调度使用以下 `ObservableSpec` 字段:" + +#: ../../../docs/api.md:79 +msgid "`frequency` must be positive. A spec runs only when `step % frequency == 0`; the default is every step." +msgstr "`frequency` 必须为正。只有 `step % frequency == 0` 时 spec 才运行;默认每个 step 都运行。" + +#: ../../../docs/api.md:81 +msgid "`budget_hint` may contain a `compute_ms` estimate. The estimate must be finite and non-negative; a missing hint counts as zero." +msgstr "`budget_hint` 可以包含 `compute_ms` 估算。估算值必须有限且非负;缺失 hint 按零计算。" + +#: ../../../docs/api.md:83 +msgid "`max_compute_ms` must be finite and non-negative when set. An observable is skipped when its estimate would exceed the remaining budget. The scheduler does not measure execution time or change a spec's frequency." +msgstr "设置 `max_compute_ms` 时,其值必须有限且非负。当可观测量估算值超过剩余 budget 时会被跳过。调度器不测量执行时间,也不改变 spec 的 frequency。" + +#: ../../../docs/api.md:86 +msgid "`generate()` gives generated observables a small positive shape-aware `heuristic`, with a conservative `0.01` ms floor. It is scheduling input only; custom observables should provide explicit `budget_hint` values when a budget matters. M2 makes no `M3` cost-accuracy claim." +msgstr "`generate()` 为生成的可观测量提供小的正数 shape-aware `heuristic`,保守下限为 `0.01` ms。它只用于调度输入;budget 重要时,自定义可观测量应提供显式 `budget_hint`。M2 不作 `M3` 成本准确性声明。" + +#: ../../../docs/api.md:91 +msgid "`OfflineAnalyzer(observables, source, budget=None, sink=None)` exposes `analyze(step, **context)` and delegates to the same runtime compute path." +msgstr "`OfflineAnalyzer(observables, source, budget=None, sink=None)` 公开 `analyze(step, **context)`,并委托给同一条 runtime 计算路径。" + +#: ../../../docs/api.md:94 +msgid "Temporal Operators" +msgstr "Temporal 运算符" + +#: ../../../docs/api.md:96 +msgid "The temporal functions operate on explicit numeric histories:" +msgstr "Temporal 函数对显式数值历史运行:" + +#: ../../../docs/api.md:98 +msgid "`delta(values, lag=1)` returns the latest value minus the value at `lag`." +msgstr "`delta(values, lag=1)` 返回最新值减去 `lag` 位置的值。" + +#: ../../../docs/api.md:99 +msgid "`ema(values, alpha)` returns the exponential moving average for `0 < alpha <= 1`." +msgstr "`ema(values, alpha)` 返回 `0 < alpha <= 1` 时的指数移动平均。" + +#: ../../../docs/api.md:101 +msgid "`slope(values)` returns the least-squares slope over equally spaced samples." +msgstr "`slope(values)` 返回等间隔样本上的最小二乘斜率。" + +#: ../../../docs/api.md:102 +msgid "`rolling_std(values, window)` returns the population standard deviation of the latest window." +msgstr "`rolling_std(values, window)` 返回最近窗口的总体标准差。" + +#: ../../../docs/api.md:105 +msgid "The `temporal` field on `ObservableSpec` is metadata included in its id. M2's `Runtime` does not apply a temporal function automatically." +msgstr "`ObservableSpec` 上的 `temporal` 字段是包含在 id 中的元数据。M2 的 `Runtime` 不会自动应用 temporal 函数。" + +#: ../../../docs/api.md:108 +msgid "Storage And Query" +msgstr "存储与查询" + +#: ../../../docs/api.md:110 +msgid "`ValueSink` is the callable protocol `(observable_id, step, value, meta) -> None`. `LocalStorage(root)` is an optional implementation using SQLite metadata and NumPy NPZ array payloads. `query(storage, observable_id, step)` performs the supported id/step readback. It is not a general analysis query engine." +msgstr "`ValueSink` 是 callable protocol `(observable_id, step, value, meta) -> None`。`LocalStorage(root)` 是使用 SQLite 元数据和 NumPy NPZ 数组 payload 的可选实现。`query(storage, observable_id, step)` 执行受支持的 id/step 读回;它不是通用分析查询引擎。" + +#: ../../../docs/api.md:116 +msgid "Observable has no separate display-name field. `ObservableSpec.id` is the 16-character storage and result key derived from the complete spec. Applications may derive a readable label from source, transforms, and reduction, but M2 does not query by that label." +msgstr "Observable 没有单独的 display-name 字段。`ObservableSpec.id` 是从完整 spec 派生的 16 字符存储和结果键。应用可以从 source、transforms 和 reduction 派生可读标签,但 M2 不按该标签查询。" + +#: ../../../docs/api.md:121 +msgid "Filter Foundation" +msgstr "Filter 基础" + +#: ../../../docs/api.md:123 +msgid "Subclass `Filter` and implement `apply(observables)`. A filter is also callable. Compose filters with `&` for intersection and `|` for stable union. M3 owns template filters and their generation pipeline. M2 includes no built-in `BySource` or `ByReduction` filters; user filters operate on an already-generated observable list." +msgstr "继承 `Filter` 并实现 `apply(observables)`。Filter 本身也可调用。用 `&` 组合取交集,用 `|` 组合取稳定并集。Template filter 及其生成 pipeline 属于 M3。M2 不包含内置 `BySource` 或 `ByReduction` filter;用户 filter 作用于已经生成的可观测量列表。" + +#: ../../../docs/api.md:129 +msgid "For executable patterns and current limitations, read [`usage.md`](usage.md)." +msgstr "可执行模式和当前限制见[`usage.md`](usage.md)。" diff --git a/docs/locale/zh_CN/LC_MESSAGES/concepts.po b/docs/locale/zh_CN/LC_MESSAGES/concepts.po new file mode 100644 index 0000000..7884f70 --- /dev/null +++ b/docs/locale/zh_CN/LC_MESSAGES/concepts.po @@ -0,0 +1,109 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2026, Observable Library contributors +# This file is distributed under the same license as the Observable Library package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: Observable Library 0.1.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-22 11:46+0800\n" +"PO-Revision-Date: 2026-07-22 12:00+0800\n" +"Last-Translator: Observable Library contributors\n" +"Language-Team: zh_CN\n" +"Language: zh_CN\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: ../../../docs/concepts.md:2 +msgid "Concepts" +msgstr "核心概念" + +#: ../../../docs/concepts.md:4 +msgid "Observable Library separates **what to compute**, **where tensors come from**, and **when computation runs**." +msgstr "Observable Library 将**计算什么**、**张量来自哪里**和**何时执行计算**分开处理。" + +#: ../../../docs/concepts.md:6 +msgid "Observable specs and identity" +msgstr "可观测量 spec 与 identity" + +#: ../../../docs/concepts.md:8 +msgid "`ObservableSpec` describes a source, selector, ordered transform chain, reduction, temporal metadata, frequency, and budget hint. Its `id` is a stable 16-character hash of the complete spec. Change an identity field and the id can change." +msgstr "`ObservableSpec` 描述 source、selector、有序 transform 链、reduction、temporal 元数据、frequency 和 budget hint。它的 `id` 是完整 spec 的稳定 16 字符哈希。修改 identity 字段可能改变 id。" + +#: ../../../docs/concepts.md:10 +msgid "`Observable` pairs a spec with a compute callable. `Pack` is the stable iterable container accepted by execution APIs and rejects duplicate ids when materialized by Runtime." +msgstr "`Observable` 把 spec 与 compute callable 组合起来。`Pack` 是执行 API 接受的稳定可迭代容器;Runtime 将其物化时会拒绝重复 id。" + +#: ../../../docs/concepts.md:12 +msgid "Generation" +msgstr "生成" + +#: ../../../docs/concepts.md:14 +msgid "`generate(model, reductions=..., transforms=...)` enumerates every `model.named_parameters()` entry. For each parameter and requested reduction it creates one observable." +msgstr "`generate(model, reductions=..., transforms=...)` 枚举 `model.named_parameters()` 的每个条目,并为每个参数和请求的 reduction 创建一个可观测量。" + +#: ../../../docs/concepts.md:16 +msgid "Generation in 0.1.0 does not discover activations, gradients, or loss values. Add those as hand-written observables and supply an appropriate source." +msgstr "0.1.0 的生成过程不会发现 activation、gradient 或 loss 值。请手写这些可观测量,并提供合适的 source。" + +#: ../../../docs/concepts.md:18 +msgid "Transforms run from left to right before the reduction. The registry validates transform and reduction names during generation; tensor compatibility is checked only when the operation executes." +msgstr "Transform 在 reduction 之前从左到右运行。Registry 在生成时验证 transform 和 reduction 名称;张量兼容性只在操作实际执行时检查。" + +#: ../../../docs/concepts.md:20 +msgid "Sources" +msgstr "数据源" + +#: ../../../docs/concepts.md:22 +msgid "All sources implement the `TensorSource` protocol:" +msgstr "所有 source 都实现 `TensorSource` protocol:" + +#: ../../../docs/concepts.md:24 +msgid "`HookSource` reads live parameters and captured activations, gradients, and explicitly recorded losses." +msgstr "`HookSource` 读取实时参数,以及捕获的 activation、gradient 和显式记录的 loss。" + +#: ../../../docs/concepts.md:25 +msgid "`FileSource` reads arrays by key from an NPZ file." +msgstr "`FileSource` 按 key 从 NPZ 文件读取数组。" + +#: ../../../docs/concepts.md:26 +msgid "`CheckpointSource` reads `param.*` values from a Torch state dict." +msgstr "`CheckpointSource` 从 Torch state dict 读取 `param.*` 值。" + +#: ../../../docs/concepts.md:28 +msgid "Source values are wrapped in `TypedTensor`, which can carry axes, stage, and provenance metadata." +msgstr "Source 值封装在 `TypedTensor` 中,可携带 axes、stage 和 provenance 元数据。" + +#: ../../../docs/concepts.md:30 +msgid "Runtime and scheduling" +msgstr "Runtime 与调度" + +#: ../../../docs/concepts.md:32 +msgid "`Runtime.observe()` computes every eligible observable for a step and returns a dictionary keyed by spec id. Within one call, Runtime caches each source lookup so multiple reductions can share one tensor read." +msgstr "`Runtime.observe()` 计算某个 step 上所有符合条件的可观测量,并返回以 spec id 为键的字典。一次调用内,Runtime 会缓存每个 source lookup,使多个 reduction 共享一次张量读取。" + +#: ../../../docs/concepts.md:34 +msgid "`frequency` controls step eligibility. `Budget(max_compute_ms=...)` limits the sum of declared `budget_hint[\"compute_ms\"]` estimates. The scheduler does not measure wall-clock execution time or recalibrate estimates." +msgstr "`frequency` 控制 step 是否符合条件。`Budget(max_compute_ms=...)` 限制已声明的 `budget_hint[\"compute_ms\"]` 估算值总和。调度器不会测量墙钟执行时间,也不会重新校准估算值。" + +#: ../../../docs/concepts.md:36 +msgid "`OfflineAnalyzer` delegates to the same runtime path through `analyze(step, **context)`." +msgstr "`OfflineAnalyzer` 通过 `analyze(step, **context)` 委托给同一条 runtime 路径。" + +#: ../../../docs/concepts.md:38 +msgid "Storage" +msgstr "存储" + +#: ../../../docs/concepts.md:40 +msgid "`ValueSink` is the storage contract. `LocalStorage` is one optional implementation using SQLite metadata and NPZ payload files. `query()` supports exact observable id and step readback only; it is not a general analytics query engine." +msgstr "`ValueSink` 是存储契约。`LocalStorage` 是一个可选实现,使用 SQLite 元数据和 NPZ payload 文件。`query()` 只支持按精确可观测量 id 和 step 读回;它不是通用分析查询引擎。" + +#: ../../../docs/concepts.md:42 +msgid "Selection and temporal functions" +msgstr "选择与 temporal 函数" + +#: ../../../docs/concepts.md:44 +msgid "Observable Library 0.1.0 supports only `selector=\"all\"`. The `delta`, `ema`, `slope`, and `rolling_std` functions operate on explicit numeric histories. A spec's `temporal` field contributes to identity but Runtime does not apply a temporal function automatically." +msgstr "Observable Library 0.1.0 只支持 `selector=\"all\"`。`delta`、`ema`、`slope` 和 `rolling_std` 函数对显式数值历史运行。Spec 的 `temporal` 字段参与 identity,但 Runtime 不会自动应用 temporal 函数。" diff --git a/docs/locale/zh_CN/LC_MESSAGES/index.po b/docs/locale/zh_CN/LC_MESSAGES/index.po new file mode 100644 index 0000000..2d1ed97 --- /dev/null +++ b/docs/locale/zh_CN/LC_MESSAGES/index.po @@ -0,0 +1,131 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2026, Observable Library contributors +# This file is distributed under the same license as the Observable Library package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: Observable Library 0.1.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-22 11:46+0800\n" +"PO-Revision-Date: 2026-07-22 12:00+0800\n" +"Last-Translator: Observable Library contributors\n" +"Language-Team: zh_CN\n" +"Language: zh_CN\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: ../../../docs/index.md:7 +msgid "Generate and compute training observables without changing your training loop." +msgstr "生成并计算训练可观测量,无需改写训练循环。" + +#: ../../../docs/index.md:16 +msgid "**PyTorch training observables · 0.1.0**" +msgstr "**PyTorch 训练可观测量 · 0.1.0**" + +#: ../../../docs/index.md:18 +msgid "Observable Library is a small Python package for defining, generating, and computing observables from a running PyTorch training loop. Return values directly or store them for exact id-and-step readback." +msgstr "Observable Library 是一个小型 Python 包,用于在运行中的 PyTorch 训练循环里定义、生成和计算可观测量。你可以直接使用返回值,也可以存储后按精确 id 和 step 读回。" + +#: ../../../docs/index.md:20 +msgid "{ref}`Get started ` · {ref}`API reference `" +msgstr "{ref}`快速开始 ` · {ref}`API 参考 `" + +#: ../../../docs/index.md:26 +msgid "" +"import observable_library as ol\n" +"\n" +"# 由你选择可观测量;不会自动推断。\n" +"observables = ol.generate(model, reductions=[\"l2_norm\"])\n" +"runtime = ol.Runtime(observables, source=ol.HookSource(model))\n" +"\n" +"if step % 100 == 0:\n" +" values = runtime.observe(step=step)" +msgstr "" +"import observable_library as ol\n" +"\n" +"# You choose the observables. Nothing is inferred automatically.\n" +"observables = ol.generate(model, reductions=[\"l2_norm\"])\n" +"runtime = ol.Runtime(observables, source=ol.HookSource(model))\n" +"\n" +"if step % 100 == 0:\n" +" values = runtime.observe(step=step)" + +#: ../../../docs/index.md:39 +msgid "`param_norm` · illustrative" +msgstr "`param_norm` · 示意数据" + +#: ../../../docs/index.md:40 +msgid "`observed_values` · illustrative" +msgstr "`observed_values` · 示意数据" + +#: ../../../docs/index.md:45 +msgid "`generate` observables → `Runtime.observe()` values → optional `ValueSink` storage" +msgstr "`generate` 可观测量 → `Runtime.observe()` 计算值 → 可选 `ValueSink` 存储" + +#: ../../../docs/index.md:48 +msgid "Install" +msgstr "安装" + +#: ../../../docs/index.md:50 +msgid "Requires Python 3.10–3.12 and `torch>=2.4.1`. The package is published on PyPI under Apache-2.0." +msgstr "需要 Python 3.10–3.12 和 `torch>=2.4.1`。本包已在 PyPI 发布,采用 Apache-2.0 许可证。" + +#: ../../../docs/index.md:52 +msgid "python -m pip install observable-library\n" +msgstr "python -m pip install observable-library\n" + +#: ../../../docs/index.md:56 +msgid "Core data flow" +msgstr "核心数据流" + +#: ../../../docs/index.md:58 +msgid "You explicitly select the reductions and the steps at which computation happens. Parameter generation does not change the training loop and does not automatically create activation, gradient, or loss observables." +msgstr "你需要显式选择 reduction 和执行计算的 step。参数可观测量生成不会改变训练循环,也不会自动创建 activation、gradient 或 loss 可观测量。" + +#: ../../../docs/index.md:60 +msgid "" +"observables = ol.generate(model, reductions=[\"l2_norm\"])\n" +"runtime = ol.Runtime(observables, source=ol.HookSource(model))\n" +"values = runtime.observe(step=0)\n" +msgstr "" +"observables = ol.generate(model, reductions=[\"l2_norm\"])\n" +"runtime = ol.Runtime(observables, source=ol.HookSource(model))\n" +"values = runtime.observe(step=0)\n" + +#: ../../../docs/index.md:66 +msgid "Where to go next" +msgstr "接下来读什么" + +#: ../../../docs/index.md:71 +msgid "Quickstart" +msgstr "快速开始" + +#: ../../../docs/index.md:75 +msgid "Go from installation to the first observed and stored value with a small CPU example." +msgstr "通过一个小型 CPU 示例,从安装走到第一个计算并存储的观测值。" + +#: ../../../docs/index.md:78 +msgid "Concepts" +msgstr "核心概念" + +#: ../../../docs/index.md:82 +msgid "Understand specs, sources, packs, runtime scheduling, storage, and stable ids." +msgstr "理解 spec、source、pack、运行时调度、存储和稳定 id。" + +#: ../../../docs/index.md:85 +msgid "How-to Guides" +msgstr "操作指南" + +#: ../../../docs/index.md:89 +msgid "Use online hooks, custom observables, filters, transforms, reductions, and offline files." +msgstr "使用在线 hooks、自定义可观测量、filter、transform、reduction 和离线文件。" + +#: ../../../docs/index.md:92 +msgid "API Reference" +msgstr "API 参考" + +#: ../../../docs/index.md:96 +msgid "Browse the complete top-level 0.1.0 public API and its current behavior." +msgstr "浏览 0.1.0 完整的顶层公共 API 及其当前行为。" diff --git a/docs/locale/zh_CN/LC_MESSAGES/installation.po b/docs/locale/zh_CN/LC_MESSAGES/installation.po new file mode 100644 index 0000000..a7303a1 --- /dev/null +++ b/docs/locale/zh_CN/LC_MESSAGES/installation.po @@ -0,0 +1,93 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2026, Observable Library contributors +# This file is distributed under the same license as the Observable Library package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: Observable Library 0.1.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-22 11:46+0800\n" +"PO-Revision-Date: 2026-07-22 12:00+0800\n" +"Last-Translator: Observable Library contributors\n" +"Language-Team: zh_CN\n" +"Language: zh_CN\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: ../../../docs/installation.md:2 +msgid "Installation" +msgstr "安装" + +#: ../../../docs/installation.md:4 +msgid "Observable Library 0.1.0 is published on PyPI." +msgstr "Observable Library 0.1.0 已发布到 PyPI。" + +#: ../../../docs/installation.md:6 +msgid "Requirements" +msgstr "环境要求" + +#: ../../../docs/installation.md:8 +msgid "Python 3.10, 3.11, or 3.12" +msgstr "Python 3.10、3.11 或 3.12" + +#: ../../../docs/installation.md:9 +msgid "`numpy>=1.24`" +msgstr "`numpy>=1.24`" + +#: ../../../docs/installation.md:10 +msgid "`torch>=2.4.1`" +msgstr "`torch>=2.4.1`" + +#: ../../../docs/installation.md:12 +msgid "Python 3.13 has an advisory CI lane but is not part of the required support policy for 0.1.0." +msgstr "Python 3.13 设有 advisory CI 通道,但不属于 0.1.0 的必需支持范围。" + +#: ../../../docs/installation.md:14 +msgid "Install from PyPI" +msgstr "从 PyPI 安装" + +#: ../../../docs/installation.md:16 +msgid "python -m pip install observable-library\n" +msgstr "python -m pip install observable-library\n" + +#: ../../../docs/installation.md:20 +msgid "Check the installed version:" +msgstr "检查已安装版本:" + +#: ../../../docs/installation.md:22 +msgid "python -c \"import observable_library as ol; print(ol.__version__)\"\n" +msgstr "python -c \"import observable_library as ol; print(ol.__version__)\"\n" + +#: ../../../docs/installation.md:26 +msgid "The expected output is `0.1.0`." +msgstr "预期输出为 `0.1.0`。" + +#: ../../../docs/installation.md:28 +msgid "Install from a checkout" +msgstr "从代码 checkout 安装" + +#: ../../../docs/installation.md:30 +msgid "python -m pip install .\n" +msgstr "python -m pip install .\n" + +#: ../../../docs/installation.md:34 +msgid "For development, install the test, formatting, lint, and typing tools:" +msgstr "开发时还需安装测试、格式化、lint 和类型检查工具:" + +#: ../../../docs/installation.md:36 +msgid "python -m pip install -e \".[dev]\"\n" +msgstr "python -m pip install -e \".[dev]\"\n" + +#: ../../../docs/installation.md:40 +msgid "The package and its documentation are licensed under Apache-2.0." +msgstr "本包及其文档采用 Apache-2.0 许可证。" + +#: ../../../docs/installation.md:42 +msgid "Next step" +msgstr "下一步" + +#: ../../../docs/installation.md:44 +msgid "Continue to the {ref}`quickstart` for a complete CPU-only example with no dataset download." +msgstr "继续阅读{ref}`快速开始 `,运行一个无需下载数据集的完整 CPU 示例。" diff --git a/docs/locale/zh_CN/LC_MESSAGES/limitations.po b/docs/locale/zh_CN/LC_MESSAGES/limitations.po new file mode 100644 index 0000000..0624bca --- /dev/null +++ b/docs/locale/zh_CN/LC_MESSAGES/limitations.po @@ -0,0 +1,85 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2026, Observable Library contributors +# This file is distributed under the same license as the Observable Library package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: Observable Library 0.1.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-22 11:46+0800\n" +"PO-Revision-Date: 2026-07-22 12:00+0800\n" +"Last-Translator: Observable Library contributors\n" +"Language-Team: zh_CN\n" +"Language: zh_CN\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: ../../../docs/limitations.md:2 +msgid "Limitations" +msgstr "限制" + +#: ../../../docs/limitations.md:4 +msgid "Observable Library 0.1.0 is a deliberately small, headless package. These boundaries are part of the released behavior." +msgstr "Observable Library 0.1.0 是一个有意保持小巧的 headless 包。以下边界属于已发布行为。" + +#: ../../../docs/limitations.md:6 +msgid "Generation is parameter-only" +msgstr "生成仅覆盖参数" + +#: ../../../docs/limitations.md:8 +msgid "`generate()` enumerates `model.named_parameters()`. It has no source allowlist and does not generate activation, gradient, or loss observables. Filter the returned list to reduce Runtime work or construct a custom `Observable` for another source." +msgstr "`generate()` 枚举 `model.named_parameters()`。它没有 source allowlist,也不生成 activation、gradient 或 loss 可观测量。可以过滤返回列表以减少 Runtime 工作,或为其他 source 构造自定义 `Observable`。" + +#: ../../../docs/limitations.md:10 +msgid "Selection supports only all values" +msgstr "Selection 只支持全部值" + +#: ../../../docs/limitations.md:12 +msgid "The only supported selector is `selector=\"all\"`. Slice, axis, and semantic selectors are not part of 0.1.0." +msgstr "唯一支持的 selector 是 `selector=\"all\"`。Slice、axis 和语义 selector 不属于 0.1.0。" + +#: ../../../docs/limitations.md:14 +msgid "Hooks retain the latest value" +msgstr "Hooks 保留最近的值" + +#: ../../../docs/limitations.md:16 +msgid "`HookSource.attach()` installs broad hooks on top-level child activations and trainable parameter gradients. It retains the latest captured tensor without checking that it belongs to the current step. Run the matching forward/backward before every observation and call `detach()` when finished." +msgstr "`HookSource.attach()` 会在顶层子模块 activation 和可训练参数 gradient 上安装宽范围 hooks。它保留最近捕获的张量,不检查该张量是否属于当前 step。每次观测前都要执行匹配的 forward/backward,结束后调用 `detach()`。" + +#: ../../../docs/limitations.md:18 +msgid "Loss is not discovered automatically. Call `record_loss(loss, step)` before observing a custom `source=\"loss\"` spec." +msgstr "Loss 不会被自动发现。观测自定义 `source=\"loss\"` spec 前,请调用 `record_loss(loss, step)`。" + +#: ../../../docs/limitations.md:20 +msgid "Budgeting uses estimates" +msgstr "Budget 使用估算值" + +#: ../../../docs/limitations.md:22 +msgid "The scheduler sums `budget_hint[\"compute_ms\"]` estimates. It does not measure execution time, learn a cost model, or change a spec's frequency. A missing custom estimate counts as zero." +msgstr "调度器累加 `budget_hint[\"compute_ms\"]` 估算值。它不测量执行时间、不学习成本模型,也不修改 spec 的 frequency。缺失的自定义估算按零计算。" + +#: ../../../docs/limitations.md:24 +msgid "Temporal metadata does not execute" +msgstr "Temporal 元数据不会自动执行" + +#: ../../../docs/limitations.md:26 +msgid "The `temporal` field contributes to `ObservableSpec.id`, but Runtime does not automatically call `delta`, `ema`, `slope`, or `rolling_std`. Apply those functions to explicit histories." +msgstr "`temporal` 字段参与 `ObservableSpec.id`,但 Runtime 不会自动调用 `delta`、`ema`、`slope` 或 `rolling_std`。请将这些函数应用到显式历史数据。" + +#: ../../../docs/limitations.md:28 +msgid "Storage and query are intentionally narrow" +msgstr "存储与查询有意保持窄边界" + +#: ../../../docs/limitations.md:30 +msgid "`LocalStorage` is a convenience `ValueSink`: SQLite metadata plus NumPy NPZ payloads. `query()` reads by exact observable id and step only. There is no query by source, reduction, tag, or display label." +msgstr "`LocalStorage` 是便捷的 `ValueSink`:SQLite 元数据加 NumPy NPZ payload。`query()` 只按精确可观测量 id 和 step 读取,不支持按 source、reduction、tag 或显示标签查询。" + +#: ../../../docs/limitations.md:32 +msgid "No application surface" +msgstr "没有应用层界面" + +#: ../../../docs/limitations.md:34 +msgid "Version 0.1.0 has no CLI, Web UI, notebook product, dataset downloader, or general analysis service. Later roadmap ideas are not current package capabilities." +msgstr "0.1.0 没有 CLI、Web UI、notebook 产品、数据集下载器或通用分析服务。后续路线图中的想法不属于当前包能力。" diff --git a/docs/locale/zh_CN/LC_MESSAGES/quickstart.po b/docs/locale/zh_CN/LC_MESSAGES/quickstart.po new file mode 100644 index 0000000..538f1a5 --- /dev/null +++ b/docs/locale/zh_CN/LC_MESSAGES/quickstart.po @@ -0,0 +1,133 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2026, Observable Library contributors +# This file is distributed under the same license as the Observable Library package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: Observable Library 0.1.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-22 11:46+0800\n" +"PO-Revision-Date: 2026-07-22 12:00+0800\n" +"Last-Translator: Observable Library contributors\n" +"Language-Team: zh_CN\n" +"Language: zh_CN\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: ../../../docs/quickstart.md:2 +msgid "Quickstart" +msgstr "快速开始" + +#: ../../../docs/quickstart.md:4 +msgid "This example generates parameter-norm observables, runs one training step, computes values after backward, stores them locally, and reads one value back." +msgstr "本例生成参数范数可观测量,执行一个训练 step,在 backward 后计算数值,将其存到本地,再读回其中一个值。" + +#: ../../../docs/quickstart.md:6 +msgid "" +"import torch\n" +"import observable_library as ol\n" +"\n" +"torch.manual_seed(0)\n" +"model = torch.nn.Sequential(torch.nn.Linear(2, 1))\n" +"optimizer = torch.optim.SGD(model.parameters(), lr=0.1)\n" +"observables = ol.generate(model, reductions=[\"l2_norm\"])\n" +"\n" +"source = ol.HookSource(model)\n" +"source.attach()\n" +"storage = ol.LocalStorage(\"./run\")\n" +"runtime = ol.Runtime(\n" +" observables=ol.Pack(observables),\n" +" source=source,\n" +" sink=storage,\n" +" budget=ol.Budget(max_compute_ms=10.0),\n" +")\n" +"\n" +"inputs = torch.tensor([[1.0, -1.0], [0.5, 0.25]])\n" +"targets = torch.tensor([[0.5], [-0.25]])\n" +"optimizer.zero_grad()\n" +"predictions = model(inputs)\n" +"loss = torch.nn.functional.mse_loss(predictions, targets)\n" +"loss.backward()\n" +"\n" +"values = runtime.observe(step=0)\n" +"assert values\n" +"observable_id, value = next(iter(values.items()))\n" +"stored_value = ol.query(storage, observable_id, step=0)\n" +"assert stored_value == value\n" +"print(f\"{observable_id}: {stored_value}\")\n" +"\n" +"optimizer.step()\n" +"source.detach()\n" +msgstr "" +"import torch\n" +"import observable_library as ol\n" +"\n" +"torch.manual_seed(0)\n" +"model = torch.nn.Sequential(torch.nn.Linear(2, 1))\n" +"optimizer = torch.optim.SGD(model.parameters(), lr=0.1)\n" +"observables = ol.generate(model, reductions=[\"l2_norm\"])\n" +"\n" +"source = ol.HookSource(model)\n" +"source.attach()\n" +"storage = ol.LocalStorage(\"./run\")\n" +"runtime = ol.Runtime(\n" +" observables=ol.Pack(observables),\n" +" source=source,\n" +" sink=storage,\n" +" budget=ol.Budget(max_compute_ms=10.0),\n" +")\n" +"\n" +"inputs = torch.tensor([[1.0, -1.0], [0.5, 0.25]])\n" +"targets = torch.tensor([[0.5], [-0.25]])\n" +"optimizer.zero_grad()\n" +"predictions = model(inputs)\n" +"loss = torch.nn.functional.mse_loss(predictions, targets)\n" +"loss.backward()\n" +"\n" +"values = runtime.observe(step=0)\n" +"assert values\n" +"observable_id, value = next(iter(values.items()))\n" +"stored_value = ol.query(storage, observable_id, step=0)\n" +"assert stored_value == value\n" +"print(f\"{observable_id}: {stored_value}\")\n" +"\n" +"optimizer.step()\n" +"source.detach()\n" + +#: ../../../docs/quickstart.md:43 +msgid "What happened" +msgstr "发生了什么" + +#: ../../../docs/quickstart.md:45 +msgid "`ol.generate()` inspected `model.named_parameters()` and produced one `l2_norm` observable for each parameter." +msgstr "`ol.generate()` 检查 `model.named_parameters()`,为每个参数生成一个 `l2_norm` 可观测量。" + +#: ../../../docs/quickstart.md:46 +msgid "`HookSource` provided the current parameter tensors. Parameter reads are lazy, so this generated parameter-only example does not technically require hooks; `attach()` is shown because the same lifecycle is required for activation and gradient sources." +msgstr "`HookSource` 提供当前参数张量。参数读取是惰性的,因此这个仅含生成参数的示例严格来说不需要 hooks;这里展示 `attach()`,因为 activation 和 gradient source 需要相同的生命周期。" + +#: ../../../docs/quickstart.md:47 +msgid "`loss.backward()` ran before `Runtime.observe()`. This ordering is required when a custom observable reads gradients." +msgstr "`loss.backward()` 在 `Runtime.observe()` 之前运行。自定义可观测量读取 gradient 时必须保持这一顺序。" + +#: ../../../docs/quickstart.md:48 +msgid "`Runtime.observe()` returned values keyed by each observable's stable 16-character spec id." +msgstr "`Runtime.observe()` 返回以各可观测量稳定的 16 字符 spec id 为键的数值。" + +#: ../../../docs/quickstart.md:49 +msgid "`LocalStorage` wrote SQLite metadata and NumPy NPZ payloads. `query()` read one value back by exact id and step." +msgstr "`LocalStorage` 写入 SQLite 元数据和 NumPy NPZ payload。`query()` 按精确 id 和 step 读回一个值。" + +#: ../../../docs/quickstart.md:51 +msgid "Keep the lifecycle explicit" +msgstr "保持显式生命周期" + +#: ../../../docs/quickstart.md:53 +msgid "For activation or gradient sources, attach before the matching forward/backward work, observe before the optimizer mutates parameters, and always detach when finished. `HookSource` retains the latest captured tensor and does not enforce per-step freshness." +msgstr "对于 activation 或 gradient source,应在对应的 forward/backward 之前 attach,在 optimizer 修改参数之前 observe,并在结束后始终 detach。`HookSource` 保留最近捕获的张量,不强制检查每个 step 的新鲜度。" + +#: ../../../docs/quickstart.md:55 +msgid "Continue with {ref}`concepts` or the task-oriented {ref}`how-to-guides`." +msgstr "继续阅读{ref}`核心概念 `或面向任务的{ref}`操作指南 `。" diff --git a/docs/locale/zh_CN/LC_MESSAGES/usage.po b/docs/locale/zh_CN/LC_MESSAGES/usage.po new file mode 100644 index 0000000..60a0d41 --- /dev/null +++ b/docs/locale/zh_CN/LC_MESSAGES/usage.po @@ -0,0 +1,219 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2026, Observable Library contributors +# This file is distributed under the same license as the Observable Library package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: Observable Library 0.1.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-22 11:46+0800\n" +"PO-Revision-Date: 2026-07-22 12:00+0800\n" +"Last-Translator: Observable Library contributors\n" +"Language-Team: zh_CN\n" +"Language: zh_CN\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: ../../../docs/usage.md:2 +msgid "How-to Guides" +msgstr "操作指南" + +#: ../../../docs/usage.md:4 +msgid "This guide describes the behavior implemented by Observable Library M2. It is written for package users; future Roadmap capabilities are called out explicitly." +msgstr "本指南描述 Observable Library M2 已实现的行为,面向包用户编写;未来路线图能力会被明确标出。" + +#: ../../../docs/usage.md:7 +msgid "Use these recipes after completing the {ref}`quickstart`. Every example stays inside the 0.1.0 public boundary." +msgstr "完成{ref}`快速开始 `后再使用这些示例。每个示例都保持在 0.1.0 公共边界内。" + +#: ../../../docs/usage.md:9 +msgid "What `generate()` Produces" +msgstr "`generate()` 会生成什么" + +#: ../../../docs/usage.md:11 +msgid "`generate(model, reductions=..., transforms=...)` scans every entry returned by `model.named_parameters()`. It produces one observable for every parameter and requested reduction. The same ordered transform chain is attached to each one." +msgstr "`generate(model, reductions=..., transforms=...)` 扫描 `model.named_parameters()` 返回的每个条目,为每个参数和请求的 reduction 生成一个可观测量,并为每个可观测量附加相同的有序 transform 链。" + +#: ../../../docs/usage.md:15 +msgid "M2 does not expose a `sources=` argument and does not generate `activation.*`, `grad.*`, or `loss` observables. Filtering the returned list reduces Runtime work, although parameter metadata has already been enumerated:" +msgstr "M2 不公开 `sources=` 参数,也不生成 `activation.*`、`grad.*` 或 `loss` 可观测量。过滤返回列表可以减少 Runtime 工作,不过参数元数据已经完成枚举:" + +#: ../../../docs/usage.md:19 +msgid "" +"generated = ol.generate(model, reductions=[\"l2_norm\"])\n" +"wanted = {\"param.0.weight\", \"param.2.weight\"}\n" +"selected = [item for item in generated if item.spec.source in wanted]\n" +msgstr "" +"generated = ol.generate(model, reductions=[\"l2_norm\"])\n" +"wanted = {\"param.0.weight\", \"param.2.weight\"}\n" +"selected = [item for item in generated if item.spec.source in wanted]\n" + +#: ../../../docs/usage.md:25 +msgid "M3 owns generation-stage template filters for parameters, gradients, and activations. Its exact source-selection API is not yet part of the public contract." +msgstr "参数、gradient 和 activation 的生成阶段 template filter 属于 M3;其精确的 source 选择 API 尚未成为公共契约。" + +#: ../../../docs/usage.md:29 +msgid "Transform Order And Validation" +msgstr "Transform 顺序与验证" + +#: ../../../docs/usage.md:31 +msgid "Transforms run from left to right. For example, `transforms=[\"center\", \"normalize\"]` executes:" +msgstr "Transform 从左到右运行。例如,`transforms=[\"center\", \"normalize\"]` 执行:" + +#: ../../../docs/usage.md:34 +msgid "normalize(center(tensor)) -> reduction\n" +msgstr "normalize(center(tensor)) -> reduction\n" + +#: ../../../docs/usage.md:38 +msgid "No subsets, reverse order, or permutations are generated. Every transform must be registered before `generate()` is called, and duplicate names are rejected. The registry validates names, not tensor contracts: incompatible rank, shape, dtype, or device assumptions fail when the observable runs." +msgstr "系统不会生成子集、反向顺序或排列。每个 transform 必须在调用 `generate()` 前注册,重复名称会被拒绝。Registry 验证名称而非张量契约;不兼容的 rank、shape、dtype 或 device 假设会在可观测量运行时失败。" + +#: ../../../docs/usage.md:43 +msgid "Generated observables capture the resolved transform and reduction callables. Later registry changes therefore cannot silently change an existing observable's computation." +msgstr "生成的可观测量会捕获已解析的 transform 和 reduction callable,因此后续 registry 变更不能静默改变已有可观测量的计算。" + +#: ../../../docs/usage.md:47 +msgid "Online Source Lifetime" +msgstr "在线 source 生命周期" + +#: ../../../docs/usage.md:49 +msgid "`HookSource.get(\"param.*\", step)` reads the current model parameter directly. Parameter-only generated observables do not need hooks, so `attach()` is optional for that case." +msgstr "`HookSource.get(\"param.*\", step)` 直接读取当前模型参数。仅含参数的生成可观测量不需要 hooks,因此这种情况下 `attach()` 是可选的。" + +#: ../../../docs/usage.md:53 +msgid "`activation.*` and `grad.*` values are produced during forward and backward. Call `source.attach()` before that work and `source.detach()` when finished. Observe gradients after `loss.backward()` and before `optimizer.step()`." +msgstr "`activation.*` 和 `grad.*` 值在 forward 和 backward 期间产生。请在这些操作前调用 `source.attach()`,完成后调用 `source.detach()`。应在 `loss.backward()` 之后、`optimizer.step()` 之前观测 gradients。" + +#: ../../../docs/usage.md:57 +msgid "Current `attach()` behavior is broad: it installs activation hooks on all top-level children and gradient hooks on all trainable parameters. It stores the latest captured tensor and does not track freshness per step. Run the matching forward/backward before every `observe()` call; otherwise a previous value may remain cached. Activation ids use `activation.` and gradient ids use `grad.`." +msgstr "当前 `attach()` 行为范围较广:它在所有顶层子模块上安装 activation hooks,在所有可训练参数上安装 gradient hooks。它存储最近捕获的张量,不跟踪每个 step 的新鲜度。每次调用 `observe()` 前都要运行匹配的 forward/backward,否则旧值可能仍在缓存中。Activation id 使用 `activation.`,gradient id 使用 `grad.`。" + +#: ../../../docs/usage.md:64 +msgid "Loss is not discovered automatically. To use a `source=\"loss\"` observable, call `source.record_loss(loss, step)` first." +msgstr "Loss 不会被自动发现。要使用 `source=\"loss\"` 可观测量,请先调用 `source.record_loss(loss, step)`。" + +#: ../../../docs/usage.md:67 +msgid "Within one `observe()` call, Runtime caches each requested source, so several reductions over the same tensor perform one source lookup." +msgstr "一次 `observe()` 调用内,Runtime 会缓存每个请求的 source,因此同一张量上的多个 reduction 只执行一次 source lookup。" + +#: ../../../docs/usage.md:70 +msgid "Mix Generated And Custom Observables" +msgstr "混合生成与自定义可观测量" + +#: ../../../docs/usage.md:72 +msgid "`generate()` returns `list[Observable]`. Add advanced custom observables before constructing a `Pack`:" +msgstr "`generate()` 返回 `list[Observable]`。构造 `Pack` 前可以加入高级自定义可观测量:" + +#: ../../../docs/usage.md:75 +msgid "" +"generated = ol.generate(model, reductions=[\"l2_norm\"])\n" +"gradient_spec = ol.ObservableSpec(\n" +" source=\"grad.0.weight\",\n" +" selector=\"all\",\n" +" reduction=\"l2_norm\",\n" +" budget_hint={\"compute_ms\": 0.01},\n" +")\n" +"gradient = ol.Observable(\n" +" spec=gradient_spec,\n" +" compute=lambda tensors, _context: tensors[gradient_spec.source].norm(),\n" +")\n" +"runtime = ol.Runtime(ol.Pack([*generated, gradient]), source=source)\n" +msgstr "" +"generated = ol.generate(model, reductions=[\"l2_norm\"])\n" +"gradient_spec = ol.ObservableSpec(\n" +" source=\"grad.0.weight\",\n" +" selector=\"all\",\n" +" reduction=\"l2_norm\",\n" +" budget_hint={\"compute_ms\": 0.01},\n" +")\n" +"gradient = ol.Observable(\n" +" spec=gradient_spec,\n" +" compute=lambda tensors, _context: tensors[gradient_spec.source].norm(),\n" +")\n" +"runtime = ol.Runtime(ol.Pack([*generated, gradient]), source=source)\n" + +#: ../../../docs/usage.md:90 +msgid "Runtime requires every observable id to be unique. A custom observable whose spec is identical to a generated one is rejected. When a `Budget` is active, custom observables should provide a realistic `budget_hint`; a missing estimate counts as zero." +msgstr "Runtime 要求每个可观测量 id 唯一。Spec 与生成项相同的自定义可观测量会被拒绝。启用 `Budget` 时,自定义可观测量应提供现实的 `budget_hint`;缺失估算按零计算。" + +#: ../../../docs/usage.md:95 +msgid "User-Defined Filters" +msgstr "用户自定义 Filters" + +#: ../../../docs/usage.md:97 +msgid "M2 provides the `Filter` base class and `&` / `|` composition, but no built-in `BySource` or `ByReduction` filters. A user-defined filter acts after generation:" +msgstr "M2 提供 `Filter` 基类以及 `&` / `|` 组合,但没有内置 `BySource` 或 `ByReduction` filter。用户自定义 filter 在生成后生效:" + +#: ../../../docs/usage.md:100 +msgid "" +"class ByReduction(ol.Filter):\n" +" def __init__(self, name: str) -> None:\n" +" self.name = name\n" +"\n" +" def apply(self, observables):\n" +" return [\n" +" item for item in observables if item.spec.reduction == self.name\n" +" ]\n" +"\n" +"keep = ByReduction(\"l2_norm\") | ByReduction(\"mean\")\n" +"selected = keep(observables)\n" +msgstr "" +"class ByReduction(ol.Filter):\n" +" def __init__(self, name: str) -> None:\n" +" self.name = name\n" +"\n" +" def apply(self, observables):\n" +" return [\n" +" item for item in observables if item.spec.reduction == self.name\n" +" ]\n" +"\n" +"keep = ByReduction(\"l2_norm\") | ByReduction(\"mean\")\n" +"selected = keep(observables)\n" + +#: ../../../docs/usage.md:114 +msgid "This prevents unselected observables from running, but does not prevent their initial generation. M3 plans true generation-stage template filters." +msgstr "这会阻止未选中的可观测量运行,但不会阻止它们最初被生成。真正的生成阶段 template filter 计划属于 M3。" + +#: ../../../docs/usage.md:117 +msgid "Identity, Results, And Query" +msgstr "Identity、结果与查询" + +#: ../../../docs/usage.md:119 +msgid "There is no separate user-facing name field. `observable.spec.id` is a deterministic 16-character hash derived from the complete spec, including its source, selector, ordered transforms, reduction, temporal settings, frequency, and budget hint. Changing any identity field can change the id." +msgstr "系统没有单独的用户可见 name 字段。`observable.spec.id` 是从完整 spec 确定性派生的 16 字符哈希,覆盖 source、selector、有序 transforms、reduction、temporal 设置、frequency 和 budget hint。修改任何 identity 字段都可能改变 id。" + +#: ../../../docs/usage.md:124 +msgid "`Runtime.observe()` returns `dict[observable_id, value]`. `LocalStorage` stores metadata in SQLite and array payloads in NumPy NPZ files; it and `query()` use the same exact id and step:" +msgstr "`Runtime.observe()` 返回 `dict[observable_id, value]`。`LocalStorage` 在 SQLite 中存储元数据,在 NumPy NPZ 文件中存储数组 payload;它与 `query()` 使用相同的精确 id 和 step:" + +#: ../../../docs/usage.md:128 +msgid "" +"observable = observables[0]\n" +"values = runtime.observe(step=7)\n" +"value = values[observable.spec.id]\n" +"stored = ol.query(storage, observable.spec.id, step=7)\n" +msgstr "" +"observable = observables[0]\n" +"values = runtime.observe(step=7)\n" +"value = values[observable.spec.id]\n" +"stored = ol.query(storage, observable.spec.id, step=7)\n" + +#: ../../../docs/usage.md:135 +msgid "For logs or user interfaces, derive a display label without treating it as the storage key:" +msgstr "用于日志或用户界面时,可以派生显示标签,但不要把它当作存储键:" + +#: ../../../docs/usage.md:138 +msgid "" +"spec = observable.spec\n" +"chain = \" -> \".join((*spec.transforms, spec.reduction))\n" +"label = f\"{spec.source} | {chain}\"\n" +msgstr "" +"spec = observable.spec\n" +"chain = \" -> \".join((*spec.transforms, spec.reduction))\n" +"label = f\"{spec.source} | {chain}\"\n" + +#: ../../../docs/usage.md:144 +msgid "M2 supports exact id/step readback only. Query by source, reduction, tag, or display label belongs to later analysis tooling." +msgstr "M2 只支持按精确 id/step 读回。按 source、reduction、tag 或显示标签查询属于后续分析工具。" diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..b4f54bf --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,55 @@ +(quickstart)= +# Quickstart + +This example generates parameter-norm observables, runs one training step, computes values after backward, stores them locally, and reads one value back. + +```python +import torch +import observable_library as ol + +torch.manual_seed(0) +model = torch.nn.Sequential(torch.nn.Linear(2, 1)) +optimizer = torch.optim.SGD(model.parameters(), lr=0.1) +observables = ol.generate(model, reductions=["l2_norm"]) + +source = ol.HookSource(model) +source.attach() +storage = ol.LocalStorage("./run") +runtime = ol.Runtime( + observables=ol.Pack(observables), + source=source, + sink=storage, + budget=ol.Budget(max_compute_ms=10.0), +) + +inputs = torch.tensor([[1.0, -1.0], [0.5, 0.25]]) +targets = torch.tensor([[0.5], [-0.25]]) +optimizer.zero_grad() +predictions = model(inputs) +loss = torch.nn.functional.mse_loss(predictions, targets) +loss.backward() + +values = runtime.observe(step=0) +assert values +observable_id, value = next(iter(values.items())) +stored_value = ol.query(storage, observable_id, step=0) +assert stored_value == value +print(f"{observable_id}: {stored_value}") + +optimizer.step() +source.detach() +``` + +## What happened + +1. `ol.generate()` inspected `model.named_parameters()` and produced one `l2_norm` observable for each parameter. +2. `HookSource` provided the current parameter tensors. Parameter reads are lazy, so this generated parameter-only example does not technically require hooks; `attach()` is shown because the same lifecycle is required for activation and gradient sources. +3. `loss.backward()` ran before `Runtime.observe()`. This ordering is required when a custom observable reads gradients. +4. `Runtime.observe()` returned values keyed by each observable's stable 16-character spec id. +5. `LocalStorage` wrote SQLite metadata and NumPy NPZ payloads. `query()` read one value back by exact id and step. + +## Keep the lifecycle explicit + +For activation or gradient sources, attach before the matching forward/backward work, observe before the optimizer mutates parameters, and always detach when finished. `HookSource` retains the latest captured tensor and does not enforce per-step freshness. + +Continue with {ref}`concepts` or the task-oriented {ref}`how-to-guides`. diff --git a/docs/usage.md b/docs/usage.md index 61aa6a7..97eaeaf 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,7 +1,10 @@ -# Practical Usage Guide +(how-to-guides)= +# How-to Guides -This guide describes the behavior implemented by the current Observable Library -package. Future capabilities are called out explicitly. +This guide describes the behavior implemented by Observable Library M2. It is +written for package users; future Roadmap capabilities are called out explicitly. + +Use these recipes after completing the {ref}`quickstart`. Every example stays inside the 0.1.0 public boundary. ## What `generate()` Produces @@ -9,7 +12,7 @@ package. Future capabilities are called out explicitly. `model.named_parameters()`. It produces one observable for every parameter and requested reduction. The same ordered transform chain is attached to each one. -`generate()` does not expose a `sources=` argument and does not generate `activation.*`, +M2 does not expose a `sources=` argument and does not generate `activation.*`, `grad.*`, or `loss` observables. Filtering the returned list reduces Runtime work, although parameter metadata has already been enumerated: @@ -19,8 +22,8 @@ wanted = {"param.0.weight", "param.2.weight"} selected = [item for item in generated if item.spec.source in wanted] ``` -Generation-stage template filters for parameters, gradients, and activations -are not implemented. Their exact source-selection API is not part of the public +M3 owns generation-stage template filters for parameters, gradients, and +activations. Its exact source-selection API is not yet part of the public contract. ## Transform Order And Validation @@ -91,7 +94,7 @@ counts as zero. ## User-Defined Filters -The package provides the `Filter` base class and `&` / `|` composition, but no built-in +M2 provides the `Filter` base class and `&` / `|` composition, but no built-in `BySource` or `ByReduction` filters. A user-defined filter acts after generation: ```python @@ -109,7 +112,7 @@ selected = keep(observables) ``` This prevents unselected observables from running, but does not prevent their -initial generation. True generation-stage template filters are planned. +initial generation. M3 plans true generation-stage template filters. ## Identity, Results, And Query @@ -138,5 +141,5 @@ chain = " -> ".join((*spec.transforms, spec.reduction)) label = f"{spec.source} | {chain}" ``` -The package supports exact id/step readback only. Query by source, reduction, tag, or +M2 supports exact id/step readback only. Query by source, reduction, tag, or display label belongs to later analysis tooling. diff --git a/pyproject.toml b/pyproject.toml index 2d5235f..019b406 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,16 @@ Issues = "https://github.com/MetaCircleAI/Observable-Library/issues" [project.optional-dependencies] dev = ["build>=1.2", "mypy>=1.10", "pytest>=8", "ruff>=0.6", "tomli>=2; python_version < '3.11'"] +docs = [ + "babel>=2.16,<3", + "sphinx>=8.2,<9", + "myst-parser>=4,<5", + "pydata-sphinx-theme>=0.19,<0.20", + "sphinx-design>=0.6,<1", + "sphinx-copybutton>=0.5,<1", + "sphinx-intl>=2.3,<3", + "pytest>=8", +] [tool.ruff] target-version = "py310" diff --git a/scripts/build_docs.py b/scripts/build_docs.py new file mode 100644 index 0000000..c0c0554 --- /dev/null +++ b/scripts/build_docs.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import argparse +import os +import shutil +import subprocess +import sys +from pathlib import Path + + +def _run_sphinx(source_dir: Path, output_dir: Path, language: str) -> None: + environment = os.environ.copy() + environment["DOCS_LANGUAGE"] = language + subprocess.run( + [ + sys.executable, + "-m", + "sphinx", + "-b", + "html", + "-W", + "--keep-going", + "-E", + "-a", + str(source_dir), + str(output_dir), + ], + check=True, + env=environment, + ) + + +def build_site(repo_root: Path, output_dir: Path) -> None: + repo_root = repo_root.resolve() + output_dir = output_dir.resolve() + if output_dir == repo_root or output_dir == Path(output_dir.anchor): + raise ValueError( + "documentation output must not be the repository or filesystem root" + ) + + if output_dir.exists(): + shutil.rmtree(output_dir) + + source_dir = repo_root / "docs" + _run_sphinx(source_dir, output_dir, "en") + _run_sphinx(source_dir, output_dir / "zh", "zh_CN") + (output_dir / ".nojekyll").touch() + + if not (output_dir / "404.html").is_file(): + raise RuntimeError("English build did not produce 404.html") + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Build the bilingual Pages artifact") + parser.add_argument( + "--output-dir", + type=Path, + default=Path("build/web-docs"), + help="artifact directory (default: build/web-docs)", + ) + return parser.parse_args() + + +def main() -> None: + args = _parse_args() + repo_root = Path(__file__).resolve().parents[1] + build_site(repo_root, args.output_dir) + + +if __name__ == "__main__": + main() diff --git a/scripts/publish_docs.sh b/scripts/publish_docs.sh new file mode 100755 index 0000000..4695774 --- /dev/null +++ b/scripts/publish_docs.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +artifact_input=${1:-} +test -n "$artifact_input" +test "${DOCS_TARGET_REPOSITORY:-}" = "MetaCircleAI/Observable-Library" +test "${DOCS_TARGET_BRANCH:-}" = "gh-pages" +test -n "${DOCS_DEPLOY_TOKEN:-}" +test -n "${DOCS_GIT_NAME:-}" +test -n "${DOCS_GIT_EMAIL:-}" +command -v git >/dev/null +command -v rsync >/dev/null + +artifact_dir=$(cd "$artifact_input" && pwd -P) +test "$artifact_dir" != "/" +test -f "$artifact_dir/index.html" +test -f "$artifact_dir/zh/index.html" +test -f "$artifact_dir/404.html" +test -f "$artifact_dir/.nojekyll" + +publish_root=$(mktemp -d "${TMPDIR:-/tmp}/observable-docs-publish.XXXXXX") +publish_dir="$publish_root/site" +askpass="$publish_root/git-askpass.sh" + +cleanup() { + if test -n "${publish_root:-}" && test "$publish_root" != "/"; then + rm -rf -- "$publish_root" + fi +} +trap cleanup EXIT + +printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'case "$1" in' \ + ' *Username*) printf "%s\\n" "$GIT_USERNAME" ;;' \ + ' *Password*) printf "%s\\n" "$GIT_PASSWORD" ;;' \ + ' *) exit 1 ;;' \ + 'esac' > "$askpass" +chmod 700 "$askpass" + +export GIT_ASKPASS="$askpass" +export GIT_TERMINAL_PROMPT=0 +export GIT_USERNAME=x-access-token +export GIT_PASSWORD="$DOCS_DEPLOY_TOKEN" + +remote_url="https://github.com/${DOCS_TARGET_REPOSITORY}.git" +if git ls-remote --exit-code --heads "$remote_url" "refs/heads/${DOCS_TARGET_BRANCH}" >/dev/null 2>&1; then + git clone --depth 1 --branch "$DOCS_TARGET_BRANCH" "$remote_url" "$publish_dir" +else + mkdir "$publish_dir" + git -C "$publish_dir" init + git -C "$publish_dir" remote add origin "$remote_url" + git -C "$publish_dir" switch --orphan "$DOCS_TARGET_BRANCH" +fi + +rsync -a --delete --exclude ".git/" "$artifact_dir/" "$publish_dir/" +git -C "$publish_dir" config user.name "$DOCS_GIT_NAME" +git -C "$publish_dir" config user.email "$DOCS_GIT_EMAIL" +git -C "$publish_dir" add --all + +if git -C "$publish_dir" diff --cached --quiet; then + echo "Documentation artifact is already published." + exit 0 +fi + +git -C "$publish_dir" commit -m "Publish documentation for ${GITHUB_SHA:-manual run}" +git -C "$publish_dir" push origin "HEAD:${DOCS_TARGET_BRANCH}" diff --git a/tests/test_web_docs.py b/tests/test_web_docs.py new file mode 100644 index 0000000..378be89 --- /dev/null +++ b/tests/test_web_docs.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +from pathlib import Path + +from babel.messages.pofile import read_po + +try: + import tomllib +except ModuleNotFoundError: + import tomli as tomllib + +from scripts.build_docs import build_site + + +ROOT = Path(__file__).resolve().parents[1] +DOCS = ROOT / "docs" +WEB_DOCS = [ + DOCS / "index.md", + DOCS / "installation.md", + DOCS / "quickstart.md", + DOCS / "concepts.md", + DOCS / "usage.md", + DOCS / "api.md", + DOCS / "limitations.md", +] +FORBIDDEN_DOCS_TEXT = [ + "ParameterNormObservable", + "UpdateRatioObservable", + "from observable_library import generate, observe", + "BSD-3-Clause", +] +LOCALE_DIR = DOCS / "locale" / "zh_CN" / "LC_MESSAGES" + + +def test_docs_extra_declares_the_sphinx_toolchain() -> None: + metadata = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) + docs = metadata["project"]["optional-dependencies"]["docs"] + + for package in [ + "babel", + "sphinx", + "myst-parser", + "pydata-sphinx-theme", + "sphinx-design", + "sphinx-copybutton", + "sphinx-intl", + "pytest", + ]: + assert any(item.lower().startswith(package) for item in docs) + + +def test_build_site_creates_one_pages_artifact(tmp_path: Path) -> None: + output_dir = tmp_path / "site" + + build_site(ROOT, output_dir) + + for relative in ["index.html", "zh/index.html", ".nojekyll", "404.html"]: + assert (output_dir / relative).is_file() + + +def test_web_docs_have_the_complete_top_level_navigation() -> None: + assert all(path.is_file() for path in WEB_DOCS) + index = (DOCS / "index.md").read_text(encoding="utf-8") + entries = [ + "installation", + "quickstart", + "concepts", + "usage", + "api", + "limitations", + ] + + positions = [index.index(f"\n{entry}\n") for entry in entries] + assert positions == sorted(positions) + + +def test_web_docs_publish_only_the_real_0_1_0_contract() -> None: + text = "\n".join(path.read_text(encoding="utf-8") for path in WEB_DOCS) + + for required in [ + "0.1.0", + "torch>=2.4.1", + "Apache-2.0", + "Runtime.observe()", + 'selector="all"', + ]: + assert required in text + for forbidden in FORBIDDEN_DOCS_TEXT: + assert forbidden not in text + + +def test_zh_catalogs_translate_every_prose_message() -> None: + catalogs = sorted(LOCALE_DIR.glob("*.po")) + assert {path.stem for path in catalogs} == { + "404", + "api", + "concepts", + "index", + "installation", + "limitations", + "quickstart", + "usage", + } + + for catalog_path in catalogs: + with catalog_path.open(encoding="utf-8") as stream: + catalog = read_po(stream, locale="zh_CN") + assert catalog.locale_identifier == "zh_CN" + for message in catalog: + if not message.id: + continue + assert message.string + assert not message.fuzzy + if message.string == message.id: + assert "\n" in message.id or ( + message.id.startswith("`") and message.id.endswith("`") + ) + + +def test_rendered_homepages_load_the_approved_design_assets(tmp_path: Path) -> None: + output_dir = tmp_path / "site" + build_site(ROOT, output_dir) + + english = (output_dir / "index.html").read_text(encoding="utf-8") + chinese = (output_dir / "zh" / "index.html").read_text(encoding="utf-8") + for html in [english, chinese]: + for asset in ["css/tokens.css", "css/site.css", "js/site.js"]: + assert asset in html + assert "hero-live-code" in html + assert "data-doc-language-switch" in html + assert "ParameterNormObservable" not in html + assert "UpdateRatioObservable" not in html + assert "BSD-3-Clause" not in html + + assert "Generate and compute training observables" in english + assert "生成并计算训练可观测量" in chinese + + +def test_responsive_header_hides_controls_outside_their_layout() -> None: + css = (DOCS / "_static" / "css" / "site.css").read_text(encoding="utf-8") + + assert "#pst-header .drawer-header {\n display: none !important;\n}" in css + + tablet_rules = css[css.index("@media (min-width: 901px) and (max-width: 1279px)") :] + assert ( + "#pst-header .secondary-toggle {\n display: none !important;\n }" + in tablet_rules + ) + + compact_header_rules = css[css.index("@media (max-width: 1279px)") :] + assert ( + "#pst-header .navbar-header-items__center {\n display: none !important;\n }" + in compact_header_rules + ) + + +def test_fragment_navigation_does_not_leave_a_persistent_heading_highlight() -> None: + css = (DOCS / "_static" / "css" / "site.css").read_text(encoding="utf-8") + + assert ":target > :is(h1, .h1, h2, .h2, h3, .h3, h4, .h4, h5, .h5)" in css + assert "background-color: transparent;" in css + + +def test_flat_navigation_is_not_duplicated_in_the_primary_sidebar() -> None: + conf = (DOCS / "conf.py").read_text(encoding="utf-8") + + assert 'html_sidebars = {"**": []}' in conf + + +def test_compact_header_aligns_actions_and_handles_a_320px_viewport() -> None: + css = (DOCS / "_static" / "css" / "site.css").read_text(encoding="utf-8") + + compact_header_rules = css[css.index("@media (max-width: 1279px)") :] + assert ( + "#pst-header .navbar-header-items__end {\n margin-left: auto;\n }" + in compact_header_rules + ) + + narrow_header_rules = css[css.index("@media (max-width: 360px)") :] + assert ".docs-wordmark__name {\n max-width: 7.25rem;\n }" in narrow_header_rules + + +def test_mobile_keeps_page_navigation_available_without_crowding_the_header() -> None: + css = (DOCS / "_static" / "css" / "site.css").read_text(encoding="utf-8") + + tablet_rules = css[ + css.index("@media (max-width: 900px)") : css.index("@media (max-width: 767px)") + ] + assert ".bd-sidebar-secondary {\n display: none;\n }" not in tablet_rules + + mobile_rules = css[css.index("@media (max-width: 767px)") :] + assert ( + "#pst-primary-sidebar-modal .drawer-header .docs-wordmark__version {\n" + " display: none;\n }" in mobile_rules + ) + + narrow_rules = css[css.index("@media (max-width: 400px)") :] + assert ( + "#pst-header .navbar-persistent--mobile:has(.theme-switch-container) {\n" + " display: none;\n }" in narrow_rules + ) + assert ( + "#pst-header .navbar-header-items__start .docs-wordmark__version {\n" + " display: none;\n }" in narrow_rules + ) + + +def test_mobile_workflow_reflows_instead_of_requiring_horizontal_scroll() -> None: + css = (DOCS / "_static" / "css" / "site.css").read_text(encoding="utf-8") + + mobile_rules = css[css.index("@media (max-width: 767px)") :] + assert ( + ".workflow-steps {\n" + " grid-template-columns: minmax(0, 1fr);\n" + " min-width: 0;\n" + " }" in mobile_rules + ) + assert ( + ".workflow-arrow {\n" + " justify-self: center;\n" + " transform: rotate(90deg);\n" + " }" in mobile_rules + ) From 869d87b11355d178bf68d5a6aba2c5988e49b103 Mon Sep 17 00:00:00 2001 From: Jinxin <102596431+JinxinWonderWorld@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:03:19 +0800 Subject: [PATCH 2/4] Remove GitHub Pages docs deployment --- .github/workflows/docs.yml | 29 ++--------------- docs/conf.py | 1 - scripts/build_docs.py | 4 +-- scripts/publish_docs.sh | 67 -------------------------------------- tests/test_web_docs.py | 4 +-- 5 files changed, 5 insertions(+), 100 deletions(-) delete mode 100755 scripts/publish_docs.sh diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index da9562a..da2a59e 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -8,7 +8,6 @@ on: - "docs/**" - "observable_library/**" - "scripts/build_docs.py" - - "scripts/publish_docs.sh" - "tests/test_web_docs.py" - "pyproject.toml" pull_request: @@ -17,7 +16,6 @@ on: - "docs/**" - "observable_library/**" - "scripts/build_docs.py" - - "scripts/publish_docs.sh" - "tests/test_web_docs.py" - "pyproject.toml" workflow_dispatch: @@ -42,11 +40,11 @@ jobs: cache: pip - name: Install documentation dependencies run: python -m pip install -e ".[docs]" - - name: Build the exact Pages artifact + - name: Build documentation artifact run: python scripts/build_docs.py --output-dir build/web-docs - name: Run documentation policy tests run: python -m pytest tests/test_web_docs.py -q - - name: Upload Pages artifact + - name: Upload documentation artifact uses: actions/upload-artifact@v7 with: name: web-docs-${{ github.sha }} @@ -54,26 +52,3 @@ jobs: include-hidden-files: true if-no-files-found: error retention-days: 7 - - deploy: - name: Publish gh-pages - needs: build - if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - timeout-minutes: 5 - environment: docs-production - steps: - - uses: actions/checkout@v7 - - name: Download the artifact built by this run - uses: actions/download-artifact@v8 - with: - name: web-docs-${{ github.sha }} - path: build/web-docs - - name: Publish by normal fast-forward push - run: scripts/publish_docs.sh build/web-docs - env: - DOCS_DEPLOY_TOKEN: ${{ secrets.DOCS_DEPLOY_TOKEN }} - DOCS_TARGET_REPOSITORY: MetaCircleAI/Observable-Library - DOCS_TARGET_BRANCH: gh-pages - DOCS_GIT_NAME: Observable Library Docs Bot - DOCS_GIT_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com diff --git a/docs/conf.py b/docs/conf.py index e433055..bd51a15 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -37,7 +37,6 @@ html_theme = "pydata_sphinx_theme" html_title = f"{project} {version}" -html_baseurl = "https://metacircleai.github.io/Observable-Library/" templates_path = ["_templates"] html_static_path = ["_static"] html_css_files = ["css/tokens.css", "css/site.css"] diff --git a/scripts/build_docs.py b/scripts/build_docs.py index c0c0554..69c082f 100644 --- a/scripts/build_docs.py +++ b/scripts/build_docs.py @@ -44,14 +44,12 @@ def build_site(repo_root: Path, output_dir: Path) -> None: source_dir = repo_root / "docs" _run_sphinx(source_dir, output_dir, "en") _run_sphinx(source_dir, output_dir / "zh", "zh_CN") - (output_dir / ".nojekyll").touch() - if not (output_dir / "404.html").is_file(): raise RuntimeError("English build did not produce 404.html") def _parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Build the bilingual Pages artifact") + parser = argparse.ArgumentParser(description="Build the bilingual documentation artifact") parser.add_argument( "--output-dir", type=Path, diff --git a/scripts/publish_docs.sh b/scripts/publish_docs.sh deleted file mode 100755 index 4695774..0000000 --- a/scripts/publish_docs.sh +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -artifact_input=${1:-} -test -n "$artifact_input" -test "${DOCS_TARGET_REPOSITORY:-}" = "MetaCircleAI/Observable-Library" -test "${DOCS_TARGET_BRANCH:-}" = "gh-pages" -test -n "${DOCS_DEPLOY_TOKEN:-}" -test -n "${DOCS_GIT_NAME:-}" -test -n "${DOCS_GIT_EMAIL:-}" -command -v git >/dev/null -command -v rsync >/dev/null - -artifact_dir=$(cd "$artifact_input" && pwd -P) -test "$artifact_dir" != "/" -test -f "$artifact_dir/index.html" -test -f "$artifact_dir/zh/index.html" -test -f "$artifact_dir/404.html" -test -f "$artifact_dir/.nojekyll" - -publish_root=$(mktemp -d "${TMPDIR:-/tmp}/observable-docs-publish.XXXXXX") -publish_dir="$publish_root/site" -askpass="$publish_root/git-askpass.sh" - -cleanup() { - if test -n "${publish_root:-}" && test "$publish_root" != "/"; then - rm -rf -- "$publish_root" - fi -} -trap cleanup EXIT - -printf '%s\n' \ - '#!/usr/bin/env bash' \ - 'case "$1" in' \ - ' *Username*) printf "%s\\n" "$GIT_USERNAME" ;;' \ - ' *Password*) printf "%s\\n" "$GIT_PASSWORD" ;;' \ - ' *) exit 1 ;;' \ - 'esac' > "$askpass" -chmod 700 "$askpass" - -export GIT_ASKPASS="$askpass" -export GIT_TERMINAL_PROMPT=0 -export GIT_USERNAME=x-access-token -export GIT_PASSWORD="$DOCS_DEPLOY_TOKEN" - -remote_url="https://github.com/${DOCS_TARGET_REPOSITORY}.git" -if git ls-remote --exit-code --heads "$remote_url" "refs/heads/${DOCS_TARGET_BRANCH}" >/dev/null 2>&1; then - git clone --depth 1 --branch "$DOCS_TARGET_BRANCH" "$remote_url" "$publish_dir" -else - mkdir "$publish_dir" - git -C "$publish_dir" init - git -C "$publish_dir" remote add origin "$remote_url" - git -C "$publish_dir" switch --orphan "$DOCS_TARGET_BRANCH" -fi - -rsync -a --delete --exclude ".git/" "$artifact_dir/" "$publish_dir/" -git -C "$publish_dir" config user.name "$DOCS_GIT_NAME" -git -C "$publish_dir" config user.email "$DOCS_GIT_EMAIL" -git -C "$publish_dir" add --all - -if git -C "$publish_dir" diff --cached --quiet; then - echo "Documentation artifact is already published." - exit 0 -fi - -git -C "$publish_dir" commit -m "Publish documentation for ${GITHUB_SHA:-manual run}" -git -C "$publish_dir" push origin "HEAD:${DOCS_TARGET_BRANCH}" diff --git a/tests/test_web_docs.py b/tests/test_web_docs.py index 378be89..a97466e 100644 --- a/tests/test_web_docs.py +++ b/tests/test_web_docs.py @@ -49,12 +49,12 @@ def test_docs_extra_declares_the_sphinx_toolchain() -> None: assert any(item.lower().startswith(package) for item in docs) -def test_build_site_creates_one_pages_artifact(tmp_path: Path) -> None: +def test_build_site_creates_one_documentation_artifact(tmp_path: Path) -> None: output_dir = tmp_path / "site" build_site(ROOT, output_dir) - for relative in ["index.html", "zh/index.html", ".nojekyll", "404.html"]: + for relative in ["index.html", "zh/index.html", "404.html"]: assert (output_dir / relative).is_file() From 8319b7c04948fe76f72e9f92256894a5a8081fbb Mon Sep 17 00:00:00 2001 From: Jinxin <102596431+JinxinWonderWorld@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:15:42 +0800 Subject: [PATCH 3/4] Fix documentation CI coverage --- .github/workflows/ci.yml | 8 ++++---- docs/api.md | 2 +- docs/locale/zh_CN/LC_MESSAGES/api.po | 4 ++-- scripts/build_docs.py | 4 +++- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ccd086f..7e4e7fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,7 +99,7 @@ jobs: check_status=0 check_start=$(date +%s) timed "Check: versions" python -c 'import platform, torch; print(platform.python_version(), torch.__version__)' || check_status=$? - timed "Check: default pytest" python -m pytest tests -q || check_status=$? + timed "Check: default pytest" python -m pytest tests -q --ignore=tests/test_web_docs.py || check_status=$? check_total=$(( $(date +%s) - check_start )) line="Check total: ${check_total}s (limit 120s)" echo "$line" @@ -145,7 +145,7 @@ jobs: check_status=0 check_start=$(date +%s) timed "Check: minimum Torch" python -c 'import torch; print(torch.__version__); assert torch.__version__.split("+")[0] == "2.4.1"' || check_status=$? - timed "Check: default pytest" python -m pytest tests -q || check_status=$? + timed "Check: default pytest" python -m pytest tests -q --ignore=tests/test_web_docs.py || check_status=$? check_total=$(( $(date +%s) - check_start )) line="Check total: ${check_total}s (limit 120s)" echo "$line" @@ -192,7 +192,7 @@ jobs: check_status=0 check_start=$(date +%s) timed "Check: versions" python -c 'import platform, torch; print(platform.python_version(), torch.__version__)' || check_status=$? - timed "Check: default pytest" python -m pytest tests -q || check_status=$? + timed "Check: default pytest" python -m pytest tests -q --ignore=tests/test_web_docs.py || check_status=$? check_total=$(( $(date +%s) - check_start )) line="Check total: ${check_total}s (limit 120s)" echo "$line" @@ -280,7 +280,7 @@ jobs: timed "Check: outside-checkout import" python -c 'import os; from pathlib import Path; import observable_library; package_file = Path(observable_library.__file__).resolve(); checkout = Path(os.environ["GITHUB_WORKSPACE"]).resolve(); print(package_file); assert checkout not in package_file.parents' timed "Check: downstream mypy" python -m mypy --python-version 3.12 tests/fixtures/downstream_typing.py timed "Check: all examples" run_examples - timed "Check: default pytest" python -m pytest tests -q + timed "Check: default pytest" python -m pytest tests -q --ignore=tests/test_web_docs.py extended_status=0 extended_start=$(date +%s) timed "Check: performance and trajectory" python -m pytest tests/performance -q -s || extended_status=$? diff --git a/docs/api.md b/docs/api.md index a8b7a4e..00d384f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -86,7 +86,7 @@ source lookup. - `generate()` gives generated observables a small positive shape-aware `heuristic`, with a conservative `0.01` ms floor. It is scheduling input only; custom observables should provide explicit `budget_hint` values when a budget - matters. M2 makes no `M3` cost-accuracy claim. + matters. M2 makes no `M3` cost-accuracy claim: the heuristic is not a calibrated cost-accuracy estimate. `OfflineAnalyzer(observables, source, budget=None, sink=None)` exposes `analyze(step, **context)` and delegates to the same runtime compute path. diff --git a/docs/locale/zh_CN/LC_MESSAGES/api.po b/docs/locale/zh_CN/LC_MESSAGES/api.po index 299f6bc..1acf433 100644 --- a/docs/locale/zh_CN/LC_MESSAGES/api.po +++ b/docs/locale/zh_CN/LC_MESSAGES/api.po @@ -109,8 +109,8 @@ msgid "`max_compute_ms` must be finite and non-negative when set. An observable msgstr "设置 `max_compute_ms` 时,其值必须有限且非负。当可观测量估算值超过剩余 budget 时会被跳过。调度器不测量执行时间,也不改变 spec 的 frequency。" #: ../../../docs/api.md:86 -msgid "`generate()` gives generated observables a small positive shape-aware `heuristic`, with a conservative `0.01` ms floor. It is scheduling input only; custom observables should provide explicit `budget_hint` values when a budget matters. M2 makes no `M3` cost-accuracy claim." -msgstr "`generate()` 为生成的可观测量提供小的正数 shape-aware `heuristic`,保守下限为 `0.01` ms。它只用于调度输入;budget 重要时,自定义可观测量应提供显式 `budget_hint`。M2 不作 `M3` 成本准确性声明。" +msgid "`generate()` gives generated observables a small positive shape-aware `heuristic`, with a conservative `0.01` ms floor. It is scheduling input only; custom observables should provide explicit `budget_hint` values when a budget matters. M2 makes no `M3` cost-accuracy claim: the heuristic is not a calibrated cost-accuracy estimate." +msgstr "`generate()` 为生成的可观测量提供小的正数 shape-aware `heuristic`,保守下限为 `0.01` ms。它只用于调度输入;budget 重要时,自定义可观测量应提供显式 `budget_hint`。M2 不作 `M3` 成本准确性声明:该 heuristic 不是经过校准的成本准确性估算。" #: ../../../docs/api.md:91 msgid "`OfflineAnalyzer(observables, source, budget=None, sink=None)` exposes `analyze(step, **context)` and delegates to the same runtime compute path." diff --git a/scripts/build_docs.py b/scripts/build_docs.py index 69c082f..6f246a4 100644 --- a/scripts/build_docs.py +++ b/scripts/build_docs.py @@ -49,7 +49,9 @@ def build_site(repo_root: Path, output_dir: Path) -> None: def _parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Build the bilingual documentation artifact") + parser = argparse.ArgumentParser( + description="Build the bilingual documentation artifact" + ) parser.add_argument( "--output-dir", type=Path, From 8820c3ad2511ea72a0f6625341a48147c1a02ca3 Mon Sep 17 00:00:00 2001 From: Jinxin <102596431+JinxinWonderWorld@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:43:01 +0800 Subject: [PATCH 4/4] Skip docs tests without docs dependencies --- .github/workflows/ci.yml | 8 ++++---- tests/test_web_docs.py | 6 +++++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e4e7fa..ccd086f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,7 +99,7 @@ jobs: check_status=0 check_start=$(date +%s) timed "Check: versions" python -c 'import platform, torch; print(platform.python_version(), torch.__version__)' || check_status=$? - timed "Check: default pytest" python -m pytest tests -q --ignore=tests/test_web_docs.py || check_status=$? + timed "Check: default pytest" python -m pytest tests -q || check_status=$? check_total=$(( $(date +%s) - check_start )) line="Check total: ${check_total}s (limit 120s)" echo "$line" @@ -145,7 +145,7 @@ jobs: check_status=0 check_start=$(date +%s) timed "Check: minimum Torch" python -c 'import torch; print(torch.__version__); assert torch.__version__.split("+")[0] == "2.4.1"' || check_status=$? - timed "Check: default pytest" python -m pytest tests -q --ignore=tests/test_web_docs.py || check_status=$? + timed "Check: default pytest" python -m pytest tests -q || check_status=$? check_total=$(( $(date +%s) - check_start )) line="Check total: ${check_total}s (limit 120s)" echo "$line" @@ -192,7 +192,7 @@ jobs: check_status=0 check_start=$(date +%s) timed "Check: versions" python -c 'import platform, torch; print(platform.python_version(), torch.__version__)' || check_status=$? - timed "Check: default pytest" python -m pytest tests -q --ignore=tests/test_web_docs.py || check_status=$? + timed "Check: default pytest" python -m pytest tests -q || check_status=$? check_total=$(( $(date +%s) - check_start )) line="Check total: ${check_total}s (limit 120s)" echo "$line" @@ -280,7 +280,7 @@ jobs: timed "Check: outside-checkout import" python -c 'import os; from pathlib import Path; import observable_library; package_file = Path(observable_library.__file__).resolve(); checkout = Path(os.environ["GITHUB_WORKSPACE"]).resolve(); print(package_file); assert checkout not in package_file.parents' timed "Check: downstream mypy" python -m mypy --python-version 3.12 tests/fixtures/downstream_typing.py timed "Check: all examples" run_examples - timed "Check: default pytest" python -m pytest tests -q --ignore=tests/test_web_docs.py + timed "Check: default pytest" python -m pytest tests -q extended_status=0 extended_start=$(date +%s) timed "Check: performance and trajectory" python -m pytest tests/performance -q -s || extended_status=$? diff --git a/tests/test_web_docs.py b/tests/test_web_docs.py index a97466e..de32af0 100644 --- a/tests/test_web_docs.py +++ b/tests/test_web_docs.py @@ -2,7 +2,7 @@ from pathlib import Path -from babel.messages.pofile import read_po +import pytest try: import tomllib @@ -12,6 +12,10 @@ from scripts.build_docs import build_site +read_po = pytest.importorskip("babel.messages.pofile").read_po +pytest.importorskip("sphinx") + + ROOT = Path(__file__).resolve().parents[1] DOCS = ROOT / "docs" WEB_DOCS = [