Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions docs/.api-usability/audit-2026-06-24.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# API Usability Audit — 2026-06-24

Scope: public surface only — `FastSense` (`addLine`/`addThreshold`/`addTag`/…/`render`/`updateData`),
`DashboardEngine` (`addWidget`/`Theme`/`render`/`save`/`load`), Tag constructors
(`SensorTag`/`StateTag`/`MonitorTag`/`CompositeTag`/`DerivedTag`), `TagRegistry`,
`toStruct`/`fromStruct`. Internals out of scope.

Runtime: headless `matlab -batch` (R2025b) — the live matlab MCP was not connected this session.
Friction was reproduced by running real code through the public API, not by reading internals.

## Score

| Metric | Value |
|---|---|
| Entry points with helpful "valid options" validation | DashboardEngine ctor ✅, attachPlantLog ✅, `addWidget` type ✅; Tag ctors ✅ (all six fixed) |
| Confusing / dead-end errors (no recovery path) | **0** open (was 1) |
| Methods that silently discard unknown options | **0** (was 4: addThreshold/Band/Marker/Shaded — now warn) |
| Discoverability: `functionSignatures.json` for tab-completion | **FastSense** (ctor + add* + setScale) + **Dashboard** (ctor + addWidget type enum); Tag pending |
| Lines-to-first-plot (example_basic) | ~4 (`FastSense` → `addLine` → `addThreshold` → `render`) — good |

## Findings (severity-ordered)

### 🔴 F-1 — `addWidget` unknown type was a dead-end — ✅ RESOLVED (this run)
- **Where:** `DashboardEngine.addWidget` → `error('DashboardEngine:unknownType', ...)`.
- **What a user hit:** any wrong guess (`'linechart'`, `'line'`, `'plot'`, `'numbers'`, `'bar'`)
returned `Unknown widget type: <guess>` with **no list of valid types and no pointer** to how to
find them — on the #1 dashboard-building entry point. It was also inconsistent with the *same
method's* unknown-name-value error (line 164: `... Valid options: %s`).
- **Fix (additive, message-only — ID `DashboardEngine:unknownType` unchanged):**
now `Unknown widget type 'linechart'. Valid types: barchart, chipbar, divider, fastsense, gauge,
group, heatmap, histogram, iconcard, image, multistatus, number, rawaxes, scatter, sparkline,
status, table, text, timeline. Call DashboardEngine.widgetTypes() for descriptions.`
List is derived from `DashboardWidgetRegistry.types()` (single source of truth — custom-registered
types appear automatically).
- **Proof:** 18/18 passed across `TestDashboardEngineWidgetTypes` + `TestDashboardWidgetRegistry`
(incl. `testUnknownTypeStillErrors` which asserts the ID); valid `number` add and the `kpi`
deprecation alias both still work.

### 🟠 F-2 — Tag constructors' `unknownOption` didn't list valid keys — ✅ RESOLVED (2nd run)
- **Where:** `SensorTag`, `StateTag`, `MonitorTag`, `CompositeTag`, `DerivedTag`, and `Tag` base all
threw `Unknown option '<key>'.` (DerivedTag: `Unknown NV key '<key>'.`) with **no list of valid
name-value keys** — contradicting the project's own convention (CLAUDE.md: *"unknown keys throw
immediately with helpful message listing valid options"*) and inconsistent with `DashboardEngine`'s
`Valid options: %s`. A user who mistyped `'Unit'` for `'Units'` got no hint of the correct spelling.
- **Fix (additive, message-only — all six `*:unknownOption` IDs unchanged):** each unknown-option
error now appends `Valid options: %s`, built via `strjoin` from the key arrays already local to each
`splitArgs_` (so the list cannot drift from what the constructor accepts). Examples:
- `SensorTag('s', 'Unit', 'bar')` → `Unknown option 'Unit'. Valid options: Name, Units, Description,
Labels, Metadata, Criticality, SourceRef, ID, Source, MatFile, KeyName, RawSource, X, Y.`
- `MonitorTag(...,'NotARealKey',5)` → lists `... AlarmOffConditionFn, MinDuration, EventStore,
OnEventStart, OnEventEnd, Persist, DataStore, EventLog.`
DerivedTag's wording was normalized from `Unknown NV key` to `Unknown option` for family consistency.
- **Proof:** 156/156 passed across `TestTag`, `TestSensorTag`, `TestStateTag`, `TestMonitorTag`,
`TestCompositeTag`, `TestDerivedTag` (incl. every `unknownOption` ID assertion); before/after
reproduced for all five concrete subclasses. The unreachable defensive `otherwise` branches at
`MonitorTag:199` / `DerivedTag:218` were intentionally left untouched (they never see a user key —
`monArgs`/`ownArgs` only hold already-recognized keys).

