======QtiSAS AI Assistant======

The AI assistant is built into QtiSAS and can generate Python scripts, explain the API,
and help automate data analysis workflows.

----

=====For Users: How to Use the AI Agent=====

----

====Entry points====

The AI can be invoked from three places:

----

**1. Status bar input line** — bottom of the main window

^ Key ^ Action ^
| ''Enter'' | Run as a direct command / short script |
| ''Ctrl+Enter'' (''Cmd+Enter'' on macOS) | Send to AI — code shown in Scripting Console, **not** run |
| ''Ctrl+Shift+Enter'' | Send to AI — code **auto-executed** |
| ''?'' | Show help summary in the console |

----

**2. Script Window** — **Ask AI** button (top-right)

  * Sends the **selected text** to the AI; if nothing is selected, sends the entire script.
  * Click → generate only; ''Ctrl+Shift''+Click → generate and run.

----

**3. Note window** — **Ask AI** button (tab corner)

  * Sends the **full content of the current tab** to the AI.
  * Click → generate only; ''Ctrl+Shift''+Click → generate and run.

----

====Setup====

Before using the AI for the first time, configure the provider in **Settings → AI**:
set the API key, model, and base URL.

----

====Modes====

**Generate code only** — use when you want to review the script before running it.
The code appears in the Scripting Console; copy it to the Script Window to edit and run.

**Generate and run** — generated code is executed immediately after it arrives.
Use for well-defined, trusted requests.

----

====What the AI can do====

  * Generate Python scripts using the QtiSAS API (tables, matrices, graphs, fitting).
  * Explain any QtiSAS Python function or workflow.
  * Create datasets, run fits, and plot results in a single script.
  * Batch-process multiple datasets.
  * Author, compile, and save new custom fitting functions via the ''Compiler'' class
  (''c = Compiler'') — see compile-python-api.md.

====What the AI cannot do====

  * Access files outside the QtiSAS workspace or make network requests.

----

====Tips for better results====

  * Name your tables and columns clearly — the AI uses them to build column references.
  * Mention the fitting function name explicitly (e.g. ''"Guinier"'', ''"sphere_SANS"'').
  * Use ''f.fitFunctions()'' to list available function names, ''f.datasets()'' to list Y-columns.

----

====User Rules====

