Header menu logo issie

Parameter System Documentation

Overview

The parameter system in Issie allows users to define symbolic integer parameters on design sheets and use them in mathematical expressions to configure component properties. This enables parameterized component design where values can be dynamically calculated based on parameter bindings rather than being hardcoded. The system supports hierarchical parameter scoping and instance-specific parameter overrides.

Key Features

Architecture

The parameter system is implemented across several key modules with clear separation of concerns:

Core Data Types (ParameterTypes.fs)

The parameter system's foundation is built on these core types:

Basic Types

Expression AST

/// Named by an enumeration, so that adding a two-argument function is a case HERE
type ParamBinFunc =
    | PMin
    | PMax

type ParamExpression =
    | PInt of ParamInt                              // Integer constant, of any size
    | PParameter of ParamName                       // Parameter reference
    | PAdd of ParamExpression * ParamExpression     // Addition
    | PSubtract of ParamExpression * ParamExpression // Subtraction
    | PMultiply of ParamExpression * ParamExpression // Multiplication
    | PDivide of ParamExpression * ParamExpression  // Division
    | PRemainder of ParamExpression * ParamExpression // Modulo
    | PShiftLeft of ParamExpression * ParamExpression // x<<y: multiply by 2^y
    | PShiftRight of ParamExpression * ParamExpression // x>>y: divide by 2^y, arithmetic
    | PCLog2 of ParamExpression                     // clog2(x)
    | PBinFunc of ParamBinFunc * ParamExpression * ParamExpression // min(x,y), max(x,y)

Saved files key a DU case by its name, not its position (SimpleJson/Json.Converter.fs), so appending a case leaves existing .dgm and .ldgm files readable. PInt now writes as a quoted string ({"PInt": "16"}) because that is how bigint is encoded — the same form Constant1 has always used — and both the numeric and the string form are accepted when reading, so older files load unchanged. Files written by this version cannot be opened by an older Issie.

Constraints

type ParamConstraint =
    | MinVal of ParamExpression * ParamError  // Minimum value constraint
    | MaxVal of ParamExpression * ParamError  // Maximum value constraint

Component Slots

type CompSlotName =
    | Buswidth              // Component bus width
    | IO of Label: string   // Input/Output port widths
    | CustomCompParam of ParamName: string // Custom component parameters
    | SplitNWidth of Index: int // SplitN output width
    | SplitNLSB of Index: int   // SplitN output LSB
    | InputDefault          // Value an Input1 takes when undriven
    | MemoryAddressWidth    // Bits of a memory's address, so 2^n locations
    | MemoryWordWidth       // Bits a memory holds at each location

A memory has two widths and neither is "the" width, so neither can be Buswidth: they have slots of their own, which apply to all four memory components. What that costs is not the slots but the contents — see Memory contents and memory widths.

The number of inputs of a gate or merge is deliberately not a slot: an input count sets how many ports a component has, and a parameter records a value, not a change of topology. It is edited as a plain number in Properties.

The label in an IO slot is not part of the slot's identity. It records the component's label as it was when the slot was created, and nothing rewrites it when the component is renamed. ParameterTypes.sameSlot therefore compares CompId and the slot kind, and tryFindSlot / addSlot / removeSlot are what every reader and writer uses. Treating the label as part of the key let a rename orphan the slot and a second slot be created for the same field, with which of the two applied decided by Map key order. CanvasExtractor.tidyParamSlots repoints the stored label on save, so it stays worth displaying; nothing depends on it being right.

Sheet-Level Definitions

/// What an INSTANCE binds: no description, because the description belongs to the declaration
type ParamBindings = Map<ParamName, ParamExpression>

/// The DECLARATION of one parameter on a sheet
type ParamDefinition = {
    Expression: ParamExpression     // its default value
    Description: string             // compulsory: see below
}
type ParamDefinitions = Map<ParamName, ParamDefinition>

type ParameterDefs = {
    DefaultBindings: ParamDefinitions // the parameters this sheet declares
    ParamSlots: ComponentSlotExpr     // component slots bound to expressions
}

A parameter must carry a description. It is what the user reads when a custom component instance of the sheet asks them for a value, so a parameter without one cannot be explained at the point it has to be understood. addParameterBox and editParameterBox both refuse to commit an empty description.