### 🟠 F-5 — `FastSense` add* methods silently discarded unknown options — ✅ RESOLVED (3rd run)
- **Where:** `FastSense.addThreshold`, `addBand`, `addMarker`, `addShaded` — each has a *closed*
option set but called `parseOpts(defaults, args, obj.Verbose)` and **discarded** the unmatched
output (`[parsed, ~]`). A misspelled option (`addThreshold(5, 'Colour', 'r')`, `addMarker(..,
'Symbol','x')`) was silently dropped: no error, no warning (unless `Verbose`), and the intended
styling silently did nothing. (Found during this run — not in the original F-1..F-4 list.)
- **Why it matters:** silent failure is worse than a loud one, and it directly violated the project's
own *house convention* — the `FastSense` constructor's comment: *"closed option set, so reject
unknown/misspelled keys immediately rather than silently discarding them."* `addLine` and the
constructor already reject unknown keys; these four were the inconsistent holdouts.
- **Fix (additive, non-breaking):** new private helper `warnUnknownOpts_` warns (does **not** error)
for each unrecognized option, naming the method and listing valid options. Wired into the four
sites; scripts still run identically (the option was already being ignored — now the mistake is
visible). Error would have been breaking (silent→throw); a **warning** keeps existing scripts
working while surfacing the bug. `addFill` was left alone — it legitimately forwards its unmatched
options to `addShaded`.
- **Proof:** all four typos now warn (e.g. `addMarker: unknown option 'Symbol' ignored. Valid
options: Marker, MarkerSize, Color, Label`); **0 false positives** on valid + case-insensitive
calls; 48/48 pass across `TestAddThreshold`, `TestAddBand`, `TestAddMarker`, `TestAddShaded`,
`TestThresholdLabels`, `TestRender`, plus 5/5 flat add-* tests.
- **Future (breaking, report-only):** a major version could promote the warning to a hard error to
fully match the constructor's reject-immediately convention.

### 🟢 F-3 — No `functionSignatures.json` (tab-completion discoverability) — ✅ RESOLVED (4th run, FastSense scope)
- **Where:** library roots had none — MATLAB editor tab-completion couldn't suggest any public-API
name-value option.
- **Fix (additive, new file — zero runtime change, ignored by Octave):** added
`libs/FastSense/functionSignatures.json` covering the core plotting surface — the `FastSense`
constructor plus `addLine`, `addThreshold`, `addBand`, `addMarker`, `addShaded`, `setScale` — with
enumerated `choices` for the discrete options (`Direction` upper/lower, `XScale`/`YScale`
linear/log, `DownsampleMethod` minmax/lttb, `Marker`, `LineStyle`). The editor now offers these as
you type.
- **Proof:** `validateFunctionSignaturesJSON` returns **0 messages** (schema-valid, all method/type
names resolve against the real class metadata with FastSense on the path); runtime smoke
(construct + add* + setScale) clean — no error, no warning; no `.m` changed so existing
tests/examples are inherently unaffected.
- **Remaining (additive follow-ups):** `libs/Dashboard/functionSignatures.json` for
`DashboardEngine.addWidget` (the `type` arg as a `choices` enum — the single highest-value
completion, directly preventing the run-1 wrong-type trap at typing time) and
`libs/SensorThreshold/functionSignatures.json` for the Tag constructors.