A small, manually-curated list of your own rules, sent to the AI on every request (unlike the
built-in docs, which are omitted specifically on automatic error-retry follow-ups, to leave room
in the model's context when a retry loop runs several times). Use it for corrections specific to
your setup that the built-in docs don't cover — e.g. a lab convention, a preferred default, or a
mistake the AI keeps making for you.

^ Call ^ Notes ^
| ''addRule(text)'' | add a rule; raises ''ValueError'' at the rule-count limit (default 20, configurable in **Preferences → AI**) |
| ''removeRule(index)'' | remove a rule by its 1-based position (see ''showRules()''); raises ''ValueError'' if out of range |
| ''removeAllRules()'' | remove every rule at once |
| ''showRules()'' | print the current numbered rules to the console and return them as a list |

The exact current count and limit are always shown to you directly — the "User Rules" section
below (if any rules are set) is headed ''## User Rules (N/limit)'' with live values, not the
default. Trust that heading over the "default 20" figure above if they differ.

<code python>
addRule("Our SANS beamline reports Q in 1/nm, not 1/Å — never convert.")
showRules()          # 1. Our SANS beamline reports Q in 1/nm, not 1/Å — never convert.
removeRule(1)
removeAllRules()     # start over
</code>

**This is manual only** — the AI never calls ''addRule()'' on its own initiative, even after you
correct it. Add a rule yourself when you want a correction to persist across sessions.

If ''addRule()'' raises the rule-limit error (e.g. because the user explicitly asked you to add
one), **report the message and its three options to the user — do not autonomously call
''removeRule()'' / ''removeAllRules()'' or change the Preferences limit yourself.** Which rule to
remove, or whether to raise the limit, is the user's call, not yours.

This still applies even after the user tells you to proceed. A vague follow-up like "try again"
or "go ahead" authorizes making room — it does **not** tell you *which* rule to sacrifice. Ask
which one before calling ''removeRule()'', unless the user's instruction already specifies a
target (by index, by content, or explicitly "remove the oldest"). Picking one yourself — e.g.
by index order — can discard a distinct, valuable rule while leaving near-duplicate ones intact.

Rules are stored in ''~/.config/qtisas/ai/user-rules.md'' (plain text, editable directly), and the
currently active list is also shown in its own **Help → Python API → User Rules** window.

**RULE — Before every action script runs, you will automatically be asked to review it once against a checklist (User Rules + a few known silent-failure gotchas). Take that pass seriously — it exists because rules like these get missed under task pressure even when you know them.**
The message looks like: ''Before this runs, check it against the checklist below. If it violates anything, return ONLY the corrected script. If not, return the SAME script unchanged.'' This is a real, separate check, not a formality — respond with the actual corrected script if anything on the checklist applies, or the unchanged script if it doesn't. Don't add explanation text; return only code either way. This review happens exactly once per candidate script, automatically, regardless of what generated the script (a fresh request, an EXPLORE follow-up, or an error-retry correction).

**Checklist items — including User Rules — are DEFAULTS, not absolute overrides.** If the original request explicitly specified something that contradicts a checklist item for that exact aspect (e.g. it named a specific color while a rule sets a different default color), the explicit request wins. Only apply a checklist item where the original request left that aspect unspecified. This already went wrong once: a request explicitly asking for a blue line got silently forced back to green by a standing "use green lines" rule during self-review — that's the failure mode this note exists to prevent.

**This review is a compliance check, not a chance to improve the script.** Only remove or correct what's needed to comply with the checklist — never add new functionality, styling, or embellishments beyond what the checklist or the original request actually calls for, even if it seems like a nice addition. This already went wrong once too: reviewing a "plot it nicely for a presentation" request, the review pass added unrequested tick-length styling with a wrong argument count, causing a ''TypeError'' that didn't exist in the original draft. Anything you add during review is itself unreviewed code, with the same risk of being wrong as anything else you generate — the checklist tells you what to fix, not an invitation to embellish.

----

=====For AI: Agentic Explore Mode=====

**Everything in this section (EXPLORE round-trips, the mandatory self-review pass, and the
automatic error-retry loop) only fires in AUTO-RUN mode** — ''Ctrl+Shift+Enter'' at the status bar,
or ''Ctrl+Shift''+Click in the Script Window / Note "Ask AI" button (see Entry points above). In
plain "generate only" mode (bare ''Enter''/Click), a ''# EXPLORE'' first line is never intercepted —
the code is simply shown as the answer, once, with no execution, no output capture, no self-review,
and no error-retry. This distinction matters when reasoning about what happens next: if you don't
know which mode produced the current request, don't assume any of these mechanisms are active.

**PRE-FLIGHT — answer these before writing ANY script:**
  - **Does the task ask you to READ/analyze/fit/plot data the user is implying already exists** (a named table, "the data", "my results", a column the user references as if it's already there)? → ''table("name")'' / ''matrix("name")'' / ''graphWindow("name")'' return **''None''** (confirmed) when missing — **always check ''if t is None:'' before using the result**, in every script, even a single-line read. This is simpler and more reliable than remembering a separate EXPLORE step, and reports missing data gracefully in one shot: ''t = table("X"); scriptPrint("not found" if t is None else str(t.cell(2, 3)))''. For anything beyond a single read — a task that also needs to know column names/structure before writing a follow-up action script — **run EXPLORE first** (''existTable("X")'', or ''matrix("X") is not None'' / ''graphWindow("X") is not None'' — **there is no ''existMatrix''/''existGraph''; only ''existTable'' and ''existNote'' exist as separate functions, confirmed live via ''NameError: name 'existGraph' is not defined''** — everything else is checked via the lookup-returns-None pattern) to gather that structure. If missing: stop and report. Do NOT generate synthetic data as a substitute. **After EXPLORE: only use tables and column names that actually appeared in the output — never invent names by pattern-matching from similar names you observed.** If required data (e.g. a residuals column or a fit curve table) is not confirmed by EXPLORE, stop and ask the user.
    **THIS DOES NOT APPLY when the task asks you to CREATE/generate/synthesize the data yourself** (e.g. "create a table with sphere form factor data", "generate synthetic SANS data") — that table doesn't exist yet BY DESIGN, checking for it first and stopping when it's (correctly) missing is backwards, not a safety check. Confirmed live, self-review-introduced: a working "create a table + fit + plot" script was "corrected" by inserting ''if not existTable("Sphere"): scriptPrint("Table 'Sphere' does not exist")'' in front of the ''newTable("Sphere", ...)'' call that was about to create it — the elif branch even referenced ''t.colNames()'' before ''t'' was ever assigned. The script silently short-circuited to "does not exist" and never ran any of the actual work (no table created, no fit, no plot) — the checklist item was misapplied to a CREATE task as if it were a READ task. If the task's own words are "create"/"generate"/"make me"/"synthesize" a dataset, go straight to ''newTable()'' — do not gate it behind an existence check on the very name you're about to create.
  - Does the action script use ''random.*''? → ''import random'' must be its first line.
  - Does the action script use ''QColor'' / ''QFont'' / ''QPen''? → ''from PyQt6.QtGui import …'' must be at the **top of the script, before any other code** — not mid-script.
  - Does the script add curves to a graph? → ''addCurve(t, ycol, style)'' takes **one column name** (Y only); ''insertCurve(t, xcol, ycol, style)'' takes two. ''addCurve(t, "x", "y", style)'' → ''TypeError: argument 3 has unexpected type 'str'''.
  - Stacked panels: X title goes on **l2 only** (l1's bottom axis is disabled). ''l1.setXTitle(...)'' is silent but invisible.
  - **EXPLORE must be read-only.** If the EXPLORE script contains ''newGraph()'', ''newTable()'', ''addCurve()'', ''maximizeWindow()'', ''plot()'', or any other write/create call, **split it out** — those lines belong in the action script only. An EXPLORE script that creates windows or modifies state runs twice (once for EXPLORE, once for the action script) and leaves duplicate windows or corrupted state. ''plot()'' is the easiest one to miss here — it doesn't read like a "write" call the way ''newTable()''/''newGraph()'' obviously do, but it unconditionally creates a brand new graph window every single call, with no way to avoid it.
  - **''scriptPrint()'' requires a ''str'' (or ''None'') argument — it does NOT accept ''float''/''int''/''list''/''tuple'' directly.** ''cell()'', ''paramNames()'', ''fitFunctions()'', and most read methods return non-string types. Always wrap: ''scriptPrint(str(t.cell(2, 3)))'', never ''scriptPrint(t.cell(2, 3))'' → ''TypeError: argument 1 has unexpected type 'float'''.
  - **Does the task modify an existing compiled function and save the result under a NEW name** (e.g. "fix X's normalization, save as X-correct")? → call ''c.setFunctionName("new-name")'' **before** ''c.compile()'' / ''c.save()''. ''compile()''/''save()'' use whatever name is currently loaded — opening the original, editing ''[code]'', and compiling without renaming first **silently overwrites the original function** instead of creating a new one. This already happened once: opening ''sasviewmodels/ellipsoid'', fixing its polydispersity normalization, and calling ''c.compile()'' without ''setFunctionName()'' destroyed the original ''ellipsoid'' and never created the requested ''ellipsoid-correct''.
  - **Does the action script call ''f.setParamValue(name, value)''?** → ''name'' must be one of the EXACT strings returned by ''c.paramNames()'' for the function just configured. ''setParamValue()'' does an exact-match lookup and **raises ''ValueError: setParamValue: no such parameter '<name>'.'' if ''name'' doesn't match any real parameter** (same for ''setParamVaries()''). Never guess generic names (''xc'', ''A'', ''sigma'', ''y0'') by convention — confirm them via EXPLORE first; catching the error after the fact still means the fit call never ran.
  - **Is "stop and report" the right outcome for this task** (required data doesn't exist, nothing to fit, etc.)? → The report itself must still be a **valid, executable Python script** — wrap the message in ''scriptPrint(...)''. Returning plain English text instead of code makes the response fail to compile: it is shown to the user as "AI code (not runnable)" and never reaches the user as a clean message.

Skipping step 1 causes ''ValueError: Invalid table in addCurve()'' or ''SystemError'' from empty cells.

If you need to read current state before acting (e.g. read existing code, info, parameters),
return **only** a read-only script with ''# EXPLORE'' as the **very first line**.
QtiSAS will run it, capture all ''scriptPrint()'' / ''print()'' output, and send it back as context.
Once you have everything you need, return the action script (without ''# EXPLORE'').

**RULE — If your action script raises a runtime error, QtiSAS automatically sends you the error and asks for a corrected script — respond with ONLY the corrected script, no explanation.**
This works exactly like the EXPLORE round-trip: you don't need to ask the user to paste the error back, it happens automatically. The message you receive starts with ''The action script raised an error:'' followed by the traceback. Treat it the same way you'd treat any other correction request — read the error, fix the actual cause (e.g. a missing ''import random'', a wrong parameter name), and return the fixed script.
**This automatic retry is capped at a limited number of total automatic continuations per user request** (default 4, configurable in **Preferences → AI**; EXPLORE rounds and error-retries share the same counter). If you're still failing after several attempts, stop guessing variations and reconsider whether the premise of the task is wrong (e.g. a function/column genuinely doesn't exist) rather than retrying the same mistake.

**RULE — Do NOT wrap ''setParamValue()'' / ''setParamVaries()'' / ''fit()'' in a broad ''try/except Exception'' that just prints the error and continues. Let the error propagate uncaught so the automatic retry above can fix it.**
Catching the exception yourself defeats the auto-retry mechanism: QtiSAS only captures and re-sends errors that make it to the script's top level (uncaught). If you swallow it locally, execution continues past the failed call — e.g. ''plotResults()'' runs even though ''fit()'' was never reached — and whatever the graph already contained (from an earlier attempt, or nothing at all) gets reported as if this run succeeded. A misleading "done" message is worse than a visible failure.
<code python>
# WRONG — catches the ValueError from a bad parameter name, then keeps going:
# plotResults() below runs even though fit() was never called, and reports
# success using whatever curve happened to already exist from an earlier run
try:
    f.setParamValue("radius", 50.0)   # ValueError: no such parameter 'radius'
    f.fit()
except Exception as e:
    scriptPrint("Fit error: " + str(e))
f.plotResults("FitResult", "")
scriptPrint("Done")   # misleading — no fit actually ran this time

# RIGHT — let it raise; QtiSAS captures it and asks you for a corrected script
f.setParamValue("radius", 50.0)
f.fit()
f.plotResults("FitResult", "")
</code>
Only catch exceptions you can actually recover from **within the same script** (e.g. a fallback value); never catch-and-continue just to avoid the task ending in an error.

**RULE — QtiSAS only sends output back to you when a script's FIRST LINE is exactly ''# EXPLORE''; any other script is treated as FINAL. This applies on every round, not just the first — omitting the marker on round 2, 3, ... silently ends the task exactly the same way omitting it on round 1 would.**
EXPLORE is not limited to one round: if the output from one EXPLORE script raises a new question, return ANOTHER ''# EXPLORE'' script rather than guessing you must write the action script yet (capped at the same configurable limit as the error-retry mechanism above, default 4 — see that rule). The only test is: does this script's job include "find out X"? If yes, it needs the marker, no matter which round it is — even a purely read-only script (''table()'', ''colNames()'', ''scriptPrint()'', no writes at all) that omits the marker is NOT treated as "one more explore step." It runs once, its output goes to the console, and the conversation ends there — if the task asked for an action (fit, plot, compile, …) and this script only gathered information, that action **never happens**: no error, no second chance, nothing signals the task is incomplete.
<code python>
# WRONG — round 2 is still pure discovery (checking Gauss's real params before
# configuring it), but drops the # EXPLORE marker because it's "not round 1"
# anymore. This becomes the FINAL script — no fit is ever attempted.
c = Compiler
c.open("Gauss")
scriptPrint("Gauss params: " + str(c.paramNames()))

# RIGHT — still discovery, so still needs the marker, even in round 2, 3, ...
# EXPLORE
c = Compiler
c.open("Gauss")
scriptPrint("Gauss params: " + str(c.paramNames()))
</code>

**RULE — Each script run (every EXPLORE round, every action-script attempt, every error-retry correction) gets a fresh execution — variables assigned in an EARLIER round are NOT automatically still bound.** ''f = Fittable'', ''c = Compiler'', ''t = table(...)'', and similar setup lines must be re-written at the top of every single script that needs them, even the very next round of the same conversation. Only the printed *output* of earlier rounds carries forward (as context in the conversation) — not the Python objects/names themselves.
<code python>
# WRONG — round 1 defines f, round 2 (a later EXPLORE) assumes it's still there:
# round 1: f = Fittable; f.configure("Guinier", ["SAXS_data_2"]); f.fit()  (fails)
# round 2:
# EXPLORE
scriptPrint("datasets: " + str(f.datasets()))   # NameError: name 'f' is not defined

# RIGHT — redefine it, even though an earlier round in the SAME conversation used it:
# EXPLORE
f = Fittable
scriptPrint("datasets: " + str(f.datasets()))
</code>

**RULE — Once EXPLORE confirms a SPECIFIC name (a function, table, or column matched by a search/pattern), use that literal confirmed name directly in the action script — do NOT re-run the same search again at execution time and take whatever it returns.** This is different from the "fresh namespace" rule above: redefining ''f = Fittable'' is required and harmless, but re-searching (''f.fitFunctions("*pattern*")'', ''[x for x in hits if ...]'', etc.) and indexing into the result again is NOT just wasteful, it's unsafe — the search can silently resolve to a DIFFERENT match than the one EXPLORE actually checked if the underlying library changed between rounds (a new function compiled, matching the same pattern, sorting earlier), and the action script would then be calling ''setParamValue()'' etc. against an unverified function's parameters while believing they're the ones EXPLORE confirmed.
<code python>
# WRONG — re-searches at execution time instead of using the confirmed name; if the "*lorentz*"
# match set has changed since EXPLORE ran, hits[0] may now be a DIFFERENT, unverified function
# EXPLORE confirmed "DoubleLorentzian" has params ['amp1','xc1','gamma1','amp2','xc2','gamma2','bgd']
f = Fittable
hits = f.fitFunctions("*lorentzian*")
f.configure(hits[0], ["NewTable_y"])   # NOT necessarily "DoubleLorentzian" anymore
f.setParamValue("xc1", 0.5)            # assumes the confirmed params, but hits[0] is unverified

# RIGHT — use the literal name EXPLORE already confirmed
f = Fittable
f.configure("DoubleLorentzian", ["NewTable_y"])
f.setParamValue("xc1", 0.5)
</code>

**RULE — When reporting, comparing, or deciding based on a value an EARLIER round/part already
printed, use that literal printed value — never invent a plausible-looking placeholder, even one
labeled "(example)".** The actual number is sitting right there in this conversation's own visible
history; typing a different one instead is a fabrication, not a shortcut, regardless of how
confident or plausible it looks. This is worse than simply not knowing, because it produces a
validated-looking answer with fabricated evidence behind it — if the invented numbers happen to
lead to the same conclusion the real ones would, that's luck, not correctness, and if they don't,
the reported decision is confidently wrong. Confirmed live: a later part's job was to compare two
earlier fits' ''chi2/dof'' (genuinely printed a few turns earlier as ''1.0561'' and ''216.6961'')
— instead of reading them, it printed brand-new fabricated numbers (''1.24'' and ''5.79''),
explicitly commented ''# example, from prior fit'', and reached its conclusion from those invented
values instead of the real ones.

<code python>
# WRONG — invents numbers instead of using the ones already printed in this same conversation
scriptPrint("GaussPeakModel chi2/dof = 1.24 (example, from prior fit)")
scriptPrint("LorentzianPeakModel chi2/dof = 5.79 (example, from prior fit)")
scriptPrint("Best model: GaussPeakModel (lower chi2/dof)")

# RIGHT — use the literal values an earlier part/round actually printed
scriptPrint("GaussPeakModel chi2/dof = 1.0561 (from earlier fit)")
scriptPrint("LorentzianPeakModel chi2/dof = 216.6961 (from earlier fit)")
scriptPrint("Best model: GaussPeakModel (lower chi2/dof)")
</code>

If the real printed value genuinely isn't visible anymore (history was cleared, or truncated by a
length limit), say so explicitly and either re-derive it (re-run the fit) or report that the
comparison can't be made — never substitute an invented number to fill the gap.

Any time your script's only job is to gather information before a real action, check that ''# EXPLORE'' is literally the first line before returning it.

**RULE — "Stop and report" means return a script that CALLS ''scriptPrint(...)'' with the message — never return plain English text as the response.**
Every action-script response must be valid, executable Python (the app already tells you this: "Return ONLY executable Python code — no explanations, no markdown fences, no text"). That requirement doesn't relax when the right answer is to give up — e.g. EXPLORE found zero tables in the project and there's nothing to fit. Returning prose instead of code makes it fail to compile: it is shown to the user as "AI code (not runnable)" and your message never gets displayed as an actual result.
<code python>
# WRONG — plain text, not Python; fails to compile, shown as "not runnable"
No data tables are present in the workspace. Please import or create SANS data before fitting.

# RIGHT — the same message, wrapped as a valid, executable script
scriptPrint("No data tables are present in the workspace. Please import or create SANS data before fitting.")
</code>

**Rules:**
  * Use ''scriptPrint()'' to output data — it is captured and returned to you.
  * EXPLORE scripts must be **strictly read-only**: only call read methods + ''scriptPrint()''.
  * Never mix EXPLORE and action code in one script.
  * **''table("name")'' / ''matrix("name")'' / ''graphWindow("name")'' return ''None'' when missing — always null-check the result before use, even for a single read.** ''t.cell(...)'' on ''None'' raises ''AttributeError''; passing ''None'' to ''addCurve()'' raises ''ValueError''. Guard with ''if''/''else'' nesting (see RULE below) to turn that crash into a graceful report, in the same script, with no separate EXPLORE round-trip needed for a single lookup.
  * **For any task that names a specific table, graph, matrix, or note AND needs more than one value from it (structure, column names, multiple cells) before writing a follow-up action script: start with EXPLORE** — verify it exists and check its structure first. If missing: stop and report — **do NOT generate synthetic data** unless the task explicitly asks for it.
  * **''import random'' must be the first line of any action script that uses ''random.*''** — it is not in the global namespace. ''import math'' is NEVER correct — math functions (''exp'', ''sin'', ''cos'', ''sqrt'', ''pi'', …) are global.

**RULE — The whole script (EXPLORE or action) runs at top level, not inside a function. A bare ''return'' there is a ''SyntaxError'', not a catchable runtime error — the automatic error-retry mechanism only fires on exceptions raised DURING execution, and a ''SyntaxError'' happens before execution even starts, so there is NO automatic retry for this at all.** A script that fails to even compile is shown once as "not runnable" and the exchange simply stops there — it is NOT resent to you automatically like a genuine runtime error. Getting this right the first time matters more here than for an ordinary bug, precisely because nothing will prompt a second attempt. Use ''if''/''else'' nesting to guard the rest of a script instead of an early ''return''.
<code python>
# WRONG — SyntaxError: 'return' outside function; this never runs, and since it's a
# compile-time error (not a raised exception), there is no automatic retry to fix it —
# the exchange just halts, shown as "not runnable"
if not existTable("SAXS_data"):
    scriptPrint("Table 'SAXS_data' does not exist.")
    return
t = table("SAXS_data")
scriptPrint("cols: " + str(t.colNames()))

# RIGHT — nest the rest of the script inside the else branch instead
if not existTable("SAXS_data"):
    scriptPrint("Table 'SAXS_data' does not exist.")
else:
    t = table("SAXS_data")
    scriptPrint("cols: " + str(t.colNames()))
</code>
''raise'' (unlike ''return'') IS valid at top level — but only use it when you actually want the automatic error-retry to trigger. For a graceful "stop and report" (data genuinely missing, nothing to do), print the message and let the script end normally — don't raise just to exit early.

**The same "don't trigger a retry just to stop" problem applies to ''exit()'' / ''quit()'' / ''sys.exit()''.** They all raise ''SystemExit'' under the hood, and the script runner treats *any* uncaught exception — ''SystemExit'' included — as a script error, feeding it back for an automatic retry exactly like a real crash. Using one of these for a graceful early stop just produces an unwanted retry loop instead of a clean report. Use the same ''if''/''else'' nesting as above, never ''exit()''/''quit()''/''sys.exit()''.

**RULE — Verify before you state a fact. If you haven't run code to check it in THIS conversation, you don't know it.**
Parameter names, column names, existing function/template names, dataset contents, settings — anything checkable by running a script must be checked, and the answer must quote real output, not training-data recall. **Partial hedging is still an unverified answer for the whole claim** — stating some values as fact while hedging only on the rest is the same mistake as guessing all of it.
<code python>
# WRONG — states 4 params as fact, hedges only on params 5-7; the function
# doesn't even exist, so EVERY part of this answer is fabricated:
# "The function has 7 parameters: A, xc, sigma, background, ... (5-7 need checking)"
# → the real answer, once actually opened: ValueError, function not found at all

# RIGHT — verify first, always, before saying anything specific
# EXPLORE
c = Compiler
c.open("sasviewmodels/gaussian-peak-v2")
scriptPrint(str(c.paramNames()))
# only after seeing real output (or a real error) do you answer the question
</code>
If you already retrieved this exact information earlier in this conversation, cite that prior output instead of re-answering from memory — do not re-guess.

**RULE — For read-only parameter/info lookups use ''Compiler.open(name)'' + ''c.paramNames()'', never ''Fittable.configure()''.**
''f.configure(name, [])'' reconfigures the LIVE Fittable widget — a real side effect that discards whatever fit was already set up, just to answer a question. ''c.open(name)'' only reads the function's source; it never touches the Fittable widget.
<code python>
# WRONG — reconfigures the live Fittable widget once per candidate, discarding
# whatever fit was already set up, just to compare parameter names
f = Fittable
for name in f.fitFunctions("*gauss*"):
    f.configure(name, [])
    scriptPrint(name + " -> " + str(f.paramNames()))

# RIGHT — read-only, no side effects on the live fit session
c = Compiler
for name in f.fitFunctions("*gauss*"):
    c.open(name)
    scriptPrint(name + " -> " + str(c.paramNames()))
</code>
Reserve ''f.configure()'' for when the task actually means to set that function up for fitting.

**RULE — Any task that fits USING AN EXISTING, ALREADY-COMPILED library function must start with EXPLORE** to discover the exact function name and its real parameters before writing the action script.
Guessing a function name or parameter count instead of checking ''f.fitFunctions()'' / ''c.paramNames()'' produces either a ''ValueError'' (name doesn't exist) or a fit that silently uses wrong defaults.
<code python>
# WRONG — jumps straight to configure()/fit() with a guessed name, no EXPLORE step
f = Fittable
f.configure("sasviewmodels/gaussian-peak-v2", ["Table1"])   # ValueError: not found
f.fit()

# RIGHT — EXPLORE first to confirm the function exists and see its real parameters
# EXPLORE
c = Compiler
c.open("sasviewmodels/gaussian-peak")
scriptPrint(str(c.paramNames()))
# -- after seeing real output, write the action script:
f = Fittable
f.configure("sasviewmodels/gaussian-peak", ["Table1"])
f.fit()
</code>
**This is a DIFFERENT task from "compile/write/create a CUSTOM fit function"** — do NOT search ''fitFunctions()''/''functionNames()'' for a coincidentally-matching existing name and reuse it when the task's own words ask you to author NEW code. Confirmed live: asked to "compile a custom fit function" for a sphere form factor, EXPLORE's search for "sphere"+"sans" found a leftover function from an EARLIER session/test run and reused it instead of writing fresh code — then, having skipped the authoring step, the script still tried to force a "compile" on the found-by-name function via ''c.compile("FoundName")'', which raised ''TypeError'' (''compile()'' takes only an optional ''bool'', never a name — it always operates on whatever's currently ''open()''/''new()''d in the Compiler's live state, not a name passed in) and repeated identically on retry.
<code python>
# WRONG — task said "compile a custom fit function"; instead searches for and reuses
# whatever coincidentally-named function already exists, then tries to "compile" it by name
# EXPLORE
c = Compiler
hits = [n for n in c.functionNames("ALL") if "sphere" in n.lower()]
scriptPrint(hits[0] if hits else "none")
# -> "SphereSANS_Custom" (a leftover from an earlier, unrelated test run)
#
# action script:
c = Compiler
ok = c.compile("SphereSANS_Custom")   # TypeError: compile() takes no name argument

# RIGHT — the task asked to author a NEW function; write it, don't search for one to reuse
c = Compiler
c.new()
c.setFunctionName("ai/SphereFormFactor")   # "ai/" folder — see compile-python-api Rule 6
c.setXName("q"); c.setYName("I")
c.setParamCount(2); c.setParamNamesAll(["radius", "background"])
c.setCode("...")
ok = c.compile()
</code>
**Always name a newly-authored function ''"ai/<shortName>"''** (see ''compile-python-api.txt''
Rule 6) — this is what lets a LATER "use an existing model" search exclude your own past one-offs
instead of mistaking them for vetted library models, exactly the confusion above. When searching
for an existing model to reuse, filter out anything under ''"ai/"'':
<code python>
hits = [h for h in c.functionNames("ALL") if "sphere" in h.lower() and not h.startswith("ai/")]
</code>
If a pre-existing (non-''"ai/"'') function already covers what's needed and the task doesn't ask for new code, no ''compile()'' call is needed at all — a function found via ''fitFunctions()'' is already compiled; just pass its name straight to ''f.configure()''.

**RULE — ''f.setParamValue(name, value)'' raises ''ValueError'' on an unmatched ''name'' — confirm names with ''c.paramNames()'' BEFORE writing these calls, don't rely on catching the error.**
It does an exact-string lookup against the currently configured function's real parameter names; no match raises ''ValueError: setParamValue: no such parameter '<name>'.'' (same for ''setParamVaries()'') and the script stops right there — ''f.fit()'' never runs. Generic textbook names (''xc'', ''A'', ''sigma'', ''y0'') are guesses, not facts — the real names vary per function (e.g. ''GaussianPeak'' uses ''scale'', ''center'', ''width'', ''background''). Confirm them with ''c.paramNames()'' in EXPLORE first, and use those exact strings.
<code python>
# WRONG — guesses generic parameter names; if they don't match, the first
# setParamValue() call raises ValueError and the script aborts — f.fit()
# never runs, and the whole task fails
f = Fittable
f.configure("GaussianPeak", ["Table1_2"])
f.setParamValue("xc", 0.5)   # ValueError: setParamValue: no such parameter 'xc'.
f.setParamValue("A", 1.0)
f.fit()

# RIGHT — EXPLORE confirms the real names first
# EXPLORE
c = Compiler
c.open("GaussianPeak")
scriptPrint(str(c.paramNames()))   # -> ['scale', 'center', 'width', 'background']
# -- after seeing real output, write the action script with those exact names:
f = Fittable
f.configure("GaussianPeak", ["Table1_2"])
f.setParamValue("center", 0.5)
f.setParamValue("scale", 1.0)
f.fit()
</code>

**FORBIDDEN in EXPLORE — calling any of these corrupts widget state and breaks the action script:**

^ Forbidden category ^ Forbidden calls ^
| Compiler write | ''c.new()'', ''c.setCode()'', ''c.setFunctionName()'', ''c.setGroup()'', ''c.compile()'', ''c.save()'' |
| Table/window create | ''newTable()'', ''newMatrix()'', ''newNote()'', ''newGraph()'', ''plot()'' |
| Table write | ''t.setCell()'', ''t.setColName()'', ''t.setColumnRole()'' |
| Fittable write | ''f.configure()'', ''f.fit()'', ''f.simulate()'', ''f.setParamValue()'', ''f.setFitMethod()'' |

Calling ''f.configure()'' or ''f.fit()'' in EXPLORE leaves the Fittable widget in an undefined state.
Even a **failed** ''f.configure()'' in EXPLORE corrupts the widget — the subsequent action script's ''f.fit()'' will then raise ''TypeError'' or return garbage.

**EXPLORE allowed calls (read-only):**

<code python>
# EXPLORE — only read methods and scriptPrint()
c = Compiler
c.scanGroups()
scriptPrint("categories: " + str(c.categories()))
scriptPrint("functions: " + str(c.functionNames("ALL")[:20]))
scriptPrint("graph templates: " + str(qti.app.graphTemplates()))

c.open("sasviewmodels/mass-fractal")    # open to read — OK in EXPLORE
scriptPrint(c.code()[:500])
scriptPrint(str(c.paramNames()))

f = Fittable
scriptPrint("fitFunctions: " + str(f.fitFunctions("*gauss*")))
scriptPrint("datasets: " + str(f.datasets()))
</code>

After receiving the output, generate the action script without the ''# EXPLORE'' line.

**When to use EXPLORE — mandatory for any task that involves:**

^ Situation ^ What to query ^
| Using ''plotResults'' with a template | ''qti.app.graphTemplates()'' → pick a valid name or ''""'' |
| Compiling a new function | ''c.functionNames("ALL")'' → check the name is not already taken |
| Fitting an existing library function | ''f.fitFunctions("*keyword*")'' → discover the exact name |
| **Accessing a named table, graph, matrix, or note** | **MANDATORY** — ''existTable("name")'' for tables; for graphs/matrices/notes there is no ''existGraph''/''existMatrix'' function — use ''graphWindow("name") is not None'' / ''matrix("name") is not None'' / ''existNote("name")'' → if missing, **stop and report to the user**; do NOT generate synthetic data as a substitute unless the task explicitly asks for it; never pass an unverified object to ''addCurve()'', ''cell()'', or any write method |
| **Creating a graph template (stacked or multi-panel)** | **''graphTitleOn()'', ''graphMajTicksStyle()'', ''graphCanvasColor()'', etc. → read current defaults before overriding** |

**EXPLORE for graph templates — check defaults first, then only override what differs:**
<code python>
# EXPLORE
scriptPrint("titleOn=%s" % qti.app.graphTitleOn())
scriptPrint("majTicksStyle=%d  minTicksStyle=%d" % (qti.app.graphMajTicksStyle(), qti.app.graphMinTicksStyle()))
scriptPrint("canvasColor=%s  bgColor=%s" % (qti.app.graphCanvasColor().name(), qti.app.graphBackgroundColor().name()))
scriptPrint("axesLineWidth=%d  drawBackbones=%s" % (qti.app.graphAxesLineWidth(), qti.app.graphDrawBackbones()))
</code>
After reading these values, the action script:
  * calls ''l2.removeTitle()'' **only if** ''graphTitleOn()'' returned ''True''
  * does **not** set fonts, tick styles, colors, or backbones unless they must differ from the defaults

----

=====For AI: QtiSAS Python API Rules=====

The following rules **must** be followed when generating Python code for QtiSAS.
Violations produce runtime errors that are hard for users to diagnose.

**RULE #1 — PyQt6 imports: ''QColor'', ''QFont'', ''QPen'', ''Qt'', ''QFrame'', ''QBrush'' are NEVER in the default namespace.**
Any script that uses any of these symbols MUST start with the imports — NO EXCEPTIONS:
<code python>
from PyQt6.QtGui import QColor, QFont, QPen, QBrush
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import QFrame   # only if QFrame is used
</code>
**Every single script that uses ''QColor'', ''QFont'', or ''QPen'' and does not have these imports will fail with ''NameError''.**
Never import ''QApplication'' — it crashes the app.

**MANDATORY: if the script uses ''random.gauss()'', ''random.random()'', or any ''random.*'' call,
''import random'' MUST be the very first line of the script — without it, every ''random.*'' call
raises ''NameError: name 'random' is not defined''.**

<code python>
import random                          # FIRST LINE — mandatory whenever random is used
t = newTable("Data", 100, 2)
for i in range(1, 101):
    x = 0.01 * i
    y = exp(-x) + random.gauss(0, 0.02)   # exp() is global; random needs import
    t.setCell(1, i, x)
    t.setCell(2, i, y)
</code>

Or avoid the import entirely by using built-in column helpers:
''t.setRandomValues(col)'' (uniform) / ''t.setNormalRandomValues(col, sd=1.0)'' (Gaussian noise)

**''import math'' is NEVER correct in QtiSAS scripts — do not write it, ever.**
The only math-related import you may need is ''import random'' (for random numbers).
''sin'' ''cos'' ''exp'' ''log'' ''sqrt'' ''pi'' ''e'' ''floor'' ''ceil'' ''abs'' are ALL in the global namespace.
Use bare names directly — never prefix with ''math.'':
<code python>
# RIGHT — exp, sin, cos, sqrt, pi are global; no math. prefix needed
y = exp(-x * x)
y = sin(q) * pi
z = sqrt(abs(x)) * pi

# WRONG — import math is NEVER correct; math.exp() / math.sin() / math.cos() all violate this rule
import math                             # ← NEVER write this line
y = math.exp(-x * x)                   # ← write exp(-x*x) instead
y = math.sin(q) * math.pi              # ← write sin(q) * pi instead
z = math.sqrt(abs(x)) * pi             # ← write sqrt(abs(x)) * pi instead
</code>

**This applies during self-review too — do not "fix" a compliant script's bare ''sin''/''cos''/''pi''/''sqrt''/etc. usage by importing them from ''math'', even though that looks like safer, more standard Python.** The bare global names are the correct, intended usage in this app specifically; adding the import is itself the violation, not a correction. Confirmed live: a compliant script using bare ''sin''/''cos''/''pi''/''sqrt'' (no import, exactly as required) had ''from math import sin, cos, pi, sqrt'' silently inserted during self-review — the same unrequested-edit pattern as dropping a needed import or renaming an identifier, just adding a forbidden line instead of removing a needed one.

**''sqrt(abs(y))'' is a shot-noise (raw photon COUNT) model — do NOT reuse that shape as a generic "add some noise" recipe for a physical quantity already in its own calibrated units** (intensity in cm⁻¹, concentration, temperature, etc.). For a value already scaled to something like ''0.001'', ''sqrt(abs(y))'' is *larger than ''y'' itself* — the resulting noise swamps the signal, and clamping the resulting negative samples to a floor like ''1e-20'' (paired with a proportional, now-tiny error bar) creates fake near-infinite-weight outliers that corrupt any downstream weighted fit. Confirmed live: synthetic SANS data with ''I'' ranging only ''0.0010''–''0.0010250'', noise generated as ''sqrt(abs(I)) * 0.10'' (std ≈ 0.0032, over 3× the signal) — **31% of the 150 simulated points came out negative** and were floor-clamped, and the resulting fit was meaningless (χ²/dof ≈ 64, R² = 0). Use noise proportional to the value itself for a physical quantity in real units:

<code python>
# WRONG — treats a calibrated physical quantity (I ~ 0.001) like a raw photon count
noise = sqrt(abs(I)) * 0.10
I_obs = I + random.gauss(0, noise)
if I_obs < 0:
    I_obs = 1e-20            # ~31% of points hit this for a small I — silently poisons the fit

# RIGHT — noise proportional to the value's own magnitude (a genuine ~10% relative error)
I_obs = I + random.gauss(0, 0.10 * I)
</code>

''sqrt(abs(y))''-style noise is only appropriate when ''y'' genuinely IS a raw count (e.g. detector counts before normalization) — not after it's already been scaled into physical units.

**Most calls only accept keyword arguments for parameters that have a default value — required parameters must be positional, even when an error's own printed signature shows their name and type as if they were keyword-capable.** This API's SIP bindings use ''keyword_arguments="Optional"'': a parameter with no default (e.g. ''axis'', ''logYN'' below) cannot be passed by name; only ones with defaults (e.g. ''changeAxisFormat='', ''forceRescale='') can.

<code python>
# WRONG — logYN has no default, so it can't be used as a keyword, even though the TypeError that
# reports this mistake prints "logYN: bool" as part of the signature, as if it were keyword-capable
g.activeLayer().setLinOrLogAxis(Graph.Axis.Bottom, logYN=True)
# TypeError: setLinOrLogAxis(self, axis: int, logYN: bool, changeAxisFormat: bool = True,
#            forceRescale: bool = False): 'logYN' is not a valid keyword argument

# CORRECT — positional for required params; keyword is fine for the defaulted ones if wanted
g.activeLayer().setLinOrLogAxis(Graph.Axis.Bottom, True)
</code>

Confirmed live.

**If your action script ends up calling into an API surface (''Fittable'', ''Compiler'', ''Graph3D'', etc.) whose topic docs weren't loaded for this request, treat that as a warning sign, not a normal choice.** The doc-selection step is supposed to match the actual task — drifting into an unselected topic's API usually means the task drifted too, not just the API choice, and you're now guessing that API's exact behavior from memory instead of the authoritative reference that would have caught a mistake. Confirmed live: asked only to "create a table with sphere form factor data" (docs: ''table'' only, no ''fittable'' selected), the response drifted into repeatedly exploring and configuring an unrelated ''Fittable'' model — including the exact ''configure(fn, [])''-empty-list mistake documented in ''fittable-python-api.txt'' — and never created any table at all. If you genuinely need a topic you didn't select, that's a sign to reconsider whether the task is still what was actually asked, not just to press ahead without its docs.

**This is not a reason to refuse a task outright.** If the request can be fulfilled without calling into the other topic's API at all — e.g. computing a well-known formula directly in a plain loop, rather than reaching for ''Fittable'''s fitting machinery — just do that. Recognizing that the task never needed that API in the first place is not "drifting into an unselected topic," it's the correct outcome. Confirmed live: asked to create a table with sphere form factor data, a response refused outright, claiming the task "requires a fitting function, not just a table" — false. The exact closed-form formula was already known (and correctly recited when asked) and could have been computed directly in the table-creation loop with zero ''Fittable'' involvement, exactly as done successfully in earlier, unrelated turns. Needing to reconsider the task (previous rule) and needing to refuse it (this one) are not the same thing — only do the former, never the latter, when a no-API-needed path exists.

**A corrected retry re-runs the ENTIRE script from the top, not just the line that failed — including every write/create call that already succeeded before the point of failure.** If ''newTable("X", ...)'' on line 2 succeeded and the script then failed on line 20, the retry's corrected script still starts with ''newTable("X", ...)'' again, and so does every retry after that, once per round. ''newTable()''/''newGraph()'' at least reuse an existing name (resizing rather than duplicating — see "Lookup vs create" above), which softens this. **''plot()'' cannot soften it at all** — it unconditionally creates a brand new graph window every single call, with no name-based reuse, so a ''plot()'' call before a later failure produces one new orphaned window per retry round. Confirmed live: a plotting script needed several retries to get an unrelated ''addErrorBars()'' call right, and each retry's ''plot()'' call on line 1 created another window — six graphs left over from what was semantically one request. If a script needs a ''plot()'' call and might also need retries (e.g. the styling calls after it are uncertain), check ''graphWindow(name)'' first and skip re-plotting if it already exists, or restructure so ''plot()'' only runs once the rest of the script has already been verified to work.

**This isn't specific to ''plot()'' — it applies to ANY expensive or side-effecting call earlier in a script.** ''Compiler.compile()'' is a second confirmed case, and arguably worse: it's a real compile-and-link of a shared library, not a cheap window creation. Confirmed live: a script that compiled a custom fit function, then fit/plotted/styled it, needed several retries to get an unrelated later call right — ''c.compile()'' genuinely re-ran, in full, on every single retry, four full recompiles of the same function for what was semantically one request. There's no name-based reuse check available for ''compile()''; the only mitigation is putting the risky, uncertain-to-get-right calls BEFORE the expensive ones wherever the script's logic allows it, so a failure happens before the expensive work rather than after it.

<!-- PyQt6 import rule is RULE #1 at the top of this section -->

----

====Graph3D====

**One ''plot3D(…)'' call = one surface = one window.** A second call opens a new window — there is no multi-surface overlay.

<code python>
# WRONG — tries to merge two windows; setMatrix(g2) fails (Graph3D ≠ Matrix)
g  = plot3D("X1","Y1","Z1", ul, ur, vl, vr, nu, nv, True, True)
g2 = plot3D("X2","Y2","Z2", ul, ur, vl, vr, nu, nv, True, True)
g.setMatrix(g2)    # TypeError

# CORRECT — one plot3D() call with a formula that covers the whole surface
g = plot3D("X(u,v)", "Y(u,v)", "Z(u,v)", ul, ur, vl, vr, nu, nv, uClosed, vClosed)
</code>

**Naming a Graph3D: use ''newPlot3D(name, ...)'', not ''plot3D(...)'' + ''setWindowName()''.** The two-step form raises ''ValueError: name already exists'' the moment a script (or an earlier turn) re-runs with a name that's already taken — and ''plot3D(...)'' already created ANOTHER orphaned window before that error was even raised, since only the rename fails, not the creation. Confirmed live: this cascaded into a wrong ''graphWindow(name)'' lookup retry (2D-only, always ''None'' for a ''Graph3D'' name → ''AttributeError'') and left 5 orphaned windows behind across the failed attempts.

<code python>
# WRONG — collides and leaks an orphaned window on any re-run
g3 = plot3D("sin(x)*cos(y)", -pi, pi, -pi, pi, -1, 1, 60, 60)
setWindowName(g3, "Surface3D")

# CORRECT — updates the existing window in place if found, creates it fresh (already named)
# otherwise; never leaks an orphaned window before failing
g3 = newPlot3D("Surface3D", "sin(x)*cos(y)", -pi, pi, -pi, pi, -1, 1, 60, 60)
</code>

If ''"Surface3D"'' is already used by a NON-''Graph3D'' window (a ''Table''/''Matrix''/''Note''/''MultiLayer'' — window names are unique across ALL types), ''newPlot3D(...)'' raises ''ValueError: newPlot3D: name '...' already exists but is not a Graph3D window'' immediately, before creating anything.

**muParser formula syntax** — formula strings are **not** Python:

^ Rule ^ Correct ^ Wrong ^
| Power | ''x^2'' | ''x**2'' |
| Conditional | ''a ? b : c'' (works) or ''sign(threshold - v)'' | ''if(cond, a, b)'' — not available |

Ternary ''a ? b : c'' DOES work (confirmed in source) — ''sign(threshold - v)'' is still preferred
for piecewise switching on chained shapes since it stays a smooth multiplier. ''if(cond, a, b)''
genuinely doesn't work (broken registration, not by design).

Available functions (not exhaustive): ''sin cos tan asin acos atan sinh cosh tanh asinh acosh atanh
exp log ln log2 log10 sqrt abs sign rint floor ceil pow min max sum avg mod''
''atan2'' and ''round'' do NOT exist under those names — use ''rint'' for round-to-nearest; there is
no 2-argument arctangent registered at all.

**Shared sub-expression rule** — when X and Y share a radius ''r(v)'', define it as a Python string and interpolate into both. Never inline the same expression twice with different trig factors — the expressions diverge when edited:

<code python>
# WRONG — r(v) written twice; one will be wrong
g = plot3D(f"sqrt(4.0 - v^2)*cos(u)", f"sqrt(4.0 - v^2)*sin(u)", "v", ...)

# CORRECT — r defined once, reused
r  = "sqrt(4.0 - v^2)"
g = plot3D(f"({r})*cos(u)", f"({r})*sin(u)", "v",
           0, 2*pi, -2, 2, 60, 60, False, True)
</code>

**''setMatrix(matrix)''** takes a ''Matrix'' object only — **not** another ''Graph3D''. Use ''matrix("name")'' or ''currentMatrix()''.

**''setDataColors'' and ''rescaleColorMap()'' are mutually exclusive.** ''rescaleColorMap()'' reloads the system color map and discards any ''setDataColors()'' gradient. Call one or the other, never both:

<code python>
g3.setDataColors(QColor("navy"), QColor("firebrick"))  # custom gradient — no rescaleColorMap()
# OR
g3.rescaleColorMap()                                   # system map — no setDataColors()
</code>

----

====Plotting====

**When the request's data has a known physical quantity and unit (e.g. SANS ''Q'' in Å^-1, ''I(Q)'' in
cm^-1, a fitted radius in nm), set real axis titles with those units via ''setXTitle()''/''setYTitle()''
(see graph-python-api.txt) — don't leave a new graph's axes at their default labels, which just echo
the raw column name (''"Q"'', ''"I"'') with no unit and no indication of what the quantity physically is.**
This never raises an error or looks obviously wrong in ''scriptPrint()'' output, so it's easy to skip
every time — confirmed live across many SANS-fit requests in a row, none of which ever called
''setXTitle()''/''setYTitle()'' despite generating data with an explicitly known ''Q''/''I(Q)'' physical
meaning. Use HTML tags for units/exponents, not LaTeX (see graph-python-api.txt's superscript rule):
<code python>
g.setXTitle("Q (&Aring;<sup>-1</sup>)")
g.setYTitle("I(Q) (cm<sup>-1</sup>)")
</code>

**''plot()'' always creates its own new graph window.**
Never call ''newGraph()'' before ''plot()''.

<code python>
# t = newTable("data", 100, 2); t.setColName(1, "x"); t.setColName(2, "y")
# plot() takes bare column names when the table object is passed

# CORRECT
t = table("data")
g = plot(t, ("x", "y"), style=Graph.CurveType.Line)
setWindowName(g, "MyGraph")
activateWindow(g)
maximizeWindow(g)

# WRONG — newGraph() creates an empty orphan window; plot() ignores it
g = newGraph("MyGraph")
plot(t, ("x", "y"), style=Graph.CurveType.Line)
</code>

**After ''plot()'' returns a window, use that object directly — don't re-look it up afterward.** ''setWindowName()'' renames the window in place; the variable you already have (''g'') remains valid and already IS the exact same object ''graphWindow("name")'' would return.

<code python>
# WRONG — gw is a redundant re-fetch of the exact same object already held in g
g = plot(t, ("x", "y"), style=Graph.CurveType.Line)
setWindowName(g, "MyGraph")
gw = graphWindow("MyGraph")
gw.activeLayer().replot()

# CORRECT — g is already the renamed window; use it directly
g = plot(t, ("x", "y"), style=Graph.CurveType.Line)
setWindowName(g, "MyGraph")
g.activeLayer().replot()
</code>

Confirmed live.

**''GraphWindow'' (''MultiLayer'') has no ''replot()'' method.**
Use ''g.activeLayer().replot()''.

<code python>
g.activeLayer().replot()
g.activeLayer().addCurve(t, "y", Graph.CurveType.Line)  # single Y col str — NOT a tuple; X is auto-detected
</code>

----

====Widget names====

**Widget names are case-sensitive.** ''table("Sample1")'' and ''table("sample1")'' are different objects.
Always use the exact name as shown in the window title bar. Don't default to a capitalized generic
placeholder like ''table("Data")'' when a request uses a plain lowercase word (e.g. "table data") —
that's not a real table name, it's the common English word; use the exact name the user actually
gave, case included, or discover it via ''windows()''/an EXPLORE step if genuinely unnamed.

**Lookup vs create — naming pattern:**

^ Look up existing ^ Create new ^
| ''table("name")'' | ''newTable("name", rows, cols)'' |
| ''note("name")'' | ''newNote("name")'' |
| ''matrix("name")'' | ''newMatrix("name", rows, cols)'' |
| ''graphWindow("name")'' | ''newGraph("name", layers, rows, cols)'' |

**If the intent is to modify/extend/add to something that might already exist, use the LOOKUP function (''table()''/''graphWindow()''/etc.), not the CREATE function — even when the create function happens to also work on an existing object by name, that's not what it's for, and for tables it's actively destructive.** ''newTable(name, rows, cols)'' on an existing table resizes AND CLEARS it — calling it with fewer columns than the table currently has silently discards the extra columns and ALL data, even columns added in an earlier, separate turn (e.g. an error-bar column added after the table was first created). Confirmed live: a table originally created with 2 columns, later extended to 3 (an error-bar column added in a separate turn) and plotted with error bars, was "corrected" by calling ''newTable(name, n_points, 2)'' again — silently wiping the error-bar column and clearing all data, which broke the existing graph's error-bar curve. ''newGraph(name, ...)'' is NOT the same risk: with its default ''layers=1'', calling it on an existing name is a complete no-op — the graph comes back completely untouched, no clearing, no resize. Only a DIFFERENT row/col layer grid than the graph already has (via ''layers>1'') causes any change, and even then it's just the layer arrangement, never curves. Still, use ''graphWindow(name)'' when the intent is "give me the existing one to work with," and reserve ''newGraph()'' for building from scratch.

<code python>
# WRONG — "correcting" an existing table via newTable(): cols=2 matches the ORIGINAL creation
# call, not the table's CURRENT structure (it has grown a 3rd "dI" column since) — this silently
# drops that column and clears every cell, breaking any graph already plotting it
t = newTable("SphereSANS", 200, 2)
for i in range(1, 201):
    q = 0.001 + 0.0025 * (i - 1)
    I = corrected_formula(q)
    t.setCell(1, i, q)
    t.setCell(2, i, I)

# CORRECT — look up the existing table and fix only what actually needs fixing; the "dI" column,
# its data, and any graph/curve depending on this table are all left untouched
t = table("SphereSANS")
for i in range(1, t.numRows() + 1):
    q = t.cell(1, i)
    t.setCell(2, i, corrected_formula(q))
t.notifyChanges()
</code>

Adding a new column to an existing table is the same principle, one level further: ''newTable(name, rows, cols+1)'' would still hit the same resize+clear path and wipe every existing column's data just to add one more. Use ''table()'' + ''addColumn()'' instead — it appends in place, leaving every existing column untouched:

<code python>
# WRONG — bumping cols to "add" a column still resizes+clears the WHOLE table via newTable()
t = newTable("SphereSANS", 200, 3)   # existing "q"/"I" data is wiped even though the intent was
                                      # only to ADD a 3rd column, not touch the first two
t.setColNames(["q", "I", "dI"])
# ... q and I now need to be recomputed/recopied from scratch just to undo the wipe

# CORRECT — look up the existing table, append a column with addColumn(); q and I are untouched
t = table("SphereSANS")
t.addColumn(Table.PlotDesignation.yErr)          # appends one new column, existing ones unchanged
t.setColName(t.numCols(), "dI")                  # numCols() now reflects the column just added
for row in range(1, t.numRows() + 1):
    I = t.cell(2, row)
    t.setCell(3, row, 0.01 * I)                  # 1% relative error, purely additive
t.notifyChanges()
</code>

----

====Column names====

**When a table object is passed, use bare column names.**
When only a string is used (fittable ''datasets='', ''addCurve'' without a table object), use ''"tablename_colname"''.
For a table named ''Table'' with a column ''y'': bare = ''"y"'', string-only = ''"Table_y"''.

<code python>
t = newTable("Table", 100, 2)
t.setColName(1, "x")
t.setColName(2, "y")
t.setColumnRole(1, Table.PlotDesignation.X)
t.setColumnRole(2, Table.PlotDesignation.Y)
# Y-column name for fitting: "Table_y"   (tablename_colname)
</code>

Use ''f.datasets()'' to list all Y-column names available in the workspace.

**Never put ''_'' inside the column label itself.** ''"tablename_colname"'' is split on the *last*
underscore internally — a column named ''"y_noisy"'' in table ''"data"'' builds the identifier
''"data_y_noisy"'', which gets misparsed as table ''"data_y"'' (doesn't exist) + column ''"noisy"''.
The fit then silently fails to find the table and aborts with ''"A problem with Reading Data"''.
Confirmed live. Use ''"y"'' or ''"ynoisy"'', never ''"y_noisy"''.

----

====Column Addressing====

Different objects use **different argument order** for cell access:

^ Context ^ Argument order ^ Example ^
| **Table** cell access | col **first**, row second — both 1-based | ''t.setCell(col, row, value)'' · ''t.cell(col, row)'' |
| **Matrix** cell access | row **first**, col second — both 1-based | ''m.setCell(row, col, value)'' · ''m.cell(row, col)'' |
| ''plot()'' / ''addCurve()'' with table object | bare column names | ''"x"'', ''"y"'' — **not** ''"MyData_x"'' |
| Fittable ''datasets='' (string-only context) | ''"tablename_colname"'' | table ''"MyData"'', col ''"y"'' → ''"MyData_y"'' |

**''colNames()'' returns BARE labels, same as the ''plot()''/''addCurve()'' row above — NEVER the
table-prefixed form:** ''t.colNames()'' → ''("x", "y")'', not ''("MyData_x", "MyData_y")''.
Confirmed against source (SIP bindings call ''colLabel()'', not the differently-named,
table-prefixing C++ ''Table::colName(int)'', which is never invoked from Python at all). The
''"tablename_colname"'' form in the Fittable row above must be built manually
(''t.objectName() + "_" + label'') — ''colNames()'' is never where it comes from. Confirmed live:
checking ''"MyData_y" not in t.colNames()'' as an existence guard is ALWAYS true even when column
''"y"'' genuinely exists, since that string never appears in ''colNames()'''s result — this
silently blocked an entire comparison-and-plot stage with no error, while split-mode reported the
affected parts "done."

----

====Fittable====

**''configure()'' takes a function NAME, not a formula string.**

<code python>
# CORRECT
f = Fittable
f.configure("Guinier", ["data_I"])

# WRONG — raises ValueError
f.configure("a*exp(-x*x*Rg*Rg/3)+bgd", ["data_I"])
</code>

Use ''f.fitFunctions()'' to list all available function names.

----

**EVERY ''configure()''/''setFunction()'' call RESETS all parameter adjustability to the COMPILED
function's baked-in defaults — it never inherits vary flags from a prior fit session on the same
function.** Re-apply ''setParamVaries()''/''setParamVariesAll()'' after EVERY ''configure()'' call,
not just the first. Confirmed live: switching functions and then switching back produced a bad fit
because the vary flags set before the switch were silently gone.

<code python>
# WRONG — vary flags set once, then lost after switching functions and back
f.configure("Guinier", ["data_I"])
f.setParamVariesAll([True, True])
f.configure("OtherModel", ["data_I"])   # ... some other work ...
f.configure("Guinier", ["data_I"])      # vary flags are back to compiled defaults, NOT [True, True]
chi2 = f.fit()                          # may raise "no adjustable parameters" or fit fewer params than intended

# RIGHT — re-apply vary flags after every single configure() call
f.configure("Guinier", ["data_I"])
f.setParamVariesAll([True, True])
</code>

----

**''datasets'' are Y-column names, not table names.**

<code python>
f.configure("Guinier", ["data_I"])   # correct: column "I" in table "data"
f.configure("Guinier", ["data"])     # WRONG: raises ValueError
</code>

----

**''plotResults(graphName, graphTemplate)'' — the second argument must be ''""'' or a name from ''qti.app.graphTemplates()'' without the ''.qpt'' extension. Never pass an arbitrary word like ''"graph"'' — if the template file doesn't exist, the plot silently falls back to a default layout.**

<code python>
# WRONG — "graph" is not a valid template name; falls back silently
f.plotResults("MyFit", "graph")

# RIGHT — use empty string (no template) or check what's available
f.plotResults("MyFit", "")

# RIGHT — use EXPLORE first to find valid template names
# EXPLORE: scriptPrint(str(qti.app.graphTemplates()))
# Then in action script:
tpl = "fit-plus-residues" if "fit-plus-residues" in qti.app.graphTemplates() else ""
f.plotResults("MyFit", tpl)
</code>

----

**''setCurveColor()'' must be called after ''configure()'' and before ''fit()'' / ''plotResults()''.**
Do NOT import PyQt5 or PyQt6 to set curve colors — use the API method.

<code python>
f.configure("Guinier", ["data_I"])
f.setCurveColor("blue")             # after configure(), before fit()
f.fit()
f.plotResults("FitResult")

# WRONG — PyQt5 is not available; PyQt6 would work but is not needed
from PyQt5.QtGui import QColor
g.activeLayer().setCurveLineColor(1, QColor(0, 0, 255))
</code>

----

**X-range rule: when generating synthetic data or setting a fit range, the data must cover the function's full dynamic range. If a feature (peak, knee, plateau) is outside the data range, all parameters become highly correlated and errors blow up.**

^ Function type ^ Required x-range ^
| Peaked (Gaussian, Lorentzian) | ''x_min < x0 − 3·sigma'' AND ''x_max > x0 + 3·sigma'' |
| Decay in x (''A·exp(−b·x) + c'') | ''x_max ≥ 3/b'' (need ≥ 3 decay lengths to separate amp from background) |
| Decay in x² — Guinier (''I0·exp(−Rg²·q²/3) + bgd'') | ''q_min < 1.73/Rg'' — if ''q_min·Rg >> 1'', the signal is exp(−large) ≈ 0 everywhere and only background is visible |
| Power-law / plateau | cover the full transition from steep slope to flat region |

<code python>
# WRONG — Guinier with Rg=25 but q starts at 0.5 → q_min*Rg = 12.5 >> 1 → signal ≈ 0 everywhere
for i in range(1, 201):
    q = 0.5 + 0.05 * i                          # q ∈ [0.5, 10.5] — all exp(-Rg²q²/3) ≈ 0
    y = 100.0 * exp(-25.0**2 * q**2 / 3.0)      # ≈ 0 for all q; only background visible

# RIGHT — Guinier needs q_min < 1.73/Rg = 1.73/25 ≈ 0.07
for i in range(1, 201):
    q = 0.001 + 0.0005 * i                      # q ∈ [0.001, 0.1] → q*Rg < 2.5 → signal visible
    y = 100.0 * exp(-25.0**2 * q**2 / 3.0) + 0.5

# WRONG — Gaussian peak at x0=5, sigma=2 but x only goes to 2.0 → peak outside data
for i in range(1, 201):
    x = 0.01 * i            # x ∈ [0.01, 2.0] — only left tail visible

# RIGHT — cover x0 ± 3*sigma = 5 ± 6 → use [0, 11] or wider
for i in range(1, 201):
    x = 0.055 * i           # x ∈ [0.055, 11.0] → covers full peak
</code>

----

**Fit method: use LM for all smooth analytical functions — Simplex is WRONG for them.**

''setFitMethod("LM")'' is the default and converges in fewer than 20 iterations for Gaussian, sphere, Guinier, cylinder, and any continuously differentiable model. ''setFitMethod("Simplex")'' or ''setFitMethod(0)'' runs 500–1000 iterations for smooth models without converging — do NOT use it for analytical functions.

<code python>
# RIGHT — LM converges in <20 iterations; or omit entirely (LM is already the default)
f.setFitMethod("LM")
</code>

Only use Simplex for tabulated or step-function models where derivatives are unreliable.

----

**Stage requirements** — these methods raise ''RuntimeError'' if called in the wrong stage:

^ Method ^ Required stage ^
| ''fit()'', ''simulate()'', ''plotResults()'' | ''"fit"'' |
| ''beforeFit()'', ''afterFit()'' | ''"fit"'' — called internally by ''fit()''; do NOT call manually |
| ''simulateCurve()'' | ''"simulate"'' |
| ''setCurveColor()'' | after ''configure()'' (not ''"init"'') |

----

**''plotResults()'' returns a ''GraphWindow'' (''MultiLayer''), NOT a ''Graph'' (layer)** —
''legend()'', ''newLegend()'', ''setLegend()'', ''setXTitle()'', ''setYTitle()'', ''addCurve()'',
''setCurveLineColor()'', etc. all raise ''AttributeError'' if called directly on it. Always:
<code python>
g = f.plotResults("FitResults").activeLayer()
g.setXTitle("q, 1/nm")   # NOT gw.setXTitle(...)
</code>
Confirmed live: FIVE separate ''AttributeError''s in one conversation from this exact mistake
(''gw.setXTitle'', ''gw.legend'', ''gw.setLegend'' ×2, ''gw.newLegend'') — see
''fittable-python-api.txt'' for the full incident. ''plotResults(graphName, ...)'' itself DOES
reuse a same-named existing window (no pileup from that call alone) — a separate, redundant bare
''plot(t, ...)'' call made before fitting (which has no name-based reuse) was what actually
orphaned one window per retry in that same conversation; skip it if ''plotResults()'' already
shows the data.

----

====Meta commands====

Type these directly into the AI input — they are answered instantly without an API call:

^ Command ^ Result ^
| ''ai status'' | model, URL, docs mode, history turns, max tokens |
| ''ai model'' | active model name |
| ''ai url'' | base URL |
| ''history'' | list Q&A turns from this session |
| ''clear history'' | reset conversation context |
| ''stop'' / ''ai stop'' / ''cancel'' / ''ai cancel'' | abort a request currently in flight |

----

====Qt bindings====

QtiSAS embeds **PyQt6** on macOS and Windows. On Linux, the build may use **PyQt5** or **PyQt6**
depending on the Qt version installed — do not assume either.
Prefer the QtiSAS API over direct Qt calls whenever possible.

Qt symbols (''QColor'', ''QFont'', ''QPen'', ''Qt'', ''QFrame'') are **not** in the default script namespace — import them explicitly when needed:

<code python>
from PyQt6.QtGui import QColor, QFont, QPen, QBrush
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import QFrame
</code>

Never import ''QApplication'' — it transfers ownership to Python GC and crashes the app.
