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
This commit is contained in:
dela
2026-08-05 16:41:22 +08:00
parent 7b59c81d02
commit 866173a831
34 changed files with 786 additions and 54 deletions
+7 -3
View File
@@ -14,7 +14,8 @@ SKILL.md the skill entry point (lean; loads references on demand)
references/ geometry, semantics, layout, style, api, checklist, antipatterns references/ geometry, semantics, layout, style, api, checklist, antipatterns
assets/supertensor.sty the macro package assets/supertensor.sty the macro package
scripts/preflight.sh is the TikZ + CJK path available? 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 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 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. 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 A clean build now also proves the source avoided untracked absolute objects, ledger
right. That is what `references/checklist.md` is for. 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 ## Provenance
+9 -7
View File
@@ -18,8 +18,8 @@ A figure built from raw TikZ has to re-earn every invariant by hand and usually
## Workflow ## Workflow
1. **Preflight.** `./scripts/preflight.sh`. Exit 0 = TikZ+CJK path. Exit 1 = degraded 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 (say so in the delivery). Exit 2 = no LaTeX; read `references/fallback.md` before
explicitly that the figure is not TikZ. falling back and state explicitly which package guarantees are unavailable.
2. **Reduce** the input to one primary computation path. Drop equivalent objectives, 2. **Reduce** the input to one primary computation path. Drop equivalent objectives,
diagnostics, and secondary metrics unless asked for. diagnostics, and secondary metrics unless asked for.
3. **Build two ledgers** before drawing anything: 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 flow layout — `\ststage` / `\strow` … `\strowend`, empty coordinate arguments, gaps
declared once. Reach for an absolute coordinate only when no band can express the declared once. Reach for an absolute coordinate only when no band can express the
placement. See `references/api.md` and `references/layout.md`. 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 5. **Build and audit.** `./scripts/build.sh fig.tex` runs the source linter, TeX checks and
happy; then run the visual audit in `references/checklist.md` against the PNG at full exports. Then run the remaining visual/semantic audit in `references/checklist.md`
size and at thumbnail size. Redraw on any mandatory-invariant violation. 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`, 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 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` | | discrete choices (indices, token ids, expert ids) | symbols in cells, no ramp | `\stindexface` |
| data movement, collectives, non-linear ops | arrows and nodes | `\stlink`, `\stcomm` | | 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` | | 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, Combine grammars only when each one adds information. Known zeros stay unfilled; masks,
diagonals, sparsity and partitions must encode their exact structure. 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. 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. - **English figures:** `\usepackage[en]{supertensor}` — same geometry, English rail labels.
- Keep math in LaTeX, not raw Unicode. - Keep math in LaTeX, not raw Unicode.
- The identification line is `\stsignature{<subject>}{<box>}`; it renders - Add `\stsignature{<subject>}{<box>}` only when the user or house template asks for an
`<subject>@五道口纳什`. Change the handle with `\stsetauthor{...}` only when asked. identification line. It renders only the subject, with no author or handle.
## Iterating ## Iterating
+4
View File
@@ -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."
+189 -13
View File
@@ -6,7 +6,7 @@
%% Options: cjk load ctex with the portable fandol fontset (XeLaTeX) %% Options: cjk load ctex with the portable fandol fontset (XeLaTeX)
%% en English rail labels in the meaning box (default: zh) %% en English rail labels in the meaning box (default: zh)
\NeedsTeXFormat{LaTeX2e} \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@cjk\st@cjkfalse
\newif\ifst@en\st@enfalse \newif\ifst@en\st@enfalse
@@ -46,8 +46,20 @@
% Role registry: draw macros take a ROLE, never a color, so one tensor role % Role registry: draw macros take a ROLE, never a color, so one tensor role
% keeps one hue across every stage of the figure. % keeps one hue across every stage of the figure.
% \stsetrole{X}{stTeal} -> role "X" is teal everywhere % \stsetrole{X}{stTeal} -> role "X" is teal everywhere. Repeating the same
\newcommand{\stsetrole}[2]{\expandafter\gdef\csname st@role@#1\endcsname{#2}} % 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 % Expandable on purpose: usable inside \edef. Undeclared roles fall back to
% neutral gray and are reported at the end of the run. % neutral gray and are reported at the end of the run.
\newcommand{\strole}[1]{% \newcommand{\strole}[1]{%
@@ -68,7 +80,17 @@
% then every face built from `d' has the same physical edge, everywhere. % then every face built from `d' has the same physical edge, everywhere.
\newlength{\stunit}\setlength{\stunit}{4.6mm} \newlength{\stunit}\setlength{\stunit}{4.6mm}
\newlength{\sttilegap}\setlength{\sttilegap}{0.5mm} \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} \newcommand{\stresolve}[1]{\ifcsname st@dim@#1\endcsname\csname st@dim@#1\endcsname\else#1\fi}
% --------------------------------------------------------- type hierarchy --- % --------------------------------------------------------- type hierarchy ---
@@ -122,14 +144,16 @@
\newlength{\st@vh} \newlength{\st@vh}
\newif\ifst@inrow \newif\ifst@inrow
\newif\ifst@incol \newif\ifst@incol
\newif\ifst@ingroup
\newif\ifst@first \newif\ifst@first
\newcommand{\stlayoutreset}{% \newcommand{\stlayoutreset}{%
\global\st@railx=0pt \global\st@ycur=0pt \global\st@railx=0pt \global\st@ycur=0pt
\global\st@cx=0pt \global\st@cy=0pt \global\st@bandh=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@rowlist{}\gdef\st@alllist{}\gdef\st@rowname{}%
\gdef\st@collist{}\gdef\st@colname{}% \gdef\st@collist{}\gdef\st@colname{}%
\gdef\st@grouplist{}\gdef\st@gname{}%
\gdef\st@lastnode{}\gdef\st@linklabel{}\gdef\st@linkprev{}} \gdef\st@lastnode{}\gdef\st@linklabel{}\gdef\st@linkprev{}}
\stlayoutreset \stlayoutreset
\newcommand{\stleftrail}[1]{\global\st@railx=\dimexpr#1\relax} \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 % 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 % terminate a pending connector, or the arrow would land on the first sheet of
% the stack instead of on the column as a whole. % 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]{% \newcommand{\st@regrow}[1]{%
\ifst@incol \ifst@incol
\xdef\st@collist{\st@collist(#1)}\st@regall{#1}% \xdef\st@collist{\st@collist(#1)}\st@regall{#1}%
\else\ifst@ingroup
\xdef\st@grouplist{\st@grouplist(#1)}\st@regall{#1}%
\else \else
\xdef\st@rowlist{\st@rowlist(#1)}\st@regall{#1}% \xdef\st@rowlist{\st@rowlist(#1)}\st@regall{#1}%
\st@drawpendinglink{#1}% \st@drawpendinglink{#1}%
\gdef\st@lastnode{#1}% \gdef\st@lastnode{#1}%
\fi} \fi\fi}
\newcommand{\st@needrow}[1]{% \newcommand{\st@needrow}[1]{%
\ifst@inrow\else \ifst@inrow\else
@@ -353,6 +382,55 @@
bracket=false, border=true, tiles=true, 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. % \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 % 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 % 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}% \pgfkeys{/st/face/.cd,#1}%
\edef\st@rows{\stresolve{#2}}% \edef\st@rows{\stresolve{#2}}%
\edef\st@cols{\stresolve{#3}}% \edef\st@cols{\stresolve{#3}}%
\st@checkpattern
\st@checklevel
\st@checkdata
\stcheckrole{\st@role}% \stcheckrole{\st@role}%
\edef\st@col{\strole{\st@role}}% \edef\st@col{\strole{\st@role}}%
\pgfmathsetlengthmacro{\st@w}{\st@cols*\stunit}% \pgfmathsetlengthmacro{\st@w}{\st@cols*\stunit}%
@@ -438,6 +519,13 @@
\newcommand{\stindexface}[6][]{% \newcommand{\stindexface}[6][]{%
\begingroup \begingroup
\st@setup{#1}{#4}{#5}% \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}% \ifblank{#3}%
{\ifst@bracket\pgfmathsetlengthmacro{\st@tw}{\st@w+6.8mm}\else\let\st@tw\st@w\fi {\ifst@bracket\pgfmathsetlengthmacro{\st@tw}{\st@w+6.8mm}\else\let\st@tw\st@w\fi
\st@flowbegin{#2}{\st@tw}{\st@h}% \st@flowbegin{#2}{\st@tw}{\st@h}%
@@ -472,9 +560,10 @@
\foreach \st@row [count=\st@ii] in \st@data {% \foreach \st@row [count=\st@ii] in \st@data {%
\foreach \st@jj in {1,...,\st@cols} {% \foreach \st@jj in {1,...,\st@cols} {%
\StrChar{\st@row}{\st@jj}[\st@c]% \StrChar{\st@row}{\st@jj}[\st@c]%
\ifnum\st@c>0 \IfInteger{\st@c}{%
\st@tile{#1}{\st@ii}{\st@jj}{\st@c}% \ifnum\st@c>0
\fi}}% \ifnum\st@c<4 \st@tile{#1}{\st@ii}{\st@jj}{\st@c}\fi
\fi}{} }}%
}{% }{%
\foreach \st@ii in {1,...,\st@rows} {% \foreach \st@ii in {1,...,\st@rows} {%
\foreach \st@jj in {1,...,\st@cols} {% \foreach \st@jj in {1,...,\st@cols} {%
@@ -547,6 +636,69 @@
\ifblank{#3}{\st@regrow{#2}}{}% \ifblank{#3}{\st@regrow{#2}}{}%
\endgroup} \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 / shape captions ---
% Symbol immediately under the block, shape on the next line. Both are reserved % 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. % 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); \draw[st arrow,#1] (#2) -- node[st note, above, fill=white, inner sep=1pt] {#4} (#3);
\end{pgfonlayer}} \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 --- % ------------------------------------------------------------ meaning box ---
\ifst@en \ifst@en
\def\st@lblaxes{Axes}\def\st@lblobj{Objects}\def\st@lblmech{Mechanism} \def\st@lblaxes{Axes}\def\st@lblobj{Objects}\def\st@lblmech{Mechanism}
@@ -607,10 +785,8 @@
\newcommand{\stsetrail}[1]{\setlength{\st@raillen}{#1}} \newcommand{\stsetrail}[1]{\setlength{\st@raillen}{#1}}
% ------------------------------------------------------------- signature ---- % ------------------------------------------------------------- signature ----
% One centered identification line, outside the meaning box, low contrast. % Optional centered subject line, outside the meaning box, low contrast.
\def\st@author{五道口纳什}
\newcommand{\stsetauthor}[1]{\def\st@author{#1}}
\newcommand{\stsignature}[2]{% \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 \endinput
+1
View File
@@ -1,6 +1,7 @@
% Anti-pattern gallery -- four ways to draw a figure that compiles cleanly and % 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. % still teaches the reader something false. Left of each pair is wrong.
% ../scripts/build.sh antipatterns.tex % ../scripts/build.sh antipatterns.tex
% supertensor-lint: allow-absolute, allow-missing-formula
\documentclass[border=10pt]{standalone} \documentclass[border=10pt]{standalone}
\usepackage[cjk]{supertensor} \usepackage[cjk]{supertensor}
+12 -1
View File
@@ -54,7 +54,18 @@ See `style.md`.
- **A formula line placed first.** It can only be centered on a figure whose width is not - **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. 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 - **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 - **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*. 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 - **Solving crowding by shrinking type.** The type hierarchy is a hard floor; move the
+59 -2
View File
@@ -8,18 +8,26 @@
Build with `./scripts/build.sh fig.tex` — it puts `assets/` on `TEXINPUTS`, so the package 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. 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 ## Ledgers
```tex ```tex
\stsetrole{q}{stTeal} % role -> color. Macros take a ROLE, never a color. \stsetrole{q}{stTeal} % role -> color. Macros take a ROLE, never a color.
\stdim{T}{6} % symbolic axis -> physical edge length in cells \stdim{T}{6} % symbolic axis -> physical edge length in cells
\stsetauthor{...} % default 五道口纳什
\stsetlabels{A}{O}{M} % override the three meaning-box rail labels \stsetlabels{A}{O}{M} % override the three meaning-box rail labels
\stsetrail{3.2em} % width of the bold label rail \stsetrail{3.2em} % width of the bold label rail
``` ```
Colors: `stTeal stOrange stCoral stViolet stGray stInk`. An unknown role falls back to gray 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. **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). 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 | | `\strow{name}{height}` | open a band; `height` is an axis name or an integer |
| `\strowend` | `fit` the band into `name`, then `\stlane` it | | `\strowend` | `fit` the band into `name`, then `\stlane` it |
| `\stcol{name}{height}` … `\stcolend` | vertical sub-flow filling one slot of the band | | `\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) | | `\stglyph{name}{$\times$}` | operator glyph (not `\stop` — plain TeX owns that name) |
| `\stcomm{name}{All-Reduce}` | collective node | | `\stcomm{name}{All-Reduce}` | collective node |
| `\stnode[style]{name}{text}` | any node, placed and measured by the cursor | | `\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, 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. 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 ## Faces
```tex ```tex
@@ -118,6 +171,8 @@ Keys:
`\stindexface` entries are row-major, `rows*cols` of them; `.` leaves a cell blank. `\stindexface` entries are row-major, `rows*cols` of them; `.` leaves a cell blank.
It deliberately has no lightness ramp — see `semantics.md`. 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 ```tex
\stface[role=w, pattern=data, level=3, \stface[role=w, pattern=data, level=3,
@@ -167,13 +222,15 @@ Connectors route on the background layer automatically.
```tex ```tex
\stbbox{all} % flow: everything drawn so far \stbbox{all} % flow: everything drawn so far
\stmeaningbox{mb}{16.6cm}{all}{axes text}{objects text}{mechanism text} \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 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 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. box will overlap them. An empty `{}` row is dropped.
`\stsignature` renders only its subject; it has no author or handle mechanism.
## Gotchas ## Gotchas
- `\ststack` never re-enters `\stface`; if you extend the package, do not pass - `\ststack` never re-enters `\stface`; if you extend the package, do not pass
+10 -7
View File
@@ -1,7 +1,8 @@
# Pre-delivery checklist # Pre-delivery checklist
A clean `build.sh` proves only that TeX was happy. Nothing below is checked by the compiler. `build.sh` already checks the source rules, package invariants, missing glyphs and text-box
Work through it against the rendered PNG. Any mandatory violation means redraw, not patch. 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) ## 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* they still need looking at, because the cursor only guarantees that objects do not *push*
into each other, not that the figure reads correctly. into each other, not that the figure reads correctly.
- [ ] No absolute coordinate that a `\strow`/`\stcol` could have expressed. Every remaining - [ ] Every lint exemption in the source is genuinely required and explained; deliverables
hand-placed node is wrapped in `\sttrack`. normally have none.
- [ ] `\sttopformula` called after the bands, so the formula is centered on the real figure.
- [ ] No forbidden intersection, tangency, clipping or occlusion — including stack offset - [ ] No forbidden intersection, tangency, clipping or occlusion — including stack offset
sheets, brackets, arrow labels and the meaning box. sheets, brackets, arrow labels and the meaning box.
- [ ] Every connector's white label underlay covers only its own connector. - [ ] Every connector's white label underlay covers only its own connector.
- [ ] No connector crosses a box that is not its endpoint. - [ ] 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. - [ ] 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). - [ ] Top zone compact (≤2 formula lines, no shape underbraces).
- [ ] Bottom box: one column, ≤3 rows, no overflow, no shrunken type. - [ ] 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 - [ ] If requested, signature is outside the box, one line, accurate, unclipped and subdued.
and not visually dominant.
- [ ] Structural support exact: known zeros unfilled, masks and diagonals exactly right. - [ ] Structural support exact: known zeros unfilled, masks and diagonals exactly right.
## 5. Thumbnail audit ## 5. Thumbnail audit
+18
View File
@@ -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.
+5 -1
View File
@@ -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 an equivalence class automatically: `Q` and `V` at `T×d_h` come out identical without you
lining anything up by hand. 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. 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 ## The rules
+18 -3
View File
@@ -34,6 +34,7 @@ Overlap is allowed only inside one declared composite:
- shards tiling a parent, - shards tiling a parent,
- outline sheets in one `\ststack`, - outline sheets in one `\ststack`,
- a bracket around its own tensor, - a bracket around its own tensor,
- a `\stgroup` outline around its own members,
- a connector endpoint touching its source/target border. - a connector endpoint touching its source/target border.
Every other intersection or occlusion is forbidden. 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 `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. 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 Explanatory prose belongs in the stage subtitle, the bottom box, above its own connector,
connector. Never drop a floating commentary card between two operands unless it is a real or on a `\stcallout` card hanging off the right edge of a band. Never drop a floating
operation node (`st comm`). 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 ## Layers
+6
View File
@@ -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 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 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). 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.
+18 -6
View File
@@ -21,8 +21,9 @@ stages that preserve the primary path. No unrelated branches, no dashboard panel
| operator | `\Large` | `st op` | | operator | `\Large` | `st op` |
| symbol | `\small` | `\stcaption` arg 2 | | symbol | `\small` | `\stcaption` arg 2 |
| shape | `\scriptsize`, muted | `\stcaption` arg 3 | | shape | `\scriptsize`, muted | `\stcaption` arg 3 |
| side card | `\scriptsize\bfseries` title, `\scriptsize` body | `\stcallout`, same tier as `st note` |
| bottom prose | `\small` | `\stmeaningbox` | | 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`. 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 this). Thin neutral brackets, `black!55`–`black!70` outer borders. No saturated
tensor-colored outlines, no continuous spreadsheet grid. 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 **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 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 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 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. 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: `\stcallout` is the only sanctioned floating text card, and it is deliberately narrow in
`\stsignature{<subject>}{<fit node>}` renders `<subject>@五道口纳什`. The subject must name scope: one per band, hung off the right edge of a *finished* band, never between two
what this figure actually visualizes. Keep it on one line, with a small but visible gap. 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{<subject>}{<fit node>}`
renders only the subject. Do not append an author, handle or brand identity.
## Never ## Never
Charts or metric insets not present in the primary formula. Decorative pills, banners, 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 ## Reference image
+6 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/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] # ./scripts/build.sh figure.tex [outdir]
# #
@@ -23,6 +23,11 @@ OUT="${2:-$SRCDIR/build}"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
mkdir -p "$OUT" mkdir -p "$OUT"
if [[ "${ST_SKIP_LINT:-0}" != "1" ]]; then
echo "==> lint $BASE"
python3 "$ROOT/scripts/lint.py" "$SRC"
fi
echo "==> xelatex $BASE" echo "==> xelatex $BASE"
# supertensor.sty lives in assets/; keep it off the user's texmf tree. # supertensor.sty lives in assets/; keep it off the user's texmf tree.
TEXINPUTS="$ROOT/assets:$SRCDIR:" \ TEXINPUTS="$ROOT/assets:$SRCDIR:" \
+175
View File
@@ -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"(?<!\\)%.*$", "", source, flags=re.MULTILINE)
def line_of(source: str, offset: int) -> 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, "<undeclared>") 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())
+11 -7
View File
@@ -12,7 +12,7 @@ QUIET=0
[[ "${1:-}" == "--quiet" ]] && QUIET=1 [[ "${1:-}" == "--quiet" ]] && QUIET=1
say() { [[ $QUIET -eq 1 ]] || echo -e "$*"; } 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 check() { # name, command
local name="$1"; shift local name="$1"; shift
if "$@" >/dev/null 2>&1; then say " ok $name"; ok=$((ok+1)); return 0 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)) check "xstring.sty" kpsewhich xstring.sty || fail=$((fail+1))
say "--- chinese figures ---" say "--- chinese figures ---"
check "ctex.sty" kpsewhich ctex.sty || 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)) check "fandol font" kpsewhich FandolSong-Regular.otf || { warn=$((warn+1)); cjk_warn=1; }
say "--- raster / vector export ---" 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 check "latexmk (optional)" command -v latexmk || true
if [[ $fail -gt 0 ]]; then if [[ $fail -gt 0 ]]; then
@@ -44,9 +44,13 @@ fi
if [[ $warn -gt 0 ]]; then if [[ $warn -gt 0 ]]; then
say "" say ""
say "RESULT: degraded." say "RESULT: degraded."
say " - missing ctex/fandol -> English-label figures only; do not substitute" if [[ $cjk_warn -eq 1 ]]; then
say " an OS-specific CJK font without telling the user it costs portability." say " - missing ctex/fandol -> English-label figures only; do not substitute"
say " - missing pdftocairo -> deliver PDF only, and say PNG/SVG were skipped." 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 exit 1
fi fi
say "" say ""
+25 -3
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/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 # ./scripts/test.sh
# #
@@ -21,8 +21,30 @@ for f in "$ROOT"/tests/*.tex "$ROOT"/examples/*.tex; do
fi fi
done 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 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 exit 1
fi fi
echo "all figures build clean" echo "all positive and negative checks passed"
+3
View File
@@ -58,6 +58,9 @@
\stcaption{Wc}{$\mathbf W^{(r)}$}{$d\times T$} \stcaption{Wc}{$\mathbf W^{(r)}$}{$d\times T$}
\stcaption{Z}{$\mathbf Z$}{$T\times T$} \stcaption{Z}{$\mathbf Z$}{$T\times T$}
\sttopformula{F}{$\displaystyle
\operatorname{flow}(X,W)\;:\;\text{对象按自身边界框依次占位}$}
\stbbox{all} \stbbox{all}
\stmeaningbox{mb}{15cm}{all} \stmeaningbox{mb}{15cm}{all}
{$T$ 序列长,$d$ 模型维,$r$ 低秩维} {$T$ 序列长,$d$ 模型维,$r$ 低秩维}
+78
View File
@@ -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}
+7
View File
@@ -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}
+8
View File
@@ -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}
+8
View File
@@ -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}
+8
View File
@@ -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}
+7
View File
@@ -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}
+7
View File
@@ -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}
+8
View File
@@ -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}
+7
View File
@@ -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}
+12
View File
@@ -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}
+13
View File
@@ -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}
+9
View File
@@ -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}
+12
View File
@@ -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}
+18
View File
@@ -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}
+6
View File
@@ -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}
+2
View File
@@ -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} \documentclass[border=8pt]{standalone}
\usepackage[cjk]{supertensor} \usepackage[cjk]{supertensor}