ParameterTypes.bindingsOf : ParamDefinitions -> ParamBindings drops the descriptions, and is how every evaluation environment is derived from a sheet's declarations — ParameterAnalysis.declaredParams, ParameterView.getDefaultParams and GraphMerger.defaultBindingsOfSheet all go through it. Use declaredParamDefs / getDefaultParamDefs where the description itself is wanted.

UI Layer (ParameterView.fs)

Manages all parameter-related user interactions:

Sheet Parameter Management

Component Parameter Binding

Parameter Evaluation

Simulation Integration (GraphMerger.fs)

Handles parameter resolution during simulation graph construction:

Stage 1 - Graph Merging

Stage 2 - Parameter Resolution (resolveParametersInSimulationGraph)

A single recursive walk (resolveSheet) resolves each sheet and then descends into the sheets of the custom components it contains:

Design-Time Analysis (ParameterAnalysis.fs)

This whole layer is analysis and UI. Elaboration semantics are untouched: only explicit per-instance bindings exist, and everything here is derived display state and consented repair. (The alternative — auto-binding unbound parameters outward by name, i.e. dynamic scoping along the instance path — reaches the same end state implicitly, and brings name capture, accidental unification of unrelated same-named parameters, a "local" opt-out marker, and new semantics to teach.)

What an instance's ports are (CanvasExtractor.fs)

A parameterised sheet has no single signature. It has a family of them, one per set of bindings, so the port widths of a custom component instance are a fact about the instance, not about the sheet: two instances of one sheet are meant to differ.

CanvasExtractor.signatureOfInstance is the only place that works this out — the child sheet's canvas resolved at the instance's bindings, with those bindings first evaluated in the sheet the instance sits on, because an instance binding is an expression in the parent's parameters. Four callers go through it, and they held three divergent copies before:

caller

what it needs the signature for

CatalogueView.placeCustomComponent

sizing the ports of an instance being placed

ParameterView.portWidthsOfInstance

resizing them when a binding is edited

CanvasStateAnalyser.checkCustomComponentForOkIOs

checking an instance before simulation

CustomCompPorts.getInstancesOfCurrentSheet

bringing instances back into step

signatureOfInstanceWithCertainty also reports whether the widths can be believed. A canvas is checked without reference to whatever contains it, so checkCustomComponentForOkIOs has no parent environment: a binding that is an expression in the parent's parameters cannot be evaluated there, the signature comes back inexact, and only the port names are compared. The widths are left to elaboration, which has the parent's bindings and is exact. Comparing them anyway would fail a design that is perfectly correct.

Keeping instances in step (CustomCompPorts.fs)

When a sheet's ports change, every instance of it elsewhere in the project must be updated. The invariant is per instance:

an instance is out of date exactly when it differs from what its own bindings give it

getOutOfDateDependents tests each instance against its signatureOfInstance, and updateInstance brings each to its own signature. The dialog reports ports added, deleted and renamed — facts about the sheet — and separately names the instances whose widths alone change.

The tempting simplification here — that every instance must equal the sheet — is wrong, and wrong loudly: it raises "you have changed the inputs or outputs" on every save of any parameterised design, and accepting that forces each instance to the sheet's declared widths while leaving its bindings alone, which the simulator then rejects with BadInputs.

Component Creation (CatalogueView.fs)

Integrates parameters during component instantiation: - Raises ParameterView.customComponentParamPopup for a sheet that declares parameters, asking for a value for each — see Design-Time Analysis - Sizes the instance's ports with signatureOfInstance at the chosen bindings, before it is created - Sets ParameterBindings on the instance, and addParamComponents records its slots once it has an id

Data Flow

1. Parameter Definition Flow

User Input (Properties Panel): name, description, default value
    
ParameterView.addParameterBox  (name checked by isValidParamName;
                                description compulsory)
    
Update Model.LoadedComponent.LCParameterSlots.DefaultBindings
    
bindParamOnInstances 
    
markSheetParamsChanged 
    
Persist to .dgm file on save

2. Component Parameterization Flow

User selects component property
    
ParameterView.paramInputField
    
ParameterTypes.parseExpression, then evaluateParamExpression
    
evaluateConstraints (returns what is unmet; dispatches nothing)
    
updateParamSlot 
                  and markSheetParamsChanged
    
updateComponentSlots 
                       all of that component's slots together

3. Simulation Resolution Flow

Simulation Start
    
GraphMerger.mergeDependencies
    
Stage 1: Merge graphs (defer parameters)
    