### 🟢 F-4 — Example bootstrap boilerplate *(minor / informational)*
- Every example repeats `projectRoot = fileparts(fileparts(fileparts(mfilename('fullpath'))));`
+ `run(fullfile(projectRoot,'install.m'))`. This is example-script bootstrap, not public-API
friction, so it is informational only — no API change warranted.

### 🟢 F-3b — `DashboardEngine.addWidget` had no type tab-completion — ✅ RESOLVED (5th run)
- **Fix (additive, new file — zero runtime change):** added `libs/Dashboard/functionSignatures.json`
covering the `DashboardEngine` constructor (Name + Theme/LiveInterval/InfoFile/ProgressMode/
ShowTimePanel) and `addWidget`, with the `type` argument modeled as a `choices` enum of all 19
built-in widget types — so the editor now lists the valid types *as you type*, complementing the
run-1 error-message fix at the point of entry. Common `addWidget` options (Position, Title, Tag,
Label) are offered too.
- **Proof:** `validateFunctionSignaturesJSON` returns **0 messages** (after switching the optional
positional `Name` from the deprecated `"optional"` kind to `"ordered"`, which R2025b now requires);
runtime smoke confirms `addWidget('number', ...)` still returns a `NumberWidget` and an unknown
type still errors `DashboardEngine:unknownType` — the metadata file is runtime-inert.

## Next-worst additive finding for `/api-polish`
**F-3c** — add `libs/SensorThreshold/functionSignatures.json` for the Tag constructors
(`SensorTag`, `StateTag`, `MonitorTag`, `CompositeTag`, `DerivedTag`), completing the
tab-completion coverage for the third core public surface. The valid name-value keys are already
enumerated per class (from the run-2 work) — `Name`/`Units`/`Description`/`Labels`/`Metadata`/
`Criticality`/`SourceRef` universals plus each class's extras (e.g. SensorTag `ID`/`Source`/`X`/`Y`,
MonitorTag `MinDuration`/`Persist`/…). Purely additive (new file).
3 changes: 2 additions & 1 deletion libs/Dashboard/DashboardEngine.m
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,8 @@ function switchPage(obj, pageIdx)
w = ctor(varargin{:});
else
error('DashboardEngine:unknownType', ...
'Unknown widget type: %s', type);
'Unknown widget type ''%s''. Valid types: %s. Call DashboardEngine.widgetTypes() for descriptions.', ...
type, strjoin(DashboardWidgetRegistry.types(), ', '));
end

% Preserve timeline no-store warning
Expand Down
25 changes: 25 additions & 0 deletions libs/Dashboard/functionSignatures.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"_schemaVersion": "1.0.0",

"DashboardEngine.DashboardEngine": {
"inputs": [
{"name": "Name", "kind": "ordered", "type": ["char"]},
{"name": "Theme", "kind": "namevalue", "type": ["choices={'light','dark'}"]},
{"name": "LiveInterval", "kind": "namevalue", "type": ["numeric", "scalar"]},
{"name": "InfoFile", "kind": "namevalue", "type": ["char"]},
{"name": "ProgressMode", "kind": "namevalue", "type": ["choices={'auto','on','off'}"]},
{"name": "ShowTimePanel", "kind": "namevalue", "type": ["logical", "scalar"]}
]
},

