+}
+
+const canvasRendererInstances: CanvasRendererStub[] = []
+
+vi.mock('../visualization/CanvasRenderer', () => ({
+ CanvasRenderer: vi.fn().mockImplementation(() => {
+ const instance: CanvasRendererStub = {
+ render: vi.fn(),
+ reset: vi.fn(),
+ updateLayout: vi.fn(),
+ setColorByInputDimension: vi.fn(),
+ handleMouseDown: vi.fn(),
+ handleMouseUp: vi.fn(),
+ handleMouseMove: vi.fn(),
+ handleWheel: vi.fn(),
+ screenToGrid: vi.fn().mockReturnValue({ row: 0, col: 0 }),
+ getCellInfo: vi.fn().mockReturnValue(null),
+ getWarpColor: vi.fn().mockReturnValue('#000000'),
+ }
+ canvasRendererInstances.push(instance)
+ return instance
+ }),
+}))
+
+const subscribeMock = vi.hoisted(() => vi.fn().mockReturnValue(() => {}))
+
+vi.mock('../integration/LayoutProjectionBus', () => ({
+ layoutProjectionBus: {
+ subscribe: subscribeMock,
+ },
+ LINEAR_LAYOUT_TAB_ID: 'linear-layout',
+}))
+
+const buildLinearLayoutDom = (): void => {
+ document.body.innerHTML = `
+
+ `
+
+ const visualization = document.querySelector('.visualization') as HTMLElement
+ Object.defineProperty(visualization, 'getBoundingClientRect', {
+ value: () => ({
+ width: 800,
+ height: 600,
+ left: 0,
+ top: 0,
+ right: 800,
+ bottom: 600,
+ x: 0,
+ y: 0,
+ toJSON: () => ({}),
+ }),
+ })
+}
+
+const instantiateTab = (): LinearLayoutTab => {
+ buildLinearLayoutDom()
+ matrixEditorInstances.length = 0
+ canvasRendererInstances.length = 0
+ return new LinearLayoutTab('linear-layout')
+}
+
+describe('LinearLayoutTab', () => {
+ beforeEach(() => {
+ document.body.innerHTML = ''
+ vi.clearAllMocks()
+ })
+
+ afterEach(() => {
+ document.body.innerHTML = ''
+ })
+
+ it('applies color metadata, truncates extra outputs, and ignores size-1 inputs', () => {
+ const tab = instantiateTab()
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const renderer = canvasRendererInstances[0]
+ expect(renderer).toBeDefined()
+
+ const baseLayout = LinearLayout.identity1D(8, 'register', 'dim0').multiply(
+ LinearLayout.identity1D(8, 'lane', 'dim1')
+ )
+ const snapshot = baseLayout.toMatrixSnapshot({
+ colorInputDimension: 'register',
+ sourceLayoutType: 'block-layout',
+ })
+ snapshot.inputDimensions = [
+ ...snapshot.inputDimensions,
+ { name: 'warp', size: 1 },
+ ]
+ snapshot.outputDimensions = [
+ ...snapshot.outputDimensions,
+ { name: 'dim2', size: 4 },
+ ]
+
+ tab.importLayoutSnapshot(snapshot)
+
+ expect(warnSpy).toHaveBeenCalledWith('Truncated imported layout to the first two output dimensions.')
+ warnSpy.mockRestore()
+
+ const dimensionState = (tab as unknown as { dimensionState: { input: Array<{ id: string; name: string }> } }).dimensionState
+ const colorSelect = document.getElementById('linear-color-dimension') as HTMLSelectElement
+ const selectedId = colorSelect.value
+ const selectedDimension = dimensionState.input.find((dim) => dim.id === selectedId)
+ expect(selectedDimension?.name).toBe('register')
+ expect(renderer?.setColorByInputDimension).toHaveBeenCalledWith('register')
+ const inputNames = dimensionState.input.map((dim) => dim.name)
+ expect(inputNames).not.toContain('warp')
+
+ const editor = matrixEditorInstances[0]
+ expect(editor.latestDimensions.output).toHaveLength(2)
+ expect(editor.latestDimensions.output.map((dim) => dim.name)).toEqual(['dim0', 'dim1'])
+ })
+
+ it('reports an error message when importing an empty snapshot', () => {
+ const tab = instantiateTab()
+ const statusElement = (tab as unknown as { layoutStatus: HTMLElement }).layoutStatus
+ tab.importLayoutSnapshot({
+ inputDimensions: [],
+ outputDimensions: [],
+ matrix: [],
+ })
+ expect(statusElement?.textContent).toContain('Cannot import layout')
+ })
+})
diff --git a/src/tabs/LinearLayoutTab.ts b/src/tabs/LinearLayoutTab.ts
index 88514f3..1815577 100644
--- a/src/tabs/LinearLayoutTab.ts
+++ b/src/tabs/LinearLayoutTab.ts
@@ -1,4 +1,9 @@
-import { LinearLayout } from '../core/LinearLayout'
+import {
+ LinearLayout,
+ type LayoutMatrixSnapshot,
+ type LayoutSnapshotMetadata,
+} from '../core/LinearLayout'
+import { filterSnapshotDimensions } from '../core/filterSnapshotDimensions'
import type { BlockLayoutParams } from '../validation/InputValidator'
import {
LinearLayoutValidator,
@@ -8,6 +13,7 @@ import {
import { CanvasRenderer, type CellInfo } from '../visualization/CanvasRenderer'
import { LinearLayoutMatrixEditor, type MatrixEditorDimensions } from '../ui/LinearLayoutMatrixEditor'
import { renderSharedControls } from '../ui/renderSharedControls'
+import { layoutProjectionBus, LINEAR_LAYOUT_TAB_ID } from '../integration/LayoutProjectionBus'
import { CanvasTab, type CanvasTabElements } from './CanvasTab'
type DimensionType = 'input' | 'output'
@@ -23,6 +29,10 @@ interface OutputBitGroup {
bitWidth: number
}
+interface RenderDimensionOptions {
+ suppressMatrixSync?: boolean
+}
+
const BASIS_HEADING = 'Basis Calculation (all weighted bases are added via ⊕)'
/**
@@ -44,6 +54,7 @@ export class LinearLayoutTab extends CanvasTab {
private selectedColorDimensionId: string | null
private hasDimensionValidationErrors: boolean
private showingValidationStatus: boolean
+ private suppressMatrixChangeOnce: boolean
constructor(tabId: string) {
const tabContent = document.getElementById(tabId)
@@ -92,7 +103,7 @@ export class LinearLayoutTab extends CanvasTab {
this.dimensionState = {
input: [
{ id: this.createDimensionId(), name: 'reg', size: 8 },
- { id: this.createDimensionId(), name: 'thread', size: 32 },
+ { id: this.createDimensionId(), name: 'lane', size: 32 },
],
output: [
{ id: this.createDimensionId(), name: 'dim0', size: 16 },
@@ -130,6 +141,7 @@ export class LinearLayoutTab extends CanvasTab {
this.selectedColorDimensionId = null
this.hasDimensionValidationErrors = false
this.showingValidationStatus = false
+ this.suppressMatrixChangeOnce = false
this.colorDimensionSelect.addEventListener('change', () => {
this.handleColorDimensionSelectionChange()
})
@@ -165,6 +177,10 @@ export class LinearLayoutTab extends CanvasTab {
this.renderOperationsInfo(controlsContainer)
this.updateRendererFromLayout()
+
+ layoutProjectionBus.subscribe(LINEAR_LAYOUT_TAB_ID, ({ snapshot }) => {
+ this.importLayoutSnapshot(snapshot)
+ })
}
protected handleHover(event: MouseEvent): void {
@@ -201,6 +217,60 @@ export class LinearLayoutTab extends CanvasTab {
this.hideTooltip()
}
+ public importLayoutSnapshot(snapshot: LayoutMatrixSnapshot): void {
+ const { snapshot: filteredSnapshot, removedInputDimensions, removedOutputDimensions } =
+ filterSnapshotDimensions(snapshot)
+
+ if (
+ filteredSnapshot.inputDimensions.length === 0 ||
+ filteredSnapshot.outputDimensions.length === 0
+ ) {
+ const hadFilteredDimensions =
+ removedInputDimensions.length > 0 || removedOutputDimensions.length > 0
+ this.setLayoutStatus(
+ hadFilteredDimensions
+ ? 'Cannot import layout because every dimension is size 1.'
+ : 'Cannot import layout without both input and output dimensions.'
+ )
+ return
+ }
+
+ const inputs = filteredSnapshot.inputDimensions.map((dimension) => ({
+ id: this.createDimensionId(),
+ name: dimension.name.trim() || dimension.name,
+ size: dimension.size,
+ }))
+
+ const outputs = filteredSnapshot.outputDimensions.slice(0, 2).map((dimension) => ({
+ id: this.createDimensionId(),
+ name: dimension.name.trim() || dimension.name,
+ size: dimension.size,
+ }))
+
+ if (filteredSnapshot.outputDimensions.length > 2) {
+ console.warn('Truncated imported layout to the first two output dimensions.')
+ }
+
+ this.dimensionState = {
+ input: inputs,
+ output: outputs,
+ }
+ this.selectedColorDimensionId = this.getMetadataPreferredColorDimensionId(
+ inputs,
+ filteredSnapshot.metadata
+ )
+ this.hasDimensionValidationErrors = false
+ this.updateValidationStatus(false)
+
+ const suppressOptions: RenderDimensionOptions = { suppressMatrixSync: true }
+ this.renderDimensionRows('input', suppressOptions)
+ this.renderDimensionRows('output', suppressOptions)
+
+ const dimensions = this.getMatrixDimensions()
+ this.matrixEditor.replaceMatrix(filteredSnapshot.matrix, dimensions)
+ this.updateProjectionStatus(filteredSnapshot.metadata)
+ }
+
private buildFormMarkup(): string {
return `
@@ -224,7 +294,7 @@ export class LinearLayoutTab extends CanvasTab {
`
}
- private renderDimensionRows(type: DimensionType): void {
+ private renderDimensionRows(type: DimensionType, options: RenderDimensionOptions = {}): void {
const list = this.dimensionLists[type]
list.innerHTML = ''
@@ -292,6 +362,9 @@ export class LinearLayoutTab extends CanvasTab {
this.updateColorDimensionOptions()
}
this.applyDimensionValidationState()
+ if (options.suppressMatrixSync) {
+ this.suppressMatrixChangeOnce = true
+ }
this.syncEditorAndLayout()
}
@@ -316,8 +389,13 @@ export class LinearLayoutTab extends CanvasTab {
if (this.hasDimensionValidationErrors) {
return
}
- const emittedMatrixChange = this.matrixEditor.updateDimensions(this.getMatrixDimensions())
- if (!emittedMatrixChange) {
+ const emitChange = !this.suppressMatrixChangeOnce
+ this.suppressMatrixChangeOnce = false
+ const emittedMatrixChange = this.matrixEditor.updateDimensions(
+ this.getMatrixDimensions(),
+ { emitChange }
+ )
+ if (emitChange && !emittedMatrixChange) {
this.rebuildLayoutFromMatrix()
}
}
@@ -402,6 +480,24 @@ export class LinearLayoutTab extends CanvasTab {
this.layoutStatus.classList.toggle('visible', Boolean(message))
}
+ private updateProjectionStatus(metadata?: LayoutSnapshotMetadata): void {
+ if (!metadata) {
+ this.setLayoutStatus('')
+ return
+ }
+ const summaryParts: string[] = []
+ if (metadata.sourceLayoutType) {
+ summaryParts.push(`Imported from ${metadata.sourceLayoutType}`)
+ }
+ if (metadata.tensorShape?.length) {
+ summaryParts.push(`tensor ${metadata.tensorShape.join('×')}`)
+ }
+ if (metadata.description) {
+ summaryParts.push(metadata.description)
+ }
+ this.setLayoutStatus(summaryParts.join(' · '))
+ }
+
private addDimension(type: DimensionType): void {
if (type === 'output' && this.dimensionState.output.length >= 2) {
return
@@ -509,13 +605,26 @@ export class LinearLayoutTab extends CanvasTab {
}
private getDefaultColorDimensionId(dimensions: LinearDimension[]): string | undefined {
- const preferred = dimensions.find((dimension) => dimension.name.trim().toLowerCase() === 'thread')
+ const preferred = dimensions.find((dimension) => dimension.name.trim().toLowerCase() === 'lane')
if (preferred) {
return preferred.id
}
return dimensions[0]?.id
}
+ private getMetadataPreferredColorDimensionId(
+ inputs: LinearDimension[],
+ metadata?: LayoutSnapshotMetadata
+ ): string | null {
+ const preferred = metadata?.colorInputDimension?.trim()
+ if (!preferred) {
+ return null
+ }
+ const normalized = preferred.toLowerCase()
+ const match = inputs.find((dimension) => dimension.name.trim().toLowerCase() === normalized)
+ return match ? match.id : null
+ }
+
private handleColorDimensionSelectionChange(): void {
const value = this.colorDimensionSelect.value
this.selectedColorDimensionId = value || null
diff --git a/src/ui/LinearLayoutMatrixEditor.ts b/src/ui/LinearLayoutMatrixEditor.ts
index ad3283b..88f8f3c 100644
--- a/src/ui/LinearLayoutMatrixEditor.ts
+++ b/src/ui/LinearLayoutMatrixEditor.ts
@@ -20,6 +20,14 @@ interface DialogSize {
height: number
}
+interface UpdateDimensionsOptions {
+ emitChange?: boolean
+}
+
+interface ReplaceMatrixOptions {
+ emitChange?: boolean
+}
+
type ForwardableMouseEventType =
| 'mousemove'
| 'mouseleave'
@@ -273,12 +281,16 @@ export class LinearLayoutMatrixEditor {
/**
* Update the internal dimension snapshot without showing the modal.
*/
- public updateDimensions(dimensions: MatrixEditorDimensions): boolean {
+ public updateDimensions(
+ dimensions: MatrixEditorDimensions,
+ options: UpdateDimensionsOptions = {}
+ ): boolean {
+ const emitChange = options.emitChange !== false
this.currentDimensions = {
input: dimensions.input.map((dim) => ({ ...dim })),
output: dimensions.output.map((dim) => ({ ...dim })),
}
- const { shapeChanged, matrixUpdated } = this.rebuildMatrixIfNeeded()
+ const { shapeChanged, matrixUpdated } = this.rebuildMatrixIfNeeded(emitChange)
if (shapeChanged) {
this.autoFitToMatrix()
}
@@ -287,7 +299,7 @@ export class LinearLayoutMatrixEditor {
} else {
this.needsRender = true
}
- return matrixUpdated
+ return emitChange && matrixUpdated
}
/**
@@ -342,6 +354,33 @@ export class LinearLayoutMatrixEditor {
return this.matrixValues.map((row) => [...row])
}
+ public replaceMatrix(
+ values: number[][],
+ dimensions?: MatrixEditorDimensions,
+ options: ReplaceMatrixOptions = {}
+ ): void {
+ if (dimensions) {
+ this.currentDimensions = {
+ input: dimensions.input.map((dim) => ({ ...dim })),
+ output: dimensions.output.map((dim) => ({ ...dim })),
+ }
+ this.rowBits = this.buildBitDescriptors(this.currentDimensions.output)
+ this.columnBits = this.buildBitDescriptors(this.currentDimensions.input)
+ this.signature = this.computeSignature(this.currentDimensions)
+ }
+ const rowCount = this.rowBits.length
+ const colCount = this.columnBits.length
+ this.matrixValues = this.normalizeMatrix(values, rowCount, colCount)
+ if (this.isOpen) {
+ this.renderMatrix()
+ } else {
+ this.needsRender = true
+ }
+ if (options.emitChange !== false) {
+ this.notifyMatrixChange()
+ }
+ }
+
public onMatrixChange(listener: (matrix: number[][]) => void): () => void {
this.matrixListeners.add(listener)
return () => {
@@ -360,7 +399,9 @@ export class LinearLayoutMatrixEditor {
this.scheduleCellSizeUpdate()
}
- private rebuildMatrixIfNeeded(): { shapeChanged: boolean; matrixUpdated: boolean } {
+ private rebuildMatrixIfNeeded(
+ emitChange: boolean
+ ): { shapeChanged: boolean; matrixUpdated: boolean } {
const rowBits = this.buildBitDescriptors(this.currentDimensions.output)
const columnBits = this.buildBitDescriptors(this.currentDimensions.input)
const signature = this.computeSignature(this.currentDimensions)
@@ -375,7 +416,9 @@ export class LinearLayoutMatrixEditor {
)
this.signature = signature
this.seedDefaultMatrix(rowBits.length, columnBits.length)
- this.notifyMatrixChange()
+ if (emitChange) {
+ this.notifyMatrixChange()
+ }
matrixUpdated = true
}
@@ -656,6 +699,19 @@ export class LinearLayoutMatrixEditor {
return `${serialize(dimensions.input)}->${serialize(dimensions.output)}`
}
+ private normalizeMatrix(values: number[][], rows: number, cols: number): number[][] {
+ if (rows === 0 || cols === 0) {
+ return Array.from({ length: rows }, () => [])
+ }
+ return Array.from({ length: rows }, (_, rowIdx) => {
+ const sourceRow = values[rowIdx] ?? []
+ return Array.from({ length: cols }, (_, colIdx) => {
+ const cell = sourceRow[colIdx] ?? 0
+ return cell & 1
+ })
+ })
+ }
+
private seedDefaultMatrix(rows: number, cols: number): void {
const limit = Math.min(rows, cols)
for (let idx = 0; idx < limit; idx++) {
diff --git a/src/validation/LinearLayoutValidator.test.ts b/src/validation/LinearLayoutValidator.test.ts
index 5b89a70..b573850 100644
--- a/src/validation/LinearLayoutValidator.test.ts
+++ b/src/validation/LinearLayoutValidator.test.ts
@@ -63,6 +63,32 @@ describe('LinearLayoutValidator', () => {
const result = validator.validateDimension({
id: 'dim-6',
name: 'warp',
+ size: 0,
+ })
+
+ expect(result.size).toBe('Size must be at least 2')
+ })
+
+ it('rejects zero or negative sizes explicitly', () => {
+ const zeroResult = validator.validateDimension({
+ id: 'dim-7',
+ name: 'warp',
+ size: 0,
+ })
+ expect(zeroResult.size).toBe('Size must be at least 2')
+
+ const negativeResult = validator.validateDimension({
+ id: 'dim-8',
+ name: 'warp',
+ size: -4,
+ })
+ expect(negativeResult.size).toBe('Size must be at least 2')
+ })
+
+ it('rejects size-1 dimensions explicitly', () => {
+ const result = validator.validateDimension({
+ id: 'dim-10',
+ name: 'warp',
size: 1,
})
@@ -71,7 +97,7 @@ describe('LinearLayoutValidator', () => {
it('rejects sizes that are not powers of two', () => {
const result = validator.validateDimension({
- id: 'dim-7',
+ id: 'dim-9',
name: 'warp',
size: 6,
})