Stage 2: resolveSheet 
         each sheet below with its instance's bindings,
         memoised on (sheet, diff from defaults)
    
FastSim with resolved values

4. Custom Component Flow

customComponentParamPopup 
                            declares, or "bind to the parent's <name>"
    
CanvasExtractor.signatureOfInstance:
      instance bindings evaluated in the PARENT
    
    
    
    
Create the instance with those ports and those ParameterBindings
    
addParamComponents records its slots once it has an id

Expression Language

The parameter expression parser supports:

Syntax Elements

Shifts

a<<b is a multiplied by 2^b and a>>b is a divided by it, so 1<<w is the number of values a w-bit bus can take — the thing that previously had to be written as a multiplication with the power worked out by hand. They are written and bound as Verilog writes and binds them, which is the language these expressions are learnt beside: more loosely than +, so w+1<<2 shifts the sum, and left-associative, so 1<<2<<3 is 32.

>> is arithmetic: it rounds towards minus infinity and keeps the sign, so -1>>1 is -1 and -7>>1 is -4. There is no logical right shift to go with it, because a parameter is a number rather than a bit pattern of some width — there is no width for zeros to come in from. bigint division truncates towards zero, so ParameterTypes.shiftRightBy does the rounding itself; both shifts are written as multiplication and division rather than with <<</>>> so that .NET and Fable's own bigint cannot disagree about a negative operand, which nothing else in Issie shifts.

The number of places must be between 0 and ParameterTypes.Constants.maxShiftCount, which is the widest bus Issie has; either way out is an evaluation error naming the shift. The bound exists because the number on the left grows with the count: unbounded, 1<<1000000000 is a hundred megabytes of bigint reached by holding a key down in a properties box.

Functions

Three built-ins, written as calls and so needing no precedence of their own:

Written

Means

clog2(x)

bits needed to index x things: ceil(log2 x). clog2(8) is 3 and clog2(9) is 4; 0 and 1 both give 0, as Verilog's $clog2 does. A negative argument is an error

min(x,y)

the smaller of the two

max(x,y)

the larger of the two

clog2 is the one that makes a width follow a size: an address bus for an N-word memory, a select input for an N-way mux, the shift amount for an N-bit shifter. CommonTypes.shifterWidthFor computes the SHIFT input's width with the same function, so clog2 written in a properties box means exactly what Issie does internally. min/max are there because clamping is usually what comes next: max(clog2(N),1) is the idiom, since a width must never be 0.

Names are matched without regard to case: clog2, CLOG2 and CLog2 are one function, as are min and MIN. They are written back in lower case, so MAX(1,2) re-renders as max(1,2). Because the parser reads them as functions, they are reserved: a parameter may not be called clog2, min or max in any case, and the "Add parameter" dialog says so.

Example Expressions

WIDTH           // Simple parameter reference
WIDTH + 1       // Increment parameter
(n * 8) - 1     // Complex calculation
baseAddr + (offset * 4)  // Address calculation
WIDTH / 2       // Division
SIZE % 8        // Modulo operation
-1              // Negative literal
BIAS - -4       // Subtracting a negative
clog2(WORDS)    // Address bits for a memory of WORDS words
max(clog2(N),1) // ...clamped, since a width of 0 is not a width
min(WIDTH,32)   // Capping a width
1<<WIDTH        // The number of values a WIDTH-bit bus can take
(1<<WIDTH)-1    // ...and the largest of them
WIDTH>>1        // Half a width, rounded down

Parser Implementation

The parser uses recursive descent with separate functions for each precedence level: - parsePrimary: Handles numbers, variables, function calls, unary minus, and parentheses - parseFactors: Processes multiplication, division, modulo - parseTerms: Handles addition and subtraction - parseExpressionTokens: Handles the shifts, and so is the whole-expression level — what a bracketed group, a function argument and the input as a whole are each parsed as

A call is parsed in parsePrimary because it is atomic — its own parentheses delimit it — and its arguments are whole expressions, parseExpressionTokens stopping at the , or ) that ends each one. The list of two-argument functions is derived from the ParamBinFunc DU by EEExtensions.Union.allCases, so adding a case to that type reserves its name and reaches the parser with no second edit; only binFuncName must then cover it, which the compiler requires.