"DashboardEngine.addWidget": {
"inputs": [
{"name": "obj", "kind": "required", "type": ["DashboardEngine"]},
{"name": "type", "kind": "required", "type": ["choices={'barchart','chipbar','divider','fastsense','gauge','group','heatmap','histogram','iconcard','image','multistatus','number','rawaxes','scatter','sparkline','status','table','text','timeline'}"]},
{"name": "Position", "kind": "namevalue", "type": ["numeric", "size=1,4"]},
{"name": "Title", "kind": "namevalue", "type": ["char"]},
{"name": "Tag", "kind": "namevalue"},
{"name": "Label", "kind": "namevalue", "type": ["char"]}
]
}
}
12 changes: 8 additions & 4 deletions libs/FastSense/FastSense.m
Original file line number Diff line number Diff line change
Expand Up @@ -655,7 +655,8 @@ function addThreshold(obj, varargin)
defaults.Color = obj.Theme.ThresholdColor;
defaults.LineStyle = obj.Theme.ThresholdStyle;
defaults.Label = '';
[parsed, ~] = parseOpts(defaults, nvPairs, obj.Verbose);
[parsed, unmatched] = parseOpts(defaults, nvPairs);
warnUnknownOpts_('addThreshold', unmatched, fieldnames(defaults));

t.Value = [];
t.X = [];
Expand Down Expand Up @@ -722,7 +723,8 @@ function addBand(obj, yLow, yHigh, varargin)
defaults.FaceAlpha = obj.Theme.BandAlpha;
defaults.EdgeColor = 'none';
defaults.Label = '';
[parsed, ~] = parseOpts(defaults, varargin, obj.Verbose);
[parsed, unmatched] = parseOpts(defaults, varargin);
warnUnknownOpts_('addBand', unmatched, fieldnames(defaults));

b.YLow = yLow;
b.YHigh = yHigh;
Expand Down Expand Up @@ -784,7 +786,8 @@ function addMarker(obj, x, y, varargin)
defaults.MarkerSize = 6;
defaults.Color = obj.Theme.ThresholdColor;
defaults.Label = '';
[parsed, ~] = parseOpts(defaults, varargin, obj.Verbose);
[parsed, unmatched] = parseOpts(defaults, varargin);
warnUnknownOpts_('addMarker', unmatched, fieldnames(defaults));

m.X = x;
m.Y = y;
Expand Down Expand Up @@ -894,7 +897,8 @@ function addShaded(obj, x, y1, y2, varargin)
defaults.FaceAlpha = 0.15;
defaults.EdgeColor = 'none';
defaults.DisplayName = '';
[parsed, ~] = parseOpts(defaults, varargin, obj.Verbose);
[parsed, unmatched] = parseOpts(defaults, varargin);
warnUnknownOpts_('addShaded', unmatched, fieldnames(defaults));

