From 866173a831778e998ca89ad1ff3855f24da1f845 Mon Sep 17 00:00:00 2001 From: dela Date: Wed, 5 Aug 2026 16:41:22 +0800 Subject: [PATCH] Add source linter, negative test fixtures, and fallback guidance - scripts/lint.py: reject raw rectangles, absolute coordinates, hue-budget and callout/group/formula-order violations at the source level - tests/invalid/ + tests/lint-invalid/: negative fixtures proving the package and linter reject bad input; test.sh now runs both directions - references/fallback.md: degraded path when no LaTeX is available - tests/group-callout.tex: exercise \stgroup and \stcallout - agents/openai.yaml: agent config - Docs and .sty updated to match --- README.md | 10 +- SKILL.md | 16 +- agents/openai.yaml | 4 + assets/supertensor.sty | 202 +++++++++++++++++++++-- examples/antipatterns.tex | 1 + references/antipatterns.md | 13 +- references/api.md | 61 ++++++- references/checklist.md | 17 +- references/fallback.md | 18 ++ references/geometry.md | 6 +- references/layout.md | 21 ++- references/semantics.md | 6 + references/style.md | 24 ++- scripts/build.sh | 7 +- scripts/lint.py | 175 ++++++++++++++++++++ scripts/preflight.sh | 18 +- scripts/test.sh | 28 +++- tests/flow.tex | 3 + tests/group-callout.tex | 78 +++++++++ tests/invalid/axis-redefinition.tex | 7 + tests/invalid/index-count.tex | 8 + tests/invalid/invalid-data-rows.tex | 8 + tests/invalid/invalid-data.tex | 8 + tests/invalid/invalid-level.tex | 7 + tests/invalid/invalid-pattern.tex | 7 + tests/invalid/role-redefinition.tex | 8 + tests/lint-invalid/absolute.tex | 7 + tests/lint-invalid/callout-anchor.tex | 12 ++ tests/lint-invalid/callout-duplicate.tex | 13 ++ tests/lint-invalid/formula-order.tex | 9 + tests/lint-invalid/group-unclosed.tex | 12 ++ tests/lint-invalid/hue-budget.tex | 18 ++ tests/lint-invalid/raw-rectangle.tex | 6 + tests/smoke.tex | 2 + 34 files changed, 786 insertions(+), 54 deletions(-) create mode 100644 agents/openai.yaml create mode 100644 references/fallback.md create mode 100755 scripts/lint.py create mode 100644 tests/group-callout.tex create mode 100644 tests/invalid/axis-redefinition.tex create mode 100644 tests/invalid/index-count.tex create mode 100644 tests/invalid/invalid-data-rows.tex create mode 100644 tests/invalid/invalid-data.tex create mode 100644 tests/invalid/invalid-level.tex create mode 100644 tests/invalid/invalid-pattern.tex create mode 100644 tests/invalid/role-redefinition.tex create mode 100644 tests/lint-invalid/absolute.tex create mode 100644 tests/lint-invalid/callout-anchor.tex create mode 100644 tests/lint-invalid/callout-duplicate.tex create mode 100644 tests/lint-invalid/formula-order.tex create mode 100644 tests/lint-invalid/group-unclosed.tex create mode 100644 tests/lint-invalid/hue-budget.tex create mode 100644 tests/lint-invalid/raw-rectangle.tex diff --git a/README.md b/README.md index 0913726..50311fc 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,8 @@ SKILL.md the skill entry point (lean; loads references on demand) references/ geometry, semantics, layout, style, api, checklist, antipatterns assets/supertensor.sty the macro package scripts/preflight.sh is the TikZ + CJK path available? -scripts/build.sh compile, audit the log, export pdf/svg/png/thumb +scripts/lint.py reject source-level invariant escapes +scripts/build.sh lint, compile, audit the log, export pdf/svg/png/thumb examples/ three golden examples + an anti-pattern gallery ``` @@ -87,8 +88,11 @@ The flow layout adds two of its own: an object that overflows its band, and a `\ whose contents do not add up to the height it declared (which means it is drawn off-center). Both are things a reader would have to notice for you. -A clean build still proves nothing about collisions, hue budget or whether the math is -right. That is what `references/checklist.md` is for. +A clean build now also proves the source avoided untracked absolute objects, ledger +changes, raw rectangles, unclosed `\stgroup` blocks, side cards anchored to a single face +or doubled up on one band, and excess per-row hues. It still cannot prove that the math, +semantics or rendered relationships are right; that is what `references/checklist.md` is +for. ## Provenance diff --git a/SKILL.md b/SKILL.md index af6df73..39d5427 100644 --- a/SKILL.md +++ b/SKILL.md @@ -18,8 +18,8 @@ A figure built from raw TikZ has to re-earn every invariant by hand and usually ## Workflow 1. **Preflight.** `./scripts/preflight.sh`. Exit 0 = TikZ+CJK path. Exit 1 = degraded - (say so in the delivery). Exit 2 = no LaTeX; fall back to SVG/matplotlib and say - explicitly that the figure is not TikZ. + (say so in the delivery). Exit 2 = no LaTeX; read `references/fallback.md` before + falling back and state explicitly which package guarantees are unavailable. 2. **Reduce** the input to one primary computation path. Drop equivalent objectives, diagnostics, and secondary metrics unless asked for. 3. **Build two ledgers** before drawing anything: @@ -33,9 +33,9 @@ A figure built from raw TikZ has to re-earn every invariant by hand and usually flow layout — `\ststage` / `\strow` … `\strowend`, empty coordinate arguments, gaps declared once. Reach for an absolute coordinate only when no band can express the placement. See `references/api.md` and `references/layout.md`. -5. **Build and audit.** `./scripts/build.sh fig.tex`. A clean build only proves TeX was - happy; then run the visual audit in `references/checklist.md` against the PNG at full - size and at thumbnail size. Redraw on any mandatory-invariant violation. +5. **Build and audit.** `./scripts/build.sh fig.tex` runs the source linter, TeX checks and + exports. Then run the remaining visual/semantic audit in `references/checklist.md` + against the PNG at full size and thumbnail size. Redraw on any mandatory violation. For code input, trace the concrete `matmul`, `einsum`, `reshape/view`, `transpose/permute`, concat, broadcast, and collective calls. Keep code variable names where useful; state any @@ -51,6 +51,8 @@ shape or convention you inferred. | discrete choices (indices, token ids, expert ids) | symbols in cells, no ramp | `\stindexface` | | data movement, collectives, non-linear ops | arrows and nodes | `\stlink`, `\stcomm` | | a split along the contracted axis | a vertical pair inside one band slot | `\stcol` … `\stcolend` | +| adjacent objects that are one composite (heads of `q`, shards of `W`) | an outline in their own role hue | `\stgroup` … `\stgroupend` | +| an aside about a whole band | a side card off its right edge, one per band | `\stcallout` | Combine grammars only when each one adds information. Known zeros stay unfilled; masks, diagonals, sparsity and partitions must encode their exact structure. @@ -86,8 +88,8 @@ and the `.tex` source plus vector artifact. select an OS-specific CJK font unless the user asks and accepts the portability cost. - **English figures:** `\usepackage[en]{supertensor}` — same geometry, English rail labels. - Keep math in LaTeX, not raw Unicode. -- The identification line is `\stsignature{}{}`; it renders - `@五道口纳什`. Change the handle with `\stsetauthor{...}` only when asked. +- Add `\stsignature{}{}` only when the user or house template asks for an + identification line. It renders only the subject, with no author or handle. ## Iterating diff --git a/agents/openai.yaml b/agents/openai.yaml new file mode 100644 index 0000000..c99512e --- /dev/null +++ b/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Supertensor" + short_description: "Create shape-aware tensor and matrix figures" + default_prompt: "Use $supertensor to turn this tensor formula or code path into an editable, shape-correct figure." diff --git a/assets/supertensor.sty b/assets/supertensor.sty index 4e4e7bd..a1b5cf6 100644 --- a/assets/supertensor.sty +++ b/assets/supertensor.sty @@ -6,7 +6,7 @@ %% Options: cjk load ctex with the portable fandol fontset (XeLaTeX) %% en English rail labels in the meaning box (default: zh) \NeedsTeXFormat{LaTeX2e} -\ProvidesPackage{supertensor}[2026/08/04 v0.1 shape-aware tensor figure toolkit] +\ProvidesPackage{supertensor}[2026/08/05 v0.2 shape-aware tensor figure toolkit] \newif\ifst@cjk\st@cjkfalse \newif\ifst@en\st@enfalse @@ -46,8 +46,20 @@ % Role registry: draw macros take a ROLE, never a color, so one tensor role % keeps one hue across every stage of the figure. -% \stsetrole{X}{stTeal} -> role "X" is teal everywhere -\newcommand{\stsetrole}[2]{\expandafter\gdef\csname st@role@#1\endcsname{#2}} +% \stsetrole{X}{stTeal} -> role "X" is teal everywhere. Repeating the same +% declaration is harmless; changing it would make one role change meaning +% halfway through the figure, so report it as a dirty-build warning. +\newcommand{\stsetrole}[2]{% + \edef\st@newrole{#2}% + \ifcsname st@role@#1\endcsname + \edef\st@oldrole{\csname st@role@#1\endcsname}% + \ifx\st@oldrole\st@newrole\else + \PackageWarning{supertensor}{Role `#1' was already mapped to + `\st@oldrole' and cannot be remapped to `\st@newrole'}% + \fi + \else + \expandafter\gdef\csname st@role@#1\endcsname{#2}% + \fi} % Expandable on purpose: usable inside \edef. Undeclared roles fall back to % neutral gray and are reported at the end of the run. \newcommand{\strole}[1]{% @@ -68,7 +80,17 @@ % then every face built from `d' has the same physical edge, everywhere. \newlength{\stunit}\setlength{\stunit}{4.6mm} \newlength{\sttilegap}\setlength{\sttilegap}{0.5mm} -\newcommand{\stdim}[2]{\expandafter\gdef\csname st@dim@#1\endcsname{#2}} +\newcommand{\stdim}[2]{% + \edef\st@newdim{#2}% + \ifcsname st@dim@#1\endcsname + \edef\st@olddim{\csname st@dim@#1\endcsname}% + \ifx\st@olddim\st@newdim\else + \PackageWarning{supertensor}{Axis `#1' was already declared as + `\st@olddim' cells and cannot be redeclared as `\st@newdim'}% + \fi + \else + \expandafter\gdef\csname st@dim@#1\endcsname{#2}% + \fi} \newcommand{\stresolve}[1]{\ifcsname st@dim@#1\endcsname\csname st@dim@#1\endcsname\else#1\fi} % --------------------------------------------------------- type hierarchy --- @@ -122,14 +144,16 @@ \newlength{\st@vh} \newif\ifst@inrow \newif\ifst@incol +\newif\ifst@ingroup \newif\ifst@first \newcommand{\stlayoutreset}{% \global\st@railx=0pt \global\st@ycur=0pt \global\st@cx=0pt \global\st@cy=0pt \global\st@bandh=0pt - \global\st@inrowfalse \global\st@incolfalse + \global\st@inrowfalse \global\st@incolfalse \global\st@ingroupfalse \gdef\st@rowlist{}\gdef\st@alllist{}\gdef\st@rowname{}% \gdef\st@collist{}\gdef\st@colname{}% + \gdef\st@grouplist{}\gdef\st@gname{}% \gdef\st@lastnode{}\gdef\st@linklabel{}\gdef\st@linkprev{}} \stlayoutreset \newcommand{\stleftrail}[1]{\global\st@railx=\dimexpr#1\relax} @@ -144,14 +168,19 @@ % Inside a column the items belong to the column, not to the band: they must not % terminate a pending connector, or the arrow would land on the first sheet of % the stack instead of on the column as a whole. +% A group behaves the same way: the wrapper, not the wrapped face, is what a +% connector may attach to. Otherwise the arrow ends inside the outline and +% crosses a box that is not its endpoint. \newcommand{\st@regrow}[1]{% \ifst@incol \xdef\st@collist{\st@collist(#1)}\st@regall{#1}% + \else\ifst@ingroup + \xdef\st@grouplist{\st@grouplist(#1)}\st@regall{#1}% \else \xdef\st@rowlist{\st@rowlist(#1)}\st@regall{#1}% \st@drawpendinglink{#1}% \gdef\st@lastnode{#1}% - \fi} + \fi\fi} \newcommand{\st@needrow}[1]{% \ifst@inrow\else @@ -353,6 +382,55 @@ bracket=false, border=true, tiles=true, } +% Validate face keys before drawing. Unknown patterns used to fall through to +% dense, which produced a plausible but semantically false picture. +\newif\ifst@validpattern +\newcommand{\st@checkpattern}{% + \st@validpatternfalse + \foreach \st@known in {solid,dense,diag,band,lower,upper,causal,empty,data}{% + \IfStrEq{\st@pattern}{\st@known}{\global\st@validpatterntrue}{}}% + \ifst@validpattern\else + \PackageWarning{supertensor}{Unknown face pattern `\st@pattern'. + Use solid, dense, diag, band, lower, upper, causal, empty, or data}% + \fi} +\newcommand{\st@checklevel}{% + \IfInteger{\st@level}{% + \ifnum\st@level<0 + \PackageWarning{supertensor}{Face level `\st@level' is outside 0--3}% + \def\st@level{2}% + \else\ifnum\st@level>3 + \PackageWarning{supertensor}{Face level `\st@level' is outside 0--3}% + \def\st@level{2}% + \fi\fi + }{\PackageWarning{supertensor}{Face level `\st@level' is not an integer in 0--3}% + \def\st@level{2}}} + +% pattern=data is a rectangular rows-by-cols array of digits 0--3. Validate +% all three facts before \StrChar reaches a missing or malformed cell. +\newcommand{\st@checkdata}{% + \IfStrEq{\st@pattern}{data}{% + \def\st@datacount{0}% + \foreach \st@drow [count=\st@di] in \st@data {\xdef\st@datacount{\st@di}}% + \ifnum\st@datacount=\st@rows\else + \PackageWarning{supertensor}{pattern=data has \st@datacount\space rows; + expected \st@rows}% + \fi + \foreach \st@drow in \st@data {% + \StrLen{\st@drow}[\st@dlen]% + \ifnum\st@dlen=\st@cols\else + \PackageWarning{supertensor}{Data row `\st@drow' has \st@dlen\space cells; + expected \st@cols}% + \fi + \edef\st@badchars{\st@drow}% + \StrSubstitute{\st@badchars}{0}{}[\st@badchars]% + \StrSubstitute{\st@badchars}{1}{}[\st@badchars]% + \StrSubstitute{\st@badchars}{2}{}[\st@badchars]% + \StrSubstitute{\st@badchars}{3}{}[\st@badchars]% + \ifdefempty{\st@badchars}{}{% + \PackageWarning{supertensor}{Data row `\st@drow' contains values outside 0--3}}% + }% + }{}} + % \st@hash{i}{j}{n} -> \st@hv in 0..n-1. % Nested mods on purpose. Any polynomial in (i,j) reduced mod 3 is periodic % with period 3 in BOTH directions, so a polynomial hash makes rows 1,2,4,5 of @@ -387,6 +465,9 @@ \pgfkeys{/st/face/.cd,#1}% \edef\st@rows{\stresolve{#2}}% \edef\st@cols{\stresolve{#3}}% + \st@checkpattern + \st@checklevel + \st@checkdata \stcheckrole{\st@role}% \edef\st@col{\strole{\st@role}}% \pgfmathsetlengthmacro{\st@w}{\st@cols*\stunit}% @@ -438,6 +519,13 @@ \newcommand{\stindexface}[6][]{% \begingroup \st@setup{#1}{#4}{#5}% + \def\st@indexcount{0}% + \foreach \st@e [count=\st@zi] in {#6} {\xdef\st@indexcount{\st@zi}}% + \pgfmathtruncatemacro{\st@indexexpected}{\st@rows*\st@cols}% + \ifnum\st@indexcount=\st@indexexpected\else + \PackageWarning{supertensor}{Index face `#2' has \st@indexcount\space entries; + expected \st@indexexpected\space for \st@rows\space x \st@cols}% + \fi \ifblank{#3}% {\ifst@bracket\pgfmathsetlengthmacro{\st@tw}{\st@w+6.8mm}\else\let\st@tw\st@w\fi \st@flowbegin{#2}{\st@tw}{\st@h}% @@ -472,9 +560,10 @@ \foreach \st@row [count=\st@ii] in \st@data {% \foreach \st@jj in {1,...,\st@cols} {% \StrChar{\st@row}{\st@jj}[\st@c]% - \ifnum\st@c>0 - \st@tile{#1}{\st@ii}{\st@jj}{\st@c}% - \fi}}% + \IfInteger{\st@c}{% + \ifnum\st@c>0 + \ifnum\st@c<4 \st@tile{#1}{\st@ii}{\st@jj}{\st@c}\fi + \fi}{} }}% }{% \foreach \st@ii in {1,...,\st@rows} {% \foreach \st@jj in {1,...,\st@cols} {% @@ -547,6 +636,69 @@ \ifblank{#3}{\st@regrow{#2}}{}% \endgroup} +% ------------------------------------------------------------- grouping ---- +% \stgroup[keys]{name} ... \stgroupend -- a thin rounded outline naming the +% objects drawn between them as one composite ("these three sheets are q"; +% "these two shards are W"). A sub-flow, like \stcol, and for the same reason: +% +% - the members are drawn INSIDE the block, so a group can only ever wrap +% adjacent objects -- one that reached across the band would swallow +% whatever sat in between; +% - the group, not the last member, terminates a pending \stlink and sources +% the next one, so an arrow lands on the outline instead of ending inside +% it and crossing a border that is not its endpoint; +% - the padding is reserved on both sides, so the neighbour cannot land +% tangent to the outline. +% +% This is the one place a tensor-colored outline is allowed, because here the +% outline IS the object being drawn -- see style.md. +\newlength{\stgrouppad}\setlength{\stgrouppad}{1.6mm} +\pgfkeys{ + /st/group/.cd, + role/.store in=\st@grole, + pad/.store in=\st@gpad, + role=neutral, pad=\stgrouppad, +} +\newcommand{\stgroup}[2][]{% + \st@needrow{\stgroup}% + \ifst@ingroup + \PackageError{supertensor}{Nested \string\stgroup}% + {Close the open group with \string\stgroupend\space first.}% + \fi + \ifst@incol + \PackageError{supertensor}{\string\stgroup\space inside \string\stcol}% + {A group reserves its padding along the band, not down the column. Wrap + the whole \string\stcol\space in the group instead.}% + \fi + \pgfkeys{/st/group/.cd,#1}% + \stcheckrole{\st@grole}% + % Snapshot everything the closing macro needs: the keys are re-read by the + % next face and the role ledger lookup must not be deferred. + \xdef\st@gname{#2}% + \xdef\st@gcolname{\strole{\st@grole}}% + \xdef\st@gpadval{\st@gpad}% + \st@leadgap{\stgutter}% + \global\advance\st@cx by \dimexpr\st@gpad\relax + \gdef\st@grouplist{}% + \global\st@ingrouptrue\global\st@firsttrue} + +\newcommand{\stgroupend}{% + \global\st@ingroupfalse + \ifdefempty{\st@grouplist}% + {\PackageWarning{supertensor}{Empty \string\stgroup\space `\st@gname'}}% + {\global\advance\st@cx by \dimexpr\st@gpadval\relax + \begin{pgfonlayer}{stbg} + \edef\st@do{\noexpand\node[inner sep=\st@gpadval, outer sep=0pt, + draw=\st@gcolname!65, line width=0.5pt, rounded corners=2pt, + fit={\st@grouplist}] (\st@gname) {};}\st@do + \end{pgfonlayer} + \global\st@firstfalse + % No \st@checkh: the overhang is 2*pad by construction, not driven by + % content, and the members were already checked against the band. Warning + % about it would only teach authors to inflate the declared band height. + % \strowend fits the group, so the caption lane still clears it. + \edef\st@do{\noexpand\st@regrow{\st@gname}}\st@do}} + % ------------------------------------------------- symbol / shape captions --- % Symbol immediately under the block, shape on the next line. Both are reserved % lanes: nothing else may be placed between a face and its caption. @@ -578,6 +730,32 @@ \draw[st arrow,#1] (#2) -- node[st note, above, fill=white, inner sep=1pt] {#4} (#3); \end{pgfonlayer}} +% --------------------------------------------------------------- callout --- +% \stcallout{name}{text width}{anchor}{title}{body} -- a side note card hanging +% off the RIGHT edge of a finished band, top-aligned with it. +% +% It is deliberately hard to misuse. Inside an open band it is an error: a +% commentary card between two operands reads as a step in the computation, +% which is the antipattern layout.md names. Outside one it still pushes the +% vertical cursor below its own bottom edge, so a card taller than its band +% opens visible space rather than colliding with the next stage -- which is the +% signal that the text belongs in \stmeaningbox instead. +\newcommand{\stcallout}[5]{% + \ifst@inrow + \PackageError{supertensor}{\string\stcallout\space inside an open + \string\strow}% + {A callout is an aside, not an operand. Close the band with + \string\strowend\space first. If the text explains a step rather than + the band, it belongs in the stage heading or \string\stmeaningbox.}% + \fi + \node[anchor=north west, draw=black!18, fill=black!3, rounded corners=1.5pt, + inner xsep=7pt, inner ysep=6pt, text width=#2] (#1) + at ([xshift=\stgutter]#3.north east) {% + \scriptsize\raggedright + \ifblank{#4}{}{\textbf{#4}\par\vspace{1.5pt}}% + #5\par}; + \sttrack{#1}} + % ------------------------------------------------------------ meaning box --- \ifst@en \def\st@lblaxes{Axes}\def\st@lblobj{Objects}\def\st@lblmech{Mechanism} @@ -607,10 +785,8 @@ \newcommand{\stsetrail}[1]{\setlength{\st@raillen}{#1}} % ------------------------------------------------------------- signature ---- -% One centered identification line, outside the meaning box, low contrast. -\def\st@author{五道口纳什} -\newcommand{\stsetauthor}[1]{\def\st@author{#1}} +% Optional centered subject line, outside the meaning box, low contrast. \newcommand{\stsignature}[2]{% - \node[below=2.2mm of #2, font=\scriptsize, text=black!45] (st-signature) {#1@\st@author};} + \node[below=2.2mm of #2, font=\scriptsize, text=black!45] (st-signature) {#1};} \endinput diff --git a/examples/antipatterns.tex b/examples/antipatterns.tex index fe6f148..f5cf2f9 100644 --- a/examples/antipatterns.tex +++ b/examples/antipatterns.tex @@ -1,6 +1,7 @@ % Anti-pattern gallery -- four ways to draw a figure that compiles cleanly and % still teaches the reader something false. Left of each pair is wrong. % ../scripts/build.sh antipatterns.tex +% supertensor-lint: allow-absolute, allow-missing-formula \documentclass[border=10pt]{standalone} \usepackage[cjk]{supertensor} diff --git a/references/antipatterns.md b/references/antipatterns.md index bb5807e..5c0d1fd 100644 --- a/references/antipatterns.md +++ b/references/antipatterns.md @@ -54,7 +54,18 @@ See `style.md`. - **A formula line placed first.** It can only be centered on a figure whose width is not known yet, so it ends up visibly off-center. Call `\sttopformula` after the bands. - **A floating commentary card between two operands.** If it is not a real operation, it - belongs in the stage subtitle or the bottom box. + belongs in the stage subtitle, the bottom box, or a `\stcallout` beside the whole band. + A card anchored to a single face reads as a step in the computation, and two cards on one + band turn the figure into a dashboard; both are lint errors. +- **A callout that should have been the meaning box.** If the card is taller than the band + it hangs off, it is not an aside — it is the **Mechanism** row, and leaving it as a card + only opens white space, since the callout pushes the vertical cursor below itself. +- **A group border used as decoration.** `\stgroup` names its members as one composite + object; drawn around whatever happened to be adjacent, it invents a grouping the + computation does not have. If you cannot caption the outline, do not draw it. +- **A group whose hue invents a new object.** The outline around the three `q` sheets is + still `q`. A fresh hue there claims a fourth tensor exists; use the members' role, or + `neutral` when the members really are of mixed roles. See `semantics.md`. - **A meaning box that repeats the shapes.** The shapes are already under every block. The box is for what the axes *mean* and what the operation *does*. - **Solving crowding by shrinking type.** The type hierarchy is a hard floor; move the diff --git a/references/api.md b/references/api.md index 77b842e..e9c73cb 100644 --- a/references/api.md +++ b/references/api.md @@ -8,18 +8,26 @@ Build with `./scripts/build.sh fig.tex` — it puts `assets/` on `TEXINPUTS`, so the package does not need to be installed into your texmf tree. +The build first runs `scripts/lint.py`. It rejects ledger changes, untracked absolute +objects, repeated anonymous dimensions, raw TikZ rectangles, a formula placed before the +last row, an unclosed `\stgroup`, a callout anchored to anything other than a band or a +second callout on one band, and more than four active hue families per row. Intentional galleries/tests may +put `% supertensor-lint: allow-absolute, allow-missing-formula` near the top; do not add an +exemption to a deliverable merely to make it pass. + ## Ledgers ```tex \stsetrole{q}{stTeal} % role -> color. Macros take a ROLE, never a color. \stdim{T}{6} % symbolic axis -> physical edge length in cells -\stsetauthor{...} % default 五道口纳什 \stsetlabels{A}{O}{M} % override the three meaning-box rail labels \stsetrail{3.2em} % width of the bold label rail ``` Colors: `stTeal stOrange stCoral stViolet stGray stInk`. An unknown role falls back to gray **and emits a package warning**, which `build.sh` turns into a failed build. +Redeclaring an axis or role with the same value is harmless; changing its value emits a +warning and keeps the original mapping. Lengths: `\stunit` (one cell, 4.6 mm) and `\sttilegap` (white gutter, 0.5 mm). @@ -48,6 +56,7 @@ the next tensor, and two stages started from two different `x` share no rail. | `\strow{name}{height}` | open a band; `height` is an axis name or an integer | | `\strowend` | `fit` the band into `name`, then `\stlane` it | | `\stcol{name}{height}` … `\stcolend` | vertical sub-flow filling one slot of the band | +| `\stgroup[role=]{name}` … `\stgroupend` | outline naming the objects inside it as one composite | | `\stglyph{name}{$\times$}` | operator glyph (not `\stop` — plain TeX owns that name) | | `\stcomm{name}{All-Reduce}` | collective node | | `\stnode[style]{name}{text}` | any node, placed and measured by the cursor | @@ -90,6 +99,50 @@ is a warning too. Absolute placement still works everywhere — pass a coordinate instead of `{}`. Mix freely, but wrap hand-placed nodes in `\sttrack` so the cursor knows about them. +## Groups + +`\stgroup` … `\stgroupend` is the second sub-flow. It draws a thin rounded outline in the +role hue around whatever is placed between them, naming those objects as one composite: + +```tex +\stgroup[role=q]{qg} % keys: role= (hue), pad= (default \stgrouppad) + \ststack[role=q]{qh}{}{T}{dh}{3} % "these three sheets are q" +\stgroupend +\stcaption{qg}{$\mathbf q$}{$B\times T\times h\times d_h$} +``` + +The members go **inside** the block, and three properties follow from that: + +- a group can only wrap *adjacent* objects — one that reached across the band would + swallow whatever sat between its members; +- the group, not its last member, terminates a pending `\stlink` and sources the next + one, so an arrow lands **on** the outline instead of ending inside it and crossing a + border that is not its endpoint; +- the padding is reserved on both sides, so the neighbour cannot land tangent to it. + +`\strowend` fits the group, so `\stcaption{qg}{...}` hangs from the caption lane below the +outline, not below the member. A group may contain a `\stcol`; it may not sit inside one, +and it may not nest. An unclosed group is a lint error. + +## Callouts + +`\stcallout{name}{text width}{band}{title}{body}` hangs a side note card off the right edge +of a **finished** band, top-aligned with it. Call it after `\strowend`: + +```tex +\strowend +\stcaption{g}{$\mathbf g$}{$B\times T\times h\times d_k$} +\stcallout{n1}{5.2cm}{rowB}{下界化换来了什么}{Kimi Linear 的 $g$ 无下界……} +``` + +Arg 3 must be a `\strow` band name — one aside per band, and never anchored to a single +face. Both rules are lint errors, because a card beside one operand reads as a step in the +computation (`layout.md`). Inside an open band it is a package error. + +A callout pushes the vertical cursor below its own bottom edge. A card taller than its band +therefore opens visible white space rather than colliding with the next stage — which is +the signal that its text belongs in `\stmeaningbox` instead. + ## Faces ```tex @@ -118,6 +171,8 @@ Keys: `\stindexface` entries are row-major, `rows*cols` of them; `.` leaves a cell blank. It deliberately has no lightness ramp — see `semantics.md`. +The package validates the entry count, pattern name, level range, and every `pattern=data` +row's count, width and `0`–`3` domain; any mismatch makes `build.sh` fail. ```tex \stface[role=w, pattern=data, level=3, @@ -167,13 +222,15 @@ Connectors route on the background layer automatically. ```tex \stbbox{all} % flow: everything drawn so far \stmeaningbox{mb}{16.6cm}{all}{axes text}{objects text}{mechanism text} -\stsignature{因果多头注意力(掩码 + 拼接投影)}{mb} +\stsignature{因果多头注意力(掩码 + 拼接投影)}{mb} % optional ``` Arg 2 is the total box width; arg 3 is the node it hangs below. `\stbbox` already contains every caption and top label; if you build the `fit` by hand, include them yourself or the box will overlap them. An empty `{}` row is dropped. +`\stsignature` renders only its subject; it has no author or handle mechanism. + ## Gotchas - `\ststack` never re-enters `\stface`; if you extend the package, do not pass diff --git a/references/checklist.md b/references/checklist.md index 5f1cb12..d1597bf 100644 --- a/references/checklist.md +++ b/references/checklist.md @@ -1,7 +1,8 @@ # Pre-delivery checklist -A clean `build.sh` proves only that TeX was happy. Nothing below is checked by the compiler. -Work through it against the rendered PNG. Any mandatory violation means redraw, not patch. +`build.sh` already checks the source rules, package invariants, missing glyphs and text-box +overflow. The math, semantics and rendered relationships below still require inspection. +Any mandatory violation means redraw, not patch. ## 1. Math (before looking at the picture) @@ -30,18 +31,20 @@ Open the PNG at 100 %. Most of the first three items are automatic under the flo they still need looking at, because the cursor only guarantees that objects do not *push* into each other, not that the figure reads correctly. -- [ ] No absolute coordinate that a `\strow`/`\stcol` could have expressed. Every remaining - hand-placed node is wrapped in `\sttrack`. -- [ ] `\sttopformula` called after the bands, so the formula is centered on the real figure. +- [ ] Every lint exemption in the source is genuinely required and explained; deliverables + normally have none. - [ ] No forbidden intersection, tangency, clipping or occlusion — including stack offset sheets, brackets, arrow labels and the meaning box. - [ ] Every connector's white label underlay covers only its own connector. - [ ] No connector crosses a box that is not its endpoint. - [ ] Symbols and shapes sit on two flat lanes per row; stage headings share a left rail. +- [ ] Every `\stgroup` outline names a composite the computation actually has, is captioned, + and carries its members' role hue (`neutral` only for genuinely mixed members). +- [ ] Every `\stcallout` is an aside about its whole band, not a step: one per band, no + taller than the band, and nothing in it that belongs in `\stmeaningbox`. - [ ] Top zone compact (≤2 formula lines, no shape underbraces). - [ ] Bottom box: one column, ≤3 rows, no overflow, no shrunken type. -- [ ] Signature outside the box, one line, names what the figure actually shows, not clipped - and not visually dominant. +- [ ] If requested, signature is outside the box, one line, accurate, unclipped and subdued. - [ ] Structural support exact: known zeros unfilled, masks and diagonals exactly right. ## 5. Thumbnail audit diff --git a/references/fallback.md b/references/fallback.md new file mode 100644 index 0000000..9e85f79 --- /dev/null +++ b/references/fallback.md @@ -0,0 +1,18 @@ +# Fallback without LaTeX + +Use this path only when `scripts/preflight.sh` exits 2. A fallback is not equivalent to the +TikZ package: say explicitly that cursor placement, ledger locking, face-data validation, +caption lanes and package warnings are unavailable. + +## Preserve manually + +1. Recompute the shape/semantics ledger before drawing. +2. Use one scale function from symbolic axis names to physical lengths; never size two + occurrences of the same axis independently. +3. Keep scores, indices and masks in distinct grammars; preserve exact zeros and support. +4. Derive every placement from previous bounding boxes and one gutter constant. +5. Export SVG plus PNG and inspect both full-size and at 360 px using `checklist.md`. + +Prefer SVG for editability. Use matplotlib only when it can emit SVG and the tensor cells +remain individually inspectable. Do not imitate package compliance in the delivery: name +the fallback renderer and list any inferred shape, convention or reduced guarantee. diff --git a/references/geometry.md b/references/geometry.md index f2802b3..0788c3e 100644 --- a/references/geometry.md +++ b/references/geometry.md @@ -17,8 +17,12 @@ to exactly one physical edge length across the whole figure**. Equal shapes ther an equivalence class automatically: `Q` and `V` at `T×d_h` come out identical without you lining anything up by hand. +Declaring the same axis twice with the same value is allowed. Redeclaring it with a +different value emits a package warning, preserves the first value, and fails `build.sh`. + Raw integers are accepted (`\stface{A}{(0,0)}{4}{4}`) but they opt out of the guarantee. -Use them only for a face whose axis appears nowhere else. +Use them only for a face whose axis appears nowhere else. The source linter rejects a +repeated raw dimension greater than one; give repeated dimensions a symbolic name. ## The rules diff --git a/references/layout.md b/references/layout.md index 01fd754..89bc467 100644 --- a/references/layout.md +++ b/references/layout.md @@ -34,6 +34,7 @@ Overlap is allowed only inside one declared composite: - shards tiling a parent, - outline sheets in one `\ststack`, - a bracket around its own tensor, +- a `\stgroup` outline around its own members, - a connector endpoint touching its source/target border. Every other intersection or occlusion is forbidden. @@ -68,9 +69,23 @@ Stage headings share one left rail. `\ststage` puts them there: the rail is a si `x` (`\stleftrail` to move it), and the `y` is derived from the lowest ink drawn so far, so a heading can neither drift right nor collide with the row above it. -Explanatory prose belongs in the stage subtitle, the bottom box, or above its own -connector. Never drop a floating commentary card between two operands unless it is a real -operation node (`st comm`). +Explanatory prose belongs in the stage subtitle, the bottom box, above its own connector, +or on a `\stcallout` card hanging off the right edge of a band. Never drop a floating +commentary card between two operands unless it is a real operation node (`st comm`). + +`\stcallout` is that rule made structural: it refuses to open inside a band, the linter +rejects it unless it is anchored to a `\strow` name, and a second card on one band is an +error — side-by-side cards are a dashboard, not a figure. A card that ends up much taller +than its band is telling you the same thing the overflow warning does: that text is not an +aside, it is the **Mechanism** row of `\stmeaningbox`. + +## Composites + +`\stcol` and `\stgroup` are the two sub-flows, and both exist so that a *relationship* can +be stated rather than arranged for. A group wraps its members from the inside, which is +what makes an arrow attach to the outline rather than end inside it — a connector whose +endpoint is a member but which crosses the group border is the ordinary version of "a +connector may not cross a box that is not its endpoint". ## Layers diff --git a/references/semantics.md b/references/semantics.md index 973073c..7248866 100644 --- a/references/semantics.md +++ b/references/semantics.md @@ -65,3 +65,9 @@ One tensor role keeps one hue for the whole figure — that is what `\stsetrole` gathered, resharded or regrouped view of the same data keeps the *same* role color; a new hue means a new object. Derived tensors may reuse their parent's family rather than spending a hue (`V → O → Y` in the MHA example are all violet). + +A `\stgroup` outline follows the same rule, because a group *is* a regrouped view: give it +the role of the objects it wraps — the three-sheet `q` stack and the outline that names it +as one composite are the same object, and a new hue there would claim a new tensor exists. +Only when the members genuinely differ in role does the group take `role=neutral`; that is +also the honest signal that the box is naming an arrangement rather than an object. diff --git a/references/style.md b/references/style.md index c8878ac..091d1bd 100644 --- a/references/style.md +++ b/references/style.md @@ -21,8 +21,9 @@ stages that preserve the primary path. No unrelated branches, no dashboard panel | operator | `\Large` | `st op` | | symbol | `\small` | `\stcaption` arg 2 | | shape | `\scriptsize`, muted | `\stcaption` arg 3 | +| side card | `\scriptsize\bfseries` title, `\scriptsize` body | `\stcallout`, same tier as `st note` | | bottom prose | `\small` | `\stmeaningbox` | -| signature | `\scriptsize`, low contrast | `\stsignature` | +| optional signature | `\scriptsize`, low contrast | `\stsignature` | Never shrink below this to make something fit — see `layout.md`. @@ -32,6 +33,10 @@ Separate tiles with a small white gutter and 0.5–1 pt corner rounding (`\st@ti this). Thin neutral brackets, `black!55`–`black!70` outer borders. No saturated tensor-colored outlines, no continuous spreadsheet grid. +The one exception is `\stgroup`, whose outline is drawn at `role!65`: there the outline +*is* the object being named, so the hue is doing semantic work rather than decorating a +face that already has its own fill. + **Encode support before magnitude.** Every known zero stays white/unfilled; every shown nonzero gets color. A diagonal matrix must read instantly as colored diagonal cells on a white field. `pattern=diag/band/lower/upper/causal/data` fill exactly the structural @@ -69,16 +74,23 @@ Narrow bold label rail, left-aligned ragged-right `\small` content, 8–10 pt in Keep each row compact: prefer symbol semantics over numeric configuration. When it is too long, **remove content** — never add cards, columns or smaller type. Pass `{}` to omit a row. -## Signature +## Side cards -One centered line below the box, outside it, low-contrast gray, `\scriptsize` or smaller: -`\stsignature{}{}` renders `@五道口纳什`. The subject must name -what this figure actually visualizes. Keep it on one line, with a small but visible gap. +`\stcallout` is the only sanctioned floating text card, and it is deliberately narrow in +scope: one per band, hung off the right edge of a *finished* band, never between two +operands. When the text outgrows the height of its band, it is not an aside — move it into +the **Mechanism** row of `\stmeaningbox` instead of widening or shrinking the card. + +## Optional signature + +Add a centered line only when the user or house template requests it. Keep it below the +box, outside it, low-contrast gray and on one line. `\stsignature{}{}` +renders only the subject. Do not append an author, handle or brand identity. ## Never Charts or metric insets not present in the primary formula. Decorative pills, banners, -shadows, repeated separators, explanatory cards. +shadows, repeated separators, explanatory cards other than one `\stcallout` per band. ## Reference image diff --git a/scripts/build.sh b/scripts/build.sh index cfe679b..7aafad9 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Compile a supertensor figure and export every delivery artifact. +# Lint, compile, and export every delivery artifact for a supertensor figure. # # ./scripts/build.sh figure.tex [outdir] # @@ -23,6 +23,11 @@ OUT="${2:-$SRCDIR/build}" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" mkdir -p "$OUT" +if [[ "${ST_SKIP_LINT:-0}" != "1" ]]; then + echo "==> lint $BASE" + python3 "$ROOT/scripts/lint.py" "$SRC" +fi + echo "==> xelatex $BASE" # supertensor.sty lives in assets/; keep it off the user's texmf tree. TEXINPUTS="$ROOT/assets:$SRCDIR:" \ diff --git a/scripts/lint.py b/scripts/lint.py new file mode 100755 index 0000000..1a222d7 --- /dev/null +++ b/scripts/lint.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Static source checks for supertensor figures. + +The TeX package owns geometry at render time; this linter catches source-level +escapes that TeX cannot see: mutable ledgers, hand placement, formula order, +raw rectangles, repeated anonymous dimensions, and per-row hue budget. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + + +DIM_RE = re.compile(r"\\stdim\{([^{}]+)\}\{([^{}]+)\}") +ROLE_RE = re.compile(r"\\stsetrole\{([^{}]+)\}\{([^{}]+)\}") +FACE_RE = re.compile( + r"\\(stface|ststack|stindexface)(?:\[[^\]]*\])?" + r"\{([^{}]+)\}\{([^{}]*)\}\{([^{}]+)\}\{([^{}]+)\}", + re.DOTALL, +) +ROW_RE = re.compile(r"\\strow\{[^{}]+\}\{[^{}]+\}(.*?)\\strowend", re.DOTALL) +ROW_NAME_RE = re.compile(r"\\strow\{([^{}]+)\}\{[^{}]+\}") +ROLE_KEY_RE = re.compile(r"role\s*=\s*([A-Za-z0-9_-]+)") +CALLOUT_RE = re.compile(r"\\stcallout\{([^{}]+)\}\{[^{}]+\}\{([^{}]+)\}") + + +def strip_comments(source: str) -> str: + return re.sub(r"(? int: + return source.count("\n", 0, offset) + 1 + + +def directives(source: str) -> set[str]: + found: set[str] = set() + for match in re.finditer(r"^\s*%\s*supertensor-lint:\s*(.+)$", source, re.MULTILINE): + found.update(item.strip() for item in match.group(1).split(",")) + return found + + +def check_unique( + source: str, pattern: re.Pattern[str], kind: str, errors: list[str] +) -> dict[str, str]: + values: dict[str, str] = {} + for match in pattern.finditer(source): + name, value = (part.strip() for part in match.groups()) + previous = values.get(name) + if previous is not None and previous != value: + errors.append( + f"line {line_of(source, match.start())}: {kind} {name!r} changes " + f"from {previous!r} to {value!r}" + ) + else: + values[name] = value + return values + + +def lint(path: Path) -> list[str]: + raw = path.read_text(encoding="utf-8") + allowed = directives(raw) + source = strip_comments(raw) + errors: list[str] = [] + + dimensions = check_unique(source, DIM_RE, "axis", errors) + roles = check_unique(source, ROLE_RE, "role", errors) + + formula_positions = [m.start() for m in re.finditer(r"\\sttopformula\b", source)] + row_ends = [m.start() for m in re.finditer(r"\\strowend\b", source)] + if not formula_positions and "allow-missing-formula" not in allowed: + errors.append("missing \\sttopformula (add an explicit lint exemption for galleries/tests)") + elif formula_positions and row_ends and formula_positions[-1] < row_ends[-1]: + errors.append( + f"line {line_of(source, formula_positions[-1])}: \\sttopformula must follow the last \\strowend" + ) + + tracked = set(re.findall(r"\\sttrack\{([^{}]+)\}", source)) + raw_dimensions: dict[str, list[int]] = {} + for match in FACE_RE.finditer(source): + _macro, name, coordinate, rows, cols = match.groups() + if coordinate.strip() and name not in tracked and "allow-absolute" not in allowed: + errors.append( + f"line {line_of(source, match.start())}: absolute object {name!r} has no \\sttrack{{{name}}}" + ) + for value in (rows.strip(), cols.strip()): + if value.isdigit() and value != "1": + raw_dimensions.setdefault(value, []).append(line_of(source, match.start())) + + if "allow-repeated-raw-dim" not in allowed: + for value, lines in sorted(raw_dimensions.items()): + if len(lines) > 1: + errors.append( + f"lines {', '.join(map(str, lines))}: raw dimension {value!r} is reused; declare a symbolic axis with \\stdim" + ) + + if "allow-raw-tikz" not in allowed: + for match in re.finditer( + r"\\(?:draw|fill|path)\b[^;]*\brectangle\b", source, re.DOTALL + ): + errors.append( + f"line {line_of(source, match.start())}: hand-drawn rectangle bypasses \\stface" + ) + + # \stgroup is a block. An unclosed one silently swallows the rest of the + # band into the group's fit list, which TeX only reports as an empty row. + opened = len(re.findall(r"\\stgroup(?![A-Za-z])", source)) + closed = len(re.findall(r"\\stgroupend(?![A-Za-z])", source)) + if opened != closed: + errors.append( + f"{opened} \\stgroup vs {closed} \\stgroupend: every group must be closed" + ) + + # A callout hangs off a BAND. Anchored to a face it becomes the floating + # commentary card between two operands that layout.md forbids, and two of + # them on one band is a dashboard, not a figure. + band_names = set(ROW_NAME_RE.findall(source)) + callout_anchors: dict[str, str] = {} + for match in CALLOUT_RE.finditer(source): + name, anchor = (part.strip() for part in match.groups()) + line = line_of(source, match.start()) + if anchor not in band_names: + errors.append( + f"line {line}: callout {name!r} is anchored to {anchor!r}, which is not a " + f"\\strow band; a card hanging off a single object reads as a step" + ) + elif anchor in callout_anchors: + errors.append( + f"line {line}: callout {name!r} is the second card on band {anchor!r} " + f"(after {callout_anchors[anchor]!r}); one aside per band" + ) + callout_anchors.setdefault(anchor, name) + + for row_number, row in enumerate(ROW_RE.finditer(source), start=1): + row_roles = set(ROLE_KEY_RE.findall(row.group(1))) + colors = {roles.get(role, "") for role in row_roles} + active = colors - {"stGray"} + if len(active) > 4: + errors.append( + f"line {line_of(source, row.start())}: row {row_number} uses {len(active)} active hue families " + f"({', '.join(sorted(active))}); maximum is 4 plus gray" + ) + + # Declared dimensions are intentionally allowed to be illustrative. This + # lookup merely keeps the declaration parse exercised and future-proof. + del dimensions + return errors + + +def main() -> int: + parser = argparse.ArgumentParser(description="lint a supertensor .tex source") + parser.add_argument("sources", nargs="+", type=Path) + args = parser.parse_args() + + failed = False + for path in args.sources: + if not path.is_file(): + print(f"supertensor-lint: no such file: {path}", file=sys.stderr) + failed = True + continue + errors = lint(path) + if errors: + failed = True + print(f"!! {path}", file=sys.stderr) + for error in errors: + print(f" {error}", file=sys.stderr) + else: + print(f" lint {path}") + return 1 if failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/preflight.sh b/scripts/preflight.sh index 25adbb7..bdac875 100755 --- a/scripts/preflight.sh +++ b/scripts/preflight.sh @@ -12,7 +12,7 @@ QUIET=0 [[ "${1:-}" == "--quiet" ]] && QUIET=1 say() { [[ $QUIET -eq 1 ]] || echo -e "$*"; } -ok=0; warn=0; fail=0 +ok=0; warn=0; fail=0; cjk_warn=0; export_warn=0 check() { # name, command local name="$1"; shift if "$@" >/dev/null 2>&1; then say " ok $name"; ok=$((ok+1)); return 0 @@ -27,11 +27,11 @@ check "tikz.sty" kpsewhich tikz.sty || fail=$((fail+1)) check "xstring.sty" kpsewhich xstring.sty || fail=$((fail+1)) say "--- chinese figures ---" -check "ctex.sty" kpsewhich ctex.sty || warn=$((warn+1)) -check "fandol font" kpsewhich FandolSong-Regular.otf || warn=$((warn+1)) +check "ctex.sty" kpsewhich ctex.sty || { warn=$((warn+1)); cjk_warn=1; } +check "fandol font" kpsewhich FandolSong-Regular.otf || { warn=$((warn+1)); cjk_warn=1; } say "--- raster / vector export ---" -check "pdftocairo" command -v pdftocairo || warn=$((warn+1)) +check "pdftocairo" command -v pdftocairo || { warn=$((warn+1)); export_warn=1; } check "latexmk (optional)" command -v latexmk || true if [[ $fail -gt 0 ]]; then @@ -44,9 +44,13 @@ fi if [[ $warn -gt 0 ]]; then say "" say "RESULT: degraded." - say " - missing ctex/fandol -> English-label figures only; do not substitute" - say " an OS-specific CJK font without telling the user it costs portability." - say " - missing pdftocairo -> deliver PDF only, and say PNG/SVG were skipped." + if [[ $cjk_warn -eq 1 ]]; then + say " - missing ctex/fandol -> English-label figures only; do not substitute" + say " an OS-specific CJK font without telling the user it costs portability." + fi + if [[ $export_warn -eq 1 ]]; then + say " - missing pdftocairo -> deliver PDF only, and say PNG/SVG were skipped." + fi exit 1 fi say "" diff --git a/scripts/test.sh b/scripts/test.sh index 1bc33bb..675b461 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Build every example and the smoke test. Any dirty build fails the run. +# Build every valid example, then prove invalid TeX and lint fixtures fail. # # ./scripts/test.sh # @@ -21,8 +21,30 @@ for f in "$ROOT"/tests/*.tex "$ROOT"/examples/*.tex; do fi done +for f in "$ROOT"/tests/invalid/*.tex; do + [[ -e "$f" ]] || continue + name="$(basename "$f")" + if ST_SKIP_LINT=1 "$ROOT/scripts/build.sh" "$f" >/dev/null 2>&1; then + echo " FAIL $name (invalid fixture built cleanly)" + fail=$((fail+1)) + else + echo " ok $name (rejected by package/build)" + fi +done + +for f in "$ROOT"/tests/lint-invalid/*.tex; do + [[ -e "$f" ]] || continue + name="$(basename "$f")" + if python3 "$ROOT/scripts/lint.py" "$f" >/dev/null 2>&1; then + echo " FAIL $name (invalid fixture passed lint)" + fail=$((fail+1)) + else + echo " ok $name (rejected by lint)" + fi +done + if [[ $fail -gt 0 ]]; then - echo "$fail failing figure(s); rerun scripts/build.sh on one to see why" >&2 + echo "$fail failing check(s); rerun the reported build or lint command to inspect" >&2 exit 1 fi -echo "all figures build clean" +echo "all positive and negative checks passed" diff --git a/tests/flow.tex b/tests/flow.tex index 42aa29f..51ee0de 100644 --- a/tests/flow.tex +++ b/tests/flow.tex @@ -58,6 +58,9 @@ \stcaption{Wc}{$\mathbf W^{(r)}$}{$d\times T$} \stcaption{Z}{$\mathbf Z$}{$T\times T$} +\sttopformula{F}{$\displaystyle + \operatorname{flow}(X,W)\;:\;\text{对象按自身边界框依次占位}$} + \stbbox{all} \stmeaningbox{mb}{15cm}{all} {$T$ 序列长,$d$ 模型维,$r$ 低秩维} diff --git a/tests/group-callout.tex b/tests/group-callout.tex new file mode 100644 index 0000000..41b7379 --- /dev/null +++ b/tests/group-callout.tex @@ -0,0 +1,78 @@ +% Regression test for \stgroup and \stcallout. +% - a group over one stack (the three sheets are one composite object), +% - a group over two adjacent shards (the pair is one partitioned parent), +% - a side card hanging off the right edge of each band. +% No absolute coordinate appears anywhere in this figure. +% ../scripts/build.sh group-callout.tex +\documentclass[border=10pt]{standalone} +\usepackage[cjk]{supertensor} + +\stsetrole{act}{stTeal} +\stsetrole{w}{stOrange} +\stsetrole{q}{stTeal} +\stsetrole{k}{stViolet} +\stsetrole{v}{stCoral} + +\stdim{T}{7} +\stdim{d}{5} +\stdim{dh}{3} + +\begin{document} +\begin{tikzpicture} + +\ststage{S1}{一、分组框:把已经排好的面命名为一个复合对象} +\strow{R1}{T} + \stface[role=act, bracket=true]{X}{}{T}{d} + \stlink{l1}{三路投影} + \stgroup[role=q]{QG} + \ststack[role=q]{Q}{}{T}{dh}{3} + \stgroupend + \stgroup[role=k]{KG} + \ststack[role=k]{K}{}{T}{dh}{3} + \stgroupend + \stgroup[role=v]{VG} + \ststack[role=v]{V}{}{T}{dh}{3} + \stgroupend +\strowend +\stcaption{X}{$\mathbf X$}{$T\times d$} +\stcaption{QG}{$\mathbf q$}{$h\times T\times d_h$} +\stcaption{KG}{$\mathbf k$}{$h\times T\times d_h$} +\stcaption{VG}{$\mathbf v$}{$h\times T\times d_h$} +\stcallout{N1}{4.2cm}{R1}{分组框在说什么}% + {外框与里面的面同色,因为它圈的是同一个对象的多头视图,不是一个新张量。 + 框的内边距计入行的包围盒,所以下面的符号轨仍然从框底起算。} + +\ststage{S2}{二、分组框圈住两片相邻的分片,标注它们合起来是谁} +\strow{R2}{T} + \stface[role=act]{H}{}{T}{d} + \stglyph{m2}{$\times$} + \stgroup[role=w, pad=2.2mm]{WG} + \stcol{Wc}{d} + \stface[role=w]{Wa}{}{dh}{T} + \stface[role=w, gap=0pt]{Wb}{}{2}{T} + \stcolend + \stgroupend + \stlink{l2}{} + \stface[role=q, bracket=true]{Z}{}{T}{T} +\strowend +\stcaption{H}{$\mathbf H$}{$T\times d$} +\stcaption{WG}{$\mathbf W$}{$d\times T$} +\stcaption{Z}{$\mathbf Z$}{$T\times T$} +\stcallout{N2}{4.2cm}{R2}{一行一张卡}% + {同一个 band 上挂两张卡会被 lint 拒绝:并排的说明卡是仪表盘,不是图。} + +\sttopformula{F}{$\displaystyle \mathbf Z=\mathbf H\,\mathbf W,\qquad + \mathbf W=\begin{bmatrix}\mathbf W_a\\ \mathbf W_b\end{bmatrix}$} + +\stbbox{all} +\stmeaningbox{mb}{17cm}{all} + {$T$ 序列长,$d$ 模型维,$d_h$ 头宽,$h$ 头数} + {} + {分组框是横向的子流:成员写在块里,所以它只能圈住相邻的对象; + 两侧的内边距各占一份宽度,箭头也接在外框上而不是成员上—— + 否则连接线会穿过一个并非自己端点的框。 + 说明卡挂在 band 右侧,并把纵向游标压到自己底边以下,所以卡片过高只会撑开空白, + 不会压住下一段。} + +\end{tikzpicture} +\end{document} diff --git a/tests/invalid/axis-redefinition.tex b/tests/invalid/axis-redefinition.tex new file mode 100644 index 0000000..12ff065 --- /dev/null +++ b/tests/invalid/axis-redefinition.tex @@ -0,0 +1,7 @@ +\documentclass[border=2pt]{standalone} +\usepackage[en]{supertensor} +\stdim{d}{4} +\stdim{d}{7} +\begin{document}\begin{tikzpicture} + \stface{A}{(0,0)}{d}{d} +\end{tikzpicture}\end{document} diff --git a/tests/invalid/index-count.tex b/tests/invalid/index-count.tex new file mode 100644 index 0000000..4d691b4 --- /dev/null +++ b/tests/invalid/index-count.tex @@ -0,0 +1,8 @@ +\documentclass[border=2pt]{standalone} +\usepackage[en]{supertensor} +\stsetrole{idx}{stOrange} +\stdim{m}{2} +\stdim{n}{3} +\begin{document}\begin{tikzpicture} + \stindexface[role=idx]{I}{(0,0)}{m}{n}{0,1,2,3,4} +\end{tikzpicture}\end{document} diff --git a/tests/invalid/invalid-data-rows.tex b/tests/invalid/invalid-data-rows.tex new file mode 100644 index 0000000..873dc04 --- /dev/null +++ b/tests/invalid/invalid-data-rows.tex @@ -0,0 +1,8 @@ +\documentclass[border=2pt]{standalone} +\usepackage[en]{supertensor} +\stsetrole{x}{stTeal} +\stdim{m}{2} +\stdim{n}{3} +\begin{document}\begin{tikzpicture} + \stface[role=x,pattern=data,data={303}]{A}{(0,0)}{m}{n} +\end{tikzpicture}\end{document} diff --git a/tests/invalid/invalid-data.tex b/tests/invalid/invalid-data.tex new file mode 100644 index 0000000..dd2da95 --- /dev/null +++ b/tests/invalid/invalid-data.tex @@ -0,0 +1,8 @@ +\documentclass[border=2pt]{standalone} +\usepackage[en]{supertensor} +\stsetrole{x}{stTeal} +\stdim{m}{2} +\stdim{n}{3} +\begin{document}\begin{tikzpicture} + \stface[role=x,pattern=data,data={303,34}]{A}{(0,0)}{m}{n} +\end{tikzpicture}\end{document} diff --git a/tests/invalid/invalid-level.tex b/tests/invalid/invalid-level.tex new file mode 100644 index 0000000..4bcbe25 --- /dev/null +++ b/tests/invalid/invalid-level.tex @@ -0,0 +1,7 @@ +\documentclass[border=2pt]{standalone} +\usepackage[en]{supertensor} +\stsetrole{x}{stTeal} +\stdim{d}{3} +\begin{document}\begin{tikzpicture} + \stface[role=x,pattern=solid,level=9]{A}{(0,0)}{d}{d} +\end{tikzpicture}\end{document} diff --git a/tests/invalid/invalid-pattern.tex b/tests/invalid/invalid-pattern.tex new file mode 100644 index 0000000..e66198c --- /dev/null +++ b/tests/invalid/invalid-pattern.tex @@ -0,0 +1,7 @@ +\documentclass[border=2pt]{standalone} +\usepackage[en]{supertensor} +\stsetrole{x}{stTeal} +\stdim{d}{3} +\begin{document}\begin{tikzpicture} + \stface[role=x,pattern=checkerboard]{A}{(0,0)}{d}{d} +\end{tikzpicture}\end{document} diff --git a/tests/invalid/role-redefinition.tex b/tests/invalid/role-redefinition.tex new file mode 100644 index 0000000..5c576f4 --- /dev/null +++ b/tests/invalid/role-redefinition.tex @@ -0,0 +1,8 @@ +\documentclass[border=2pt]{standalone} +\usepackage[en]{supertensor} +\stsetrole{x}{stTeal} +\stsetrole{x}{stCoral} +\stdim{d}{3} +\begin{document}\begin{tikzpicture} + \stface[role=x]{A}{(0,0)}{d}{d} +\end{tikzpicture}\end{document} diff --git a/tests/lint-invalid/absolute.tex b/tests/lint-invalid/absolute.tex new file mode 100644 index 0000000..a59daee --- /dev/null +++ b/tests/lint-invalid/absolute.tex @@ -0,0 +1,7 @@ +\documentclass{standalone} +\usepackage[en]{supertensor} +\stdim{d}{3} +\begin{document}\begin{tikzpicture} + \stface{A}{(0,0)}{d}{d} + \sttopformula{F}{$A$} +\end{tikzpicture}\end{document} diff --git a/tests/lint-invalid/callout-anchor.tex b/tests/lint-invalid/callout-anchor.tex new file mode 100644 index 0000000..380ae5d --- /dev/null +++ b/tests/lint-invalid/callout-anchor.tex @@ -0,0 +1,12 @@ +% A callout anchored to a face rather than to a band: the card lands beside a +% single operand and reads as a step in the computation. +\documentclass{standalone} +\usepackage[en]{supertensor} +\stdim{T}{4} +\begin{document}\begin{tikzpicture} + \strow{R1}{T} + \stface{A}{}{T}{T} + \strowend + \stcallout{N1}{3cm}{A}{aside}{anchored to a face, not to the band} + \sttopformula{F}{$A$} +\end{tikzpicture}\end{document} diff --git a/tests/lint-invalid/callout-duplicate.tex b/tests/lint-invalid/callout-duplicate.tex new file mode 100644 index 0000000..1d8e7f6 --- /dev/null +++ b/tests/lint-invalid/callout-duplicate.tex @@ -0,0 +1,13 @@ +% Two asides on one band: a dashboard, not a figure. The second card belongs +% in \stmeaningbox. +\documentclass{standalone} +\usepackage[en]{supertensor} +\stdim{T}{4} +\begin{document}\begin{tikzpicture} + \strow{R1}{T} + \stface{A}{}{T}{T} + \strowend + \stcallout{N1}{3cm}{R1}{first}{one aside per band} + \stcallout{N2}{3cm}{R1}{second}{this one has nowhere to go} + \sttopformula{F}{$A$} +\end{tikzpicture}\end{document} diff --git a/tests/lint-invalid/formula-order.tex b/tests/lint-invalid/formula-order.tex new file mode 100644 index 0000000..637ed98 --- /dev/null +++ b/tests/lint-invalid/formula-order.tex @@ -0,0 +1,9 @@ +\documentclass{standalone} +\usepackage[en]{supertensor} +\stdim{d}{3} +\begin{document}\begin{tikzpicture} + \sttopformula{F}{$A$} + \strow{row}{d} + \stface{A}{}{d}{d} + \strowend +\end{tikzpicture}\end{document} diff --git a/tests/lint-invalid/group-unclosed.tex b/tests/lint-invalid/group-unclosed.tex new file mode 100644 index 0000000..49504a1 --- /dev/null +++ b/tests/lint-invalid/group-unclosed.tex @@ -0,0 +1,12 @@ +% An unclosed \stgroup swallows the rest of the band into its fit list; TeX +% only reports the resulting empty row, which does not point at the cause. +\documentclass{standalone} +\usepackage[en]{supertensor} +\stdim{T}{4} +\begin{document}\begin{tikzpicture} + \strow{R1}{T} + \stgroup{G} + \stface{A}{}{T}{T} + \strowend + \sttopformula{F}{$A$} +\end{tikzpicture}\end{document} diff --git a/tests/lint-invalid/hue-budget.tex b/tests/lint-invalid/hue-budget.tex new file mode 100644 index 0000000..0bfe3a6 --- /dev/null +++ b/tests/lint-invalid/hue-budget.tex @@ -0,0 +1,18 @@ +\documentclass{standalone} +\usepackage[en]{supertensor} +\stsetrole{a}{stTeal} +\stsetrole{b}{stOrange} +\stsetrole{c}{stCoral} +\stsetrole{d}{stViolet} +\stsetrole{e}{stInk} +\stdim{x}{2} +\begin{document}\begin{tikzpicture} + \strow{row}{x} + \stface[role=a]{A}{}{x}{x} + \stface[role=b]{B}{}{x}{x} + \stface[role=c]{C}{}{x}{x} + \stface[role=d]{D}{}{x}{x} + \stface[role=e]{E}{}{x}{x} + \strowend + \sttopformula{F}{$A+B+C+D+E$} +\end{tikzpicture}\end{document} diff --git a/tests/lint-invalid/raw-rectangle.tex b/tests/lint-invalid/raw-rectangle.tex new file mode 100644 index 0000000..e4e19f9 --- /dev/null +++ b/tests/lint-invalid/raw-rectangle.tex @@ -0,0 +1,6 @@ +\documentclass{standalone} +\usepackage[en]{supertensor} +\begin{document}\begin{tikzpicture} + \fill (0,0) rectangle (2,2); + \sttopformula{F}{$A$} +\end{tikzpicture}\end{document} diff --git a/tests/smoke.tex b/tests/smoke.tex index 3c061aa..bf4ffa6 100644 --- a/tests/smoke.tex +++ b/tests/smoke.tex @@ -1,3 +1,5 @@ +% supertensor-lint: allow-absolute, allow-missing-formula +% Legacy absolute-placement smoke test; flow layout is covered by flow.tex. \documentclass[border=8pt]{standalone} \usepackage[cjk]{supertensor}