One name rule. ParameterTypes.isValidParamName ([a-zA-Z][a-zA-Z0-9]*, and not a built-in function name) is both what the "Add parameter" dialog accepts and what the tokenizer reads as a name, because a name that cannot be written in an expression is of no use. Two rules diverging breaks it in both directions: a name the dialog takes but the tokenizer will not read can be declared and never referred to, and one the tokenizer reads but the dialog marks invalid is shown in red and accepted anyway. That is also why a function name cannot be a parameter: the parser reads min as the function, so a parameter of that name could never be referred to. A number run directly into a name (2W) is reported as such, since it is either a missing * or a name from a file written under a looser rule.

Negation is PSubtract (PInt 0, e), not a new AST case: subtraction from zero is the same expression, so every function over ParamExpression already handles it and no saved file changes. A negated literal is folded to PInt -n so it renders back as the user typed it.

Notes and caveats: - Tokenizer restricts inputs to digits/letters/operators/whitespace; unsupported characters are reported precisely. - Division or modulo by zero is reported as an informative evaluation error, as is a parameter defined in terms of itself, and a shift by a negative or oversized number of places. - A single < or > is a token only so that it can be refused with a message saying a shift is written <<. Unmatched characters are dropped by the tokenizer, so without it a<b would tokenise as a b and be reported as a number run into a name.

Code: src/Renderer/Common/ParameterTypes.fs (parseExpression, isValidParamName, tokenizer regex, and helpers). Tests/Issie.Tests/Properties.fs holds a render/parse round-trip property over generated expressions, negative literals included.

Parameter Scoping & Precedence

Scope Levels

  1. Sheet-level parameters: declared in sheet properties, in scope for the slot expressions of components on that sheet, and for the bindings of instances placed on it
  2. Instance bindings: what an instance supplies for the parameters of the sheet inside it. The expression is in the parent's parameters; the child sheet knows nothing of them

Nothing is inherited. Scoping is single-level and there is no implicit outward lookup: a parameter of an enclosing sheet reaches a child only through an explicit binding written on each instance along the way. That is a deliberate rejection of dynamic scoping by name, which brings name capture, accidental unification of unrelated same-named parameters, and a "local" opt-out marker to teach. The bind-to-top button exists to materialise such a chain on request.

Precedence

An instance's binding wins over the child sheet's declared default, for every parameter the child declares. That is the whole rule — GraphMerger.effectiveBindings and CanvasExtractor.effectiveInstanceBindings are the two places that implement it, identically.

Example Scenario

Sheet B declares:                 WIDTH = 16   (its default)
Sheet A declares:                 W = 8
Instance of B on A binds:         WIDTH = W * 4

Inside that instance, B resolves at WIDTH = 32.
Opened on its own, B still resolves at WIDTH = 16.

Constraint System

Constraints keep a value within the range the field it is going into can hold:

Constraint Definition

type ParamConstraint =
    | MinVal of ParamExpression * ParamError
    | MaxVal of ParamExpression * ParamError

The error text is author-written and is handed to the user unchanged — it should say what is wrong with this field, not restate the bound.

Derived from the slot, not stored on it

ComponentSlots.constraintsFor : CompSlotName -> ComponentType -> ParamConstraint list is the one place that says what may go in a slot, and lives beside trySetSlotValue, which says where it goes. Every box asks it rather than building a list of its own:

slot

bounds

any width

1 .. CommonTypes.Constants.maxIssieBusWidth

InputDefault, and a BusCompare value

0 .. 2^w - 1 at the component's current width

a BusSelection LSB

>= 0 — a bit position, with no width to exceed

CustomCompParam

none here: see below

Two things follow, and both used to be wrong. A bound computed from the component's width is recomputed every time the pane is drawn, so widening or narrowing the component — which a property can do without the box being touched — moves the bound with it; built inline at the box, it was frozen at the width showing when the expression was typed, and an Input's "must fit in 8 bits" outlived the 8. And a value arriving any other way is now bounded too: maxIssieBusWidth was enforced only by those inline lists, so a width reached through an instance binding, or written by the sheet-description DSL, had no upper limit at all.

The Constraints stored on a slot in the .dgm are still written as they always were, so files are unchanged in both directions — but they are no longer what a value is checked against.

An instance binding is checked against the sheet inside it