s.X = x;
s.Y1 = y1;
Expand Down
97 changes: 97 additions & 0 deletions libs/FastSense/functionSignatures.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
{
"_schemaVersion": "1.0.0",

"FastSense.FastSense": {
"inputs": [
{"name": "Parent", "kind": "namevalue", "type": ["matlab.graphics.axis.Axes"]},
{"name": "LinkGroup", "kind": "namevalue"},
{"name": "Theme", "kind": "namevalue", "type": ["choices={'light','dark'}"]},
{"name": "Verbose", "kind": "namevalue", "type": ["logical", "scalar"]},
{"name": "XScale", "kind": "namevalue", "type": ["choices={'linear','log'}"]},
{"name": "YScale", "kind": "namevalue", "type": ["choices={'linear','log'}"]},
{"name": "DefaultDownsampleMethod","kind": "namevalue", "type": ["choices={'minmax','lttb'}"]},
{"name": "LiveInterval", "kind": "namevalue", "type": ["numeric", "scalar"]},
{"name": "HoverCrosshair", "kind": "namevalue", "type": ["logical", "scalar"]},
{"name": "MinPointsForDownsample", "kind": "namevalue", "type": ["numeric", "scalar"]},
{"name": "DownsampleFactor", "kind": "namevalue", "type": ["numeric", "scalar"]},
{"name": "PyramidReduction", "kind": "namevalue", "type": ["numeric", "scalar"]},
{"name": "StorageMode", "kind": "namevalue", "type": ["char"]},
{"name": "MemoryLimit", "kind": "namevalue", "type": ["numeric", "scalar"]}
]
},

"FastSense.addLine": {
"inputs": [
{"name": "obj", "kind": "required", "type": ["FastSense"]},
{"name": "x", "kind": "required", "type": ["numeric"]},
{"name": "y", "kind": "required", "type": ["numeric"]},
{"name": "DownsampleMethod", "kind": "namevalue", "type": ["choices={'minmax','lttb'}"]},
{"name": "AssumeSorted", "kind": "namevalue", "type": ["logical", "scalar"]},
{"name": "HasNaN", "kind": "namevalue", "type": ["logical", "scalar"]},
{"name": "XType", "kind": "namevalue", "type": ["choices={'numeric','datenum'}"]},
{"name": "Metadata", "kind": "namevalue", "type": ["struct"]},
{"name": "Color", "kind": "namevalue"},
{"name": "LineStyle", "kind": "namevalue", "type": ["choices={'-','--',':','-.','none'}"]},
{"name": "LineWidth", "kind": "namevalue", "type": ["numeric", "scalar"]},
{"name": "DisplayName", "kind": "namevalue", "type": ["char"]},
{"name": "Marker", "kind": "namevalue"}
]
},

"FastSense.addThreshold": {
"inputs": [
{"name": "obj", "kind": "required", "type": ["FastSense"]},
{"name": "value", "kind": "required", "type": ["numeric"]},
{"name": "Direction", "kind": "namevalue", "type": ["choices={'upper','lower'}"]},
{"name": "ShowViolations", "kind": "namevalue", "type": ["logical", "scalar"]},
{"name": "Color", "kind": "namevalue"},
{"name": "LineStyle", "kind": "namevalue", "type": ["choices={'-','--',':','-.'}"]},
{"name": "Label", "kind": "namevalue", "type": ["char"]}
]
},

"FastSense.addBand": {
"inputs": [
{"name": "obj", "kind": "required", "type": ["FastSense"]},
{"name": "yLow", "kind": "required", "type": ["numeric", "scalar"]},
{"name": "yHigh", "kind": "required", "type": ["numeric", "scalar"]},
{"name": "FaceColor", "kind": "namevalue"},
{"name": "FaceAlpha", "kind": "namevalue", "type": ["numeric", "scalar"]},
{"name": "EdgeColor", "kind": "namevalue"},
{"name": "Label", "kind": "namevalue", "type": ["char"]}
]
},

"FastSense.addMarker": {
"inputs": [
{"name": "obj", "kind": "required", "type": ["FastSense"]},
{"name": "x", "kind": "required", "type": ["numeric"]},
{"name": "y", "kind": "required", "type": ["numeric"]},
{"name": "Marker", "kind": "namevalue", "type": ["choices={'o','+','*','.','x','s','d','^','v'}"]},
{"name": "MarkerSize", "kind": "namevalue", "type": ["numeric", "scalar"]},
{"name": "Color", "kind": "namevalue"},
{"name": "Label", "kind": "namevalue", "type": ["char"]}
]
},

"FastSense.addShaded": {
"inputs": [
{"name": "obj", "kind": "required", "type": ["FastSense"]},
{"name": "x", "kind": "required", "type": ["numeric"]},
{"name": "y1", "kind": "required", "type": ["numeric"]},
{"name": "y2", "kind": "required", "type": ["numeric"]},
{"name": "FaceColor", "kind": "namevalue"},
{"name": "FaceAlpha", "kind": "namevalue", "type": ["numeric", "scalar"]},
{"name": "EdgeColor", "kind": "namevalue"},
{"name": "DisplayName", "kind": "namevalue", "type": ["char"]}
]
},

"FastSense.setScale": {
"inputs": [
{"name": "obj", "kind": "required", "type": ["FastSense"]},
{"name": "XScale", "kind": "namevalue", "type": ["choices={'linear','log'}"]},
{"name": "YScale", "kind": "namevalue", "type": ["choices={'linear','log'}"]}
]
}
}
Loading
Loading