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
+189 -13
View File
@@ -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