The bounds on a CustomCompParam value belong to the child sheet, and are expressions in the child's parameters — which is why they cannot be handed to paramInputField, whose constraint list is evaluated in the parameters of the sheet the instance sits on. ParameterView.instanceBindingProblem therefore resolves the child sheet instead: it takes the bindings that instance gives (CanvasExtractor.effectiveInstanceBindings), substitutes the candidate value, and runs evaluateConstraints over the child's slots with their derived constraints — the same call editParameterBox makes for the open sheet. Only the slots that use the parameter are checked, so a complaint can never name a box the user is not editing.

Memory contents and memory widths

A memory's widths can be parameters; its contents cannot. Memory1.Data is one map, and every instance of the sheet holds the same one — so a sheet used at two sizes has one set of contents that has to fit both, and "do the contents fit" becomes a question with an answer per instance.

MemoryData holds the question — does this map fit these widths — and three places ask it, each about different widths:

when

widths asked about

where

a width is edited

none: nothing is checked, nothing is discarded

contents are edited, or a .ram file is linked

every shape the memory has in the design

MemoryEditorView, MenuHelpers.makeSourceMenu

the design is simulated

the shape that instance resolved to

FastValidate.checkAndValidate

Editing a width throws nothing away, deliberately: a memory passing through a size that does not hold its data is an ordinary step on the way to one that does, and the data is the only copy of what the user typed. The simulator is where a memory finally has one definite shape — the canvas it was drawn from has neither shape when a sheet is used at two sizes — so that is where contents that do not fit are an error rather than a state being passed through.

The width sets come from ParameterAnalysis.memoryWidthsInDesign, over bindingEnvironmentsOf: one environment per distinct way the design binds the sheet. The per-parameter answer (displayValuesOfSheet) cannot be used for this, because both widths of a pair must come from the same instance and that function has already forgotten which instance each value came from.

One evaluator, one slot mapping, one instance signature

There is one expression evaluator, ParameterTypes.evaluateParamExpression. What differs between contexts is the environment it is given and what a failure means:

context

environment

a failure is

properties pane

the open sheet's declared bindings

a message under the input box

simulation (GraphMerger.resolveSheet)

each sheet's effective bindings

a SimulationError naming the component and sheet

design-time analysis (ParameterAnalysis)

the values computed down the instance tree

an unknown value, never reported as a conflict

Two other mappings are likewise single:

Keep it that way. Each of these has been three or four copies at some point, and each time the copies drifted apart and the drift was a bug: a slot applied by the canvas and ignored by the simulator, an instance placed at the child's default widths, a renamed port silently losing its parameterised width.

Persistence

Parameter data is stored across multiple locations:

File Storage

Runtime State

Nothing else is kept. Where one design reaches a sheet by paths that bind it differently, the values it is not drawn at are not stored anywhere: the properties pane asks displayValues for them as it draws, which is the same question and one fewer copy of the answer to keep in step.

Knowing when to save

UpdateHelpers.currentSheetIsOutOfDate decides whether the open sheet needs saving by comparing its canvas against the saved one, plus the LoadedComponentIsOutOfDate flag. A change confined to LCParameterSlots — a parameter declared, a description written, an unused one deleted, or a slot given an expression that works out to the width already shown — leaves the canvas identical, so it is invisible to that comparison. Every path that edits parameter data therefore sets the flag, through ParameterView.markSheetParamsChanged. A new such path that forgets to call it leaves the save button dark and the work is dropped on leaving the sheet, with nothing to say so.

Bringing every sheet into line with what its design sets

A sheet is not drawn at its declared values and then adjusted for display: the values its design settles are written into it, canvas and declarations alike. PropagateParameters is the message that does it, and sending it twice or in the wrong order is harmless, because what it triggers is a pure recomputation from the primary state rather than an incremental edit — each design's top-sheet values, and the bindings on the instances below. That is what makes it safe to send after anything, and it is sent after everything: a project load, a top-sheet choice, an edit to what a sheet declares, and any draw-block message that changed an instance's bindings (Update.fs).

