Extracted from the tensor-formula-viz skill and rebuilt around the idea that the geometry rules should be enforced by construction rather than restated as prose an agent has to remember. - assets/supertensor.sty: faces, stacks, index faces, shared caption lanes, meaning box, signature. Macros take a declared axis and a declared role, so equal shapes get equal edges, a x a is square, a transpose swaps the face, and contracted axes share an edge length -- without any manual alignment. - scripts/preflight.sh: decide the TikZ/CJK path before drawing. - scripts/build.sh: compile and fail on silent corruption (missing CJK glyphs, overfull boxes, undeclared roles), then export pdf/svg/png/thumb. - scripts/test.sh: build every figure as a regression test for the package. - examples/: three golden figures (TP-FFN, causal MHA, MoE top-k gather) plus an anti-pattern gallery of figures that compile cleanly and still lie. - SKILL.md + references/: lean entry point, details loaded on demand.
340 lines
14 KiB
TeX
340 lines
14 KiB
TeX
%% supertensor.sty -- shape-aware tensor figure toolkit
|
|
%% Geometry invariants are enforced by construction: every face is sized from a
|
|
%% declared axis length, so equal shapes get equal edges, a x a is a square, and
|
|
%% a transpose physically swaps width and height.
|
|
%%
|
|
%% 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]
|
|
|
|
\newif\ifst@cjk\st@cjkfalse
|
|
\newif\ifst@en\st@enfalse
|
|
\DeclareOption{cjk}{\st@cjktrue}
|
|
\DeclareOption{en}{\st@entrue}
|
|
\DeclareOption{zh}{\st@enfalse}
|
|
\ProcessOptions\relax
|
|
|
|
\RequirePackage{amsmath}
|
|
\RequirePackage{amssymb}
|
|
\RequirePackage{xcolor}
|
|
\RequirePackage{array}
|
|
\RequirePackage{etoolbox}
|
|
\RequirePackage{xstring}
|
|
\RequirePackage{tikz}
|
|
\usetikzlibrary{calc,positioning,arrows.meta,backgrounds,fit,shapes.geometric,%
|
|
decorations.pathreplacing,decorations.markings}
|
|
|
|
\ifst@cjk
|
|
\RequirePackage[UTF8,fontset=fandol]{ctex}
|
|
\fi
|
|
|
|
% ---------------------------------------------------------------- layers ----
|
|
% Connectors on the background, tensors on main, all text on the foreground.
|
|
\pgfdeclarelayer{stbg}
|
|
\pgfdeclarelayer{stfg}
|
|
\pgfsetlayers{stbg,main,stfg}
|
|
|
|
% ---------------------------------------------------------------- palette ---
|
|
% Muted, mid-chroma paper colors. Do not add saturated primaries.
|
|
\definecolor{stTeal}{HTML}{4F8FA5}
|
|
\definecolor{stOrange}{HTML}{EE995B}
|
|
\definecolor{stCoral}{HTML}{C95B5B}
|
|
\definecolor{stViolet}{HTML}{8A74B5}
|
|
\definecolor{stGray}{HTML}{85898F}
|
|
\definecolor{stInk}{HTML}{1A1A1A}
|
|
|
|
% 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}}
|
|
% 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]{%
|
|
\ifcsname st@role@#1\endcsname\csname st@role@#1\endcsname\else stGray\fi}
|
|
\newcommand{\stcheckrole}[1]{%
|
|
\ifcsname st@role@#1\endcsname\else
|
|
\PackageWarning{supertensor}{Undeclared role `#1' -- drawn in neutral gray.
|
|
Declare it with \string\stsetrole\space so the hue budget stays visible}%
|
|
\fi}
|
|
\stsetrole{neutral}{stGray}
|
|
|
|
% Three separated lightness levels. Never render data cells below level 1.
|
|
\newcommand{\stlevelpct}[1]{\ifcase#1 0\or30\or55\or80\else55\fi}
|
|
|
|
% ------------------------------------------------------------ geometry ------
|
|
% The geometry ledger, made executable. Declare each symbolic axis once:
|
|
% \stdim{d}{8} \stdim{dh}{4}
|
|
% 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{\stresolve}[1]{\ifcsname st@dim@#1\endcsname\csname st@dim@#1\endcsname\else#1\fi}
|
|
|
|
% --------------------------------------------------------- type hierarchy ---
|
|
\newcommand{\stformula}[1]{{\large #1}}
|
|
\newcommand{\ststagelabel}[1]{{\small\bfseries\color{black!55}#1}}
|
|
\newcommand{\stoperator}[1]{{\Large #1}}
|
|
\newcommand{\stsymfont}[1]{{\small #1}}
|
|
\newcommand{\stshapefont}[1]{{\scriptsize\color{black!55}#1}}
|
|
\newcommand{\stprose}[1]{{\small #1}}
|
|
|
|
\tikzset{
|
|
st sym/.style = {font=\small, text=stInk, inner sep=1pt},
|
|
st shape/.style = {font=\scriptsize, text=black!55, inner sep=1pt},
|
|
st stage/.style = {font=\small\bfseries, text=black!55, inner sep=2pt},
|
|
st op/.style = {font=\Large, text=stInk, inner sep=2pt},
|
|
st note/.style = {font=\scriptsize, text=black!55, inner sep=2pt},
|
|
st arrow/.style = {-{Stealth[length=2.2mm,width=1.6mm]}, draw=black!45, line width=0.5pt},
|
|
st comm/.style = {draw=black!45, fill=black!4, rounded corners=1.2pt,
|
|
font=\scriptsize, inner sep=3pt},
|
|
st brace/.style = {decorate, decoration={brace,amplitude=3pt}, draw=black!45,
|
|
line width=0.4pt},
|
|
}
|
|
|
|
% ------------------------------------------------------------- faces --------
|
|
\newif\ifst@bracket
|
|
\newif\ifst@border
|
|
\newif\ifst@tiles
|
|
\pgfkeys{
|
|
/st/face/.cd,
|
|
role/.store in=\st@role,
|
|
pattern/.store in=\st@pattern,
|
|
data/.store in=\st@data,
|
|
level/.store in=\st@level,
|
|
bracket/.is if=st@bracket,
|
|
border/.is if=st@border,
|
|
tiles/.is if=st@tiles,
|
|
role=neutral, pattern=dense, data={}, level=2,
|
|
bracket=false, border=true, tiles=true,
|
|
}
|
|
|
|
% \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
|
|
% a face literally identical -- the eye reads that stripe as structure in the
|
|
% data. Composing coprime moduli (17, 19) pushes the period past any face we
|
|
% would ever draw.
|
|
\newcommand{\st@hash}[3]{%
|
|
\pgfmathtruncatemacro{\st@hv}%
|
|
{mod(mod(11*#1+7*#2,17)*mod(5*#1+13*#2,19)+3*#1+#2,#3)}}
|
|
|
|
% \st@cellfill{i}{j} -> \st@lv in 0..3 (0 = known zero, left unfilled)
|
|
\newcommand{\st@cellfill}[2]{%
|
|
\edef\st@p{\st@pattern}%
|
|
\IfStrEq{\st@p}{solid}{\def\st@lv{\st@level}}{%
|
|
\IfStrEq{\st@p}{diag}{\pgfmathtruncatemacro{\st@lv}{ifthenelse(#1==#2,3,0)}}{%
|
|
\IfStrEq{\st@p}{band}{\pgfmathtruncatemacro{\st@lv}{ifthenelse(abs(#1-#2)<=1,3,0)}}{%
|
|
\IfStrEq{\st@p}{empty}{\def\st@lv{0}}{%
|
|
\IfStrEq{\st@p}{lower}{%
|
|
\ifnum#1<#2 \def\st@lv{0}\else\st@hash{#1}{#2}{2}%
|
|
\pgfmathtruncatemacro{\st@lv}{\st@hv+2}\fi}{%
|
|
\IfStrEq{\st@p}{upper}{%
|
|
\ifnum#1>#2 \def\st@lv{0}\else\st@hash{#1}{#2}{2}%
|
|
\pgfmathtruncatemacro{\st@lv}{\st@hv+2}\fi}{%
|
|
\IfStrEq{\st@p}{causal}{%
|
|
\ifnum#1<#2 \def\st@lv{0}\else\st@hash{#1}{#2}{3}%
|
|
\pgfmathtruncatemacro{\st@lv}{\st@hv+1}\fi}{%
|
|
% default, including `dense'
|
|
\st@hash{#1}{#2}{3}\pgfmathtruncatemacro{\st@lv}{\st@hv+1}}}}}}}}}
|
|
|
|
% Shared setup: read keys, resolve the axis names through the geometry ledger.
|
|
\newcommand{\st@setup}[3]{%
|
|
\pgfkeys{/st/face/.cd,#1}%
|
|
\edef\st@rows{\stresolve{#2}}%
|
|
\edef\st@cols{\stresolve{#3}}%
|
|
\stcheckrole{\st@role}%
|
|
\edef\st@col{\strole{\st@role}}%
|
|
\pgfmathsetlengthmacro{\st@w}{\st@cols*\stunit}%
|
|
\pgfmathsetlengthmacro{\st@h}{\st@rows*\stunit}}
|
|
|
|
% \st@facecore{name}{coord} -- draws one face from the already-resolved state.
|
|
% \ststack calls this directly rather than re-entering \stface: passing
|
|
% `role=\st@role' back through pgfkeys would define \st@role in terms of
|
|
% itself and hang the run.
|
|
\newcommand{\st@facecore}[2]{%
|
|
\node[inner sep=0pt, outer sep=0pt, minimum width=\st@w, minimum height=\st@h]
|
|
(#1) at #2 {};
|
|
\st@facebody{#1}}
|
|
|
|
% \stface[keys]{name}{center coord}{rows}{cols}
|
|
% rows/cols accept a declared axis name or a raw integer. Height <- rows,
|
|
% width <- cols, always: a matrix face a x b never renders sideways.
|
|
\newcommand{\stface}[5][]{%
|
|
\begingroup
|
|
\st@setup{#1}{#4}{#5}%
|
|
\st@facecore{#2}{#3}%
|
|
\endgroup}
|
|
|
|
% \stindexface[keys]{name}{center coord}{rows}{cols}{entries}
|
|
% An INDEX face: the cells carry discrete symbols, not magnitudes. Entries are
|
|
% comma-separated in row-major order (rows*cols of them); `.' leaves a cell
|
|
% blank. Deliberately a different grammar from \stface -- outlined cells, no
|
|
% lightness ramp -- because an index that is drawn like a score invites the
|
|
% reader to compare 7 > 2 as if the numbers meant size.
|
|
\newcommand{\stindexface}[6][]{%
|
|
\begingroup
|
|
\st@setup{#1}{#4}{#5}%
|
|
\node[inner sep=0pt, outer sep=0pt, minimum width=\st@w, minimum height=\st@h]
|
|
(#2) at #3 {};
|
|
\foreach \st@e [count=\st@z from 0] in {#6} {%
|
|
\pgfmathtruncatemacro{\st@ii}{div(\st@z,\st@cols)+1}%
|
|
\pgfmathtruncatemacro{\st@jj}{mod(\st@z,\st@cols)+1}%
|
|
\edef\st@ee{\st@e}%
|
|
\IfStrEq{\st@ee}{.}{}{%
|
|
\draw[draw=\st@col!70, line width=0.4pt, rounded corners=0.7pt,
|
|
fill=\st@col!12]
|
|
($(#2.north west)+(\st@jj*\stunit-\stunit+0.5\sttilegap,%
|
|
-\st@ii*\stunit+\stunit-0.5\sttilegap)$)
|
|
rectangle
|
|
($(#2.north west)+(\st@jj*\stunit-0.5\sttilegap,%
|
|
-\st@ii*\stunit+0.5\sttilegap)$);
|
|
\node[font=\tiny, text=\st@col!85!black, inner sep=0pt]
|
|
at ($(#2.north west)+(\st@jj*\stunit-0.5\stunit,%
|
|
-\st@ii*\stunit+0.5\stunit)$) {\st@ee};}}%
|
|
\ifst@border
|
|
\draw[draw=black!60, line width=0.5pt, rounded corners=1pt]
|
|
(#2.south west) rectangle (#2.north east);
|
|
\fi
|
|
\endgroup}
|
|
|
|
\newcommand{\st@facebody}[1]{%
|
|
\def\st@n{#1}%
|
|
\ifst@tiles
|
|
\IfStrEq{\st@pattern}{data}{%
|
|
\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}}%
|
|
}{%
|
|
\foreach \st@ii in {1,...,\st@rows} {%
|
|
\foreach \st@jj in {1,...,\st@cols} {%
|
|
\st@cellfill{\st@ii}{\st@jj}%
|
|
\ifnum\st@lv>0
|
|
\st@tile{#1}{\st@ii}{\st@jj}{\st@lv}%
|
|
\fi}}%
|
|
}%
|
|
\else
|
|
\fill[\st@col!\stlevelpct{\st@level}, rounded corners=1pt]
|
|
(#1.south west) rectangle (#1.north east);
|
|
\fi
|
|
\ifst@border
|
|
\draw[draw=black!60, line width=0.5pt, rounded corners=1pt]
|
|
(#1.south west) rectangle (#1.north east);
|
|
\fi
|
|
\ifst@bracket
|
|
\draw[black!55, line width=0.5pt]
|
|
($(#1.north west)+(-1.1mm,0.6mm)$) -- ++(-1.1mm,0)
|
|
-- ($(#1.south west)+(-2.2mm,-0.6mm)$) -- ++(1.1mm,0);
|
|
\draw[black!55, line width=0.5pt]
|
|
($(#1.north east)+(1.1mm,0.6mm)$) -- ++(1.1mm,0)
|
|
-- ($(#1.south east)+(2.2mm,-0.6mm)$) -- ++(-1.1mm,0);
|
|
\fi}
|
|
|
|
% \st@tile{face}{row}{col}{level} -- one rounded tile with a white gutter.
|
|
\newcommand{\st@tile}[4]{%
|
|
\fill[\st@col!\stlevelpct{#4}, rounded corners=0.7pt]
|
|
($(#1.north west)+(#3*\stunit-\stunit+0.5\sttilegap,%
|
|
-#2*\stunit+\stunit-0.5\sttilegap)$)
|
|
rectangle
|
|
($(#1.north west)+(#3*\stunit-0.5\sttilegap,%
|
|
-#2*\stunit+0.5\sttilegap)$);}
|
|
|
|
% \ststack[keys]{name}{center}{rows}{cols}{sheets}
|
|
% Leading axes become depth. Back sheets are outline-only and are included in
|
|
% the bounding box, so neighbours can be spaced against the real extent.
|
|
\newcommand{\ststack}[6][]{%
|
|
\begingroup
|
|
\st@setup{#1}{#4}{#5}%
|
|
\pgfmathtruncatemacro{\st@back}{#6-1}%
|
|
\pgfmathsetlengthmacro{\st@dx}{1.3mm}%
|
|
\coordinate (#2-c) at ($#3+(-0.5*\st@back*\st@dx,-0.5*\st@back*\st@dx)$);
|
|
\node[inner sep=0pt, outer sep=0pt, minimum width=\st@w, minimum height=\st@h]
|
|
(#2-front) at (#2-c) {};
|
|
\ifnum\st@back>0
|
|
% Ascending loop, descending index: `{\macro,...,1}' cannot infer its
|
|
% direction from an unexpanded macro and runs away.
|
|
\foreach \st@kk in {1,...,\st@back} {%
|
|
\pgfmathtruncatemacro{\st@k}{\st@back+1-\st@kk}%
|
|
\draw[draw=black!35, line width=0.4pt, rounded corners=1pt, fill=white]
|
|
($(#2-front.south west)+(\st@k*\st@dx,\st@k*\st@dx)$)
|
|
rectangle
|
|
($(#2-front.north east)+(\st@k*\st@dx,\st@k*\st@dx)$);}
|
|
\fi
|
|
\st@facebody{#2-front}%
|
|
% Braces are load-bearing: a bare coordinate expression contains a comma and
|
|
% would be split into two pgfkeys keys.
|
|
\node[inner sep=0pt, outer sep=0pt,
|
|
fit={(#2-front) ($(#2-front.north east)+(\st@back*\st@dx,\st@back*\st@dx)$)}]
|
|
(#2) {};
|
|
\endgroup}
|
|
|
|
% ------------------------------------------------- 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.
|
|
% \stlane{node} declares a shared caption baseline: every later \stcaption
|
|
% hangs from the bottom of that node instead of from its own block, so symbols
|
|
% and shapes occupy two flat lanes even when the faces have different heights.
|
|
% Typical use: draw the row, \node[fit=(A)(B)(C)] (row) {};, \stlane{row}.
|
|
\def\st@lane{}
|
|
\newcommand{\stlane}[1]{\def\st@lane{#1}}
|
|
\newcommand{\stnolane}{\def\st@lane{}}
|
|
\newcommand{\stcaption}[3]{%
|
|
\ifdefempty{\st@lane}%
|
|
{\node[st sym, below=1.6mm of #1] (#1-sym) {#2};}%
|
|
{\node[st sym, anchor=north]
|
|
at ($(#1.center |- \st@lane.south)+(0,-1.6mm)$) (#1-sym) {#2};}%
|
|
\node[st shape, below=0.6mm of #1-sym] (#1-shape) {#3};}
|
|
\newcommand{\stcaptiontop}[2]{%
|
|
\node[st sym, above=1.6mm of #1] (#1-top) {#2};}
|
|
|
|
% ------------------------------------------------------------ connectors ----
|
|
% Routed on the background layer so a connector can never cover a face.
|
|
\newcommand{\starrow}[3][]{%
|
|
\begin{pgfonlayer}{stbg}
|
|
\draw[st arrow,#1] (#2) -- (#3);
|
|
\end{pgfonlayer}}
|
|
\newcommand{\starrowlabel}[4][]{%
|
|
\begin{pgfonlayer}{stbg}
|
|
\draw[st arrow,#1] (#2) -- node[st note, above, fill=white, inner sep=1pt] {#4} (#3);
|
|
\end{pgfonlayer}}
|
|
|
|
% ------------------------------------------------------------ meaning box ---
|
|
\ifst@en
|
|
\def\st@lblaxes{Axes}\def\st@lblobj{Objects}\def\st@lblmech{Mechanism}
|
|
\else
|
|
\def\st@lblaxes{轴}\def\st@lblobj{对象}\def\st@lblmech{机制}
|
|
\fi
|
|
\newcommand{\stsetlabels}[3]{\def\st@lblaxes{#1}\def\st@lblobj{#2}\def\st@lblmech{#3}}
|
|
|
|
% \stmeaningbox{node name}{total width}{below-of anchor}{axes}{objects}{mechanism}
|
|
% One full-width, low-contrast box, one reading column, at most three rows.
|
|
% Leave a row empty to drop it.
|
|
\newcommand{\stmeaningbox}[6]{%
|
|
\node[below=4mm of #3, anchor=north,
|
|
draw=black!18, fill=black!3, rounded corners=1.5pt,
|
|
inner xsep=9pt, inner ysep=8pt, text width=#2] (#1) {%
|
|
\small
|
|
\setlength{\tabcolsep}{0pt}%
|
|
\renewcommand{\arraystretch}{1.25}%
|
|
\begin{tabular}{@{}p{\st@raillen}@{\hspace{7pt}}p{\dimexpr#2-\st@raillen-7pt\relax}@{}}
|
|
% \ifblank, not \IfStrEq: the row content is math and CJK, and must not
|
|
% be expanded just to test for emptiness.
|
|
\ifblank{#4}{}{\textbf{\st@lblaxes} & \raggedright\arraybackslash #4 \\}
|
|
\ifblank{#5}{}{\textbf{\st@lblobj} & \raggedright\arraybackslash #5 \\}
|
|
\ifblank{#6}{}{\textbf{\st@lblmech} & \raggedright\arraybackslash #6 \\}
|
|
\end{tabular}};}
|
|
\newlength{\st@raillen}\setlength{\st@raillen}{3.2em}
|
|
\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}}
|
|
\newcommand{\stsignature}[2]{%
|
|
\node[below=2.2mm of #2, font=\scriptsize, text=black!45] (st-signature) {#1@\st@author};}
|
|
|
|
\endinput
|