Three steps, and they are separate because no one of them can do the others' work:

  1. *ParameterAnalysis.propagateParameterValues* — pure, over the LoadedComponents. For every sheet it works out what its design sets each parameter to, writes the settled value into DefaultBindings, and rewrites the sheet's canvas at those values with ComponentSlots.resolveCanvasAtBindings. A parameter nothing sets is left exactly as it is: its stored value is the primary state for that sheet, and overwriting it would destroy the only copy. The instance tree is walked once per candidate top and the answers reused; asking effectiveTopSheetFor and then displayValues per sheet, as this did, cost sheets × (roots + 1) walks on every edit.
  2. *CanvasExtractor.syncInstancePorts* — also pure, and separate because of what step 1 cannot reach. Resolving a CustomCompParam slot writes the value into the instance's ParameterBindings, which is as far as ComponentSlots.setSlotValue can see; the instance's port widths follow from that binding by way of the child sheet, and only signatureOfInstance knows how. Without this step a sheet came out of step 1 holding an instance whose bindings said one width and whose ports still said another — invisible on the open sheet, which is redrawn anyway, and written straight to file on every other, so that opening it raised the very instance-out-of-date error the per-instance signature exists to prevent. Widths only: the order of an instance's ports, and which ports it has, are left alone (see Keeping instances in step).
  3. *ParameterView.propagateParameters* — the part that touches the world. A closed sheet whose values changed is written to its file at once, because only the open sheet is ever allowed to be unsaved; a sheet that cannot be written (a library component, which belongs to its library) is still brought into line in memory but its file is left alone. The open sheet's canvas is not in LoadedComponents but in the draw block, so its slots are pushed through the same symbol-change path the properties pane uses — symbol size, ports and geometry are recomputed rather than patched, and the change joins that sheet's undo history like any other edit.

Steps 1 and 2 are both idempotent, which is what the whole arrangement rests on: undo need only restore the primary state and run it again, and no edit has to reason about which sheets a binding might reach.

Component Support

Currently Parameterizable Components

Width-Configurable Components

Multiplexers and demultiplexers are not parameterisable: they have no case in ComponentSlots.trySetSlotValue, so slotApplies refuses a slot on one and the properties pane offers none. GateN and MergeN are excluded deliberately, their integer being an input count. trySetSlotValue is the list that decides this, and is worth reading rather than trusting this one.

Custom Components

I/O Components

Constants

Adding Parameter Support to New Components

  1. Update the component type in CommonTypes.fs if the field is not there yet
  2. Add a case to ComponentSlots.trySetSlotValue, which is the only place that knows how a CompSlotName maps onto a field of a ComponentType. The properties pane, elaboration and the sheet-description DSL all go through it, and slotApplies — which decides whether a slot may be written at all — is derived from the same match, so there is nothing else to keep in step
  3. Add a case to ComponentSlots.constraintsFor giving the bounds a value must satisfy, named so that a message says which field it is about. A component with two of anything — a memory's two widths — needs one slot and one bound per field, since a message that says only "Width" cannot say which
  4. Add a case to ParameterView.updateComponentSlots saying which sheet message writes the field on the canvas. If that message does not run width inference itself, as UpdateMemory does not, dispatch DoBusWidthInference after it or the attached wires keep their old widths
  5. Add UI support in the component's properties, via ParameterView.paramInputField, and an entry in AppMessages.Fields.tips under the label the box displays: every other field in the pane explains itself, and one that does not is the odd one out
  6. ParameterView.slotFieldName names the slot in messages that refuse to delete a property; the compiler will require it

Usage Examples

Example 1: Define Sheet Parameter

// User adds parameter "WIDTH" with value 8
1. Open sheet properties panel
2. Click "Add Parameter"
3. Enter name: "WIDTH"          // a letter, then letters and digits
4. Enter description: "width of the data bus in bits"   // compulsory
5. Enter value: 8               // used when this sheet is simulated on its own
6. Parameter available for use in expressions

The description is compulsory because it is what an instance of this sheet shows the user when it asks them for a value — the one place the parameter has to be understood.

Example 2: Use Parameter in Component

// Configure Register with parameterized width
1. Add Register component to sheet
2. Select Register
3. In properties, enter bus width: "WIDTH"
4. System evaluates to 8
5. Change WIDTH parameter 

Example 3: Override in Custom Component

// Custom component with parameter override
1. Create custom component from sheet with WIDTH parameter
2. Place instance in parent sheet
3. Select instance
4. Edit parameter binding: WIDTH = "parentWidth * 2"
5. Instance uses calculated value

Example 4: Complex Expression

// Address decoder with calculated ranges
1. Define parameters: baseAddr = 4096, blockSize = 256
   (decimal only 
2. Create comparator with expression: "baseAddr + (blockSize * 4)"
3. System evaluates to 5120

Error Handling

The system provides comprehensive error handling at multiple levels:

The user-facing word for a sheet parameter is property, and every message below says so; only the code calls them parameters. Quotes here are the messages themselves — ParameterTypes is where they are written.

Parse Errors

Evaluation Errors

An unresolved name is nearly always one of two mistakes, and they need different advice, so the message names the alternatives rather than only the failure: - Undefined property, where the sheet declares some: "Property 'WITDH' is not defined. Properties of this sheet: DEPTH, WIDTH" - Undefined property, where the sheet declares none: "This value must be numeric: to use a property this must first be added to the sheet" - Self-reference: "Property 'W' is defined in terms of itself: W which uses W" - Division or remainder by zero: "Division by zero: 4 cannot be divided by 0"

Constraint Violations

Only the first failure is shown. One bad value usually breaks the same bound on several components, and a column of repeated sentences reads as noise.

Implementation Details

Optics/Lenses Pattern

The system uses functional lenses for immutable state updates:

let paramSlotsOfModel_ = 
    lcParameterInfoOfModel_ >?> paramSlots_

model |> set paramSlotsOfModel_ newSlots

Message Dispatch

State changes flow through Elmish messages:

Sheet (SheetT.Wire (BusWireT.Symbol (SymbolT.ChangeWidth ...)))

Functional Patterns

Testing & Debugging

Tests

npm run test reaches the whole parameter system under plain .NET — no Electron, no browser. The groups, runnable individually with --filter Issie.<name>:

group

covers

InstanceSignatures

what an instance's ports are, and keeping instances in step with the sheet inside them

ParameterScenarios

parameterised sheets instantiated at different bindings and simulated end to end

ParameterUI

the two gates deciding how much of the feature the UI shows, and binding totality

Properties

the expression language against a reference evaluator and through render/parse

Debug Helpers

Nothing prints unconditionally. Common/Log.fs is the only route to the console: Log.warn and Log.error always show, and category logging shows only when its category is switched on — from Development > Log, from --log=sim at launch, or from window.issieLog.on "sim" in a console.

Log.dbg Log.Sim $"Parameter evaluation: %A{expr} -> %A{value}"

A new printf outside a short allowlist fails Tests/Issie.Tests/SourceHygiene.fs.

Common Issues

  1. Forward references: Resolved by merging all graphs before resolving parameters
  2. Circular dependencies: Detected and reported
  3. Constraint conflicts: Validated before application
  4. Type mismatches: Caught by F# type system

API Reference

Key Functions

Expression Parsing

parseExpression: string -> Result<ParamExpression, ParamError>

Expression Evaluation

evaluateParamExpression: ParamBindings -> ParamExpression -> Result<ParamInt, ParamError>

Expression Rendering

renderParamExpression: ParamExpression -> int -> string

Name validity (the parser's rule and the dialog's)

isValidParamName: string -> bool

Slot identity (the IO label is not part of it)

sameSlot:     ParamSlot -> ParamSlot -> bool
tryFindSlot:  ParamSlot -> ComponentSlotExpr -> ConstrainedExpr option
addSlot:      ParamSlot -> ConstrainedExpr -> ComponentSlotExpr -> ComponentSlotExpr
removeSlot:   ParamSlot -> ComponentSlotExpr -> ComponentSlotExpr

An instance's ports (CanvasExtractor)

signatureOfInstance:
    LoadedComponent list -> ParamBindings -> string -> ParamBindings -> Signature option
signatureOfInstanceWithCertainty:
    LoadedComponent list -> ParamBindings -> string -> ParamBindings -> (Signature * bool) option

The arguments are: the project's sheets, the bindings of the sheet the instance sits on, the child sheet's name, and the instance's bindings.

Sizing every instance in a project at its own bindings (CanvasExtractor)

withPortWidths:    Map<string,int> -> CustomComponentType -> CustomComponentType
syncInstancePorts: LoadedComponent list -> LoadedComponent list

Widths only, matched by port label: the order of an instance's ports and which ports it has are left alone. Run after ParameterAnalysis.propagateParameterValues, which cannot reach them.

Slot resolution (ComponentSlots)

trySetSlotValue: CompSlotName -> ParamInt -> ComponentType -> ComponentType option
setSlotValue:    CompSlotName -> ParamInt -> ComponentType -> ComponentType
slotApplies:     CompSlotName -> ComponentType -> bool
constraintsFor:  CompSlotName -> ComponentType -> ParamConstraint list

Constraint Checking

evaluateConstraints:    ParamBindings -> ConstrainedExpr list -> Result<Unit, ParamConstraint list>
instanceBindingProblem: LoadedComponent list -> string -> ParamBindings -> ParamName -> ParamInt
                            -> Result<unit, ParamError>

Resolution Mechanics Deep-Dive

Developer Notes (Files & Responsibilities)

Known Limitations

Smaller rough edges are in dev/openIssues.md.

Best Practices

  1. Use descriptive parameter names: dataWidth instead of W. Names are letters and digits only — there is no underscore, so DATA_WIDTH is not a name Issie will accept — and may not be clog2, min or max, which are functions
  2. Write the description for the person choosing the value, not for yourself: it is what an instance of the sheet shows where the value is entered
  3. Define constraints early: Prevent invalid values at input time
  4. Test edge cases: Min/max values, zero, negative numbers
  5. Keep expressions simple: Complex logic in simulation, not parameters
  6. Use consistent naming: Across sheets and components. A same-named parameter on an ancestor sheet is what the bind-to-top button looks for
  7. Validate before simulation: Check all parameters resolve correctly

Troubleshooting

Parameter Not Found

Expression Parse Error

Constraint Violation

Simulation Failure

type ParamBinFunc = | PMin | PMax
 Named by an enumeration, so that adding a two-argument function is a case HERE
type ParamExpression = | PInt of obj | PParameter of obj | PAdd of ParamExpression * ParamExpression | PSubtract of ParamExpression * ParamExpression | PMultiply of ParamExpression * ParamExpression | PDivide of ParamExpression * ParamExpression | PRemainder of ParamExpression * ParamExpression | PShiftLeft of ParamExpression * ParamExpression | PShiftRight of ParamExpression * ParamExpression | PCLog2 of ParamExpression ...
type ParamConstraint = | MinVal of ParamExpression * obj | MaxVal of ParamExpression * obj
type CompSlotName = | Buswidth | IO of Label: string | CustomCompParam of ParamName: string | SplitNWidth of Index: int | SplitNLSB of Index: int | InputDefault | MemoryAddressWidth | MemoryWordWidth
Multiple items
val string: value: 'T -> string

--------------------
type string = System.String
Multiple items
val int: value: 'T -> int (requires member op_Explicit)

--------------------
type int = int32

--------------------
type int<'Measure> = int
type ParamBindings = obj
 What an INSTANCE binds: no description, because the description belongs to the declaration
Multiple items
module Map from Microsoft.FSharp.Collections

--------------------
type Map<'Key,'Value (requires comparison)> = interface IReadOnlyDictionary<'Key,'Value> interface IReadOnlyCollection<KeyValuePair<'Key,'Value>> interface IEnumerable interface IStructuralEquatable interface IComparable interface IEnumerable<KeyValuePair<'Key,'Value>> interface ICollection<KeyValuePair<'Key,'Value>> interface IDictionary<'Key,'Value> new: elements: ('Key * 'Value) seq -> Map<'Key,'Value> member Add: key: 'Key * value: 'Value -> Map<'Key,'Value> ...

--------------------
new: elements: ('Key * 'Value) seq -> Map<'Key,'Value>
type ParamDefinition = { Expression: ParamExpression Description: string }
 The DECLARATION of one parameter on a sheet
type ParamDefinitions = obj
type ParameterDefs = { DefaultBindings: ParamDefinitions ParamSlots: obj }
val id: x: 'T -> 'T
val max: e1: 'T -> e2: 'T -> 'T (requires comparison)
val min: e1: 'T -> e2: 'T -> 'T (requires comparison)
union case ParamConstraint.MinVal: ParamExpression * obj -> ParamConstraint
union case ParamConstraint.MaxVal: ParamExpression * obj -> ParamConstraint
namespace System
Multiple items
val decimal: value: 'T -> decimal (requires member op_Explicit)

--------------------
type decimal = System.Decimal

--------------------
type decimal<'Measure> = decimal
val set: elements: 'T seq -> Set<'T> (requires comparison)
Multiple items
module Result from Microsoft.FSharp.Core

--------------------
[<Struct>] type Result<'T,'TError> = | Ok of ResultValue: 'T | Error of ErrorValue: 'TError
type bool = System.Boolean
type 'T option = Option<'T>
type 'T list = List<'T>
type unit = Unit

Type something to start searching.